fix(server): reject a modern POST without the required MCP-Protocol-Version header (#2590)

Co-authored-by: Konstantin Konstantinov <konstantin@mach5technology.com>
Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com>
This commit is contained in:
David Pavlovschii 2026-08-17 09:31:47 +03:00 • committed by GitHub
commit 75dc7ea6e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 328 additions and 43 deletions

View file

@ -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.

View file

@ -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

View file

@ -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
@ -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 ' +
@ -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
@ -511,7 +528,10 @@ 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') {
@ -519,6 +539,24 @@ export function validateStandardRequestHeaders(request: InboundHttpRequest, rout
}
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',

View file

@ -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',

View file

@ -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
@ -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', {});
@ -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',

View file

@ -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;

View file

@ -31,6 +31,7 @@ import type {
ClientCapabilities,
Implementation,
InboundClassificationOutcome,
InboundHttpRequest,
InboundLadderRejection,
InboundLegacyRoute,
InboundModernRoute,
@ -406,6 +407,25 @@ export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (er
* The entry's classification step (shared with isLegacyRequest)
* ------------------------------------------------------------------------ */
/**
* Read the SEP-2243 standard request headers off the inbound request.
*
* Both halves of the standard-header story need them — the body-primary
* classifier for its cross-check cells, and
* {@linkcode validateStandardRequestHeaders} for the presence half — and a
* header read at one site but not the other is precisely how a required header
* goes unenforced: that divergence is what let a modern POST omitting
* `MCP-Protocol-Version` be served. Read them here once so a header added to
* {@linkcode InboundHttpRequest} reaches both sites together.
*/
function standardHeadersOf(request: Request): Omit<InboundHttpRequest, 'httpMethod' | 'body'> {
return {
protocolVersionHeader: request.headers.get('mcp-protocol-version') ?? undefined,
mcpMethodHeader: request.headers.get('mcp-method') ?? undefined,
mcpNameHeader: request.headers.get('mcp-name') ?? undefined
};
}
/** The outcome of the entry's classification step for one inbound HTTP request. */
type EntryClassification =
/** The body bytes could not be read at all (a failing stream, not malformed JSON). */
@ -467,9 +487,7 @@ async function classifyEntryRequest(request: Request, providedParsedBody?: unkno
const outcome = classifyInboundRequest({
httpMethod,
protocolVersionHeader: request.headers.get('mcp-protocol-version') ?? undefined,
mcpMethodHeader: request.headers.get('mcp-method') ?? undefined,
mcpNameHeader: request.headers.get('mcp-name') ?? undefined,
...standardHeadersOf(request),
...(body !== undefined && { body })
});
return { step: 'classified', outcome, body, parsedBody, forwardRequest };
@ -661,10 +679,12 @@ 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
// SEP-2243 standard-header presence (`MCP-Protocol-Version`,
// `Mcp-Method`) and `Mcp-Name` cross-check
// (`standard-header-validation` rung; the `MCP-Protocol-Version` and
// `Mcp-Method` *mismatch* cells are already answered inside
// `classifyInboundRequest` on the edge `era-classification` rung).
// `classifyInboundRequest` on the edge `era-classification` rung,
// which only cross-checks a protocol-version header that is present).
// Evaluated after the supported-revision
// gate so an envelope naming a revision this endpoint does not serve
// is still answered with `-32022` (the supported list is the more
@ -672,14 +692,7 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
// 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
);
const stdHeaderRejection = validateStandardRequestHeaders({ httpMethod: request.method, ...standardHeadersOf(request) }, route);
if (stdHeaderRejection !== undefined) {
reportError(new Error(`Rejected inbound request (${stdHeaderRejection.cell}): ${stdHeaderRejection.message}`));
return rejectionResponse(stdHeaderRejection, echoableRequestId(route.message));

View file

@ -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;

View file

@ -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'
},

View file

@ -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]: {}
};
@ -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({
@ -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 } })

View file

