From d46923684ede0d46a4fd989e4b6ebf1e68ad6d2f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 17 Jun 2026 19:26:20 +0000 Subject: [PATCH 1/8] feat(server): default createMcpHandler to stateless legacy serving and export isLegacyRequest The legacy option becomes a posture: 'stateless' (now the default) serves 2025-era traffic per request from the same factory, 'reject' keeps the endpoint modern-only strict. The handler-valued legacy option is removed; existing legacy deployments route in user land with the new isLegacyRequest predicate, which runs the entry's own classification step (shared code, not a re-implementation) and leaves the request body readable for the routed handler. legacyStatelessFallback stays exported as a standalone building block. --- packages/server/src/index.ts | 2 +- .../server/src/server/createMcpHandler.ts | 310 ++++++++++++------ 2 files changed, 217 insertions(+), 95 deletions(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 408f4be340..4420e20712 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -18,7 +18,7 @@ export type { NodeIncomingMessageLike, NodeServerResponseLike } from './server/createMcpHandler.js'; -export { createMcpHandler, legacyStatelessFallback } from './server/createMcpHandler.js'; +export { createMcpHandler, isLegacyRequest, legacyStatelessFallback } from './server/createMcpHandler.js'; export type { AnyToolHandler, BaseToolCallback, diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 3b4f45ab3d..3355fb4e13 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -1,6 +1,6 @@ /** * `createMcpHandler` — the HTTP entry point for serving the 2026-07-28 protocol - * revision, with 2025-era serving available as an opt-in slot. + * revision, with old-school stateless 2025-era serving as the default fallback. * * The entry classifies every inbound HTTP request exactly once (body-primary, * via {@linkcode classifyInboundRequest}) and routes it: @@ -11,9 +11,16 @@ * transport. * - Requests without an envelope claim (including `initialize`, GET/DELETE * session operations, and 2025-era notification POSTs) are legacy traffic. - * When the `legacy` slot is configured they are handed to it untouched; when - * it is not, the endpoint is modern-only strict and answers the documented - * rejection cells. There is no silent 2025 serving without the slot. + * By default they are served per request through the stateless idiom from + * the same factory (`legacy: 'stateless'`); with `legacy: 'reject'` the + * endpoint is modern-only strict and answers the documented rejection cells + * instead — there is no 2025 serving in that mode. + * + * There is no handler-valued `legacy` option: an existing legacy deployment + * (for example a sessionful streamable HTTP wiring) keeps serving 2025 traffic + * by routing in user land with {@linkcode isLegacyRequest} — the entry's own + * classification step, exported as a predicate — in front of a strict + * (`legacy: 'reject'`) handler. * * The entry performs no Origin/Host validation (mount the origin/host * validation middleware in front of it) and no token verification — `authInfo` @@ -23,6 +30,7 @@ import type { AuthInfo, ClientCapabilities, Implementation, + InboundClassificationOutcome, InboundLadderRejection, InboundLegacyRoute, InboundModernRoute, @@ -77,9 +85,10 @@ export interface McpRequestContext { * The protocol era the constructed instance will serve: `modern` for * 2026-07-28 (per-request envelope) traffic, `legacy` for 2025-era * traffic. Under {@linkcode createMcpHandler} a `legacy` instance serves - * one request through the `legacy: 'stateless'` slot; under `serveStdio` - * it serves a connection that opened with the 2025 handshake and stays - * pinned to that era for its lifetime. + * one request through the stateless legacy fallback (the default — + * `legacy: 'reject'` endpoints are strict and never construct one); under + * `serveStdio` it serves a connection that opened with the 2025 handshake + * and stays pinned to that era for its lifetime. */ era: 'legacy' | 'modern'; /** @@ -101,7 +110,7 @@ export interface McpRequestContext { */ export type McpServerFactory = (ctx: McpRequestContext) => McpServer | Server | Promise; -/** Caller-provided per-request inputs for {@linkcode McpHttpHandler.fetch} and legacy slot handlers. */ +/** Caller-provided per-request inputs for {@linkcode McpHttpHandler.fetch} and fetch-shaped legacy handlers ({@linkcode LegacyHttpHandler}). */ export interface McpHandlerRequestOptions { /** * Validated authentication information for the request. Strictly @@ -114,9 +123,11 @@ export interface McpHandlerRequestOptions { } /** - * A fetch-shaped handler serving 2025-era traffic for the `legacy` slot: - * receives the original request untouched (plus the caller-provided - * pass-through options) and produces the HTTP response. + * A fetch-shaped handler serving 2025-era traffic: the shape produced by + * {@linkcode legacyStatelessFallback}, and the shape a hand-wired composition + * routes legacy requests to (see {@linkcode isLegacyRequest}). It is not a + * `legacy` option value — the entry's own legacy serving is selected by the + * `'stateless' | 'reject'` posture only. */ export type LegacyHttpHandler = (request: Request, options?: McpHandlerRequestOptions) => Promise; @@ -125,19 +136,26 @@ export interface CreateMcpHandlerOptions { /** * How 2025-era (non-envelope) traffic is served: * - * - omitted — modern-only strict: legacy-classified requests are rejected - * with the unsupported-protocol-version error naming the endpoint's - * supported revisions (legacy-classified notifications are acknowledged - * with `202` and dropped). **There is no silent 2025 serving.** - * - `'stateless'` — serve legacy traffic with the per-request stateless - * idiom (a fresh instance from the same factory and a streamable HTTP - * transport constructed with only `sessionIdGenerator: undefined`). - * Equivalent to passing {@linkcode legacyStatelessFallback | legacyStatelessFallback(factory)}. - * - a handler — bring your own legacy serving (for example an existing - * sessionful streamable HTTP wiring); requests are handed to it - * byte-untouched and its lifecycle stays yours. + * - `'stateless'` (the default, also when the option is omitted) — + * old-school stateless serving: each legacy request is answered by a + * fresh instance from the same factory over a streamable HTTP transport + * constructed with only `sessionIdGenerator: undefined` (the established + * stateless idiom). Because serving is per-request and stateless, GET and + * DELETE (2025 session operations) are answered with `405` / + * `Method not allowed.`. + * - `'reject'` — modern-only strict: legacy-classified requests are + * rejected with the unsupported-protocol-version error naming the + * endpoint's supported revisions (legacy-classified notifications are + * acknowledged with `202` and dropped). **There is no 2025 serving in + * this mode.** + * + * There is no handler-valued option: to keep an existing legacy deployment + * (for example a sessionful streamable HTTP wiring) serving 2025 traffic + * next to this entry, route in user land with {@linkcode isLegacyRequest} + * in front of a `legacy: 'reject'` handler — see that predicate's + * documentation for the pattern. */ - legacy?: 'stateless' | LegacyHttpHandler; + legacy?: 'stateless' | 'reject'; /** Callback for out-of-band errors and rejected requests (reporting only; it never alters the response). */ onerror?: (error: Error) => void; /** @@ -194,8 +212,8 @@ export interface McpHttpHandler { /** * Tears down the modern leg: aborts in-flight modern exchanges and closes * their per-request instances. Legacy serving is unaffected — the - * `'stateless'` slot is per-request by construction, and a bring-your-own - * legacy handler's lifecycle stays with its owner. + * stateless fallback is per-request by construction and holds nothing + * between exchanges. */ close: () => Promise; } @@ -257,25 +275,27 @@ function internalServerErrorResponse(id: RequestId | null = null): Response { } /* ------------------------------------------------------------------------ * - * The canonical legacy slot value + * The default legacy fallback * ------------------------------------------------------------------------ */ /** - * The canonical `legacy` slot value: per-request stateless serving of 2025-era - * traffic using the same factory as the modern path. + * The entry's default legacy serving (`legacy: 'stateless'`): per-request + * stateless serving of 2025-era traffic using the same factory as the modern + * path. Exported as a standalone building block for hand-wired compositions + * (for example mounting legacy stateless serving on its own route next to a + * strict modern endpoint). * * Each POST is served by a fresh instance from the factory connected to a * fresh streamable HTTP transport constructed with only * `sessionIdGenerator: undefined` — the established stateless idiom, unchanged. * Because serving is per-request and stateless, GET and DELETE (2025 session * operations) are answered with `405` / `Method not allowed.`, exactly like the - * canonical stateless example. `createMcpHandler(factory, { legacy: 'stateless' })` - * is shorthand for passing `legacyStatelessFallback(factory)` here explicitly. + * canonical stateless example. * * The optional `onerror` callback receives factory and serving failures on * this leg (reporting only — the response stays the 500 internal-error body). - * The entry passes its own `onerror` here when expanding `legacy: 'stateless'`, - * so legacy-leg failures are never silently swallowed. + * The entry passes its own `onerror` here when expanding the default, so + * legacy-leg failures are never silently swallowed. */ export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (error: Error) => void): LegacyHttpHandler { return async (request, options) => { @@ -361,14 +381,144 @@ export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (er }; } +/* ------------------------------------------------------------------------ * + * The entry's classification step (shared with isLegacyRequest) + * ------------------------------------------------------------------------ */ + +/** The outcome of the entry's classification step for one inbound HTTP request. */ +type EntryClassification = + /** The body bytes could not be read at all (a failing stream, not malformed JSON). */ + | { step: 'unreadable-body' } + /** A POST with an empty or non-JSON body: nothing to classify, so there is no envelope claim. */ + | { step: 'no-json-body'; forwardRequest: Request } + /** A classifiable request, with the classifier's routing outcome. */ + | { step: 'classified'; outcome: InboundClassificationOutcome; body: unknown; parsedBody: unknown; forwardRequest: Request }; + +/** + * The entry's classification step: read the request body exactly once (unless + * a pre-parsed body is supplied) and classify the request with + * {@linkcode classifyInboundRequest}. This is the single code path behind both + * {@linkcode createMcpHandler}'s routing and the exported + * {@linkcode isLegacyRequest} predicate, so the two can never disagree. + */ +async function classifyEntryRequest(request: Request, providedParsedBody?: unknown): Promise { + const httpMethod = request.method.toUpperCase(); + + let body: unknown; + let parsedBody = providedParsedBody; + let forwardRequest = request; + let unparseable = false; + + if (httpMethod === 'POST') { + if (parsedBody === undefined) { + // Read the body exactly once for classification, keeping an unread + // copy of the original bytes for the legacy leg (web-standard + // request bodies are single-use). + forwardRequest = request.clone(); + let bodyText: string; + try { + bodyText = await request.text(); + } catch { + return { step: 'unreadable-body' }; + } + try { + body = bodyText.length === 0 ? undefined : JSON.parse(bodyText); + } catch { + unparseable = true; + } + if (!unparseable && body !== undefined) { + parsedBody = body; + } + } else { + body = parsedBody; + } + + if (unparseable || body === undefined) { + return { step: 'no-json-body', forwardRequest }; + } + } + + const outcome = classifyInboundRequest({ + httpMethod, + protocolVersionHeader: request.headers.get('mcp-protocol-version') ?? undefined, + mcpMethodHeader: request.headers.get('mcp-method') ?? undefined, + ...(body !== undefined && { body }) + }); + return { step: 'classified', outcome, body, parsedBody, forwardRequest }; +} + +/** + * Whether {@linkcode createMcpHandler} would route this request to its legacy + * (2025-era) serving rather than the modern (2026-07-28) path. + * + * This is the entry's own classification step exported as a predicate — it + * runs exactly the code `createMcpHandler` runs to make the routing decision, + * not a re-implementation — so a hand-wired composition that branches on it + * can never disagree with the entry. Use it to keep an existing legacy + * deployment (for example a sessionful streamable HTTP wiring) serving 2025 + * traffic next to a strict modern endpoint, now that the entry has no + * handler-valued `legacy` option: + * + * ```ts + * import { createMcpHandler, isLegacyRequest } from '@modelcontextprotocol/server'; + * + * const modern = createMcpHandler(factory, { legacy: 'reject' }); + * + * export default { + * async fetch(request: Request): Promise { + * if (await isLegacyRequest(request)) { + * // e.g. an existing sessionful WebStandardStreamableHTTPServerTransport wiring + * return myExistingLegacyHandler(request); + * } + * return modern.fetch(request); + * } + * }; + * ``` + * + * Semantics (identical to the entry's routing): + * + * - Returns `true` only for requests with no per-request `_meta` envelope + * claim: claim-less POSTs (including the `initialize` handshake and 2025-era + * notification POSTs without a modern protocol-version header), body-less + * GET/DELETE session operations, all-legacy JSON-RPC batch arrays, posted + * JSON-RPC responses, and POSTs whose body is empty or not valid JSON. + * - Returns `false` for everything the modern path answers, including its + * validation-ladder rejections: a request carrying the envelope claim (even + * one naming a revision the endpoint does not serve — the modern path + * answers it with the unsupported-protocol-version error), a malformed + * envelope behind a present claim (answered `-32602`), a request whose + * `MCP-Protocol-Version` header names a modern revision but that lacks the + * envelope (`-32602`), and header/body mismatches (`-32001`). Consumers + * routing on the predicate must send `false` traffic to the modern handler, + * never to a legacy handler — the modern path owns those error answers. + * - `server/discover` probes sent by negotiating clients always carry the + * envelope claim, so they are never legacy; a hand-built claim-less POST to + * a method named `server/discover` has no claim and classifies legacy, + * exactly as the entry itself routes it. + * + * The body is read from a clone, so the passed request stays readable for + * whichever handler the caller routes it to. If the body has already been + * consumed (for example behind `express.json()`), pass the parsed body as the + * second argument and no body read happens at all. + */ +export async function isLegacyRequest(request: Request, parsedBody?: unknown): Promise { + // Classify a clone so the caller's request body stays readable; with a + // pre-parsed body (or a body-less method) nothing is read and no clone is + // needed. + const probe = parsedBody === undefined && request.method.toUpperCase() === 'POST' ? request.clone() : request; + const classified = await classifyEntryRequest(probe, parsedBody); + return classified.step === 'no-json-body' || (classified.step === 'classified' && classified.outcome.kind === 'legacy'); +} + /* ------------------------------------------------------------------------ * * The entry * ------------------------------------------------------------------------ */ /** * Creates an HTTP handler that serves the 2026-07-28 protocol revision from a - * per-request server factory, with 2025-era serving available through the - * opt-in `legacy` slot. + * per-request server factory and, by default, falls back to old-school + * stateless serving for 2025-era traffic. Pass `legacy: 'reject'` for a + * modern-only strict endpoint. * * Mounting: `handler.fetch` is the web-standard face (Cloudflare Workers, * Deno, Bun, Hono's `c.req.raw`); `handler.node(req, res, req.body)` is the @@ -390,12 +540,15 @@ export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (er * ``` * * Use ONE factory for both legs: the same tools/resources/prompts definition - * backs the modern path and the `legacy: 'stateless'` slot, so the two eras - * can never drift apart. Power users who want to compose the routing - * themselves (for example to mount the modern path and an existing legacy - * deployment on different routes) can use the exported building blocks - * directly: {@linkcode classifyInboundRequest} for the era decision and - * `PerRequestHTTPServerTransport` for single-exchange serving. + * backs the modern path and the stateless legacy fallback, so the two eras can + * never drift apart. To keep an existing legacy deployment (for example a + * sessionful streamable HTTP wiring) serving 2025 traffic instead of the + * stateless fallback, route in user land with {@linkcode isLegacyRequest} in + * front of a strict handler — see that predicate's documentation for the + * pattern. Power users composing transport-neutral routing can also use the + * exported building blocks directly: {@linkcode classifyInboundRequest} for + * the era decision and `PerRequestHTTPServerTransport` for single-exchange + * serving. * * The entry performs no token verification: `authInfo` given to the faces is * passed through to handlers and the factory as-is and is never derived from @@ -417,7 +570,9 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa } }; - const legacyHandler: LegacyHttpHandler | undefined = legacy === 'stateless' ? legacyStatelessFallback(factory, reportError) : legacy; + // The default posture is the stateless fallback; 'reject' is the only way + // to turn legacy serving off (modern-only strict). + const legacyHandler: LegacyHttpHandler | undefined = legacy === 'reject' ? undefined : legacyStatelessFallback(factory, reportError); async function serveModern( route: InboundModernRoute, @@ -561,57 +716,24 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa } async function handle(request: Request, requestOptions?: McpHandlerRequestOptions): Promise { - const httpMethod = request.method.toUpperCase(); const authInfo = requestOptions?.authInfo; + const classified = await classifyEntryRequest(request, requestOptions?.parsedBody); - let body: unknown; - let parsedBody = requestOptions?.parsedBody; - let forwardRequest = request; - let unparseable = false; - - if (httpMethod === 'POST') { - if (parsedBody === undefined) { - // Read the body exactly once for classification, keeping an - // unread copy of the original bytes for the legacy slot - // (web-standard request bodies are single-use). - forwardRequest = request.clone(); - let bodyText: string; - try { - bodyText = await request.text(); - } catch { - return jsonRpcErrorResponse(400, -32_700, 'Parse error: the request body could not be read'); - } - try { - body = bodyText.length === 0 ? undefined : JSON.parse(bodyText); - } catch { - unparseable = true; - } - if (!unparseable && body !== undefined) { - parsedBody = body; - } - } else { - body = parsedBody; - } - - if (unparseable || body === undefined) { - // No JSON body to classify: there is no envelope claim, so this - // is legacy traffic when a slot is configured (the legacy leg - // answers its own parse error, unchanged), and a parse error - // otherwise. - if (legacyHandler !== undefined) { - return legacyHandler(forwardRequest, { ...(authInfo !== undefined && { authInfo }) }); - } - return jsonRpcErrorResponse(400, -32_700, 'Parse error: the request body is not valid JSON'); + if (classified.step === 'unreadable-body') { + return jsonRpcErrorResponse(400, -32_700, 'Parse error: the request body could not be read'); + } + if (classified.step === 'no-json-body') { + // No JSON body to classify: there is no envelope claim, so this is + // legacy traffic when legacy serving is configured (the legacy leg + // answers its own parse error, unchanged), and a parse error + // otherwise. + if (legacyHandler !== undefined) { + return legacyHandler(classified.forwardRequest, { ...(authInfo !== undefined && { authInfo }) }); } + return jsonRpcErrorResponse(400, -32_700, 'Parse error: the request body is not valid JSON'); } - const outcome = classifyInboundRequest({ - httpMethod, - protocolVersionHeader: request.headers.get('mcp-protocol-version') ?? undefined, - mcpMethodHeader: request.headers.get('mcp-method') ?? undefined, - ...(body !== undefined && { body }) - }); - + const { outcome, body, parsedBody, forwardRequest } = classified; try { switch (outcome.kind) { case 'reject': { @@ -627,9 +749,9 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa } } catch (error) { // Entry-internal failure while serving a classified request (a - // throwing factory, a failed connect, a throwing bring-your-own - // legacy handler): the parsed body is in scope here, so the 500 - // body echoes the request id when it could be read. + // throwing factory or a failed connect, on either leg): the parsed + // body is in scope here, so the 500 body echoes the request id when + // it could be read. reportError(toError(error)); return internalServerErrorResponse(echoableRequestId(body)); } @@ -777,10 +899,10 @@ async function nodeRequestToFetchRequest(req: NodeIncomingMessageLike, parsedBod // The caller already consumed and parsed the Node stream (the // documented `handler.node(req, res, req.body)` mounting behind // `express.json()`), so the bytes cannot be re-read. Re-serialize - // the parsed value so consumers of the forwarded Request — a - // bring-your-own legacy handler reading `request.json()`/`text()` - // in particular — still receive the body, and replace the entity - // headers that described the original raw bytes. + // the parsed value so consumers of the forwarded Request — anything + // on the legacy leg reading `request.json()`/`text()` instead of + // the pass-through parsedBody — still receive the body, and replace + // the entity headers that described the original raw bytes. const serialized: string | undefined = JSON.stringify(parsedBody); headers.delete('content-encoding'); headers.delete('transfer-encoding'); From c1f8b5fdfdd02514b94e3db993c78a1c6c086b32 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 17 Jun 2026 19:44:37 +0000 Subject: [PATCH 2/8] test(server): cover the stateless legacy default, the strict posture, and isLegacyRequest routing Strict-mode rejection cells now construct the handler with legacy: 'reject' explicitly; the legacy-serving cells run against the default (with one explicit 'stateless' equivalence cell). The bring-your-own-handler cells are re-expressed through the documented isLegacyRequest routing pattern, plus predicate semantics tests (claim-less vs malformed-claim traffic, body stays readable, pre-parsed body support). --- .../test/server/createMcpHandler.test.ts | 235 +++++++++++++----- .../server/legacyStatelessFallback.test.ts | 2 +- 2 files changed, 173 insertions(+), 64 deletions(-) diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index a07df6f264..f46e4e513a 100644 --- a/packages/server/test/server/createMcpHandler.test.ts +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -1,10 +1,11 @@ /** - * createMcpHandler: the slot-model HTTP entry. + * createMcpHandler: the dual-era HTTP entry. * - * Covers the three slot states (omitted → modern-only strict, 'stateless' → - * per-request legacy sugar, handler → bring-your-own), the handler faces, the - * per-request era write + client-identity backfill, notification routing, the - * response-mode knob, and close() teardown of the modern leg. + * Covers the two legacy postures ('stateless' — the default — and 'reject' → + * modern-only strict), the isLegacyRequest predicate and the user-land routing + * pattern that replaces the removed handler-valued legacy option, the handler + * faces, the per-request era write + client-identity backfill, notification + * routing, the response-mode knob, and close() teardown of the modern leg. */ import { Readable } from 'node:stream'; @@ -13,7 +14,7 @@ import { describe, expect, it, vi } from 'vitest'; import * as z from 'zod/v4'; import type { McpRequestContext, NodeServerResponseLike } from '../../src/server/createMcpHandler.js'; -import { createMcpHandler } from '../../src/server/createMcpHandler.js'; +import { createMcpHandler, isLegacyRequest } from '../../src/server/createMcpHandler.js'; import { McpServer } from '../../src/server/mcp.js'; import { PerRequestHTTPServerTransport } from '../../src/server/perRequestTransport.js'; @@ -298,11 +299,11 @@ describe('createMcpHandler — modern path', () => { }); }); -describe('createMcpHandler — modern-only strict (legacy slot omitted)', () => { +describe("createMcpHandler — modern-only strict (legacy: 'reject')", () => { it('rejects envelope-less requests with the unsupported-protocol-version error and the supported list', async () => { const { factory, state } = testFactory(); const onerror = vi.fn(); - const handler = createMcpHandler(factory, { onerror }); + const handler = createMcpHandler(factory, { legacy: 'reject', onerror }); const response = await handler.fetch( postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'echo', arguments: { text: 'x' } } }) @@ -318,7 +319,7 @@ describe('createMcpHandler — modern-only strict (legacy slot omitted)', () => it('rejects an envelope-less initialize naming the supported and requested versions', async () => { const { factory } = testFactory(); - const handler = createMcpHandler(factory); + const handler = createMcpHandler(factory, { legacy: 'reject' }); const response = await handler.fetch( postRequest({ @@ -338,7 +339,7 @@ describe('createMcpHandler — modern-only strict (legacy slot omitted)', () => it('answers GET and DELETE with 405 Method not allowed', async () => { const { factory } = testFactory(); - const handler = createMcpHandler(factory); + const handler = createMcpHandler(factory, { legacy: 'reject' }); for (const method of ['GET', 'DELETE']) { const response = await handler.fetch(new Request('http://localhost/mcp', { method })); @@ -353,7 +354,7 @@ describe('createMcpHandler — modern-only strict (legacy slot omitted)', () => it('rejects batch and response-body POSTs as invalid requests', async () => { const { factory } = testFactory(); - const handler = createMcpHandler(factory); + const handler = createMcpHandler(factory, { legacy: 'reject' }); const batch = await handler.fetch(postRequest([{ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }])); expect(batch.status).toBe(400); @@ -372,7 +373,7 @@ describe('createMcpHandler — modern-only strict (legacy slot omitted)', () => it('answers unparseable JSON with a parse error', async () => { const { factory } = testFactory(); - const handler = createMcpHandler(factory); + const handler = createMcpHandler(factory, { legacy: 'reject' }); const response = await handler.fetch(postRequest('{not json')); expect(response.status).toBe(400); @@ -384,7 +385,7 @@ describe('createMcpHandler — modern-only strict (legacy slot omitted)', () => it('acknowledges and drops legacy-classified notifications (202, never dispatched)', async () => { const { factory, state } = testFactory(); - const handler = createMcpHandler(factory); + const handler = createMcpHandler(factory, { legacy: 'reject' }); const response = await handler.fetch( postRequest({ jsonrpc: '2.0', method: 'notifications/initialized' }, { 'mcp-method': 'something/else' }) @@ -398,7 +399,7 @@ describe('createMcpHandler — modern-only strict (legacy slot omitted)', () => it('routes a notification POST by the modern header when the body carries no claim', async () => { const { factory, state } = testFactory(); - const handler = createMcpHandler(factory); + const handler = createMcpHandler(factory, { legacy: 'reject' }); const response = await handler.fetch( postRequest( @@ -413,7 +414,7 @@ describe('createMcpHandler — modern-only strict (legacy slot omitted)', () => it('names the modern revisions in the strict rejection data so legacy clients can discover the endpoint era', async () => { const { factory } = testFactory(); - const handler = createMcpHandler(factory); + const handler = createMcpHandler(factory, { legacy: 'reject' }); const response = await handler.fetch(postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} })); const body = (await response.json()) as JSONRPCErrorBody; // The strict rejection deliberately names the modern revisions so a legacy @@ -422,10 +423,10 @@ describe('createMcpHandler — modern-only strict (legacy slot omitted)', () => }); }); -describe('createMcpHandler — legacy: "stateless" sugar', () => { - it('serves a 2025-era client through the frozen stateless idiom with a fresh instance per request', async () => { +describe('createMcpHandler — stateless legacy fallback (the default)', () => { + it('serves a 2025-era client by default through the frozen stateless idiom with a fresh instance per request', async () => { const { factory, state } = testFactory(); - const handler = createMcpHandler(factory, { legacy: 'stateless' }); + const handler = createMcpHandler(factory); const initialize = await handler.fetch( postRequest({ @@ -451,9 +452,26 @@ describe('createMcpHandler — legacy: "stateless" sugar', () => { expect(state.products[0]!.server.getNegotiatedProtocolVersion()).not.toBe(MODERN_REVISION); }); + it("serves the same legacy traffic when 'stateless' is passed explicitly (the explicit value of the default)", async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + const initialize = await handler.fetch( + postRequest({ + jsonrpc: '2.0', + id: 'init-2', + method: 'initialize', + params: { protocolVersion: '2025-11-25', clientInfo: { name: 'legacy-client', version: '1.0' }, capabilities: {} } + }) + ); + expect(initialize.status).toBe(200); + expect(await initialize.text()).toContain('"protocolVersion":"2025-11-25"'); + expect(state.contexts[0]?.era).toBe('legacy'); + }); + it('answers GET and DELETE like the canonical stateless example (405, Method not allowed.)', async () => { const { factory } = testFactory(); - const handler = createMcpHandler(factory, { legacy: 'stateless' }); + const handler = createMcpHandler(factory); for (const method of ['GET', 'DELETE']) { const response = await handler.fetch(new Request('http://localhost/mcp', { method })); @@ -466,7 +484,7 @@ describe('createMcpHandler — legacy: "stateless" sugar', () => { it('routes legacy notification POSTs to the legacy leg (202 acknowledged by the stateless transport)', async () => { const { factory, state } = testFactory(); - const handler = createMcpHandler(factory, { legacy: 'stateless' }); + const handler = createMcpHandler(factory); const response = await handler.fetch(postRequest({ jsonrpc: '2.0', method: 'notifications/initialized' })); expect(response.status).toBe(202); @@ -476,7 +494,7 @@ describe('createMcpHandler — legacy: "stateless" sugar', () => { it('routes all-legacy batch arrays to the legacy leg unchanged', async () => { const { factory } = testFactory(); - const handler = createMcpHandler(factory, { legacy: 'stateless' }); + const handler = createMcpHandler(factory); const response = await handler.fetch( postRequest([ @@ -489,7 +507,7 @@ describe('createMcpHandler — legacy: "stateless" sugar', () => { it('hands unparseable bodies to the legacy leg so the parse error stays the legacy transport answer', async () => { const { factory } = testFactory(); - const handler = createMcpHandler(factory, { legacy: 'stateless' }); + const handler = createMcpHandler(factory); const response = await handler.fetch(postRequest('{not json')); expect(response.status).toBe(400); @@ -499,7 +517,7 @@ describe('createMcpHandler — legacy: "stateless" sugar', () => { it('still serves the modern path on the same endpoint (one factory, both legs)', async () => { const { factory, state } = testFactory(); - const handler = createMcpHandler(factory, { legacy: 'stateless' }); + const handler = createMcpHandler(factory); const modern = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'modern hello' }))); expect(modern.status).toBe(200); @@ -507,7 +525,7 @@ describe('createMcpHandler — legacy: "stateless" sugar', () => { expect(state.contexts[0]?.era).toBe('modern'); }); - it("reports legacy: 'stateless' leg failures through the entry's onerror instead of swallowing them", async () => { + it("reports legacy-leg failures through the entry's onerror instead of swallowing them", async () => { const onerror = vi.fn(); const handler = createMcpHandler( ctx => { @@ -516,7 +534,7 @@ describe('createMcpHandler — legacy: "stateless" sugar', () => { } return new McpServer({ name: 'modern-only-product', version: '1.0.0' }); }, - { legacy: 'stateless', onerror } + { onerror } ); const response = await handler.fetch(postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} })); @@ -525,9 +543,9 @@ describe('createMcpHandler — legacy: "stateless" sugar', () => { expect(onerror).toHaveBeenCalledWith(expect.objectContaining({ message: 'legacy factory exploded' })); }); - it('keeps classifier rejections authoritative on the dual arm (pins the current -32600 cells with a slot configured)', async () => { + it('keeps classifier rejections authoritative on the dual arm (pins the current -32600 cells with the fallback active)', async () => { const { factory, state } = testFactory(); - const handler = createMcpHandler(factory, { legacy: 'stateless' }); + const handler = createMcpHandler(factory); // Parsed-but-not-JSON-RPC single object: the entry's -32600, not the // legacy transport's -32700. @@ -551,7 +569,7 @@ describe('createMcpHandler — legacy: "stateless" sugar', () => { it('answers a legacy-direction server/discover with a plain method-not-found and zero 2026 vocabulary', async () => { const { factory } = testFactory(); - const handler = createMcpHandler(factory, { legacy: 'stateless' }); + const handler = createMcpHandler(factory); const response = await handler.fetch(postRequest({ jsonrpc: '2.0', id: 4, method: 'server/discover', params: {} })); expect(response.status).toBe(200); @@ -562,34 +580,135 @@ describe('createMcpHandler — legacy: "stateless" sugar', () => { }); }); -describe('createMcpHandler — legacy: bring-your-own handler', () => { - it('hands legacy-classified requests to the handler with the original bytes untouched', async () => { +describe('createMcpHandler — user-land routing with isLegacyRequest (replaces the handler-valued legacy option)', () => { + it('routes legacy traffic to an existing handler with the original bytes untouched, alongside a strict modern entry', async () => { const { factory, state } = testFactory(); const original = { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }; let receivedBody: string | undefined; - let receivedParsedBody: unknown; - const byo = vi.fn(async (request: Request, options?: { parsedBody?: unknown }) => { + const existingLegacyHandler = vi.fn(async (request: Request) => { receivedBody = await request.text(); - receivedParsedBody = options?.parsedBody; - return new Response('byo-served', { status: 299 }); + return new Response('legacy-served', { status: 299 }); }); - const handler = createMcpHandler(factory, { legacy: byo }); + const modern = createMcpHandler(factory, { legacy: 'reject' }); + // The documented routing pattern: the predicate decides, the strict + // entry serves everything that is not legacy. + const route = async (request: Request): Promise => { + if (await isLegacyRequest(request)) { + return existingLegacyHandler(request); + } + return modern.fetch(request); + }; - const response = await handler.fetch(postRequest(original)); + // A claim-less 2025 request reaches the existing handler with its body + // still readable — the predicate classifies a clone, never the original. + const response = await route(postRequest(original)); expect(response.status).toBe(299); - expect(await response.text()).toBe('byo-served'); + expect(await response.text()).toBe('legacy-served'); expect(receivedBody).toBe(JSON.stringify(original)); - expect(receivedParsedBody).toEqual(original); - // GET/DELETE are method-routed to the handler too (sessionful BYO wirings own them). - const get = await handler.fetch(new Request('http://localhost/mcp', { method: 'GET' })); + // GET/DELETE are method-routed to the existing handler too (sessionful wirings own them). + const get = await route(new Request('http://localhost/mcp', { method: 'GET' })); expect(get.status).toBe(299); - // Modern envelope traffic never reaches the legacy slot. - const modern = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'hi' }))); - expect(modern.status).toBe(200); - expect(byo).toHaveBeenCalledTimes(2); + // Modern envelope traffic never reaches the legacy handler. + const modernResponse = await route(postRequest(modernToolsCall('echo', { text: 'hi' }))); + expect(modernResponse.status).toBe(200); + expect(existingLegacyHandler).toHaveBeenCalledTimes(2); expect(state.contexts.filter(ctx => ctx.era === 'modern')).toHaveLength(1); + + // A malformed modern claim is NOT legacy: it goes to the modern entry, + // which answers the validation-ladder error (-32602), never the legacy handler. + const malformed = await route( + postRequest(modernToolsCall('echo', { text: 'x' }, { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION })) + ); + expect(malformed.status).toBe(400); + expect(((await malformed.json()) as JSONRPCErrorBody).error.code).toBe(-32_602); + expect(existingLegacyHandler).toHaveBeenCalledTimes(2); + }); + + it('isLegacyRequest agrees with the entry classification rung across the routing cells', async () => { + const legacyShaped: Array<{ name: string; request: () => Request }> = [ + { + name: 'claim-less request', + request: () => postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }) + }, + { + name: 'initialize handshake', + request: () => + postRequest({ + jsonrpc: '2.0', + id: 'init-1', + method: 'initialize', + params: { protocolVersion: '2025-11-25', clientInfo: { name: 'legacy', version: '1.0' }, capabilities: {} } + }) + }, + { name: 'claim-less notification', request: () => postRequest({ jsonrpc: '2.0', method: 'notifications/initialized' }) }, + { name: 'GET session operation', request: () => new Request('http://localhost/mcp', { method: 'GET' }) }, + { name: 'DELETE session operation', request: () => new Request('http://localhost/mcp', { method: 'DELETE' }) }, + { + name: 'all-legacy batch array', + request: () => postRequest([{ jsonrpc: '2.0', method: 'notifications/initialized' }]) + }, + { name: 'posted JSON-RPC response', request: () => postRequest({ jsonrpc: '2.0', id: 9, result: { ok: true } }) }, + { name: 'unparseable body', request: () => postRequest('{not json') }, + { + name: 'claim-less server/discover (no envelope, classified like any other claim-less request)', + request: () => postRequest({ jsonrpc: '2.0', id: 4, method: 'server/discover', params: {} }) + } + ]; + const modernShaped: Array<{ name: string; request: () => Request }> = [ + { name: 'valid modern envelope', request: () => postRequest(modernToolsCall('echo', { text: 'x' })) }, + { + name: 'enveloped server/discover probe', + request: () => postRequest({ jsonrpc: '2.0', id: 5, method: 'server/discover', params: { _meta: ENVELOPE } }) + }, + { + name: 'envelope claiming an unsupported revision (modern path answers -32004)', + request: () => + postRequest(modernToolsCall('echo', { text: 'x' }, { ...ENVELOPE, [PROTOCOL_VERSION_META_KEY]: '2030-01-01' })) + }, + { + name: 'malformed envelope behind a present claim (modern path answers -32602)', + request: () => postRequest(modernToolsCall('echo', { text: 'x' }, { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION })) + }, + { + name: 'modern header without a claim (modern path answers -32602)', + request: () => + postRequest( + { jsonrpc: '2.0', id: 11, method: 'tools/list', params: {} }, + { 'mcp-protocol-version': MODERN_REVISION, 'mcp-method': 'tools/list' } + ) + }, + { + name: 'header/body mismatch (modern path answers -32001)', + request: () => postRequest(modernToolsCall('echo', { text: 'x' }), { 'mcp-protocol-version': '2025-11-25' }) + } + ]; + + for (const { name, request } of legacyShaped) { + expect(await isLegacyRequest(request()), name).toBe(true); + } + for (const { name, request } of modernShaped) { + expect(await isLegacyRequest(request()), name).toBe(false); + } + }); + + it('leaves the request body readable and accepts a pre-parsed body without reading the stream', async () => { + const original = { jsonrpc: '2.0', id: 7, method: 'tools/list', params: {} }; + + // Body stays readable after the predicate ran (it classified a clone). + const request = postRequest(original); + expect(await isLegacyRequest(request)).toBe(true); + expect(request.bodyUsed).toBe(false); + expect(await request.text()).toBe(JSON.stringify(original)); + + // With a pre-parsed body the request stream is never touched at all. + const preParsed = postRequest(original); + expect(await isLegacyRequest(preParsed, original)).toBe(true); + expect(preParsed.bodyUsed).toBe(false); + expect(await isLegacyRequest(postRequest(modernToolsCall('echo', { text: 'x' })), modernToolsCall('echo', { text: 'x' }))).toBe( + false + ); }); }); @@ -660,33 +779,23 @@ describe('createMcpHandler — handler faces', () => { expect(await body()).toContain('pre-parsed'); }); - it('synthesizes the forwarded body from a pre-parsed body so node-face BYO legacy handlers can read it', async () => { - const { factory } = testFactory(); - const legacyMessage = { jsonrpc: '2.0', id: 7, method: 'tools/list', params: {} }; - let receivedText: string | undefined; - let receivedContentLength: string | null = null; - let receivedTransferEncoding: string | null = null; - const byo = async (request: Request) => { - receivedText = await request.text(); - receivedContentLength = request.headers.get('content-length'); - receivedTransferEncoding = request.headers.get('transfer-encoding'); - return new Response('byo-node-served', { status: 200 }); - }; - const handler = createMcpHandler(factory, { legacy: byo }); + it('serves a pre-parsed legacy body through the node face on the default fallback (the documented express.json mounting)', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); // The documented Express mounting: express.json() consumed the stream // and hands the parsed object as the third argument; the raw headers // still describe the original (already-consumed) bytes. + const legacyMessage = { jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'echo', arguments: { text: 'node legacy' } } }; const { req, res, body } = nodeRequestResponse(undefined); req.headers['content-length'] = '999'; req.headers['transfer-encoding'] = 'chunked'; await handler.node(req, res, legacyMessage); expect(res.statusCode).toBe(200); - expect(await body()).toBe('byo-node-served'); - expect(receivedText).toBe(JSON.stringify(legacyMessage)); - expect(receivedContentLength).toBe(String(JSON.stringify(legacyMessage).length)); - expect(receivedTransferEncoding).toBeNull(); + expect(await body()).toContain('node legacy'); + expect(state.contexts).toHaveLength(1); + expect(state.contexts[0]?.era).toBe('legacy'); }); it('forwards req.auth from upstream middleware as pass-through authInfo on the node face', async () => { @@ -788,9 +897,9 @@ describe('createMcpHandler — close()', () => { await expect(handler.fetch(postRequest(modernToolsCall('echo', { text: 'late' })))).rejects.toThrow(/closed/); }); - it('leaves the legacy slot untouched by close() until the handler itself refuses requests', async () => { + it('leaves the legacy fallback untouched by close() until the handler itself refuses requests', async () => { const { factory } = testFactory(); - const handler = createMcpHandler(factory, { legacy: 'stateless' }); + const handler = createMcpHandler(factory); await handler.close(); await expect(handler.fetch(postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }))).rejects.toThrow(/closed/); }); diff --git a/packages/server/test/server/legacyStatelessFallback.test.ts b/packages/server/test/server/legacyStatelessFallback.test.ts index 8958a3516b..a125168c78 100644 --- a/packages/server/test/server/legacyStatelessFallback.test.ts +++ b/packages/server/test/server/legacyStatelessFallback.test.ts @@ -1,5 +1,5 @@ /** - * legacyStatelessFallback — the canonical `legacy` slot value, tested + * legacyStatelessFallback — the entry's default legacy serving, tested * independently of createMcpHandler: per-request stateless serving via the * frozen idiom (fresh instance + sessionIdGenerator: undefined + handleRequest). */ From ad2b4afbaf0d6e3bbe514f3dd717ddc85aeb7ee4 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 17 Jun 2026 19:44:37 +0000 Subject: [PATCH 3/8] test(e2e): host the strict entry arm explicitly and route the sessionful legacy cell through isLegacyRequest The entryModern harness arm now passes legacy: 'reject' (strict is no longer the entry default); entryStateless keeps passing 'stateless' explicitly so the arm stays era-pinned. The sessionful-legacy cell keeps its requirement id and lifecycle assertions but expresses them through the documented user-land routing pattern (isLegacyRequest in front of an existing sessionful wiring plus a strict entry), hosted by the test body. Integration coverage now exercises the stateless default and the explicit 'reject' posture over real HTTP. --- test/e2e/CLAUDE.md | 11 +- test/e2e/helpers/index.ts | 19 ++- test/e2e/requirements.ts | 10 +- .../scenarios/hosting-entry-session.test.ts | 142 +++++++++++------- test/e2e/scenarios/hosting-entry.test.ts | 23 +-- test/e2e/types.ts | 13 +- .../test/server/createMcpHandler.test.ts | 29 ++-- 7 files changed, 140 insertions(+), 107 deletions(-) diff --git a/test/e2e/CLAUDE.md b/test/e2e/CLAUDE.md index c72d8f2a6e..3e16961b60 100644 --- a/test/e2e/CLAUDE.md +++ b/test/e2e/CLAUDE.md @@ -62,9 +62,9 @@ entry points back via `supersededBy` (requires `removedInSpecVersion`). A covera Two transport arms host the dual-era HTTP entry (`createMcpHandler`) in process via an injected fetch, exactly like the other HTTP arms. They are era-fixed (`TRANSPORT_SPEC_VERSIONS`), so each registers cells on exactly one spec-version axis: -- `entryStateless` — the entry with the `legacy: 'stateless'` slot; the scenario's plain client is served per request through the slot. Cells run on the 2025-11-25 axis only. -- `entryModern` — the entry modern-only strict (no legacy slot); the scenario's client is put into pinned 2026-07-28 negotiation by the arm and the per-request `_meta` envelope is attached to every outgoing request/notification by the arm (a harness stop-gap until the client - emits it itself). Cells run on the 2026-07-28 axis only. +- `entryStateless` — the entry with its stateless legacy fallback (`legacy: 'stateless'`, the entry's default posture, passed explicitly so the arm stays era-pinned); the scenario's plain client is served per request through the fallback. Cells run on the 2025-11-25 axis only. +- `entryModern` — the entry hosted modern-only strict (`legacy: 'reject'`); the scenario's client is put into pinned 2026-07-28 negotiation by the arm and the per-request `_meta` envelope is attached to every outgoing request/notification by the arm (a harness stop-gap until the + client emits it itself). Cells run on the 2026-07-28 axis only. Both arms are part of the default transport list, so unrestricted requirements run through the entry automatically. When a requirement cannot run on an entry arm, annotate it with a machine-readable reason instead of bending the test: @@ -75,8 +75,9 @@ entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' Omitting `arm` excludes both arms. The reasons (`EntryExclusionReason` in types.ts) are the acceptance checklist for re-admitting cells when the corresponding entry feature lands; a coverage gate rejects annotations that would never have an effect. Requirement families that the per-request entry structurally cannot serve at all (server→client requests, sessions/resumability, standalone GET streams, subscriptions) are already expressed through their `transports` restrictions and need no annotation. -Arm-specific helpers: `wire()`'s fourth argument also accepts `entry` (createMcpHandler hosting overrides — e.g. a `responseMode` or a bring-your-own `legacy` slot value), the returned `Wired.httpLog` records every HTTP exchange (request body, status, content-type, a readable -response clone) for raw wire assertions, factories may accept the optional per-request context (`EntryServerFactory`), and `modernEnvelopeMeta()` builds the envelope for bodies that POST raw 2026-era requests through `wired.fetch`. +Arm-specific helpers: `wire()`'s fourth argument also accepts `entry` (createMcpHandler hosting overrides — e.g. a `responseMode` or a different `legacy` posture), the returned `Wired.httpLog` records every HTTP exchange (request body, status, content-type, a readable response +clone) for raw wire assertions, factories may accept the optional per-request context (`EntryServerFactory`), and `modernEnvelopeMeta()` builds the envelope for bodies that POST raw 2026-era requests through `wired.fetch`. Compositions that the entry no longer expresses through +an option (for example an existing sessionful legacy wiring routed via `isLegacyRequest` next to a strict entry) are hosted by the test body itself behind an in-process fetch — see `scenarios/hosting-entry-session.test.ts`. ## Running diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts index 4a5218b5f5..c79c27b3c4 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -89,9 +89,10 @@ export interface Wired extends AsyncDisposable { export interface WireOptions extends SnifferOptions { /** * createMcpHandler hosting overrides for the entry arms. Defaults: - * `{ legacy: 'stateless' }` on entryStateless (the canonical slot value) and - * modern-only strict (no legacy slot) on entryModern. `onerror` and - * `responseMode` pass through unchanged. + * `{ legacy: 'stateless' }` on entryStateless (the entry's default posture, + * passed explicitly so the arm stays pinned to the 2025 leg even if the + * default ever moves) and `{ legacy: 'reject' }` (modern-only strict) on + * entryModern. `onerror` and `responseMode` pass through unchanged. */ entry?: CreateMcpHandlerOptions; } @@ -136,13 +137,15 @@ export async function wire( // injected fetch, exactly like the other HTTP arms. The scenario factory // backs the entry directly (the entry calls it once per request with its // per-request context). `entryStateless` serves the scenario's plain - // client through the entry's `legacy: 'stateless'` slot; `entryModern` - // keeps the endpoint modern-only strict and connects the client on the - // 2026-07-28 revision (pin-mode negotiation + the per-request envelope - // stop-gap). Every HTTP exchange is recorded on `httpLog`. + // client through the entry's stateless legacy fallback (the default, + // passed explicitly to keep the arm era-pinned); `entryModern` hosts the + // endpoint modern-only strict (`legacy: 'reject'` — strict is no longer + // the entry default) and connects the client on the 2026-07-28 revision + // (pin-mode negotiation + the per-request envelope stop-gap). Every HTTP + // exchange is recorded on `httpLog`. const handler = createMcpHandler( makeServer, - transport === 'entryStateless' ? { legacy: 'stateless', ...sniff.entry } : { ...sniff.entry } + transport === 'entryStateless' ? { legacy: 'stateless', ...sniff.entry } : { legacy: 'reject', ...sniff.entry } ); const url = new URL('http://in-process/mcp'); const httpLog: RecordedHttpExchange[] = []; diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index b526e12749..d75c799c5f 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2282,15 +2282,15 @@ export const REQUIREMENTS: Record = { 'A client pinned to the 2026-07-28 revision (versionNegotiation mode pin) connects to a strict createMcpHandler endpoint without ever sending initialize — its first request is server/discover — and an enveloped tools/call round-trips.', transports: ['entryModern'], addedInSpecVersion: '2026-07-28', - note: 'Runs on the entryModern arm (modern-only strict is its default hosting); the body constructs the pinned client itself and asserts the never-initialize, discover-first and envelope clauses on the arm-recorded HTTP exchanges.' + note: "Runs on the entryModern arm (which hosts the entry strict via legacy: 'reject'; stateless legacy serving is the entry's own default); the body constructs the pinned client itself and asserts the never-initialize, discover-first and envelope clauses on the arm-recorded HTTP exchanges." }, 'typescript:hosting:entry:strict-rejects-legacy': { source: 'sdk', behavior: - 'A createMcpHandler endpoint with no legacy slot configured (modern-only strict) rejects a 2025-shaped initialize with the unsupported-protocol-version error carrying the supported modern revisions in error.data.supported; nothing is silently served on the 2025 era.', + "A createMcpHandler endpoint configured strict (legacy: 'reject') rejects a 2025-shaped initialize with the unsupported-protocol-version error carrying the supported modern revisions in error.data.supported; nothing is silently served on the 2025 era in that mode (stateless legacy serving is the entry's default and must be turned off explicitly).", transports: ['entryModern'], addedInSpecVersion: '2026-07-28', - note: 'Runs on the entryModern arm (modern-only strict is its default hosting); the 2025-shaped initialize and the plain-client connect attempt are driven against the harness-hosted endpoint via wired.fetch/wired.url. The numeric error code is asserted by message and supported-list shape only, since it shares a code with the still-disputed header/body mismatch family.' + note: "Runs on the entryModern arm (which hosts the entry strict via legacy: 'reject'); the 2025-shaped initialize and the plain-client connect attempt are driven against the harness-hosted endpoint via wired.fetch/wired.url. The numeric error code is asserted by message and supported-list shape only, since it shares a code with the still-disputed header/body mismatch family." }, 'typescript:hosting:entry:notification-202': { source: 'sdk', @@ -2318,10 +2318,10 @@ export const REQUIREMENTS: Record = { 'typescript:hosting:entry:byo-sessionful-legacy': { source: 'sdk', behavior: - 'A real sessionful legacy wiring (per-session WebStandardStreamableHTTPServerTransport instances keyed by Mcp-Session-Id) passed as the createMcpHandler legacy slot value serves the full 2025-era session lifecycle through the entry: initialize issues an Mcp-Session-Id, a follow-up POST is served on that session, GET opens the standalone SSE stream, and DELETE tears the session down (a request carrying the dead session id answers 404).', + "A real sessionful legacy wiring (per-session WebStandardStreamableHTTPServerTransport instances keyed by Mcp-Session-Id) keeps serving the full 2025-era session lifecycle alongside a strict (legacy: 'reject') createMcpHandler endpoint via explicit user-land routing on the exported isLegacyRequest predicate: initialize issues an Mcp-Session-Id, a follow-up POST is served on that session, GET opens the standalone SSE stream, and DELETE tears the session down (a request carrying the dead session id answers 404), while envelope-claiming traffic is answered by the strict modern entry and never reaches the legacy wiring.", transports: ['entryStateless'], removedInSpecVersion: '2026-07-28', - note: "The lifecycle is a statement about 2025-era serving through the bring-your-own legacy slot, so the requirement is bounded to the 2025-11-25 axis and runs on the entryStateless arm with the slot overridden via wire()'s entry.legacy option. It pins the entry routing of body-less GET and DELETE to the bring-your-own legacy slot, observed at the slot as method/status/content-type; byte-level forwarding fidelity is not asserted." + note: 'The lifecycle is a statement about 2025-era serving kept by an existing sessionful deployment, so the requirement is bounded to the 2025-11-25 axis (the entryStateless arm label). The handler-valued legacy option was removed from createMcpHandler, so the body hosts the documented replacement composition itself — isLegacyRequest in front of the existing wiring plus a strict entry — behind an in-process fetch instead of overriding the wire() arm. It pins the routing of body-less GET and DELETE to the legacy wiring, observed at the wiring as method/status/content-type; byte-level forwarding fidelity is not asserted.' }, 'typescript:hosting:entry:modern-lazy-sse-upgrade': { source: 'sdk', diff --git a/test/e2e/scenarios/hosting-entry-session.test.ts b/test/e2e/scenarios/hosting-entry-session.test.ts index 40d042104b..a41a1b8bbb 100644 --- a/test/e2e/scenarios/hosting-entry-session.test.ts +++ b/test/e2e/scenarios/hosting-entry-session.test.ts @@ -1,36 +1,42 @@ /** - * Sessionful 2025-era serving through the dual-era HTTP entry's - * bring-your-own legacy slot, exercised on the wire() entryStateless arm with - * the slot overridden via `wire()`'s `entry.legacy` option. + * Sessionful 2025-era serving kept alive next to a strict dual-era HTTP entry + * through explicit user-land routing: the exported `isLegacyRequest` predicate + * (the entry's own classification step) decides, an existing sessionful wiring + * serves the legacy branch, and a strict (`legacy: 'reject'`) `createMcpHandler` + * serves everything else. This is the documented replacement for the removed + * handler-valued `legacy` option. * - * The legacy slot value is a real sessionful wiring — one + * The legacy wiring is real and sessionful — one * WebStandardStreamableHTTPServerTransport per session, kept in a map keyed by * the Mcp-Session-Id the transport itself issues (the documented sessionful * hosting pattern) — and a plain 2025 SDK client drives the full session - * lifecycle through the harness-hosted `createMcpHandler`: initialize issues a - * session id, a follow-up POST is served on that session, the body-less GET - * opens the standalone SSE stream, and DELETE tears the session down. Every - * exchange the slot serves is recorded as it leaves the wiring (method, status, - * content-type), so the entry's routing of GET/DELETE (no envelope, no body → - * legacy slot) to the bring-your-own handler is pinned directly; byte-level - * forwarding fidelity is not asserted here. + * lifecycle through the routed composition: initialize issues a session id, a + * follow-up POST is served on that session, the body-less GET opens the + * standalone SSE stream, and DELETE tears the session down. Every exchange the + * wiring serves is recorded as it leaves it (method, status, content-type), so + * the predicate's routing of GET/DELETE (no envelope, no body → legacy) is + * pinned directly; byte-level forwarding fidelity is not asserted here. An + * envelope-claiming probe at the end pins that modern traffic is answered by + * the strict entry, never by the legacy wiring. + * + * The composition is hosted by the test body itself (an in-process fetch in + * front of both handlers), so the wire() entry arm is not used; the matrix + * still bounds the cell to the 2025-11-25 axis via the requirement entry. */ import { randomUUID } from 'node:crypto'; -import type { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import { Client } from '@modelcontextprotocol/client'; -import type { LegacyHttpHandler, McpHandlerRequestOptions, McpRequestContext } from '@modelcontextprotocol/server'; -import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import type { LegacyHttpHandler, McpRequestContext } from '@modelcontextprotocol/server'; +import { createMcpHandler, isLegacyRequest, McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; import { expect, vi } from 'vitest'; import { z } from 'zod/v4'; -import { wire } from '../helpers/index.js'; +import { modernEnvelopeMeta } from '../helpers/index.js'; import { verifies } from '../helpers/verifies.js'; -import type { TestArgs } from '../types.js'; const LEGACY = '2025-11-25'; -/** The factory backing the modern path; this cell never drives it (the lifecycle under test is the legacy slot's). */ +/** The factory backing the strict modern entry; legacy traffic never reaches it (the lifecycle under test is the legacy wiring's). */ function modernFactory(_ctx?: McpRequestContext): McpServer { const server = new McpServer({ name: 'e2e-entry-session', version: '1.0.0' }, { capabilities: { tools: {} } }); server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ @@ -39,19 +45,19 @@ function modernFactory(_ctx?: McpRequestContext): McpServer { return server; } -verifies('typescript:hosting:entry:byo-sessionful-legacy', async ({ transport }: TestArgs) => { - // The documented sessionful wiring, passed as the bring-your-own legacy - // slot value: a fresh transport per initialize, kept in a map keyed by the - // Mcp-Session-Id it issues; later requests are routed by that header. +verifies('typescript:hosting:entry:byo-sessionful-legacy', async () => { + // The documented sessionful wiring, kept exactly as an existing deployment + // would have it: a fresh transport per initialize, kept in a map keyed by + // the Mcp-Session-Id it issues; later requests are routed by that header. const sessions = new Map(); const closedSessions: string[] = []; const sessionServers: McpServer[] = []; - async function routeSessionRequest(request: Request, options?: McpHandlerRequestOptions): Promise { + async function routeSessionRequest(request: Request): Promise { const sessionId = request.headers.get('mcp-session-id'); if (sessionId !== null) { const existing = sessions.get(sessionId); - if (existing !== undefined) return existing.handleRequest(request, options); + if (existing !== undefined) return existing.handleRequest(request); // A request for a session this wiring no longer (or never) knew — // the documented sessionful pattern answers 404. return Response.json({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' }, id: null }, { status: 404 }); @@ -64,21 +70,21 @@ verifies('typescript:hosting:entry:byo-sessionful-legacy', async ({ transport }: sessions.delete(id); } }); - const server = new McpServer({ name: 'byo-session-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + const server = new McpServer({ name: 'sessionful-legacy-server', version: '1.0.0' }, { capabilities: { tools: {} } }); server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, ({ name }) => ({ - content: [{ type: 'text', text: `hello ${name} (byo session)` }] + content: [{ type: 'text', text: `hello ${name} (legacy session)` }] })); sessionServers.push(server); await server.connect(transport); - return transport.handleRequest(request, options); + return transport.handleRequest(request); } - // Every exchange the entry forwards to the bring-your-own slot, recorded - // as it leaves the wiring: this is what proves the GET/DELETE routing. - const slotExchanges: Array<{ method: string; status: number; contentType: string }> = []; - const sessionfulLegacy: LegacyHttpHandler = async (request, options) => { - const response = await routeSessionRequest(request, options); - slotExchanges.push({ + // Every exchange routed to the existing legacy wiring, recorded as it + // leaves the wiring: this is what proves the GET/DELETE routing. + const legacyExchanges: Array<{ method: string; status: number; contentType: string }> = []; + const sessionfulLegacy: LegacyHttpHandler = async request => { + const response = await routeSessionRequest(request); + legacyExchanges.push({ method: request.method.toUpperCase(), status: response.status, contentType: response.headers.get('content-type') ?? '' @@ -86,15 +92,25 @@ verifies('typescript:hosting:entry:byo-sessionful-legacy', async ({ transport }: return response; }; + // The documented user-land routing pattern: a strict modern entry plus the + // exported predicate in front of the existing legacy wiring. + const modern = createMcpHandler(modernFactory, { legacy: 'reject' }); + const route = async (request: Request): Promise => { + if (await isLegacyRequest(request)) { + return sessionfulLegacy(request); + } + return modern.fetch(request); + }; + const url = new URL('http://in-process/mcp'); + const fetchViaRouter = (input: URL | string, init?: RequestInit) => route(new Request(input, init)); + const client = new Client({ name: 'plain-2025-client', version: '1.0.0' }); try { - // The harness hosts the entry; the bring-your-own wiring replaces the - // arm's default 'stateless' slot value. - await using wired = await wire(transport, modernFactory, client, { entry: { legacy: sessionfulLegacy } }); + await client.connect(new StreamableHTTPClientTransport(url, { fetch: fetchViaRouter })); - // initialize → the bring-your-own transport issues an Mcp-Session-Id. - // (The stateless slot never issues one, so a defined session id alone - // proves the request reached the bring-your-own wiring.) + // initialize → the sessionful wiring issues an Mcp-Session-Id. (The + // strict entry never issues one, so a defined session id alone proves + // the request was routed to the existing legacy wiring.) expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY); const clientTransport = client.transport as StreamableHTTPClientTransport; const sessionId = clientTransport.sessionId; @@ -103,27 +119,27 @@ verifies('typescript:hosting:entry:byo-sessionful-legacy', async ({ transport }: // Follow-up POST on the session: served by the same per-session instance. const result = await client.callTool({ name: 'greet', arguments: { name: 'session friend' } }); - expect(result.content).toEqual([{ type: 'text', text: 'hello session friend (byo session)' }]); + expect(result.content).toEqual([{ type: 'text', text: 'hello session friend (legacy session)' }]); expect(clientTransport.sessionId).toBe(sessionId); // GET route: the client opens its standalone SSE stream after - // initialization; the entry routes the body-less GET (no envelope) to - // the legacy slot, which answers it with the stream. + // initialization; the predicate routes the body-less GET (no envelope) + // to the legacy wiring, which answers it with the stream. await vi.waitFor( () => { - const get = slotExchanges.find(exchange => exchange.method === 'GET'); - if (get === undefined) throw new Error('the standalone GET stream has not reached the legacy slot yet'); + const get = legacyExchanges.find(exchange => exchange.method === 'GET'); + if (get === undefined) throw new Error('the standalone GET stream has not reached the legacy wiring yet'); expect(get.status).toBe(200); expect(get.contentType).toContain('text/event-stream'); }, { timeout: 5000, interval: 50 } ); - // DELETE route: terminating the session goes through the entry to the - // bring-your-own transport, which tears the session down. + // DELETE route: terminating the session goes through the predicate to + // the sessionful wiring, which tears the session down. await clientTransport.terminateSession(); expect(closedSessions).toEqual([sessionId]); - const deleteExchange = slotExchanges.find(exchange => exchange.method === 'DELETE'); + const deleteExchange = legacyExchanges.find(exchange => exchange.method === 'DELETE'); expect(deleteExchange?.status).toBe(200); // Stop the client before probing the dead session so its standalone @@ -131,8 +147,8 @@ verifies('typescript:hosting:entry:byo-sessionful-legacy', async ({ transport }: await client.close(); // The dead session is gone: a POST carrying its id is answered 404 by - // the bring-your-own wiring, not silently re-served by anything else. - const stale = await wired.fetch!(wired.url!, { + // the sessionful wiring, not silently re-served by anything else. + const stale = await fetchViaRouter(url, { method: 'POST', headers: { 'content-type': 'application/json', @@ -144,11 +160,33 @@ verifies('typescript:hosting:entry:byo-sessionful-legacy', async ({ transport }: }); expect(stale.status).toBe(404); await stale.text(); - // ...and that 404 was produced by the bring-your-own wiring (the probe - // reached the slot), not synthesized by the entry or anything in front of it. - expect(slotExchanges.some(exchange => exchange.method === 'POST' && exchange.status === 404)).toBe(true); + // ...and that 404 was produced by the sessionful wiring (the probe + // reached it), not synthesized by the entry or anything in front of it. + expect(legacyExchanges.some(exchange => exchange.method === 'POST' && exchange.status === 404)).toBe(true); + + // Modern traffic is the strict entry's: an envelope-claiming request is + // answered by the modern factory and never reaches the legacy wiring. + const exchangesBeforeModernProbe = legacyExchanges.length; + const modernProbe = await fetchViaRouter(url, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 100, + method: 'tools/call', + params: { + name: 'greet', + arguments: { name: 'router' }, + _meta: modernEnvelopeMeta({ name: 'router-probe-client', version: '1.0.0' }) + } + }) + }); + expect(modernProbe.status).toBe(200); + expect(await modernProbe.text()).toContain('hello router (modern)'); + expect(legacyExchanges).toHaveLength(exchangesBeforeModernProbe); } finally { await client.close().catch(() => {}); + await modern.close().catch(() => {}); for (const server of sessionServers) await server.close().catch(() => {}); } }); diff --git a/test/e2e/scenarios/hosting-entry.test.ts b/test/e2e/scenarios/hosting-entry.test.ts index e5b3cf08d8..1958a59d0f 100644 --- a/test/e2e/scenarios/hosting-entry.test.ts +++ b/test/e2e/scenarios/hosting-entry.test.ts @@ -1,11 +1,12 @@ /** * Core cells for the dual-era HTTP entry (`createMcpHandler`), exercised - * through the wire() entry arms: `entryStateless` hosts the entry's - * `legacy: 'stateless'` slot for plain 2025-era clients (2025-11-25 axis) and - * `entryModern` hosts the modern-only strict endpoint for negotiating clients - * (2026-07-28 axis). Raw wire facts (request bodies, statuses, response bytes) - * are asserted on the arm-recorded `wired.httpLog`; raw HTTP probes go through - * `wired.fetch` so every exchange still rides the harness-hosted entry. + * through the wire() entry arms: `entryStateless` hosts the entry's stateless + * legacy fallback (the default posture) for plain 2025-era clients (2025-11-25 + * axis) and `entryModern` hosts the modern-only strict (`legacy: 'reject'`) + * endpoint for negotiating clients (2026-07-28 axis). Raw wire facts (request + * bodies, statuses, response bytes) are asserted on the arm-recorded + * `wired.httpLog`; raw HTTP probes go through `wired.fetch` so every exchange + * still rides the harness-hosted entry. */ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; @@ -31,8 +32,8 @@ function greetFactory(ctx?: McpRequestContext): McpServer { } verifies('typescript:hosting:entry:dual-era-one-factory', async ({ transport }: TestArgs) => { - // Both cells host the same handler shape — one ctx-taking factory, legacy - // 'stateless' slot configured — and differ only in the client driving it. + // Both cells host the same handler shape — one ctx-taking factory, the + // 'stateless' legacy posture — and differ only in the client driving it. const client = transport === 'entryModern' ? new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }) @@ -41,7 +42,7 @@ verifies('typescript:hosting:entry:dual-era-one-factory', async ({ transport }: if (transport === 'entryStateless') { // 2025-era leg: a plain client is served per request through the - // legacy 'stateless' slot — initialize → tools/list → tools/call. + // stateless legacy fallback — initialize → tools/list → tools/call. expect(client.getNegotiatedProtocolVersion()).toBe(LEGACY); const tools = await client.listTools(); expect(tools.tools.map(tool => tool.name)).toEqual(['greet']); @@ -68,7 +69,7 @@ verifies('typescript:hosting:entry:dual-era-one-factory', async ({ transport }: }); verifies('typescript:hosting:entry:pin-negotiation', async ({ transport }: TestArgs) => { - // Strict endpoint (no legacy slot — the entryModern arm default): the pinned client never needs one. + // Strict endpoint (legacy: 'reject' — the entryModern arm hosting): the pinned client never needs the legacy leg. const client = new Client({ name: 'pin-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); await using wired = await wire(transport, greetFactory, client); @@ -89,7 +90,7 @@ verifies('typescript:hosting:entry:pin-negotiation', async ({ transport }: TestA }); verifies('typescript:hosting:entry:strict-rejects-legacy', async ({ transport }: TestArgs) => { - // legacy omitted → modern-only strict (the entryModern arm default): no silent 2025 serving. + // legacy: 'reject' → modern-only strict (the entryModern arm hosting): no silent 2025 serving. const modernClient = new Client({ name: 'strict-modern-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: MODERN } } }); await using wired = await wire(transport, greetFactory, modernClient); diff --git a/test/e2e/types.ts b/test/e2e/types.ts index 8887f60322..cef6bd9ea8 100644 --- a/test/e2e/types.ts +++ b/test/e2e/types.ts @@ -15,11 +15,12 @@ export type Transport = (typeof ALL_TRANSPORTS)[number]; /** * The createMcpHandler entry arms: the dual-era HTTP entry hosted in process - * (injected fetch → `handler.fetch`), one arm per slot. `entryStateless` serves - * a plain 2025-era client through the entry's `legacy: 'stateless'` slot; - * `entryModern` serves a client that negotiates the 2026-07-28 revision through - * the entry's modern (per-request envelope) path. Each arm is era-fixed, so it - * registers cells on exactly one spec-version axis (see TRANSPORT_SPEC_VERSIONS). + * (injected fetch → `handler.fetch`), one arm per leg. `entryStateless` serves + * a plain 2025-era client through the entry's stateless legacy fallback (the + * default posture); `entryModern` serves a client that negotiates the + * 2026-07-28 revision through the entry's modern (per-request envelope) path. + * Each arm is era-fixed, so it registers cells on exactly one spec-version + * axis (see TRANSPORT_SPEC_VERSIONS). */ export const ENTRY_TRANSPORTS = ['entryStateless', 'entryModern'] as const satisfies readonly Transport[]; export type EntryTransport = (typeof ENTRY_TRANSPORTS)[number]; @@ -38,7 +39,7 @@ export const ALL_SPEC_VERSIONS = ['2025-11-25', '2026-07-28'] as const satisfies /** * Spec versions a transport arm can serve. Transports without an entry serve * every spec version on the active axis; the entry arms are era-fixed (the - * `legacy: 'stateless'` slot serves only 2025-era traffic, the modern path + * stateless legacy fallback serves only 2025-era traffic, the modern path * serves only the 2026-07-28 revision), so each registers cells on exactly one * axis. `verifies()` intersects this with a requirement's own spec-version * bounds when forming cells. diff --git a/test/integration/test/server/createMcpHandler.test.ts b/test/integration/test/server/createMcpHandler.test.ts index 61e78d13d6..e1b2693667 100644 --- a/test/integration/test/server/createMcpHandler.test.ts +++ b/test/integration/test/server/createMcpHandler.test.ts @@ -1,8 +1,8 @@ /** * createMcpHandler served over real HTTP, driven by real clients: the * 2026-capable negotiation client for the modern path and a plain 2025 client - * for the legacy slot — the three slot states on one endpoint, all backed by - * one factory. + * for the legacy fallback — both legacy postures (the stateless default and + * the strict 'reject') on one endpoint, all backed by one factory. */ import type { Server as HttpServer } from 'node:http'; import { createServer } from 'node:http'; @@ -10,14 +10,14 @@ import { createServer } from 'node:http'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; import type { CallToolResult, CreateMcpHandlerOptions, McpHttpHandler, McpRequestContext } from '@modelcontextprotocol/server'; -import { createMcpHandler, legacyStatelessFallback, McpServer } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; import { afterEach, describe, expect, it } from 'vitest'; import * as z from 'zod/v4'; const MODERN = '2026-07-28'; -describe('createMcpHandler over HTTP (slot states end to end)', () => { +describe('createMcpHandler over HTTP (legacy postures end to end)', () => { const cleanups: Array<() => Promise | void> = []; afterEach(async () => { while (cleanups.length > 0) await cleanups.pop()!(); @@ -55,7 +55,7 @@ describe('createMcpHandler over HTTP (slot states end to end)', () => { }; } - it('serves the modern era to an auto-negotiating client (strict endpoint, no legacy slot)', async () => { + it('serves the modern era to an auto-negotiating client (default endpoint)', async () => { const { baseUrl } = await startEndpoint(); const client = new Client({ name: 'auto-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); @@ -76,16 +76,16 @@ describe('createMcpHandler over HTTP (slot states end to end)', () => { expect(result.content).toEqual([{ type: 'text', text: 'hello modern (modern)' }]); }); - it('rejects a plain 2025 client on the strict endpoint with the unsupported-protocol-version error', async () => { - const { baseUrl } = await startEndpoint(); + it("rejects a plain 2025 client on a strict (legacy: 'reject') endpoint with the unsupported-protocol-version error", async () => { + const { baseUrl } = await startEndpoint({ legacy: 'reject' }); const client = new Client({ name: 'legacy-client', version: '1.0.0' }); await expect(client.connect(new StreamableHTTPClientTransport(baseUrl))).rejects.toThrow(/Unsupported protocol version|400/); cleanups.push(() => client.close().catch(() => {})); }); - it("serves a plain 2025 client through the 'stateless' legacy slot while the modern path keeps working", async () => { - const { baseUrl } = await startEndpoint({ legacy: 'stateless' }); + it('serves a plain 2025 client through the default stateless legacy fallback while the modern path keeps working', async () => { + const { baseUrl } = await startEndpoint(); const legacyClient = new Client({ name: 'legacy-client', version: '1.0.0' }); await legacyClient.connect(new StreamableHTTPClientTransport(baseUrl)); @@ -107,17 +107,6 @@ describe('createMcpHandler over HTTP (slot states end to end)', () => { expect(modernResult.content).toEqual([{ type: 'text', text: 'hello new friend (modern)' }]); }); - it('serves a plain 2025 client through a bring-your-own legacy handler', async () => { - const { baseUrl } = await startEndpoint({ legacy: legacyStatelessFallback(factory) }); - - const client = new Client({ name: 'legacy-client', version: '1.0.0' }); - await client.connect(new StreamableHTTPClientTransport(baseUrl)); - cleanups.push(() => client.close()); - - const result = await client.callTool({ name: 'greet', arguments: { name: 'byo' } }); - expect(result.content).toEqual([{ type: 'text', text: 'hello byo (legacy)' }]); - }); - it('pinning the modern revision works against the entry and never sends initialize', async () => { const { baseUrl } = await startEndpoint({ legacy: 'stateless' }); From 080b83ac35d62edde07d3a2b37089faa558b0d31 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 17 Jun 2026 19:45:05 +0000 Subject: [PATCH 4/8] docs: document the createMcpHandler legacy default, the removed handler option, and isLegacyRequest migration.md and migration-SKILL.md now describe the stateless legacy default, the explicit 'reject' strict posture, the removed handler-valued legacy option with a before/after user-land routing example, and the new isLegacyRequest export with its exact semantics (malformed modern claims are not legacy). The dual-era HTTP example drops its bring-your-own mode in favor of the default/reject postures, and the pending createMcpHandler changeset is updated so the released changelog describes the final option shape; a new changeset records the revision itself. --- .../create-mcp-handler-legacy-revision.md | 9 +++ .changeset/create-mcp-handler.md | 10 ++-- docs/migration-SKILL.md | 9 +++ docs/migration.md | 60 +++++++++++++------ examples/server/src/dualEraStreamableHttp.ts | 37 ++++++------ 5 files changed, 83 insertions(+), 42 deletions(-) create mode 100644 .changeset/create-mcp-handler-legacy-revision.md diff --git a/.changeset/create-mcp-handler-legacy-revision.md b/.changeset/create-mcp-handler-legacy-revision.md new file mode 100644 index 0000000000..4212856377 --- /dev/null +++ b/.changeset/create-mcp-handler-legacy-revision.md @@ -0,0 +1,9 @@ +--- +'@modelcontextprotocol/server': minor +--- + +Revise `createMcpHandler`'s legacy handling (a behavior change to the unreleased entry). The entry now serves 2025-era (non-envelope) traffic **by default** through per-request stateless serving from the same factory — `legacy: 'stateless'` is the default rather than an +opt-in — and the strict, modern-only posture is selected with the new `legacy: 'reject'` value (the earlier alpha's default). The handler-valued `legacy` option (bring-your-own legacy serving) is removed: existing legacy deployments (for example a sessionful streamable +HTTP wiring) keep serving 2025 traffic by routing in user land with the new `isLegacyRequest(request, parsedBody?)` export, which runs the entry's own classification step — it returns `true` only for requests with no per-request `_meta` envelope claim, while malformed or +incomplete modern claims are NOT legacy and must be routed to the modern handler, which answers them with the documented validation errors. The predicate classifies a clone, so the routed request body stays readable. `legacyStatelessFallback` remains exported as a +standalone fetch-shaped handler with the same stateless serving as the default. diff --git a/.changeset/create-mcp-handler.md b/.changeset/create-mcp-handler.md index d103fb0ac1..35eeccada2 100644 --- a/.changeset/create-mcp-handler.md +++ b/.changeset/create-mcp-handler.md @@ -3,8 +3,8 @@ --- Add `createMcpHandler(factory, { legacy?, onerror?, responseMode? })`, an HTTP entry point that serves the 2026-07-28 draft revision per request: each envelope-carrying request is classified once, served on a fresh instance from the factory bound to the claimed revision, -and answered with a JSON body or a lazily-upgraded SSE stream. 2025-era serving is opt-in through the `legacy` slot (`'stateless'` for per-request stateless serving via the existing streamable HTTP transport, or any fetch-shaped handler for bring-your-own wiring); without -the slot the endpoint is modern-only and rejects 2025-era requests with the unsupported-protocol-version error naming its supported revisions. The handler exposes a web-standard `fetch(request, { authInfo?, parsedBody? })` face and a duck-typed `node(req, res, parsedBody?)` -face, plus `close()` for tearing down in-flight modern exchanges. Also exported: `legacyStatelessFallback` (the canonical slot value), the `PerRequestHTTPServerTransport` single-exchange transport and the `classifyInboundRequest` classifier for hand-wired compositions, and -the supporting types. `responseMode: 'json'` never streams and drops mid-call notifications (progress, logging and other related messages emitted before the result); listen-class subscription streams are always served over SSE. The entry performs no Origin/Host validation -(use the middleware packages) and no token verification — `authInfo` is pass-through and never derived from request headers. +and answered with a JSON body or a lazily-upgraded SSE stream. 2025-era serving is selected with the `legacy` option (`'stateless'` — the default — for per-request stateless serving via the existing streamable HTTP transport, `'reject'` for a modern-only strict endpoint +that answers 2025-era requests with the unsupported-protocol-version error naming its supported revisions). The handler exposes a web-standard `fetch(request, { authInfo?, parsedBody? })` face and a duck-typed `node(req, res, parsedBody?)` +face, plus `close()` for tearing down in-flight modern exchanges. Also exported: `legacyStatelessFallback` (the same stateless legacy serving as a standalone fetch-shaped handler), the `PerRequestHTTPServerTransport` single-exchange transport and the +`classifyInboundRequest` classifier for hand-wired compositions, and the supporting types. `responseMode: 'json'` never streams and drops mid-call notifications (progress, logging and other related messages emitted before the result); listen-class subscription streams are +always served over SSE. The entry performs no Origin/Host validation (use the middleware packages) and no token verification — `authInfo` is pass-through and never derived from request headers. diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index f8014412fb..7b23e355e2 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -550,6 +550,15 @@ These can require code changes: - `Server.getClientCapabilities()`, `getClientVersion()` and `getNegotiatedProtocolVersion()` are deprecated but functional: prefer the per-request context (`ctx.mcpReq.envelope`) on 2026-07-28 requests. No mechanical change required yet; plan the move before the deprecations are removed. - `createMcpExpressApp()` / `createMcpHonoApp()` / `createMcpFastifyApp()` with a localhost-class `host` now also validate the `Origin` header by default (requests without an `Origin` header are unaffected). Browser-served clients on a non-localhost origin need `allowedOrigins: [...]`, which replaces the default localhost allowlist — Origin validation cannot be disabled for localhost-class binds. +### Server (HTTP entry: createMcpHandler — serving the 2026-07-28 draft revision) + +These apply only to code already written against an earlier 2.0 alpha's `createMcpHandler`; v1 has no equivalent API: + +- DEFAULT FLIP: `createMcpHandler(factory)` now serves 2025-era (non-envelope) traffic by default through per-request stateless serving (`legacy: 'stateless'`, the default). The earlier 2.0 alpha default was modern-only strict. Pass `legacy: 'reject'` explicitly to keep a strict, modern-only endpoint. +- REMOVED: the handler-valued `legacy` option (`legacy: `). The option type is now `legacy?: 'stateless' | 'reject'`. Replace `createMcpHandler(factory, { legacy: myLegacyHandler })` with explicit user-land routing: `if (await isLegacyRequest(request)) return myLegacyHandler(request); return strictHandler.fetch(request)` where `strictHandler = createMcpHandler(factory, { legacy: 'reject' })`. +- NEW EXPORT: `isLegacyRequest(request: Request, parsedBody?: unknown): Promise` from `@modelcontextprotocol/server` — the entry's own classification step. Returns `true` only for requests with no per-request `_meta` envelope claim (claim-less POSTs including `initialize`, GET/DELETE session operations, all-legacy batches, posted responses, non-JSON bodies). Returns `false` for envelope-claiming requests AND for malformed/incomplete modern claims (the modern path answers those with `-32602`/`-32001`) — route `false` traffic to the modern handler, never to a legacy handler. The predicate classifies a clone (the body stays readable); pass the parsed body as the second argument when the stream was already consumed. +- `legacyStatelessFallback(factory)` remains exported as a standalone fetch-shaped handler producing the same stateless legacy serving as the default. + ### Server (stdio / long-lived connections) - A hand-constructed `Server`/`McpServer` connected to a `StdioServerTransport` serves only the 2025-era protocol it was written for: today's behavior, byte-identical — no change required during a mechanical migration. diff --git a/docs/migration.md b/docs/migration.md index 1e9cbfdbde..ea82f24b38 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1032,19 +1032,16 @@ already does; automatic envelope emission for every request is a client-side fol ### Serving the 2026-07-28 draft revision over HTTP: `createMcpHandler` -The server package now ships an HTTP entry point that serves the 2026-07-28 draft revision per request, with 2025-era serving available as an **opt-in** slot: +The server package now ships an HTTP entry point that serves the 2026-07-28 draft revision per request and, **by default, also serves 2025-era traffic** per request through the established stateless idiom — one factory, one endpoint, both eras: ```typescript import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; -const handler = createMcpHandler( - ctx => { - const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); - // register tools/resources/prompts once — the same factory backs both eras - return server; - }, - { legacy: 'stateless' } -); +const handler = createMcpHandler(ctx => { + const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + // register tools/resources/prompts once — the same factory backs both eras + return server; +}); // Web-standard runtimes (Cloudflare Workers, Deno, Bun, Hono): // handler.fetch(request) @@ -1052,21 +1049,48 @@ const handler = createMcpHandler( // handler.node(req, res, req.body) ``` -How the `legacy` slot behaves: - -- **omitted** — modern-only strict. 2026-07-28 (per-request `_meta` envelope) requests are served; 2025-era requests are rejected with `-32004` naming the supported revisions, and 2025-era notifications are acknowledged with `202` and dropped. **There is no silent 2025 - serving without the slot.** -- **`legacy: 'stateless'`** — 2025-era traffic is additionally served per request through the established stateless idiom: a fresh instance from the same factory and a streamable HTTP transport constructed with only `sessionIdGenerator: undefined`. The exported - `legacyStatelessFallback(factory)` is the same handler as a standalone value. -- **`legacy: `** — bring your own legacy serving (for example an existing sessionful `WebStandardStreamableHTTPServerTransport` wiring). Requests are handed to it untouched and its lifecycle stays yours. +How the `legacy` option behaves: + +- **omitted / `legacy: 'stateless'`** (the default) — 2025-era (non-envelope) traffic is served per request through the established stateless idiom: a fresh instance from the same factory and a streamable HTTP transport constructed with only + `sessionIdGenerator: undefined`. Because this serving is per-request and stateless, GET and DELETE (2025 session operations) are answered `405` / `Method not allowed.`, exactly like the canonical stateless example. The exported `legacyStatelessFallback(factory)` is the + same serving as a standalone fetch-shaped handler for hand-wired compositions. +- **`legacy: 'reject'`** — modern-only strict. 2026-07-28 (per-request `_meta` envelope) requests are served; 2025-era requests are rejected with `-32004` naming the supported revisions, and 2025-era notifications are acknowledged with `202` and dropped. **There is no + 2025 serving in this mode.** + +> **Changed since the earlier 2.0 alpha (default flip):** the earlier alpha made modern-only strict the default and required `legacy: 'stateless'` to opt in to 2025 serving. The default is now the stateless fallback — `createMcpHandler(factory)` serves both eras out of +> the box. Pass `legacy: 'reject'` to keep a strict, modern-only endpoint. + +> **Removed: the handler-valued `legacy` option.** The earlier 2.0 alpha also accepted any fetch-shaped handler as the `legacy` value (bring-your-own legacy serving). That option is gone: route in user land with the exported `isLegacyRequest(request)` predicate instead. +> The predicate is the entry's own classification step (the same code `createMcpHandler` runs to decide a request is not on the modern path), so a composition that branches on it can never disagree with the entry: +> +> ```typescript +> // before (earlier 2.0 alpha): const handler = createMcpHandler(factory, { legacy: myExistingLegacyHandler }); +> // after: explicit user-land routing in front of a strict entry +> import { createMcpHandler, isLegacyRequest } from '@modelcontextprotocol/server'; +> +> const modern = createMcpHandler(factory, { legacy: 'reject' }); +> +> export default { +> async fetch(request: Request): Promise { +> if (await isLegacyRequest(request)) { +> return myExistingLegacyHandler(request); // e.g. an existing sessionful WebStandardStreamableHTTPServerTransport wiring +> } +> return modern.fetch(request); +> } +> }; +> ``` +> +> `isLegacyRequest` returns `true` only for requests with no per-request `_meta` envelope claim (claim-less POSTs including `initialize`, GET/DELETE session operations, all-legacy batches, posted responses, and non-JSON bodies). It returns `false` for everything the +> modern path answers — including a request carrying a **malformed** modern claim, which the modern path rejects with `-32602` — so route `false` traffic to the modern handler, never to your legacy handler. The predicate classifies a clone, so the request body stays +> readable for whichever handler you route to (pass an already-parsed body as the second argument if the stream has been consumed). The optional `responseMode` controls how modern request exchanges are answered: `'auto'` (default) returns a single JSON body and lazily upgrades to an SSE stream when the handler emits a related message before its result; `'sse'` always streams; **`'json'` never streams and DROPS mid-call notifications** (progress, logging, and any other related message emitted before the result) — only the terminal result is delivered. Subscription (listen-class) streams are always served over SSE regardless of the setting. `onerror` receives out-of-band errors and rejected requests for logging. The entry performs no Origin/Host validation (see the origin-validation middleware below) and no token verification: `authInfo` passed to `handler.fetch(request, { authInfo })` / attached as `req.auth` on the Node face is forwarded to handlers as-is and never derived from -request headers. Power users who want to compose routing themselves can use the exported `classifyInboundRequest` and `PerRequestHTTPServerTransport` building blocks directly; the handler faces are bound properties, so they can be detached and passed around -(`const { fetch } = handler`). +request headers. Power users who want to compose routing themselves can use the exported `isLegacyRequest`, `classifyInboundRequest` and `PerRequestHTTPServerTransport` building blocks directly; the handler faces are bound properties, so they can be detached and passed +around (`const { fetch } = handler`). ### Serving the 2026-07-28 draft revision on stdio: `serveStdio` diff --git a/examples/server/src/dualEraStreamableHttp.ts b/examples/server/src/dualEraStreamableHttp.ts index b10121ebad..5891bb5bc4 100644 --- a/examples/server/src/dualEraStreamableHttp.ts +++ b/examples/server/src/dualEraStreamableHttp.ts @@ -2,22 +2,25 @@ * Dual-era HTTP serving with `createMcpHandler`: one factory, one endpoint, * both protocol eras. * - * The same factory backs every serving mode; the `MCP_LEGACY_MODE` environment - * variable selects how 2025-era (non-envelope) traffic is handled: + * The same factory backs both legacy postures; the `MCP_LEGACY_MODE` + * environment variable selects how 2025-era (non-envelope) traffic is handled: * - * - `MCP_LEGACY_MODE=none` → modern-only strict: 2026-07-28 requests are + * - unset / `MCP_LEGACY_MODE=stateless` → (the entry's default) 2025-era + * traffic is served per-request via the + * stateless idiom from the same factory. + * - `MCP_LEGACY_MODE=reject` → modern-only strict: 2026-07-28 requests are * served, 2025-era requests get the documented * rejection naming the supported revisions. - * - `MCP_LEGACY_MODE=stateless` → (default) 2025-era traffic is additionally - * served per-request via the stateless idiom. - * - `MCP_LEGACY_MODE=byo` → the same, but wired explicitly through the - * exported `legacyStatelessFallback` slot value - * (stand-in for bringing your own legacy handler, - * e.g. an existing sessionful wiring). + * + * To keep an existing sessionful 2025 deployment serving legacy traffic next + * to a strict endpoint, route in user land with the exported `isLegacyRequest` + * predicate in front of a `legacy: 'reject'` handler (see the createMcpHandler + * section of docs/migration.md for the pattern) — there is no handler-valued + * `legacy` option. * * Run with `tsx examples/server/src/dualEraStreamableHttp.ts`, then point any * plain 2025 client at http://localhost:3000/mcp (served through the legacy - * slot when one is configured). A `versionNegotiation: { mode: 'auto' }` + * fallback unless `reject` is selected). A `versionNegotiation: { mode: 'auto' }` * client negotiates 2026-07-28 against the same endpoint, but automatic * envelope emission for every request is still a client-side follow-up: * ordinary typed calls (for example `callTool`) must attach the per-request @@ -27,11 +30,11 @@ */ import { createMcpExpressApp } from '@modelcontextprotocol/express'; import type { CallToolResult, CreateMcpHandlerOptions, McpRequestContext } from '@modelcontextprotocol/server'; -import { createMcpHandler, legacyStatelessFallback, McpServer } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import type { Request, Response } from 'express'; import * as z from 'zod/v4'; -// One factory for both legs (and every slot state): tools are defined once and +// One factory for both legs (and both postures): tools are defined once and // served identically to 2025-era and 2026-era clients. const getServer = (ctx: McpRequestContext) => { const server = new McpServer( @@ -60,13 +63,9 @@ const legacyMode = process.env.MCP_LEGACY_MODE ?? 'stateless'; const options: CreateMcpHandlerOptions = { onerror: error => console.error('MCP handler error:', error.message) }; -if (legacyMode === 'stateless') { - options.legacy = 'stateless'; -} else if (legacyMode === 'byo') { - // Bring-your-own legacy serving: any fetch-shaped handler works here. The - // canonical stateless fallback doubles as the simplest BYO value; an - // existing sessionful streamable HTTP wiring would be passed the same way. - options.legacy = legacyStatelessFallback(getServer); +if (legacyMode === 'reject') { + // Modern-only strict: turn the default stateless legacy fallback off. + options.legacy = 'reject'; } const handler = createMcpHandler(getServer, options); From 442faad82d7fc3df462ba13e344aaff6dbac2a68 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 09:42:40 +0000 Subject: [PATCH 5/8] docs: frame the createMcpHandler migration guidance as a 1.x-to-2.0 mapping Describe what the HTTP entry does for readers coming from 1.x: dual-era serving by default, legacy: 'reject' for a strict modern-only endpoint, and isLegacyRequest routing for keeping an existing sessionful Streamable HTTP setup serving 2025 clients. The worked routing example is kept; only the framing changes. --- docs/migration-SKILL.md | 11 ++++++----- docs/migration.md | 11 ++++------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 7b23e355e2..b44c5a1f80 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -552,12 +552,13 @@ These can require code changes: ### Server (HTTP entry: createMcpHandler — serving the 2026-07-28 draft revision) -These apply only to code already written against an earlier 2.0 alpha's `createMcpHandler`; v1 has no equivalent API: +New in 2.0 — v1 has no equivalent API. How v1 Streamable HTTP hosting maps onto the entry: -- DEFAULT FLIP: `createMcpHandler(factory)` now serves 2025-era (non-envelope) traffic by default through per-request stateless serving (`legacy: 'stateless'`, the default). The earlier 2.0 alpha default was modern-only strict. Pass `legacy: 'reject'` explicitly to keep a strict, modern-only endpoint. -- REMOVED: the handler-valued `legacy` option (`legacy: `). The option type is now `legacy?: 'stateless' | 'reject'`. Replace `createMcpHandler(factory, { legacy: myLegacyHandler })` with explicit user-land routing: `if (await isLegacyRequest(request)) return myLegacyHandler(request); return strictHandler.fetch(request)` where `strictHandler = createMcpHandler(factory, { legacy: 'reject' })`. -- NEW EXPORT: `isLegacyRequest(request: Request, parsedBody?: unknown): Promise` from `@modelcontextprotocol/server` — the entry's own classification step. Returns `true` only for requests with no per-request `_meta` envelope claim (claim-less POSTs including `initialize`, GET/DELETE session operations, all-legacy batches, posted responses, non-JSON bodies). Returns `false` for envelope-claiming requests AND for malformed/incomplete modern claims (the modern path answers those with `-32602`/`-32001`) — route `false` traffic to the modern handler, never to a legacy handler. The predicate classifies a clone (the body stays readable); pass the parsed body as the second argument when the stream was already consumed. -- `legacyStatelessFallback(factory)` remains exported as a standalone fetch-shaped handler producing the same stateless legacy serving as the default. +- `createMcpHandler(factory)` from `@modelcontextprotocol/server` serves the 2026-07-28 draft revision per request and, out of the box, also serves 2025-era (non-envelope) traffic through per-request stateless serving (`legacy: 'stateless'`, the default) — one factory, one endpoint, both eras. A v1 stateless `StreamableHTTPServerTransport` hosting (`sessionIdGenerator: undefined`, fresh transport per request) maps directly onto the default entry. +- Pass `legacy: 'reject'` for a strict, modern-only endpoint: 2025-era requests are rejected with the unsupported-protocol-version error naming the supported revisions, and 2025-era notifications are acknowledged with `202` and dropped. The option type is `legacy?: 'stateless' | 'reject'`. +- An existing sessionful v1 Streamable HTTP setup (a `StreamableHTTPServerTransport` wiring with session IDs) keeps serving 2025 clients by routing in user land in front of a strict entry: `if (await isLegacyRequest(request)) return myExistingLegacyHandler(request); return strictHandler.fetch(request)` where `strictHandler = createMcpHandler(factory, { legacy: 'reject' })`. +- `isLegacyRequest(request: Request, parsedBody?: unknown): Promise` from `@modelcontextprotocol/server` is the entry's own classification step. Returns `true` only for requests with no per-request `_meta` envelope claim (claim-less POSTs including `initialize`, GET/DELETE session operations, all-legacy batches, posted responses, non-JSON bodies). Returns `false` for envelope-claiming requests AND for malformed/incomplete modern claims (the modern path answers those with `-32602`/`-32001`) — route `false` traffic to the modern handler, never to a legacy handler. The predicate classifies a clone (the body stays readable); pass the parsed body as the second argument when the stream was already consumed. +- `legacyStatelessFallback(factory)` is exported as a standalone fetch-shaped handler producing the same stateless legacy serving as the default. ### Server (stdio / long-lived connections) diff --git a/docs/migration.md b/docs/migration.md index ea82f24b38..cd51f7dbbc 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1057,15 +1057,12 @@ How the `legacy` option behaves: - **`legacy: 'reject'`** — modern-only strict. 2026-07-28 (per-request `_meta` envelope) requests are served; 2025-era requests are rejected with `-32004` naming the supported revisions, and 2025-era notifications are acknowledged with `202` and dropped. **There is no 2025 serving in this mode.** -> **Changed since the earlier 2.0 alpha (default flip):** the earlier alpha made modern-only strict the default and required `legacy: 'stateless'` to opt in to 2025 serving. The default is now the stateless fallback — `createMcpHandler(factory)` serves both eras out of -> the box. Pass `legacy: 'reject'` to keep a strict, modern-only endpoint. - -> **Removed: the handler-valued `legacy` option.** The earlier 2.0 alpha also accepted any fetch-shaped handler as the `legacy` value (bring-your-own legacy serving). That option is gone: route in user land with the exported `isLegacyRequest(request)` predicate instead. -> The predicate is the entry's own classification step (the same code `createMcpHandler` runs to decide a request is not on the modern path), so a composition that branches on it can never disagree with the entry: +> **If you have an existing sessionful 1.x Streamable HTTP setup** (a `StreamableHTTPServerTransport` wiring with session IDs that your deployed 2025-era clients depend on), keep that handler serving 2025 traffic and route it in front of a strict (`legacy: 'reject'`) +> entry with the exported `isLegacyRequest(request)` predicate. The predicate is the entry's own classification step (the same code `createMcpHandler` runs to decide a request is not on the modern path), so a composition that branches on it can never disagree with the +> entry: > > ```typescript -> // before (earlier 2.0 alpha): const handler = createMcpHandler(factory, { legacy: myExistingLegacyHandler }); -> // after: explicit user-land routing in front of a strict entry +> // An existing sessionful 1.x streamable HTTP wiring keeps serving 2025 clients, routed in front of a strict entry. > import { createMcpHandler, isLegacyRequest } from '@modelcontextprotocol/server'; > > const modern = createMcpHandler(factory, { legacy: 'reject' }); From 54fe58b79424505142b9dbe4c9255f31206d8076 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 09:43:35 +0000 Subject: [PATCH 6/8] feat(server): reject a function-valued legacy option with a clear TypeError createMcpHandler's legacy option only selects a serving posture ('stateless' | 'reject'). JavaScript callers passing a handler function were previously treated as the default posture silently; construction now throws a TypeError pointing at isLegacyRequest-based user-land routing. --- packages/server/src/server/createMcpHandler.ts | 10 ++++++++++ packages/server/test/server/createMcpHandler.test.ts | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 3355fb4e13..562817b0b2 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -557,6 +557,16 @@ export async function isLegacyRequest(request: Request, parsedBody?: unknown): P export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHandlerOptions = {}): McpHttpHandler { const { legacy, onerror, responseMode } = options; + // Construction-time guard for JavaScript callers passing a handler as the + // legacy value: the option only selects a posture ('stateless' | 'reject'). + // Failing loudly here beats silently treating the handler as the default. + if (typeof legacy === 'function') { + throw new TypeError( + "The 'legacy' option only accepts 'stateless' or 'reject', not a handler function. To serve 2025-era traffic with your own " + + "handler, route in user land with the exported isLegacyRequest(request) predicate in front of a strict (legacy: 'reject') handler." + ); + } + /** Modern per-request instances with an exchange still in flight (close() tears these down). */ const inflight = new Set(); let closed = false; diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index f46e4e513a..0a069376ac 100644 --- a/packages/server/test/server/createMcpHandler.test.ts +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -710,6 +710,14 @@ describe('createMcpHandler — user-land routing with isLegacyRequest (replaces false ); }); + + it("throws a TypeError at construction when a handler function is passed as the 'legacy' option", () => { + const { factory } = testFactory(); + const myExistingLegacyHandler = async (): Promise => new Response(null, { status: 200 }); + const construct = () => createMcpHandler(factory, { legacy: myExistingLegacyHandler as unknown as 'stateless' }); + expect(construct).toThrow(TypeError); + expect(construct).toThrow(/isLegacyRequest/); + }); }); describe('createMcpHandler — responseMode', () => { From a95dacfb156649422e1f13866cbb6b73503663aa Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 09:43:53 +0000 Subject: [PATCH 7/8] docs(server): note isLegacyRequest behavior when the body was already consumed Document that classifying a POST whose body stream was already consumed, without supplying parsedBody, rejects (cloning a used body throws), and that passing the parsed body as the second argument avoids the body read. --- packages/server/src/server/createMcpHandler.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 562817b0b2..e5f79dec85 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -499,7 +499,9 @@ async function classifyEntryRequest(request: Request, providedParsedBody?: unkno * The body is read from a clone, so the passed request stays readable for * whichever handler the caller routes it to. If the body has already been * consumed (for example behind `express.json()`), pass the parsed body as the - * second argument and no body read happens at all. + * second argument and no body read happens at all — without it the predicate + * cannot classify a consumed POST body (cloning a used body throws a + * `TypeError`), so the call rejects instead of guessing. */ export async function isLegacyRequest(request: Request, parsedBody?: unknown): Promise { // Classify a clone so the caller's request body stays readable; with a From bc191ab867ec2d6ec6d37eb2cd60fba71c954aee Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 09:44:21 +0000 Subject: [PATCH 8/8] test(server): update the stateless literal twin header to name the legacy fallback Comment-only: the header still described the entry's legacy serving as a slot; it is the stateless legacy fallback. No assertion changes. --- .../test/server/createMcpHandlerStatelessLiteral.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/server/test/server/createMcpHandlerStatelessLiteral.test.ts b/packages/server/test/server/createMcpHandlerStatelessLiteral.test.ts index fff1734cb2..4aac72549e 100644 --- a/packages/server/test/server/createMcpHandlerStatelessLiteral.test.ts +++ b/packages/server/test/server/createMcpHandlerStatelessLiteral.test.ts @@ -2,8 +2,8 @@ * Wire-level continuity twin for the "Unsupported protocol version" rejection, * exercised through `createMcpHandler(factory, { legacy: 'stateless' })`. * - * The legacy slot routes 2025-era traffic through the untouched streamable HTTP - * transport, so the rejection site (and therefore the wire bytes deployed + * The legacy fallback routes 2025-era traffic through the untouched streamable + * HTTP transport, so the rejection site (and therefore the wire bytes deployed * clients sniff — see streamableHttpUnsupportedVersionLiteral.test.ts for the * go-sdk substring dependency) is the same one the standalone transport test * pins. This twin asserts the bytes hold on the sugar path itself: HTTP 400,