diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 79a20adfc..28c6610ae 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/server.test.ts b/test/integration/test/server.test.ts index ef7cd931e..9aa4a1442 100644 --- a/test/integration/test/server.test.ts +++ b/test/integration/test/server.test.ts @@ -329,6 +329,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( { 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(); + }); }); }); });