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
16 changed files with 328 additions and 43 deletions
33
.changeset/require-protocol-version-header-on-modern-post.md
Normal file
33
.changeset/require-protocol-version-header-on-modern-post.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue