Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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