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
9 changed files with 328 additions and 43 deletions

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);
});
});