diff --git a/.changeset/pin-modern-rejection-codes.md b/.changeset/pin-modern-rejection-codes.md new file mode 100644 index 0000000000..f54bdd9785 --- /dev/null +++ b/.changeset/pin-modern-rejection-codes.md @@ -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. diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts index 14d7b16f54..3dd85ad2ad 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -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'; @@ -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 * ------------------------------------------------------------------------ */ @@ -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', @@ -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', @@ -293,7 +321,7 @@ export const LADDER_ERROR_HTTP_STATUS: Readonly> = { [ProtocolErrorCode.MethodNotFound]: 404, [ProtocolErrorCode.UnsupportedProtocolVersion]: 400, [ProtocolErrorCode.MissingRequiredClientCapability]: 400, - [-32_001]: 400 + [HEADER_MISMATCH_ERROR_CODE]: 400 }; /** @@ -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 * ------------------------------------------------------------------------ */ @@ -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 ); } @@ -504,13 +515,29 @@ function classifyRequestBody(request: InboundHttpRequest, body: Record 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 }) }; @@ -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 diff --git a/packages/core/test/shared/errorHttpStatusMatrix.test.ts b/packages/core/test/shared/errorHttpStatusMatrix.test.ts index 6cab1c46d0..7f505daece 100644 --- a/packages/core/test/shared/errorHttpStatusMatrix.test.ts +++ b/packages/core/test/shared/errorHttpStatusMatrix.test.ts @@ -13,9 +13,9 @@ * 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 @@ -23,11 +23,7 @@ */ 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', () => { @@ -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); }); }); diff --git a/packages/core/test/shared/inboundClassification.test.ts b/packages/core/test/shared/inboundClassification.test.ts index 30d21c94e5..d288fd21d8 100644 --- a/packages/core/test/shared/inboundClassification.test.ts +++ b/packages/core/test/shared/inboundClassification.test.ts @@ -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, @@ -66,12 +60,10 @@ const expectMismatch = (outcome: ReturnType, 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)', () => { @@ -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', () => { diff --git a/packages/core/test/shared/inboundLadderCellSheet.test.ts b/packages/core/test/shared/inboundLadderCellSheet.test.ts index 9eedf58f52..6713e3bd4b 100644 --- a/packages/core/test/shared/inboundLadderCellSheet.test.ts +++ b/packages/core/test/shared/inboundLadderCellSheet.test.ts @@ -1,14 +1,16 @@ /** * The inbound validation-ladder cell sheet. * - * Each row names one ladder cell, whether its outcome is pinned or - * parameterized, the conformance scenarios that exercise it (where one - * exists), and the expected outcome. Pinned rows assert exact codes and HTTP - * statuses; parameterized rows assert the outcome class and that the emitted - * code is the documented provisional value drawn from the candidate set — - * those cells are re-derived when a published conformance release settles the - * disputed assignments (see the note in - * `test/conformance/expected-failures.yaml`). + * Each row names one ladder cell, the conformance scenarios that exercise it + * (where one exists), and the expected outcome with its exact code and HTTP + * status. The header/body mismatch and missing-envelope cells were originally + * parameterized (asserted as candidate-set membership) while their error codes + * were under discussion upstream; they are now pinned to the assignments the + * published conformance suite asserts (`-32001` HeaderMismatch for header/body + * disagreements, `-32602` invalid params naming the missing key(s) for a + * missing envelope or missing protocol-version key). If a future published + * conformance release changes an assignment, the affected rows are re-derived + * here. * * Cells evaluated at protocol dispatch (the era registry gate, per-method * params, capability assertion) are listed for ordering and status mapping @@ -23,13 +25,11 @@ import { httpStatusForErrorCode, INBOUND_VALIDATION_LADDER, LADDER_ERROR_HTTP_STATUS, - modernOnlyStrictRejection, - PROVISIONAL_CROSS_CHECK_MISMATCH_CODE + 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, @@ -54,8 +54,6 @@ const post = (body: unknown, headers: { protocolVersion?: string; mcpMethod?: st interface SheetRow { /** Stable cell identifier (matches `InboundLadderRejection.cell` for rejection cells). */ cell: string; - /** Pinned cells assert exact outcomes; parameterized cells assert the provisional outcome + candidate-set membership. */ - status: 'pinned' | 'parameterized'; /** Conformance scenarios exercising the cell, where one exists in the published referee. */ conformance: readonly string[]; /** The classifier input. */ @@ -64,7 +62,7 @@ interface SheetRow { strict?: boolean; /** The expected outcome for routing cells. */ route?: 'legacy' | 'modern'; - /** The expected rejection (exact for pinned cells; for parameterized cells `code` is the provisional value). */ + /** The expected rejection, asserted exactly. */ reject?: Partial; /** Why the cell behaves the way it does. */ rationale: string; @@ -74,7 +72,6 @@ const SHEET: readonly SheetRow[] = [ /* --- Routing cells (pinned) --------------------------------------------------- */ { cell: 'modern-enveloped-request', - status: 'pinned', conformance: ['server-stateless'], input: post(enveloped('tools/call', { name: 'echo', arguments: {} }), { protocolVersion: MODERN_REVISION }), route: 'modern', @@ -82,7 +79,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'modern-enveloped-request-header-stripped', - status: 'pinned', conformance: ['server-stateless'], input: post(enveloped('tools/call', { name: 'echo', arguments: {} })), route: 'modern', @@ -90,7 +86,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'legacy-claimless-request', - status: 'pinned', conformance: [], input: post(bare('tools/list'), { protocolVersion: '2025-06-18' }), route: 'legacy', @@ -98,7 +93,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'legacy-initialize', - status: 'pinned', conformance: [], input: post(bare('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '1' } })), route: 'legacy', @@ -106,7 +100,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'modern-enveloped-initialize', - status: 'pinned', conformance: ['server-stateless'], input: post(enveloped('initialize'), { protocolVersion: MODERN_REVISION, mcpMethod: 'initialize' }), route: 'modern', @@ -117,7 +110,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'legacy-method-routed-get', - status: 'pinned', conformance: [], input: { httpMethod: 'GET' }, route: 'legacy', @@ -125,7 +117,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'legacy-notification-stripped-header', - status: 'pinned', conformance: [], input: post({ jsonrpc: '2.0', method: 'notifications/initialized' }), route: 'legacy', @@ -134,7 +125,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'modern-notification-by-header', - status: 'pinned', conformance: ['http-header-validation'], input: post({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 1 } }, { protocolVersion: MODERN_REVISION }), route: 'modern', @@ -142,7 +132,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'legacy-batch', - status: 'pinned', conformance: [], input: post([bare('tools/list')]), route: 'legacy', @@ -150,7 +139,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'legacy-response-post', - status: 'pinned', conformance: [], input: post({ jsonrpc: '2.0', id: 5, result: {} }), route: 'legacy', @@ -160,7 +148,6 @@ const SHEET: readonly SheetRow[] = [ /* --- Edge rejection cells (pinned) -------------------------------------------- */ { cell: 'envelope-invalid', - status: 'pinned', conformance: ['server-stateless'], input: post({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION } } }), reject: { rung: 'envelope', httpStatus: 400, code: -32_602, settled: true }, @@ -168,7 +155,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'batch-with-modern-element', - status: 'pinned', conformance: [], input: post([bare('tools/list'), enveloped('tools/call', { name: 'echo', arguments: {} })]), reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, @@ -176,7 +162,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'batch-with-invalid-element', - status: 'pinned', conformance: [], input: post([bare('tools/list'), { nonsense: true }]), reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, @@ -184,7 +169,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'invalid-json-rpc-body', - status: 'pinned', conformance: [], input: post({ hello: 'world' }), reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, @@ -195,7 +179,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'empty-batch', - status: 'pinned', conformance: [], input: post([]), reject: { rung: 'jsonrpc-shape', httpStatus: 400, code: -32_600, settled: true }, @@ -206,7 +189,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'notification-envelope-invalid', - status: 'pinned', conformance: [], input: post({ jsonrpc: '2.0', method: 'notifications/progress', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: 42 } } }), reject: { rung: 'envelope', httpStatus: 400, code: -32_602, settled: true }, @@ -218,7 +200,6 @@ const SHEET: readonly SheetRow[] = [ /* --- Modern-only (strict) cells (pinned) --------------------------------------- */ { cell: 'modern-only-missing-envelope', - status: 'pinned', conformance: ['server-stateless'], input: post(bare('tools/list')), strict: true, @@ -229,7 +210,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'modern-only-missing-envelope-initialize', - status: 'pinned', conformance: ['server-stateless'], input: post(bare('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '1' } })), strict: true, @@ -246,7 +226,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'modern-only-method-not-allowed', - status: 'pinned', conformance: [], input: { httpMethod: 'DELETE' }, strict: true, @@ -255,7 +234,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'modern-only-batch-not-supported', - status: 'pinned', conformance: [], input: post([bare('tools/list')]), strict: true, @@ -264,7 +242,6 @@ const SHEET: readonly SheetRow[] = [ }, { cell: 'modern-only-response-post', - status: 'pinned', conformance: [], input: post({ jsonrpc: '2.0', id: 5, result: {} }), strict: true, @@ -272,88 +249,89 @@ const SHEET: readonly SheetRow[] = [ rationale: 'There is no server-to-client request channel on the modern era, so posted responses are invalid requests.' }, - /* --- Parameterized cells (disputed error-code assignments) --------------------- */ + /* --- Header cross-check and missing-envelope cells (pinned to the published suite) --- */ { cell: 'header-body-version-mismatch', - status: 'parameterized', - conformance: ['http-header-validation', 'http-custom-header-server-validation'], + conformance: ['server-stateless', 'http-header-validation', 'http-custom-header-server-validation'], input: post(enveloped('tools/call', { name: 'echo', arguments: {} }), { protocolVersion: '2025-06-18' }), - reject: { rung: 'era-classification', httpStatus: 400, settled: false }, - rationale: 'Header/body protocol-version disagreement; the exact code is still under discussion upstream.' + reject: { rung: 'era-classification', httpStatus: 400, code: -32_001, settled: true }, + rationale: + 'Header/body protocol-version disagreement is a header-validation failure: -32001 (HeaderMismatch) on HTTP 400, as ' + + 'asserted by the published conformance suite.' }, { cell: 'modern-header-without-claim', - status: 'parameterized', - conformance: ['http-header-validation'], + conformance: ['server-stateless'], input: post(bare('tools/list'), { protocolVersion: MODERN_REVISION }), - reject: { rung: 'era-classification', httpStatus: 400, settled: false }, - rationale: 'A modern header on a claim-less body is a disagreement, not an upgrade; code pending upstream settlement.' + reject: { rung: 'envelope', httpStatus: 400, code: -32_602, settled: true }, + rationale: + 'A modern protocol-version header on a claim-less body is a modern-classified request missing its required _meta ' + + 'envelope: invalid params naming the missing key(s), never an upgrade and never a silent legacy fallthrough.' }, { cell: 'initialize-with-modern-header', - status: 'parameterized', - conformance: ['http-header-validation'], + conformance: [], input: post(bare('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'c', version: '1' } }), { protocolVersion: MODERN_REVISION }), - reject: { rung: 'era-classification', httpStatus: 400, settled: false }, - rationale: 'An envelope-less initialize classifies legacy; a modern header on it is the same disagreement family.' + reject: { rung: 'era-classification', httpStatus: 400, code: -32_001, settled: true }, + rationale: + 'An envelope-less initialize classifies legacy; a modern header on it is a header/body disagreement and answers the ' + + 'same -32001 (HeaderMismatch) as the rest of the mismatch family.' }, { cell: 'method-header-mismatch', - status: 'parameterized', - conformance: ['http-custom-header-server-validation'], + conformance: ['http-header-validation', 'http-custom-header-server-validation'], input: post(enveloped('tools/call', { name: 'echo', arguments: {} }), { protocolVersion: MODERN_REVISION, mcpMethod: 'tools/list' }), - reject: { rung: 'era-classification', httpStatus: 400, settled: false }, - rationale: 'The Mcp-Method header must describe the body it accompanies; the rejection code is pending upstream settlement.' + reject: { rung: 'era-classification', httpStatus: 400, code: -32_001, settled: true }, + rationale: + 'The Mcp-Method header must describe the body it accompanies; a disagreement is a header-validation failure and ' + + 'answers -32001 (HeaderMismatch) on HTTP 400.' }, { cell: 'notification-header-body-version-mismatch', - status: 'parameterized', conformance: [], input: post( { jsonrpc: '2.0', method: 'notifications/progress', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION } } }, { protocolVersion: '2025-06-18' } ), - reject: { rung: 'era-classification', httpStatus: 400, settled: false }, + reject: { rung: 'era-classification', httpStatus: 400, code: -32_001, settled: true }, rationale: - 'A notification body claim disagreeing with the protocol-version header is the same disagreement family as the request ' + - 'cells above; the exact code is still under discussion upstream.' + 'A notification body claim disagreeing with the protocol-version header is the same header-validation failure as the ' + + 'request cells above and answers the same -32001 (HeaderMismatch).' }, { cell: 'notification-method-header-mismatch', - status: 'parameterized', conformance: [], input: post( { jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken: 1, progress: 1 } }, { protocolVersion: MODERN_REVISION, mcpMethod: 'notifications/cancelled' } ), - reject: { rung: 'era-classification', httpStatus: 400, settled: false }, + reject: { rung: 'era-classification', httpStatus: 400, code: -32_001, settled: true }, rationale: 'The Mcp-Method header must describe the notification body it accompanies (validated only when the notification ' + - 'classifies modern); the rejection code is pending upstream settlement.' + 'classifies modern); a disagreement answers -32001 (HeaderMismatch).' }, { cell: 'multi-fault-mismatched-claim-and-malformed-envelope', - status: 'parameterized', conformance: ['server-stateless', 'http-header-validation'], // The claim names a different version than the header AND the envelope - // is missing required keys: today the envelope rung answers (the - // mismatch is only checked on a valid envelope), so the emitted code is - // -32602 — but the precedence between the era-classification and - // envelope rungs for multi-fault requests is part of the disputed set. + // is missing required keys: the envelope rung answers (the header + // cross-check is only evaluated on a valid envelope), so the emitted + // code is the envelope rung's -32602. input: post( { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION } } }, { protocolVersion: '2025-06-18' } ), - reject: { httpStatus: 400 }, + reject: { rung: 'envelope', httpStatus: 400, code: -32_602, settled: true }, rationale: - 'Multi-fault precedence between the version error and invalid params is not settled upstream; asserted as candidate-set membership only.' + 'Multi-fault precedence: envelope validity is checked before the header cross-check, so the malformed envelope answers ' + + 'with invalid params; the mismatch is never reached.' } ]; @@ -383,26 +361,15 @@ describe('inbound validation-ladder cell sheet', () => { expect(outcome.kind).toBe('reject'); if (outcome.kind !== 'reject') return; - if (row.status === 'pinned') { - expect(outcome).toMatchObject(row.reject ?? {}); - } else { - // Parameterized: outcome class and provisional code only — the - // exact assignment is re-derived from a future conformance pin. - if (row.reject?.rung !== undefined) expect(outcome.rung).toBe(row.reject.rung); - if (row.reject?.httpStatus !== undefined) expect(outcome.httpStatus).toBe(row.reject.httpStatus); - expect(MISMATCH_CODE_CANDIDATES).toContain(outcome.code); - if (row.reject?.settled !== undefined) { - expect(outcome.settled).toBe(row.reject.settled); - expect(outcome.code).toBe(PROVISIONAL_CROSS_CHECK_MISMATCH_CODE); - } - } + expect(outcome).toMatchObject(row.reject ?? {}); }); - test('every cell id is unique and every parameterized cell is marked unsettled or candidate-bound', () => { + test('every cell id is unique and every rejection row pins an expected outcome', () => { const ids = SHEET.map(row => row.cell); expect(new Set(ids).size).toBe(ids.length); - for (const row of SHEET.filter(candidate => candidate.status === 'parameterized')) { - expect(row.reject).toBeDefined(); + for (const row of SHEET.filter(candidate => candidate.route === undefined)) { + expect(row.reject?.code).toBeDefined(); + expect(row.reject?.httpStatus).toBeDefined(); } }); }); diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index 0170a12ead..a07df6f264 100644 --- a/packages/server/test/server/createMcpHandler.test.ts +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -201,7 +201,7 @@ describe('createMcpHandler — modern path', () => { expect(state.contexts).toHaveLength(0); }); - it('keeps the disputed header/body mismatch cells inside the candidate code set (parameterized, not pinned)', async () => { + it('rejects a header/body protocol-version mismatch with -32001 (HeaderMismatch) over HTTP 400', async () => { const { factory } = testFactory(); const onerror = vi.fn(); const handler = createMcpHandler(factory, { onerror }); @@ -209,12 +209,33 @@ describe('createMcpHandler — modern path', () => { const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'x' }), { 'mcp-protocol-version': '2025-11-25' })); expect(response.status).toBe(400); const body = (await response.json()) as JSONRPCErrorBody; - expect([-32_001, -32_602, -32_004]).toContain(body.error.code); - // Whatever the disputed code lands on, the rejection echoes the request id. + expect(body.error.code).toBe(-32_001); + // The rejection echoes the request id. expect(body.id).toBe(1); expect(onerror).toHaveBeenCalled(); }); + it('rejects a modern-classified request without a _meta envelope with -32602 naming the missing key over HTTP 400', async () => { + const { factory, state } = testFactory(); + const handler = createMcpHandler(factory); + + // The MCP-Protocol-Version header names the modern revision but the body + // carries no per-request envelope: invalid params naming what is missing, + // not a version error and not silent legacy serving. + const response = await handler.fetch( + postRequest( + { jsonrpc: '2.0', id: 11, method: 'tools/list', params: {} }, + { 'mcp-protocol-version': MODERN_REVISION, 'mcp-method': 'tools/list' } + ) + ); + expect(response.status).toBe(400); + const body = (await response.json()) as JSONRPCErrorBody; + expect(body.error.code).toBe(-32_602); + expect(JSON.stringify(body.error.data)).toContain('_meta'); + expect(body.id).toBe(11); + expect(state.contexts).toHaveLength(0); + }); + it('answers entry-internal failures with 500/-32603 and reports them through onerror', async () => { const onerror = vi.fn(); const handler = createMcpHandler( diff --git a/packages/server/test/server/eraSupport.test.ts b/packages/server/test/server/eraSupport.test.ts index 0b95b9e46f..78ff050cb7 100644 --- a/packages/server/test/server/eraSupport.test.ts +++ b/packages/server/test/server/eraSupport.test.ts @@ -161,9 +161,10 @@ describe("DV-31: strict 'modern' on a long-lived connection", () => { const response = await request({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }); expect(isJSONRPCErrorResponse(response)).toBe(true); if (isJSONRPCErrorResponse(response)) { - // Note: this cell shares its numeric code (−32004) with the - // still-disputed header/body mismatch family; the cell itself is - // settled (unsupported protocol version + supported list). + // The envelope-less request on a modern-only instance answers the + // unsupported-protocol-version error with the supported list (the + // HTTP entry's header/body mismatch cells use −32001 instead; there + // is no header layer on a long-lived connection). expect(response.error.code).toBe(-32_004); const data = response.error.data as { supported?: string[]; requested?: string }; // The strict instance serves only modern revisions, so the supported diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index abfb3751d3..486df89058 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -10,11 +10,11 @@ # are still not shipped in the published release — the runner reports them # unknown/failed; their entries below cover them either way. # -# NOTE: the draft error-code assignments exercised by the SEP-2243 server -# scenarios (-32001 HeaderMismatch) and their neighbours (-32602, -32004) are -# still under discussion upstream (pending conformance #336). Those cells are -# treated as parameterized, not settled: the entries below record today's -# referee behavior and are re-derived when a #336-containing referee is pinned. +# NOTE: the SDK's modern-path rejection codes are aligned with what this +# referee asserts: header/body mismatches answer -32001 (HeaderMismatch) and a +# missing _meta envelope (or missing protocolVersion key) answers -32602. +# If a future published conformance release changes those assignments, the +# affected cells are re-derived when that release is pinned. # # Entries are grouped by SEP. As each SEP/milestone is implemented in the SDK the # corresponding scenarios start passing and MUST be removed from this list (the @@ -78,9 +78,10 @@ server: # SEP-2549 (caching): no ttlMs/cacheScope support; scenario also hits the # stateful-mode "Session ID required" error. - caching - # SEP-2243 (HTTP header standardization): -32001 HeaderMismatch handling and - # case-insensitive/whitespace-trimmed header validation not implemented. - # (Error-code cells parameterized pending conformance #336 — see header note.) + # SEP-2243 (HTTP header standardization): the reject cells the SDK does + # answer now use -32001 (HeaderMismatch), but missing-header enforcement + # (Mcp-Method, Mcp-Name) and the Mcp-Name cross-check are not implemented, + # so those reject cells are still accepted with 200. - http-header-validation - http-custom-header-server-validation # WARNING-only entries: these scenarios emit no FAILURE checks, only SHOULD-level