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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/require-protocol-version-header-on-modern-post.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
'@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 standard headers
SEP-2243 requires on a modern POST, presence was enforced for `Mcp-Method` (and for
`Mcp-Name` on the methods that mirror `params.name` / `params.uri`) 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, body-less `GET` / `DELETE` session operations are method-routed before
any header validation, 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.
14 changes: 13 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,19 @@ 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 **request** POST; `Mcp-Name` only
for the methods that mirror `params.name` / `params.uri`) and their agreement with the
body. A modern-enveloped request 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.

Notification POSTs are exempt from the presence half: this revision defines no
client-to-server notifications over Streamable HTTP, and states that header requirements
for notification POSTs are not defined by it — so a modern-enveloped notification is
dispatched and answered `202` even with no standard headers at all. Do not rely on the
entry to reject one.

**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
54 changes: 46 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,15 @@
* 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 *request* POST, so a
* modern-classified request that omits it is refused one rung later, by
* {@linkcode validateStandardRequestHeaders}, not here. Notification POSTs
* are exempt (see the next bullet), and that rung enforces presence on
* requests only.
* - 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 @@ -320,10 +326,17 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor
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 `MCP-Protocol-Version` and `Mcp-Method` on every ' +
'modern *request* POST (`Mcp-Name` only for the methods that mirror `params.name` / `params.uri`) and names them ' +
'in that order, so a request missing several is answered by the earliest. Notification POSTs are exempt: the ' +
'presence half runs on requests only, so a modern-enveloped notification is dispatched even with no standard ' +
'headers at all. 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 ' +
Expand Down Expand Up @@ -494,6 +507,10 @@ function stripHttpOws(value: string): string {
* `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 *request* 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 +528,35 @@ function stripHttpOws(value: string): string {
* 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,11 @@ 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 *request* POST (see validateStandardRequestHeaders / ' +
'version-header-missing). A notification POST is exempt from that rung and stays served.'
},
{
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,55 @@ 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('a present and matching MCP-Protocol-Version header passes', () => {
const { request, route } = modernPost('tools/list', {}, { mcpMethod: 'tools/list' });
expect(validateStandardRequestHeaders(request, route)).toBeUndefined();
});

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 +136,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
Loading
Loading