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

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