@ -4,7 +4,8 @@
*
* The presence and `Mcp-Name` cross-check half of the standard-header rung,
* evaluated by the entry on a modern-classified request immediately after the
* body-primary classifier returns a modern route. A missing `Mcp-Method`
* body-primary classifier returns a modern route. A missing
* `MCP-Protocol-Version` header, a missing `Mcp-Method`
* 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
@ -74,6 +75,93 @@ describe('SEP-2243 standard-header validation (createMcpHandler, modern era)', (
expect(body.result.content[0]?.text).toBe('hi');
});
it('a missing MCP-Protocol-Version header is rejected 400/-32020', async () => {
const handler = createMcpHandler(makeFactory());
const error = await expectHeaderMismatch(
await handler.fetch(
new Request('http://localhost/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-method': 'tools/list'
},
body: JSON.stringify({ jsonrpc: '2.0', id: 5, method: 'tools/list', params: { _meta: ENVELOPE } })
})
)
);
expect(error.message).toContain('MCP-Protocol-Version header is absent');
});
it('a missing MCP-Protocol-Version header is rejected under the strict (legacy: reject) posture', async () => {
// The spec's "MAY treat a header-less request as 2025-03-26" allowance
// is available only to a server that also serves pre-2025-06-18
// clients; a modern-only endpoint MUST reject.
const handler = createMcpHandler(makeFactory(), { legacy: 'reject' });
await expectHeaderMismatch(
await handler.fetch(
new Request('http://localhost/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-method': 'tools/list'
},
body: JSON.stringify({ jsonrpc: '2.0', id: 5, method: 'tools/list', params: { _meta: ENVELOPE } })
})
)
);
});
it('a tools/call missing the MCP-Protocol-Version header never reaches the handler', async () => {
let ran = false;
const handler = createMcpHandler(() => {
const s = new McpServer({ name: 'std-header-server', version: '1.0.0' });
s.registerTool('echo', { inputSchema: z.object({ text: z.string().optional() }) }, async ({ text }) => {
ran = true;
return { content: [{ type: 'text', text: text ?? 'ok' }] };
});
return s;
});
await expectHeaderMismatch(
await handler.fetch(
new Request('http://localhost/mcp', {
method: 'POST',
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: 5,
method: 'tools/call',
params: { name: 'echo', arguments: { text: 'ran' }, _meta: ENVELOPE }
})
})
)
);
expect(ran).toBe(false);
});
it('a present and matching MCP-Protocol-Version header still dispatches', async () => {
const handler = createMcpHandler(makeFactory());
const response = await handler.fetch(modernRequest('tools/list', {}, { 'mcp-method': 'tools/list' }));
expect(response.status).toBe(200);
});
it('a present-but-disagreeing MCP-Protocol-Version header is still rejected 400/-32020', async () => {
// The absence cell added above must not displace the pre-existing
// disagreement cell, which the classifier still answers on its edge
// `era-classification` rung before serveModern runs this rung at all.
const handler = createMcpHandler(makeFactory());
const error = await expectHeaderMismatch(
await handler.fetch(modernRequest('tools/list', {}, { 'mcp-protocol-version': '2025-11-25', 'mcp-method': 'tools/list' }))
);
expect(error.message).toContain('MCP-Protocol-Version header names 2025-11-25');
});
it('a missing Mcp-Method header is rejected 400/-32020', async () => {
const handler = createMcpHandler(makeFactory());
const error = await expectHeaderMismatch(await handler.fetch(modernRequest('tools/list', {})));
@ -166,4 +254,16 @@ describe('SEP-2243 standard-header validation is era-gated', () => {
// The default 'stateless' legacy posture answers initialize.
expect(response.status).toBe(200);
});
it.each(['GET', 'DELETE'])('a body-less %s is method-routed, never standard-header validated', async httpMethod => {
// The modern era is POST-only, so a body-less session operation never
// reaches a modern route and the presence rung cannot fire on it —
// whatever it lacks in standard headers. It is answered by the
// http-method rung instead: 405 / -32000, never 400 / -32020.
const handler = createMcpHandler(makeFactory());
const response = await handler.fetch(new Request('http://localhost/mcp', { method: httpMethod }));
expect(response.status).toBe(405);
const body = (await response.json()) as { error: { code: number } };
expect(body.error.code).toBe(-32_000);
});
});

View file

