From 3684376cb3ecab9afa4e6697b340ac98ee434caa Mon Sep 17 00:00:00 2001 From: Alaa Azazi Date: Thu, 13 Aug 2026 17:11:41 -0600 Subject: [PATCH 1/3] fix(client): treat HTTP 404 with session ID as session expiry Per the MCP spec (Streamable HTTP, Session Management), when a client receives an HTTP 404 in response to a request that carried an Mcp-Session-Id, the session has expired or been terminated server-side and the client must start a new session. StreamableHTTPClientTransport previously surfaced every non-401/403 error status -- including 404 -- as a generic ClientHttpNotImplemented (POST) error, with no way to distinguish session expiry from other failures. Consumers were left matching the response body, which only works against the reference server; servers that report expiry with a different body (e.g. a -32002 JSON-RPC code, or a plain-text/HTML proxy response) slipped through. Detect session expiry by status code alone, scoped to requests that actually carried a session ID (snapshotted before the fetch, and before the isHandshake header-stripping check, so a sessionless initialize is never misclassified): on a 404 when the request carried a session ID, clear the stale session ID (so a subsequent connect() issues a fresh initialize) and throw SdkHttpError with the new SdkErrorCode.ClientHttpSessionExpired. A 404 without a session ID is unchanged and still surfaces as ClientHttpNotImplemented. Not applied to the standalone GET SSE stream, whose failure must not tear down an otherwise healthy session. terminateSession() now also treats a 404 (session already gone server-side) the same as the existing 405 (termination unsupported) case: it resolves instead of throwing ClientHttpFailedToTerminateSession, since the session being already gone is exactly the caller's intent. Rebases the essential behavior of #2125 onto current main, resolving the conflict introduced by #2469 (the isHandshake / SdkHttpError changes did not exist when #2125 was opened) and updating the newly-added errorSurfacePins test and migration guide per docs/behavior-surface-pins.md's protocol for a deliberate SdkErrorCode membership change. Co-authored-by: David Soria Parra --- docs/migration/upgrade-to-v2.md | 11 ++++++ packages/client/src/client/streamableHttp.ts | 36 +++++++++++++++++-- .../core-internal/src/errors/sdkErrors.ts | 13 ++++++- .../test/types/errorSurfacePins.test.ts | 3 +- 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 19f4127733..ee1637499b 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -879,6 +879,17 @@ class to match per scenario: | 403 `insufficient_scope` after step-up retry cap | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpForbidden` | | Unexpected content type | `StreamableHTTPError` | `SdkError` + `SdkErrorCode.ClientHttpUnexpectedContent` | | Session termination failed | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpFailedToTerminateSession` | +| 404 to a session-bound request (session expired server-side) | `StreamableHTTPError` (no distinct classification) | `SdkHttpError` + `SdkErrorCode.ClientHttpSessionExpired` | + +**`ClientHttpSessionExpired` is new behavior, not just a reclassification.** In v1, a 404 +to a session-bound request fell into the same generic `StreamableHTTPError` bucket as +any other HTTP failure, and the transport kept the stale session ID. In v2, per the MCP +spec's Session Management requirements, `StreamableHTTPClientTransport` clears its +session ID itself before throwing `ClientHttpSessionExpired`, so a subsequent +`client.connect()` starts a fresh session automatically instead of continuing to send a +session ID the server has already forgotten. This only applies to the POST request path; +a 404 on the optional standalone GET SSE stream does not clear the session, since that +channel's failure doesn't indicate the session itself is gone. ```typescript // v1 diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index ace0663158..2447dbdfec 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -1004,6 +1004,13 @@ export class StreamableHTTPClientTransport implements Transport { signal }; + // Snapshot whether *this* request actually carried Mcp-Session-Id, before + // the fetch and before any response handling can mutate `_sessionId` — the + // 404 session-expiry check below is defined in terms of the request, not + // post-response state. A handshake request never carries the header (it's + // stripped above regardless of `_sessionId`), so it is never eligible. + const requestHadSessionId = !isHandshake && this._sessionId !== undefined; + const response = await (this._fetch ?? fetch)(this._url, init); // The spec assigns the session id "at initialization time … on the HTTP response containing the InitializeResult"; it is ignored everywhere else. @@ -1098,6 +1105,26 @@ export class StreamableHTTPClientTransport implements Transport { } } + // Per the MCP spec (Streamable HTTP, Session Management): a 404 to a + // request that carried an Mcp-Session-Id means the session has expired + // or been terminated server-side, and the client must start a new + // session. Detected by status code alone (not the response body), since + // non-reference servers report expiry with varying bodies (a -32002 + // JSON-RPC code, plain text, an HTML proxy page, etc). Clears the dead + // session ID so a subsequent connect() issues a fresh initialize, and + // surfaces a distinguishable error code rather than the generic one + // below. Scoped to requests that actually carried a session ID: a 404 + // without one (e.g. a wrong URL on the initial connect) is unrelated to + // session state and still surfaces as ClientHttpNotImplemented. + if (response.status === 404 && requestHadSessionId) { + this._sessionId = undefined; + throw new SdkHttpError(SdkErrorCode.ClientHttpSessionExpired, `Session expired (HTTP 404): ${text}`, { + status: 404, + statusText: response.statusText, + text + }); + } + throw new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${text}`, { status: response.status, statusText: response.statusText, @@ -1207,9 +1234,12 @@ 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 Method Not Allowed: per the spec the server does not support explicit + // session termination — treat as success. + // 404 Not Found: the session is already gone server-side, which is exactly + // what the caller asked for — treat as success rather than a failure. Both + // fall through to clear the local session ID below. + if (!response.ok && response.status !== 405 && response.status !== 404) { throw new SdkHttpError( SdkErrorCode.ClientHttpFailedToTerminateSession, `Failed to terminate session: ${response.statusText}`, diff --git a/packages/core-internal/src/errors/sdkErrors.ts b/packages/core-internal/src/errors/sdkErrors.ts index 0bc8f9a1ad..06e4722628 100644 --- a/packages/core-internal/src/errors/sdkErrors.ts +++ b/packages/core-internal/src/errors/sdkErrors.ts @@ -96,7 +96,18 @@ export enum SdkErrorCode { ClientHttpForbidden = 'CLIENT_HTTP_FORBIDDEN', ClientHttpUnexpectedContent = 'CLIENT_HTTP_UNEXPECTED_CONTENT', ClientHttpFailedToOpenStream = 'CLIENT_HTTP_FAILED_TO_OPEN_STREAM', - ClientHttpFailedToTerminateSession = 'CLIENT_HTTP_FAILED_TO_TERMINATE_SESSION' + ClientHttpFailedToTerminateSession = 'CLIENT_HTTP_FAILED_TO_TERMINATE_SESSION', + /** + * HTTP 404 to a request that carried an `Mcp-Session-Id`: per the MCP spec + * (Streamable HTTP, Session Management), the server has terminated or expired the + * session. The transport clears its stored session ID before throwing this, so a + * subsequent `connect()` starts a fresh session. Not thrown for a 404 on a request + * that carried no session ID (surfaced as {@linkcode ClientHttpNotImplemented} + * instead), and not thrown for the standalone GET SSE stream, whose failure must + * not tear down an otherwise-healthy session. + * Carried on an {@linkcode SdkHttpError} with `status: 404`. + */ + ClientHttpSessionExpired = 'CLIENT_HTTP_SESSION_EXPIRED' } /** diff --git a/packages/core-internal/test/types/errorSurfacePins.test.ts b/packages/core-internal/test/types/errorSurfacePins.test.ts index cc01cf4c57..942fe4f1de 100644 --- a/packages/core-internal/test/types/errorSurfacePins.test.ts +++ b/packages/core-internal/test/types/errorSurfacePins.test.ts @@ -86,7 +86,8 @@ describe('SdkErrorCode', () => { ClientHttpForbidden: 'CLIENT_HTTP_FORBIDDEN', ClientHttpUnexpectedContent: 'CLIENT_HTTP_UNEXPECTED_CONTENT', ClientHttpFailedToOpenStream: 'CLIENT_HTTP_FAILED_TO_OPEN_STREAM', - ClientHttpFailedToTerminateSession: 'CLIENT_HTTP_FAILED_TO_TERMINATE_SESSION' + ClientHttpFailedToTerminateSession: 'CLIENT_HTTP_FAILED_TO_TERMINATE_SESSION', + ClientHttpSessionExpired: 'CLIENT_HTTP_SESSION_EXPIRED' }); }); }); From 475e64fcb3b8281b88c1430c89176d27bcdbac66 Mon Sep 17 00:00:00 2001 From: Alaa Azazi Date: Thu, 13 Aug 2026 17:12:01 -0600 Subject: [PATCH 2/3] test(client): correct and extend Streamable HTTP 404 session-expiry test coverage The existing 'should handle 404 response when session expires' test never established a session before sending the 404-triggering request, so despite its name it only ever exercised the generic ClientHttpNotImplemented fallback -- it did not cover session expiry at all. Renamed to describe what it actually tests and kept as regression coverage for the "404 with no active session" case, which is intentionally unchanged by this fix. Added: - The actual session-expiry case: establish a session, get a 404, assert ClientHttpSessionExpired is thrown, the session ID is cleared, and a subsequent request no longer carries a session ID. - A regression guard for the standalone GET SSE stream: a 404 there must not clear the session. - terminateSession() treating a 404 (session already gone) as success, mirror of the existing 405 test. --- .../client/test/client/streamableHttp.test.ts | 139 +++++++++++++++++- 1 file changed, 138 insertions(+), 1 deletion(-) diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index a36bbc0ad3..c715d59f20 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -340,7 +340,45 @@ describe('StreamableHTTPClientTransport', () => { await expect(transport.terminateSession()).resolves.not.toThrow(); }); - it('should handle 404 response when session expires', async () => { + it('should treat a 404 response as success when terminating an already-gone session', async () => { + // First, simulate getting a session ID + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client', version: '1.0' }, + capabilities: {}, + protocolVersion: '2025-03-26' + }, + id: 'init-id' + }; + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'test-session-id' }) + }); + + await transport.send(message); + + // Now terminate the session, but the server has already forgotten it (404) — + // this is exactly the caller's intent, not a failure. + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + headers: new Headers() + }); + + await expect(transport.terminateSession()).resolves.not.toThrow(); + expect(transport.sessionId).toBeUndefined(); + }); + + it('should surface a generic error for a 404 with no active session', async () => { + // No session has been established (no prior initialize), so this 404 is + // unrelated to session expiry per the MCP spec's scoping — it still surfaces as + // the pre-existing generic error, not ClientHttpSessionExpired. See the + // "session expires" tests below for the case where a session ID is present. const message: JSONRPCMessage = { jsonrpc: '2.0', method: 'test', @@ -367,6 +405,105 @@ describe('StreamableHTTPClientTransport', () => { }) ); expect(errorSpy).toHaveBeenCalled(); + expect(transport.sessionId).toBeUndefined(); + }); + + it('should clear the session ID and throw ClientHttpSessionExpired on a 404 to a session-bound request', async () => { + // Establish a session first, exactly like the "should store session ID + // received during initialization" test above. + const initMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client', version: '1.0' }, + capabilities: {}, + protocolVersion: '2025-03-26' + }, + id: 'init-id' + }; + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'test-session-id' }) + }); + await transport.send(initMessage); + expect(transport.sessionId).toBe('test-session-id'); + + // A later, session-bound request gets a 404: per the MCP spec (Streamable + // HTTP, Session Management), the session has expired server-side. + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + params: {}, + id: 'test-id' + }; + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + text: () => Promise.resolve('Session not found'), + headers: new Headers() + }); + + const errorSpy = vi.fn(); + transport.onerror = errorSpy; + + await expect(transport.send(message)).rejects.toThrow( + new SdkHttpError(SdkErrorCode.ClientHttpSessionExpired, 'Session expired (HTTP 404): Session not found', { + status: 404, + statusText: 'Not Found', + text: 'Session not found' + }) + ); + expect(errorSpy).toHaveBeenCalled(); + + // The dead session ID is cleared so a subsequent connect() starts fresh. + expect(transport.sessionId).toBeUndefined(); + + // And a subsequent request no longer carries the stale (or any) session ID. + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers() + }); + await transport.send({ jsonrpc: '2.0', method: 'test2', params: {} } as JSONRPCMessage); + const lastCall = (globalThis.fetch as Mock).mock.calls.at(-1)!; + expect(lastCall[1].headers.get('mcp-session-id')).toBeNull(); + }); + + it('should not clear the session ID on a 404 from the standalone GET SSE stream', async () => { + // The standalone GET SSE stream is the optional server->client notification + // channel. A 404 on its (re)connection must not tear down an otherwise-healthy + // session — the client should keep the session and continue issuing POST + // requests. Session-expiry detection is scoped to the POST path (_send) only. + const initMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client', version: '1.0' }, + capabilities: {}, + protocolVersion: '2025-03-26' + }, + id: 'init-id' + }; + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'test-session-id' }) + }); + await transport.send(initMessage); + expect(transport.sessionId).toBe('test-session-id'); + + (globalThis.fetch as Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + text: () => Promise.resolve('Not Found'), + headers: new Headers() + }); + + await expect(transport.resumeStream('some-event-id')).rejects.toThrow(SdkHttpError); + expect(transport.sessionId).toBe('test-session-id'); }); it('should handle non-streaming JSON response', async () => { From 9a88b3f2d9ef5925f461963c7823386d633337ac Mon Sep 17 00:00:00 2001 From: Alaa Azazi Date: Thu, 13 Aug 2026 17:12:06 -0600 Subject: [PATCH 3/3] chore(client): add changeset for Streamable HTTP 404 session-expiry fix #2125 (which this rebases) never included one. --- .changeset/streamable-http-404-session-expiry.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/streamable-http-404-session-expiry.md diff --git a/.changeset/streamable-http-404-session-expiry.md b/.changeset/streamable-http-404-session-expiry.md new file mode 100644 index 0000000000..05bab8b255 --- /dev/null +++ b/.changeset/streamable-http-404-session-expiry.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Clear the session ID and throw a distinguishable `SdkErrorCode.ClientHttpSessionExpired` error when the server returns HTTP 404 to a session-bound Streamable HTTP request, per the MCP spec's Session Management requirements. `terminateSession()` now also treats a 404 (session already gone) the same as the existing 405 (termination unsupported) case, resolving instead of throwing.