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:
parent
03842cd9ca
commit
75dc7ea6e2
5 changed files with 328 additions and 43 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue