Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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 by clearing the stored session ID, so the next initialize flow can proceed without an MCP session header.
10 changes: 10 additions & 0 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ export class StreamableHTTPClientTransport implements Transport {
// 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 sentWithSession = headers.has('mcp-session-id');
headers.set('Accept', 'text/event-stream');

// Include Last-Event-ID header for resumable streams if provided
Expand All @@ -252,6 +253,10 @@ export class StreamableHTTPClientTransport implements Transport {
});

if (!response.ok) {
if (response.status === 404 && sentWithSession) {
this._sessionId = undefined;
}

if (response.status === 401 && this._authProvider) {
if (response.headers.has('www-authenticate')) {
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
Comment thread
Maverick-666 marked this conversation as resolved.
Expand Down Expand Up @@ -537,6 +542,7 @@ export class StreamableHTTPClientTransport implements Transport {
}

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

Expand All @@ -557,6 +563,10 @@ export class StreamableHTTPClientTransport implements Transport {
}

if (!response.ok) {
if (response.status === 404 && sentWithSession) {
this._sessionId = undefined;
}
Comment thread
Maverick-666 marked this conversation as resolved.

if (response.status === 401 && this._authProvider) {
// Store WWW-Authenticate params for interactive finishAuth() path
if (response.headers.has('www-authenticate')) {
Comment thread
Maverick-666 marked this conversation as resolved.
Expand Down
88 changes: 87 additions & 1 deletion packages/client/test/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,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 @@ -248,6 +248,61 @@ describe('StreamableHTTPClientTransport', () => {
expect(errorSpy).toHaveBeenCalled();
});

it('should clear session ID on 404 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,
data: expect.objectContaining({
status: 404,
text: 'Session not found'
})
});
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 @@ -309,6 +364,37 @@ 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.ClientHttpFailedToOpenStream,
data: expect.objectContaining({
status: 404,
statusText: 'Not Found'
})
});

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