Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
295267e
fix(client): abort legacy SSE reconnect chain when the originating re…
claude Aug 6, 2026
29c4e86
fix(client): cover all settlement paths for the request-scoped abort;…
claude Aug 6, 2026
e00d771
fix(client): stop the resumptionToken send path double-reporting and …
claude Aug 6, 2026
473bb37
fix(client): sweep the last bare _startOrAuthSse catch; scope resumed…
claude Aug 6, 2026
c1b55eb
fix(client,core): finish the onerror discipline sweep; wire cancel fo…
claude Aug 6, 2026
ec94475
test(e2e): update reconnect-failure onerror assertions to the report-…
claude Aug 6, 2026
248cb69
fix(core,client): disarm the leg timer on maxTotalTimeout; guard term…
claude Aug 6, 2026
3b486e8
fix(core,client): Gecko-safe timeout timers; keep resume token across…
claude Aug 6, 2026
d3bc0a3
fix(core,client): guard the cancellation-send and SSE POST catches; d…
claude Aug 6, 2026
adefd71
fix(core,client): honor relatedRequestId 0 in the debounce gate; disa…
claude Aug 6, 2026
3f66066
fix(core,client): SSE transport lifecycle hardening; identity-keyed c…
claude Aug 6, 2026
e0ab79e
fix(client): disarm-once reconnect bookkeeping; one composed abort si…
claude Aug 6, 2026
2728d04
fix(core,client): honor maxTotalTimeout 0; no EventSource resurrectio…
claude Aug 6, 2026
0e65433
fix(client): contain throwing scheduler cancels in the settlement lis…
claude Aug 6, 2026
9636485
fix(core,client): settle the graceful SSE tail outside the read try; …
claude Aug 6, 2026
6bf58a3
fix(client): contain every user callback in the reconnect machinery; …
claude Aug 6, 2026
87ebece
fix(client): report raw scheduler errors on the error-path first-sche…
claude Aug 6, 2026
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
6 changes: 6 additions & 0 deletions .changeset/legacy-sse-reconnect-after-timeout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/server': patch
---

