Skip to content

Commit 34869be

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 34869be

File tree

2 files changed

+57
-0
lines changed

2 files changed

+57
-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

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)