From 2464526e0654c412be3d472bd85a7efb2cc0bb66 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 21:48:05 +0000 Subject: [PATCH 1/8] =?UTF-8?q?feat(core):=20SEP-2243=20standard-header=20?= =?UTF-8?q?server=20validation=20=E2=80=94=20Mcp-Method/Mcp-Name=20presenc?= =?UTF-8?q?e=20and=20Mcp-Name=20cross-check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `validateStandardRequestHeaders`, evaluated by the HTTP entry on a modern-classified request immediately after `classifyInboundRequest` returns a modern route. Rejects `400`/`-32001` (`HeaderMismatch`) — the same shape and rung the classifier already emits for the `MCP-Protocol-Version` and `Mcp-Method` mismatch cells — when the required `Mcp-Method` header is absent, when the required `Mcp-Name` header is absent on a `tools/call`/`prompts/get`/`resources/read` request, when `Mcp-Name` carries an invalid Base64 sentinel, and when its (decoded) value disagrees with `params.name`/`params.uri`. Kept separate from `classifyInboundRequest` so a body-only call to the classifier keeps routing a modern request unchanged: the classifier remains a pure body-primary router, and this function is the presence/`Mcp-Name` half of the standard-header rung the entry layers on top. `InboundHttpRequest` gains an additive optional `mcpNameHeader` field. --- packages/client/src/client/streamableHttp.ts | 7 +- .../core/src/shared/inboundClassification.ts | 103 ++++++++++ .../shared/standardHeaderValidation.test.ts | 184 ++++++++++++++++++ 3 files changed, 290 insertions(+), 4 deletions(-) create mode 100644 packages/core/test/shared/standardHeaderValidation.test.ts diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 440422c130..9c7495a57c 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -332,10 +332,9 @@ export class StreamableHTTPClientTransport implements Transport { // non-ASCII name/URI (or one with leading/trailing whitespace, // control characters, or CR/LF) cannot make `Headers.set()` throw a // TypeError or silently normalize to a value that differs from the - // body. The spec's value-encoding rules apply to `Mcp-Name`; this - // SDK's server does not yet cross-check `Mcp-Name` against the body - // (tracked in expected-failures.yaml) — when it does it will decode - // the sentinel before comparison. + // body. The spec's value-encoding rules apply to `Mcp-Name`; the SDK + // server's `validateStandardRequestHeaders` decodes the sentinel via + // `decodeMcpParamValue` before the `Mcp-Name` ↔ body cross-check. const params = message.params as { name?: unknown; uri?: unknown } | undefined; const nameHeader = message.method === 'resources/read' diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts index 800d65941e..b31e287646 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -69,6 +69,12 @@ import { ProtocolError, UnsupportedProtocolVersionError } from '../types/errors. import { isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResultResponse } from '../types/guards.js'; import type { JSONRPCNotification, JSONRPCRequest, MessageClassification } from '../types/types.js'; import { envelopeClaimVersion, hasEnvelopeClaim, requestMetaOf, validateEnvelopeMeta } from './envelope.js'; +// Value encoding is shared between the standard `Mcp-Name` header and the +// custom `Mcp-Param-*` headers; the codec module already imports the +// `HeaderMismatch` constant and rejection type from here, so this is a benign +// two-module cycle (both sides only consume the other's exports inside +// function bodies, never at module-evaluation time). +import { decodeMcpParamValue } from './mcpParamHeaders.js'; import { isModernProtocolVersion } from './protocolEras.js'; /* ------------------------------------------------------------------------ * @@ -88,6 +94,8 @@ export interface InboundHttpRequest { protocolVersionHeader?: string; /** The value of the `Mcp-Method` header, when present. */ mcpMethodHeader?: string; + /** The value of the `Mcp-Name` header, when present. */ + mcpNameHeader?: string; /** The parsed JSON request body (`undefined` for body-less methods). */ body?: unknown; } @@ -408,6 +416,101 @@ function crossCheckMismatch(cell: string, header: string, body: string): Inbound ); } +/** + * The methods whose body carries a `params.name` / `params.uri` value the + * `Mcp-Name` header must mirror, and which body field supplies it (SEP-2243 + * § Standard Request Headers, `Required For` column). + */ +export const MCP_NAME_HEADER_SOURCE: Readonly> = { + 'tools/call': 'name', + 'prompts/get': 'name', + 'resources/read': 'uri' +}; + +/** + * SEP-2243 standard-header server-side validation, evaluated by the HTTP + * entry on a modern-classified request immediately after + * {@linkcode classifyInboundRequest} returns a modern route. + * + * Returns the `-32001` (`HeaderMismatch`) ladder rejection (HTTP `400`, + * `era-classification` rung — the same shape and rung + * {@linkcode classifyInboundRequest} already emits for the + * `MCP-Protocol-Version` and `Mcp-Method` *mismatch* cells) when: + * + * - the required `Mcp-Method` header is absent; + * - the required `Mcp-Name` header is absent on a `tools/call`, + * `prompts/get`, or `resources/read` request whose body carries the + * `params.name` / `params.uri` value the header mirrors; + * - the `Mcp-Name` header carries an invalid `=?base64?…?=` sentinel; or + * - the (decoded) `Mcp-Name` value disagrees with the body's + * `params.name` / `params.uri`. + * + * Returns `undefined` (pass) for notifications (the spec table reads + * "All requests"), for methods that have no `Mcp-Name` source, and when the + * headers agree with the body. Never enforced on legacy traffic — the entry + * only calls this on a modern route. + * + * Kept separate from {@linkcode classifyInboundRequest} so that a body-only + * call to the classifier (no headers passed) keeps routing a modern request + * unchanged: the classifier remains a pure body-primary router, and this + * function is the presence/`Mcp-Name` half of the standard-header rung the + * entry layers on top. + */ +export function validateStandardRequestHeaders(request: InboundHttpRequest, route: InboundModernRoute): InboundLadderRejection | undefined { + if (route.messageKind !== 'request') { + return undefined; + } + const method = route.message.method; + + if (request.mcpMethodHeader === undefined) { + return crossCheckMismatch( + 'method-header-missing', + '(missing)', + `the body names method ${method} but the required Mcp-Method header is absent` + ); + } + + const sourceField = MCP_NAME_HEADER_SOURCE[method]; + if (sourceField === undefined) { + return undefined; + } + const params = route.message.params as Record | undefined; + const sourceValue = params?.[sourceField]; + const bodyValue = typeof sourceValue === 'string' ? sourceValue : undefined; + + if (request.mcpNameHeader === undefined) { + // The header is required for these methods whenever the body carries + // the source value. A body without `params.name`/`params.uri` is a + // params-validation failure further down the ladder; this rung only + // answers the missing-header case it can observe. + if (bodyValue === undefined) { + return undefined; + } + return crossCheckMismatch( + 'name-header-missing', + '(missing)', + `the body carries params.${sourceField}="${bodyValue}" but the required Mcp-Name header is absent` + ); + } + + const decoded = decodeMcpParamValue(request.mcpNameHeader); + if (decoded === undefined) { + return crossCheckMismatch( + 'name-header-invalid-encoding', + request.mcpNameHeader, + 'the Mcp-Name header carries an invalid Base64 sentinel value' + ); + } + if (bodyValue !== undefined && decoded !== bodyValue) { + return crossCheckMismatch( + 'name-header-mismatch', + request.mcpNameHeader, + `the body carries params.${sourceField}="${bodyValue}" but the Mcp-Name header names "${decoded}"` + ); + } + return undefined; +} + function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } diff --git a/packages/core/test/shared/standardHeaderValidation.test.ts b/packages/core/test/shared/standardHeaderValidation.test.ts new file mode 100644 index 0000000000..47735f72b7 --- /dev/null +++ b/packages/core/test/shared/standardHeaderValidation.test.ts @@ -0,0 +1,184 @@ +/** + * SEP-2243 standard-header server-side validation + * (`validateStandardRequestHeaders`). + * + * Evaluated by the HTTP entry on a modern-classified request immediately + * after `classifyInboundRequest` returns a modern route: rejects `400` / + * `-32001` (`HeaderMismatch`) when the required `Mcp-Method` header is + * absent, when the required `Mcp-Name` header is absent on a `tools/call` / + * `prompts/get` / `resources/read` request, when the `Mcp-Name` header + * carries an invalid Base64 sentinel, and when its (decoded) value disagrees + * with the body's `params.name` / `params.uri`. Never enforced on + * notifications or on methods without an `Mcp-Name` source. + * + * The classifier itself is left unchanged by these rungs (it stays a + * body-primary router that passes a modern request through when no headers + * are supplied) — this function is the presence/`Mcp-Name` half of the + * standard-header rung the entry layers on top, so the existing + * `inboundClassification` and cell-sheet tests stay byte-untouched. + */ +import { describe, expect, test } from 'vitest'; + +import type { InboundHttpRequest, InboundLadderRejection, InboundModernRoute } from '../../src/shared/inboundClassification.js'; +import { classifyInboundRequest, MCP_NAME_HEADER_SOURCE, validateStandardRequestHeaders } from '../../src/shared/inboundClassification.js'; +import { encodeMcpParamValue } from '../../src/shared/mcpParamHeaders.js'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../src/types/constants.js'; + +const MODERN = '2026-07-28'; +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'std-header-test', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +function modernPost( + method: string, + params: Record, + headers: { mcpMethod?: string; mcpName?: string } = {} +): { request: InboundHttpRequest; route: InboundModernRoute } { + const request: InboundHttpRequest = { + httpMethod: 'POST', + protocolVersionHeader: MODERN, + ...(headers.mcpMethod !== undefined && { mcpMethodHeader: headers.mcpMethod }), + ...(headers.mcpName !== undefined && { mcpNameHeader: headers.mcpName }), + body: { jsonrpc: '2.0', id: 1, method, params: { ...params, _meta: ENVELOPE } } + }; + const outcome = classifyInboundRequest(request); + if (outcome.kind !== 'modern') { + throw new Error(`expected a modern route, got ${outcome.kind}`); + } + return { request, route: outcome }; +} + +function expectRejection(result: InboundLadderRejection | undefined, cell: string): void { + expect(result).toBeDefined(); + expect(result?.kind).toBe('reject'); + expect(result?.cell).toBe(cell); + expect(result?.rung).toBe('era-classification'); + expect(result?.httpStatus).toBe(400); + expect(result?.code).toBe(-32_001); + expect(result?.settled).toBe(true); +} + +describe('SEP-2243 standard-header validation (Mcp-Method presence)', () => { + test('a modern request without an Mcp-Method header is rejected (method-header-missing)', () => { + const { request, route } = modernPost('tools/list', {}); + expectRejection(validateStandardRequestHeaders(request, route), 'method-header-missing'); + }); + + test('a present Mcp-Method header passes for a method with no Mcp-Name source', () => { + const { request, route } = modernPost('tools/list', {}, { mcpMethod: 'tools/list' }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('the Mcp-Method mismatch cell stays inside classifyInboundRequest (precedence over presence)', () => { + // The mismatch is answered by the classifier itself; this function + // never sees a route for that input. Asserted here so the + // standard-header rung's two halves stay observably ordered. + const inbound: InboundHttpRequest = { + httpMethod: 'POST', + protocolVersionHeader: MODERN, + mcpMethodHeader: 'prompts/list', + body: { jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: ENVELOPE } } + }; + const outcome = classifyInboundRequest(inbound); + expect(outcome.kind).toBe('reject'); + expect((outcome as InboundLadderRejection).cell).toBe('method-header-mismatch'); + }); + + test('notifications are never enforced', () => { + const route: InboundModernRoute = { + kind: 'modern', + messageKind: 'notification', + message: { jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 1 } }, + classification: { era: 'modern', revision: MODERN } + }; + expect(validateStandardRequestHeaders({ httpMethod: 'POST' }, route)).toBeUndefined(); + }); +}); + +describe('SEP-2243 standard-header validation (Mcp-Name presence and cross-check)', () => { + test('a tools/call without an Mcp-Name header is rejected (name-header-missing)', () => { + const { request, route } = modernPost('tools/call', { name: 'echo', arguments: {} }, { mcpMethod: 'tools/call' }); + expectRejection(validateStandardRequestHeaders(request, route), 'name-header-missing'); + }); + + test('a resources/read without an Mcp-Name header is rejected and names params.uri', () => { + const { request, route } = modernPost('resources/read', { uri: 'file:///a' }, { mcpMethod: 'resources/read' }); + const result = validateStandardRequestHeaders(request, route); + expectRejection(result, 'name-header-missing'); + expect(result?.message).toContain('params.uri'); + }); + + test('a tools/call whose body has no params.name passes the Mcp-Name presence rung', () => { + // The missing `params.name` is a request-params failure further down + // the ladder; this rung only answers what it can observe. + const { request, route } = modernPost('tools/call', { arguments: {} }, { mcpMethod: 'tools/call' }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('an Mcp-Name header disagreeing with params.name is rejected (name-header-mismatch)', () => { + const { request, route } = modernPost( + 'tools/call', + { name: 'echo', arguments: {} }, + { mcpMethod: 'tools/call', mcpName: 'wrong_tool_name' } + ); + const result = validateStandardRequestHeaders(request, route); + expectRejection(result, 'name-header-mismatch'); + expect((result?.data as { mismatch?: { header?: string } })?.mismatch?.header).toBe('wrong_tool_name'); + }); + + test('a Base64-sentinel Mcp-Name decodes before comparison (matching)', () => { + const { request, route } = modernPost( + 'tools/call', + { name: 'Hello, 世界', arguments: {} }, + { mcpMethod: 'tools/call', mcpName: encodeMcpParamValue('Hello, 世界') } + ); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('a Base64-sentinel Mcp-Name decodes before comparison (mismatch names the decoded value)', () => { + const { request, route } = modernPost( + 'tools/call', + { name: 'echo', arguments: {} }, + { mcpMethod: 'tools/call', mcpName: encodeMcpParamValue('not-echo') } + ); + const result = validateStandardRequestHeaders(request, route); + expectRejection(result, 'name-header-mismatch'); + expect(result?.message).toContain('"not-echo"'); + }); + + test('an invalid Base64 sentinel in Mcp-Name is rejected (name-header-invalid-encoding)', () => { + const { request, route } = modernPost( + 'tools/call', + { name: 'echo', arguments: {} }, + { mcpMethod: 'tools/call', mcpName: '=?base64?SGVs!!!bG8=?=' } + ); + expectRejection(validateStandardRequestHeaders(request, route), 'name-header-invalid-encoding'); + }); + + test('a matching Mcp-Name on a prompts/get passes', () => { + const { request, route } = modernPost('prompts/get', { name: 'greeting' }, { mcpMethod: 'prompts/get', mcpName: 'greeting' }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('a matching Mcp-Name on a resources/read compares against params.uri', () => { + const uri = 'file:///projects/app/config.json'; + const { request, route } = modernPost('resources/read', { uri }, { mcpMethod: 'resources/read', mcpName: uri }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('the Mcp-Name source map covers exactly the spec table', () => { + expect(MCP_NAME_HEADER_SOURCE).toEqual({ 'tools/call': 'name', 'prompts/get': 'name', 'resources/read': 'uri' }); + }); +}); + +describe('classifyInboundRequest is unchanged by the standard-header presence rung', () => { + test('a body-only modern request (no headers passed) still routes modern', () => { + const outcome = classifyInboundRequest({ + httpMethod: 'POST', + body: { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'echo', arguments: {}, _meta: ENVELOPE } } + }); + expect(outcome.kind).toBe('modern'); + }); +}); From 33d39ca13bbbb218a9eec50a1036406f42776a0c Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 21:50:42 +0000 Subject: [PATCH 2/8] feat(server): SEP-2243 standard-header validation at the createMcpHandler entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createMcpHandler` now reads `Mcp-Name` and runs `validateStandardRequestHeaders` on a modern-classified request immediately after the body-primary classifier returns a modern route. The four hand-written test fixtures that build modern requests directly (`postRequest`/`nodeRequestResponse` in createMcpHandler.test.ts, `listenRequest` in createMcpHandlerListen.test.ts, `postEcho` in createMcpHandlerCapabilityGate.test.ts, `call` in mcpParamValidation.test.ts) now derive the SEP-2243 standard headers from the body they send, exactly as a conformant client does — fixture-only, no assertion changes. Legacy test cells stay byte-untouched (the derivation only emits for a body carrying a modern envelope claim). --- .changeset/sep-2243-std-header-server.md | 10 ++ docs/migration.md | 26 +-- examples/json-response/client.ts | 3 +- .../server/src/server/createMcpHandler.ts | 27 ++- .../test/server/createMcpHandler.test.ts | 22 ++- .../createMcpHandlerCapabilityGate.test.ts | 7 +- .../server/createMcpHandlerListen.test.ts | 12 +- .../test/server/mcpParamValidation.test.ts | 1 + .../test/server/stdHeaderValidation.test.ts | 168 ++++++++++++++++++ 9 files changed, 260 insertions(+), 16 deletions(-) create mode 100644 .changeset/sep-2243-std-header-server.md create mode 100644 packages/server/test/server/stdHeaderValidation.test.ts diff --git a/.changeset/sep-2243-std-header-server.md b/.changeset/sep-2243-std-header-server.md new file mode 100644 index 0000000000..1ffbc57341 --- /dev/null +++ b/.changeset/sep-2243-std-header-server.md @@ -0,0 +1,10 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/server': minor +--- + +SEP-2243 standard-header server-side validation (protocol revision 2026-07-28). On the modern (2026-07-28) serving path, `createMcpHandler` now enforces the required `Mcp-Method` and `Mcp-Name` standard request headers in addition to the existing `MCP-Protocol-Version` and `Mcp-Method` cross-checks: a modern request without an `Mcp-Method` header, a `tools/call` / `prompts/get` / `resources/read` request without an `Mcp-Name` header, an `Mcp-Name` header carrying an invalid `=?base64?…?=` sentinel, and an `Mcp-Name` header whose (decoded) value disagrees with the body's `params.name` / `params.uri` are all rejected with `400 Bad Request` and JSON-RPC `-32001` (`HeaderMismatch`). The 2025-era serving paths are unchanged. + +New public surface: + +- `@modelcontextprotocol/core`: `validateStandardRequestHeaders` (function), `MCP_NAME_HEADER_SOURCE` (const), the `mcpNameHeader` field on `InboundHttpRequest`, and the `'standard-header-validation'` member of `InboundValidationRung` (with `client-capabilities` / `param-header-validation` renumbered). diff --git a/docs/migration.md b/docs/migration.md index 5c5c63a8bb..c78eaf9fc0 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1056,16 +1056,16 @@ versionNegotiation: { } ``` -`maxRetries` governs timeout re-sends only (the spec-mandated `-32004` corrective continuation — select-and-continue with a mutual version — is a separate negotiation step and is never counted against it). Negotiation can also be configured pre-connect on an -already-constructed instance via `client.setVersionNegotiation(options)` (equivalent to the constructor option; throws after connecting). +`maxRetries` governs timeout re-sends only (the spec-mandated `-32004` corrective continuation — select-and-continue with a mutual version — is a separate negotiation step and is never counted against it). Negotiation can also be configured pre-connect on an already-constructed +instance via `client.setVersionNegotiation(options)` (equivalent to the constructor option; throws after connecting). -Once a modern era is negotiated, the client **automatically attaches the per-request `_meta` envelope** (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification — you never set it by hand. Any `_meta` keys you pass -in a request are preserved over the auto-attached ones. After connect, `client.getProtocolEra()` returns `'legacy'` or `'modern'` and `client.getNegotiatedProtocolVersion()` the exact revision. +Once a modern era is negotiated, the client **automatically attaches the per-request `_meta` envelope** (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification — you never set it by hand. Any `_meta` keys you pass in a +request are preserved over the auto-attached ones. After connect, `client.getProtocolEra()` returns `'legacy'` or `'modern'` and `client.getNegotiatedProtocolVersion()` the exact revision. On the server side, `server/discover` (advertising only the modern revisions) is served by instances hosted through one of the 2026-era serving entries; a hand-constructed `Server`/`McpServer` is byte-identical to before (it keeps answering `-32601`, and the `initialize` -handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the next section; serving it on stdio (and -other long-lived connections) is the `serveStdio` entry point described after that. The client can also issue `client.discover()` directly on a 2026-era connection; on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that -protocol revision. +handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the next section; serving it on stdio (and other +long-lived connections) is the `serveStdio` entry point described after that. The client can also issue `client.discover()` directly on a 2026-era connection; on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that protocol +revision. ### Serving the 2026-07-28 draft revision over HTTP: `createMcpHandler` @@ -1140,6 +1140,10 @@ skips the reserved standard/auth header names so a per-request header cannot ove `Client.listTools()` (and the client's internal `tools/list` cache) exclude tool definitions whose `x-mcp-header` declarations violate the spec's constraints, logging a warning naming the tool and the reason. Browser clients skip mirroring (dynamically named headers cannot be statically allow-listed for credentialed CORS); calling an `x-mcp-header` tool with a non-null designated argument from a browser against a conforming SEP-2243 server is therefore a known limitation. +On the modern path, `createMcpHandler` also validates the SEP-2243 **standard** request-metadata headers against the body and rejects with the same `400` / `-32001` (`HeaderMismatch`) when the `MCP-Protocol-Version` or `Mcp-Method` header disagrees with the body, when the +required `Mcp-Method` header is absent, when the required `Mcp-Name` header is absent on a `tools/call` / `prompts/get` / `resources/read` request, and when the (Base64-sentinel-decoded) `Mcp-Name` value disagrees with `params.name` / `params.uri`. These checks only fire on the +modern (2026-07-28) serving path — 2025-era traffic is unchanged — and a hand-built modern HTTP request must carry the `Mcp-Method` (and where applicable `Mcp-Name`) header; the SDK client already sends them. + ### Serving the 2026-07-28 draft revision on stdio: `serveStdio` The server package ships a stdio entry point that mirrors `createMcpHandler` for long-lived connections: the entry owns the transport and the era decision, the client's opening exchange selects the era for the connection, and ONE instance from your factory is pinned to that @@ -1213,9 +1217,11 @@ const handler = createMcpHandler(() => buildServer()); handler.notify.toolsChanged(); ``` -**Client side.** `ClientOptions.listChanged` keeps working: on a 2026-07-28 connection the SDK auto-opens a `subscriptions/listen` stream whose filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities, so the same handlers -fire on every published change (the auto-opened subscription is exposed at `client.autoOpenedSubscription` for `close()`; when the intersection is empty auto-open is skipped and `autoOpenedSubscription` stays `undefined`). `client.listen(filter)` opens a stream explicitly and resolves once the server's acknowledged notification arrives with `{ honoredFilter, close(), closed }` (where `closed` is a `Promise<'local' | 'remote'>` that resolves once on termination — `'remote'` means the server cancelled, the stream ended, or the transport dropped, so re-listen if you still want events); change notifications dispatch to the existing `setNotificationHandler` -registrations. `resources/subscribe` is 2025-only — on a 2026-07-28 connection, request `notifications/resources/updated` via the `resourceSubscriptions` field of the listen filter instead. +**Client side.** `ClientOptions.listChanged` keeps working: on a 2026-07-28 connection the SDK auto-opens a `subscriptions/listen` stream whose filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities, so the same handlers fire +on every published change (the auto-opened subscription is exposed at `client.autoOpenedSubscription` for `close()`; when the intersection is empty auto-open is skipped and `autoOpenedSubscription` stays `undefined`). `client.listen(filter)` opens a stream explicitly and resolves +once the server's acknowledged notification arrives with `{ honoredFilter, close(), closed }` (where `closed` is a `Promise<'local' | 'remote'>` that resolves once on termination — `'remote'` means the server cancelled, the stream ended, or the transport dropped, so re-listen if +you still want events); change notifications dispatch to the existing `setNotificationHandler` registrations. `resources/subscribe` is 2025-only — on a 2026-07-28 connection, request `notifications/resources/updated` via the `resourceSubscriptions` field of the listen filter +instead. ### Multi round-trip requests (2026-07-28): write-once handlers and the client auto-fulfilment driver diff --git a/examples/json-response/client.ts b/examples/json-response/client.ts index 147d91f097..d047a0a59d 100644 --- a/examples/json-response/client.ts +++ b/examples/json-response/client.ts @@ -18,7 +18,8 @@ runClient('json-response', async () => { headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', - 'mcp-protocol-version': '2026-07-28' + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': 'tools/list' }, body: JSON.stringify({ jsonrpc: '2.0', diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 5d02871a6e..fb6dbb0148 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -52,7 +52,8 @@ import { setNegotiatedProtocolVersion, SUPPORTED_MODERN_PROTOCOL_VERSIONS, UnsupportedProtocolVersionError, - validateMcpParamHeaders + validateMcpParamHeaders, + validateStandardRequestHeaders } from '@modelcontextprotocol/core'; import { invoke } from './invoke.js'; @@ -471,6 +472,7 @@ async function classifyEntryRequest(request: Request, providedParsedBody?: unkno httpMethod, protocolVersionHeader: request.headers.get('mcp-protocol-version') ?? undefined, mcpMethodHeader: request.headers.get('mcp-method') ?? undefined, + mcpNameHeader: request.headers.get('mcp-name') ?? undefined, ...(body !== undefined && { body }) }); return { step: 'classified', outcome, body, parsedBody, forwardRequest }; @@ -644,6 +646,29 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa return jsonRpcErrorResponse(400, error.code, error.message, error.data, echoableRequestId(route.message)); } + // SEP-2243 standard-header presence and `Mcp-Name` cross-check + // (`era-classification` rung; the `MCP-Protocol-Version` and + // `Mcp-Method` *mismatch* cells are already answered inside + // `classifyInboundRequest`). Evaluated after the supported-revision + // gate so an envelope naming a revision this endpoint does not serve + // is still answered with `-32004` (the supported list is the more + // useful answer to a client speaking the wrong revision); evaluated + // before the capability gate, the factory call, and the + // `Mcp-Param-*` rung so a request that fails several rungs is + // answered by the standard-header rung first. + const stdHeaderRejection = validateStandardRequestHeaders( + { + httpMethod: request.method, + mcpMethodHeader: request.headers.get('mcp-method') ?? undefined, + mcpNameHeader: request.headers.get('mcp-name') ?? undefined + }, + route + ); + if (stdHeaderRejection !== undefined) { + reportError(new Error(`Rejected inbound request (${stdHeaderRejection.cell}): ${stdHeaderRejection.message}`)); + return rejectionResponse(stdHeaderRejection, echoableRequestId(route.message)); + } + const meta = route.messageKind === 'request' ? requestMetaOf(route.message.params) : undefined; const declaredClientCapabilities = meta?.[CLIENT_CAPABILITIES_META_KEY] as ClientCapabilities | undefined; diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index 0a069376ac..ee5e8dcf9e 100644 --- a/packages/server/test/server/createMcpHandler.test.ts +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -41,12 +41,30 @@ function modernToolsCall(name: string, args: Record, envelope: }; } +/** + * The SEP-2243 standard headers a conformant client derives from the body it + * sends. Only emitted for a body carrying a modern envelope claim, so legacy + * test cells stay byte-untouched; spread before any explicit `headers` so a + * caller that needs to test a stripped or disagreeing header can override. + */ +function bodyDerivedStandardHeaders(body: unknown): Record { + if (body === null || typeof body !== 'object' || Array.isArray(body)) return {}; + const b = body as { method?: unknown; params?: { name?: unknown; uri?: unknown; _meta?: Record } }; + if (typeof b.params?._meta?.[PROTOCOL_VERSION_META_KEY] !== 'string') return {}; + const out: Record = {}; + if (typeof b.method === 'string') out['mcp-method'] = b.method; + const name = b.method === 'resources/read' ? b.params.uri : b.params.name; + if (typeof name === 'string') out['mcp-name'] = name; + return out; +} + function postRequest(body: unknown, headers: Record = {}): Request { return new Request('http://localhost/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', + ...bodyDerivedStandardHeaders(body), ...headers }, body: typeof body === 'string' ? body : JSON.stringify(body) @@ -782,6 +800,7 @@ describe('createMcpHandler — handler faces', () => { const parsed = modernToolsCall('echo', { text: 'pre-parsed' }); const { req, res, body } = nodeRequestResponse(undefined); + Object.assign(req.headers, bodyDerivedStandardHeaders(parsed)); await handler.node(req, res, parsed); expect(res.statusCode).toBe(200); expect(await body()).toContain('pre-parsed'); @@ -939,7 +958,8 @@ function nodeRequestResponse(body: unknown): { headers: { host: 'localhost:3000', 'content-type': 'application/json', - accept: 'application/json, text/event-stream' + accept: 'application/json, text/event-stream', + ...bodyDerivedStandardHeaders(body) } as Record }); diff --git a/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts b/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts index 3decb71b60..9d40e8e66f 100644 --- a/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts +++ b/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts @@ -33,7 +33,12 @@ const envelope = (clientCapabilities: ClientCapabilities) => ({ function postEcho(clientCapabilities: ClientCapabilities): Request { return new Request('http://localhost/mcp', { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'tools/call', + 'mcp-name': 'echo' + }, body: JSON.stringify({ jsonrpc: '2.0', id: 7, diff --git a/packages/server/test/server/createMcpHandlerListen.test.ts b/packages/server/test/server/createMcpHandlerListen.test.ts index 479e17b905..980e7e7578 100644 --- a/packages/server/test/server/createMcpHandlerListen.test.ts +++ b/packages/server/test/server/createMcpHandlerListen.test.ts @@ -27,7 +27,11 @@ const ENVELOPE = { function listenRequest(id: string | number, filter: Record): Request { return new Request('http://localhost/mcp', { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, body: JSON.stringify({ jsonrpc: '2.0', id, @@ -165,7 +169,11 @@ describe('createMcpHandler — subscriptions/listen', () => { const response = await handler.fetch( new Request('http://localhost/mcp', { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, body: JSON.stringify({ jsonrpc: '2.0', id: 9, method: 'subscriptions/listen', params: { _meta: ENVELOPE } }) }) ); diff --git a/packages/server/test/server/mcpParamValidation.test.ts b/packages/server/test/server/mcpParamValidation.test.ts index 015d63203d..8c49d92b49 100644 --- a/packages/server/test/server/mcpParamValidation.test.ts +++ b/packages/server/test/server/mcpParamValidation.test.ts @@ -52,6 +52,7 @@ function call(args: Record, paramHeaders: Record McpServer { + return () => { + const s = new McpServer({ name: 'std-header-server', version: '1.0.0' }); + s.registerTool('echo', { inputSchema: z.object({ text: z.string().optional() }) }, async ({ text }) => ({ + content: [{ type: 'text', text: text ?? 'ok' }] + })); + return s; + }; +} + +function modernRequest(method: string, params: Record, headers: Record = {}): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-protocol-version': MODERN, + ...headers + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 5, method, params: { ...params, _meta: ENVELOPE } }) + }); +} + +async function expectHeaderMismatch(response: Response): Promise<{ code: number; message: string }> { + expect(response.status).toBe(400); + const body = (await response.json()) as { id: unknown; error: { code: number; message: string } }; + expect(body.id).toBe(5); + expect(body.error.code).toBe(-32_001); + return body.error; +} + +describe('SEP-2243 standard-header validation (createMcpHandler, modern era)', () => { + it('a fully conformant tools/call passes and dispatches', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch( + modernRequest('tools/call', { name: 'echo', arguments: { text: 'hi' } }, { 'mcp-method': 'tools/call', 'mcp-name': 'echo' }) + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('hi'); + }); + + it('a missing Mcp-Method header is rejected 400/-32001', async () => { + const handler = createMcpHandler(makeFactory()); + const error = await expectHeaderMismatch(await handler.fetch(modernRequest('tools/list', {}))); + expect(error.message).toContain('Mcp-Method header is absent'); + }); + + it('a missing Mcp-Name header on tools/call is rejected 400/-32001', async () => { + const handler = createMcpHandler(makeFactory()); + const error = await expectHeaderMismatch( + await handler.fetch(modernRequest('tools/call', { name: 'echo', arguments: {} }, { 'mcp-method': 'tools/call' })) + ); + expect(error.message).toContain('Mcp-Name header is absent'); + }); + + it('an Mcp-Name header disagreeing with params.name is rejected 400/-32001', async () => { + const handler = createMcpHandler(makeFactory()); + const error = await expectHeaderMismatch( + await handler.fetch( + modernRequest('tools/call', { name: 'echo', arguments: {} }, { 'mcp-method': 'tools/call', 'mcp-name': 'wrong' }) + ) + ); + expect(error.message).toContain('Mcp-Name header names "wrong"'); + }); + + it('Mcp-Name accepts an OWS-padded value (RFC 9110 §5.5; Fetch Headers normalises)', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch( + modernRequest('tools/call', { name: 'echo', arguments: {} }, { 'mcp-method': 'tools/call', 'mcp-name': ' echo ' }) + ); + expect(response.status).toBe(200); + }); + + it('Mcp-Name decodes a Base64 sentinel before comparison', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch( + modernRequest( + 'tools/call', + { name: 'echo', arguments: {} }, + { 'mcp-method': 'tools/call', 'mcp-name': encodeMcpParamValue('echo') } + ) + ); + // `encodeMcpParamValue('echo')` is plain ASCII, so the sentinel is not + // applied; assert the explicit-sentinel case below instead. + expect(response.status).toBe(200); + const sentinel = await handler.fetch( + modernRequest( + 'tools/call', + { name: 'echo', arguments: {} }, + { 'mcp-method': 'tools/call', 'mcp-name': `=?base64?${Buffer.from('echo').toString('base64')}?=` } + ) + ); + expect(sentinel.status).toBe(200); + }); + + it('an invalid Mcp-Name Base64 sentinel is rejected 400/-32001', async () => { + const handler = createMcpHandler(makeFactory()); + await expectHeaderMismatch( + await handler.fetch( + modernRequest( + 'tools/call', + { name: 'echo', arguments: {} }, + { 'mcp-method': 'tools/call', 'mcp-name': '=?base64?SGVsbG8?=' } + ) + ) + ); + }); + + it('Mcp-Name is not required for methods outside its source map', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch(modernRequest('tools/list', {}, { 'mcp-method': 'tools/list' })); + expect(response.status).toBe(200); + }); +}); + +describe('SEP-2243 standard-header validation is era-gated', () => { + it('legacy traffic is byte-untouched: a 2025-era tools/list without standard headers still serves', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 5, + method: 'initialize', + params: { protocolVersion: '2025-11-25', clientInfo: { name: 'c', version: '1' }, capabilities: {} } + }) + }) + ); + // The default 'stateless' legacy posture answers initialize. + expect(response.status).toBe(200); + }); +}); From 20ed3eaf7e224545b30e862179b040ef2bb890f4 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 21:57:59 +0000 Subject: [PATCH 3/8] test(conformance): burn down http-header-validation; add SEP-2243 standard headers to raw-request fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes `http-header-validation` from both server expected-failures baselines (the scenario is now 13/0). The four hand-written raw-request sites in test/integration and test/e2e that POST a modern envelope directly at `createMcpHandler` (the subscriptions/listen ack-first cell, the capacity-guard cell, the honored-filter integration check, and the hosting-entry-session router probe) now carry the SEP-2243 standard headers a conformant client sends — fixture-only, no assertion changes. --- test/conformance/expected-failures.2026-07-28.yaml | 7 ------- test/conformance/expected-failures.yaml | 5 ----- test/e2e/scenarios/hosting-entry-session.test.ts | 7 ++++++- test/e2e/scenarios/subscriptions.test.ts | 12 ++++++++++-- .../integration/test/server/createMcpHandler.test.ts | 6 +++++- 5 files changed, 21 insertions(+), 16 deletions(-) diff --git a/test/conformance/expected-failures.2026-07-28.yaml b/test/conformance/expected-failures.2026-07-28.yaml index dc675aff4d..3a5194f209 100644 --- a/test/conformance/expected-failures.2026-07-28.yaml +++ b/test/conformance/expected-failures.2026-07-28.yaml @@ -73,10 +73,3 @@ server: # (WARNING-only; the expected-failures evaluator counts WARNINGs as # failures). Same failure as in the 2025 baseline. - sep-2164-resource-not-found - - # --- Draft scenarios (same failures and reasons as the `--suite draft` leg) --- - # 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 diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index 3a178dcf9c..9fed2dee25 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -46,11 +46,6 @@ client: server: # --- Draft-spec scenarios (in `--suite draft`; the default `active` suite is green) --- - # 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 # WARNING-only entry: the scenario emits no FAILURE checks, only a SHOULD-level # WARNING, but the expected-failures evaluator counts WARNINGs as failures. # SEP-2164: server returns -32002 without the requested URI in error.data. diff --git a/test/e2e/scenarios/hosting-entry-session.test.ts b/test/e2e/scenarios/hosting-entry-session.test.ts index a41a1b8bbb..2a09b95d42 100644 --- a/test/e2e/scenarios/hosting-entry-session.test.ts +++ b/test/e2e/scenarios/hosting-entry-session.test.ts @@ -169,7 +169,12 @@ verifies('typescript:hosting:entry:byo-sessionful-legacy', async () => { const exchangesBeforeModernProbe = legacyExchanges.length; const modernProbe = await fetchViaRouter(url, { method: 'POST', - headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-method': 'tools/call', + 'mcp-name': 'greet' + }, body: JSON.stringify({ jsonrpc: '2.0', id: 100, diff --git a/test/e2e/scenarios/subscriptions.test.ts b/test/e2e/scenarios/subscriptions.test.ts index bbca6105d5..cd9fea8981 100644 --- a/test/e2e/scenarios/subscriptions.test.ts +++ b/test/e2e/scenarios/subscriptions.test.ts @@ -42,7 +42,11 @@ verifies('subscriptions:listen:ack-first-stamped', async () => { const response = await handler.fetch( new Request('http://in-process/mcp', { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, body: JSON.stringify({ jsonrpc: '2.0', id: 'sub-1', @@ -140,7 +144,11 @@ verifies('subscriptions:listen:capacity-guard', async () => { handler.fetch( new Request('http://in-process/mcp', { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, body: JSON.stringify({ jsonrpc: '2.0', id, diff --git a/test/integration/test/server/createMcpHandler.test.ts b/test/integration/test/server/createMcpHandler.test.ts index ae2a30aac8..6ce066ed1e 100644 --- a/test/integration/test/server/createMcpHandler.test.ts +++ b/test/integration/test/server/createMcpHandler.test.ts @@ -169,7 +169,11 @@ describe('createMcpHandler over HTTP — subscriptions/listen honored filter', ( const response = await fetch(new URL('/mcp', baseUrl), { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, body: JSON.stringify({ jsonrpc: '2.0', id: 'sub-1', From 2ebba126d767cc7d4a388fb9c45889c2437cf37b Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 19 Jun 2026 12:08:16 +0000 Subject: [PATCH 4/8] fix(core): guard MCP_NAME_HEADER_SOURCE lookup with Object.hasOwn The body method string is peer-controlled; a bare plain-object lookup returns inherited Object.prototype members for names like 'constructor' or 'toString', so the off-table early-return would not fire and an invalid Mcp-Name sentinel on such a request was answered 400/-32001 instead of reaching dispatch's -32601. Match the established idiom in requiredClientCapabilitiesForRequest. --- packages/core/src/shared/inboundClassification.ts | 6 +++++- packages/core/test/shared/standardHeaderValidation.test.ts | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts index b31e287646..ab149b09e5 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -470,7 +470,11 @@ export function validateStandardRequestHeaders(request: InboundHttpRequest, rout ); } - const sourceField = MCP_NAME_HEADER_SOURCE[method]; + // `method` is the JSON-RPC method string from the body — peer-controlled, + // so guard the plain-object lookup against `Object.prototype` collisions + // (`constructor`, `toString`, …) the same way the client-capability table + // lookup does. + const sourceField = Object.hasOwn(MCP_NAME_HEADER_SOURCE, method) ? MCP_NAME_HEADER_SOURCE[method] : undefined; if (sourceField === undefined) { return undefined; } diff --git a/packages/core/test/shared/standardHeaderValidation.test.ts b/packages/core/test/shared/standardHeaderValidation.test.ts index 47735f72b7..e5547645c7 100644 --- a/packages/core/test/shared/standardHeaderValidation.test.ts +++ b/packages/core/test/shared/standardHeaderValidation.test.ts @@ -171,6 +171,13 @@ describe('SEP-2243 standard-header validation (Mcp-Name presence and cross-check test('the Mcp-Name source map covers exactly the spec table', () => { expect(MCP_NAME_HEADER_SOURCE).toEqual({ 'tools/call': 'name', 'prompts/get': 'name', 'resources/read': 'uri' }); }); + + test('a method colliding with Object.prototype members is treated as off-table (passes through to dispatch)', () => { + // `constructor` would return Object.prototype.constructor on a bare + // lookup; the Object.hasOwn guard keeps the early-return firing. + const { request, route } = modernPost('constructor', {}, { mcpMethod: 'constructor', mcpName: '=?base64?!!?=' }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); }); describe('classifyInboundRequest is unchanged by the standard-header presence rung', () => { From 72b7a06f0ce94c1fa8a6166a4388afe6675a9e24 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 19 Jun 2026 12:52:18 +0000 Subject: [PATCH 5/8] test(e2e): add SEP-2243 Mcp-Method header/body mismatch rejection cell on the entryModern arm Adds the sep-2243:std-header:mismatch-rejected requirement (entryModern, 2026-07-28) and its scenario body: a raw envelope-carrying tools/call POSTed with an Mcp-Method: tools/list header is rejected by the createMcpHandler entry with HTTP 400 and the -32001 HeaderMismatch JSON-RPC error code. --- test/e2e/requirements.ts | 8 ++++++++ test/e2e/scenarios/sep2243.test.ts | 33 +++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 7f58a901da..5789be6c73 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2604,6 +2604,14 @@ export const REQUIREMENTS: Record = { addedInSpecVersion: '2026-07-28', note: 'Runs on the entryModern arm; the Mcp-Param-{Name} header is asserted on the arm-recorded HTTP request headers and the encoded value is checked against the SEP-2243 codec.' }, + 'sep-2243:std-header:mismatch-rejected': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#standard-request-headers', + behavior: + 'A 2026-07-28 request whose Mcp-Method header disagrees with the JSON-RPC method in the body is rejected by the createMcpHandler entry with HTTP 400 carrying a JSON-RPC error with the SEP-2243 HeaderMismatch code.', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: 'Runs on the entryModern arm; the body POSTs a raw envelope-carrying tools/call with an Mcp-Method: tools/list header through wired.fetch and asserts the 400 status and the HeaderMismatch error code on the response bytes.' + }, // Multi round-trip requests (SEP-2322, protocol revision 2026-07-28) 'typescript:mrtr:tools-call:write-once-roundtrip': { source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr', diff --git a/test/e2e/scenarios/sep2243.test.ts b/test/e2e/scenarios/sep2243.test.ts index 60beb935e5..8d3ab17f92 100644 --- a/test/e2e/scenarios/sep2243.test.ts +++ b/test/e2e/scenarios/sep2243.test.ts @@ -10,7 +10,7 @@ import { encodeMcpParamValue, MCP_PARAM_HEADER_PREFIX } from '@modelcontextproto import { fromJsonSchema, McpServer } from '@modelcontextprotocol/server'; import { expect } from 'vitest'; -import { wire } from '../helpers/index.js'; +import { modernEnvelopeMeta, wire } from '../helpers/index.js'; import { verifies } from '../helpers/verifies.js'; import type { TestArgs } from '../types.js'; @@ -58,3 +58,34 @@ verifies('sep-2243:param-header:roundtrip', async ({ transport }: TestArgs) => { expect(result.isError).toBeFalsy(); expect(result.content).toEqual([{ type: 'text', text: 'region=us-west1' }]); }); + +verifies('sep-2243:std-header:mismatch-rejected', async ({ transport }: TestArgs) => { + const makeServer = () => new McpServer({ name: 'e2e-sep2243-std', version: '1.0.0' }, { capabilities: { tools: {} } }); + const client = new Client({ name: 'sep2243-std-client', version: '1.0.0' }); + await using wired = await wire(transport, makeServer, client); + + // Raw POST through the harness-hosted entry: the body is a valid + // envelope-carrying tools/call, but the Mcp-Method header names + // tools/list. The era-classification rung answers the disagreement + // before any factory instance is constructed. + const response = await wired.fetch!(wired.url!, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-method': 'tools/list' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'locate', arguments: {}, _meta: modernEnvelopeMeta({ name: 'sep2243-std-client', version: '1.0.0' }) } + }) + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { code: number; message: string } }; + // -32001 is the SEP-2243 HeaderMismatch code at this branch's spec pin. + expect(body.error.code).toBe(-32_001); + expect(body.error.message).toMatch(/Mcp-Method/); +}); From d82a55efaf2281a49955886b7148a190e6c5630a Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 19 Jun 2026 13:24:47 +0000 Subject: [PATCH 6/8] fix(core): stamp std-header rejection cells on a dedicated pre-dispatch ladder rung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four validateStandardRequestHeaders rejection cells (method-header-missing, name-header-missing, name-header-invalid-encoding, name-header-mismatch) were stamped 'era-classification', whose ladder descriptor says evaluatedAt: 'edge' — but they are evaluated by the HTTP entry's serveModern path after the supported-revision gate, not by the edge classifier. Add a 'standard-header-validation' rung (order 8, evaluatedAt: 'pre-dispatch', sitting between client-capabilities and the param-header rung, which moves to order 9) and re-stamp the four cells with it. The classifier's own header-mismatch cells (protocol-version, Mcp-Method mismatch) stay on the edge era-classification rung. crossCheckMismatch grows an optional rung parameter so the shared shape stays single-source. --- .../core/src/shared/inboundClassification.ts | 43 ++++++++++++++----- .../shared/standardHeaderValidation.test.ts | 2 +- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts index ab149b09e5..0dfa363e94 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -170,6 +170,7 @@ export type InboundValidationRung = | 'method-registry' | 'request-params' | 'client-capabilities' + | 'standard-header-validation' | 'param-header-validation'; /** A ladder rejection: the JSON-RPC error to emit and the HTTP status to emit it with. */ @@ -327,10 +328,22 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor 'consult the method registry before the gate if the documented precedence is to stay observable.' }, { - rung: 'param-header-validation', + rung: 'standard-header-validation', order: 8, evaluatedAt: 'pre-dispatch', codes: [HEADER_MISMATCH_ERROR_CODE], + conformance: ['http-header-validation'], + rationale: + 'SEP-2243 standard `Mcp-Method` / `Mcp-Name` headers — presence, sentinel decoding, and `Mcp-Name` ↔ body cross-check ' + + '— are validated by the HTTP entry on a modern-classified request after the supported-revision gate and before ' + + 'dispatch. The classifier’s own header-mismatch cells (protocol-version, `Mcp-Method` mismatch) stay on the edge ' + + '`era-classification` rung; this rung carries the entry-layer presence/`Mcp-Name` half.' + }, + { + rung: 'param-header-validation', + order: 9, + evaluatedAt: 'pre-dispatch', + codes: [HEADER_MISMATCH_ERROR_CODE], conformance: ['http-custom-header-server-validation'], rationale: 'SEP-2243 `Mcp-Param-*` headers are validated against the named tool’s `x-mcp-header` declarations and the body ' + @@ -404,9 +417,14 @@ function rejection( }; } -function crossCheckMismatch(cell: string, header: string, body: string): InboundLadderRejection { +function crossCheckMismatch( + cell: string, + header: string, + body: string, + rung: InboundValidationRung = 'era-classification' +): InboundLadderRejection { return rejection( - 'era-classification', + rung, cell, 400, new ProtocolError(HEADER_MISMATCH_ERROR_CODE, `Bad Request: the request headers and body disagree: ${body}`, { @@ -433,9 +451,10 @@ export const MCP_NAME_HEADER_SOURCE: Readonly> = * {@linkcode classifyInboundRequest} returns a modern route. * * Returns the `-32001` (`HeaderMismatch`) ladder rejection (HTTP `400`, - * `era-classification` rung — the same shape and rung - * {@linkcode classifyInboundRequest} already emits for the - * `MCP-Protocol-Version` and `Mcp-Method` *mismatch* cells) when: + * `standard-header-validation` rung — the same shape + * {@linkcode classifyInboundRequest} already emits on the edge + * `era-classification` rung for the `MCP-Protocol-Version` and + * `Mcp-Method` *mismatch* cells) when: * * - the required `Mcp-Method` header is absent; * - the required `Mcp-Name` header is absent on a `tools/call`, @@ -466,7 +485,8 @@ export function validateStandardRequestHeaders(request: InboundHttpRequest, rout return crossCheckMismatch( 'method-header-missing', '(missing)', - `the body names method ${method} but the required Mcp-Method header is absent` + `the body names method ${method} but the required Mcp-Method header is absent`, + 'standard-header-validation' ); } @@ -493,7 +513,8 @@ export function validateStandardRequestHeaders(request: InboundHttpRequest, rout return crossCheckMismatch( 'name-header-missing', '(missing)', - `the body carries params.${sourceField}="${bodyValue}" but the required Mcp-Name header is absent` + `the body carries params.${sourceField}="${bodyValue}" but the required Mcp-Name header is absent`, + 'standard-header-validation' ); } @@ -502,14 +523,16 @@ export function validateStandardRequestHeaders(request: InboundHttpRequest, rout return crossCheckMismatch( 'name-header-invalid-encoding', request.mcpNameHeader, - 'the Mcp-Name header carries an invalid Base64 sentinel value' + 'the Mcp-Name header carries an invalid Base64 sentinel value', + 'standard-header-validation' ); } if (bodyValue !== undefined && decoded !== bodyValue) { return crossCheckMismatch( 'name-header-mismatch', request.mcpNameHeader, - `the body carries params.${sourceField}="${bodyValue}" but the Mcp-Name header names "${decoded}"` + `the body carries params.${sourceField}="${bodyValue}" but the Mcp-Name header names "${decoded}"`, + 'standard-header-validation' ); } return undefined; diff --git a/packages/core/test/shared/standardHeaderValidation.test.ts b/packages/core/test/shared/standardHeaderValidation.test.ts index e5547645c7..97baadb407 100644 --- a/packages/core/test/shared/standardHeaderValidation.test.ts +++ b/packages/core/test/shared/standardHeaderValidation.test.ts @@ -54,7 +54,7 @@ function expectRejection(result: InboundLadderRejection | undefined, cell: strin expect(result).toBeDefined(); expect(result?.kind).toBe('reject'); expect(result?.cell).toBe(cell); - expect(result?.rung).toBe('era-classification'); + expect(result?.rung).toBe('standard-header-validation'); expect(result?.httpStatus).toBe(400); expect(result?.code).toBe(-32_001); expect(result?.settled).toBe(true); From 87c636832876052c685d993ea7a8b0915cc5e074 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 19 Jun 2026 13:44:30 +0000 Subject: [PATCH 7/8] docs(core): name standard-header-validation rung at call site + test header; add precedence caveat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The serveModern call-site comment and the server stdHeaderValidation test header still attributed the presence/Mcp-Name rejections to the edge era-classification rung after 5779aab49 re-stamped them onto the dedicated standard-header-validation rung. Both now name the rung explicitly (and note the classifier's mismatch cells stay on era-classification). The new rung's documented order (8) is also not the observed precedence — serveModern evaluates it immediately after the supported-revision gate, before the dispatch rungs (5-6) and the capability gate (7). Rather than renumber (wider blast across the ladder table and the param-header rung), add a precedence caveat to its rationale, mirroring the client-capabilities entry's caveat. --- packages/core/src/shared/inboundClassification.ts | 5 ++++- packages/server/src/server/createMcpHandler.ts | 5 +++-- packages/server/test/server/stdHeaderValidation.test.ts | 7 ++++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts index 0dfa363e94..bd079f8365 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -337,7 +337,10 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor 'SEP-2243 standard `Mcp-Method` / `Mcp-Name` headers — presence, sentinel decoding, and `Mcp-Name` ↔ body cross-check ' + '— are validated by the HTTP entry on a modern-classified request after the supported-revision gate and before ' + 'dispatch. The classifier’s own header-mismatch cells (protocol-version, `Mcp-Method` mismatch) stay on the edge ' + - '`era-classification` rung; this rung carries the entry-layer presence/`Mcp-Name` half.' + '`era-classification` rung; this rung carries the entry-layer presence/`Mcp-Name` half. The documented order ' + + '(after method resolution, params validation, and the capability gate) is not the observed precedence: serveModern ' + + 'evaluates this rung immediately after the supported-revision gate, so a request that fails several rungs is ' + + 'answered by this gate before the dispatch rungs (5–6) and the capability gate (7) are consulted.' }, { rung: 'param-header-validation', diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index fb6dbb0148..4f51c2ccc3 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -647,9 +647,10 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa } // SEP-2243 standard-header presence and `Mcp-Name` cross-check - // (`era-classification` rung; the `MCP-Protocol-Version` and + // (`standard-header-validation` rung; the `MCP-Protocol-Version` and // `Mcp-Method` *mismatch* cells are already answered inside - // `classifyInboundRequest`). Evaluated after the supported-revision + // `classifyInboundRequest` on the edge `era-classification` rung). + // Evaluated after the supported-revision // gate so an envelope naming a revision this endpoint does not serve // is still answered with `-32004` (the supported list is the more // useful answer to a client speaking the wrong revision); evaluated diff --git a/packages/server/test/server/stdHeaderValidation.test.ts b/packages/server/test/server/stdHeaderValidation.test.ts index 46d1939330..5432f3f640 100644 --- a/packages/server/test/server/stdHeaderValidation.test.ts +++ b/packages/server/test/server/stdHeaderValidation.test.ts @@ -8,9 +8,10 @@ * header, a missing `Mcp-Name` header on a `tools/call` / `prompts/get` / * `resources/read` request, an `Mcp-Name` value disagreeing with * `params.name` / `params.uri`, and an invalid `Mcp-Name` Base64 sentinel are - * all rejected `400` / `-32001` (`HeaderMismatch`) — the same shape and rung - * the classifier already emits for the `MCP-Protocol-Version` and - * `Mcp-Method` mismatch cells. Legacy-era traffic is byte-unchanged. + * all rejected `400` / `-32001` (`HeaderMismatch`) on the + * `standard-header-validation` rung — the same shape the classifier already + * emits for the `MCP-Protocol-Version` and `Mcp-Method` mismatch cells on the + * edge `era-classification` rung. Legacy-era traffic is byte-unchanged. */ import { CLIENT_CAPABILITIES_META_KEY, From 55bc820accedfc0f902f15771978566a439a6374 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Fri, 19 Jun 2026 14:22:01 +0000 Subject: [PATCH 8/8] =?UTF-8?q?docs(core),test(server):=20renumber=20stand?= =?UTF-8?q?ard-header=20rung=208=E2=86=927=20ahead=20of=20capability=20gat?= =?UTF-8?q?e;=20align=20era-gating=20test=20title=20with=20its=20initializ?= =?UTF-8?q?e=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standard-header-validation ladder entry is moved to order 7 (ahead of client-capabilities, now 8, and param-header-validation, now 9), and its rationale carries an explicit precedence caveat: serveModern evaluates this rung immediately after the supported-revision gate, so a request that also fails a dispatch rung (method-registry 5, request-params 6) is answered here first — the documented order is not the observed precedence relative to those. The era-gating test's title said it posts a 2025-era tools/list without standard headers, but the body POSTs initialize (the 2025 handshake, which is the right body for era-gating); aligned the title to match. --- .../core/src/shared/inboundClassification.ts | 35 ++++++++++--------- .../test/server/stdHeaderValidation.test.ts | 2 +- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts index bd079f8365..4f4be395cb 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -169,8 +169,8 @@ export type InboundValidationRung = | 'envelope' | 'method-registry' | 'request-params' - | 'client-capabilities' | 'standard-header-validation' + | 'client-capabilities' | 'param-header-validation'; /** A ladder rejection: the JSON-RPC error to emit and the HTTP status to emit it with. */ @@ -314,9 +314,25 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor rationale: 'Per-method params validation; emitted in-band by the dispatch layer (HTTP 200), never via the ladder status table.' }, { - rung: 'client-capabilities', + rung: 'standard-header-validation', order: 7, evaluatedAt: 'pre-dispatch', + codes: [HEADER_MISMATCH_ERROR_CODE], + conformance: ['http-header-validation'], + rationale: + 'SEP-2243 standard `Mcp-Method` / `Mcp-Name` headers — presence, sentinel decoding, and `Mcp-Name` ↔ body cross-check ' + + '— are validated by the HTTP entry on a modern-classified request after the supported-revision gate and before ' + + 'dispatch. The classifier’s own header-mismatch cells (protocol-version, `Mcp-Method` mismatch) stay on the edge ' + + '`era-classification` rung; this rung carries the entry-layer presence/`Mcp-Name` half. Evaluated before the ' + + 'capability gate, the factory call, and the `Mcp-Param-*` rung so a request that fails several rungs is answered by ' + + 'the standard-header rung first. The documented order (after method-registry 5 and request-params 6) is NOT the ' + + 'observed precedence: serveModern evaluates this rung immediately after the supported-revision gate, so a request ' + + 'that also fails a dispatch rung is answered here before the dispatch rungs (5–6) are consulted.' + }, + { + rung: 'client-capabilities', + order: 8, + evaluatedAt: 'pre-dispatch', codes: [ProtocolErrorCode.MissingRequiredClientCapability], conformance: ['server-stateless'], rationale: @@ -327,21 +343,6 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor 'missing the capability and would also fail a dispatch rung is answered by this gate first, so the entry must ' + 'consult the method registry before the gate if the documented precedence is to stay observable.' }, - { - rung: 'standard-header-validation', - order: 8, - evaluatedAt: 'pre-dispatch', - codes: [HEADER_MISMATCH_ERROR_CODE], - conformance: ['http-header-validation'], - rationale: - 'SEP-2243 standard `Mcp-Method` / `Mcp-Name` headers — presence, sentinel decoding, and `Mcp-Name` ↔ body cross-check ' + - '— are validated by the HTTP entry on a modern-classified request after the supported-revision gate and before ' + - 'dispatch. The classifier’s own header-mismatch cells (protocol-version, `Mcp-Method` mismatch) stay on the edge ' + - '`era-classification` rung; this rung carries the entry-layer presence/`Mcp-Name` half. The documented order ' + - '(after method resolution, params validation, and the capability gate) is not the observed precedence: serveModern ' + - 'evaluates this rung immediately after the supported-revision gate, so a request that fails several rungs is ' + - 'answered by this gate before the dispatch rungs (5–6) and the capability gate (7) are consulted.' - }, { rung: 'param-header-validation', order: 9, diff --git a/packages/server/test/server/stdHeaderValidation.test.ts b/packages/server/test/server/stdHeaderValidation.test.ts index 5432f3f640..58a8c89a40 100644 --- a/packages/server/test/server/stdHeaderValidation.test.ts +++ b/packages/server/test/server/stdHeaderValidation.test.ts @@ -149,7 +149,7 @@ describe('SEP-2243 standard-header validation (createMcpHandler, modern era)', ( }); describe('SEP-2243 standard-header validation is era-gated', () => { - it('legacy traffic is byte-untouched: a 2025-era tools/list without standard headers still serves', async () => { + it('legacy traffic is byte-untouched: a 2025-era initialize without standard headers still serves', async () => { const handler = createMcpHandler(makeFactory()); const response = await handler.fetch( new Request('http://localhost/mcp', {