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