Skip to content
11 changes: 11 additions & 0 deletions src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export class StreamableHTTPClientTransport implements Transport {
private _lastUpscopingHeader?: string; // Track last upscoping header to prevent infinite upscoping.
private _serverRetryMs?: number; // Server-provided retry delay from SSE retry field
private _reconnectionTimeout?: ReturnType<typeof setTimeout>;
private _sseStreamOpened = false; // Track if SSE stream was successfully opened

onclose?: () => void;
onerror?: (error: Error) => void;
Expand Down Expand Up @@ -240,6 +241,7 @@ export class StreamableHTTPClientTransport implements Transport {
throw new StreamableHTTPError(response.status, `Failed to open SSE stream: ${response.statusText}`);
}

this._sseStreamOpened = true;
this._handleSseStream(response.body, options, true);
} catch (error) {
this.onerror?.(error as Error);
Expand Down Expand Up @@ -479,10 +481,19 @@ export class StreamableHTTPClientTransport implements Transport {

// Handle session ID received during initialization
const sessionId = response.headers.get('mcp-session-id');
const hadSessionId = this._sessionId !== undefined;
if (sessionId) {
this._sessionId = sessionId;
}

// If we just received a session ID for the first time and SSE stream is not open,
// try to open it now. This handles the case where the initial SSE connection
// during start() was rejected because the server wasn't initialized yet.
// See: https://github.com/modelcontextprotocol/typescript-sdk/issues/1167
if (sessionId && !hadSessionId && !this._sseStreamOpened) {
this._startOrAuthSse({ resumptionToken: undefined }).catch(err => this.onerror?.(err));
}

if (!response.ok) {
const text = await response.text().catch(() => null);

Expand Down
34 changes: 34 additions & 0 deletions test/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,19 @@ describe('StreamableHTTPClientTransport', () => {
headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'test-session-id' })
});

// Mock the SSE stream GET request that happens after receiving session ID
(global.fetch as Mock).mockResolvedValueOnce({
ok: false,
status: 405,
headers: new Headers(),
body: { cancel: vi.fn() }
});

await transport.send(message);

// Allow the async SSE connection attempt to complete
await new Promise(resolve => setTimeout(resolve, 10));

// Send a second message that should include the session ID
(global.fetch as Mock).mockResolvedValueOnce({
ok: true,
Expand Down Expand Up @@ -137,7 +148,19 @@ describe('StreamableHTTPClientTransport', () => {
headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'test-session-id' })
});

// Mock the SSE stream GET request that happens after receiving session ID
(global.fetch as Mock).mockResolvedValueOnce({
ok: false,
status: 405,
headers: new Headers(),
body: { cancel: vi.fn() }
});

await transport.send(message);

// Allow the async SSE connection attempt to complete
await new Promise(resolve => setTimeout(resolve, 10));

expect(transport.sessionId).toBe('test-session-id');

// Now terminate the session
Expand Down Expand Up @@ -177,8 +200,19 @@ describe('StreamableHTTPClientTransport', () => {
headers: new Headers({ 'content-type': 'text/event-stream', 'mcp-session-id': 'test-session-id' })
});

// Mock the SSE stream GET request that happens after receiving session ID
(global.fetch as Mock).mockResolvedValueOnce({
ok: false,
status: 405,
headers: new Headers(),
body: { cancel: vi.fn() }
});

await transport.send(message);

// Allow the async SSE connection attempt to complete
await new Promise(resolve => setTimeout(resolve, 10));

// Now terminate the session, but server responds with 405
(global.fetch as Mock).mockResolvedValueOnce({
ok: false,
Expand Down
47 changes: 47 additions & 0 deletions test/integration-tests/stateManagementStreamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ListToolsResultSchema,
ListResourcesResultSchema,
ListPromptsResultSchema,
ListRootsRequestSchema,
LATEST_PROTOCOL_VERSION
} from '../../src/types.js';
import { zodTestMatrix, type ZodMatrixEntry } from '../../src/__fixtures__/zodTestMatrix.js';
Expand Down Expand Up @@ -376,6 +377,52 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
// Clean up
await transport.close();
});

it('should support server-initiated roots/list request', async () => {
// This test reproduces GitHub issue #1167
// https://github.com/modelcontextprotocol/typescript-sdk/issues/1167
//
// The bug: server.listRoots() hangs when using HTTP transport because:
// 1. Client tries to open GET SSE stream before initialization
// 2. Server rejects with 400 "Server not initialized"
// 3. Client never retries opening SSE stream after initialization
// 4. Server's send() silently returns when no SSE stream exists
// 5. listRoots() promise never resolves

// Create client with roots capability
const client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { roots: { listChanged: true } } });

// Register handler for roots/list requests from server
client.setRequestHandler(ListRootsRequestSchema, async () => {
return {
roots: [{ uri: 'file:///home/user/project', name: 'Test Project' }]
};
});

const transport = new StreamableHTTPClientTransport(baseUrl);
await client.connect(transport);

// Verify client has session ID (stateful mode)
expect(transport.sessionId).toBeDefined();

// Now try to call listRoots from the server
const rootsPromise = mcpServer!.server.listRoots();

// Use a short timeout to detect the hang
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('listRoots() timed out - SSE stream not working')), 2000);
});

const result = await Promise.race([rootsPromise, timeoutPromise]);

expect(result.roots).toHaveLength(1);
expect(result.roots[0]).toEqual({
uri: 'file:///home/user/project',
name: 'Test Project'
});

await transport.close();
});
});
});
});
Loading
Loading