diff --git a/.changeset/stateless-routing-seam.md b/.changeset/stateless-routing-seam.md new file mode 100644 index 0000000000..d2111d81dc --- /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). 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 fa28967427..addf06743f 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. 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/errors/notImplementedYetError.ts b/packages/core/src/errors/notImplementedYetError.ts new file mode 100644 index 0000000000..61fc72b64e --- /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 0f83b8fa2a..d884d58a94 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 d3a6397822..d8f3b94e39 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 c606e2e3b5..7d2d57d23e 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/src/types/constants.ts b/packages/core/src/types/constants.ts index 098ba50a08..e1b85c6e3c 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/core/test/errors/notImplementedYetError.test.ts b/packages/core/test/errors/notImplementedYetError.test.ts new file mode 100644 index 0000000000..39f8a1c0a2 --- /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); + }); +}); diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index c12e109299..566ad9bf8a 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/stdio.ts b/packages/server/src/server/stdio.ts index ac2dd3f784..27abac3238 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 fd3563a077..508121f598 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -7,20 +7,49 @@ * 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, 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; +/** + * 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 +369,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 +448,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 d4408f888b..09a60f7b85 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/stdio.test.ts b/packages/server/test/server/stdio.test.ts index 92671cacd9..fda8feead8 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/packages/server/test/server/streamableHttp.test.ts b/packages/server/test/server/streamableHttp.test.ts index 7a23dd56bb..bc2cb2b467 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 }); diff --git a/test/e2e/fixtures/stdio-server.ts b/test/e2e/fixtures/stdio-server.ts index f9ead4ee6a..fd1df15514 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 1d9cffa93d..c73e8e740e 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 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 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': { 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 0000000000..1719ad499f --- /dev/null +++ b/test/e2e/scenarios/hosting-routing.test.ts @@ -0,0 +1,311 @@ +/** + * Self-contained test bodies for hosting:routing requirements. + * + * 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 + * 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 { 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, + McpServer, + SUPPORTED_PROTOCOL_VERSIONS, + WebStandardStreamableHTTPServerTransport +} from '@modelcontextprotocol/server'; +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' }, + 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 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: {} }); + +const baseHeaders = { + 'content-type': 'application/json', + 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 }); + 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 ({ transport }: TestArgs) => { + if (transport === 'stdio') { + await statelessOnlyConfiguredStdio(); + return; + } + + 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 ({ 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 }); + 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(); + } +});