Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/pin-modern-rejection-codes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/core': patch
'@modelcontextprotocol/server': patch
---

Pin the modern (2026-07-28) HTTP serving path's rejection codes to the assignments the published conformance suite asserts: a header/body cross-check mismatch (`MCP-Protocol-Version` or `Mcp-Method` disagreeing with the request body) is now rejected with `-32001` (HeaderMismatch), and a request whose protocol-version header names a modern revision but whose body is missing the `_meta` envelope (or its required protocol-version key) is rejected with `-32602` invalid params naming the missing key(s). Both keep HTTP 400. These cells previously emitted a provisional `-32004` while the upstream error-code discussion was open. The envelope-less rejection on a modern-only endpoint (`-32004` with the supported-versions list), the 2025-era serving paths, and the client-side probe handling are unchanged.
102 changes: 64 additions & 38 deletions packages/core/src/shared/inboundClassification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,21 @@
* legacy and hand-wired traffic is never classified, which keeps its
* dispatch behavior byte-identical to today's.
*
* Some ladder cells do not have a settled error code upstream yet (the
* header/body mismatch family: the candidate codes are `-32001`, `-32602`
* and `-32004`; see the note in `test/conformance/expected-failures.yaml`).
* Those outcomes are emitted with a single provisional code and are marked
* `settled: false` so tests and consumers can treat them as parameterized
* rather than pinned.
* Error codes for the modern-path rejection cells follow the published
* conformance suite (and the spec text it asserts):
*
* - A header/body cross-check mismatch (the `MCP-Protocol-Version` header
* disagreeing with the body, or the `Mcp-Method` header disagreeing with the
* body method) is rejected with `-32001` (`HeaderMismatch`) on HTTP 400.
* - A request whose protocol-version header names a modern revision but whose
* body carries no `_meta` envelope claim — including an envelope present but
* missing the required protocol-version key — is rejected with `-32602`
* (invalid params) naming the missing key(s), on HTTP 400.
*
* Should a future spec revision or conformance release change these
* assignments, the affected cells are re-derived against that release; the
* `settled` flag on {@linkcode InboundLadderRejection} stays available to mark
* a cell provisional again while such a change is in flight.
*/
import { PROTOCOL_VERSION_META_KEY } from '../types/constants.js';
import { ProtocolErrorCode } from '../types/enums.js';
Expand Down Expand Up @@ -158,6 +167,23 @@ export interface InboundLadderRejection {
/** The outcome of classifying one inbound HTTP request. */
export type InboundClassificationOutcome = InboundLegacyRoute | InboundModernRoute | InboundLadderRejection;

/* ------------------------------------------------------------------------ *
* Header cross-check mismatches
* ------------------------------------------------------------------------ */

/**
* The error code emitted for header/body cross-check mismatches: the
* `MCP-Protocol-Version` header disagreeing with the body's envelope claim (or
* with the body's classification), and the `Mcp-Method` header disagreeing
* with the body method.
*
* `-32001` is the SEP-2243 `HeaderMismatch` code, as asserted by the published
* conformance suite for header-validation failures. It has no
* {@linkcode ProtocolErrorCode} member because it is not part of the 2025-era
* wire vocabulary; the validation ladder is its only emitter.
*/
export const HEADER_MISMATCH_ERROR_CODE = -32_001;

/* ------------------------------------------------------------------------ *
* The validation ladder as data
* ------------------------------------------------------------------------ */
Expand Down Expand Up @@ -219,11 +245,12 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor
rung: 'era-classification',
order: 3,
evaluatedAt: 'edge',
codes: [ProtocolErrorCode.UnsupportedProtocolVersion],
codes: [HEADER_MISMATCH_ERROR_CODE, ProtocolErrorCode.UnsupportedProtocolVersion],
conformance: ['server-stateless', 'http-header-validation', 'http-custom-header-server-validation'],
rationale:
'Body-primary era classification with the protocol-version header as a cross-check; a header/body disagreement is a ' +
'distinct outcome whose exact error code is still under discussion upstream (provisional, see expected-failures.yaml).'
'Body-primary era classification with the protocol-version header as a cross-check; a header/body disagreement is rejected ' +
'with -32001 (HeaderMismatch), and an envelope-less request on a modern-only endpoint is answered with the ' +
'unsupported-protocol-version error naming the supported revisions.'
},
{
rung: 'envelope',
Expand All @@ -232,8 +259,9 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor
codes: [ProtocolErrorCode.InvalidParams],
conformance: ['server-stateless'],
rationale:
'A present envelope claim with a malformed envelope is an invalid-params rejection naming the offending key — never a ' +
'silent fall back to legacy handling. This is the only place an invalid-params rejection maps to HTTP 400.'
'A present envelope claim with a malformed envelope — and a missing envelope on a request whose protocol-version header ' +
'names a modern revision — is an invalid-params rejection naming the offending or missing key(s); never a silent fall ' +
'back to legacy handling. This is the only place an invalid-params rejection maps to HTTP 400.'
},
{
rung: 'method-registry',
Expand Down Expand Up @@ -293,7 +321,7 @@ export const LADDER_ERROR_HTTP_STATUS: Readonly<Record<number, number>> = {
[ProtocolErrorCode.MethodNotFound]: 404,
[ProtocolErrorCode.UnsupportedProtocolVersion]: 400,
[ProtocolErrorCode.MissingRequiredClientCapability]: 400,
[-32_001]: 400
[HEADER_MISMATCH_ERROR_CODE]: 400
};

/**
Expand All @@ -307,23 +335,6 @@ export function httpStatusForErrorCode(code: number, origin: 'ladder' | 'in-band
return LADDER_ERROR_HTTP_STATUS[code] ?? 400;
}

/* ------------------------------------------------------------------------ *
* Provisional cells
* ------------------------------------------------------------------------ */

/**
* The error code emitted for header/body cross-check mismatches (the
* protocol-version header disagreeing with the body classification, and the
* `Mcp-Method` header disagreeing with the body method).
*
* The exact code for these cells is still under discussion upstream — the
* candidates are `-32001`, `-32602` and `-32004` (see the note in
* `test/conformance/expected-failures.yaml`). Until a published conformance
* release settles them, the ladder emits the protocol-layer era-mismatch code
* and marks the outcome `settled: false`.
*/
export const PROVISIONAL_CROSS_CHECK_MISMATCH_CODE: number = ProtocolErrorCode.UnsupportedProtocolVersion;

/* ------------------------------------------------------------------------ *
* The classifier
* ------------------------------------------------------------------------ */
Expand Down Expand Up @@ -352,10 +363,10 @@ function crossCheckMismatch(cell: string, header: string, body: string): Inbound
'era-classification',
cell,
400,
new ProtocolError(PROVISIONAL_CROSS_CHECK_MISMATCH_CODE, `Bad Request: the request headers and body disagree: ${body}`, {
new ProtocolError(HEADER_MISMATCH_ERROR_CODE, `Bad Request: the request headers and body disagree: ${body}`, {
mismatch: { header, body }
}),
false
true
);
}

Expand Down Expand Up @@ -504,13 +515,29 @@ function classifyRequestBody(request: InboundHttpRequest, body: Record<string, u
return { kind: 'modern', messageKind: 'request', classification: classificationForClaim(claimedVersion) };
}

// No claim: legacy-era traffic. The header is a cross-check only — a
// modern header on a claim-less body is a disagreement, not an upgrade.
// No claim: legacy-era traffic — unless the protocol-version header names a
// modern revision. The modern revisions carry their request metadata in the
// per-request `_meta` envelope, so a modern-classified request without one
// is missing required params: it is rejected with invalid params naming the
// missing key(s), never silently served as legacy traffic and never
// upgraded from the header alone.
if (headerNamesModern) {
return crossCheckMismatch(
const meta = requestMetaOf(params);
const missingFromEnvelope = validateEnvelopeMeta(meta ?? {})
.filter(issue => issue.problem === 'missing')
.map(issue => issue.key);
const missing = meta === undefined ? ['_meta'] : missingFromEnvelope.length > 0 ? missingFromEnvelope : [PROTOCOL_VERSION_META_KEY];
return rejection(
'envelope',
'modern-header-without-claim',
headerVersion,
'the MCP-Protocol-Version header names a modern protocol revision but the request body carries no _meta envelope claim'
400,
new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Invalid params: the MCP-Protocol-Version header names protocol revision ${headerVersion}, but the request is missing ` +
`the required per-request envelope key(s): ${missing.join(', ')}`,
{ envelope: { missing } }
),
true
);
}
return { kind: 'legacy', reason: 'no-claim', ...(headerVersion !== undefined && { requestedVersion: headerVersion }) };
Expand Down Expand Up @@ -687,8 +714,7 @@ export function classifyInboundMessage(message: { method: string; params?: unkno
* versions and echoing the version the request named (when it named one —
* `requested` is omitted rather than fabricated when the request named no
* version at all), so a legacy client can discover what the endpoint serves
* from the error alone. (This cell shares its numeric code with the
* still-disputed mismatch cells above, but its own outcome is settled.)
* from the error alone.
* - Posted responses and batch arrays are invalid requests on the modern era.
* - Non-`POST` methods are not allowed.
* - Legacy-classified notifications return `undefined`: the caller answers
Expand Down
27 changes: 9 additions & 18 deletions packages/core/test/shared/errorHttpStatusMatrix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,17 @@
* carries its own HTTP 400 and is the only invalid-params rejection that
* maps to 400.
*
* Cells whose error CODE is still disputed upstream (the header/body mismatch
* family) stay parameterized: the emitted code is asserted as candidate-set
* membership, never a pinned literal.
* The header/body mismatch family is pinned to `-32001` (HeaderMismatch) and
* the missing-envelope cells to `-32602`, the assignments asserted by the
* published conformance suite.
*
* Transport- and dispatch-level behavior for these cells is covered by the
* ladder cell sheet and the per-request transport suites; this file pins the
* table itself.
*/
import { describe, expect, test } from 'vitest';

import {
httpStatusForErrorCode,
LADDER_ERROR_HTTP_STATUS,
PROVISIONAL_CROSS_CHECK_MISMATCH_CODE
} from '../../src/shared/inboundClassification.js';
import { HEADER_MISMATCH_ERROR_CODE, httpStatusForErrorCode, LADDER_ERROR_HTTP_STATUS } from '../../src/shared/inboundClassification.js';
import { ProtocolErrorCode } from '../../src/types/enums.js';

describe('the status matrix — pinned cells', () => {
Expand Down Expand Up @@ -84,15 +80,10 @@ describe('the status matrix — pinned cells', () => {
});
});

describe('the status matrix — parameterized (disputed) cells', () => {
test('the header/body mismatch family code is a candidate, not a pin, and maps to 400 whichever candidate it is', () => {
const candidates = [-32_001, ProtocolErrorCode.InvalidParams, ProtocolErrorCode.UnsupportedProtocolVersion];
expect(candidates).toContain(PROVISIONAL_CROSS_CHECK_MISMATCH_CODE);
// Whatever the upstream resolution turns out to be, a ladder-originated
// rejection in this family answers HTTP 400: every candidate either has
// a 400 row or is carried by the classifier's own httpStatus.
if (PROVISIONAL_CROSS_CHECK_MISMATCH_CODE !== ProtocolErrorCode.InvalidParams) {
expect(httpStatusForErrorCode(PROVISIONAL_CROSS_CHECK_MISMATCH_CODE, 'ladder')).toBe(400);
}
describe('the status matrix — header/body mismatch family', () => {
test('the header/body mismatch family is pinned to -32001 (HeaderMismatch) and maps to HTTP 400', () => {
expect(HEADER_MISMATCH_ERROR_CODE).toBe(-32_001);
expect(LADDER_ERROR_HTTP_STATUS[HEADER_MISMATCH_ERROR_CODE]).toBe(400);
expect(httpStatusForErrorCode(HEADER_MISMATCH_ERROR_CODE, 'ladder')).toBe(400);
});
});
64 changes: 44 additions & 20 deletions packages/core/test/shared/inboundClassification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,19 @@
* cross-checks, notification routing, element-wise batch classification, and
* the modern-only (strict) rejection mapping.
*
* Cells whose exact error code is still under discussion upstream (the
* header/body mismatch family) are asserted as parameterized: the outcome is
* pinned (a rejection, marked unsettled), the code is asserted to be the
* provisional constant and a member of the candidate set — never a hard-coded
* literal of its own.
* The header/body mismatch cells are pinned to `-32001` (HeaderMismatch) and
* the missing-envelope / missing-protocol-version cells to `-32602` (invalid
* params naming the missing key(s)) — the assignments asserted by the
* published conformance suite.
*/
import { describe, expect, test } from 'vitest';

import { hasEnvelopeClaim, validateEnvelopeMeta } from '../../src/shared/envelope.js';
import type { InboundHttpRequest, InboundLegacyRoute } from '../../src/shared/inboundClassification.js';
import {
classifyInboundRequest,
modernOnlyStrictRejection,
PROVISIONAL_CROSS_CHECK_MISMATCH_CODE
} from '../../src/shared/inboundClassification.js';
import { classifyInboundRequest, modernOnlyStrictRejection } from '../../src/shared/inboundClassification.js';
import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../src/types/constants.js';

const MODERN_REVISION = '2026-07-28';
const MISMATCH_CODE_CANDIDATES = [-32_001, -32_602, -32_004];

const ENVELOPE = {
[PROTOCOL_VERSION_META_KEY]: MODERN_REVISION,
Expand Down Expand Up @@ -66,12 +60,10 @@ const expectMismatch = (outcome: ReturnType<typeof classifyInboundRequest>, cell
expect(outcome.cell).toBe(cell);
expect(outcome.rung).toBe('era-classification');
expect(outcome.httpStatus).toBe(400);
// Parameterized: the exact code for the mismatch family is not settled
// upstream. The classifier emits the provisional constant; assert set
// membership rather than a literal of our own.
expect(outcome.settled).toBe(false);
expect(outcome.code).toBe(PROVISIONAL_CROSS_CHECK_MISMATCH_CODE);
expect(MISMATCH_CODE_CANDIDATES).toContain(outcome.code);
// Pinned: a header/body disagreement is a header-validation failure and
// answers -32001 (HeaderMismatch), per the published conformance suite.
expect(outcome.settled).toBe(true);
expect(outcome.code).toBe(-32_001);
};

describe('envelope claim detection (claim = the reserved protocol-version key)', () => {
Expand Down Expand Up @@ -219,15 +211,47 @@ describe('body-primary era predicate', () => {
});
});

describe('header cross-checks (parameterized mismatch family)', () => {
describe('header cross-checks (-32001 HeaderMismatch) and the missing-envelope rejection (-32602)', () => {
test('a body claim disagreeing with the protocol-version header is a mismatch outcome', () => {
const outcome = classifyInboundRequest(post(modernToolsCall(), { protocolVersion: '2025-06-18' }));
expectMismatch(outcome, 'header-body-version-mismatch');
});

test('a modern header on a claim-less body is a mismatch outcome, not an upgrade', () => {
test('a modern header on a claim-less body is rejected with invalid params naming the missing _meta envelope', () => {
// Never an upgrade and never a silent legacy fallthrough: the modern
// revisions require the per-request envelope, so the request is
// answered as missing required params.
const outcome = classifyInboundRequest(post(legacyToolsList(), { protocolVersion: MODERN_REVISION }));
expectMismatch(outcome, 'modern-header-without-claim');
expect(outcome).toMatchObject({
kind: 'reject',
rung: 'envelope',
cell: 'modern-header-without-claim',
httpStatus: 400,
code: -32_602,
settled: true,
data: { envelope: { missing: ['_meta'] } }
});
});

test('a modern header on a body whose _meta lacks the protocol-version key names that key as missing', () => {
const body = {
jsonrpc: '2.0',
id: 4,
method: 'tools/list',
params: { _meta: { [CLIENT_INFO_META_KEY]: { name: 'c', version: '1' }, [CLIENT_CAPABILITIES_META_KEY]: {} } }
};
const outcome = classifyInboundRequest(post(body, { protocolVersion: MODERN_REVISION }));
expect(outcome).toMatchObject({
kind: 'reject',
rung: 'envelope',
cell: 'modern-header-without-claim',
httpStatus: 400,
code: -32_602,
settled: true,
data: { envelope: { missing: [PROTOCOL_VERSION_META_KEY] } }
});
if (outcome.kind !== 'reject') return;
expect(outcome.message).toContain(PROTOCOL_VERSION_META_KEY);
});

test('initialize with a modern protocol-version header is a mismatch outcome', () => {
Expand Down
Loading
Loading