@ -15,7 +15,12 @@ import { PassThrough } from 'node:stream';
import type { Client } from '@modelcontextprotocol/client';
import { SSEClientTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core-internal';
import {
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
encodeMcpParamValue,
PROTOCOL_VERSION_META_KEY
} from '@modelcontextprotocol/core-internal';
import type {
CreateMcpHandlerOptions,
EventStore,
@ -356,6 +361,25 @@ export function modernEnvelopeMeta(clientInfo?: Implementation): Record<string,
};
}
/**
* The SEP-2243 standard request headers a conformant 2026-07-28 client sends
* alongside {@linkcode modernEnvelopeMeta}, for scenario bodies that put raw
* HTTP requests on the wire. `MCP-Protocol-Version` and `Mcp-Method` are
* required on every modern *request* POST (notification POSTs are exempt);
* `Mcp-Name` is additionally required for the methods that mirror
* `params.name` / `params.uri`, and carries the `=?base64?…?=` sentinel
* encoding the shipped client applies to it, so a name that is not a safe
* plain-ASCII field value reaches the server exactly as that client would
* send it rather than throwing in `Headers` construction.
*/
export function modernStandardHeaders(method: string, name?: string): Record<string, string> {
return {
'mcp-protocol-version': MODERN_REVISION,
'mcp-method': method,
...(name !== undefined && { 'mcp-name': encodeMcpParamValue(name) })
};
}
/**
* Fail fast if an entryModern connection did not actually negotiate the
* 2026-07-28 revision. Every cell on the arm asserts modern-path behavior, so

View file

@ -31,7 +31,7 @@ import { createMcpHandler, isLegacyRequest, McpServer, WebStandardStreamableHTTP
import { expect, vi } from 'vitest';
import { z } from 'zod/v4';
import { modernEnvelopeMeta } from '../helpers/index';
import { modernEnvelopeMeta, modernStandardHeaders } from '../helpers/index';
import { verifies } from '../helpers/verifies';
const LEGACY = '2025-11-25';
@ -172,8 +172,7 @@ verifies('typescript:hosting:entry:byo-sessionful-legacy', async () => {
headers: {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
'mcp-method': 'tools/call',
'mcp-name': 'greet'
...modernStandardHeaders('tools/call', 'greet')
},
body: JSON.stringify({
jsonrpc: '2.0',

View file

@ -35,7 +35,7 @@ import {
import { expect, vi } from 'vitest';
import { z } from 'zod/v4';
import { modernEnvelopeMeta, tapWire, wire } from '../helpers/index';
import { modernEnvelopeMeta, modernStandardHeaders, tapWire, wire } from '../helpers/index';
import { verifies } from '../helpers/verifies';
import type { TestArgs } from '../types';
@ -1158,8 +1158,7 @@ verifies('protocol:meta:server-identity', async ({ transport }: TestArgs) => {
headers: {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
'mcp-protocol-version': '2026-07-28',
'mcp-method': 'server/discover'
...modernStandardHeaders('server/discover')
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'server/discover', params: { _meta: modernEnvelopeMeta() } })
});
@ -1184,8 +1183,7 @@ verifies('protocol:envelope:client-info-optional', async ({ transport }: TestArg
headers: {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
'mcp-protocol-version': '2026-07-28',
'mcp-method': 'tools/list'
...modernStandardHeaders('tools/list')
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: meta } })
});

View file

@ -11,7 +11,7 @@ import { createMcpHandler, McpServer, SUBSCRIPTION_ID_META_KEY } from '@modelcon
import { expect } from 'vitest';
import { z } from 'zod/v4';
import { modernEnvelopeMeta, wire } from '../helpers/index';
import { modernEnvelopeMeta, modernStandardHeaders, wire } from '../helpers/index';
import { verifies } from '../helpers/verifies';
import type { TestArgs } from '../types';
@ -72,7 +72,7 @@ verifies('subscriptions:listen:ack-first-stamped', async () => {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-method': 'subscriptions/listen'
...modernStandardHeaders('subscriptions/listen')
},
body: JSON.stringify({
jsonrpc: '2.0',
@ -270,7 +270,7 @@ verifies('subscriptions:listen:capacity-guard', async () => {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-method': 'subscriptions/listen'
...modernStandardHeaders('subscriptions/listen')
},
body: JSON.stringify({
jsonrpc: '2.0',

View file

@ -173,6 +173,7 @@ describe('createMcpHandler over HTTP — subscriptions/listen honored filter', (
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'mcp-protocol-version': MODERN,
'mcp-method': 'subscriptions/listen'
},
body: JSON.stringify({