Skip to content

Commit 93bb2c3

Browse files
[SEP-2567] server: StreamableHTTP per-message router
streamableHttp server: handleRequest routes by MCP-Protocol-Version header (falls back to body _meta) to statelessHttpHandler; pre-2026 or absent header falls through to handleStatefulRequest (body unchanged, GHSA-345p guard stays inside). Node middleware: setStatelessHandlers forwards to wrapped web-standard transport. Server.connect() already calls transport.setStatelessHandlers?.() (C7). StreamableHTTPClientTransport.sendAndReceive gains opts?.signal (AbortSignal.any with transport-wide controller). Satisfies: 2567-R1 (HTTP), 2575-R7
1 parent ec07d53 commit 93bb2c3

3 files changed

Lines changed: 86 additions & 7 deletions

File tree

packages/client/src/client/streamableHttp.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,17 +200,24 @@ export class StreamableHTTPClientTransport implements Transport {
200200
* is left to the caller (`Client` falls back to legacy `request()` on
201201
* auth failure).
202202
*/
203-
async *sendAndReceive(request: Omit<JSONRPCRequest, 'jsonrpc' | 'id'>): AsyncGenerator<JSONRPCMessage, void, void> {
203+
async *sendAndReceive(
204+
request: Omit<JSONRPCRequest, 'jsonrpc' | 'id'>,
205+
opts?: { signal?: AbortSignal }
206+
): AsyncGenerator<JSONRPCMessage, void, void> {
204207
const headers = await this._commonHeaders();
205208
headers.set('content-type', 'application/json');
206209
headers.set('accept', 'application/json, text/event-stream');
207210
const body = JSON.stringify({ jsonrpc: '2.0', id: 0, ...request });
211+
const signal =
212+
opts?.signal && this._abortController
213+
? AbortSignal.any([opts.signal, this._abortController.signal])
214+
: (opts?.signal ?? this._abortController?.signal);
208215
const response = await (this._fetch ?? fetch)(this._url, {
209216
...this._requestInit,
210217
method: 'POST',
211218
headers,
212219
body,
213-
signal: this._abortController?.signal
220+
signal
214221
});
215222
if (!response.ok) {
216223
const text = await response.text().catch(() => '');

packages/middleware/node/src/streamableHttp.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import type { IncomingMessage, ServerResponse } from 'node:http';
1111

1212
import { getRequestListener } from '@hono/node-server';
13-
import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core';
13+
import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, StatelessHandlers, Transport } from '@modelcontextprotocol/core';
1414
import type { WebStandardStreamableHTTPServerTransportOptions } from '@modelcontextprotocol/server';
1515
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
1616

@@ -130,6 +130,14 @@ export class NodeStreamableHTTPServerTransport implements Transport {
130130
return this._webStandardTransport.onmessage;
131131
}
132132

133+
/**
134+
* Installed by `Server.connect()`. Forwards to the wrapped web-standard
135+
* transport so its `handleRequest` router can dispatch 2026-06 requests.
136+
*/
137+
setStatelessHandlers(h: StatelessHandlers): void {
138+
this._webStandardTransport.setStatelessHandlers(h);
139+
}
140+
133141
/**
134142
* Starts the transport. This is required by the {@linkcode Transport} interface but is a no-op
135143
* for the Streamable HTTP transport as connections are managed per-request.

packages/server/src/server/streamableHttp.ts

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,29 @@
77
* For Node.js Express/HTTP compatibility, use {@linkcode @modelcontextprotocol/node!NodeStreamableHTTPServerTransport | NodeStreamableHTTPServerTransport} which wraps this transport.
88
*/
99

10-
import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core';
10+
import type {
11+
AuthInfo,
12+
JSONRPCMessage,
13+
ListenContext,
14+
MessageExtraInfo,
15+
RequestId,
16+
StatelessHandlers,
17+
Transport
18+
} from '@modelcontextprotocol/core';
1119
import {
1220
DEFAULT_NEGOTIATED_PROTOCOL_VERSION,
1321
isInitializeRequest,
1422
isJSONRPCErrorResponse,
1523
isJSONRPCRequest,
1624
isJSONRPCResultResponse,
25+
isStatelessProtocolVersion,
1726
JSONRPCMessageSchema,
27+
parseClientMeta,
1828
SUPPORTED_PROTOCOL_VERSIONS
1929
} from '@modelcontextprotocol/core';
2030

31+
import { statelessHttpHandler } from './statelessHttp.js';
32+
2133
export type StreamId = string;
2234
export type EventId = string;
2335

@@ -168,6 +180,15 @@ export interface HandleRequestOptions {
168180
* Authentication info from middleware. If provided, will be passed to message handlers.
169181
*/
170182
authInfo?: AuthInfo;
183+
184+
/**
185+
* Per-URI authorization for `resourceSubscriptions` on the 2026-06
186+
* `subscriptions/listen` path. See {@linkcode ListenContext.onAuthorizeResourceSubscription}.
187+
*/
188+
onAuthorizeResourceSubscription?: ListenContext['onAuthorizeResourceSubscription'];
189+
190+
/** Maximum POST body size for the 2026-06 stateless path. Default 4 MiB. */
191+
maxBodyBytes?: number;
171192
}
172193

173194
/**
@@ -241,11 +262,22 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
241262
private _retryInterval?: number;
242263
private _supportedProtocolVersions: string[];
243264

265+
private _statelessHandlers?: StatelessHandlers;
266+
244267
sessionId?: string;
245268
onclose?: () => void;
246269
onerror?: (error: Error) => void;
247270
onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;
248271

272+
/**
273+
* Installed by `Server.connect()`. When present, {@linkcode handleRequest}
274+
* routes 2026-06 requests to {@linkcode statelessHttpHandler}; when absent,
275+
* all requests fall through to the legacy stateful path.
276+
*/
277+
setStatelessHandlers(h: StatelessHandlers): void {
278+
this._statelessHandlers = h;
279+
}
280+
249281
constructor(options: WebStandardStreamableHTTPServerTransportOptions = {}) {
250282
this.sessionIdGenerator = options.sessionIdGenerator;
251283
this._enableJsonResponse = options.enableJsonResponse ?? false;
@@ -341,16 +373,42 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
341373
}
342374

343375
/**
344-
* Handles an incoming HTTP request, whether `GET`, `POST`, or `DELETE`
345-
* Returns a `Response` object (Web Standard)
376+
* Top-level request entry. Validates DNS-rebinding headers (both protocol
377+
* eras), then routes by `MCP-Protocol-Version` header (spec: header is
378+
* mandatory for 2026-06 POSTs; absent or pre-2026 implies legacy stateful path).
346379
*/
347380
async handleRequest(req: Request, options?: HandleRequestOptions): Promise<Response> {
348-
// Validate request headers for DNS rebinding protection
349381
const validationError = this.validateRequestHeaders(req);
350382
if (validationError) {
351383
return validationError;
352384
}
353385

386+
// Route by header first (spec: header is MUST for 2026-06 POSTs). If
387+
// header is absent, fall back to the body's first request _meta. The
388+
// conformance harness (and any client that omits the header) still
389+
// routes correctly. parsedBody is set by Node/Express adapters.
390+
const headerPv = req.headers.get('mcp-protocol-version');
391+
const pv = headerPv ?? versionFromParsedBody(options?.parsedBody);
392+
if (pv && this._supportedProtocolVersions.includes(pv) && isStatelessProtocolVersion(pv) && this._statelessHandlers) {
393+
// Stateless requests have no session by definition. Authorization
394+
// MUST be enforced at the transport/framework layer (bearer token
395+
// surfaced as `options.authInfo`, threaded to dispatch/listen ctx),
396+
// not via session state. `validateSession()` is session-id
397+
// correlation, not authorization, so it does not apply here.
398+
return statelessHttpHandler(this._statelessHandlers, req, options);
399+
}
400+
// Unsupported / pre-2026 / no stateless handlers route to legacy path
401+
// (existing unsupported-version handling lives in handlePostRequest, byte-identical).
402+
return this.handleStatefulRequest(req, options);
403+
}
404+
405+
/**
406+
* Pre-2026 stateful request handling. Body moved wholesale from
407+
* `handleRequest`; behavior is byte-identical. The GHSA-345p
408+
* `_hasHandledRequest` guard correctly stays inside this path (the
409+
* stateless dispatch path is reuse-safe by construction).
410+
*/
411+
private async handleStatefulRequest(req: Request, options?: HandleRequestOptions): Promise<Response> {
354412
switch (req.method) {
355413
case 'POST': {
356414
return this.handlePostRequest(req, options);
@@ -1036,3 +1094,9 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
10361094
}
10371095
}
10381096
}
1097+
1098+
function versionFromParsedBody(body: unknown): string | undefined {
1099+
const first = Array.isArray(body) ? body.find(m => isJSONRPCRequest(m)) : body;
1100+
if (!isJSONRPCRequest(first)) return undefined;
1101+
return parseClientMeta(first.params).protocolVersion;
1102+
}

0 commit comments

Comments
 (0)