Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
5 changes: 5 additions & 0 deletions .changeset/legacy-sse-reconnect-after-timeout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': 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, the stream-close cancel on modern ones) while the caller still sees the original maxTotalTimeout error. 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.

Check warning on line 5 in .changeset/legacy-sse-reconnect-after-timeout.md

View check run for this annotation

Claude / Claude Code Review

Changeset under-documents server-side wire change and onerror-contract changes

The changeset under-documents two consumer-observable behavior changes this PR ships: (1) it names only `@modelcontextprotocol/client`, but the maxTotalTimeout cancel reroute lives in the shared Protocol layer bundled into `@modelcontextprotocol/server` too — a server-initiated request (createMessage/elicitInput/requestSampling with `maxTotalTimeout` + `resetTimeoutOnProgress`) that trips the budget now POSTs `notifications/cancelled` where pre-PR nothing went on the wire, so the server CHANGELO
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
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
72 changes: 56 additions & 16 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,9 +334,12 @@ export class StreamableHTTPClientTransport implements Transport {

/**
* 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 @@ -684,9 +687,13 @@ export class StreamableHTTPClientTransport implements Transport {
// (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) {
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.
Expand Down Expand Up @@ -954,11 +961,37 @@ export class StreamableHTTPClientTransport implements Transport {
// 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.
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 +1144,14 @@ export class StreamableHTTPClientTransport implements Transport {
// 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,13 +1200,14 @@ export class StreamableHTTPClientTransport implements Transport {
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;
Comment thread
claude[bot] marked this conversation as resolved.
Comment thread
claude[bot] marked this conversation as resolved.
Expand Down
Loading
Loading