Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/streamable-http-404-session-expiry.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 33 additions & 3 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}`,
Expand Down
139 changes: 138 additions & 1 deletion packages/client/test/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 () => {
Expand Down
13 changes: 12 additions & 1 deletion packages/core-internal/src/errors/sdkErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
});
});
});
Expand Down
Loading