diff --git a/.changeset/legacy-envelope-claim-routing.md b/.changeset/legacy-envelope-claim-routing.md new file mode 100644 index 0000000000..fe0197f1ee --- /dev/null +++ b/.changeset/legacy-envelope-claim-routing.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +Dual-era Streamable HTTP entries (`createMcpHandler` with legacy serving) no longer reject non-`initialize` requests whose `_meta` carries a pre-2026 `io.modelcontextprotocol/protocolVersion` claim. Such requests are legacy-era traffic: they are routed to legacy serving exactly as they would be without the claim, and the 2026-07-28 envelope schema is no longer applied to them (a claim carrying only the version key previously failed with `-32602`, and a full claim with `-32022`). Notifications and batch elements claiming a pre-2026 revision get the same routing. Claims naming a modern revision are unchanged, a `MCP-Protocol-Version` header that disagrees with a legacy claim is still a `-32020` header-validation failure, and modern-only endpoints still answer 2025-era claims with `-32022` naming the supported revisions. diff --git a/packages/core-internal/src/shared/inboundClassification.ts b/packages/core-internal/src/shared/inboundClassification.ts index 4f20e8cb41..dd7b03160a 100644 --- a/packages/core-internal/src/shared/inboundClassification.ts +++ b/packages/core-internal/src/shared/inboundClassification.ts @@ -14,8 +14,12 @@ * define). * - A request whose `params._meta` carries the reserved protocol-version key * claims the per-request envelope mechanism and classifies into the era the - * named revision belongs to (a malformed envelope behind a present claim is - * a validation error, never a silent fall back to legacy handling). + * named revision belongs to: a claim naming a pre-2026 revision is legacy-era + * traffic served exactly as it would be without the claim (the 2026-07-28 + * envelope schema does not apply to it), while a claim naming a modern + * revision is validated against that schema (a malformed envelope behind a + * present claim is a validation error, never a silent fall back to legacy + * handling). * - A request without a claim is legacy-era traffic. * - The `MCP-Protocol-Version` header is a cross-check only *for * classification*: it never upgrades or downgrades a body-derived @@ -703,9 +707,18 @@ function classifyBatch(body: readonly unknown[]): InboundClassificationOutcome { for (const element of body) { const params = isPlainObject(element) ? element['params'] : undefined; if (hasEnvelopeClaim(params)) { + const claimedVersion = envelopeClaimVersion(params); + if (claimedVersion !== undefined && !isModernProtocolVersion(claimedVersion)) { + // An element claiming a pre-2026 revision is legacy-era + // traffic: the array stays a legacy batch and legacy serving + // decides whether it accepts batches at all. + continue; + } // Element-wise rule: a single modern element makes the whole array // unservable — modern requests are single-message POSTs, and the - // legacy path must never serve an envelope-claiming element. + // legacy path must never serve a modern envelope-claiming element. + // A claim whose version key is not a string is treated the same: + // its era cannot be established, so it is never served silently. return rejection( 'jsonrpc-shape', 'batch-with-modern-element', @@ -763,6 +776,24 @@ function classifyRequestBody(request: InboundHttpRequest, body: JSONRPCRequest): } if (hasEnvelopeClaim(params)) { + const claimedVersion = envelopeClaimVersion(params); + if (claimedVersion !== undefined && !isModernProtocolVersion(claimedVersion)) { + // A claim naming a pre-2026 revision is legacy-era traffic: the + // 2026-07-28 envelope schema does not apply to it, and the request + // is served by the legacy leg exactly as it would be without the + // claim (the same precedence `initialize` already gets). A + // protocol-version header that disagrees with the claim is still a + // header-validation failure. + if (headerVersion !== undefined && headerVersion !== claimedVersion) { + return crossCheckMismatch( + 'header-body-version-mismatch', + headerVersion, + `the body envelope names protocol version ${claimedVersion} but the MCP-Protocol-Version header names ${headerVersion}` + ); + } + return { kind: 'legacy', reason: 'no-claim', requestedVersion: claimedVersion }; + } + // A present claim is validated, never silently ignored: a malformed // envelope behind the claim is an invalid-params rejection naming the // offending key, not a fall back to legacy handling. @@ -783,7 +814,6 @@ function classifyRequestBody(request: InboundHttpRequest, body: JSONRPCRequest): ); } - const claimedVersion = envelopeClaimVersion(params); if (headerVersion !== undefined && claimedVersion !== undefined && headerVersion !== claimedVersion) { return crossCheckMismatch( 'header-body-version-mismatch', @@ -870,6 +900,12 @@ function classifyNotificationBody(request: InboundHttpRequest, body: JSONRPCNoti `the notification envelope names protocol version ${claimedVersion} but the MCP-Protocol-Version header names ${headerVersion}` ); } + if (!isModernProtocolVersion(claimedVersion)) { + // A claim naming a pre-2026 revision is legacy-era traffic: the + // notification is served by the legacy leg exactly as it would be + // without the claim. + return { kind: 'legacy', reason: 'notification', requestedVersion: claimedVersion }; + } const classification = classificationForClaim(claimedVersion); if (classification.era === 'modern' && request.mcpMethodHeader !== undefined && request.mcpMethodHeader !== method) { return crossCheckMismatch( diff --git a/packages/core-internal/test/shared/inboundClassification.test.ts b/packages/core-internal/test/shared/inboundClassification.test.ts index e2cbc2c04e..491f67665d 100644 --- a/packages/core-internal/test/shared/inboundClassification.test.ts +++ b/packages/core-internal/test/shared/inboundClassification.test.ts @@ -185,13 +185,40 @@ describe('body-primary era predicate', () => { expect(classifyInboundRequest({ httpMethod: 'DELETE' })).toMatchObject({ kind: 'legacy', reason: 'http-method' }); }); - test('a claim naming a legacy revision keeps the named revision on the classification', () => { - // The envelope mechanism naming a pre-2026 revision is carried as-is; - // the serving instance answers it through the protocol-version - // mismatch handoff rather than being silently re-routed. + test('a claim naming a legacy revision routes legacy, keeping the named revision as requestedVersion', () => { + // The envelope mechanism naming a pre-2026 revision classifies into + // the era the named revision belongs to: the request is legacy-era + // traffic served exactly as it would be without the claim, with the + // claimed revision kept as requestedVersion so a modern-only endpoint + // still answers it through the protocol-version mismatch handoff. const meta = { ...ENVELOPE, [PROTOCOL_VERSION_META_KEY]: '2025-06-18' }; const outcome = classifyInboundRequest(post(modernToolsCall(meta))); - expect(outcome).toMatchObject({ kind: 'modern', classification: { era: 'legacy', revision: '2025-06-18' } }); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'no-claim', requestedVersion: '2025-06-18' }); + }); + + test('a claim naming a legacy revision is not validated against the modern envelope schema', () => { + // Only the version key in `_meta`: still a legacy route, never the + // modern envelope's missing-key rejection. + const body = { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + params: { _meta: { [PROTOCOL_VERSION_META_KEY]: '2025-06-18' } } + }; + const outcome = classifyInboundRequest(post(body, { protocolVersion: '2025-06-18' })); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'no-claim', requestedVersion: '2025-06-18' }); + }); + + test('a legacy claim with a disagreeing protocol-version header is still a mismatch outcome', () => { + const meta = { ...ENVELOPE, [PROTOCOL_VERSION_META_KEY]: '2025-06-18' }; + const outcome = classifyInboundRequest(post(modernToolsCall(meta), { protocolVersion: '2025-11-25' })); + expectMismatch(outcome, 'header-body-version-mismatch'); + }); + + test('a notification whose claim names a legacy revision routes legacy', () => { + const meta = { [PROTOCOL_VERSION_META_KEY]: '2025-06-18' }; + const outcome = classifyInboundRequest(post(notification('notifications/cancelled', meta), { protocolVersion: '2025-06-18' })); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'notification', requestedVersion: '2025-06-18' }); }); test('a claim with a malformed envelope is rejected, never silently treated as legacy', () => { @@ -388,6 +415,25 @@ describe('element-wise batch classification', () => { expect(outcome).toMatchObject({ kind: 'reject', cell: 'batch-with-modern-element', code: -32_600, httpStatus: 400, settled: true }); }); + test('an array containing an element whose claim names a legacy revision stays legacy traffic', () => { + const legacyClaiming = { + jsonrpc: '2.0', + id: 3, + method: 'tools/list', + params: { _meta: { [PROTOCOL_VERSION_META_KEY]: '2025-06-18' } } + }; + const outcome = classifyInboundRequest(post([legacyToolsList(), legacyClaiming])); + expect(outcome).toMatchObject({ kind: 'legacy', reason: 'batch' }); + }); + + test('an array containing an element with a malformed claim is rejected', () => { + // The claim key is present but not a string: the era cannot be + // established, so the element is never served silently. + const malformedClaiming = { jsonrpc: '2.0', id: 4, method: 'tools/list', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: 42 } } }; + const outcome = classifyInboundRequest(post([legacyToolsList(), malformedClaiming])); + expect(outcome).toMatchObject({ kind: 'reject', cell: 'batch-with-modern-element', code: -32_600, httpStatus: 400, settled: true }); + }); + test('an array containing an invalid element is rejected', () => { const outcome = classifyInboundRequest(post([legacyToolsList(), { not: 'json-rpc' }])); expect(outcome).toMatchObject({ kind: 'reject', cell: 'batch-with-invalid-element', code: -32_600, httpStatus: 400 }); diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 4ef836c636..2d222dad4b 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -730,8 +730,10 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa const claimedRevision = route.classification.revision; if (claimedRevision === undefined || !SUPPORTED_MODERN_PROTOCOL_VERSIONS.includes(claimedRevision)) { // The claim names a revision this endpoint does not serve (an - // unknown future revision, or a 2025-era revision delivered via the - // envelope mechanism). + // unknown future revision; a 2025-era revision delivered via the + // envelope mechanism never reaches this path — the classifier + // routes it to legacy serving, where a modern-only endpoint + // answers it with the same -32022 through the strict mapping). const error = new UnsupportedProtocolVersionError({ supported: [...SUPPORTED_MODERN_PROTOCOL_VERSIONS], requested: claimedRevision ?? 'unknown' diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index 1069faba2e..8e81d49a55 100644 --- a/packages/server/test/server/createMcpHandler.test.ts +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -522,6 +522,88 @@ describe('createMcpHandler — stateless legacy fallback (the default)', () => { expect(state.products[0]!.server.getNegotiatedProtocolVersion()).not.toBe(MODERN_REVISION); }); + it('serves a request whose _meta claim names a 2025-era revision through the legacy leg (#3159)', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + const legacyClaim = { + [PROTOCOL_VERSION_META_KEY]: '2025-06-18', + [CLIENT_INFO_META_KEY]: { name: 't', version: '0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; + const response = await handler.fetch(postRequest({ jsonrpc: '2.0', id: 9, method: 'tools/list', params: { _meta: legacyClaim } })); + expect(response.status).toBe(200); + expect(state.contexts).toHaveLength(1); + expect(state.contexts[0]?.era).toBe('legacy'); + }); + + it('serves a request carrying only the version key of a 2025-era claim, without the modern envelope schema (#3159)', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + const response = await handler.fetch( + postRequest({ jsonrpc: '2.0', id: 9, method: 'tools/list', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: '2025-06-18' } } }) + ); + expect(response.status).toBe(200); + expect(state.contexts[0]?.era).toBe('legacy'); + }); + + it('routes a notification whose claim names a 2025-era revision to the legacy leg (#3159)', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + const response = await handler.fetch( + postRequest({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { requestId: 1, _meta: { [PROTOCOL_VERSION_META_KEY]: '2025-06-18' } } + }) + ); + expect(response.status).toBe(202); + expect(state.contexts).toHaveLength(1); + expect(state.contexts[0]?.era).toBe('legacy'); + }); + + it('routes a batch containing an element whose claim names a 2025-era revision to the legacy leg (#3159)', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'stateless' }); + + const response = await handler.fetch( + postRequest([ + { jsonrpc: '2.0', method: 'notifications/initialized' }, + { jsonrpc: '2.0', id: 9, method: 'tools/list', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: '2025-06-18' } } } + ]) + ); + // The legacy stateless leg decides how to answer a batch; it must not + // be the entry's -32600 batch-with-modern-element rejection. + expect(response.status).not.toBe(400); + }); + + it('a modern-only endpoint still answers a 2025-era claim with -32022 naming the supported revisions', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory, { legacy: 'reject' }); + + const response = await handler.fetch( + postRequest({ + jsonrpc: '2.0', + id: 9, + method: 'tools/list', + params: { + _meta: { + [PROTOCOL_VERSION_META_KEY]: '2025-06-18', + [CLIENT_INFO_META_KEY]: { name: 't', version: '0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + } + } + }) + ); + expect(response.status).toBe(400); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_022); + expect(JSON.stringify(body.error.data)).toContain(MODERN_REVISION); + expect(JSON.stringify(body.error.data)).toContain('2025-06-18'); + }); + 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' });