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/gentle-maps-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

Clear stale Streamable HTTP client sessions when a session-bound request receives HTTP 404, and tag the thrown SDK error as recoverable (`sessionExpired: true`) so callers can reconnect and re-initialize.
26 changes: 25 additions & 1 deletion packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,13 +207,26 @@ export class StreamableHTTPClientTransport implements Transport {
});
}

private _sessionExpiredError(text: string | null): SdkError {
return new SdkError(
SdkErrorCode.ClientHttpNotImplemented,
'Session expired (HTTP 404). Cleared session ID; reconnect and re-initialize.',
{
status: 404,
text,
sessionExpired: true
}
);
}

private async _startOrAuthSse(options: StartSSEOptions): Promise<void> {
const { resumptionToken } = options;

try {
// Try to open an initial SSE stream with GET to listen for server messages
// This is optional according to the spec - server may not support it
const headers = await this._commonHeaders();
const sentSessionId = headers.has('mcp-session-id');
headers.set('Accept', 'text/event-stream');

// Include Last-Event-ID header for resumable streams if provided
Expand All @@ -229,13 +242,18 @@ export class StreamableHTTPClientTransport implements Transport {
});

if (!response.ok) {
await response.text?.().catch(() => {});
const text = await response.text?.().catch(() => null);

if (response.status === 401 && this._authProvider) {
// Need to authenticate
return await this._authThenStart();
}

if (response.status === 404 && sentSessionId) {
this._sessionId = undefined;
throw this._sessionExpiredError(text);
}

// 405 indicates that the server does not offer an SSE stream at GET endpoint
// This is an expected case that should not trigger an error
if (response.status === 405) {
Expand Down Expand Up @@ -472,6 +490,7 @@ export class StreamableHTTPClientTransport implements Transport {
}

const headers = await this._commonHeaders();
const sentSessionId = headers.has('mcp-session-id');
headers.set('content-type', 'application/json');
headers.set('accept', 'application/json, text/event-stream');

Expand All @@ -494,6 +513,11 @@ export class StreamableHTTPClientTransport implements Transport {
if (!response.ok) {
const text = await response.text?.().catch(() => null);

if (response.status === 404 && sentSessionId) {
this._sessionId = undefined;
throw this._sessionExpiredError(text);
}

if (response.status === 401 && this._authProvider) {
// Prevent infinite recursion when server returns 401 after successful auth
if (this._hasCompletedAuthFlow) {
Expand Down
91 changes: 90 additions & 1 deletion packages/client/test/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ describe('StreamableHTTPClientTransport', () => {
await expect(transport.terminateSession()).resolves.not.toThrow();
});

it('should handle 404 response when session expires', async () => {
it('should preserve existing 404 behavior when request is not session-bound', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'test',
Expand Down Expand Up @@ -221,6 +221,63 @@ describe('StreamableHTTPClientTransport', () => {
expect(errorSpy).toHaveBeenCalled();
});

it('should clear session ID and mark 404 as recoverable for session-bound POST requests', async () => {
const initializeMessage: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'initialize',
params: {
clientInfo: { name: 'test-client', version: '1.0' },
protocolVersion: '2025-03-26'
},
id: 'init-id'
};
const message: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'tools/list',
params: {},
id: 'test-id'
};

(globalThis.fetch as Mock)
.mockResolvedValueOnce({
ok: true,
status: 202,
headers: new Headers({ 'mcp-session-id': 'stale-session-id' }),
text: () => Promise.resolve('')
})
.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
text: () => Promise.resolve('Session not found'),
headers: new Headers()
})
.mockResolvedValueOnce({
ok: true,
status: 202,
headers: new Headers(),
text: () => Promise.resolve('')
});

await transport.send(initializeMessage);
expect(transport.sessionId).toBe('stale-session-id');

await expect(transport.send(message)).rejects.toMatchObject({
code: SdkErrorCode.ClientHttpNotImplemented,
message: 'Session expired (HTTP 404). Cleared session ID; reconnect and re-initialize.',
data: expect.objectContaining({
status: 404,
text: 'Session not found',
sessionExpired: true
})
});
expect(transport.sessionId).toBeUndefined();

await transport.send({ jsonrpc: '2.0', method: 'notifications/ping' } as JSONRPCMessage);
const lastCall = (globalThis.fetch as Mock).mock.calls.at(-1)!;
expect(lastCall[1].headers.get('mcp-session-id')).toBeNull();
});

it('should handle non-streaming JSON response', async () => {
const message: JSONRPCMessage = {
jsonrpc: '2.0',
Expand Down Expand Up @@ -282,6 +339,38 @@ describe('StreamableHTTPClientTransport', () => {
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});

it('should clear session ID when GET SSE stream returns 404 for a session-bound request', async () => {
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
sessionId: 'stale-session-id'
});
await transport.start();

(globalThis.fetch as Mock).mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
text: () => Promise.resolve('Session not found'),
headers: new Headers()
});

await expect(
(transport as unknown as { _startOrAuthSse: (opts: StartSSEOptions) => Promise<void> })._startOrAuthSse({})
).rejects.toMatchObject({
code: SdkErrorCode.ClientHttpNotImplemented,
data: expect.objectContaining({
status: 404,
text: 'Session not found',
sessionExpired: true
})
});

expect(transport.sessionId).toBeUndefined();

const getCall = (globalThis.fetch as Mock).mock.calls[0]!;
expect(getCall[1].method).toBe('GET');
expect(getCall[1].headers.get('mcp-session-id')).toBe('stale-session-id');
});

it('should handle successful initial GET connection for SSE', async () => {
// Set up readable stream for SSE events
const encoder = new TextEncoder();
Expand Down
Loading