From 54e98208a6ce69b81381e0fb5cbc4b267eed3b5d Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Tue, 10 Feb 2026 13:35:01 -0800 Subject: [PATCH 1/5] test(server): add roots/list test coverage Add 6 tests for roots/list bidirectional request flow: - Successful request/response with client handler - Empty roots list handling - Multiple roots array - Optional name and _meta fields - sendRootsListChanged notification - Handler context verification --- test/integration/test/server.test.ts | 326 +++++++++++++++++++++++++++ 1 file changed, 326 insertions(+) diff --git a/test/integration/test/server.test.ts b/test/integration/test/server.test.ts index 436b4427a..ad0abe15e 100644 --- a/test/integration/test/server.test.ts +++ b/test/integration/test/server.test.ts @@ -325,6 +325,332 @@ test('should respect client capabilities', async () => { await expect(server.listRoots()).rejects.toThrow(/Client does not support/); }); +describe('roots/list', () => { + test('should successfully list roots when client supports roots capability', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + roots: {} + } + } + ); + + // Register handler for roots/list + client.setRequestHandler('roots/list', async () => { + return { + roots: [ + { + uri: 'file:///home/user/project', + name: 'My Project' + } + ] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.listRoots(); + + expect(result.roots).toHaveLength(1); + expect(result.roots[0]).toEqual({ + uri: 'file:///home/user/project', + name: 'My Project' + }); + }); + + test('should handle empty roots list', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + roots: {} + } + } + ); + + // Return empty roots list + client.setRequestHandler('roots/list', async () => { + return { + roots: [] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.listRoots(); + + expect(result.roots).toHaveLength(0); + expect(result.roots).toEqual([]); + }); + + test('should handle multiple roots', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + roots: {} + } + } + ); + + const expectedRoots = [ + { uri: 'file:///home/user/project1', name: 'Project 1' }, + { uri: 'file:///home/user/project2', name: 'Project 2' }, + { uri: 'file:///var/data/shared' } + ]; + + client.setRequestHandler('roots/list', async () => { + return { roots: expectedRoots }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.listRoots(); + + expect(result.roots).toHaveLength(3); + expect(result.roots).toEqual(expectedRoots); + }); + + test('should handle roots with optional name and _meta fields', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + roots: {} + } + } + ); + + const expectedRoots = [ + // Root with all optional fields + { + uri: 'file:///home/user/project', + name: 'Full Project', + _meta: { + type: 'workspace', + priority: 1 + } + }, + // Root with only uri (minimal) + { + uri: 'file:///tmp/scratch' + }, + // Root with name but no _meta + { + uri: 'file:///var/logs', + name: 'Log Directory' + } + ]; + + client.setRequestHandler('roots/list', async () => { + return { roots: expectedRoots }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await server.listRoots(); + + expect(result.roots).toHaveLength(3); + expect(result.roots[0]).toEqual({ + uri: 'file:///home/user/project', + name: 'Full Project', + _meta: { + type: 'workspace', + priority: 1 + } + }); + expect(result.roots[1]).toEqual({ + uri: 'file:///tmp/scratch' + }); + expect(result.roots[2]).toEqual({ + uri: 'file:///var/logs', + name: 'Log Directory' + }); + }); + + test('should send roots list changed notification', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + roots: { + listChanged: true + } + } + } + ); + + // Track if notification was received + let notificationReceived = false; + + server.setNotificationHandler('notifications/roots/list_changed', async () => { + notificationReceived = true; + }); + + client.setRequestHandler('roots/list', async () => { + return { roots: [] }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Send the notification + await client.sendRootsListChanged(); + + // Give a moment for the notification to be processed + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(notificationReceived).toBe(true); + }); + + test('should pass context to roots/list handler', async () => { + const server = new Server( + { + name: 'test server', + version: '1.0' + }, + { + capabilities: { + prompts: {}, + resources: {}, + tools: {}, + logging: {} + }, + enforceStrictCapabilities: true + } + ); + + const client = new Client( + { + name: 'test client', + version: '1.0' + }, + { + capabilities: { + roots: {} + } + } + ); + + let capturedContext: unknown = null; + + client.setRequestHandler('roots/list', async (_request, ctx) => { + capturedContext = ctx; + return { roots: [] }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await server.listRoots(); + + expect(capturedContext).not.toBeNull(); + expect(capturedContext).toHaveProperty('sessionId'); + expect(capturedContext).toHaveProperty('mcpReq'); + expect((capturedContext as { mcpReq: { signal: AbortSignal } }).mcpReq).toHaveProperty('signal'); + }); +}); + test('should respect client elicitation capabilities', async () => { const server = new Server( { From be20cd24910fce77c7d7423324dcc090c6604dba Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Tue, 10 Feb 2026 14:50:01 -0800 Subject: [PATCH 2/5] 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: https://github.com/modelcontextprotocol/typescript-sdk/issues/1167 --- packages/client/src/client/streamableHttp.ts | 11 +++++ .../client/test/client/streamableHttp.test.ts | 34 ++++++++++++++ .../stateManagementStreamableHttp.test.ts | 46 +++++++++++++++++++ 3 files changed, 91 insertions(+) diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 22cd417bd..aed8489cb 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -141,6 +141,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; + private _sseStreamOpened = false; // Track if SSE stream was successfully opened onclose?: () => void; onerror?: (error: Error) => void; @@ -247,6 +248,7 @@ export class StreamableHTTPClientTransport implements Transport { }); } + this._sseStreamOpened = true; this._handleSseStream(response.body, options, true); } catch (error) { this.onerror?.(error as Error); @@ -486,10 +488,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(error => this.onerror?.(error)); + } + if (!response.ok) { const text = await response.text?.().catch(() => null); diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 8a550feae..b852fe8dc 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -104,8 +104,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 + (globalThis.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 (globalThis.fetch as Mock).mockResolvedValueOnce({ ok: true, @@ -140,7 +151,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 + (globalThis.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 @@ -180,8 +203,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 + (globalThis.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 (globalThis.fetch as Mock).mockResolvedValueOnce({ ok: false, diff --git a/test/integration/test/stateManagementStreamableHttp.test.ts b/test/integration/test/stateManagementStreamableHttp.test.ts index 6844724a6..ae1a3aa91 100644 --- a/test/integration/test/stateManagementStreamableHttp.test.ts +++ b/test/integration/test/stateManagementStreamableHttp.test.ts @@ -352,6 +352,52 @@ describe('Zod v4', () => { // 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('roots/list', 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((_, 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(); + }); }); }); }); From df954566ff7c2d71677faadf1d492e3c0815b867 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Tue, 10 Feb 2026 15:04:41 -0800 Subject: [PATCH 3/5] chore: rerun ci From 88db838f3c0fc1ac74ae1ba7ebcee712bc177859 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Tue, 31 Mar 2026 14:38:20 -0400 Subject: [PATCH 4/5] fix(client): await SSE stream open in connect() to prevent race condition After sending notifications/initialized, await the GET SSE stream opening so connect() does not resolve until the server can deliver requests (e.g. roots/list) through it. Previously the stream was opened fire-and-forget, meaning the server could attempt to send before the client was listening. --- packages/client/src/client/streamableHttp.ts | 19 ++++------- .../client/test/client/streamableHttp.test.ts | 33 ------------------- 2 files changed, 6 insertions(+), 46 deletions(-) diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index c08521e7a..7c45c4304 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -184,7 +184,6 @@ export class StreamableHTTPClientTransport implements Transport { private _serverRetryMs?: number; // Server-provided retry delay from SSE retry field private readonly _reconnectionScheduler?: ReconnectionScheduler; private _cancelReconnection?: () => void; - private _sseStreamOpened = false; // Track if SSE stream was successfully opened onclose?: () => void; onerror?: (error: Error) => void; @@ -293,7 +292,6 @@ export class StreamableHTTPClientTransport implements Transport { }); } - this._sseStreamOpened = true; this._handleSseStream(response.body, options, true); } catch (error) { this.onerror?.(error as Error); @@ -554,19 +552,10 @@ 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(error => this.onerror?.(error)); - } - if (!response.ok) { if (response.status === 401 && this._authProvider) { // Store WWW-Authenticate params for interactive finishAuth() path @@ -650,8 +639,12 @@ export class StreamableHTTPClientTransport implements Transport { // if the accepted notification is initialized, we start the SSE stream // if it's supported by the server if (isInitializedNotification(message)) { - // Start without a lastEventId since this is a fresh connection - this._startOrAuthSse({ resumptionToken: undefined }).catch(error => this.onerror?.(error)); + // Await the SSE stream opening so that connect() does not resolve + // until the GET listener is established. This prevents a race where + // the server sends a request (e.g. roots/list) before the stream is ready. + // Errors are swallowed here because _startOrAuthSse already reports + // them via onerror, and the SSE stream is optional (server may 405). + await this._startOrAuthSse({ resumptionToken: undefined }).catch(() => {}); } return; } diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 8774df5f8..e1c76e945 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -104,19 +104,8 @@ 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 - (globalThis.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 (globalThis.fetch as Mock).mockResolvedValueOnce({ ok: true, @@ -178,19 +167,8 @@ 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 - (globalThis.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 @@ -230,19 +208,8 @@ 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 - (globalThis.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 (globalThis.fetch as Mock).mockResolvedValueOnce({ ok: false, From 47f27a281b68d270c3814424f7c372c70098280b Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Mon, 6 Apr 2026 11:53:13 -0700 Subject: [PATCH 5/5] Revert "fix(client): await SSE stream open in connect() to prevent race condition" This reverts commit 88db838f3c0fc1ac74ae1ba7ebcee712bc177859. --- packages/client/src/client/streamableHttp.ts | 19 +++++++---- .../client/test/client/streamableHttp.test.ts | 33 +++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 7c45c4304..c08521e7a 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -184,6 +184,7 @@ export class StreamableHTTPClientTransport implements Transport { private _serverRetryMs?: number; // Server-provided retry delay from SSE retry field private readonly _reconnectionScheduler?: ReconnectionScheduler; private _cancelReconnection?: () => void; + private _sseStreamOpened = false; // Track if SSE stream was successfully opened onclose?: () => void; onerror?: (error: Error) => void; @@ -292,6 +293,7 @@ export class StreamableHTTPClientTransport implements Transport { }); } + this._sseStreamOpened = true; this._handleSseStream(response.body, options, true); } catch (error) { this.onerror?.(error as Error); @@ -552,10 +554,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(error => this.onerror?.(error)); + } + if (!response.ok) { if (response.status === 401 && this._authProvider) { // Store WWW-Authenticate params for interactive finishAuth() path @@ -639,12 +650,8 @@ export class StreamableHTTPClientTransport implements Transport { // if the accepted notification is initialized, we start the SSE stream // if it's supported by the server if (isInitializedNotification(message)) { - // Await the SSE stream opening so that connect() does not resolve - // until the GET listener is established. This prevents a race where - // the server sends a request (e.g. roots/list) before the stream is ready. - // Errors are swallowed here because _startOrAuthSse already reports - // them via onerror, and the SSE stream is optional (server may 405). - await this._startOrAuthSse({ resumptionToken: undefined }).catch(() => {}); + // Start without a lastEventId since this is a fresh connection + this._startOrAuthSse({ resumptionToken: undefined }).catch(error => this.onerror?.(error)); } return; } diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index e1c76e945..8774df5f8 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -104,8 +104,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 + (globalThis.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 (globalThis.fetch as Mock).mockResolvedValueOnce({ ok: true, @@ -167,8 +178,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 + (globalThis.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 @@ -208,8 +230,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 + (globalThis.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 (globalThis.fetch as Mock).mockResolvedValueOnce({ ok: false,