From c403c96a0c212c6a01ea529477cefc05ae8bf4db Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 9 Jul 2026 09:39:41 +0000 Subject: [PATCH 1/6] fix(client): only capture the session id header from successful responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StreamableHTTPClientTransport stored the mcp-session-id response header before checking response.ok, so a session id on an error reply became the connection's session state. With version negotiation in auto mode this became reachable on every connect: a legacy server whose 404 to the probe carried a session id made the fallback initialize present a session id the server never issued — spec-conforming stateful servers reject that, failing a connect that plain legacy mode completes. The stale id also made a retry connect() on the same transport take the session-resuming branch and skip the handshake entirely. Capture now happens only on successful responses, matching the spec's assignment point (the InitializeResult response). Additionally, a 404 to a POST that carried the session id clears the stored id — per spec the session is gone and the next attempt must start a fresh handshake. POST only: SSE reconnect GETs can 404 for transient infra reasons while the session is still live. Regression tests pin the error-path non-capture, the session-less follow-up request, the 404 clear, and that successful capture (including 202 rotation) is unchanged. --- .changeset/shttp-session-capture-on-ok.md | 5 + packages/client/src/client/streamableHttp.ts | 19 +++- .../client/test/client/streamableHttp.test.ts | 94 +++++++++++++++++++ 3 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 .changeset/shttp-session-capture-on-ok.md diff --git a/.changeset/shttp-session-capture-on-ok.md b/.changeset/shttp-session-capture-on-ok.md new file mode 100644 index 0000000000..2a1bde150d --- /dev/null +++ b/.changeset/shttp-session-capture-on-ok.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +`StreamableHTTPClientTransport` no longer captures the `Mcp-Session-Id` header from error responses. Previously the header was stored from any response before the status check, so a session id on an error reply became the connection's session state — most visibly in `versionNegotiation: { mode: 'auto' }`, where a legacy server answering the probe with a 404-plus-session-id made the fallback `initialize` present a session id the server never issued; spec-conforming stateful servers reject that, failing a connect that would otherwise succeed. Session ids are now only captured from successful responses, matching the spec's assignment point; error responses contribute nothing to session state. Additionally, a 404 to a POST that carried the session id now clears the stored id (per spec, the session is gone and the client must start a new one) — previously the dead id was re-presented forever, and a retry `connect()` on the same transport took the session-resuming branch and skipped the handshake entirely. diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 0d8eff917b..527d084d29 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -973,13 +973,24 @@ export class StreamableHTTPClientTransport implements Transport { 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; + // Capture the session id only from successful responses — the + // spec assigns it on the InitializeResult response. An error + // reply (e.g. a 404 to a probe) contributes nothing to session state. + if (response.ok) { + const sessionId = response.headers.get('mcp-session-id'); + if (sessionId) { + this._sessionId = sessionId; + } } if (!response.ok) { + // Spec: a 404 to a POST that carried the session id means the + // session is gone — drop it so the next attempt can start a + // fresh handshake. POST only: SSE reconnect GETs can 404 for + // transient infra reasons while the session is still live. + if (response.status === 404 && headers.get('mcp-session-id') !== null) { + this._sessionId = undefined; + } if (response.status === 401 && this._authProvider) { // Store WWW-Authenticate params for interactive finishAuth() path if (response.headers.has('www-authenticate')) { diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index a20fb92252..0dc2471907 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -122,6 +122,100 @@ describe('StreamableHTTPClientTransport', () => { expect(lastCall[1].headers.get('mcp-session-id')).toBe('test-session-id'); }); + it('should NOT store a session ID from an error response', async () => { + // Regression: a session header on a 404 probe reply used to carry + // over into the legacy fallback initialize. + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Headers({ 'mcp-session-id': 'from-error-reply-123' }), + text: () => Promise.resolve('not found') + }); + + await expect(transport.send({ jsonrpc: '2.0', method: 'server/discover', id: 'probe-id' } as JSONRPCMessage)).rejects.toThrow(); + + expect(transport.sessionId).toBeUndefined(); + + // The next request must go out session-less. + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers() + }); + await transport.send({ jsonrpc: '2.0', method: 'test', params: {} } as JSONRPCMessage); + + const lastSend = (globalThis.fetch as Mock).mock.calls.at(-1)!; + expect(lastSend[1].headers.get('mcp-session-id')).toBeNull(); + }); + + it('should still store the session ID assigned by a successful response after an earlier error response', async () => { + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Headers({ 'mcp-session-id': 'from-error-reply-123' }), + text: () => Promise.resolve('not found') + }); + await expect(transport.send({ jsonrpc: '2.0', method: 'server/discover', id: 'probe-id' } as JSONRPCMessage)).rejects.toThrow(); + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'real-session' }) + }); + await transport.send({ + jsonrpc: '2.0', + method: 'initialize', + params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26' }, + id: 'init-id' + } as JSONRPCMessage); + + expect(transport.sessionId).toBe('real-session'); + + // Any successful status captures — 202 (accepted notification) + // included. Not notifications/initialized: that one fire-and-forgets + // the standalone GET, which would fall through the fetch mock. + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers({ 'mcp-session-id': 'rotated-session' }) + }); + await transport.send({ jsonrpc: '2.0', method: 'notifications/roots/list_changed' } as JSONRPCMessage); + expect(transport.sessionId).toBe('rotated-session'); + }); + + it('should clear the session ID on a 404 to a request that carried it', async () => { + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'expired-later' }) + }); + await transport.send({ + jsonrpc: '2.0', + method: 'initialize', + params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26' }, + id: 'init-id' + } as JSONRPCMessage); + expect(transport.sessionId).toBe('expired-later'); + + // Server expired the session: 404 to a request that presented it. + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Headers(), + text: () => Promise.resolve('session not found') + }); + await expect(transport.send({ jsonrpc: '2.0', method: 'test', id: 'r1' } as JSONRPCMessage)).rejects.toThrow(); + expect(transport.sessionId).toBeUndefined(); + + // Next request goes out session-less (fresh handshake possible). + (globalThis.fetch as Mock).mockResolvedValueOnce({ ok: true, status: 202, headers: new Headers() }); + await transport.send({ jsonrpc: '2.0', method: 'notifications/roots/list_changed' } as JSONRPCMessage); + expect((globalThis.fetch as Mock).mock.calls.at(-1)![1].headers.get('mcp-session-id')).toBeNull(); + }); + it('should accept protocolVersion constructor option and include it in request headers', async () => { // When reconnecting with a preserved sessionId, users need to also preserve the // negotiated protocol version so the required mcp-protocol-version header is sent. From ec450a1e4d4484e5eec9fbaa7106c9b272d03b82 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 9 Jul 2026 09:41:29 +0000 Subject: [PATCH 2/6] fix(client): guard session-id capture and clear against late responses; treat DELETE 404 as terminated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups: interpret each response against the session id its own request carried (a late success from a previous session no longer installs its stale id over a rotated one, and a late 404 no longer wipes a newer session); terminateSession() now treats 404 as successful termination (the session is already gone — the goal state — and the stored id is dropped instead of left dead behind a throw); changeset motivation reworded to the reconnect pattern that actually reproduces the resume-branch hazard; e2e knownFailure note refreshed to describe the post-fix behavior. --- .changeset/shttp-session-capture-on-ok.md | 2 +- packages/client/src/client/streamableHttp.ts | 28 +++++- .../client/test/client/streamableHttp.test.ts | 99 +++++++++++++++++++ test/e2e/requirements.ts | 2 +- 4 files changed, 124 insertions(+), 7 deletions(-) diff --git a/.changeset/shttp-session-capture-on-ok.md b/.changeset/shttp-session-capture-on-ok.md index 2a1bde150d..216c16e69a 100644 --- a/.changeset/shttp-session-capture-on-ok.md +++ b/.changeset/shttp-session-capture-on-ok.md @@ -2,4 +2,4 @@ '@modelcontextprotocol/client': patch --- -`StreamableHTTPClientTransport` no longer captures the `Mcp-Session-Id` header from error responses. Previously the header was stored from any response before the status check, so a session id on an error reply became the connection's session state — most visibly in `versionNegotiation: { mode: 'auto' }`, where a legacy server answering the probe with a 404-plus-session-id made the fallback `initialize` present a session id the server never issued; spec-conforming stateful servers reject that, failing a connect that would otherwise succeed. Session ids are now only captured from successful responses, matching the spec's assignment point; error responses contribute nothing to session state. Additionally, a 404 to a POST that carried the session id now clears the stored id (per spec, the session is gone and the client must start a new one) — previously the dead id was re-presented forever, and a retry `connect()` on the same transport took the session-resuming branch and skipped the handshake entirely. +`StreamableHTTPClientTransport` no longer captures the `Mcp-Session-Id` header from error responses. Previously the header was stored from any response before the status check, so a session id on an error reply became the connection's session state — most visibly in `versionNegotiation: { mode: 'auto' }`, where a legacy server answering the probe with a 404-plus-session-id made the fallback `initialize` present a session id the server never issued; spec-conforming stateful servers reject that, failing a connect that would otherwise succeed. Session ids are now only captured from successful responses, matching the spec's assignment point; error responses contribute nothing to session state. Additionally, a 404 to a POST that carried the session id now clears the stored id (per spec, the session is gone and the client must start a new one) — previously the dead id was re-presented forever, and a reconnect that preserved the stale id (the documented pattern of constructing a new transport with `sessionId: oldTransport.sessionId`) took the session-resuming branch and skipped the handshake entirely. A 404 to `terminateSession()`'s DELETE now counts as successful termination (the session is already gone) and likewise drops the stored id. diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 527d084d29..e21c1278c3 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -971,14 +971,25 @@ export class StreamableHTTPClientTransport implements Transport { signal }; + // Snapshot of the id this request went out with — responses are + // interpreted against what THIS request presented, not whatever + // `_sessionId` holds by the time the response lands (a late + // response from a previous session must neither install its + // stale id nor wipe a newer one). + const sentSessionId = headers.get('mcp-session-id'); + const response = await (this._fetch ?? fetch)(this._url, init); // Capture the session id only from successful responses — the // spec assigns it on the InitializeResult response. An error - // reply (e.g. a 404 to a probe) contributes nothing to session state. + // reply (e.g. a 404 to a probe) contributes nothing to session + // state. Guard against late arrivals: a request that carried no + // id (the initialize) may always install the assignment; a + // request that carried an id may only refresh it while that id + // is still the current one. if (response.ok) { const sessionId = response.headers.get('mcp-session-id'); - if (sessionId) { + if (sessionId && (sentSessionId === null || sentSessionId === this._sessionId)) { this._sessionId = sessionId; } } @@ -988,7 +999,10 @@ export class StreamableHTTPClientTransport implements Transport { // session is gone — drop it so the next attempt can start a // fresh handshake. POST only: SSE reconnect GETs can 404 for // transient infra reasons while the session is still live. - if (response.status === 404 && headers.get('mcp-session-id') !== null) { + // Only when the id this request carried is still the current + // one: a late 404 from a previous session must not wipe a + // newer session's id. + if (response.status === 404 && sentSessionId !== null && sentSessionId === this._sessionId) { this._sessionId = undefined; } if (response.status === 401 && this._authProvider) { @@ -1178,8 +1192,12 @@ export class StreamableHTTPClientTransport implements Transport { 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) { + // meaning the server does not support explicit session termination. + // 404 likewise terminates locally: the session is already gone on + // the server (expired or evicted — the exact race termination is + // prone to), which is the outcome termination seeks; the stored id + // must still be dropped rather than left dead. + if (!response.ok && response.status !== 405 && response.status !== 404) { throw new SdkHttpError( SdkErrorCode.ClientHttpFailedToTerminateSession, `Failed to terminate session: ${response.statusText}`, diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 0dc2471907..8a1e43c414 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -216,6 +216,105 @@ describe('StreamableHTTPClientTransport', () => { expect((globalThis.fetch as Mock).mock.calls.at(-1)![1].headers.get('mcp-session-id')).toBeNull(); }); + it('does not let a late success from an old session overwrite a newer session id', async () => { + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'old-session' }) + }); + await transport.send({ + jsonrpc: '2.0', + method: 'initialize', + params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26' }, + id: 'init-id' + } as JSONRPCMessage); + expect(transport.sessionId).toBe('old-session'); + + // Request A goes out under old-session but its response is delayed. + let resolveLate!: (r: unknown) => void; + (globalThis.fetch as Mock).mockImplementationOnce(() => new Promise(r => (resolveLate = r))); + const late = transport.send({ jsonrpc: '2.0', method: 'test', id: 'late' } as JSONRPCMessage); + + // Meanwhile the server rotates the session on request B. + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers({ 'mcp-session-id': 'new-session' }) + }); + await transport.send({ jsonrpc: '2.0', method: 'notifications/roots/list_changed' } as JSONRPCMessage); + expect(transport.sessionId).toBe('new-session'); + + // A's late echo of the old id must not clobber the rotated id. + resolveLate({ ok: true, status: 202, headers: new Headers({ 'mcp-session-id': 'old-session' }) }); + await late; + expect(transport.sessionId).toBe('new-session'); + }); + + it('does not let a late 404 from an old session wipe a newer session id', async () => { + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'first-session' }) + }); + await transport.send({ + jsonrpc: '2.0', + method: 'initialize', + params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26' }, + id: 'init-id' + } as JSONRPCMessage); + expect(transport.sessionId).toBe('first-session'); + + let resolveLate!: (r: unknown) => void; + (globalThis.fetch as Mock).mockImplementationOnce(() => new Promise(r => (resolveLate = r))); + const late = transport.send({ jsonrpc: '2.0', method: 'test', id: 'late' } as JSONRPCMessage); + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers({ 'mcp-session-id': 'second-session' }) + }); + await transport.send({ jsonrpc: '2.0', method: 'notifications/roots/list_changed' } as JSONRPCMessage); + expect(transport.sessionId).toBe('second-session'); + + // The late 404 was for first-session; second-session is still live. + resolveLate({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Headers(), + text: () => Promise.resolve('session not found') + }); + await expect(late).rejects.toThrow(); + expect(transport.sessionId).toBe('second-session'); + }); + + it('treats a 404 to the termination DELETE as successful termination and drops the id', async () => { + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'gone-session' }) + }); + await transport.send({ + jsonrpc: '2.0', + method: 'initialize', + params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26' }, + id: 'init-id' + } as JSONRPCMessage); + expect(transport.sessionId).toBe('gone-session'); + + // Server already expired the session — exactly the race termination + // is prone to. The goal state (no server session) is achieved. + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Headers(), + text: () => Promise.resolve('session not found') + }); + await expect(transport.terminateSession()).resolves.toBeUndefined(); + expect(transport.sessionId).toBeUndefined(); + }); + it('should accept protocolVersion constructor option and include it in request headers', async () => { // When reconnecting with a preserved sessionId, users need to also preserve the // negotiated protocol version so the required mcp-protocol-version header is sent. diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 4fda9e661f..c7a901c2ed 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -1896,7 +1896,7 @@ export const REQUIREMENTS: Record = { note: 'This exercises the StreamableHTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.', knownFailures: [ { - note: 'On a 404 for an existing session the transport throws StreamableHTTPError (streamableHttp.ts:551) and never re-initializes — no session recovery is attempted.' + note: 'On a 404 for an existing session the transport clears the dead session id but never auto-re-initializes — recovery still requires a new host-driven connect (a fresh session-less InitializeRequest is not sent automatically).' } ] }, From ebe1bdea98a29b824a40e8f76e210d84c6045e1f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 9 Jul 2026 10:18:21 +0000 Subject: [PATCH 3/6] feat(client): start a new session automatically after a 404 to the current session Per the spec's session-management requirement, a 404 to a request that carried the session id means the session is gone and the client must start a new one with a fresh session-less InitializeRequest. The transport marks that condition on the thrown error; on a legacy-era connection the client then re-runs the initialize handshake (deduped across concurrent failures) and retries the failed request once. Server-side per-session state (e.g. resource subscriptions) does not carry over. The session-404-reinitialize e2e requirement now passes and its knownFailure entry is removed; 404-surfaces narrows to the unrecoverable case (re-establishment also fails). Comments and the changeset tightened throughout. --- .changeset/shttp-session-capture-on-ok.md | 2 +- packages/client/src/client/client.ts | 52 +++++++++++++++++++ packages/client/src/client/streamableHttp.ts | 38 +++++--------- .../client/test/client/streamableHttp.test.ts | 10 ++-- test/e2e/requirements.ts | 12 ++--- test/e2e/scenarios/transport-http.test.ts | 5 ++ 6 files changed, 77 insertions(+), 42 deletions(-) diff --git a/.changeset/shttp-session-capture-on-ok.md b/.changeset/shttp-session-capture-on-ok.md index 216c16e69a..28bd8d6975 100644 --- a/.changeset/shttp-session-capture-on-ok.md +++ b/.changeset/shttp-session-capture-on-ok.md @@ -2,4 +2,4 @@ '@modelcontextprotocol/client': patch --- -`StreamableHTTPClientTransport` no longer captures the `Mcp-Session-Id` header from error responses. Previously the header was stored from any response before the status check, so a session id on an error reply became the connection's session state — most visibly in `versionNegotiation: { mode: 'auto' }`, where a legacy server answering the probe with a 404-plus-session-id made the fallback `initialize` present a session id the server never issued; spec-conforming stateful servers reject that, failing a connect that would otherwise succeed. Session ids are now only captured from successful responses, matching the spec's assignment point; error responses contribute nothing to session state. Additionally, a 404 to a POST that carried the session id now clears the stored id (per spec, the session is gone and the client must start a new one) — previously the dead id was re-presented forever, and a reconnect that preserved the stale id (the documented pattern of constructing a new transport with `sessionId: oldTransport.sessionId`) took the session-resuming branch and skipped the handshake entirely. A 404 to `terminateSession()`'s DELETE now counts as successful termination (the session is already gone) and likewise drops the stored id. +`StreamableHTTPClientTransport` session-id handling now follows the spec's assignment model: the `Mcp-Session-Id` response header is captured only from successful responses (the spec assigns it on the `InitializeResult` response), and only when the request carried no id or the still-current one — so an error reply, or a late reply from an older session, 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 once; server-side per-session state (e.g. resource subscriptions) does not carry over. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 5028e937cd..b21f630927 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -40,6 +40,7 @@ import type { ReadResourceRequest, ReadResourceResult, RequestMethod, + ResultTypeMap, RequestOptions, ResolvedInputRequiredDriverConfig, Result, @@ -74,6 +75,7 @@ import { scanXMcpHeaderDeclarations, SdkError, SdkErrorCode, + SdkHttpError, SUBSCRIPTION_ID_META_KEY, SUPPORTED_MODERN_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core-internal'; @@ -926,6 +928,56 @@ export class Client extends Protocol { * } * ``` */ + private _sessionReinit?: Promise; + + override request( + request: { method: M; params?: Record }, + options?: RequestOptions + ): Promise; + override request( + request: { method: string; params?: Record }, + resultSchema: T, + options?: RequestOptions + ): Promise>; + override async request( + request: { method: string; params?: Record }, + schemaOrOptions?: StandardSchemaV1 | RequestOptions, + maybeOptions?: RequestOptions + ): Promise { + 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; + this._sessionReinit ??= this._reinitializeSession().finally(() => { + this._sessionReinit = undefined; + }); + await this._sessionReinit; + return await super.request(request as never, schemaOrOptions as never, maybeOptions); + } + } + + 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 + ); + } + + private async _reinitializeSession(): Promise { + const transport = this.transport; + if (transport === undefined) { + throw new SdkError(SdkErrorCode.ConnectionClosed, 'Cannot reinitialize: not connected'); + } + await this._legacyHandshake(transport); + } + override async connect(transport: Transport, options?: ConnectOptions): Promise { if (options?.prior !== undefined) { // Zero-round-trip reconnect from a previously-obtained diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index e21c1278c3..3e4dc9b152 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -971,22 +971,14 @@ export class StreamableHTTPClientTransport implements Transport { signal }; - // Snapshot of the id this request went out with — responses are - // interpreted against what THIS request presented, not whatever - // `_sessionId` holds by the time the response lands (a late - // response from a previous session must neither install its - // stale id nor wipe a newer one). + // Responses 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); - // Capture the session id only from successful responses — the - // spec assigns it on the InitializeResult response. An error - // reply (e.g. a 404 to a probe) contributes nothing to session - // state. Guard against late arrivals: a request that carried no - // id (the initialize) may always install the assignment; a - // request that carried an id may only refresh it while that id - // is still the current one. + // Session ids come only from successful replies whose request + // carried no id or the still-current one. if (response.ok) { const sessionId = response.headers.get('mcp-session-id'); if (sessionId && (sentSessionId === null || sentSessionId === this._sessionId)) { @@ -995,15 +987,12 @@ export class StreamableHTTPClientTransport implements Transport { } if (!response.ok) { - // Spec: a 404 to a POST that carried the session id means the - // session is gone — drop it so the next attempt can start a - // fresh handshake. POST only: SSE reconnect GETs can 404 for - // transient infra reasons while the session is still live. - // Only when the id this request carried is still the current - // one: a late 404 from a previous session must not wipe a - // newer session's id. + // 404 to the current session id: session is gone (spec) — + // drop it. POSTs only; SSE reconnect GETs 404 transiently. + let sessionTerminated = false; if (response.status === 404 && sentSessionId !== null && sentSessionId === this._sessionId) { this._sessionId = undefined; + sessionTerminated = true; } if (response.status === 401 && this._authProvider) { // Store WWW-Authenticate params for interactive finishAuth() path @@ -1085,7 +1074,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 } : {}) }); } @@ -1191,12 +1181,8 @@ 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. - // 404 likewise terminates locally: the session is already gone on - // the server (expired or evicted — the exact race termination is - // prone to), which is the outcome termination seeks; the stored id - // must still be dropped rather than left dead. + // 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, diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 8a1e43c414..1113836bf1 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -123,8 +123,6 @@ describe('StreamableHTTPClientTransport', () => { }); it('should NOT store a session ID from an error response', async () => { - // Regression: a session header on a 404 probe reply used to carry - // over into the legacy fallback initialize. (globalThis.fetch as Mock).mockResolvedValueOnce({ ok: false, status: 404, @@ -173,9 +171,8 @@ describe('StreamableHTTPClientTransport', () => { expect(transport.sessionId).toBe('real-session'); - // Any successful status captures — 202 (accepted notification) - // included. Not notifications/initialized: that one fire-and-forgets - // the standalone GET, which would fall through the fetch mock. + // 202 captures too; notifications/initialized would fire the + // standalone GET past the fetch mock. (globalThis.fetch as Mock).mockResolvedValueOnce({ ok: true, status: 202, @@ -302,8 +299,7 @@ describe('StreamableHTTPClientTransport', () => { } as JSONRPCMessage); expect(transport.sessionId).toBe('gone-session'); - // Server already expired the session — exactly the race termination - // is prone to. The goal state (no server session) is achieved. + // Session already gone server-side — termination goal state. (globalThis.fetch as Mock).mockResolvedValueOnce({ ok: false, status: 404, diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index c7a901c2ed..1b42402a6a 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -1884,21 +1884,17 @@ export const REQUIREMENTS: Record = { 'client-transport:http:404-surfaces': { source: 'sdk', - behavior: 'A 404 (session expired) on a request surfaces as an error to the caller.', + behavior: + 'When a 404 (session expired) is followed by a failed session re-establishment, the failure surfaces as an error to the caller.', transports: ['streamableHttp'], - note: 'Session-id continuity testing requires the per-session host (404 is session-not-found).' + note: 'Session-id continuity testing requires the per-session host (404 is session-not-found). The recovered path is client-transport:http:session-404-reinitialize.' }, 'client-transport:http:session-404-reinitialize': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management', behavior: 'A 404 in response to a request carrying a session ID makes the client start a new session with a fresh InitializeRequest and no session ID attached.', transports: ['streamableHttp'], - note: 'This exercises the StreamableHTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.', - knownFailures: [ - { - note: 'On a 404 for an existing session the transport clears the dead session id but never auto-re-initializes — recovery still requires a new host-driven connect (a fresh session-less InitializeRequest is not sent automatically).' - } - ] + note: 'This exercises the StreamableHTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, 'client-transport:http:accept-header-get': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server', diff --git a/test/e2e/scenarios/transport-http.test.ts b/test/e2e/scenarios/transport-http.test.ts index 7cda0f28aa..8e9381b18a 100644 --- a/test/e2e/scenarios/transport-http.test.ts +++ b/test/e2e/scenarios/transport-http.test.ts @@ -363,6 +363,11 @@ verifies('client-transport:http:404-surfaces', async (_args: TestArgs) => { if (sid && sid === sessionIdToBreak) { return Response.json({ error: 'Session not found' }, { status: 404 }); } + // Once the session is broken, refuse re-establishment too: + // the automatic session-less InitializeRequest gets a 500. + if (sessionIdToBreak !== undefined && !sid && req.method === 'POST') { + return new Response('unavailable', { status: 500 }); + } return handle.handleRequest(req); } }); From af924bbd8a622ed17a3bca4e219fe78d62699110 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 9 Jul 2026 10:28:51 +0000 Subject: [PATCH 4/6] fix(client): harden session recovery per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The terminated session id is remembered until a new one is assigned, so every in-flight request of the dead session recovers (previously only the first-processed 404 was marked and the dedup was unreachable). - The stale protocol-version header is dropped with the session, so the recovery initialize goes out like a fresh connect. - A failed re-establishment surfaces the request's own 404 (the handshake failure goes to onerror) instead of replacing it. - The recovery handshake runs under the failing request's options, so caller timeouts/signals bound it. - Pagination continuations are not retried — a dead session's cursor is meaningless to the new one. - connect()'s doc block reattached to connect(); unit tests for the marker, sibling marking, dedup, failure surface, and cursor guard. --- packages/client/src/client/client.ts | 86 +++++++----- packages/client/src/client/streamableHttp.ts | 10 ++ .../test/client/sessionReinitialize.test.ts | 127 ++++++++++++++++++ .../client/test/client/streamableHttp.test.ts | 66 +++++++++ 4 files changed, 255 insertions(+), 34 deletions(-) create mode 100644 packages/client/test/client/sessionReinitialize.test.ts diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index b21f630927..c5d33f041c 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -76,6 +76,7 @@ import { SdkError, SdkErrorCode, SdkHttpError, + isStandardSchema, SUBSCRIPTION_ID_META_KEY, SUPPORTED_MODERN_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core-internal'; @@ -899,35 +900,6 @@ export class Client extends Protocol { } } - /** - * Connects to a server via the given transport and performs the MCP initialization handshake. - * - * @example Basic usage (stdio) - * ```ts source="./client.examples.ts#Client_connect_stdio" - * const client = new Client({ name: 'my-client', version: '1.0.0' }); - * const transport = new StdioClientTransport({ command: 'my-mcp-server' }); - * await client.connect(transport); - * ``` - * - * @example Streamable HTTP with SSE fallback - * ```ts source="./client.examples.ts#Client_connect_sseFallback" - * const baseUrl = new URL(url); - * - * try { - * // Try modern Streamable HTTP transport first - * const client = new Client({ name: 'my-client', version: '1.0.0' }); - * const transport = new StreamableHTTPClientTransport(baseUrl); - * await client.connect(transport); - * return { client, transport }; - * } catch { - * // Fall back to legacy SSE transport - * const client = new Client({ name: 'my-client', version: '1.0.0' }); - * const transport = new SSEClientTransport(baseUrl); - * await client.connect(transport); - * return { client, transport }; - * } - * ``` - */ private _sessionReinit?: Promise; override request( @@ -950,11 +922,28 @@ export class Client extends Protocol { // 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; - this._sessionReinit ??= this._reinitializeSession().finally(() => { + // Pagination continuations are not retried: a cursor minted by + // the dead session is meaningless to the new one. + if ( + request.method === 'initialize' || + !this._isSessionTerminated(error) || + (request.params !== undefined && 'cursor' in request.params) + ) { + throw error; + } + const options = isStandardSchema(schemaOrOptions) ? maybeOptions : schemaOrOptions; + this._sessionReinit ??= this._reinitializeSession(options).finally(() => { this._sessionReinit = undefined; }); - await this._sessionReinit; + 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 error; + } return await super.request(request as never, schemaOrOptions as never, maybeOptions); } } @@ -970,14 +959,43 @@ export class Client extends Protocol { ); } - private async _reinitializeSession(): Promise { + private async _reinitializeSession(options?: RequestOptions): Promise { const transport = this.transport; if (transport === undefined) { throw new SdkError(SdkErrorCode.ConnectionClosed, 'Cannot reinitialize: not connected'); } - await this._legacyHandshake(transport); + await this._legacyHandshake(transport, options); } + /** + * Connects to a server via the given transport and performs the MCP initialization handshake. + * + * @example Basic usage (stdio) + * ```ts source="./client.examples.ts#Client_connect_stdio" + * const client = new Client({ name: 'my-client', version: '1.0.0' }); + * const transport = new StdioClientTransport({ command: 'my-mcp-server' }); + * await client.connect(transport); + * ``` + * + * @example Streamable HTTP with SSE fallback + * ```ts source="./client.examples.ts#Client_connect_sseFallback" + * const baseUrl = new URL(url); + * + * try { + * // Try modern Streamable HTTP transport first + * const client = new Client({ name: 'my-client', version: '1.0.0' }); + * const transport = new StreamableHTTPClientTransport(baseUrl); + * await client.connect(transport); + * return { client, transport }; + * } catch { + * // Fall back to legacy SSE transport + * const client = new Client({ name: 'my-client', version: '1.0.0' }); + * const transport = new SSEClientTransport(baseUrl); + * await client.connect(transport); + * return { client, transport }; + * } + * ``` + */ override async connect(transport: Transport, options?: ConnectOptions): Promise { if (options?.prior !== undefined) { // Zero-round-trip reconnect from a previously-obtained diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 3e4dc9b152..d757aa3134 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -315,6 +315,7 @@ 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'; @@ -983,15 +984,24 @@ export class StreamableHTTPClientTransport implements Transport { const sessionId = response.headers.get('mcp-session-id'); if (sessionId && (sentSessionId === null || sentSessionId === this._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; + sessionTerminated = true; + } else if (response.status === 404 && sentSessionId !== null && sentSessionId === this._terminatedSessionId) { sessionTerminated = true; } if (response.status === 401 && this._authProvider) { diff --git a/packages/client/test/client/sessionReinitialize.test.ts b/packages/client/test/client/sessionReinitialize.test.ts new file mode 100644 index 0000000000..de6ad98460 --- /dev/null +++ b/packages/client/test/client/sessionReinitialize.test.ts @@ -0,0 +1,127 @@ +import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal'; +import { SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it } from 'vitest'; + +import { Client } from '../../src/client/client'; +import type { Transport } from '../../src/index'; + +const INIT_RESULT = { + protocolVersion: '2025-11-25', + capabilities: { tools: {} }, + serverInfo: { name: 's', version: '1' } +}; + +function terminated404(): SdkHttpError { + return new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, 'Error POSTing to endpoint: session not found', { + status: 404, + statusText: 'Not Found', + sessionTerminated: true + }); +} + +class ScriptedTransport implements Transport { + onmessage?: (message: JSONRPCMessage) => void; + onerror?: (error: Error) => void; + onclose?: () => void; + sent: JSONRPCMessage[] = []; + constructor(private readonly script: (message: JSONRPCMessage, t: ScriptedTransport) => void) {} + async start(): Promise {} + async close(): Promise { + this.onclose?.(); + } + async send(message: JSONRPCMessage): Promise { + this.sent.push(message); + this.script(message, this); + } + reply(message: JSONRPCMessage): void { + queueMicrotask(() => this.onmessage?.(message)); + } +} + +function isRequest(m: JSONRPCMessage): m is JSONRPCMessage & { id: number | string; method: string } { + return 'method' in m && 'id' in m; +} + +function echoScript(opts: { failNthToolCall?: number[]; failReinit?: boolean }) { + let toolCalls = 0; + let initCount = 0; + return (message: JSONRPCMessage, t: ScriptedTransport) => { + if (!isRequest(message)) return; + if (message.method === 'initialize') { + initCount++; + if (opts.failReinit && initCount > 1) { + throw new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, 'unavailable', { status: 500 }); + } + t.reply({ jsonrpc: '2.0', id: message.id, result: INIT_RESULT }); + return; + } + if (message.method === 'tools/call') { + toolCalls++; + if (opts.failNthToolCall?.includes(toolCalls)) throw terminated404(); + t.reply({ jsonrpc: '2.0', id: message.id, result: { content: [{ type: 'text', text: 'ok' }] } }); + return; + } + if (message.method === 'tools/list') { + toolCalls++; + if (opts.failNthToolCall?.includes(toolCalls)) throw terminated404(); + t.reply({ jsonrpc: '2.0', id: message.id, result: { tools: [] } }); + } + }; +} + +describe('session re-initialization after a terminated-session 404', () => { + it('re-initializes and retries the failed request once', async () => { + const transport = new ScriptedTransport(echoScript({ failNthToolCall: [1] })); + const client = new Client({ name: 'c', version: '0' }, {}); + await client.connect(transport); + + const result = await client.callTool({ name: 'echo', arguments: {} }); + expect(result.content).toEqual([{ type: 'text', text: 'ok' }]); + + const methods = transport.sent.filter(isRequest).map(m => m.method); + expect(methods).toEqual(['initialize', 'tools/call', 'initialize', 'tools/call']); + await client.close(); + }); + + it('deduplicates concurrent recoveries into one handshake', async () => { + const transport = new ScriptedTransport(echoScript({ failNthToolCall: [1, 2] })); + const client = new Client({ name: 'c', version: '0' }, {}); + await client.connect(transport); + + const [a, b] = await Promise.all([ + client.callTool({ name: 'echo', arguments: {} }), + client.callTool({ name: 'echo', arguments: {} }) + ]); + expect(a.content).toEqual([{ type: 'text', text: 'ok' }]); + expect(b.content).toEqual([{ type: 'text', text: 'ok' }]); + + const inits = transport.sent.filter(isRequest).filter(m => m.method === 'initialize'); + expect(inits).toHaveLength(2); + await client.close(); + }); + + it('surfaces the original 404 when re-establishment fails, reporting the handshake error via onerror', async () => { + const transport = new ScriptedTransport(echoScript({ failNthToolCall: [1], failReinit: true })); + const client = new Client({ name: 'c', version: '0' }, {}); + const reported: Error[] = []; + client.onerror = e => void reported.push(e); + await client.connect(transport); + + const rejection = await client.callTool({ name: 'echo', arguments: {} }).catch((e: unknown) => e); + expect(rejection).toBeInstanceOf(SdkHttpError); + expect((rejection as SdkHttpError).data.status).toBe(404); + expect(reported.some(e => e instanceof SdkHttpError && e.data.status === 500)).toBe(true); + }); + + it('does not retry pagination continuations', async () => { + const transport = new ScriptedTransport(echoScript({ failNthToolCall: [1] })); + const client = new Client({ name: 'c', version: '0' }, {}); + await client.connect(transport); + + const rejection = await client.request({ method: 'tools/list', params: { cursor: 'stale' } }).catch((e: unknown) => e); + expect(rejection).toBeInstanceOf(SdkHttpError); + const methods = transport.sent.filter(isRequest).map(m => m.method); + expect(methods).toEqual(['initialize', 'tools/list']); + await client.close(); + }); +}); diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 1113836bf1..60538f8178 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -182,6 +182,72 @@ describe('StreamableHTTPClientTransport', () => { expect(transport.sessionId).toBe('rotated-session'); }); + it('marks raced 404s of the same dead session until a new one is assigned', async () => { + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'doomed' }) + }); + await transport.send({ + jsonrpc: '2.0', + method: 'initialize', + params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26' }, + id: 'init-id' + } as JSONRPCMessage); + + const mock404 = () => + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Headers(), + text: () => Promise.resolve('gone') + }); + + // First 404: terminates the session — marked. + mock404(); + const first = await transport.send({ jsonrpc: '2.0', method: 'a', id: 1 } as JSONRPCMessage).catch((e: unknown) => e); + expect((first as SdkHttpError).data['sessionTerminated']).toBe(true); + expect(transport.sessionId).toBeUndefined(); + + // A raced sibling that also went out under 'doomed' — still marked. + mock404(); + // Simulate: the sibling's request carried the dead id. + (transport as unknown as { _sessionId?: string })._sessionId = 'doomed'; + const sibling = await transport.send({ jsonrpc: '2.0', method: 'b', id: 2 } as JSONRPCMessage).catch((e: unknown) => e); + expect((sibling as SdkHttpError).data['sessionTerminated']).toBe(true); + + // A request that went out under 'doomed', answered 404 only AFTER a + // fresh session was assigned: no longer marked. + (transport as unknown as { _sessionId?: string })._sessionId = 'doomed'; + let resolveLate: (r: unknown) => void; + (globalThis.fetch as Mock).mockImplementationOnce(() => new Promise(resolve => (resolveLate = resolve))); + const latePromise = transport.send({ jsonrpc: '2.0', method: 'c', id: 3 } as JSONRPCMessage).catch((e: unknown) => e); + // Rotation completes while the late request is in flight. + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'fresh' }) + }); + (transport as unknown as { _sessionId?: string })._sessionId = undefined; + await transport.send({ + jsonrpc: '2.0', + method: 'initialize', + params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26' }, + id: 'init-2' + } as JSONRPCMessage); + resolveLate!({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Headers(), + text: () => Promise.resolve('gone') + }); + const late = await latePromise; + expect((late as SdkHttpError).data['sessionTerminated']).toBeUndefined(); + expect(transport.sessionId).toBe('fresh'); + }); + it('should clear the session ID on a 404 to a request that carried it', async () => { (globalThis.fetch as Mock).mockResolvedValueOnce({ ok: true, From 26020782dfd8f24cccde43e56c7e021737cea31b Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 9 Jul 2026 11:46:35 +0000 Subject: [PATCH 5/6] fix(client): extend session recovery to notifications and cursor continuations Session recovery after a terminated-session 404 previously covered only plain requests. A pagination continuation or a client-sent notification that was the first to observe a dead session left the client without a session id and with no re-initialize, wedging it (a stateful server answers subsequent session-less requests with 400, which never triggers recovery). Now: - Cursor continuations re-establish the session before surfacing the 404, so the caller can restart pagination on a live session. - notification() recovers and resends once, mirroring request(); notifications/initialized is exempt since it is part of the recovery handshake itself. - The recovery handshake runs without the failing request's options, so a per-request signal, timeout, or onprogress cannot cancel or decorate the shared initialize that concurrent recoveries depend on. - The transport's 404 session-clear cancels a pending SSE stream reconnect (it would fire into the new session with a stale last-event-id) and fires its onRequestStreamEnd so a per-request caller waiting on the resume settles instead of hanging. Also documents the 404 behavior changes in the migration guide and the terminateSession JSDoc, and drops the repl example's unreachable 405 detection branch (terminateSession always clears the session id on every resolving path). --- .changeset/shttp-session-capture-on-ok.md | 2 +- docs/migration/upgrade-to-v2.md | 12 +++- docs/serving/sessions-state-scaling.md | 2 +- examples/repl/client.ts | 24 +++---- packages/client/src/client/client.ts | 64 ++++++++++++------- packages/client/src/client/streamableHttp.ts | 16 ++++- .../test/client/sessionReinitialize.test.ts | 59 +++++++++++++++-- .../client/test/client/streamableHttp.test.ts | 37 +++++++++++ 8 files changed, 170 insertions(+), 46 deletions(-) diff --git a/.changeset/shttp-session-capture-on-ok.md b/.changeset/shttp-session-capture-on-ok.md index 28bd8d6975..1db79da591 100644 --- a/.changeset/shttp-session-capture-on-ok.md +++ b/.changeset/shttp-session-capture-on-ok.md @@ -2,4 +2,4 @@ '@modelcontextprotocol/client': patch --- -`StreamableHTTPClientTransport` session-id handling now follows the spec's assignment model: the `Mcp-Session-Id` response header is captured only from successful responses (the spec assigns it on the `InitializeResult` response), and only when the request carried no id or the still-current one — so an error reply, or a late reply from an older session, 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 once; server-side per-session state (e.g. resource subscriptions) does not carry over. +`StreamableHTTPClientTransport` session-id handling now follows the spec's assignment model: the `Mcp-Session-Id` response header is captured only from successful responses (the spec assigns it on the `InitializeResult` response), and only when the request carried no id or the still-current one — so an error reply, or a late reply from an older session, 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. diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 89188aa893..4ea23b13b4 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -1560,6 +1560,14 @@ 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 `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 @@ -1614,7 +1622,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) diff --git a/docs/serving/sessions-state-scaling.md b/docs/serving/sessions-state-scaling.md index 0bbffe450a..400e88015b 100644 --- a/docs/serving/sessions-state-scaling.md +++ b/docs/serving/sessions-state-scaling.md @@ -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. diff --git a/examples/repl/client.ts b/examples/repl/client.ts index ccb1ca2c0c..bb5fb50b78 100644 --- a/examples/repl/client.ts +++ b/examples/repl/client.ts @@ -531,23 +531,17 @@ async function terminateSession(): Promise { 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); } diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index c5d33f041c..ecd9c58049 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -34,16 +34,18 @@ import type { LoggingLevel, MessageExtraInfo, NonCompleteResultFlow, + Notification, NotificationMethod, + NotificationOptions, ProtocolEra, ProtocolOptions, ReadResourceRequest, ReadResourceResult, RequestMethod, - ResultTypeMap, RequestOptions, ResolvedInputRequiredDriverConfig, Result, + ResultTypeMap, ServerCapabilities, StandardSchemaV1, SubscribeRequest, @@ -76,7 +78,6 @@ import { SdkError, SdkErrorCode, SdkHttpError, - isStandardSchema, SUBSCRIPTION_ID_META_KEY, SUPPORTED_MODERN_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core-internal'; @@ -922,32 +923,49 @@ export class Client extends Protocol { // 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. - // Pagination continuations are not retried: a cursor minted by - // the dead session is meaningless to the new one. - if ( - request.method === 'initialize' || - !this._isSessionTerminated(error) || - (request.params !== undefined && 'cursor' in request.params) - ) { + if (request.method === 'initialize' || !this._isSessionTerminated(error)) { throw error; } - const options = isStandardSchema(schemaOrOptions) ? maybeOptions : schemaOrOptions; - this._sessionReinit ??= this._reinitializeSession(options).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))); + 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); } } + override async notification(notification: Notification, options?: NotificationOptions): Promise { + 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); + } + } + + private async _recoverSession(cause: unknown): Promise { + 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; + } + } + private _isSessionTerminated(error: unknown): boolean { return ( error instanceof SdkHttpError && @@ -959,12 +977,14 @@ export class Client extends Protocol { ); } - private async _reinitializeSession(options?: RequestOptions): Promise { + private async _reinitializeSession(): Promise { const transport = this.transport; if (transport === undefined) { throw new SdkError(SdkErrorCode.ConnectionClosed, 'Cannot reinitialize: not connected'); } - await this._legacyHandshake(transport, options); + // No caller options: the handshake is shared connection state, so no + // single request's signal/timeout/onprogress may govern it. + await this._legacyHandshake(transport); } /** diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index d757aa3134..5a448a0741 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -323,6 +323,7 @@ export class StreamableHTTPClientTransport implements Transport { 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; @@ -653,6 +654,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. @@ -675,6 +677,7 @@ export class StreamableHTTPClientTransport implements Transport { const handle = setTimeout(reconnect, delay); this._cancelReconnection = () => clearTimeout(handle); } + this._pendingReconnectOptions = options; } private _handleSseStream(stream: ReadableStream | null, options: StartSSEOptions, isReconnectable: boolean): void { @@ -1000,6 +1003,14 @@ export class StreamableHTTPClientTransport implements Transport { 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?.(); + this._pendingReconnectOptions = undefined; sessionTerminated = true; } else if (response.status === 404 && sentSessionId !== null && sentSessionId === this._terminatedSessionId) { sessionTerminated = true; @@ -1170,8 +1181,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 { if (!this._sessionId) { diff --git a/packages/client/test/client/sessionReinitialize.test.ts b/packages/client/test/client/sessionReinitialize.test.ts index de6ad98460..fe99aedfc9 100644 --- a/packages/client/test/client/sessionReinitialize.test.ts +++ b/packages/client/test/client/sessionReinitialize.test.ts @@ -42,11 +42,22 @@ function isRequest(m: JSONRPCMessage): m is JSONRPCMessage & { id: number | stri return 'method' in m && 'id' in m; } -function echoScript(opts: { failNthToolCall?: number[]; failReinit?: boolean }) { +function echoScript(opts: { failNthToolCall?: number[]; failReinit?: boolean; failFirstRootsNotification?: boolean }) { let toolCalls = 0; let initCount = 0; + let rootsNotifications = 0; return (message: JSONRPCMessage, t: ScriptedTransport) => { - if (!isRequest(message)) return; + if (!isRequest(message)) { + if ( + 'method' in message && + message.method === 'notifications/roots/list_changed' && + opts.failFirstRootsNotification && + ++rootsNotifications === 1 + ) { + throw terminated404(); + } + return; + } if (message.method === 'initialize') { initCount++; if (opts.failReinit && initCount > 1) { @@ -113,15 +124,55 @@ describe('session re-initialization after a terminated-session 404', () => { expect(reported.some(e => e instanceof SdkHttpError && e.data.status === 500)).toBe(true); }); - it('does not retry pagination continuations', async () => { + it('recovers the session on a pagination continuation but does not retry it', async () => { const transport = new ScriptedTransport(echoScript({ failNthToolCall: [1] })); const client = new Client({ name: 'c', version: '0' }, {}); await client.connect(transport); const rejection = await client.request({ method: 'tools/list', params: { cursor: 'stale' } }).catch((e: unknown) => e); expect(rejection).toBeInstanceOf(SdkHttpError); + expect((rejection as SdkHttpError).data.status).toBe(404); + // The session is re-established (so later requests work), but the + // dead session's cursor is not replayed into the new one. const methods = transport.sent.filter(isRequest).map(m => m.method); - expect(methods).toEqual(['initialize', 'tools/list']); + expect(methods).toEqual(['initialize', 'tools/list', 'initialize']); + + const result = await client.request({ method: 'tools/list' }); + expect(result.tools).toEqual([]); + await client.close(); + }); + + it('recovers and resends a notification that hit a terminated session', async () => { + const transport = new ScriptedTransport(echoScript({ failFirstRootsNotification: true })); + const client = new Client({ name: 'c', version: '0' }, { capabilities: { roots: { listChanged: true } } }); + await client.connect(transport); + + await client.sendRootsListChanged(); + + const methods = transport.sent.map(m => ('method' in m ? m.method : '')).filter(m => m !== ''); + expect(methods).toEqual([ + 'initialize', + 'notifications/initialized', + 'notifications/roots/list_changed', + 'initialize', + 'notifications/initialized', + 'notifications/roots/list_changed' + ]); + await client.close(); + }); + + it('runs the recovery handshake without the failing request options', async () => { + const transport = new ScriptedTransport(echoScript({ failNthToolCall: [1] })); + const client = new Client({ name: 'c', version: '0' }, {}); + await client.connect(transport); + + await client.callTool({ name: 'echo', arguments: {} }, { onprogress: () => {} }); + + const inits = transport.sent.filter(isRequest).filter(m => m.method === 'initialize'); + expect(inits).toHaveLength(2); + // The caller's onprogress must not decorate the shared recovery + // initialize with a progress token. + expect((inits[1] as unknown as { params: { _meta?: unknown } }).params._meta).toBeUndefined(); await client.close(); }); }); diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 60538f8178..6ef0e966eb 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -351,6 +351,43 @@ describe('StreamableHTTPClientTransport', () => { expect(transport.sessionId).toBe('second-session'); }); + it('cancels a pending SSE reconnection when a 404 terminates the session', async () => { + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'doomed' }) + }); + await transport.send({ + jsonrpc: '2.0', + method: 'initialize', + params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26' }, + id: 'init-id' + } as JSONRPCMessage); + + // A reconnect scheduled for the dead session's stream must not fire + // into the recovered session with a stale last-event-id. + const cancel = vi.fn(); + const onRequestStreamEnd = vi.fn(); + const internals = transport as unknown as { + _cancelReconnection?: () => void; + _pendingReconnectOptions?: { onRequestStreamEnd?: () => void }; + }; + internals._cancelReconnection = cancel; + internals._pendingReconnectOptions = { onRequestStreamEnd }; + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Headers(), + text: () => Promise.resolve('session not found') + }); + await expect(transport.send({ jsonrpc: '2.0', method: 'test', id: 'r1' } as JSONRPCMessage)).rejects.toThrow(); + expect(cancel).toHaveBeenCalledTimes(1); + // A per-request caller waiting on that stream's resume settles. + expect(onRequestStreamEnd).toHaveBeenCalledTimes(1); + }); + it('treats a 404 to the termination DELETE as successful termination and drops the id', async () => { (globalThis.fetch as Mock).mockResolvedValueOnce({ ok: true, From 0c0aaf2a6924dcf361ca6b5cf751c7011d3ea1ea Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 9 Jul 2026 12:04:02 +0000 Subject: [PATCH 6/6] Restrict session-id capture to the initialize exchange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec assigns Mcp-Session-Id at initialization time, on the HTTP response containing the InitializeResult, and is silent on the header elsewhere. Key the client transport's capture on the outgoing POST carrying an InitializeRequest instead of guarding by the sent session id: a session header on any other successful response is now ignored entirely. The sent-id guard remains only where it still matters — the 404 termination transition, which must be attributed to the session the request actually presented. Drop the tests that blessed mid-session rotation via ordinary 2xx replies and replace them with handshake-keyed coverage: capture from an initialize response (single and batched), no capture from any non-initialize response (same or different id), and the late-404 race now recovers through a real re-initialize handshake. Document the behavior change in the v2 migration guide. --- .changeset/shttp-session-capture-on-ok.md | 2 +- docs/migration/upgrade-to-v2.md | 9 ++ packages/client/src/client/streamableHttp.ts | 13 +- .../client/test/client/streamableHttp.test.ts | 123 +++++++++++------- 4 files changed, 93 insertions(+), 54 deletions(-) diff --git a/.changeset/shttp-session-capture-on-ok.md b/.changeset/shttp-session-capture-on-ok.md index 1db79da591..5c52e696a9 100644 --- a/.changeset/shttp-session-capture-on-ok.md +++ b/.changeset/shttp-session-capture-on-ok.md @@ -2,4 +2,4 @@ '@modelcontextprotocol/client': patch --- -`StreamableHTTPClientTransport` session-id handling now follows the spec's assignment model: the `Mcp-Session-Id` response header is captured only from successful responses (the spec assigns it on the `InitializeResult` response), and only when the request carried no id or the still-current one — so an error reply, or a late reply from an older session, 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. +`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. diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 4ea23b13b4..e5d3623dda 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -1560,6 +1560,15 @@ 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 + 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 diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 5a448a0741..68f16a48db 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -5,6 +5,7 @@ import { createFetchWithInit, encodeMcpParamValue, isInitializedNotification, + isInitializeRequest, isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, @@ -975,17 +976,17 @@ export class StreamableHTTPClientTransport implements Transport { signal }; - // Responses are judged against the id this request carried — - // late replies from an older session race `_sessionId`. + // 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); - // Session ids come only from successful replies whose request - // carried no id or the still-current one. - if (response.ok) { + // 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 && (sentSessionId === null || sentSessionId === this._sessionId)) { + if (sessionId) { this._sessionId = sessionId; this._terminatedSessionId = undefined; } diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 6ef0e966eb..41e29200f0 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -93,7 +93,8 @@ describe('StreamableHTTPClientTransport', () => { method: 'initialize', params: { clientInfo: { name: 'test-client', version: '1.0' }, - protocolVersion: '2025-03-26' + protocolVersion: '2025-03-26', + capabilities: {} }, id: 'init-id' }; @@ -165,21 +166,64 @@ describe('StreamableHTTPClientTransport', () => { await transport.send({ jsonrpc: '2.0', method: 'initialize', - params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26' }, + params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26', capabilities: {} }, id: 'init-id' } as JSONRPCMessage); expect(transport.sessionId).toBe('real-session'); + }); + + it('stores the session ID when the initialize request is part of a batch', async () => { + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'batch-session' }) + }); + await transport.send([ + { + jsonrpc: '2.0', + method: 'initialize', + params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26', capabilities: {} }, + id: 'init-id' + }, + { jsonrpc: '2.0', method: 'other', id: 'other-id' } + ] as JSONRPCMessage[]); + expect(transport.sessionId).toBe('batch-session'); + }); - // 202 captures too; notifications/initialized would fire the - // standalone GET past the fetch mock. + it('ignores a session id on a successful non-initialize response', async () => { + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'handshake-session' }) + }); + await transport.send({ + jsonrpc: '2.0', + method: 'initialize', + params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26', capabilities: {} }, + id: 'init-id' + } as JSONRPCMessage); + expect(transport.sessionId).toBe('handshake-session'); + + // Spec: the session id is assigned "at initialization time ... on the + // HTTP response containing the InitializeResult" — the spec is silent on + // the header elsewhere, so the client ignores it on any other response. (globalThis.fetch as Mock).mockResolvedValueOnce({ ok: true, status: 202, - headers: new Headers({ 'mcp-session-id': 'rotated-session' }) + headers: new Headers({ 'mcp-session-id': 'unrelated-session' }) }); await transport.send({ jsonrpc: '2.0', method: 'notifications/roots/list_changed' } as JSONRPCMessage); - expect(transport.sessionId).toBe('rotated-session'); + expect(transport.sessionId).toBe('handshake-session'); + + // A same-id echo on a request's response is equally inert. + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers({ 'mcp-session-id': 'handshake-session' }) + }); + await transport.send({ jsonrpc: '2.0', method: 'test', id: 'r1' } as JSONRPCMessage); + expect(transport.sessionId).toBe('handshake-session'); }); it('marks raced 404s of the same dead session until a new one is assigned', async () => { @@ -191,7 +235,7 @@ describe('StreamableHTTPClientTransport', () => { await transport.send({ jsonrpc: '2.0', method: 'initialize', - params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26' }, + params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26', capabilities: {} }, id: 'init-id' } as JSONRPCMessage); @@ -233,7 +277,7 @@ describe('StreamableHTTPClientTransport', () => { await transport.send({ jsonrpc: '2.0', method: 'initialize', - params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26' }, + params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26', capabilities: {} }, id: 'init-2' } as JSONRPCMessage); resolveLate!({ @@ -257,7 +301,7 @@ describe('StreamableHTTPClientTransport', () => { await transport.send({ jsonrpc: '2.0', method: 'initialize', - params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26' }, + params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26', capabilities: {} }, id: 'init-id' } as JSONRPCMessage); expect(transport.sessionId).toBe('expired-later'); @@ -279,64 +323,47 @@ describe('StreamableHTTPClientTransport', () => { expect((globalThis.fetch as Mock).mock.calls.at(-1)![1].headers.get('mcp-session-id')).toBeNull(); }); - it('does not let a late success from an old session overwrite a newer session id', async () => { + it('does not let a late 404 from an old session wipe a newer session id', async () => { (globalThis.fetch as Mock).mockResolvedValueOnce({ ok: true, status: 200, - headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'old-session' }) + headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'first-session' }) }); await transport.send({ jsonrpc: '2.0', method: 'initialize', - params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26' }, + params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26', capabilities: {} }, id: 'init-id' } as JSONRPCMessage); - expect(transport.sessionId).toBe('old-session'); + expect(transport.sessionId).toBe('first-session'); - // Request A goes out under old-session but its response is delayed. + // Request A goes out under first-session but its response is delayed. let resolveLate!: (r: unknown) => void; (globalThis.fetch as Mock).mockImplementationOnce(() => new Promise(r => (resolveLate = r))); const late = transport.send({ jsonrpc: '2.0', method: 'test', id: 'late' } as JSONRPCMessage); - // Meanwhile the server rotates the session on request B. + // Meanwhile request B hits a 404 — first-session is dead — and a + // fresh handshake establishes second-session. (globalThis.fetch as Mock).mockResolvedValueOnce({ - ok: true, - status: 202, - headers: new Headers({ 'mcp-session-id': 'new-session' }) + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Headers(), + text: () => Promise.resolve('session not found') }); - await transport.send({ jsonrpc: '2.0', method: 'notifications/roots/list_changed' } as JSONRPCMessage); - expect(transport.sessionId).toBe('new-session'); - - // A's late echo of the old id must not clobber the rotated id. - resolveLate({ ok: true, status: 202, headers: new Headers({ 'mcp-session-id': 'old-session' }) }); - await late; - expect(transport.sessionId).toBe('new-session'); - }); - - it('does not let a late 404 from an old session wipe a newer session id', async () => { + await expect(transport.send({ jsonrpc: '2.0', method: 'test', id: 'b' } as JSONRPCMessage)).rejects.toThrow(); + expect(transport.sessionId).toBeUndefined(); (globalThis.fetch as Mock).mockResolvedValueOnce({ ok: true, status: 200, - headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'first-session' }) + headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'second-session' }) }); await transport.send({ jsonrpc: '2.0', method: 'initialize', - params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26' }, - id: 'init-id' + params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26', capabilities: {} }, + id: 'init-2' } as JSONRPCMessage); - expect(transport.sessionId).toBe('first-session'); - - let resolveLate!: (r: unknown) => void; - (globalThis.fetch as Mock).mockImplementationOnce(() => new Promise(r => (resolveLate = r))); - const late = transport.send({ jsonrpc: '2.0', method: 'test', id: 'late' } as JSONRPCMessage); - - (globalThis.fetch as Mock).mockResolvedValueOnce({ - ok: true, - status: 202, - headers: new Headers({ 'mcp-session-id': 'second-session' }) - }); - await transport.send({ jsonrpc: '2.0', method: 'notifications/roots/list_changed' } as JSONRPCMessage); expect(transport.sessionId).toBe('second-session'); // The late 404 was for first-session; second-session is still live. @@ -360,7 +387,7 @@ describe('StreamableHTTPClientTransport', () => { await transport.send({ jsonrpc: '2.0', method: 'initialize', - params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26' }, + params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26', capabilities: {} }, id: 'init-id' } as JSONRPCMessage); @@ -397,7 +424,7 @@ describe('StreamableHTTPClientTransport', () => { await transport.send({ jsonrpc: '2.0', method: 'initialize', - params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26' }, + params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-03-26', capabilities: {} }, id: 'init-id' } as JSONRPCMessage); expect(transport.sessionId).toBe('gone-session'); @@ -448,7 +475,8 @@ describe('StreamableHTTPClientTransport', () => { method: 'initialize', params: { clientInfo: { name: 'test-client', version: '1.0' }, - protocolVersion: '2025-03-26' + protocolVersion: '2025-03-26', + capabilities: {} }, id: 'init-id' }; @@ -488,7 +516,8 @@ describe('StreamableHTTPClientTransport', () => { method: 'initialize', params: { clientInfo: { name: 'test-client', version: '1.0' }, - protocolVersion: '2025-03-26' + protocolVersion: '2025-03-26', + capabilities: {} }, id: 'init-id' };