Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/legacy-envelope-claim-routing.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 40 additions & 4 deletions packages/core-internal/src/shared/inboundClassification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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.
Expand All @@ -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',
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 });
Expand Down
6 changes: 4 additions & 2 deletions packages/server/src/server/createMcpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
82 changes: 82 additions & 0 deletions packages/server/test/server/createMcpHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
Loading