|
7 | 7 | * For Node.js Express/HTTP compatibility, use {@linkcode @modelcontextprotocol/node!NodeStreamableHTTPServerTransport | NodeStreamableHTTPServerTransport} which wraps this transport. |
8 | 8 | */ |
9 | 9 |
|
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'; |
11 | 19 | import { |
12 | 20 | DEFAULT_NEGOTIATED_PROTOCOL_VERSION, |
13 | 21 | isInitializeRequest, |
14 | 22 | isJSONRPCErrorResponse, |
15 | 23 | isJSONRPCRequest, |
16 | 24 | isJSONRPCResultResponse, |
| 25 | + isStatelessProtocolVersion, |
17 | 26 | JSONRPCMessageSchema, |
| 27 | + parseClientMeta, |
18 | 28 | SUPPORTED_PROTOCOL_VERSIONS |
19 | 29 | } from '@modelcontextprotocol/core'; |
20 | 30 |
|
| 31 | +import { statelessHttpHandler } from './statelessHttp.js'; |
| 32 | + |
21 | 33 | export type StreamId = string; |
22 | 34 | export type EventId = string; |
23 | 35 |
|
@@ -168,6 +180,15 @@ export interface HandleRequestOptions { |
168 | 180 | * Authentication info from middleware. If provided, will be passed to message handlers. |
169 | 181 | */ |
170 | 182 | 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; |
171 | 192 | } |
172 | 193 |
|
173 | 194 | /** |
@@ -241,11 +262,22 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { |
241 | 262 | private _retryInterval?: number; |
242 | 263 | private _supportedProtocolVersions: string[]; |
243 | 264 |
|
| 265 | + private _statelessHandlers?: StatelessHandlers; |
| 266 | + |
244 | 267 | sessionId?: string; |
245 | 268 | onclose?: () => void; |
246 | 269 | onerror?: (error: Error) => void; |
247 | 270 | onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; |
248 | 271 |
|
| 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 | + |
249 | 281 | constructor(options: WebStandardStreamableHTTPServerTransportOptions = {}) { |
250 | 282 | this.sessionIdGenerator = options.sessionIdGenerator; |
251 | 283 | this._enableJsonResponse = options.enableJsonResponse ?? false; |
@@ -341,16 +373,42 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { |
341 | 373 | } |
342 | 374 |
|
343 | 375 | /** |
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). |
346 | 379 | */ |
347 | 380 | async handleRequest(req: Request, options?: HandleRequestOptions): Promise<Response> { |
348 | | - // Validate request headers for DNS rebinding protection |
349 | 381 | const validationError = this.validateRequestHeaders(req); |
350 | 382 | if (validationError) { |
351 | 383 | return validationError; |
352 | 384 | } |
353 | 385 |
|
| 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> { |
354 | 412 | switch (req.method) { |
355 | 413 | case 'POST': { |
356 | 414 | return this.handlePostRequest(req, options); |
@@ -1036,3 +1094,9 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { |
1036 | 1094 | } |
1037 | 1095 | } |
1038 | 1096 | } |
| 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