Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/shttp-session-capture-on-ok.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

`StreamableHTTPClientTransport` session-id handling now follows the spec's assignment model: the `Mcp-Session-Id` response header is captured only from a successful response to a POST that carried an `InitializeRequest` — the spec assigns the id "at initialization time ... on the HTTP response containing the `InitializeResult`", and is silent on the header elsewhere. Session headers on any other response are now ignored entirely (previously any 2xx reply could install one), so an error reply, a late reply from an older session, or a stray header on a normal request contributes nothing to session state. Previously a 404 probe reply carrying a session id made the legacy fallback `initialize` present an id the server never issued, which strict stateful servers reject. A 404 to a POST that carried the current id now clears it (the session is gone per spec), and `terminateSession()` treats 404 as successful termination. On a legacy-era connection the client then starts a new session automatically — a fresh session-less `InitializeRequest`, per the spec's session-management requirement — and retries the failed request or notification once (pagination continuations re-establish the session but surface the 404 — the dead session's cursor is meaningless to the new one); server-side per-session state (e.g. resource subscriptions) does not carry over.
21 changes: 20 additions & 1 deletion docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -1560,6 +1560,23 @@ rewrite required unless noted.
open is benign (the client proceeds without the stream), and a `405` answering the
session DELETE resolves `terminateSession()` normally — stateless-topology servers
that decline both verbs keep working without changes, as in v1.
- **Changed: session-id capture is handshake-only.** The `Mcp-Session-Id` response
header is stored only from a successful response to a POST that carried an
`InitializeRequest` — the spec assigns the id "at initialization time ... on the
HTTP response containing the `InitializeResult`". v1 captured the header from any
response (including error replies), so a server that (re)assigned session ids on
ordinary responses could silently rotate the client's session. The spec defines
that single assignment point and is silent on the header elsewhere, so the client
now ignores it on every other response — a server that wants a session must
Comment on lines +1567 to +1570

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The closing sentence of the new session-404 bullet — "Host code keyed off that 404 now only sees it when the automatic recovery has also failed, at which point the client is closed" (echoed at line ~1627) — is false for pagination continuations: per the cursor guard in Client.request, a cursor-carrying request surfaces the original 404 even when recovery succeeded, with the client open and fully usable (the PR's own test asserts a follow-up tools/list succeeds after the surfaced 404). A host following the guide as written would tear down a healthy, just-recovered client on every cursor-page session expiry; qualify both sentences to carve out the pagination case.

Extended reasoning...

What the docs claim vs. what the code does. The new migration-guide bullet (docs/migration/upgrade-to-v2.md:1569-1570) ends with: "Host code keyed off that 404 now only sees it when the automatic recovery has also failed, at which point the client is closed." The same claim is repeated at :1625-1627: "hosts only observe it when that recovery fails." But the code has a third outcome the sentence doesn't cover: in the Client.request override (packages/client/src/client/client.ts:923-935), await this._recoverSession(error) runs before the cursor guard, and only after recovery succeeds does the 'cursor' in request.params check rethrow the original 404. So for a pagination continuation the host observes a terminated-session 404 while the client is open, recovered, and fully usable.

The code path, concretely:

  1. Host paginates: client.listTools() returns nextCursor; the session then expires server-side.
  2. client.request({ method: 'tools/list', params: { cursor } }) POSTs with the dead session id → 404 with sessionTerminated: true.
  3. The override's catch runs await this._recoverSession(error) — a fresh session-less initialize/initialized handshake succeeds; the transport now holds a new live session id.
  4. Only then does the cursor guard match and rethrow the original 404 (the dead session's cursor is meaningless to the new session).
  5. The host's promise rejects with a terminated-session 404 — while the client is connected and the next request works.

The PR's own test pins this. sessionReinitialize.test.ts's "recovers the session on a pagination continuation but does not retry it" asserts exactly the sequence above: the cursor request rejects with the 404, the wire trace shows ['initialize', 'tools/list', 'initialize'] (recovery ran), and a follow-up tools/list on the same client then succeeds. The changeset (.changeset/shttp-session-capture-on-ok.md) even describes it accurately — "pagination continuations re-establish the session but surface the 404" — so the migration guide contradicts the PR's own more careful wording. The bullet's earlier parenthetical ("pagination continuations are not retried") hints at the exception, but the closing sentence then asserts the opposite unconditionally.

Why it matters. The migration guide is the document hosts are told to code against (the :1623-1625 prose explicitly directs client code to "key off the HTTP 404 status"). A host that follows the sentence literally — "a surfaced terminated-session 404 means recovery failed and the client is closed, so rebuild client + transport" — will discard a healthy, just-recovered client every time a cursor page hits session expiry. That is precisely the reconnect churn the automatic recovery feature was added to eliminate, and mid-pagination expiry is a realistic ordering (long list walks are exactly when a session TTL is likely to lapse). Note this isn't confined to manual pagination: _listAllPages (behind the default no-cursor listTools/listPrompts/listResources aggregate walk) issues {...params, cursor} requests for pages 2+, so the most common list path can surface this 404 with the client open.

Why nit, not blocking. This is docs-only — nothing in the SDK misbehaves at runtime; the damage requires a host to code to the sentence literally, and the fix is a one-line qualification in each of the two places.

How to fix. Qualify both sentences, e.g. at :1569-1570: "Host code keyed off that 404 now only sees it when the automatic recovery has also failed (the client is closed at that point) — or when the failed request was a pagination continuation, in which case the session has been re-established and the client remains usable; restart pagination from the first page." And at :1627: "hosts only observe it when that recovery fails, or on a pagination continuation (session recovered; restart pagination)."

assign the id on the `initialize` response, as SDK servers always have.
- **Changed: session `404` handling.** A `404` answering the session DELETE now
resolves `terminateSession()` (v1 threw) — the session is gone either way, and the
stored id is cleared. A `404` to a POST that carried the current session id clears
`transport.sessionId`, and on a pre-2026 connection the client re-initializes
automatically and retries the failed request once (pagination continuations are not
retried, and server-side per-session state such as subscriptions or log level does
not carry over). Host code keyed off that `404` now only sees it when the automatic
recovery has also failed, at which point the client is closed.

#### stdio transport

Expand Down Expand Up @@ -1614,7 +1631,9 @@ rewrite required unless noted.
request body no longer enable it.
- Session-ID mismatch still responds `404` with JSON-RPC `-32001` (`Session not found`),
unchanged from v1. This `-32001` is an SDK convention, not spec-assigned; client code
should key off the HTTP `404` status, not `-32001`.
should key off the HTTP `404` status, not `-32001`. The v2 client handles this `404`
itself on pre-2026 connections (see the session `404` bullet under the client
Streamable HTTP section above) — hosts only observe it when that recovery fails.

#### Server (deprecated accessors and app-factory Origin validation)

Expand Down
2 changes: 1 addition & 1 deletion docs/serving/sessions-state-scaling.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ app.get('/mcp', route);
app.delete('/mcp', route);
```

The map cleans itself up: `transport.onclose` fires when the session ends, whether the client sent `DELETE` or you called `transport.close()`. A request with an unknown `Mcp-Session-Id` gets the `404` above, which tells the client to start a new session; a request with no session header at all gets the `400`, which tells it to re-send the id it already has instead of re-initializing.
The map cleans itself up: `transport.onclose` fires when the session ends, whether the client sent `DELETE` or you called `transport.close()`. A request with an unknown `Mcp-Session-Id` gets the `404` above, which tells the client to start a new session (the v2 client does this automatically on pre-2026 connections, re-initializing and retrying the failed request once); a request with no session header at all gets the `400`, which tells it to re-send the id it already has instead of re-initializing.

::: tip
On shutdown, close every stored transport — `for (const [, transport] of sessions) await transport.close()` — before exiting; `close()` ends the session's SSE streams and rejects its pending requests.
Expand Down
24 changes: 9 additions & 15 deletions examples/repl/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,23 +531,17 @@ async function terminateSession(): Promise<void> {

try {
console.log('Terminating session with ID:', transport.sessionId);
// terminateSession() also resolves on 405 (server declines DELETE)
// and 404 (session already gone), clearing the session id in every
// resolving case — the outcome is not distinguishable here.
await transport.terminateSession();
console.log('Session terminated successfully');
console.log('Session terminated; session ID cleared');
sessionId = undefined;

// Check if sessionId was cleared after termination
if (transport.sessionId) {
console.log('Server responded with 405 Method Not Allowed (session termination not supported)');
console.log('Session ID is still active:', transport.sessionId);
} else {
console.log('Session ID has been cleared');
sessionId = undefined;

// Also close the transport and clear client objects
await transport.close();
console.log('Transport closed after session termination');
client = null;
transport = null;
}
await transport.close();
console.log('Transport closed after session termination');
client = null;
transport = null;
} catch (error) {
console.error('Error terminating session:', error);
}
Expand Down
90 changes: 90 additions & 0 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@
LoggingLevel,
MessageExtraInfo,
NonCompleteResultFlow,
Notification,
NotificationMethod,
NotificationOptions,
ProtocolEra,
ProtocolOptions,
ReadResourceRequest,
Expand All @@ -43,6 +45,7 @@
RequestOptions,
ResolvedInputRequiredDriverConfig,
Result,
ResultTypeMap,
ServerCapabilities,
StandardSchemaV1,
SubscribeRequest,
Expand Down Expand Up @@ -74,6 +77,7 @@
scanXMcpHeaderDeclarations,
SdkError,
SdkErrorCode,
SdkHttpError,
SUBSCRIPTION_ID_META_KEY,
SUPPORTED_MODERN_PROTOCOL_VERSIONS
} from '@modelcontextprotocol/core-internal';
Expand Down Expand Up @@ -897,6 +901,92 @@
}
}

private _sessionReinit?: Promise<void>;

override request<M extends RequestMethod>(
request: { method: M; params?: Record<string, unknown> },
options?: RequestOptions
): Promise<ResultTypeMap[M]>;
override request<T extends StandardSchemaV1>(
request: { method: string; params?: Record<string, unknown> },
resultSchema: T,
options?: RequestOptions
): Promise<StandardSchemaV1.InferOutput<T>>;
override async request(
request: { method: string; params?: Record<string, unknown> },
schemaOrOptions?: StandardSchemaV1 | RequestOptions,
maybeOptions?: RequestOptions
): Promise<unknown> {
Comment thread
claude[bot] marked this conversation as resolved.
try {
return await super.request(request as never, schemaOrOptions as never, maybeOptions);
} catch (error) {
// Spec (Session Management): a 404 to a request that carried the
// session id means the session is gone — start a new one with a
// fresh InitializeRequest, then retry the failed request once.
if (request.method === 'initialize' || !this._isSessionTerminated(error)) {
throw error;
}
await this._recoverSession(error);
// A cursor minted by the dead session is meaningless to the new
// one: the session is recovered, but the 404 surfaces so the
// caller restarts pagination.
if (request.params !== undefined && 'cursor' in request.params) {
throw error;
}
return await super.request(request as never, schemaOrOptions as never, maybeOptions);

Check warning on line 936 in packages/client/src/client/client.ts

View check run for this annotation

Claude / Claude Code Review

SDK-owned aggregate list walk (_listAllPages) surfaces the terminated-session 404 mid-walk instead of restarting

The default no-cursor `listTools()`/`listPrompts()`/`listResources()`/`listResourceTemplates()` all route through `_listAllPages`, whose page-2+ requests carry a `cursor` — so a session expiring mid-walk makes the whole aggregate call reject with the terminated-session 404 (per the cursor guard here) even though `_recoverSession` already succeeded and the client is healthy. Since `_listAllPages` holds `baseParams` and the accumulator, consider catching the terminated-session 404 on a page contin
Comment on lines +930 to +936

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The default no-cursor listTools()/listPrompts()/listResources()/listResourceTemplates() all route through _listAllPages, whose page-2+ requests carry a cursor — so a session expiring mid-walk makes the whole aggregate call reject with the terminated-session 404 (per the cursor guard here) even though _recoverSession already succeeded and the client is healthy. Since _listAllPages holds baseParams and the accumulator, consider catching the terminated-session 404 on a page continuation there and restarting the walk once from page 1, making the SDK-owned list path as transparently recoverable as callTool() already is.

Extended reasoning...

What happens. The cursor guard in the new Client.request override (client.ts:930-936) rethrows the terminated-session 404 for any request whose params contain cursor, on the rationale that "the caller restarts pagination." That rationale holds for a host-managed cursor — but the SDK's most common list path is itself that caller: the default listTools() (client.ts:2506), listPrompts() (:1601), listResources() (:1641), and listResourceTemplates() (:1674) all delegate to _listAllPages (:1701), which issues this.request({ method, params: { ...baseParams, cursor } }, options) for every page ≥ 2 (:1730) with no catch in the walk loop.

The code path, step by step.

  1. Legacy-era stateful server with a multi-page tool list; host calls client.listTools() (no cursor).
  2. Page 1 goes out as {...baseParams} — no cursor key — so if the session were already dead here, recovery + retry would work transparently.
  3. The session expires between page 1 and page 2 (long list walks are exactly where a TTL lapse lands).
  4. The page-2 request POSTs with the dead id → 404 with sessionTerminated: true. The override's catch runs _recoverSession — a fresh session-less initialize/initialized handshake succeeds.
  5. The cursor guard then matches ('cursor' in request.params) and rethrows the original 404. _listAllPages has no catch, so the entire listTools() promise rejects with a raw SdkHttpError 404 — while the client is open, recovered, and the very next call works.

Why this is a gap in the PR's own design, not just documented behavior. The changeset does say "pagination continuations re-establish the session but surface the 404," but the guard's justification — the caller restarts pagination — presumes the caller can. For the aggregate walk, the caller is _listAllPages itself: it holds baseParams and the accumulator and could trivially restart from page 1 after the recovery it just performed. The external host called listTools() with no cursor, never manages pagination, and receives an opaque 404 from a convenience API whose whole purpose is hiding pagination. Worse, per the migration guide's (separately flagged) claim that a surfaced 404 means "recovery has also failed, at which point the client is closed," a guide-following host would tear down a healthy, just-recovered client. The PR's own pagination test (sessionReinitialize.test.ts, "recovers the session on a pagination continuation but does not retry it") pins only the manual client.request({ ..., params: { cursor } }) case — the aggregate walk is untested.

Impact and why it's a nit. The failure is transient and self-healing: the session is re-established as part of the failed call, so an immediate host retry of listTools() succeeds; nothing wedges or closes. It's also not a regression — pre-PR the same mid-walk expiry rejected listTools() with a 404 and no recovery at all — and the trigger window is narrow (expiry must land exactly between pages of a typically sub-second walk). Merging as-is is strictly better than main; this is incomplete coverage of the new recovery feature on one common path, worth a follow-up rather than a blocker.

How to fix. In _listAllPages, catch the terminated-session 404 (SdkHttpError with status === 404 && data.sessionTerminated === true) on a page-continuation request and restart the walk once from page 1 — the session was already re-established by the request() override, so the restart just re-runs the loop with the retained baseParams and a fresh accumulator. (Alternatively, route the walk's internal page requests around the cursor guard with a single internal retry.) Note this is distinct from the earlier docs comment on upgrade-to-v2.md:1570, which asks only to qualify the migration-guide prose; this is the complementary code-level fix that would make the qualification largely moot for the SDK-owned path.

}
}

override async notification(notification: Notification, options?: NotificationOptions): Promise<void> {
try {
return await super.notification(notification, options);
} catch (error) {
// `notifications/initialized` is part of the recovery handshake
// itself — recovering on it would await the in-flight recovery.
if (notification.method === 'notifications/initialized' || !this._isSessionTerminated(error)) {
throw error;
}
await this._recoverSession(error);
return await super.notification(notification, options);
}
}
Comment on lines +940 to +952

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Two send paths escape the new session-recovery fence and end up POSTing session-less, which the SDK's own stateful server answers with a 400 that can never be marked sessionTerminated (both marking branches require sentSessionId !== null), so recovery can never arm afterwards: (1) a notification debounced via debouncedNotificationMethods (e.g. notifications/roots/list_changed, the option's own JSDoc example) is sent in an un-awaited microtask whose error goes only to _onerror (protocol.ts:1637) — this override's catch never sees the terminated-session 404, the notification is silently lost, and the client is permanently wedged; (2) new requests/notifications initiated during the recovery window (after the transport's 404-clear, before the recovery initialize restores _sessionId) go out session-less and fail with a hard 400 instead of joining the in-flight _sessionReinit. Route the debounced send's failure through the same recovery path, and fence new sends on the in-flight _sessionReinit (e.g. if (this._sessionReinit) await this._sessionReinit.catch(() => {}) at the top of both overrides).

Extended reasoning...

Path 1 — debounced notifications bypass the notification() override entirely (permanent wedge). The override recovers by catching the error from await super.notification(...), but Protocol. _notificationViaCodec has a second send path the catch cannot observe: when the client is constructed with debouncedNotificationMethods (ProtocolOptions, protocol.ts:89) and the notification is simple (no params, no relatedRequestId), the send is deferred to a microtask and fired un-awaited — this._transport?.send(jsonrpcNotification, options).catch(error => this._onerror(error)) (protocol.ts:1626-1638). super.notification() returns success immediately, so the terminated-session 404 goes only to onerror and _recoverSession never runs. sendRootsListChanged() (client.ts:2610-2612) sends exactly the eligible shape, and the option's own JSDoc example is a list_changed method — the trigger is the documented usage.\n\nStep-by-step proof (path 1).\n1. Client created with debouncedNotificationMethods: ['notifications/roots/list_changed'] on a legacy-era connection to a stateful server; session S1 established.\n2. The server expires S1. The host calls client.sendRootsListChanged() as the first observer of the dead session.\n3. The debounced microtask POSTs with mcp-session-id: S1 → 404. The transport's new 404 branch fires (streamableHttp.ts, the sentSessionId === this._sessionId clear): _sessionId and _protocolVersion wiped, _terminatedSessionId = S1, error marked sessionTerminated — but the error dies in _onerror. No _recoverSession, and the notification is silently lost.\n4. Every subsequent request goes out session-less via _commonHeaders(). The SDK's own stateful server rejects a session-less non-initialize POST with 400 Mcp-Session-Id header is required (server streamableHttp.ts:940-941) — never a 404, and never with sentSessionId !== null, so neither transport marking branch can ever fire again.\n5. _isSessionTerminated requires status 404 and sessionTerminated === true, so the automatic recovery is permanently unreachable; the migration guide's 404-keyed host recovery also never matches (all failures are now 400), and a second connect() on the same transport throws already started. The client is wedged until the host builds a brand-new transport.\n\nPath 2 — traffic during the recovery window falls into an unhandled third category (transient). The 404-clear wipes _sessionId/_protocolVersion synchronously in the failing request's continuation, and the new id only returns with the recovery initialize's 2xx capture — so there is a ≥1-RTT window with no session id. The request()/notification() overrides have no fence: a call issued during the window goes straight to super.transport.send_commonHeaders(), emitting a POST with no mcp-session-id and no mcp-protocol-version header, which the stateful server 400s. That 400 is recognized by nothing — it is not deduped into _sessionReinit (the dedup keys on the terminated-session 404 via _terminatedSessionId, which only marks requests that went out with the dead id), _isSessionTerminated is false so the override rethrows raw, and the docs-prescribed 404-keyed host recovery does not match either. Concretely: request A 404s and starts recovery; a second tool call C issued while the handshake round-trips gets a hard, caller-visible SdkHttpError(400) — a spurious failure the machinery was actively (and successfully) absorbing for A. This half self-heals (the next attempt carries the fresh id), but it converts a previously host-recoverable 404 into a 400 nothing recovers, precisely inside the feature's own window.\n\nWhy existing code doesn't prevent either. Both failures land in the same unrecoverable state through the same mechanism: once the transport-side clear runs, any send that bypasses the recovery-aware catch goes out session-less, and a session-less request can never re-arm the sessionTerminated marking (both branches require sentSessionId !== null). The changeset's "deduped across concurrent failures" claim covers only raced siblings that went out with the dead id. Path 1 is the repo's recurring partial-migration pattern: commit 2602078 added recovery to the awaited notification path after the earlier review comment, but the debounced sibling path at protocol.ts:1637 was left with the identical gap; no test covers debounced + terminated-session. Path 2 is a timing hole in the dedup's own target scenario.\n\nNew-in-PR surface. Pre-PR, a debounced notification's 404 was merely a lost notification with session state intact — every later request still carried the id, still 404d, and the documented 404-keyed host recovery kept matching. The PR's unconditional transport-side clear converts that into a silent, permanently session-less wedge, while the changeset promises notification recovery. Likewise for path 2, the same concurrent request pre-PR failed with the documented 404 rather than an unrecognizable 400.\n\nHow to fix. (1) Route the debounced send's failure through the recovery path — e.g. have the deferred send's rejection call a hook the Client can override instead of going straight to _onerror, or trigger a lazy reinit on the next request() when the transport reports _terminatedSessionId set with _sessionId === undefined (or simply skip debouncing in Client.notification on legacy sessions). (2) Fence new work on the in-flight recovery: at the top of both overrides, before the first super. attempt, if (this._sessionReinit) { await this._sessionReinit.catch(() => {}); } — new calls then wait the handshake out and go out carrying the fresh id. Severity reflects path 1: it is gated on the opt-in debouncedNotificationMethods option, but when triggered the client silently and permanently wedges — the same failure class as the cursor/notification wedge already fixed in this PR, and it should be closed alongside it since the PR introduces the clear that creates it.


Check failure on line 953 in packages/client/src/client/client.ts

View check run for this annotation

Claude / Claude Code Review

notifications/cancelled bypasses the session-recovery override, permanently wedging the client

This `notification()` override never sees the `notifications/cancelled` POST that `Protocol.request`'s cancel closure sends directly on the transport (`protocol.ts:1453-1465`, taken on every legacy-era abort or request timeout, since `streamCloseCancels` requires the modern era) — so when that POST is the first observer of a dead session, the transport's 404-clear wipes `_sessionId`/`_protocolVersion` but the `sessionTerminated` error dies in `_onerror` and `_recoverSession` never runs. Every su
Comment on lines +941 to +953

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 This notification() override never sees the notifications/cancelled POST that Protocol.request's cancel closure sends directly on the transport (protocol.ts:1453-1465, taken on every legacy-era abort or request timeout, since streamCloseCancels requires the modern era) — so when that POST is the first observer of a dead session, the transport's 404-clear wipes _sessionId/_protocolVersion but the sessionTerminated error dies in _onerror and _recoverSession never runs. Every subsequent request then goes out session-less and gets a hard 400 (never a sessionTerminated 404), permanently wedging the client with no opt-in required. This is a third sibling of the two escape paths already flagged (debounced notifications, recovery-window sends); a lazy reinit on the next request when the transport holds _terminatedSessionId with _sessionId === undefined would close all three.

Extended reasoning...

What the bug is. The new Client.notification() override (client.ts:941-953) adds session recovery for notifications, but Protocol's request-cancellation path never goes through it. In Protocol.request, the cancel closure (packages/core-internal/src/shared/protocol.ts:1446-1473) checks requestAbort === undefined — which is always true on a legacy-era connection, because streamCloseCancels requires codec.era === MODERN_WIRE_REVISION (protocol.ts:1416) — and then calls this._transport?.send({ method: 'notifications/cancelled', ... }).catch(error => this._onerror(...)) directly on the transport (protocol.ts:1453-1465). The send bypasses Client.notification() entirely, so its rejection can only ever reach _onerror; _recoverSession never runs.

The trigger is routine — no opt-in needed. cancel() fires from two everyday sources: the caller's options.signal abort (protocol.ts:1549-1550) and every request timeout (protocol.ts:1552-1555, default 60s). A realistic ordering: a long-running tools/call is in flight when the server restarts and drops the session; the request's SSE stream dies with it, the timeout fires, and the resulting notifications/cancelled POST — carrying the dead session id — becomes the first observer of the terminated session. Unlike the already-flagged debouncedNotificationMethods path, nothing has to be configured for this to happen.

Step-by-step proof.

  1. Legacy-era connection to a stateful server; session S1 established. Server expires S1 (TTL, restart, eviction).
  2. The host aborts an in-flight tools/call (or its timeout fires). cancel() runs; since requestAbort is undefined on legacy era, the closure POSTs notifications/cancelled directly via this._transport.send(...) with mcp-session-id: S1.
  3. The server answers 404. The transport's new clear branch fires (streamableHttp.ts, the sentSessionId === this._sessionId arm): _sessionId and _protocolVersion are wiped, _terminatedSessionId = S1, the error is marked sessionTerminated: true — but the rejection lands in the closure's .catch_onerror. No recovery hook observes it. (The aborted request itself can't rescue anything either: cancel() already rejected its promise with SdkError(RequestTimeout), so its own late rejection is a no-op on a settled promise.)
  4. The next client.callTool() goes out with no mcp-session-id and no mcp-protocol-version. The SDK's own stateful server rejects session-less non-initialize POSTs with 400 "Bad Request: Mcp-Session-Id header is required" (packages/server/src/server/streamableHttp.ts:938-941) — never a 404.
  5. _isSessionTerminated requires a 404 with sessionTerminated === true, and both transport marking branches require sentSessionId !== null — which session-less requests can never satisfy. So recovery can never re-arm, the docs-prescribed 404-keyed host recovery stops matching (all failures are now 400s), and a same-instance connect() retry throws already started. The client is permanently wedged until the host constructs a brand-new transport.

Why this is a regression from this PR. Pre-PR, a 404 to the cancel POST was harmless: session state stayed intact, every later request kept carrying S1 and kept 404ing, so host-side 404-keyed recovery worked from any observer. The PR's unconditional transport-side clear plus the recovery hooks living only in the request()/notification() overrides is what converts this into a silent, permanent wedge.

Not a duplicate. The existing comment on this override flags two escape paths — the debouncedNotificationMethods microtask (protocol.ts:1637) and new sends during the recovery window. This is a third, distinct send site: the direct transport.send inside Protocol.request's cancel closure. Fixing exactly the two flagged paths (e.g. routing the debounced send's failure through recovery, fencing new sends on _sessionReinit) would leave this one broken — and it's the broadest of the three, requiring no options at all.

How to fix. Either route the cancel-notification send's failure through the same recovery/marking-aware path (a Protocol-level hook the Client can override, mirroring how the awaited notification path was fixed in 2602078), or — the option that closes all three sibling paths at once — lazily re-initialize on the next request()/notification() when the transport reports _terminatedSessionId set with _sessionId === undefined. A test pinning legacy-era cancel/timeout racing session expiry would prevent this partial-migration pattern from recurring.

private async _recoverSession(cause: unknown): Promise<void> {
this._sessionReinit ??= this._reinitializeSession().finally(() => {
this._sessionReinit = undefined;
});
try {
await this._sessionReinit;
} catch (reinitError) {
// Re-establishment failed (the client is closed at this
// point): surface the request's own 404; the handshake
// failure goes to onerror.
this.onerror?.(reinitError instanceof Error ? reinitError : new Error(String(reinitError)));
throw cause;
}
}

Check warning on line 968 in packages/client/src/client/client.ts

View check run for this annotation

Claude / Claude Code Review

Failed recovery handshake is reported to onerror once per deduped waiter, not once

When N concurrent requests/notifications are deduped into one shared recovery handshake via `_sessionReinit ??=` and that single `initialize` fails, each waiter's own `catch` in `_recoverSession` calls `this.onerror?.(reinitError)` — so the host's `onerror` receives the same handshake error N times for one failure (the client was closed exactly once). Report the reinit error once where `_sessionReinit` is created (or guard with a reported flag) and have each waiter's catch just `throw cause`.
Comment on lines +955 to +968

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 When N concurrent requests/notifications are deduped into one shared recovery handshake via _sessionReinit ??= and that single initialize fails, each waiter's own catch in _recoverSession calls this.onerror?.(reinitError) — so the host's onerror receives the same handshake error N times for one failure (the client was closed exactly once). Report the reinit error once where _sessionReinit is created (or guard with a reported flag) and have each waiter's catch just throw cause.

Extended reasoning...

What the bug is. _recoverSession (packages/client/src/client/client.ts:954-967) deliberately deduplicates concurrent terminated-session recoveries into one shared handshake: this._sessionReinit ??= this._reinitializeSession().finally(...). That dedup is correct and pinned by this PR's own deduplicates concurrent recoveries into one handshake test. But the failure reporting is per-waiter: every caller that awaits the shared promise has its own catch (reinitError) block running this.onerror?.(reinitError instanceof Error ? reinitError : new Error(String(reinitError))). When the one recovery initialize fails, that rejected promise is observed by every deduped waiter, and each one independently reports the same error object to onerror.

The code path that triggers it. This is exactly the scenario the dedup exists for: (1) session S1 expires server-side; (2) N in-flight requests/notifications each get a terminated-session 404; (3) all N enter _recoverSession, the first creates _sessionReinit, the rest join it; (4) the recovery handshake fails (e.g. server returns 500 to the fresh initialize); (5) _legacyHandshake's catch (client.ts:1121-1125) runs void this.close() — once — and rethrows with no onerror of its own; (6) all N waiters' catches fire, each calling this.onerror?.(reinitError). One handshake, one close, N identical onerror invocations.

Step-by-step proof. Take the PR's own dedup test (sessionReinitialize.test.ts, two concurrent callTools failing on the same dead session) and flip on failReinit: true while collecting client.onerror calls in an array: both tool-call promises reject with their own 404 (correct), but the reported array contains the status-500 handshake error twice — same Error instance, two callbacks. The existing failure-surface test only exercises a single failing waiter and asserts with reported.some(...), so the duplication is invisible to the suite.

Why nothing prevents it. There is no reported flag or once-guard anywhere on this path; the per-waiter catch is the sole reporting site (_legacyHandshake closes and rethrows without touching onerror). The code comment's own intent is singular — "the handshake failure goes to onerror" — and the close happens exactly once, so N reports of one event is an accuracy defect in the diagnostics, not intentional fan-out. Note per-request failure already surfaces to each caller via the rethrown cause (the original 404), so the duplicated onerror calls are strictly redundant.

Impact — why this is a nit, not blocking. Nothing functionally breaks: the client closes once, every waiter still rejects with its own 404, and onerror in this SDK is an out-of-band diagnostic channel with no exactly-once contract (reconnect attempts and transport failures already report per occurrence). The cost is noise: a host that counts, alerts, logs, or triggers reconnect logic from onerror sees N duplicate reports of a single failure event.

How to fix. Report once at the source instead of per-waiter — e.g. when creating _sessionReinit, attach the report in the shared chain:

this._sessionReinit ??= this._reinitializeSession()
    .catch(e => {
        this.onerror?.(e instanceof Error ? e : new Error(String(e)));
        throw e;
    })
    .finally(() => { this._sessionReinit = undefined; });

and reduce each waiter's catch to just throw cause. (A boolean reported flag guarding the existing call works equally well.) Extending the dedup test with failReinit: true and expect(reported).toHaveLength(1) would pin the once-only behavior.

private _isSessionTerminated(error: unknown): boolean {
return (
error instanceof SdkHttpError &&
error.data.status === 404 &&
error.data['sessionTerminated'] === true &&
// Legacy era only: the 2026-07-28 handshake is discover-based.
this._negotiatedProtocolVersion !== undefined &&
legacyProtocolVersions([this._negotiatedProtocolVersion]).length === 1
);
Comment on lines +969 to +977

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The new session recovery never arms on session-resume reconnects: _isSessionTerminated requires this._negotiatedProtocolVersion !== undefined, but both resume branches (the plain legacy connect at client.ts:1034-1043 and _connectNegotiated's resume at :1141-1148) skip the handshake and never set it — so on the documented { sessionId, protocolVersion } reconnect pattern the gate always returns false. When the resumed session later expires, the transport's 404-clear still wipes _sessionId/_protocolVersion, every subsequent request goes out session-less and gets a 400 (never a sessionTerminated 404) from the SDK's stateful server, permanently wedging the client with both automatic and documented 404-keyed host recovery unreachable. Fix: seed _negotiatedProtocolVersion from transport.protocolVersion in the resume branches, or let the gate fall back to the transport's protocol version.

Extended reasoning...

What the bug is. The recovery gate _isSessionTerminated (packages/client/src/client/client.ts:969-977) requires this._negotiatedProtocolVersion !== undefined && legacyProtocolVersions([...]).length === 1. But on the documented session-resume reconnect pattern — construct a new StreamableHTTPClientTransport with { sessionId, protocolVersion } (per the protocolVersion option JSDoc) and connect a fresh Client_negotiatedProtocolVersion is never populated. Both resume branches only read it and return early: the plain legacy connect resume branch (client.ts:1034-1043) and _connectNegotiated's resume branch (client.ts:1141-1148). The only writers are _legacyHandshake completion (:1116, skipped by design on resume), the modern negotiated path (:1185), and _connectFromPrior (:1305) — none of which run on a resume connect — and _resetConnectionState (:533) nulls it. So on a resumed connection the field stays undefined for the connection's lifetime, and this is exactly the path the typescript:client-transport:http:session-id-option e2e test exercises (fresh Client + transport constructed with {sessionId}).\n\nThe failure sequence. When the resumed session later expires server-side:\n\n1. Host reconnects: new StreamableHTTPClientTransport(url, { sessionId: 'S1', protocolVersion: '2025-03-26' }), fresh Client, connect() takes the resume branch — no handshake, _negotiatedProtocolVersion stays undefined. Requests work fine (the transport sends the headers from its constructor options).\n2. The server expires S1 (TTL, restart, eviction — the persist-and-reconnect deployment is precisely where this is most likely).\n3. The next request POSTs with mcp-session-id: S1 → 404. The transport's 404-clear does run (streamableHttp.tssentSessionId === this._sessionId, both from the constructor option): it wipes _sessionId and _protocolVersion and marks the error sessionTerminated: true.\n4. Client.request's catch calls _isSessionTerminated, which returns false purely because _negotiatedProtocolVersion is undefined — no recovery handshake is attempted, and the raw 404 surfaces once.\n5. Every subsequent request goes out with neither mcp-session-id nor mcp-protocol-version. The SDK's own stateful server rejects a session-less non-initialize POST with 400 "Bad Request: Mcp-Session-Id header is required" (packages/server/src/server/streamableHttp.ts:938-941) — never a 404, and never sessionTerminated (both transport marking branches require sentSessionId !== null). So recovery can never arm later either; a same-instance connect() retry throws "already started". The client is permanently wedged.\n\nWhy this is a regression, not just a missing feature. Pre-PR, every request against the dead session kept 404ing, so the docs-prescribed host recovery ("key off the HTTP 404 status", docs/migration/upgrade-to-v2.md) matched from any observer. Post-PR, the first 404 flips the failure mode to 400s — 404-keyed host recovery silently stops matching after the first failure. The unconditional transport-side clear and the era-gated client-side recovery, both added by this PR, disagree about when recovery will run, and the gap between them is the wedge.\n\nWhy it contradicts the PR's own docs. The changeset and docs/migration/upgrade-to-v2.md (session 404 bullet) promise: "on a pre-2026 connection the client re-initializes automatically and retries the failed request once." A resumed connection is definitionally pre-2026 — only the legacy era has session ids — and the gate's own comment ("Legacy era only: the 2026-07-28 handshake is discover-based") shows the intent is era-scoping, not resume-exclusion. This is an oversight, not a design choice: _reinitializeSession_legacyHandshake(transport) does not depend on _negotiatedProtocolVersion at all, so recovery would work fine if the gate let it through.\n\nWhy the tests miss it. sessionReinitialize.test.ts always runs a real initialize first (so _negotiatedProtocolVersion is set), and the session-id-option e2e test never expires the resumed session — no test covers resume-connect + expiry.\n\nHow to fix. Small change, two equivalent options: (a) seed _negotiatedProtocolVersion from transport.protocolVersion in the two resume branches (the getter exists on StreamableHTTPClientTransport), or (b) make _isSessionTerminated fall back to the transport's protocol version when the negotiated one is undefined (an undefined negotiated version on a session-carrying transport is by construction a legacy connection — the era gate is effectively redundant with the sessionTerminated flag, which can only arise from legacy Mcp-Session-Id mechanics). Either way, a test covering resume-connect followed by session expiry would pin the behavior.

}

private async _reinitializeSession(): Promise<void> {
const transport = this.transport;
if (transport === undefined) {
throw new SdkError(SdkErrorCode.ConnectionClosed, 'Cannot reinitialize: not connected');
}
// No caller options: the handshake is shared connection state, so no
// single request's signal/timeout/onprogress may govern it.
await this._legacyHandshake(transport);
}

/**
* Connects to a server via the given transport and performs the MCP initialization handshake.
*
Expand Down
58 changes: 48 additions & 10 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
createFetchWithInit,
encodeMcpParamValue,
isInitializedNotification,
isInitializeRequest,
isJSONRPCErrorResponse,
isJSONRPCRequest,
isJSONRPCResultResponse,
Expand Down Expand Up @@ -315,13 +316,15 @@ export class StreamableHTTPClientTransport implements Transport {
private _fetch?: FetchLike;
private _fetchWithInit: FetchLike;
private _sessionId?: string;
private _terminatedSessionId?: string;
private _reconnectionOptions: StreamableHTTPReconnectionOptions;
private _protocolVersion?: string;
private _onInsufficientScope: 'reauthorize' | 'throw';
private _maxStepUpRetries: number;
private _serverRetryMs?: number; // Server-provided retry delay from SSE retry field
private readonly _reconnectionScheduler?: ReconnectionScheduler;
private _cancelReconnection?: () => void;
private _pendingReconnectOptions?: StartSSEOptions;

onclose?: () => void;
onerror?: (error: Error) => void;
Expand Down Expand Up @@ -652,6 +655,7 @@ export class StreamableHTTPClientTransport implements Transport {

const reconnect = (): void => {
this._cancelReconnection = undefined;
this._pendingReconnectOptions = 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.
Expand All @@ -674,6 +678,7 @@ export class StreamableHTTPClientTransport implements Transport {
const handle = setTimeout(reconnect, delay);
this._cancelReconnection = () => clearTimeout(handle);
}
this._pendingReconnectOptions = options;
}

private _handleSseStream(stream: ReadableStream<Uint8Array> | null, options: StartSSEOptions, isReconnectable: boolean): void {
Expand Down Expand Up @@ -971,15 +976,46 @@ export class StreamableHTTPClientTransport implements Transport {
signal
};

// 404s are judged against the id this request carried — late
// replies from an older session race `_sessionId`.
const sentSessionId = headers.get('mcp-session-id');

const response = await (this._fetch ?? fetch)(this._url, init);

// Handle session ID received during initialization
const sessionId = response.headers.get('mcp-session-id');
if (sessionId) {
this._sessionId = sessionId;
// Spec: the session id is assigned "at initialization time ... on the HTTP
// response containing the InitializeResult" — non-handshake echoes are ignored.
if (response.ok && (Array.isArray(message) ? message.some(m => isInitializeRequest(m)) : isInitializeRequest(message))) {
const sessionId = response.headers.get('mcp-session-id');
if (sessionId) {
this._sessionId = sessionId;
this._terminatedSessionId = undefined;
}
}

if (!response.ok) {
// 404 to the current session id: session is gone (spec) —
// drop it. POSTs only; SSE reconnect GETs 404 transiently.
// `_terminatedSessionId` marks raced in-flight requests of the
// same dead session until a new one is assigned; the stale
// version header is dropped so the recovery initialize goes
// out like a fresh connect.
let sessionTerminated = false;
if (response.status === 404 && sentSessionId !== null && sentSessionId === this._sessionId) {
this._terminatedSessionId = sentSessionId;
this._sessionId = undefined;
this._protocolVersion = undefined;
// A reconnect pending for the dead session's SSE stream
// would fire into the new session with a stale
// last-event-id — drop it, and settle any per-request
// caller waiting on that stream (it is definitively gone).
this._cancelReconnection?.();
this._cancelReconnection = undefined;
this._pendingReconnectOptions?.onRequestStreamEnd?.();
Comment on lines +1005 to +1013

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The stale-reconnect teardown added on the 404 session-clear is single-slot while multiple SSE reconnects can be pending at once: _cancelReconnection/_pendingReconnectOptions are overwritten by every _scheduleReconnection call, so when a server restart drops the standalone GET stream and a primed POST SSE stream together, only the last-scheduled reconnect is cancelled — the orphaned timer (or a reconnect already in flight, whose catch re-arms itself) later fires into the recovered session with the dead session's last-event-id under the new session id, exactly the cross-session leak the new comment says this block prevents. Consider tracking pending reconnects in a Set of {cancel, options} entries, or adding a session-generation check inside the reconnect closure so reconnects armed under a since-terminated session refuse to fire.

Extended reasoning...

What the bug is. The 404 teardown in _send (packages/client/src/client/streamableHttp.ts:1005-1013) cancels "the" pending SSE reconnection via this._cancelReconnection?.() and settles "the" waiting per-request caller via this._pendingReconnectOptions?.onRequestStreamEnd?.(). But both are single instance fields that every _scheduleReconnection call overwrites (lines 675-680, in both the scheduler branch and the setTimeout branch), while multiple reconnections can genuinely be pending simultaneously: this transport is hasPerRequestStream = true, so _handleSseStream schedules a reconnect per dropped stream — the standalone GET stream (isReconnectable = true) and every primed per-request POST SSE stream (hasPrimingEvent). A server restart that kills the session — the exact trigger this teardown was written for — typically drops all of them at once.

The escape paths. Two orderings escape the teardown:

  1. Overwritten slot. GET stream drops → _scheduleReconnection arms timer A and stores its cancel handle; a primed POST stream drops → timer B overwrites the slot, making A's cancel handle unreachable. When a pending POST then 404s with the current session id, the new block cancels only B and fires only B's onRequestStreamEnd. Timer A stays armed.
  2. In-flight window. reconnect() clears both slots at its start (lines 656-657) before awaiting _startOrAuthSse. A 404 arriving while that reconnect GET is in flight finds nothing to cancel; _startOrAuthSse has no 404 session-clear of its own (the new comment explicitly scopes the clear to "POSTs only"), so the GET fails generically and its .catch (lines 662-670) calls _scheduleReconnection(options, attempt + 1) — re-arming the stale-token reconnect after the teardown already ran.

Why nothing else stops the escaped reconnect. The reconnect closure gates only on the transport-wide abort and the per-request requestSignal — neither fires during session recovery (unlike close(), where the same single-slot limitation is harmless because _abortController.abort() makes every closure bail). _startOrAuthSse rebuilds headers via _commonHeaders() at attempt time, so the escaped GET goes out with the new session id plus the dead session's last-event-id — precisely the cross-session resumption-token leak the comment at lines 1006-1009 claims this block prevents.

Step-by-step proof. (1) Legacy-era connect under session S1; standalone GET stream A open; a long-running tools/call holds primed POST stream B. (2) Server restarts, killing S1: A and B both drop; A's reconnect is scheduled (slot ← A), then B's (slot ← B; A's cancel handle orphaned). (3) A pending POST 404s with S1 → the teardown cancels B only, fires B's onRequestStreamEnd only. (4) The Client-level recovery re-initializes → _sessionId = S2. (5) A's timer fires (~1s backoff, comfortably straddling the fast handshake); both abort checks pass; _startOrAuthSse sends GET with mcp-session-id: S2 + last-event-id: <S1 token>. Against the SDK's own server this gets a 400 (unknown event id) or races the recovery's own standalone GET for the one-stream slot (409 to whichever loses); against a permissive non-conforming server it can produce a second live standalone stream and duplicate notification delivery.

One correction to the raw finding, and why this is a nit. The overwritten per-request caller is not left waiting forever against a conforming server: the orphaned timer still fires, its GETs fail, retries exhaust at maxRetries (default 2), and _scheduleReconnection (lines 645-649) then fires options.onRequestStreamEnd?.() — so settlement is late and noisy (spurious onerror, wasted GETs), not lost; a true hang needs a non-conforming server that accepts the stale token as a live stream. That bounded, self-limiting impact — plus the narrow trigger (multi-stream simultaneous drop racing a terminated-session 404 within the ~1s backoff window) — is why this is a nit rather than blocking. The single-slot pattern itself predates this PR (close() shares it, harmlessly), but this PR is what makes same-transport session replacement a supported flow and adds the correctness claim that depends on the teardown being complete, so the residual gap belongs here rather than as pre-existing.

How to fix. Track pending reconnects in a Set of { cancel, options } entries instead of one slot (the 404 teardown drains the whole set; close() can share it), and/or add a session-generation counter captured in the reconnect closure and re-checked before firing — the same deferred-callback-must-recheck-state shape the surrounding code already applies to the capture/clear guards. Either closes both the overwritten-slot and the in-flight re-arm paths.

this._pendingReconnectOptions = undefined;
sessionTerminated = true;
Comment thread
claude[bot] marked this conversation as resolved.
} else if (response.status === 404 && sentSessionId !== null && sentSessionId === this._terminatedSessionId) {
sessionTerminated = true;
}
if (response.status === 401 && this._authProvider) {
// Store WWW-Authenticate params for interactive finishAuth() path
if (response.headers.has('www-authenticate')) {
Expand Down Expand Up @@ -1060,7 +1096,8 @@ export class StreamableHTTPClientTransport implements Transport {
throw new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${text}`, {
status: response.status,
statusText: response.statusText,
text
text,
...(sessionTerminated ? { sessionTerminated: true } : {})
});
}

Expand Down Expand Up @@ -1145,8 +1182,9 @@ export class StreamableHTTPClientTransport implements Transport {
* HTTP `DELETE` to the MCP endpoint with the `Mcp-Session-Id` header to explicitly
* terminate the session.
*
* The server MAY respond with HTTP `405 Method Not Allowed`, indicating that
* the server does not allow clients to terminate sessions.
* A `405 Method Not Allowed` (server does not allow client termination)
* or `404 Not Found` (session already expired) response also resolves;
* the stored session id is cleared in every resolving case.
*/
async terminateSession(): Promise<void> {
if (!this._sessionId) {
Expand All @@ -1166,9 +1204,9 @@ export class StreamableHTTPClientTransport implements Transport {
const response = await (this._fetch ?? fetch)(this._url, init);
await response.text?.().catch(() => {});

// We specifically handle 405 as a valid response according to the spec,
// meaning the server does not support explicit session termination
if (!response.ok && response.status !== 405) {
// 405: server does not support explicit termination (spec).
// 404: session already gone — the goal state; still drop the id.
if (!response.ok && response.status !== 405 && response.status !== 404) {
throw new SdkHttpError(
SdkErrorCode.ClientHttpFailedToTerminateSession,
`Failed to terminate session: ${response.statusText}`,
Expand Down
Loading
Loading