Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/no-cancel-notification-for-initialize.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions .changeset/require-protocol-version-header-on-modern-post.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 18 additions & 4 deletions docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -636,7 +638,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
Expand Down
6 changes: 5 additions & 1 deletion docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 46 additions & 8 deletions packages/core-internal/src/shared/inboundClassification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ' +
Expand Down Expand Up @@ -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
Expand All @@ -511,14 +528,35 @@ 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') {
return undefined;
}
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',
Expand Down
36 changes: 23 additions & 13 deletions packages/core-internal/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1453,19 +1453,29 @@ export abstract class Protocol<ContextT extends BaseContext> {
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading