Skip to content

Commit 880bf68

Browse files
committed
fix(client): clear stale session on HTTP 404 in streamable transport
1 parent ccb78f2 commit 880bf68

File tree

2 files changed

+115
-2
lines changed

2 files changed

+115
-2
lines changed

packages/client/src/client/streamableHttp.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,13 +207,26 @@ export class StreamableHTTPClientTransport implements Transport {
207207
});
208208
}
209209

210+
private _sessionExpiredError(text: string | null): SdkError {
211+
return new SdkError(
212+
SdkErrorCode.ClientHttpNotImplemented,
213+
'Session expired (HTTP 404). Cleared session ID; reconnect and re-initialize.',
214+
{
215+
status: 404,
216+
text,
217+
sessionExpired: true
218+
}
219+
);
220+
}
221+
210222
private async _startOrAuthSse(options: StartSSEOptions): Promise<void> {
211223
const { resumptionToken } = options;
212224

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

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

231244
if (!response.ok) {
232-
await response.text?.().catch(() => {});
245+
const text = await response.text?.().catch(() => null);
233246

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

252+
if (response.status === 404 && sentSessionId) {
253+
this._sessionId = undefined;
254+
throw this._sessionExpiredError(text);
255+
}
256+
239257
// 405 indicates that the server does not offer an SSE stream at GET endpoint
240258
// This is an expected case that should not trigger an error
241259
if (response.status === 405) {
@@ -472,6 +490,7 @@ export class StreamableHTTPClientTransport implements Transport {
472490
}
473491

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

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

516+
if (response.status === 404 && sentSessionId) {
517+
this._sessionId = undefined;
518+
throw this._sessionExpiredError(text);
519+
}
520+
497521
if (response.status === 401 && this._authProvider) {
498522
// Prevent infinite recursion when server returns 401 after successful auth
499523
if (this._hasCompletedAuthFlow) {

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

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

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

224+
it('should clear session ID and mark 404 as recoverable for session-bound POST requests', async () => {
225+
const initializeMessage: JSONRPCMessage = {
226+
jsonrpc: '2.0',
227+
method: 'initialize',
228+
params: {
229+
clientInfo: { name: 'test-client', version: '1.0' },
230+
protocolVersion: '2025-03-26'
231+
},
232+
id: 'init-id'
233+
};
234+
const message: JSONRPCMessage = {
235+
jsonrpc: '2.0',
236+
method: 'tools/list',
237+
params: {},
238+
id: 'test-id'
239+
};
240+
241+
(globalThis.fetch as Mock)
242+
.mockResolvedValueOnce({
243+
ok: true,
244+
status: 202,
245+
headers: new Headers({ 'mcp-session-id': 'stale-session-id' }),
246+
text: () => Promise.resolve('')
247+
})
248+
.mockResolvedValueOnce({
249+
ok: false,
250+
status: 404,
251+
statusText: 'Not Found',
252+
text: () => Promise.resolve('Session not found'),
253+
headers: new Headers()
254+
})
255+
.mockResolvedValueOnce({
256+
ok: true,
257+
status: 202,
258+
headers: new Headers(),
259+
text: () => Promise.resolve('')
260+
});
261+
262+
await transport.send(initializeMessage);
263+
expect(transport.sessionId).toBe('stale-session-id');
264+
265+
await expect(transport.send(message)).rejects.toMatchObject({
266+
code: SdkErrorCode.ClientHttpNotImplemented,
267+
message: 'Session expired (HTTP 404). Cleared session ID; reconnect and re-initialize.',
268+
data: expect.objectContaining({
269+
status: 404,
270+
text: 'Session not found',
271+
sessionExpired: true
272+
})
273+
});
274+
expect(transport.sessionId).toBeUndefined();
275+
276+
await transport.send({ jsonrpc: '2.0', method: 'notifications/ping' } as JSONRPCMessage);
277+
const lastCall = (globalThis.fetch as Mock).mock.calls.at(-1)!;
278+
expect(lastCall[1].headers.get('mcp-session-id')).toBeNull();
279+
});
280+
224281
it('should handle non-streaming JSON response', async () => {
225282
const message: JSONRPCMessage = {
226283
jsonrpc: '2.0',
@@ -282,6 +339,38 @@ describe('StreamableHTTPClientTransport', () => {
282339
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
283340
});
284341

342+
it('should clear session ID when GET SSE stream returns 404 for a session-bound request', async () => {
343+
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
344+
sessionId: 'stale-session-id'
345+
});
346+
await transport.start();
347+
348+
(globalThis.fetch as Mock).mockResolvedValueOnce({
349+
ok: false,
350+
status: 404,
351+
statusText: 'Not Found',
352+
text: () => Promise.resolve('Session not found'),
353+
headers: new Headers()
354+
});
355+
356+
await expect(
357+
(transport as unknown as { _startOrAuthSse: (opts: StartSSEOptions) => Promise<void> })._startOrAuthSse({})
358+
).rejects.toMatchObject({
359+
code: SdkErrorCode.ClientHttpNotImplemented,
360+
data: expect.objectContaining({
361+
status: 404,
362+
text: 'Session not found',
363+
sessionExpired: true
364+
})
365+
});
366+
367+
expect(transport.sessionId).toBeUndefined();
368+
369+
const getCall = (globalThis.fetch as Mock).mock.calls[0]!;
370+
expect(getCall[1].method).toBe('GET');
371+
expect(getCall[1].headers.get('mcp-session-id')).toBe('stale-session-id');
372+
});
373+
285374
it('should handle successful initial GET connection for SSE', async () => {
286375
// Set up readable stream for SSE events
287376
const encoder = new TextEncoder();

0 commit comments

Comments
 (0)