Skip to content

Commit 147ca2a

Browse files
committed
fix(client): clear stale session on HTTP 404 in streamable transport
# Conflicts: # packages/client/src/client/streamableHttp.ts
1 parent babaa50 commit 147ca2a

2 files changed

Lines changed: 98 additions & 1 deletion

File tree

packages/client/src/client/streamableHttp.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,10 @@ export class StreamableHTTPClientTransport implements Transport {
252252
});
253253

254254
if (!response.ok) {
255+
if (response.status === 404 && this._sessionId !== undefined) {
256+
this._sessionId = undefined;
257+
}
258+
255259
if (response.status === 401 && this._authProvider) {
256260
if (response.headers.has('www-authenticate')) {
257261
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
@@ -557,6 +561,10 @@ export class StreamableHTTPClientTransport implements Transport {
557561
}
558562

559563
if (!response.ok) {
564+
if (response.status === 404 && this._sessionId !== undefined) {
565+
this._sessionId = undefined;
566+
}
567+
560568
if (response.status === 401 && this._authProvider) {
561569
// Store WWW-Authenticate params for interactive finishAuth() path
562570
if (response.headers.has('www-authenticate')) {

packages/client/test/client/streamableHttp.test.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ describe('StreamableHTTPClientTransport', () => {
220220
await expect(transport.terminateSession()).resolves.not.toThrow();
221221
});
222222

223-
it('should handle 404 response when session expires', async () => {
223+
it('should preserve existing 404 behavior when request is not session-bound', async () => {
224224
const message: JSONRPCMessage = {
225225
jsonrpc: '2.0',
226226
method: 'test',
@@ -248,6 +248,63 @@ describe('StreamableHTTPClientTransport', () => {
248248
expect(errorSpy).toHaveBeenCalled();
249249
});
250250

251+
it('should clear session ID and mark 404 as recoverable for session-bound POST requests', async () => {
252+
const initializeMessage: JSONRPCMessage = {
253+
jsonrpc: '2.0',
254+
method: 'initialize',
255+
params: {
256+
clientInfo: { name: 'test-client', version: '1.0' },
257+
protocolVersion: '2025-03-26'
258+
},
259+
id: 'init-id'
260+
};
261+
const message: JSONRPCMessage = {
262+
jsonrpc: '2.0',
263+
method: 'tools/list',
264+
params: {},
265+
id: 'test-id'
266+
};
267+
268+
(globalThis.fetch as Mock)
269+
.mockResolvedValueOnce({
270+
ok: true,
271+
status: 202,
272+
headers: new Headers({ 'mcp-session-id': 'stale-session-id' }),
273+
text: () => Promise.resolve('')
274+
})
275+
.mockResolvedValueOnce({
276+
ok: false,
277+
status: 404,
278+
statusText: 'Not Found',
279+
text: () => Promise.resolve('Session not found'),
280+
headers: new Headers()
281+
})
282+
.mockResolvedValueOnce({
283+
ok: true,
284+
status: 202,
285+
headers: new Headers(),
286+
text: () => Promise.resolve('')
287+
});
288+
289+
await transport.send(initializeMessage);
290+
expect(transport.sessionId).toBe('stale-session-id');
291+
292+
await expect(transport.send(message)).rejects.toMatchObject({
293+
code: SdkErrorCode.ClientHttpNotImplemented,
294+
message: 'Session expired (HTTP 404). Cleared session ID; reconnect and re-initialize.',
295+
data: expect.objectContaining({
296+
status: 404,
297+
text: 'Session not found',
298+
sessionExpired: true
299+
})
300+
});
301+
expect(transport.sessionId).toBeUndefined();
302+
303+
await transport.send({ jsonrpc: '2.0', method: 'notifications/ping' } as JSONRPCMessage);
304+
const lastCall = (globalThis.fetch as Mock).mock.calls.at(-1)!;
305+
expect(lastCall[1].headers.get('mcp-session-id')).toBeNull();
306+
});
307+
251308
it('should handle non-streaming JSON response', async () => {
252309
const message: JSONRPCMessage = {
253310
jsonrpc: '2.0',
@@ -309,6 +366,38 @@ describe('StreamableHTTPClientTransport', () => {
309366
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
310367
});
311368

369+
it('should clear session ID when GET SSE stream returns 404 for a session-bound request', async () => {
370+
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
371+
sessionId: 'stale-session-id'
372+
});
373+
await transport.start();
374+
375+
(globalThis.fetch as Mock).mockResolvedValueOnce({
376+
ok: false,
377+
status: 404,
378+
statusText: 'Not Found',
379+
text: () => Promise.resolve('Session not found'),
380+
headers: new Headers()
381+
});
382+
383+
await expect(
384+
(transport as unknown as { _startOrAuthSse: (opts: StartSSEOptions) => Promise<void> })._startOrAuthSse({})
385+
).rejects.toMatchObject({
386+
code: SdkErrorCode.ClientHttpNotImplemented,
387+
data: expect.objectContaining({
388+
status: 404,
389+
text: 'Session not found',
390+
sessionExpired: true
391+
})
392+
});
393+
394+
expect(transport.sessionId).toBeUndefined();
395+
396+
const getCall = (globalThis.fetch as Mock).mock.calls[0]!;
397+
expect(getCall[1].method).toBe('GET');
398+
expect(getCall[1].headers.get('mcp-session-id')).toBe('stale-session-id');
399+
});
400+
312401
it('should handle successful initial GET connection for SSE', async () => {
313402
// Set up readable stream for SSE events
314403
const encoder = new TextEncoder();

0 commit comments

Comments
 (0)