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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/require-mcp-protocol-version-header.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@modelcontextprotocol/core-internal': patch
'@modelcontextprotocol/server': patch
---

Reject a modern (2026-07-28) POST that omits the required `MCP-Protocol-Version` header.

`createMcpHandler` accepted a request whose body carried a valid per-request `_meta`
envelope but whose `MCP-Protocol-Version` header was absent: the request was classified
modern, dispatched, and answered `200` — tool handlers ran. Only the _mismatch_ case
(header present, disagreeing with the body) was rejected, so of the three standard
headers SEP-2243 requires on every modern POST, presence was enforced for `Mcp-Method`
and `Mcp-Name` but not for `MCP-Protocol-Version`.

Such a request is now refused with `400 Bad Request` and JSON-RPC `-32020`
(`HeaderMismatch`), matching the shape the sibling missing-header cells already emit and
echoing the request id — per the Streamable HTTP spec, which requires the header on every
POST and lists a missing required standard header as a `HeaderMismatch` failure. The
spec's allowance to treat a header-less request as `2025-03-26` is available only to a
server that also serves pre-2025-06-18 clients, and permits routing it to _legacy_
handling — never serving it as 2026-07-28; under `legacy: 'reject'` the requirement is
unconditional.

Era classification is deliberately unchanged and stays body-primary: a proxy that strips
the header still must not change the era, so such a request is still _classified_ modern
and is refused one rung later, at `standard-header-validation` — the same rung that
already answers a missing `Mcp-Method`. Legacy-era traffic is untouched, notifications
are unaffected, and stdio serving (which has no HTTP headers) is not involved.

Clients built with this SDK always send the header, so no first-party client is affected;
hand-rolled clients that omitted it must add it.
8 changes: 7 additions & 1 deletion docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,13 @@ present body value, malformed, or disagree with the body — `400 Bad Request` w
JSON-RPC `-32020` (`HeaderMismatch`). The Streamable HTTP transport also emits the
`Mcp-Name` standard header on every modern-enveloped request, and `createMcpHandler`
validates the SEP-2243 standard headers (`MCP-Protocol-Version`, `Mcp-Method`,
`Mcp-Name`) against the body on the modern path with the same rejection.
`Mcp-Name`) against the body on the modern path with the same rejection — both their
**presence** (all three are required on every modern POST; `Mcp-Name` only for the
methods that mirror `params.name` / `params.uri`) and their agreement with the body. A
modern-enveloped POST that omits `MCP-Protocol-Version` is refused rather than served,
even though the body claim alone still determines the era — so a hand-rolled client that
relied on the body envelope without sending the header must add it. Clients built with
this SDK send all three already.

**Modern-era exception** to the `SdkHttpError` mapping: on a modern-enveloped request,
an HTTP `400` whose body is a well-formed JSON-RPC error response addressed to the
Expand Down
49 changes: 41 additions & 8 deletions packages/core-internal/src/shared/inboundClassification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@
* named revision belongs to (a malformed envelope behind a present claim is
* a validation error, never a silent fall back to legacy handling).
* - A request without a claim is legacy-era traffic.
* - The `MCP-Protocol-Version` header is a cross-check only: it never
* upgrades or downgrades a body-derived classification, and a disagreement
* between header and body is an explicit ladder outcome.
* - The `MCP-Protocol-Version` header is a cross-check only *for
* classification*: it never upgrades or downgrades a body-derived
* classification, and a disagreement between header and body is an explicit
* ladder outcome. Its absence likewise never changes the era — but the spec
* requires the header on every modern POST, so a modern-classified request
* that omits it is refused one rung later, by
* {@linkcode validateStandardRequestHeaders}, not here.
* - Notifications carry no envelope claim of their own under the current
* spec, so for notification POSTs without a body claim the modern header is
* determinative; the `Mcp-Method` header is validated against the body when
Expand Down Expand Up @@ -317,16 +321,20 @@
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 ' +
'SEP-2243 standard `MCP-Protocol-Version` / `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 spec requires all three on every modern POST and names them in that ' +
'order, so a request missing several is answered by the earliest. 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 — including the missing ' +
'`MCP-Protocol-Version` cell, which cannot live on the edge rung without breaking body-primary classification. ' +
'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 ' +

Check warning on line 337 in packages/core-internal/src/shared/inboundClassification.ts

View check run for this annotation

Claude / Claude Code Review