Stop the legacy-era (2025-11-25) Streamable HTTP SSE reconnect chain when its originating request settles. Previously, when a request on a legacy session timed out or was aborted, the client POSTed `notifications/cancelled` but the transport kept resuming the request's SSE stream via GET + Last-Event-ID indefinitely (each successful resume reset the retry counter), and a late resumed response surfaced as "Received a response for an unknown message ID". The protocol layer now threads a request-scoped abort signal to per-request-stream transports on the legacy era too, and aborts it on EVERY settlement path — cancel/timeout, `maxTotalTimeout` exceeded, and successful completion (which also releases the per-request abort listener otherwise leaked on Node 20.0–20.2) — the wire cancellation mechanism per era is unchanged (legacy connections still POST `notifications/cancelled`; modern 2026-07-28 connections still cancel via per-request stream close), and `maxTotalTimeout` settlements — which previously settled without any wire cancel signal at either era — now route through the request's cancel path and emit the era's signal (the `notifications/cancelled` POST on legacy connections and modern single-channel transports, the stream-close cancel on modern per-request-stream connections) while the caller still sees the original maxTotalTimeout error. This lives in the shared `Protocol` base, so server-initiated requests (`createMessage`, `elicitInput`) gain the same maxTotalTimeout cancellation signal. Additionally, the `notifications/cancelled` send no longer inherits the original request's `resumptionToken`, which on Streamable HTTP short-circuited the send into an SSE resume and silently swallowed the cancellation. For a request re-issued with `resumptionToken` on the SDK's own Streamable HTTP transport that POST is best-effort: the re-issued request's JSON-RPC id never reached the server (the send resumed via GET instead of POSTing), so the server cannot correlate the cancellation and such requests are only torn down locally; custom per-request-stream transports that POST the re-issued body normally give the server a correlatable id. (Same asymmetry inbound, pre-existing: progress notifications replayed on a resumed stream carry the original request's `progressToken`, so `onprogress`/`resetTimeoutOnProgress` do not survive a `resumptionToken` re-issue on this transport.) The client transport's `onerror` contract is also tightened: each failed SSE reconnect leg now reports exactly once with the underlying error (the `"Failed to reconnect SSE stream:"` wrapper message is gone), deliberate teardown (`close()` landing mid-POST/mid-GET/mid-DELETE, or a settled request's signal aborting its resume) no longer surfaces an `AbortError` through `onerror`, and `onRequestStreamEnd` now fires when a `resumptionToken` resume fails to open (a terminal outcome that previously reported only through `onerror`).
3 changes: 2 additions & 1 deletion docs/advanced/custom-transports.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
shape: how-to
---

# Custom transports

A **transport** moves `JSONRPCMessage` values in both directions over a channel the SDK knows nothing about. Implement the `Transport` interface and `connect()` accepts it like a built-in one.
Expand Down Expand Up @@ -180,7 +181,7 @@ async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<voi
}
```

On a 2026-07-28 connection the protocol layer cancels an in-flight request by aborting that request's `requestSignal` instead of sending `notifications/cancelled` see [Protocol versions](../protocol-versions.md). Single-channel transports — stdio, the loopback above — leave the flag undefined and ignore `requestSignal`; cancellation stays a notification for them.
The protocol layer threads `requestSignal` into every outbound request on a per-request-stream transport and aborts it when the request settles (response, error, timeout, or caller abort). On a 2026-07-28 connection that abort IS the spec cancellation — no `notifications/cancelled` is sent — while on a 2025-era connection it is local teardown (stop the request's stream and any reconnect state) accompanying the `notifications/cancelled` POST; see [Protocol versions](../protocol-versions.md). Because the abort is always intentional, a transport that forwards `requestSignal` into `fetch` (as above) should treat the resulting `AbortError` as a clean shutdown — swallow it rather than surfacing it through `onerror` or scheduling a reconnect. Single-channel transports — stdio, the loopback above — leave the flag undefined and ignore `requestSignal`; cancellation stays a notification for them.

## Test it against the in-memory pair

Expand Down
13 changes: 9 additions & 4 deletions docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,10 +236,15 @@ 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
`readonly hasPerRequestStream = true`.
code. 2025-era connections and stdio at any era still send `notifications/cancelled`;
on a 2025-era Streamable HTTP connection the abort of the request's stream additionally
happens as purely local teardown accompanying that POST (it stops the request's SSE
reconnect chain once the request settles). Custom `Transport` implementations that open
one underlying request per outbound message and honor
`TransportSendOptions.requestSignal` may opt in by declaring
`readonly hasPerRequestStream = true` — the protocol layer threads `requestSignal` into
every request on such transports, at either protocol version, and aborts it whenever
Comment thread
claude[bot] marked this conversation as resolved.
the request settles.

### `ctx.mcpReq.log()` and the per-request `logLevel`

Expand Down
7 changes: 5 additions & 2 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -1825,8 +1825,11 @@ where an entry notes its own signature change:
wrappers, test doubles, decorators) compile and run against v2 with only the import
path updated. v2 adds **optional** members only — `hasPerRequestStream` and
`setSupportedProtocolVersions` on the interface, `requestSignal` / `headers` /
`onRequestStreamEnd` on `TransportSendOptions` — which matter only for 2026-era
per-request-stream cancellation and `Mcp-Param-*` header attachment
`onRequestStreamEnd` on `TransportSendOptions` — used for per-request
cancellation and teardown at either protocol version on per-request-stream
transports (on a 2026-era connection the `requestSignal` abort IS the spec
cancel signal; on a 2025-era connection it is local teardown accompanying the
`notifications/cancelled` POST) and for `Mcp-Param-*` header attachment
([support-2026-07-28.md](./support-2026-07-28.md)).
- All TypeScript **type** definitions from `types.ts` (except the aliases listed under
[Removed type aliases](#removed-type-aliases) and the `experimental` capability
Comment thread
claude[bot] marked this conversation as resolved.
Expand Down
108 changes: 89 additions & 19 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@
/**
* Override Message ID to associate with the replay message
* so that the response can be associated with the new resumed request.
*
* Only JSON-RPC RESPONSES are remapped. Notifications replayed on the
* resumed stream (e.g. `notifications/progress`) pass through verbatim,
* still carrying the original request's identifiers — the transport never
* learns the original wire id (callers persist SSE event ids, not message
* ids), so it has nothing to remap `params.progressToken` with.
*/
replayMessageId?: string | number;

Expand Down Expand Up @@ -334,9 +340,12 @@

/**
* Streamable HTTP opens one POST (and SSE response stream) per outbound
* request and honors `TransportSendOptions.requestSignal`. On a 2026-era
* connection the protocol layer aborts that per-request stream as the
* spec cancellation signal instead of POSTing `notifications/cancelled`.
* request and honors `TransportSendOptions.requestSignal`. The protocol
* layer threads `requestSignal` into every outbound request and aborts it
* when the request settles — on a 2026-era connection that abort IS the
* spec cancellation signal (no `notifications/cancelled` is sent); on a
* 2025-era connection it is purely local teardown (it stops the request's
* SSE reconnect chain) accompanying the `notifications/cancelled` POST.
*/
readonly hasPerRequestStream = true;

Expand Down Expand Up @@ -681,15 +690,19 @@
const reconnect = (): void => {
this._cancelReconnection = undefined;
// Honour BOTH the transport-wide abort and the per-request abort
// (a listen subscription closed during the backoff delay): do not
// resurrect a stream the caller already tore down.
if (this._abortController?.signal.aborted || options.requestSignal?.aborted) return;
this._startOrAuthSse(options).catch(error => {
this._startOrAuthSse(options).catch(() => {
if (this._abortController?.signal.aborted || options.requestSignal?.aborted) return;
this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`));
// No onerror here: `_startOrAuthSse`'s own catch already
// reported the genuine failure once before rethrowing (and
// stayed silent on an intentional abort — caught above).
// Reporting again would double-fire onerror for every failed
// reconnect leg. Just schedule the next attempt.
try {
this._scheduleReconnection(options, attemptCount + 1);
} catch (scheduleError) {

Check notice on line 705 in packages/client/src/client/streamableHttp.ts

View check run for this annotation

Claude / Claude Code Review

Single-slot _cancelReconnection cannot cancel concurrent reconnect chains on close(), and settled requests leave armed timers/scheduler tasks

Pre-existing issue at the center of the reconnect-teardown machinery this PR extends: `_cancelReconnection` is a single per-transport slot, but the transport routinely owns multiple concurrent reconnect chains (the standalone notification GET chain plus one per in-flight request), so each `_scheduleReconnection` overwrites the slot and `close()` can disarm only the last-written pending attempt — surviving timers pin a Node process for up to `maxReconnectionDelay` (30s default), and the `Reconnec
Comment thread
claude[bot] marked this conversation as resolved.
Comment thread
claude[bot] marked this conversation as resolved.
Comment thread
claude[bot] marked this conversation as resolved.
this.onerror?.(scheduleError instanceof Error ? scheduleError : new Error(String(scheduleError)));
}
Comment thread
claude[bot] marked this conversation as resolved.
});
Expand Down Expand Up @@ -791,7 +804,13 @@
if (needsReconnect && this._abortController && !isIntentionalAbort()) {
this._scheduleReconnection(
{
resumptionToken: lastEventId,
// Fall back to the token this leg was opened with:
// a resume leg that drops before delivering its
// first event has no `lastEventId`, and rebuilding
// without a token would degrade the resume into a
// token-less standalone GET (dead-ends on 405
// servers; loses the replay position otherwise).
resumptionToken: lastEventId ?? options.resumptionToken,
onresumptiontoken,
replayMessageId,
requestSignal,
Expand Down Expand Up @@ -824,7 +843,10 @@
try {
this._scheduleReconnection(
{
resumptionToken: lastEventId,
// Same fallback as the graceful-close path
// above: never rebuild a resume without its
// token.
resumptionToken: lastEventId ?? options.resumptionToken,
onresumptiontoken,
replayMessageId,
requestSignal,
Expand Down Expand Up @@ -954,11 +976,45 @@
// same per-request abort as the original POST — modern-era
// cancel-via-stream-close routes through `requestSignal`, and
// without it a resumed long-running request would not cancel.
// `onresumptiontoken` / `onRequestStreamEnd` are forwarded like
// every other `_startOrAuthSse` call site, so a resumed request
Comment thread
claude[bot] marked this conversation as resolved.
// keeps reporting newer event IDs to the caller's persistence
// hook and still fires the stream-end callback on a terminal
// non-resumable outcome.
//
// Known limitation: `onprogress` / `resetTimeoutOnProgress` do
// NOT survive a resumptionToken re-issue on this transport.
// The re-issued request's fresh progressToken never reaches
// the wire (this path resumes via GET instead of POSTing),
// and replayed `notifications/progress` carry the ORIGINAL
// request's token — see the `replayMessageId` JSDoc: only
// responses are remapped.
this._startOrAuthSse({
resumptionToken,
onresumptiontoken,
replayMessageId: isJSONRPCRequest(message) ? message.id : undefined,
requestSignal: options?.requestSignal
}).catch(error => this.onerror?.(error));
requestSignal: options?.requestSignal,
onRequestStreamEnd: options?.onRequestStreamEnd
}).catch(() => {
// Swallow the rethrow: `_startOrAuthSse`'s own catch already
// surfaced genuine failures via `onerror` before rethrowing
// (and deliberately stayed silent on an intentional abort —
// transport close or a settled request's `requestSignal`).
// Reporting here would double-fire `onerror` for real
// failures and turn a clean per-request teardown into a
// spurious `AbortError`.
//
// A genuine open failure IS terminal for the resumed
// stream (an initial-open failure never enters the
// reconnect loop) and this send() already resolved
// fire-and-forget — fire the stream-end callback so the
// caller can settle, mirroring `_scheduleReconnection`'s
// maxRetries-exhaustion branch. Not on intentional aborts:
// the contract excludes deliberate teardown.
if (options?.requestSignal?.aborted !== true && this._abortController?.signal.aborted !== true) {
options?.onRequestStreamEnd?.();
}
Comment thread
claude[bot] marked this conversation as resolved.
});
return;
}

Comment thread
claude[bot] marked this conversation as resolved.
Comment thread
claude[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -1111,8 +1167,14 @@
// if the accepted notification is initialized, we start the SSE stream
// if it's supported by the server
if (isInitializedNotification(message)) {
// Start without a lastEventId since this is a fresh connection
this._startOrAuthSse({ resumptionToken: undefined }).catch(error => this.onerror?.(error));
// Start without a lastEventId since this is a fresh connection.
// Swallow the rethrow: `_startOrAuthSse`'s own catch already
// surfaced genuine failures via `onerror` before rethrowing
// (and deliberately stayed silent on an intentional abort —
// transport close). Reporting here would double-fire
// `onerror` for real failures and turn a clean shutdown
// into a spurious `AbortError`.
this._startOrAuthSse({ resumptionToken: undefined }).catch(() => {});
}
return;
}
Comment thread
claude[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -1161,16 +1223,17 @@
await response.text?.().catch(() => {});
}
} catch (error) {
// Intentional per-request abort BEFORE response headers (the
// `subscriptions/listen` driver aborting its `requestSignal`):
// fetch rejects with AbortError. Same guard as
// `_handleSseStream`'s `isIntentionalAbort` — do not surface a
// misleading onerror; still rethrow so `listen()`'s send-catch
// settles the per-subscription state machine.
if (options?.requestSignal?.aborted !== true) {
// Intentional abort BEFORE response headers — a per-request abort
// (the `subscriptions/listen` driver aborting its `requestSignal`)
// or a transport-wide close() landing mid-POST: fetch rejects with
// AbortError. Same guard as `_handleSseStream`'s
// `isIntentionalAbort` (BOTH signal halves) — do not surface a
// misleading onerror; still rethrow so `listen()`'s send-catch and
// the protocol layer settle their state machines.
if (options?.requestSignal?.aborted !== true && this._abortController?.signal.aborted !== true) {
this.onerror?.(error as Error);
}
throw error;

Check notice on line 1236 in packages/client/src/client/streamableHttp.ts

View check run for this annotation

Claude / Claude Code Review

onerror-discipline sweep leaves SSEClientTransport._send catch unguarded: close() mid-POST still surfaces a spurious AbortError

Pre-existing issue, outside this diff (anchored here because the actual site sits in a sibling file): `SSEClientTransport._send`'s catch (`packages/client/src/client/sse.ts:409-412`) is the last surviving instance in the client package of the unguarded `this.onerror?.(error); throw error;` catch shape this PR's onerror-discipline sweep replaced. Its POST runs on `signal: this._abortController?.signal` alone and `close()` aborts that controller, so `close()` landing mid-POST on the legacy HTTP+SS
Comment thread
claude[bot] marked this conversation as resolved.
Comment thread
claude[bot] marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -1222,7 +1285,14 @@

this._sessionId = undefined;
} catch (error) {
this.onerror?.(error as Error);
// Same guard as the POST path: the DELETE runs on the
// transport-lifetime signal alone, so close() landing mid-flight
// (or terminateSession() called after close()) rejects with an
// intentional AbortError — a clean shutdown, not a transport
// error. Still rethrow so the caller sees the failure.
if (this._abortController?.signal.aborted !== true) {
this.onerror?.(error as Error);
}
throw error;
}
}
Expand Down
Loading
Loading