From c6d670372683bc505e94efb8f7c3dcec7b3aa036 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 1 Jun 2026 22:26:06 +0000 Subject: [PATCH 1/4] feat(core): add the stateless dispatch seam and NotImplementedYetError - NotImplementedYetError (internal barrel only): marks a deliberately-open seam whose behavior lands with a later milestone; every throw site names what fills the gap in a code comment, so grepping for the class name is the inventory of remaining gaps. - Transport.setStatelessHandlers?() (@internal) plus the StatelessHandlers / StatelessDispatchContext shapes: the seam a server uses to install a stateless dispatch path on transports that support per-request routing. The context is minimal (authInfo only) and grows with its consumers. No transport implements the seam yet and nothing installs it; behavior is unchanged. --- .../core/src/errors/notImplementedYetError.ts | 19 ++++++++ packages/core/src/index.ts | 1 + packages/core/src/shared/protocol.ts | 2 +- packages/core/src/shared/transport.ts | 44 ++++++++++++++++++- .../errors/notImplementedYetError.test.ts | 18 ++++++++ 5 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/errors/notImplementedYetError.ts create mode 100644 packages/core/test/errors/notImplementedYetError.test.ts diff --git a/packages/core/src/errors/notImplementedYetError.ts b/packages/core/src/errors/notImplementedYetError.ts new file mode 100644 index 000000000..61fc72b64 --- /dev/null +++ b/packages/core/src/errors/notImplementedYetError.ts @@ -0,0 +1,19 @@ +/** + * Marks a deliberately-open seam: code that is already wired into the message + * flow but whose behavior lands with a later milestone of the 2026-07-28 spec + * implementation. Every throw site names what fills the gap in an adjacent + * code comment, so `git grep NotImplementedYet` is the inventory of remaining + * gaps; the implementation is complete when that grep comes back empty. + * + * Messages must stay wire-safe — transports may surface them in error + * responses — so they describe the missing behavior generically and never + * reference internal planning details. + * + * @internal + */ +export class NotImplementedYetError extends Error { + constructor(message: string) { + super(message); + this.name = 'NotImplementedYetError'; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0f83b8fa2..d884d58a9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,5 @@ export * from './auth/errors.js'; +export * from './errors/notImplementedYetError.js'; export * from './errors/sdkErrors.js'; export * from './shared/auth.js'; export * from './shared/authUtils.js'; diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index d3a639782..d8f3b94e3 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -65,7 +65,7 @@ export type ProtocolOptions = { * Protocol versions supported. The first stateful entry (see * {@linkcode STATEFUL_PROTOCOL_VERSIONS}) is preferred: sent by the client at initialize, * used as the server's fallback. Revisions newer than 2025-11-25 are never negotiated via - * the initialize handshake. Passed to transport during {@linkcode Protocol.connect | connect()}. + * the initialize handshake. Passed to transport during `connect()`. * * @default {@linkcode SUPPORTED_PROTOCOL_VERSIONS} */ diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts index c606e2e3b..7d2d57d23 100644 --- a/packages/core/src/shared/transport.ts +++ b/packages/core/src/shared/transport.ts @@ -1,4 +1,12 @@ -import type { JSONRPCMessage, MessageExtraInfo, RequestId } from '../types/index.js'; +import type { + AuthInfo, + JSONRPCErrorResponse, + JSONRPCMessage, + JSONRPCRequest, + JSONRPCResponse, + MessageExtraInfo, + RequestId +} from '../types/index.js'; export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; @@ -68,6 +76,30 @@ export type TransportSendOptions = { */ onresumptiontoken?: ((token: string) => void) | undefined; }; +/** + * Per-request context a transport supplies when dispatching a stateless + * (draft-protocol-version) request. Everything in it is request-scoped — + * nothing is sourced from connection or session state. + * + * @internal + */ +export interface StatelessDispatchContext { + /** Validated authorization info from the transport layer (HTTP only). */ + authInfo?: AuthInfo; +} + +/** + * The handler shape a server installs on a transport to serve stateless + * (draft-protocol-version) requests: `dispatch` is request→response, always + * short-lived. Installed on the transport via + * {@linkcode Transport.setStatelessHandlers}. + * + * @internal + */ +export interface StatelessHandlers { + dispatch(request: JSONRPCRequest, ctx: StatelessDispatchContext): Promise; +} + /** * Describes the minimal contract for an MCP transport that a client or server can communicate over. */ @@ -131,4 +163,14 @@ export interface Transport { * This allows the server to pass its supported versions to the transport. */ setSupportedProtocolVersions?: ((versions: string[]) => void) | undefined; + + /** + * Server-side. Installs the stateless dispatch handlers on this transport. + * Called by `Server.connect()` before the transport is started. Transports + * that implement this route stateless (draft-protocol-version) requests via + * `StatelessHandlers` instead of the `onmessage` path. + * + * @internal + */ + setStatelessHandlers?(handlers: StatelessHandlers): void; } diff --git a/packages/core/test/errors/notImplementedYetError.test.ts b/packages/core/test/errors/notImplementedYetError.test.ts new file mode 100644 index 000000000..39f8a1c0a --- /dev/null +++ b/packages/core/test/errors/notImplementedYetError.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { NotImplementedYetError } from '../../src/errors/notImplementedYetError.js'; + +describe('NotImplementedYetError', () => { + it('is an Error with the class name and the given message', () => { + const error = new NotImplementedYetError('stateless request dispatch is not implemented yet'); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(NotImplementedYetError); + expect(error.name).toBe('NotImplementedYetError'); + expect(error.message).toBe('stateless request dispatch is not implemented yet'); + }); + + it('is distinguishable from plain errors via instanceof', () => { + expect(new Error('x')).not.toBeInstanceOf(NotImplementedYetError); + }); +}); From 76a3d96ad9d8c0b4931ae19dfdca18fa8853028f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 1 Jun 2026 22:40:25 +0000 Subject: [PATCH 2/4] feat(server): route stateless-revision HTTP requests to the stateless seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server.connect() installs the stateless dispatch handlers on transports that support the seam, before super.connect() starts the transport so the first message cannot arrive before the router is wired. The dispatch implementation itself is a deliberately-open seam: it throws NotImplementedYetError with a wire-safe message until the envelope acceptance work lands. WebStandardStreamableHTTPServerTransport.handleRequest() now routes, in order: (1) any request carrying Mcp-Session-Id takes the session path, unconditionally — sessions are version-locked, a version claim never bypasses session validation; (2) otherwise the claimed version is the MCP-Protocol-Version header, falling back to the pre-parsed body's first request _meta; (3) a claimed draft version that the transport lists as supported routes to the stateless path when handlers are installed; (4) everything else takes the stateful path — the previous POST/GET/DELETE switch moved unchanged into handleStatefulRequest(), so a draft version the server does not list still gets the existing unsupported-version 400. The stateless path is minimal: it parses a single JSON-RPC request (no batches, no SSE, no session interaction) and maps NotImplementedYetError to HTTP 501 with a JSON-RPC -32603 error, echoing the request id when parseable. Unit tests cover all four routing rules (including session-header-beats- version and the seam-absent fallthrough), the 501 mapping, the body-_meta fallback, and that Server.connect() tolerates transports without the seam. --- packages/server/src/server/server.ts | 36 +++- packages/server/src/server/streamableHttp.ts | 153 ++++++++++++++- packages/server/test/server/server.test.ts | 38 +++- .../server/test/server/streamableHttp.test.ts | 175 ++++++++++++++++++ 4 files changed, 398 insertions(+), 4 deletions(-) diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index c12e10929..566ad9bf8 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -12,7 +12,9 @@ import type { Implementation, InitializeRequest, InitializeResult, + JSONRPCErrorResponse, JSONRPCRequest, + JSONRPCResponse, JsonSchemaType, jsonSchemaValidator, ListRootsRequest, @@ -28,9 +30,11 @@ import type { Result, ServerCapabilities, ServerContext, + StatelessDispatchContext, TaskManagerOptions, ToolResultContent, - ToolUseContent + ToolUseContent, + Transport } from '@modelcontextprotocol/core'; import { assertClientRequestTaskCapability, @@ -48,6 +52,7 @@ import { ListRootsResultSchema, LoggingLevelSchema, mergeCapabilities, + NotImplementedYetError, parseSchema, Protocol, ProtocolError, @@ -153,6 +158,35 @@ export class Server extends Protocol { }); } + /** + * Attaches to the given transport, starts it, and starts listening for messages. + * + * Installs the stateless dispatch handlers on transports that support + * per-request routing (the seam is optional on the {@linkcode Transport} + * contract) before `super.connect()` starts the transport, so the first + * message cannot arrive before the router is wired. + */ + override async connect(transport: Transport): Promise { + transport.setStatelessHandlers?.({ + dispatch: (request, ctx) => this._dispatchStateless(request, ctx) + }); + await super.connect(transport); + } + + /** + * Serves one stateless (draft-protocol-version) request routed here by the + * transport, outside the `onmessage` / session flow. + */ + private async _dispatchStateless( + _request: JSONRPCRequest, + _ctx: StatelessDispatchContext + ): Promise { + // TODO(M5, envelope acceptance): validate the per-request envelope and + // dispatch to the registered request handlers. Until then this seam is + // deliberately open and answers with a wire-safe NotImplementedYetError. + throw new NotImplementedYetError('stateless request dispatch is not implemented yet'); + } + protected override buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ServerContext { // Only create http when there's actual HTTP transport info or auth info const hasHttpInfo = ctx.http || transportInfo?.request || transportInfo?.closeSSEStream || transportInfo?.closeStandaloneSSEStream; diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index fd3563a07..466cf69c4 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -7,20 +7,55 @@ * For Node.js Express/HTTP compatibility, use {@linkcode @modelcontextprotocol/node!NodeStreamableHTTPServerTransport | NodeStreamableHTTPServerTransport} which wraps this transport. */ -import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core'; +import type { + AuthInfo, + JSONRPCMessage, + JSONRPCRequest, + MessageExtraInfo, + RequestId, + StatelessHandlers, + Transport +} from '@modelcontextprotocol/core'; import { + INTERNAL_ERROR, DEFAULT_NEGOTIATED_PROTOCOL_VERSION, + isStatefulProtocolVersion, isInitializeRequest, isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema, + JSONRPCRequestSchema, + NotImplementedYetError, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; export type StreamId = string; export type EventId = string; +/** + * The `_meta` key carrying the protocol version a request claims + * (`io.modelcontextprotocol/protocolVersion`). Used as a routing fallback when + * the `MCP-Protocol-Version` header is absent. + */ +const PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion'; + +/** + * Extracts the protocol version claimed by a pre-parsed POST body's first + * request via `params._meta` (see {@linkcode PROTOCOL_VERSION_META_KEY}). + * Only pre-parsed bodies are sniffed (`options.parsedBody`, set by + * body-parsing middleware) — the raw body stream stays untouched for the + * downstream handler. + */ +function versionFromParsedBody(body: unknown): string | undefined { + const first = Array.isArray(body) ? body.find(message => isJSONRPCRequest(message)) : body; + if (!isJSONRPCRequest(first)) { + return undefined; + } + const version = first.params?._meta?.[PROTOCOL_VERSION_META_KEY]; + return typeof version === 'string' ? version : undefined; +} + /** * Interface for resumability support via event storage */ @@ -340,17 +375,69 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { return undefined; } + /** + * Hook for the stateless (draft-protocol-version) request path. Set + * internally by `Server.connect()` so this transport can route requests + * claiming a draft protocol version to the server's stateless dispatch + * instead of the `onmessage` path. Optional on the {@linkcode Transport} + * contract; only this concrete class reads it. + * @internal + */ + private _statelessHandlers?: StatelessHandlers; + + /** @internal */ + setStatelessHandlers(handlers: StatelessHandlers): void { + this._statelessHandlers = handlers; + } + /** * Handles an incoming HTTP request, whether `GET`, `POST`, or `DELETE` * Returns a `Response` object (Web Standard) + * + * Routes between the stateful path (today's sessions/initialize flow) and + * the stateless dispatch path (draft protocol revisions), in this order: + * + * 1. A request carrying an `Mcp-Session-Id` header always takes the + * stateful path, regardless of any claimed version — sessions are + * version-locked at initialize, so a version claim never bypasses + * session validation. + * 2. Otherwise the claimed version is resolved: the + * `MCP-Protocol-Version` header first, falling back to the pre-parsed + * body's first request `_meta`. + * 3. A claimed draft version that this transport lists as supported is + * routed to the stateless dispatch path when stateless handlers are + * installed. + * 4. Everything else takes the stateful path unchanged — including a + * draft version the server does not list, which still gets the + * existing unsupported-version 400 from header validation there. */ async handleRequest(req: Request, options?: HandleRequestOptions): Promise { - // Validate request headers for DNS rebinding protection + // Validate request headers for DNS rebinding protection (applies to both paths) const validationError = this.validateRequestHeaders(req); if (validationError) { return validationError; } + if (req.headers.get('mcp-session-id') === null) { + const claimedVersion = req.headers.get('mcp-protocol-version') ?? versionFromParsedBody(options?.parsedBody); + if ( + claimedVersion !== undefined && + !isStatefulProtocolVersion(claimedVersion) && + this._supportedProtocolVersions.includes(claimedVersion) && + this._statelessHandlers + ) { + return this.handleStatelessRequest(req, this._statelessHandlers, options); + } + } + + return this.handleStatefulRequest(req, options); + } + + /** + * Today's request handling (`initialize`, sessions, `GET`/`DELETE`): the + * body `handleRequest` had before stateless routing was added, unchanged. + */ + private async handleStatefulRequest(req: Request, options?: HandleRequestOptions): Promise { switch (req.method) { case 'POST': { return this.handlePostRequest(req, options); @@ -367,6 +454,68 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } } + /** + * Handles a request routed to the stateless dispatch path (draft protocol + * revisions). Parses a single JSON-RPC request — the stateless path never + * accepts batches — and forwards it to the installed dispatch handler. + * Never touches session or stream state; safe to call concurrently on a + * shared transport instance. + */ + private async handleStatelessRequest(req: Request, handlers: StatelessHandlers, options?: HandleRequestOptions): Promise { + // TODO(M6, discover over HTTP): GET semantics for the stateless path. + if (req.method !== 'POST') { + this.onerror?.(new Error('Method not allowed.')); + return Response.json( + { jsonrpc: '2.0', error: { code: -32_000, message: 'Method not allowed.' }, id: null }, + { status: 405, headers: { Allow: 'POST', 'Content-Type': 'application/json' } } + ); + } + + let rawMessage: unknown; + if (options?.parsedBody === undefined) { + try { + rawMessage = await req.json(); + } catch (error) { + this.onerror?.(error as Error); + return this.createJsonErrorResponse(400, -32_700, 'Parse error: Invalid JSON'); + } + } else { + rawMessage = options.parsedBody; + } + + if (Array.isArray(rawMessage)) { + this.onerror?.(new Error('Invalid Request: Batching is not supported on the stateless path')); + return this.createJsonErrorResponse(400, -32_600, 'Invalid Request: Batching is not supported on the stateless path'); + } + + let request: JSONRPCRequest; + try { + request = JSONRPCRequestSchema.parse(rawMessage); + } catch (error) { + this.onerror?.(error as Error); + return this.createJsonErrorResponse(400, -32_700, 'Parse error: Invalid JSON-RPC message'); + } + + try { + const response = await handlers.dispatch(request, { authInfo: options?.authInfo }); + return Response.json(response, { status: 200, headers: { 'Content-Type': 'application/json' } }); + } catch (error) { + if (error instanceof NotImplementedYetError) { + // Open seam (see Server._dispatchStateless): 501 with the gap error, id echoed. + this.onerror?.(error); + return Response.json( + { jsonrpc: '2.0', error: { code: INTERNAL_ERROR, message: error.message }, id: request.id }, + { status: 501, headers: { 'Content-Type': 'application/json' } } + ); + } + this.onerror?.(error as Error); + return Response.json( + { jsonrpc: '2.0', error: { code: INTERNAL_ERROR, message: 'Internal error' }, id: request.id }, + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } + } + /** * Writes a priming event to establish resumption capability. * Only sends if `eventStore` is configured (opt-in for resumability) and diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index d4408f888..09a60f7b8 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -1,4 +1,4 @@ -import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'; +import type { JSONRPCMessage, JSONRPCRequest, StatelessHandlers, Transport } from '@modelcontextprotocol/core'; import type { ClientCapabilities, Implementation, ServerContext } from '@modelcontextprotocol/core'; import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION, @@ -242,4 +242,40 @@ describe('Server', () => { await server.close(); }); }); + + describe('connect() installs the stateless dispatch seam', () => { + /** Minimal transport double offering the `setStatelessHandlers` seam. */ + function transportDouble(): { + transport: Transport; + calls: string[]; + handlers: () => StatelessHandlers | undefined; + } { + const calls: string[] = []; + let installed: StatelessHandlers | undefined; + const transport: Transport = { + start: async () => { + calls.push('start'); + }, + send: async () => {}, + close: async () => {}, + setStatelessHandlers: handlers => { + calls.push('setStatelessHandlers'); + installed = handlers; + } + }; + return { transport, calls, handlers: () => installed }; + } + + it('installs the dispatch handler before starting the transport', async () => { + const { transport, calls, handlers } = transportDouble(); + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); + + await server.connect(transport); + + expect(calls).toEqual(['setStatelessHandlers', 'start']); + expect(handlers()).toBeDefined(); + + await server.close(); + }); + }); }); diff --git a/packages/server/test/server/streamableHttp.test.ts b/packages/server/test/server/streamableHttp.test.ts index 7a23dd56b..bc2cb2b46 100644 --- a/packages/server/test/server/streamableHttp.test.ts +++ b/packages/server/test/server/streamableHttp.test.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; import type { CallToolResult, JSONRPCErrorResponse, JSONRPCMessage } from '@modelcontextprotocol/core'; +import { DRAFT_PROTOCOL_VERSION_2026, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; import * as z from 'zod/v4'; import { McpServer } from '../../src/server/mcp.js'; @@ -957,6 +958,180 @@ describe('Zod v4', () => { }); }); + describe('HTTPServerTransport - Stateless routing (draft protocol versions)', () => { + const draftHeaders = { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION_2026 + }; + const toolsListDraft = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'route-1' + } as JSONRPCMessage; + + /** A transport connected to a server that lists the draft protocol version as supported. */ + async function connectDraftServer(transport: WebStandardStreamableHTTPServerTransport): Promise { + const mcpServer = new McpServer( + { name: 'test-server', version: '1.0.0' }, + { + capabilities: {}, + supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION_2026] + } + ); + mcpServer.registerTool('noop', { inputSchema: z.object({}) }, async (): Promise => ({ content: [] })); + await mcpServer.connect(transport); + return mcpServer; + } + + it('non-POST methods on the stateless path get 405 with Allow: POST', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await connectDraftServer(transport); + + for (const method of ['GET', 'DELETE'] as const) { + const response = await transport.handleRequest(new Request('http://localhost/mcp', { method, headers: draftHeaders })); + expect(response.status).toBe(405); + expect(response.headers.get('allow')).toBe('POST'); + } + + await transport.close(); + }); + + it('falls back to the parsed body _meta version claim when the header is absent', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await connectDraftServer(transport); + + const parsedBody = { + jsonrpc: '2.0', + method: 'tools/list', + params: { _meta: { 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION_2026 } }, + id: 'route-2' + }; + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify(parsedBody) + }), + { parsedBody } + ); + + expect(response.status).toBe(501); + expect(await response.json()).toMatchObject({ id: 'route-2', error: { code: -32_603 } }); + + await transport.close(); + }); + + it('takes the stateful path when no stateless handlers are installed, even with the draft version listed', async () => { + // Transport double for the seam-absent case: the draft version is listed via the + // constructor option but the transport is never connected, so no handlers exist. + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION_2026] + }); + + // A notification-only POST is answered 202 by the stateful path (the + // stateless path would reject it: it only dispatches single requests). + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: draftHeaders, + body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + }) + ); + + expect(response.status).toBe(202); + + await transport.close(); + }); + + it('rejects batches on the stateless path', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await connectDraftServer(transport); + + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: draftHeaders, + body: JSON.stringify([toolsListDraft, { ...toolsListDraft, id: 'route-3' }]) + }) + ); + + expect(response.status).toBe(400); + expectErrorResponse(await response.json(), -32_600, /Batching is not supported/); + + await transport.close(); + }); + + it('rejects non-request bodies on the stateless path with a parse error', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await connectDraftServer(transport); + + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: draftHeaders, + body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + }) + ); + + expect(response.status).toBe(400); + expectErrorResponse(await response.json(), -32_700, /Invalid JSON-RPC message/); + + await transport.close(); + }); + + it('a non-NotImplementedYetError dispatch failure answers 500 with a generic message (no leak)', async () => { + // Fault-injecting handlers double: the real seam only ever throws + // NotImplementedYetError, so the generic branch needs direct injection. + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION_2026] + }); + transport.setStatelessHandlers({ + dispatch: () => { + throw new Error('secret internal detail'); + } + }); + const errors: Error[] = []; + transport.onerror = error => errors.push(error); + + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { method: 'POST', headers: draftHeaders, body: JSON.stringify(toolsListDraft) }) + ); + + // The wire gets a generic message (no internal details leak); onerror gets the real one. + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + jsonrpc: '2.0', + id: 'route-1', + error: { code: -32_603, message: 'Internal error' } + }); + expect(errors.map(error => error.message)).toEqual(['secret internal detail']); + + await transport.close(); + }); + + it('a raw non-JSON body on the stateless path gets a 400 parse error', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await connectDraftServer(transport); + + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { method: 'POST', headers: draftHeaders, body: 'this is not json' }) + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + jsonrpc: '2.0', + id: null, + error: { code: -32_700, message: 'Parse error: Invalid JSON' } + }); + + await transport.close(); + }); + }); + describe('close() re-entrancy guard', () => { it('should not recurse when onclose triggers a second close()', async () => { const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: randomUUID }); From 7eb74ea16663bf9075be3fa858417fa6e8bf94f1 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 1 Jun 2026 22:44:07 +0000 Subject: [PATCH 3/4] test(e2e): cover stateless routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three hosting:routing requirements pin the transport's routing facts on the wire: - session-id-never-stateless: a session-bearing request always goes through session validation regardless of the protocol version it claims (valid session served as today, unknown session 404 — never the stateless path's 501). - stateless-only-configured: a draft-version claim routes stateless only when the server lists that version and is connected; otherwise the existing unsupported-version 400 is returned byte-identically. - gap-is-self-describing: a routed request is answered with HTTP 501 and a JSON-RPC -32603 error naming the unimplemented dispatch, with the request id echoed — observably distinct from the session-required 400. Plus a changeset (core/server patch) and a docs/server.md note in the protocol-versions section. --- .changeset/stateless-routing-seam.md | 6 + docs/server.md | 2 +- test/e2e/requirements.ts | 24 +++ test/e2e/scenarios/hosting-routing.test.ts | 206 +++++++++++++++++++++ 4 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 .changeset/stateless-routing-seam.md create mode 100644 test/e2e/scenarios/hosting-routing.test.ts diff --git a/.changeset/stateless-routing-seam.md b/.changeset/stateless-routing-seam.md new file mode 100644 index 000000000..cf7c441ac --- /dev/null +++ b/.changeset/stateless-routing-seam.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/server': patch +--- + +Streamable HTTP servers route draft-protocol requests to a stateless dispatch path (not yet implemented; answers with a clear error). Existing behavior for all current traffic is unchanged. diff --git a/docs/server.md b/docs/server.md index fa2896742..375b738bf 100644 --- a/docs/server.md +++ b/docs/server.md @@ -66,7 +66,7 @@ await server.connect(transport); A server negotiates the protocol version per connection from the stateful subset of its supported list — by default the versions in {@linkcode @modelcontextprotocol/server!index.SUPPORTED_PROTOCOL_VERSIONS | SUPPORTED_PROTOCOL_VERSIONS}. Pass `supportedProtocolVersions` in the server options to restrict or reorder that list. -Only the stateful protocol versions in {@linkcode @modelcontextprotocol/server!index.STATEFUL_PROTOCOL_VERSIONS | STATEFUL_PROTOCOL_VERSIONS} negotiate via the initialize handshake — the server neither accepts a newer revision nor falls back to one. Every revision after 2025-11-25, including the draft revision in {@linkcode @modelcontextprotocol/server!index.DRAFT_PROTOCOL_VERSIONS | DRAFT_PROTOCOL_VERSIONS}, is stateless and negotiates per-request, which arrives with a later release. +Only the stateful protocol versions in {@linkcode @modelcontextprotocol/server!index.STATEFUL_PROTOCOL_VERSIONS | STATEFUL_PROTOCOL_VERSIONS} negotiate via the initialize handshake — the server neither accepts a newer revision nor falls back to one. Every revision after 2025-11-25, including the draft revision in {@linkcode @modelcontextprotocol/server!index.DRAFT_PROTOCOL_VERSIONS | DRAFT_PROTOCOL_VERSIONS}, is stateless and negotiates per-request, which arrives with a later release. On a Streamable HTTP server that lists such a revision, sessionless requests claiming it are routed to a stateless dispatch path (not yet implemented; they are answered with a clear error), while requests carrying a session ID and all stateful-version traffic behave exactly as before. ## Server instructions diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 1d9cffa93..c58155776 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -1828,6 +1828,30 @@ export const REQUIREMENTS: Record = { transports: ['streamableHttp'], note: 'This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, + // Hosting: stateless routing (draft protocol revisions) + + 'hosting:routing:session-id-never-stateless': { + source: 'sdk', + behavior: + 'A request carrying an Mcp-Session-Id header always takes the session path, regardless of the protocol version it claims: session validation still applies (valid session served as today, unknown session 404) and the request is never routed to the stateless dispatch path.', + transports: ['streamableHttp'], + note: 'Routing rule: the session header is checked before any version logic — sessions are version-locked at initialize, so a claimed draft version never bypasses session validation. This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'hosting:routing:stateless-only-configured': { + source: 'sdk', + behavior: + 'A sessionless request claiming a draft protocol version is routed to the stateless dispatch path only when the server lists that draft version in supportedProtocolVersions and a server is connected; otherwise existing behavior applies byte-identically (today: the unsupported-version 400).', + transports: ['streamableHttp'], + note: 'Routing rule: the stateless path is reachable only for versions the server is configured to support — draft versions stay out of the default supported list, so existing deployments see no behavior change. This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'hosting:routing:gap-is-self-describing': { + source: 'sdk', + behavior: + 'A request routed to the stateless dispatch path is answered (until dispatch is implemented) with HTTP 501 and a JSON-RPC -32603 error whose message names the unimplemented stateless dispatch, with the request id echoed — observably distinct from the session-required 400 the session path produces for sessionless requests.', + transports: ['streamableHttp'], + note: 'Routing rule: the routed gap must be self-describing on the wire rather than falling through to a misleading session error. This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'hosting:express-app-helper': { transports: ['streamableHttp'], source: 'sdk', diff --git a/test/e2e/scenarios/hosting-routing.test.ts b/test/e2e/scenarios/hosting-routing.test.ts new file mode 100644 index 000000000..b42700db1 --- /dev/null +++ b/test/e2e/scenarios/hosting-routing.test.ts @@ -0,0 +1,206 @@ +/** + * Self-contained test bodies for hosting:routing requirements. + * + * These pin the WebStandard server transport's routing between the session + * (stateful) path and the stateless dispatch path for draft protocol + * revisions, so they drive raw Request/Response against transports connected + * directly (the routing decision is the transport's, not a hosting helper's). + * + * Routing rules under test: a session-bearing request always goes through + * session validation regardless of version headers; the stateless path is + * reachable only for draft versions the server is configured to support (and + * only once a server is connected); the routed gap answers with a + * self-describing error until stateless dispatch is implemented. + */ + +import { randomUUID } from 'node:crypto'; + +import { + DRAFT_PROTOCOL_VERSION_2026, + LATEST_PROTOCOL_VERSION, + McpServer, + SUPPORTED_PROTOCOL_VERSIONS, + WebStandardStreamableHTTPServerTransport +} from '@modelcontextprotocol/server'; +import { expect } from 'vitest'; +import { z } from 'zod/v4'; + +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +function echoServer(options?: { listDraftVersion?: boolean }): McpServer { + const s = new McpServer( + { name: 's', version: '0' }, + options?.listDraftVersion ? { supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION_2026] } : {} + ); + s.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + return s; +} + +const initializeBody = () => + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'probe', version: '0' } } + }); + +const toolsListBody = (id: number | string) => JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/list', params: {} }); + +const baseHeaders = { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream' +}; + +verifies('hosting:routing:session-id-never-stateless', async (_args: TestArgs) => { + // Direct transport so the routing decision under test is the SDK's, not a hosting helper's. + const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: randomUUID }); + await echoServer({ listDraftVersion: true }).connect(tx); + + try { + const initRes = await tx.handleRequest( + new Request('http://in-process/mcp', { + method: 'POST', + headers: { ...baseHeaders, 'mcp-protocol-version': LATEST_PROTOCOL_VERSION }, + body: initializeBody() + }) + ); + expect(initRes.status).toBe(200); + const sessionId = initRes.headers.get('mcp-session-id')!; + + // Valid session + draft version header: served on the session path as today (SSE result), + // even though the draft version alone would route stateless on this transport. + const valid = await tx.handleRequest( + new Request('http://in-process/mcp', { + method: 'POST', + headers: { ...baseHeaders, 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION_2026, 'mcp-session-id': sessionId }, + body: toolsListBody(2) + }) + ); + expect(valid.status).toBe(200); + expect(valid.headers.get('content-type')).toMatch(/text\/event-stream/); + + // Actually served on the session path, not merely accepted: the SSE stream carries + // the tools/list result for request id 2 — a real response, never the stateless 501. + const reader = valid.body!.getReader(); + const { value: firstEvent } = await reader.read(); + const dataLine = new TextDecoder() + .decode(firstEvent) + .split('\n') + .find(line => line.startsWith('data: ')); + expect(dataLine).toBeDefined(); + expect(JSON.parse(dataLine!.slice('data: '.length))).toMatchObject({ + jsonrpc: '2.0', + id: 2, + result: { tools: [{ name: 'echo' }] } + }); + + // Unknown session + draft version header: session validation answers (404), never the 501. + const unknown = await tx.handleRequest( + new Request('http://in-process/mcp', { + method: 'POST', + headers: { ...baseHeaders, 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION_2026, 'mcp-session-id': 'no-such-session' }, + body: toolsListBody(3) + }) + ); + expect(unknown.status).toBe(404); + expect(await unknown.json()).toMatchObject({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' } }); + } finally { + await tx.close(); + } +}); + +verifies('hosting:routing:stateless-only-configured', async (_args: TestArgs) => { + const draftRequest = () => + new Request('http://in-process/mcp', { + method: 'POST', + headers: { ...baseHeaders, 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION_2026 }, + body: toolsListBody(1) + }); + + // Draft version claim on a server NOT listing the draft: today's unsupported-version 400, byte-identical. + const txDefault = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await echoServer().connect(txDefault); + try { + const res = await txDefault.handleRequest(draftRequest()); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + jsonrpc: '2.0', + id: null, + error: { + code: -32_000, + message: `Bad Request: Unsupported protocol version: ${DRAFT_PROTOCOL_VERSION_2026} (supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')})` + } + }); + } finally { + await txDefault.close(); + } + + // Draft version claim with the draft listed and a server connected: routed to the stateless path. + const txDraft = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await echoServer({ listDraftVersion: true }).connect(txDraft); + try { + const res = await txDraft.handleRequest(draftRequest()); + expect(res.status).toBe(501); + expect(await res.json()).toMatchObject({ jsonrpc: '2.0', error: { code: -32_603 } }); + } finally { + await txDraft.close(); + } +}); + +verifies('hosting:routing:gap-is-self-describing', async (_args: TestArgs) => { + // One session-mode transport, draft listed: the same sessionless POST flips between the + // session-required 400 and the stateless 501 purely on the claimed version. + const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: randomUUID }); + await echoServer({ listDraftVersion: true }).connect(tx); + + try { + const initRes = await tx.handleRequest( + new Request('http://in-process/mcp', { + method: 'POST', + headers: { ...baseHeaders, 'mcp-protocol-version': LATEST_PROTOCOL_VERSION }, + body: initializeBody() + }) + ); + expect(initRes.status).toBe(200); + + // Sessionless request at a released version: today's session-required 400. + const sessionRequired = await tx.handleRequest( + new Request('http://in-process/mcp', { + method: 'POST', + headers: { ...baseHeaders, 'mcp-protocol-version': LATEST_PROTOCOL_VERSION }, + body: toolsListBody(2) + }) + ); + expect(sessionRequired.status).toBe(400); + const sessionRequiredBody = (await sessionRequired.json()) as { error: { code: number } }; + expect(sessionRequiredBody).toMatchObject({ + jsonrpc: '2.0', + error: { code: -32_000, message: 'Bad Request: Mcp-Session-Id header is required' } + }); + + // Sessionless request at the draft version: routed, and the gap is self-describing — + // 501 with -32603 and the request id echoed, provably distinct from the 400 above. + const routed = await tx.handleRequest( + new Request('http://in-process/mcp', { + method: 'POST', + headers: { ...baseHeaders, 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION_2026 }, + body: toolsListBody(7) + }) + ); + expect(routed.status).toBe(501); + const routedBody = (await routed.json()) as { error: { code: number } }; + expect(routedBody).toEqual({ + jsonrpc: '2.0', + id: 7, + error: { code: -32_603, message: 'stateless request dispatch is not implemented yet' } + }); + + expect(routed.status).not.toBe(sessionRequired.status); + expect(routedBody.error.code).not.toBe(sessionRequiredBody.error.code); + } finally { + await tx.close(); + } +}); From c1f23f23a700943b8e6fe9d2973f4e4809ddd7b5 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 1 Jun 2026 23:38:30 +0000 Subject: [PATCH 4/4] feat(server): route stateless-revision stdio messages to the stateless seam StdioServerTransport now implements the stateless dispatch seam: setSupportedProtocolVersions() (Protocol.connect() already passes the server's list down optionally) and setStatelessHandlers() (installed by Server.connect() before start(), so the router is wired before the first byte of stdin is read). The read loop routes a message to the stateless path only when all hold: handlers are installed, the message is a JSON-RPC request, and its params._meta['io.modelcontextprotocol/protocolVersion'] claim is a string that is not a stateful protocol version AND is in the supported list -- the same dual-key rule the HTTP transport applies to sessionless requests. stdio has no session header, so the _meta claim is the routing signal, but it only activates when the operator explicitly lists a draft version (drafts never enter the default supported list). Notifications and responses claiming stateless versions stay on onmessage, matching the HTTP path at this milestone. A routed request is dispatched and its response written to stdout; NotImplementedYetError maps to a JSON-RPC -32603 error with the wire-safe message and the request id echoed (the same self-describing gap as the HTTP path's 501); any other dispatch failure answers with a generic -32603 'Internal error' so internals never leak. The shared _meta key moves to core as PROTOCOL_VERSION_META_KEY (was a local constant in streamableHttp.ts) so both transports key on the same string. Unit tests cover the routed gap reply (never reaching onmessage), dispatch-result forwarding, stateful/meta-less/notification fallthrough, the unlisted-version dual-key fallthrough, the no-handlers fallthrough, and the generic-error mapping. The hosting:routing e2e requirements stateless-only-configured and gap-is-self-describing gain stdio cells driving the spawned fixture server with hand-built messages (the fixture gains an E2E_LIST_DRAFT_VERSION opt-in knob); the changeset and docs/server.md note the stdio routing. --- .changeset/stateless-routing-seam.md | 2 +- docs/server.md | 2 +- packages/core/src/types/constants.ts | 7 + packages/server/src/server/stdio.ts | 84 +++++++++++- packages/server/src/server/streamableHttp.ts | 10 +- packages/server/test/server/stdio.test.ts | 122 ++++++++++++++++- test/e2e/fixtures/stdio-server.ts | 15 ++- test/e2e/requirements.ts | 12 +- test/e2e/scenarios/hosting-routing.test.ts | 133 +++++++++++++++++-- 9 files changed, 350 insertions(+), 37 deletions(-) diff --git a/.changeset/stateless-routing-seam.md b/.changeset/stateless-routing-seam.md index cf7c441ac..d2111d81d 100644 --- a/.changeset/stateless-routing-seam.md +++ b/.changeset/stateless-routing-seam.md @@ -3,4 +3,4 @@ '@modelcontextprotocol/server': patch --- -Streamable HTTP servers route draft-protocol requests to a stateless dispatch path (not yet implemented; answers with a clear error). Existing behavior for all current traffic is unchanged. +Streamable HTTP servers route draft-protocol requests to a stateless dispatch path (not yet implemented; answers with a clear error). Stdio servers route the same way, keying on the request's `io.modelcontextprotocol/protocolVersion` `_meta` claim — and only when the server explicitly lists the draft version as supported. Existing behavior for all current traffic is unchanged. diff --git a/docs/server.md b/docs/server.md index 375b738bf..addf06743 100644 --- a/docs/server.md +++ b/docs/server.md @@ -66,7 +66,7 @@ await server.connect(transport); A server negotiates the protocol version per connection from the stateful subset of its supported list — by default the versions in {@linkcode @modelcontextprotocol/server!index.SUPPORTED_PROTOCOL_VERSIONS | SUPPORTED_PROTOCOL_VERSIONS}. Pass `supportedProtocolVersions` in the server options to restrict or reorder that list. -Only the stateful protocol versions in {@linkcode @modelcontextprotocol/server!index.STATEFUL_PROTOCOL_VERSIONS | STATEFUL_PROTOCOL_VERSIONS} negotiate via the initialize handshake — the server neither accepts a newer revision nor falls back to one. Every revision after 2025-11-25, including the draft revision in {@linkcode @modelcontextprotocol/server!index.DRAFT_PROTOCOL_VERSIONS | DRAFT_PROTOCOL_VERSIONS}, is stateless and negotiates per-request, which arrives with a later release. On a Streamable HTTP server that lists such a revision, sessionless requests claiming it are routed to a stateless dispatch path (not yet implemented; they are answered with a clear error), while requests carrying a session ID and all stateful-version traffic behave exactly as before. +Only the stateful protocol versions in {@linkcode @modelcontextprotocol/server!index.STATEFUL_PROTOCOL_VERSIONS | STATEFUL_PROTOCOL_VERSIONS} negotiate via the initialize handshake — the server neither accepts a newer revision nor falls back to one. Every revision after 2025-11-25, including the draft revision in {@linkcode @modelcontextprotocol/server!index.DRAFT_PROTOCOL_VERSIONS | DRAFT_PROTOCOL_VERSIONS}, is stateless and negotiates per-request, which arrives with a later release. On a Streamable HTTP server that lists such a revision, sessionless requests claiming it are routed to a stateless dispatch path (not yet implemented; they are answered with a clear error), while requests carrying a session ID and all stateful-version traffic behave exactly as before. A stdio server that lists such a revision routes the same way, keyed on the request's `io.modelcontextprotocol/protocolVersion` `_meta` claim. ## Server instructions diff --git a/packages/core/src/types/constants.ts b/packages/core/src/types/constants.ts index 098ba50a0..e1b85c6e3 100644 --- a/packages/core/src/types/constants.ts +++ b/packages/core/src/types/constants.ts @@ -28,6 +28,13 @@ export const DRAFT_PROTOCOL_VERSIONS = [DRAFT_PROTOCOL_VERSION_2026]; export const RELATED_TASK_META_KEY = 'io.modelcontextprotocol/related-task'; +/** + * The `_meta` key carrying the protocol version a request claims + * (`io.modelcontextprotocol/protocolVersion`). Stateless protocol revisions negotiate + * per-request through this claim; server transports read it as a routing signal. + */ +export const PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion'; + /* JSON-RPC types */ export const JSONRPC_VERSION = '2.0'; diff --git a/packages/server/src/server/stdio.ts b/packages/server/src/server/stdio.ts index ac2dd3f78..27abac323 100644 --- a/packages/server/src/server/stdio.ts +++ b/packages/server/src/server/stdio.ts @@ -1,7 +1,16 @@ import type { Readable, Writable } from 'node:stream'; -import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; -import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/core'; +import type { JSONRPCMessage, JSONRPCRequest, StatelessHandlers, Transport } from '@modelcontextprotocol/core'; +import { + INTERNAL_ERROR, + isJSONRPCRequest, + isStatefulProtocolVersion, + NotImplementedYetError, + PROTOCOL_VERSION_META_KEY, + ReadBuffer, + serializeMessage, + SUPPORTED_PROTOCOL_VERSIONS +} from '@modelcontextprotocol/core'; import { process } from '@modelcontextprotocol/server/_shims'; /** @@ -20,12 +29,36 @@ export class StdioServerTransport implements Transport { private _readBuffer: ReadBuffer = new ReadBuffer(); private _started = false; private _closed = false; + private _supportedProtocolVersions: string[] = SUPPORTED_PROTOCOL_VERSIONS; + + /** + * Hook for the stateless (draft-protocol-version) request path. Set + * internally by `Server.connect()` so this transport can route requests + * claiming a draft protocol version to the server's stateless dispatch + * instead of the `onmessage` path. Optional on the {@linkcode Transport} + * contract; only concrete server transports read it. + * @internal + */ + private _statelessHandlers?: StatelessHandlers; constructor( private _stdin: Readable = process.stdin, private _stdout: Writable = process.stdout ) {} + /** + * Sets the supported protocol versions for stateless routing. + * Called by the server during {@linkcode server/server.Server.connect | connect()} to pass its supported versions. + */ + setSupportedProtocolVersions(versions: string[]): void { + this._supportedProtocolVersions = versions; + } + + /** @internal */ + setStatelessHandlers(handlers: StatelessHandlers): void { + this._statelessHandlers = handlers; + } + onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage) => void; @@ -69,13 +102,58 @@ export class StdioServerTransport implements Transport { break; } - this.onmessage?.(message); + // stdio has no session header: a request's _meta version claim is the routing + // signal, dual-keyed on the server listing that version. Requests only. + const handlers = this._statelessHandlers; + if (handlers && isJSONRPCRequest(message) && this.claimsRoutableStatelessVersion(message)) { + void this.dispatchStatelessRequest(message, handlers); + } else { + this.onmessage?.(message); + } } catch (error) { this.onerror?.(error as Error); } } } + /** + * Whether `request` claims (via `params._meta`, see + * {@linkcode PROTOCOL_VERSION_META_KEY}) a stateless protocol version this + * transport lists as supported — the same dual-key rule the Streamable + * HTTP transport applies to sessionless requests. + */ + private claimsRoutableStatelessVersion(request: JSONRPCRequest): boolean { + const version = request.params?._meta?.[PROTOCOL_VERSION_META_KEY]; + return typeof version === 'string' && !isStatefulProtocolVersion(version) && this._supportedProtocolVersions.includes(version); + } + + /** + * Serves one request routed to the stateless dispatch path: forwards it to + * the installed dispatch handler and writes the returned response to + * stdout. Detached from the read loop (request→response, never blocks + * `onmessage` traffic); never throws — failures map to a JSON-RPC error + * response with the request id echoed, mirroring the HTTP path. + */ + private async dispatchStatelessRequest(request: JSONRPCRequest, handlers: StatelessHandlers): Promise { + try { + const response = await handlers.dispatch(request, {}); + await this.send(response); + } catch (error) { + // Deliberately-open seam: dispatch is filled in by the server side + // (see Server._dispatchStateless). Until then the gap is + // self-describing on the wire: a -32603 error carrying the + // wire-safe NotImplementedYetError message; anything else stays a + // generic internal error. + this.onerror?.(error as Error); + const message = error instanceof NotImplementedYetError ? error.message : 'Internal error'; + try { + await this.send({ jsonrpc: '2.0', id: request.id, error: { code: INTERNAL_ERROR, message } }); + } catch (sendError) { + this.onerror?.(sendError as Error); + } + } + } + async close(): Promise { if (this._closed) { return; diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index 466cf69c4..508121f59 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -19,27 +19,21 @@ import type { import { INTERNAL_ERROR, DEFAULT_NEGOTIATED_PROTOCOL_VERSION, - isStatefulProtocolVersion, isInitializeRequest, isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, + isStatefulProtocolVersion, JSONRPCMessageSchema, JSONRPCRequestSchema, NotImplementedYetError, + PROTOCOL_VERSION_META_KEY, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; export type StreamId = string; export type EventId = string; -/** - * The `_meta` key carrying the protocol version a request claims - * (`io.modelcontextprotocol/protocolVersion`). Used as a routing fallback when - * the `MCP-Protocol-Version` header is absent. - */ -const PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion'; - /** * Extracts the protocol version claimed by a pre-parsed POST body's first * request via `params._meta` (see {@linkcode PROTOCOL_VERSION_META_KEY}). diff --git a/packages/server/test/server/stdio.test.ts b/packages/server/test/server/stdio.test.ts index 92671cacd..fda8feead 100644 --- a/packages/server/test/server/stdio.test.ts +++ b/packages/server/test/server/stdio.test.ts @@ -1,7 +1,14 @@ import { Readable, Writable } from 'node:stream'; import type { JSONRPCMessage } from '@modelcontextprotocol/core'; -import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/core'; +import { + DRAFT_PROTOCOL_VERSION_2026, + PROTOCOL_VERSION_META_KEY, + ReadBuffer, + serializeMessage, + SUPPORTED_PROTOCOL_VERSIONS +} from '@modelcontextprotocol/core'; +import { vi } from 'vitest'; import { StdioServerTransport } from '../../src/server/stdio.js'; @@ -179,3 +186,116 @@ test('should fire onerror before onclose on stdout error', async () => { expect(events).toEqual(['error', 'close']); }); + +// ───── stateless routing (draft protocol revisions) ───── + +/** A request claiming `version` per-request via `params._meta` — the stdio routing signal. */ +function versionClaimingRequest(id: number, version: string): JSONRPCMessage { + return { jsonrpc: '2.0', id, method: 'tools/list', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: version } } }; +} + +const DRAFT_LISTED = [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION_2026]; + +/** Waits for the next JSON-RPC message the transport writes to stdout. */ +async function nextStdoutMessage(): Promise { + return await vi.waitFor(() => { + const message = outputBuffer.readMessage(); + if (message === null) { + throw new Error('no message written to stdout yet'); + } + return message; + }); +} + +test('sends the dispatch result for a routed request on stdout', async () => { + const server = new StdioServerTransport(input, output); + const dispatch = vi.fn().mockResolvedValue({ jsonrpc: '2.0', id: 3, result: { ok: true } }); + server.setStatelessHandlers({ dispatch }); + server.setSupportedProtocolVersions(DRAFT_LISTED); + const onmessage = vi.fn(); + server.onmessage = onmessage; + server.onerror = error => { + throw error; + }; + + await server.start(); + const request = versionClaimingRequest(3, DRAFT_PROTOCOL_VERSION_2026); + input.push(serializeMessage(request)); + + expect(await nextStdoutMessage()).toEqual({ jsonrpc: '2.0', id: 3, result: { ok: true } }); + expect(dispatch).toHaveBeenCalledExactlyOnceWith(request, {}); + expect(onmessage).not.toHaveBeenCalled(); +}); + +test('leaves stateful-version, meta-less, and notification traffic on onmessage', async () => { + const server = new StdioServerTransport(input, output); + const dispatch = vi.fn(); + server.setStatelessHandlers({ dispatch }); + server.setSupportedProtocolVersions(DRAFT_LISTED); + server.onerror = error => { + throw error; + }; + + const messages: JSONRPCMessage[] = [ + // A stateful-version claim never routes, even though the version is listed. + versionClaimingRequest(1, '2025-06-18'), + // No claim at all: today's traffic, untouched. + { jsonrpc: '2.0', id: 2, method: 'ping' }, + // Only requests route: a notification claiming a listed draft version stays on onmessage. + { + jsonrpc: '2.0', + method: 'notifications/initialized', + params: { _meta: { [PROTOCOL_VERSION_META_KEY]: DRAFT_PROTOCOL_VERSION_2026 } } + } + ]; + + const received: JSONRPCMessage[] = []; + server.onmessage = message => received.push(message); + + await server.start(); + for (const message of messages) { + input.push(serializeMessage(message)); + } + + await vi.waitFor(() => expect(received).toHaveLength(3)); + expect(received).toEqual(messages); + expect(dispatch).not.toHaveBeenCalled(); + expect(outputBuffer.readMessage()).toBeNull(); +}); + +test('a stateless-version claim falls through to onmessage when no handlers are installed', async () => { + const server = new StdioServerTransport(input, output); + server.setSupportedProtocolVersions(DRAFT_LISTED); + server.onerror = error => { + throw error; + }; + + const received: JSONRPCMessage[] = []; + server.onmessage = message => received.push(message); + + await server.start(); + const request = versionClaimingRequest(5, DRAFT_PROTOCOL_VERSION_2026); + input.push(serializeMessage(request)); + + await vi.waitFor(() => expect(received).toEqual([request])); + expect(outputBuffer.readMessage()).toBeNull(); +}); + +test('a non-NotImplementedYetError dispatch failure answers with a generic internal error', async () => { + const server = new StdioServerTransport(input, output); + server.setStatelessHandlers({ + dispatch: () => { + throw new Error('secret internal detail'); + } + }); + server.setSupportedProtocolVersions(DRAFT_LISTED); + const errors: Error[] = []; + server.onerror = error => errors.push(error); + + await server.start(); + input.push(serializeMessage(versionClaimingRequest(9, DRAFT_PROTOCOL_VERSION_2026))); + + // The wire gets a generic message (no internal details leak); onerror gets the real one. + expect(await nextStdoutMessage()).toEqual({ jsonrpc: '2.0', id: 9, error: { code: -32_603, message: 'Internal error' } }); + expect(errors.map(error => error.message)).toEqual(['secret internal detail']); +}); diff --git a/test/e2e/fixtures/stdio-server.ts b/test/e2e/fixtures/stdio-server.ts index f9ead4ee6..fd1df1551 100644 --- a/test/e2e/fixtures/stdio-server.ts +++ b/test/e2e/fixtures/stdio-server.ts @@ -5,16 +5,25 @@ * single `echo` tool, writes a readiness marker line to stderr once it is * serving, and — when E2E_IGNORE_SIGTERM=1 — keeps running after stdin EOF and * swallows SIGTERM so the client transport's shutdown escalation - * (stdin EOF → SIGTERM → SIGKILL) is observable. + * (stdin EOF → SIGTERM → SIGKILL) is observable. When E2E_LIST_DRAFT_VERSION=1 + * the server lists the draft protocol revision in supportedProtocolVersions so + * the hosting:routing tests can exercise stdio's stateless routing. */ /* eslint-disable unicorn/no-process-exit -- standalone spawned executable; exit codes are the behavior under test */ -import { McpServer } from '@modelcontextprotocol/server'; +import { DRAFT_PROTOCOL_VERSION_2026, McpServer, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/server'; import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; import { z } from 'zod/v4'; -const server = new McpServer({ name: 'stdio-echo-server', version: '1.0.0' }); +const server = new McpServer( + { name: 'stdio-echo-server', version: '1.0.0' }, + // E2E_LIST_DRAFT_VERSION=1: opt the server in to the draft protocol revision so the + // hosting:routing tests can observe stdio's stateless routing (drafts never enter the default list). + process.env.E2E_LIST_DRAFT_VERSION === '1' + ? { supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION_2026] } + : {} +); server.registerTool( 'echo', diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index c58155776..c73e8e740 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -1840,16 +1840,16 @@ export const REQUIREMENTS: Record = { 'hosting:routing:stateless-only-configured': { source: 'sdk', behavior: - 'A sessionless request claiming a draft protocol version is routed to the stateless dispatch path only when the server lists that draft version in supportedProtocolVersions and a server is connected; otherwise existing behavior applies byte-identically (today: the unsupported-version 400).', - transports: ['streamableHttp'], - note: 'Routing rule: the stateless path is reachable only for versions the server is configured to support — draft versions stay out of the default supported list, so existing deployments see no behavior change. This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + 'A sessionless request claiming a draft protocol version is routed to the stateless dispatch path only when the server lists that draft version in supportedProtocolVersions and a server is connected; otherwise existing behavior applies byte-identically (today: the unsupported-version 400 over HTTP, normal onmessage delivery over stdio).', + transports: ['streamableHttp', 'stdio'], + note: 'Routing rule: the stateless path is reachable only for versions the server is configured to support — draft versions stay out of the default supported list, so existing deployments see no behavior change. The streamableHttp cell exercises both halves against the WebStandard transport; the stdio cell proves the not-listed half against a spawned fixture server (a draft _meta claim the server does not list is served on the existing path), with the routed half on stdio carried by gap-is-self-describing. Version resolution reads the first request of a batch body; batch semantics on the stateless path are settled with the no-batching rule in a later release.' }, 'hosting:routing:gap-is-self-describing': { source: 'sdk', behavior: - 'A request routed to the stateless dispatch path is answered (until dispatch is implemented) with HTTP 501 and a JSON-RPC -32603 error whose message names the unimplemented stateless dispatch, with the request id echoed — observably distinct from the session-required 400 the session path produces for sessionless requests.', - transports: ['streamableHttp'], - note: 'Routing rule: the routed gap must be self-describing on the wire rather than falling through to a misleading session error. This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + 'A request routed to the stateless dispatch path is answered (until dispatch is implemented) with a JSON-RPC -32603 error whose message names the unimplemented stateless dispatch, with the request id echoed (over HTTP: status 501) — observably distinct from what the existing path produces for the same traffic.', + transports: ['streamableHttp', 'stdio'], + note: 'Routing rule: the routed gap must be self-describing on the wire rather than falling through to a misleading session error. The streamableHttp cell drives the WebStandard transport directly; the stdio cell spawns the fixture server with the draft version listed and routes via the request _meta claim (stdio has no session header, so the dual-keyed _meta claim is the routing signal).' }, 'hosting:express-app-helper': { diff --git a/test/e2e/scenarios/hosting-routing.test.ts b/test/e2e/scenarios/hosting-routing.test.ts index b42700db1..1719ad499 100644 --- a/test/e2e/scenarios/hosting-routing.test.ts +++ b/test/e2e/scenarios/hosting-routing.test.ts @@ -1,10 +1,15 @@ /** * Self-contained test bodies for hosting:routing requirements. * - * These pin the WebStandard server transport's routing between the session - * (stateful) path and the stateless dispatch path for draft protocol - * revisions, so they drive raw Request/Response against transports connected - * directly (the routing decision is the transport's, not a hosting helper's). + * These pin the server transports' routing between the existing (stateful) + * path and the stateless dispatch path for draft protocol revisions. The + * streamableHttp cells drive raw Request/Response against WebStandard + * transports connected directly (the routing decision is the transport's, not + * a hosting helper's); the stdio cells spawn the fixture server in + * `fixtures/stdio-server.ts` as a real child process and inject hand-built + * JSON-RPC messages via {@link StdioClientTransport} — on stdio there is no + * session header, so routing keys on the request's `_meta` version claim, + * dual-keyed with the server's supported-versions list. * * Routing rules under test: a session-bearing request always goes through * session validation regardless of version headers; the stateless path is @@ -14,7 +19,11 @@ */ import { randomUUID } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { CallToolResultSchema, JSONRPCResultResponseSchema, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/server'; import { DRAFT_PROTOCOL_VERSION_2026, LATEST_PROTOCOL_VERSION, @@ -22,12 +31,18 @@ import { SUPPORTED_PROTOCOL_VERSIONS, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; -import { expect } from 'vitest'; +import { expect, vi } from 'vitest'; import { z } from 'zod/v4'; import { verifies } from '../helpers/verifies.js'; import type { TestArgs } from '../types.js'; +/** Absolute path to the runnable stdio fixture server (executed with tsx). */ +const FIXTURE_PATH = fileURLToPath(new URL('../fixtures/stdio-server.ts', import.meta.url)); + +/** E2E package root — spawn cwd so the workspace-local `tsx` resolves and tsconfig paths map workspace packages to source. */ +const E2E_ROOT = fileURLToPath(new URL('../', import.meta.url)); + function echoServer(options?: { listDraftVersion?: boolean }): McpServer { const s = new McpServer( { name: 's', version: '0' }, @@ -39,13 +54,14 @@ function echoServer(options?: { listDraftVersion?: boolean }): McpServer { return s; } -const initializeBody = () => - JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'probe', version: '0' } } - }); +const initializeRequest = (id: number): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'initialize', + params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'probe', version: '0' } } +}); + +const initializeBody = () => JSON.stringify(initializeRequest(1)); const toolsListBody = (id: number | string) => JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/list', params: {} }); @@ -54,6 +70,85 @@ const baseHeaders = { accept: 'application/json, text/event-stream' }; +/** Hand-built echo tools/call whose `params._meta` claims the draft protocol version — the stdio routing signal. */ +const draftClaimingEchoCall = (id: number): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'claimed' }, _meta: { [PROTOCOL_VERSION_META_KEY]: DRAFT_PROTOCOL_VERSION_2026 } } +}); + +/** Spawns the stdio fixture server (optionally listing the draft protocol version) and collects its messages. */ +function spawnStdioFixture(options?: { listDraftVersion?: boolean }): { transport: StdioClientTransport; received: JSONRPCMessage[] } { + const transport = new StdioClientTransport({ + command: process.execPath, + args: ['--import', 'tsx', FIXTURE_PATH], + cwd: E2E_ROOT, + ...(options?.listDraftVersion ? { env: { E2E_LIST_DRAFT_VERSION: '1' } } : {}) + }); + const received: JSONRPCMessage[] = []; + transport.onmessage = message => void received.push(message); + return { transport, received }; +} + +/** + * stdio half of stateless-only-configured: on a fixture server that does NOT + * list the draft version, a request claiming it via `_meta` is NOT routed — + * it is served on the existing path exactly as today (dual key: the claim + * alone never routes). The routed half on stdio is pinned by the + * gap-is-self-describing stdio body. + */ +async function statelessOnlyConfiguredStdio(): Promise { + const { transport, received } = spawnStdioFixture(); + try { + await transport.start(); + + await transport.send(initializeRequest(1)); + // Generous first wait: tsx compiles the fixture inside the freshly spawned child before it can answer. + await vi.waitFor(() => expect(received).toHaveLength(1), { timeout: 10_000, interval: 25 }); + await transport.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + await transport.send(draftClaimingEchoCall(2)); + await vi.waitFor(() => expect(received).toHaveLength(2), { timeout: 5000, interval: 25 }); + + // Existing behavior, untouched: a tools/call result — never the stateless gap error. + const response = JSONRPCResultResponseSchema.parse(received[1]); + expect(response.id).toBe(2); + expect(CallToolResultSchema.parse(response.result).content).toEqual([{ type: 'text', text: 'claimed' }]); + } finally { + await transport.close(); + } +} + +/** + * stdio half of gap-is-self-describing: on a fixture server that lists the + * draft version, the same server answers stateful traffic as today while a + * draft-claiming request is routed and answered with the self-describing + * -32603 error, request id echoed — observably distinct from a served result. + */ +async function gapIsSelfDescribingStdio(): Promise { + const { transport, received } = spawnStdioFixture({ listDraftVersion: true }); + try { + await transport.start(); + + // The server is otherwise fully functional: the stateful handshake succeeds as today. + await transport.send(initializeRequest(1)); + await vi.waitFor(() => expect(received).toHaveLength(1), { timeout: 10_000, interval: 25 }); + expect(JSONRPCResultResponseSchema.parse(received[0]).id).toBe(1); + await transport.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + await transport.send(draftClaimingEchoCall(7)); + await vi.waitFor(() => expect(received).toHaveLength(2), { timeout: 5000, interval: 25 }); + expect(received[1]).toEqual({ + jsonrpc: '2.0', + id: 7, + error: { code: -32_603, message: 'stateless request dispatch is not implemented yet' } + }); + } finally { + await transport.close(); + } +} + verifies('hosting:routing:session-id-never-stateless', async (_args: TestArgs) => { // Direct transport so the routing decision under test is the SDK's, not a hosting helper's. const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: randomUUID }); @@ -112,7 +207,12 @@ verifies('hosting:routing:session-id-never-stateless', async (_args: TestArgs) = } }); -verifies('hosting:routing:stateless-only-configured', async (_args: TestArgs) => { +verifies('hosting:routing:stateless-only-configured', async ({ transport }: TestArgs) => { + if (transport === 'stdio') { + await statelessOnlyConfiguredStdio(); + return; + } + const draftRequest = () => new Request('http://in-process/mcp', { method: 'POST', @@ -150,7 +250,12 @@ verifies('hosting:routing:stateless-only-configured', async (_args: TestArgs) => } }); -verifies('hosting:routing:gap-is-self-describing', async (_args: TestArgs) => { +verifies('hosting:routing:gap-is-self-describing', async ({ transport }: TestArgs) => { + if (transport === 'stdio') { + await gapIsSelfDescribingStdio(); + return; + } + // One session-mode transport, draft listed: the same sessionless POST flips between the // session-required 400 and the stateless 501 purely on the claimed version. const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: randomUUID });