From 75dc7ea6e2913e1ac37d4f06eec62cd5cfac9e7a Mon Sep 17 00:00:00 2001 From: David Pavlovschii <154081173+davidpavlovschi@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:31:47 +0300 Subject: [PATCH 1/2] fix(server): reject a modern POST without the required MCP-Protocol-Version header (#2590) Co-authored-by: Konstantin Konstantinov Co-authored-by: Konstantin Konstantinov --- ...-protocol-version-header-on-modern-post.md | 33 ++++++ docs/migration/support-2026-07-28.md | 14 ++- .../src/shared/inboundClassification.ts | 54 ++++++++-- .../shared/inboundLadderCellSheet.test.ts | 6 +- .../shared/standardHeaderValidation.test.ts | 61 ++++++++++- .../node/test/toNodeHandler.test.ts | 5 +- .../server/src/server/createMcpHandler.ts | 39 ++++--- .../test/server/createMcpHandler.test.ts | 5 +- .../createMcpHandlerCapabilityGate.test.ts | 1 + .../server/createMcpHandlerListen.test.ts | 5 +- .../test/server/stdHeaderValidation.test.ts | 102 +++++++++++++++++- test/e2e/helpers/index.ts | 26 ++++- .../scenarios/hosting-entry-session.test.ts | 5 +- test/e2e/scenarios/protocol.test.ts | 8 +- test/e2e/scenarios/subscriptions.test.ts | 6 +- .../test/server/createMcpHandler.test.ts | 1 + 16 files changed, 328 insertions(+), 43 deletions(-) create mode 100644 .changeset/require-protocol-version-header-on-modern-post.md diff --git a/.changeset/require-protocol-version-header-on-modern-post.md b/.changeset/require-protocol-version-header-on-modern-post.md new file mode 100644 index 0000000000..a120853db7 --- /dev/null +++ b/.changeset/require-protocol-version-header-on-modern-post.md @@ -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. diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index 13e869c0f2..b9d24b5ee6 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -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 diff --git a/packages/core-internal/src/shared/inboundClassification.ts b/packages/core-internal/src/shared/inboundClassification.ts index 22883cf488..1dfe4485f8 100644 --- a/packages/core-internal/src/shared/inboundClassification.ts +++ b/packages/core-internal/src/shared/inboundClassification.ts @@ -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', diff --git a/packages/core-internal/test/shared/inboundLadderCellSheet.test.ts b/packages/core-internal/test/shared/inboundLadderCellSheet.test.ts index ace5d9d2c3..ffa79a2484 100644 --- a/packages/core-internal/test/shared/inboundLadderCellSheet.test.ts +++ b/packages/core-internal/test/shared/inboundLadderCellSheet.test.ts @@ -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', diff --git a/packages/core-internal/test/shared/standardHeaderValidation.test.ts b/packages/core-internal/test/shared/standardHeaderValidation.test.ts index 99de31457b..c79c0752ac 100644 --- a/packages/core-internal/test/shared/standardHeaderValidation.test.ts +++ b/packages/core-internal/test/shared/standardHeaderValidation.test.ts @@ -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', diff --git a/packages/middleware/node/test/toNodeHandler.test.ts b/packages/middleware/node/test/toNodeHandler.test.ts index 3861f3afd2..3f76c53a3d 100644 --- a/packages/middleware/node/test/toNodeHandler.test.ts +++ b/packages/middleware/node/test/toNodeHandler.test.ts @@ -40,8 +40,9 @@ function modernToolsCall(name: string, args: Record): unknown { function bodyDerivedStandardHeaders(body: unknown): Record { if (body === null || typeof body !== 'object' || Array.isArray(body)) return {}; const b = body as { method?: unknown; params?: { name?: unknown; uri?: unknown; _meta?: Record } }; - if (typeof b.params?._meta?.[PROTOCOL_VERSION_META_KEY] !== 'string') return {}; - const out: Record = {}; + const claimedVersion = b.params?._meta?.[PROTOCOL_VERSION_META_KEY]; + if (b.params === undefined || typeof claimedVersion !== 'string') return {}; + const out: Record = { '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; diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index a484869944..da165a6c7c 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -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 { + 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)); diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index ded506e57c..a51062b977 100644 --- a/packages/server/test/server/createMcpHandler.test.ts +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -48,8 +48,9 @@ function modernToolsCall(name: string, args: Record, envelope: function bodyDerivedStandardHeaders(body: unknown): Record { if (body === null || typeof body !== 'object' || Array.isArray(body)) return {}; const b = body as { method?: unknown; params?: { name?: unknown; uri?: unknown; _meta?: Record } }; - if (typeof b.params?._meta?.[PROTOCOL_VERSION_META_KEY] !== 'string') return {}; - const out: Record = {}; + const claimedVersion = b.params?._meta?.[PROTOCOL_VERSION_META_KEY]; + if (b.params === undefined || typeof claimedVersion !== 'string') return {}; + const out: Record = { '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; diff --git a/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts b/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts index e6b6bad4e6..6dcfd91b32 100644 --- a/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts +++ b/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts @@ -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' }, diff --git a/packages/server/test/server/createMcpHandlerListen.test.ts b/packages/server/test/server/createMcpHandlerListen.test.ts index 7a0d2c8675..fe17aa7536 100644 --- a/packages/server/test/server/createMcpHandlerListen.test.ts +++ b/packages/server/test/server/createMcpHandlerListen.test.ts @@ -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): 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 } }) diff --git a/packages/server/test/server/stdHeaderValidation.test.ts b/packages/server/test/server/stdHeaderValidation.test.ts index 21b491f90a..b9b9c0b3c3 100644 --- a/packages/server/test/server/stdHeaderValidation.test.ts +++ b/packages/server/test/server/stdHeaderValidation.test.ts @@ -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); + }); }); diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts index 0f3b4daa1c..bb85449191 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -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 { + 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 diff --git a/test/e2e/scenarios/hosting-entry-session.test.ts b/test/e2e/scenarios/hosting-entry-session.test.ts index 0d25ab626b..256955b693 100644 --- a/test/e2e/scenarios/hosting-entry-session.test.ts +++ b/test/e2e/scenarios/hosting-entry-session.test.ts @@ -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', diff --git a/test/e2e/scenarios/protocol.test.ts b/test/e2e/scenarios/protocol.test.ts index 7aaed05b3b..4abcde078d 100644 --- a/test/e2e/scenarios/protocol.test.ts +++ b/test/e2e/scenarios/protocol.test.ts @@ -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 } }) }); diff --git a/test/e2e/scenarios/subscriptions.test.ts b/test/e2e/scenarios/subscriptions.test.ts index 45753afc57..ce644fa430 100644 --- a/test/e2e/scenarios/subscriptions.test.ts +++ b/test/e2e/scenarios/subscriptions.test.ts @@ -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', diff --git a/test/integration/test/server/createMcpHandler.test.ts b/test/integration/test/server/createMcpHandler.test.ts index 9c2a6a7a0d..d58d3a8e11 100644 --- a/test/integration/test/server/createMcpHandler.test.ts +++ b/test/integration/test/server/createMcpHandler.test.ts @@ -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({ From 3e90449fd52997da43b79a536d2c19c446603cc7 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Mon, 17 Aug 2026 14:09:11 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(core):=20never=20send=20notifications/c?= =?UTF-8?q?ancelled=20for=20the=20initialize=20hand=E2=80=A6=20(#2668)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Felix Weinberger Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .../no-cancel-notification-for-initialize.md | 9 +++ docs/migration/support-2026-07-28.md | 8 +- docs/migration/upgrade-to-v2.md | 6 +- packages/core-internal/src/shared/protocol.ts | 36 +++++---- .../test/shared/protocol.test.ts | 74 ++++++++++++++++--- test/e2e/requirements.ts | 9 +-- 6 files changed, 109 insertions(+), 33 deletions(-) create mode 100644 .changeset/no-cancel-notification-for-initialize.md diff --git a/.changeset/no-cancel-notification-for-initialize.md b/.changeset/no-cancel-notification-for-initialize.md new file mode 100644 index 0000000000..b7acd086d3 --- /dev/null +++ b/.changeset/no-cancel-notification-for-initialize.md @@ -0,0 +1,9 @@ +--- +'@modelcontextprotocol/core-internal': patch +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +Stop sending `notifications/cancelled` for the `initialize` handshake. The spec is explicit that a client MUST NOT attempt to cancel its `initialize` request, but the outbound cancel path fired for any in-flight request: aborting the `AbortSignal` passed to `connect()`, or letting the handshake hit its timeout, put a forbidden cancellation on the wire naming the initialize request id. + +The local behaviour is unchanged — the caller's promise still rejects with the same abort/timeout error, and `connect()` still tears the connection down. Only the wire notification is suppressed. Every other method keeps the existing cancellation path. diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index b9d24b5ee6..6c096396a8 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -236,9 +236,11 @@ coverage, spawn `serveStdio` as a child process. On a 2026-07-28 Streamable HTTP connection, aborting an in-flight client request (`signal` / timeout) closes that request's SSE response stream — the spec cancellation signal — instead of POSTing `notifications/cancelled`. Nothing to change in calling -code. 2025-era connections and stdio at any era still send `notifications/cancelled`. -Custom `Transport` implementations that open one underlying request per outbound message -and honor `TransportSendOptions.requestSignal` may opt in by declaring +code. 2025-era connections and stdio at any era still send `notifications/cancelled` +(except for the `initialize` handshake, which the spec forbids cancelling — an aborted +or timed-out `connect()` rejects locally and sends nothing). Custom `Transport` +implementations that open one underlying request per outbound message and honor +`TransportSendOptions.requestSignal` may opt in by declaring `readonly hasPerRequestStream = true`. ### `ctx.mcpReq.log()` and the per-request `logLevel` diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 19f4127733..8f4cd5990d 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -1506,7 +1506,11 @@ rewrite required unless noted. on those survive verbatim. The cancelled-on-timeout signal is unchanged on legacy-era connections and on stdio/in-memory at any era; on 2026-era Streamable HTTP the cancel signal is the per-request stream close instead of a `notifications/cancelled` POST - (see [support-2026-07-28.md](./support-2026-07-28.md)). + (see [support-2026-07-28.md](./support-2026-07-28.md)). The one exemption is the + `initialize` handshake: an aborted or timed-out `connect()` still rejects locally, but + no `notifications/cancelled` goes on the wire — the spec forbids cancelling + `initialize`, and v1 sent one anyway. v1 tests asserting that notification need + re-baselining. - **Also unchanged: SSE reconnection exhaustion.** `StreamableHTTPClientTransport`'s standalone GET-stream reconnection behavior and its exhaustion signal carry over from v1: when retries run out, the transport emits `onerror` with a plain `Error` whose diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index ee79f5f0fc..637be389aa 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -1453,19 +1453,29 @@ export abstract class Protocol { this._progressHandlers.delete(messageId); if (requestAbort === undefined) { - this._transport - ?.send( - this._envelopeOutbound({ - jsonrpc: '2.0', - method: 'notifications/cancelled', - params: { - requestId: messageId, - reason: String(reason) - } - }), - { relatedRequestId, resumptionToken, onresumptiontoken } - ) - .catch(error => this._onerror(new Error(`Failed to send cancellation: ${error}`))); + // "A client MUST NOT attempt to cancel its `initialize` + // request" (spec basic/lifecycle, mirrored on + // `CancelledNotification`). The handshake is the one request + // whose cancellation is forbidden outright, so an abort or + // timeout on it settles purely locally: the promise still + // rejects below, but nothing goes on the wire. Only the + // legacy era can reach this — `initialize` is absent from the + // modern registry, which negotiates via `server/discover`. + if (request.method !== 'initialize') { + this._transport + ?.send( + this._envelopeOutbound({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { + requestId: messageId, + reason: String(reason) + } + }), + { relatedRequestId, resumptionToken, onresumptiontoken } + ) + .catch(error => this._onerror(new Error(`Failed to send cancellation: ${error}`))); + } } else { // Modern-era per-request-stream transport: aborting the // request's underlying stream IS the spec cancel signal. diff --git a/packages/core-internal/test/shared/protocol.test.ts b/packages/core-internal/test/shared/protocol.test.ts index 95d038c2ce..2fb0f64813 100644 --- a/packages/core-internal/test/shared/protocol.test.ts +++ b/packages/core-internal/test/shared/protocol.test.ts @@ -866,6 +866,23 @@ describe('protocol tests', () => { const cancelledSent = (sent: JSONRPCMessage[]): JSONRPCMessage[] => sent.filter(m => 'method' in m && m.method === 'notifications/cancelled'); + /** + * Connects a fresh protocol over a single-channel transport (stdio / + * in-memory shape: no `hasPerRequestStream`) at `version`, recording + * every outbound message. + */ + const connectSingleChannel = async (version: string) => { + const sent: JSONRPCMessage[] = []; + const tx = new MockTransport(); + tx.send = async (m: JSONRPCMessage) => { + sent.push(m); + }; + const proto = createTestProtocol(); + await proto.connect(tx); + setNegotiatedProtocolVersion(proto, version); + return { proto, sent }; + }; + test('modern era + per-request-stream transport: abort closes the stream, NO notifications/cancelled', async () => { const tx = new PerRequestStreamTransport(); const proto = createTestProtocol(); @@ -888,15 +905,7 @@ describe('protocol tests', () => { }); test('modern era + single-channel transport (no hasPerRequestStream): POSTs notifications/cancelled', async () => { - // stdio / in-memory shape: hasPerRequestStream is undefined. - const sent: JSONRPCMessage[] = []; - const tx = new MockTransport(); - tx.send = async (m: JSONRPCMessage, _opts?: TransportSendOptions) => { - sent.push(m); - }; - const proto = createTestProtocol(); - await proto.connect(tx); - setNegotiatedProtocolVersion(proto, '2026-07-28'); + const { proto, sent } = await connectSingleChannel('2026-07-28'); const ac = new AbortController(); const pending = testRequest(proto, { method: 'example', params: {} }, z.object({}), { signal: ac.signal }); @@ -937,6 +946,53 @@ describe('protocol tests', () => { expect(tx.lastRequestSignal?.aborted).toBe(true); expect(cancelledSent(tx.sent)).toHaveLength(0); }); + + // "A client MUST NOT attempt to cancel its `initialize` request." The + // handshake is exempt from the POST path above on every transport: an + // abort or timeout rejects the caller locally and sends nothing. Both + // triggers are covered because they reach cancel() by different routes + // (the caller's signal vs the timeout handler). + describe('the initialize handshake is never cancelled on the wire', () => { + test('aborting an in-flight initialize sends NO notifications/cancelled', async () => { + // ARRANGE + const { proto, sent } = await connectSingleChannel('2025-11-25'); + + // ACT + const ac = new AbortController(); + const pending = testRequest(proto, { method: 'initialize', params: {} }, z.object({}), { signal: ac.signal }); + ac.abort('user cancel'); + + // ASSERT — rejects locally, wire stays clean + await expect(pending).rejects.toThrow(); + expect(cancelledSent(sent)).toHaveLength(0); + }); + + test('timing out an in-flight initialize sends NO notifications/cancelled', async () => { + // ARRANGE + const { proto, sent } = await connectSingleChannel('2025-11-25'); + + // ACT + const pending = testRequest(proto, { method: 'initialize', params: {} }, z.object({}), { timeout: 0 }); + + // ASSERT + await expect(pending).rejects.toThrow(); + expect(cancelledSent(sent)).toHaveLength(0); + }); + + test('every other method still POSTs notifications/cancelled (regression guard)', async () => { + // ARRANGE + const { proto, sent } = await connectSingleChannel('2025-11-25'); + + // ACT + const ac = new AbortController(); + const pending = testRequest(proto, { method: 'example', params: {} }, z.object({}), { signal: ac.signal }); + ac.abort('user cancel'); + + // ASSERT + await expect(pending).rejects.toThrow(); + expect(cancelledSent(sent)).toHaveLength(1); + }); + }); }); }); diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index a3439e1ceb..75311be0e3 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -140,15 +140,10 @@ export const REQUIREMENTS: Record = { note: 'Stateless hosting creates a fresh server per request and has no standalone GET stream, so there is no server→client channel to deliver/observe these.' }, 'protocol:cancel:initialize-not-cancellable': { - transports: STATEFUL_TRANSPORTS, + transports: ['inMemory'], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation#behavior-requirements', behavior: 'The client never sends notifications/cancelled for the initialize request.', - note: 'Stateless hosting creates a fresh server per request and has no standalone GET stream, so there is no server→client channel to deliver/observe these.', - knownFailures: [ - { - note: 'SDK sends notifications/cancelled for initialize when connect() is aborted; spec says initialize MUST NOT be cancelled.' - } - ] + note: "The behavior itself is transport-agnostic (shared/protocol.ts), but the test must tap the client's outbound messages before connect() resolves, which only the in-memory wiring supports." }, 'protocol:cancel:late-response-ignored': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation#timing-considerations',