Rationale and changeset overstate Mcp-Name as required on every modern POST

The rewritten rationale for the standard-header-validation rung says "The spec requires all three on every modern POST", but Mcp-Name is required only for tools/call, prompts/get, and resources/read (per the SEP-2243 'Required For' column cited by MCP_NAME_HEADER_SOURCE in this same file) — and validateStandardRequestHeaders itself enforces Mcp-Name presence only for those three methods. The changeset (.changeset/require-mcp-protocol-version-header.md) repeats the same overstatement; consider ad
Comment on lines 324 to 337

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The rewritten rationale for the standard-header-validation rung says "The spec requires all three on every modern POST", but Mcp-Name is required only for tools/call, prompts/get, and resources/read (per the SEP-2243 'Required For' column cited by MCP_NAME_HEADER_SOURCE in this same file) — and validateStandardRequestHeaders itself enforces Mcp-Name presence only for those three methods. The changeset (.changeset/require-mcp-protocol-version-header.md) repeats the same overstatement; consider adding the qualifier the migration doc and the new modernStandardHeaders JSDoc already use ('Mcp-Name only for the methods that mirror params.name / params.uri') in both places.

Extended reasoning...

What the prose claims vs. what the code does

The new rationale string for the standard-header-validation rung (packages/core-internal/src/shared/inboundClassification.ts, INBOUND_VALIDATION_LADDER) states:

The spec requires all three on every modern POST and names them in that order

This overstates Mcp-Name. Per SEP-2243 § Standard Request Headers, the "Required For" column scopes Mcp-Name to tools/call, prompts/get, and resources/read only — the exact table cited by the MCP_NAME_HEADER_SOURCE doc comment a few hundred lines below in the same file, whose map is { 'tools/call': 'name', 'prompts/get': 'name', 'resources/read': 'uri' }.

The implementation agrees with the spec, not the rationale

validateStandardRequestHeaders in this same file enforces Mcp-Name presence only when Object.hasOwn(MCP_NAME_HEADER_SOURCE, method) hits — i.e. only for those three methods — and even then only when the body actually carries the mirrored params.name / params.uri value (if (bodyValue === undefined) return undefined). Every other method passes the rung with no Mcp-Name at all.

Step-by-step proof from this PR's own tests

  1. packages/server/test/server/stdHeaderValidation.test.ts pins: "Mcp-Name is not required for methods outside its source map" — a modern tools/list POST with mcp-protocol-version and mcp-method but no mcp-name is served 200.
  2. packages/core-internal/test/shared/standardHeaderValidation.test.ts pins: "a present Mcp-Method header passes for a method with no Mcp-Name source"validateStandardRequestHeaders returns undefined for a modern tools/list without Mcp-Name.
  3. So if "all three" were truly required on every modern POST, both of these tests (added/kept green by this PR) would have to fail. They pass — the rationale contradicts the behavior the same diff pins.

The changeset repeats the overstatement

.changeset/require-mcp-protocol-version-header.md: "of the three standard headers SEP-2243 requires on every modern POST, presence was enforced for Mcp-Method and Mcp-Name" — same unqualified claim, in text that will ship in release notes.

The accurate wording already exists in this PR

Both docs/migration/support-2026-07-28.md and the new modernStandardHeaders helper JSDoc in test/e2e/helpers/index.ts carry the correct qualifier: "Mcp-Name only for the methods that mirror params.name / params.uri". So the fix is mechanical: add that same parenthetical to the rung rationale and the changeset.

Why nit: this is prose-only — the ladder rationale is documentation-as-data and the changeset is release-notes text; no runtime behavior is affected, and the enforcement code itself is correct. It matches the repo's Documentation & Changesets recurring catch (prose in the diff contradicting the implementation in the same diff), which asks for it to be flagged but does not make it blocking.

'that also fails a dispatch rung is answered here before the dispatch rungs (5–6) are consulted.'
},
{
Expand Down Expand Up @@ -494,6 +502,10 @@
* `era-classification` rung for the `MCP-Protocol-Version` and
* `Mcp-Method` *mismatch* cells) when:
*
* - the required `MCP-Protocol-Version` header is absent (SEP-2243 requires it
* on every modern POST, and lists it first among the required standard
* headers — so a request missing it *and* `Mcp-Method` is answered by this
* cell);
* - 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
Expand All @@ -511,14 +523,35 @@
* 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.
* entry layers on top. That separation is what lets the missing
* `MCP-Protocol-Version` cell live here without disturbing the body-primary
* rule — classification still resolves from the body (a proxy stripping the
* header must not change the era), and only this rung refuses to serve it.
*/
export function validateStandardRequestHeaders(request: InboundHttpRequest, route: InboundModernRoute): InboundLadderRejection | undefined {
if (route.messageKind !== 'request') {
return undefined;
}
const method = route.message.method;

// SEP-2243 names `MCP-Protocol-Version` first among the required standard
// headers, so a request missing both it and `Mcp-Method` is answered by
// the header the spec names first. The presence check lives here rather
// than in `classifyInboundRequest` on purpose: classification stays
// body-primary (a proxy stripping the header must not change the era), and
// only this rung refuses to serve the request.
if (request.protocolVersionHeader === undefined) {
const claimed = route.classification.revision;
return crossCheckMismatch(
'version-header-missing',
'(missing)',
claimed === undefined
? 'the body carries a modern per-request envelope but the required MCP-Protocol-Version header is absent'
: `the body envelope names protocol version ${claimed} but the required MCP-Protocol-Version header is absent`,
'standard-header-validation'
);
}

if (request.mcpMethodHeader === undefined) {
return crossCheckMismatch(
'method-header-missing',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ const SHEET: readonly SheetRow[] = [
conformance: ['server-stateless'],
input: post(enveloped('tools/call', { name: 'echo', arguments: {} })),
route: 'modern',
rationale: 'Body-primary classification: a proxy stripping the protocol-version header must not change the era.'
rationale:
'Body-primary classification: a proxy stripping the protocol-version header must not change the era. This cell pins ' +
'the CLASSIFICATION only — such a request is still refused before dispatch by the standard-header rung, which requires ' +
'the header the spec mandates on every modern POST (see validateStandardRequestHeaders / version-header-missing).'
},
{
cell: 'legacy-claimless-request',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
*
* Evaluated by the HTTP entry on a modern-classified request immediately
* after `classifyInboundRequest` returns a modern route: rejects `400` /
* `-32020` (`HeaderMismatch`) when the required `Mcp-Method` header is
* `-32020` (`HeaderMismatch`) when the required `MCP-Protocol-Version` header
* is absent, 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
Expand Down Expand Up @@ -60,6 +61,50 @@ function expectRejection(result: InboundLadderRejection | undefined, cell: strin
expect(result?.settled).toBe(true);
}

describe('SEP-2243 standard-header validation (MCP-Protocol-Version presence)', () => {
test('a modern request without an MCP-Protocol-Version header is rejected (version-header-missing)', () => {
const request: InboundHttpRequest = {
httpMethod: 'POST',
mcpMethodHeader: 'tools/list',
body: { jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: ENVELOPE } }
};
const outcome = classifyInboundRequest(request);
// Body-primary classification is deliberately untouched: a proxy that
// strips the header must not change the era, so the request still
// routes modern and the rejection belongs to this rung — not to the
// classifier.
expect(outcome.kind).toBe('modern');
expectRejection(validateStandardRequestHeaders(request, outcome as InboundModernRoute), 'version-header-missing');
});

test('the missing-version cell outranks the missing-method cell (spec header order)', () => {
const request: InboundHttpRequest = {
httpMethod: 'POST',
body: { jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: ENVELOPE } }
};
const outcome = classifyInboundRequest(request);
expect(outcome.kind).toBe('modern');
expectRejection(validateStandardRequestHeaders(request, outcome as InboundModernRoute), 'version-header-missing');
});

test('the header/body version mismatch cell stays inside classifyInboundRequest (this rung never sees it)', () => {
// The protocol-version check is split across two rungs: *absence* is
// this rung's cell, *disagreement* stays on the classifier's edge
// `era-classification` rung. Pinned so the split stays observable —
// the same guard the Mcp-Method pair carries below.
const outcome = classifyInboundRequest({
httpMethod: 'POST',
protocolVersionHeader: '2025-11-25',
mcpMethodHeader: 'tools/list',
body: { jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: ENVELOPE } }
});
expect(outcome.kind).toBe('reject');
expect((outcome as InboundLadderRejection).cell).toBe('header-body-version-mismatch');
expect((outcome as InboundLadderRejection).rung).toBe('era-classification');
expect((outcome as InboundLadderRejection).code).toBe(-32_020);
});
});

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', {});
Expand All @@ -86,7 +131,14 @@ describe('SEP-2243 standard-header validation (Mcp-Method presence)', () => {
expect((outcome as InboundLadderRejection).cell).toBe('method-header-mismatch');
});

test('notifications are never enforced', () => {
test('notifications are never enforced — including the MCP-Protocol-Version presence cell', () => {
// Deliberate, not an oversight. The Streamable HTTP "Sending Messages"
// note states that "header requirements for notification POSTs are not
// defined by this revision" (the revision defines no client-to-server
// notifications over Streamable HTTP at all), so the "Every POST
// request MUST include an MCP-Protocol-Version header" rule does not
// reach a posted notification and this rung stays request-only.
// The request below carries NO standard headers whatsoever.
const route: InboundModernRoute = {
kind: 'modern',
messageKind: 'notification',
Expand Down
5 changes: 3 additions & 2 deletions packages/middleware/node/test/toNodeHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ function modernToolsCall(name: string, args: Record<string, unknown>): unknown {
function bodyDerivedStandardHeaders(body: unknown): Record<string, string> {
if (body === null || typeof body !== 'object' || Array.isArray(body)) return {};
const b = body as { method?: unknown; params?: { name?: unknown; uri?: unknown; _meta?: Record<string, unknown> } };
if (typeof b.params?._meta?.[PROTOCOL_VERSION_META_KEY] !== 'string') return {};
const out: Record<string, string> = {};
const claimedVersion = b.params?._meta?.[PROTOCOL_VERSION_META_KEY];
if (b.params === undefined || typeof claimedVersion !== 'string') return {};
const out: Record<string, string> = { 'mcp-protocol-version': claimedVersion };
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;
Expand Down
1 change: 1 addition & 0 deletions packages/server/src/server/createMcpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,7 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
const stdHeaderRejection = validateStandardRequestHeaders(
{
httpMethod: request.method,
protocolVersionHeader: request.headers.get('mcp-protocol-version') ?? undefined,
mcpMethodHeader: request.headers.get('mcp-method') ?? undefined,
mcpNameHeader: request.headers.get('mcp-name') ?? undefined
},
Expand Down
5 changes: 3 additions & 2 deletions packages/server/test/server/createMcpHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ function modernToolsCall(name: string, args: Record<string, unknown>, envelope:
function bodyDerivedStandardHeaders(body: unknown): Record<string, string> {
if (body === null || typeof body !== 'object' || Array.isArray(body)) return {};
const b = body as { method?: unknown; params?: { name?: unknown; uri?: unknown; _meta?: Record<string, unknown> } };
if (typeof b.params?._meta?.[PROTOCOL_VERSION_META_KEY] !== 'string') return {};
const out: Record<string, string> = {};
const claimedVersion = b.params?._meta?.[PROTOCOL_VERSION_META_KEY];
if (b.params === undefined || typeof claimedVersion !== 'string') return {};
const out: Record<string, string> = { 'mcp-protocol-version': claimedVersion };
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ function postEcho(clientCapabilities: ClientCapabilities): Request {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-protocol-version': MODERN_REVISION,
'mcp-method': 'tools/call',
'mcp-name': 'echo'
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ import { createMcpHandler } from '../../src/server/createMcpHandler';
import { McpServer } from '../../src/server/mcp';
import type { ServerEventBus } from '../../src/server/serverEventBus';

const MODERN_REVISION = '2026-07-28';
const ENVELOPE = {
[PROTOCOL_VERSION_META_KEY]: '2026-07-28',
[PROTOCOL_VERSION_META_KEY]: MODERN_REVISION,
[CLIENT_INFO_META_KEY]: { name: 'listen-test-client', version: '1.0.0' },
[CLIENT_CAPABILITIES_META_KEY]: {}
};
Expand All @@ -31,6 +32,7 @@ function listenRequest(id: string | number, filter: Record<string, unknown>): Re
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-protocol-version': MODERN_REVISION,
'mcp-method': 'subscriptions/listen'
},
body: JSON.stringify({
Expand Down Expand Up @@ -212,6 +214,7 @@ describe('createMcpHandler — subscriptions/listen', () => {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-protocol-version': MODERN_REVISION,
'mcp-method': 'subscriptions/listen'
},
body: JSON.stringify({ jsonrpc: '2.0', id: 9, method: 'subscriptions/listen', params: { _meta: ENVELOPE } })
Expand Down
Loading
Loading