Skip to content

Commit be20cd2

Browse files
committed
fix(client): retry SSE stream after receiving session ID
Fixes server-initiated requests (roots/list, sampling, elicitation) hanging over HTTP transport. The client now retries opening the GET SSE stream after receiving a session ID during initialization. Fixes: #1167
1 parent 54e9820 commit be20cd2

File tree

3 files changed

+91
-0
lines changed

3 files changed

+91
-0
lines changed

packages/client/src/client/streamableHttp.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ export class StreamableHTTPClientTransport implements Transport {
141141
private _lastUpscopingHeader?: string; // Track last upscoping header to prevent infinite upscoping.
142142
private _serverRetryMs?: number; // Server-provided retry delay from SSE retry field
143143
private _reconnectionTimeout?: ReturnType<typeof setTimeout>;
144+
private _sseStreamOpened = false; // Track if SSE stream was successfully opened
144145

145146
onclose?: () => void;
146147
onerror?: (error: Error) => void;
@@ -247,6 +248,7 @@ export class StreamableHTTPClientTransport implements Transport {
247248
});
248249
}
249250

251+
this._sseStreamOpened = true;
250252
this._handleSseStream(response.body, options, true);
251253
} catch (error) {
252254
this.onerror?.(error as Error);
@@ -486,10 +488,19 @@ export class StreamableHTTPClientTransport implements Transport {
486488

487489
// Handle session ID received during initialization
488490
const sessionId = response.headers.get('mcp-session-id');
491+
const hadSessionId = this._sessionId !== undefined;
489492
if (sessionId) {
490493
this._sessionId = sessionId;
491494
}
492495

496+
// If we just received a session ID for the first time and SSE stream is not open,
497+
// try to open it now. This handles the case where the initial SSE connection
498+
// during start() was rejected because the server wasn't initialized yet.
499+
// See: https://github.com/modelcontextprotocol/typescript-sdk/issues/1167
500+
if (sessionId && !hadSessionId && !this._sseStreamOpened) {
501+
this._startOrAuthSse({ resumptionToken: undefined }).catch(error => this.onerror?.(error));
502+
}
503+
493504
if (!response.ok) {
494505
const text = await response.text?.().catch(() => null);
495506

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,19 @@ describe('StreamableHTTPClientTransport', () => {
104104
headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'test-session-id' })
105105
});
106106

107+
// Mock the SSE stream GET request that happens after receiving session ID
108+
(globalThis.fetch as Mock).mockResolvedValueOnce({
109+
ok: false,
110+
status: 405,
111+
headers: new Headers(),
112+
body: { cancel: vi.fn() }
113+
});
114+
107115
await transport.send(message);
108116

117+
// Allow the async SSE connection attempt to complete
118+
await new Promise(resolve => setTimeout(resolve, 10));
119+
109120
// Send a second message that should include the session ID
110121
(globalThis.fetch as Mock).mockResolvedValueOnce({
111122
ok: true,
@@ -140,7 +151,19 @@ describe('StreamableHTTPClientTransport', () => {
140151
headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'test-session-id' })
141152
});
142153

154+
// Mock the SSE stream GET request that happens after receiving session ID
155+
(globalThis.fetch as Mock).mockResolvedValueOnce({
156+
ok: false,
157+
status: 405,
158+
headers: new Headers(),
159+
body: { cancel: vi.fn() }
160+
});
161+
143162
await transport.send(message);
163+
164+
// Allow the async SSE connection attempt to complete
165+
await new Promise(resolve => setTimeout(resolve, 10));
166+
144167
expect(transport.sessionId).toBe('test-session-id');
145168

146169
// Now terminate the session
@@ -180,8 +203,19 @@ describe('StreamableHTTPClientTransport', () => {
180203
headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'test-session-id' })
181204
});
182205

206+
// Mock the SSE stream GET request that happens after receiving session ID
207+
(globalThis.fetch as Mock).mockResolvedValueOnce({
208+
ok: false,
209+
status: 405,
210+
headers: new Headers(),
211+
body: { cancel: vi.fn() }
212+
});
213+
183214
await transport.send(message);
184215

216+
// Allow the async SSE connection attempt to complete
217+
await new Promise(resolve => setTimeout(resolve, 10));
218+
185219
// Now terminate the session, but server responds with 405
186220
(globalThis.fetch as Mock).mockResolvedValueOnce({
187221
ok: false,

test/integration/test/stateManagementStreamableHttp.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,52 @@ describe('Zod v4', () => {
352352
// Clean up
353353
await transport.close();
354354
});
355+
356+
it('should support server-initiated roots/list request', async () => {
357+
// This test reproduces GitHub issue #1167
358+
// https://github.com/modelcontextprotocol/typescript-sdk/issues/1167
359+
//
360+
// The bug: server.listRoots() hangs when using HTTP transport because:
361+
// 1. Client tries to open GET SSE stream before initialization
362+
// 2. Server rejects with 400 "Server not initialized"
363+
// 3. Client never retries opening SSE stream after initialization
364+
// 4. Server's send() silently returns when no SSE stream exists
365+
// 5. listRoots() promise never resolves
366+
367+
// Create client with roots capability
368+
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { roots: { listChanged: true } } });
369+
370+
// Register handler for roots/list requests from server
371+
client.setRequestHandler('roots/list', async () => {
372+
return {
373+
roots: [{ uri: 'file:///home/user/project', name: 'Test Project' }]
374+
};
375+
});
376+
377+
const transport = new StreamableHTTPClientTransport(baseUrl);
378+
await client.connect(transport);
379+
380+
// Verify client has session ID (stateful mode)
381+
expect(transport.sessionId).toBeDefined();
382+
383+
// Now try to call listRoots from the server
384+
const rootsPromise = mcpServer.server.listRoots();
385+
386+
// Use a short timeout to detect the hang
387+
const timeoutPromise = new Promise<never>((_, reject) => {
388+
setTimeout(() => reject(new Error('listRoots() timed out - SSE stream not working')), 2000);
389+
});
390+
391+
const result = await Promise.race([rootsPromise, timeoutPromise]);
392+
393+
expect(result.roots).toHaveLength(1);
394+
expect(result.roots[0]).toEqual({
395+
uri: 'file:///home/user/project',
396+
name: 'Test Project'
397+
});
398+
399+
await transport.close();
400+
});
355401
});
356402
});
357403
});

0 commit comments

Comments
 (0)