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. 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/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 () => { 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' }); }); });