diff --git a/.changeset/fix-transport-restart-after-close.md b/.changeset/fix-transport-restart-after-close.md new file mode 100644 index 000000000..a8f89445c --- /dev/null +++ b/.changeset/fix-transport-restart-after-close.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Allow `StreamableHTTPClientTransport` and `SSEClientTransport` to restart after `close()`. `close()` now clears `_abortController` (previously aborted but not unset, blocking the start guard) and `_sessionId` (previously leaked into post-restart requests, causing 404s). diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index f441e9cdb..a608d50cf 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -241,7 +241,10 @@ export class SSEClientTransport implements Transport { async close(): Promise { this._abortController?.abort(); + this._abortController = undefined; this._eventSource?.close(); + this._eventSource = undefined; + this._endpoint = undefined; this.onclose?.(); } diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 56cbb4d98..174ef42ab 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -474,7 +474,7 @@ export class StreamableHTTPClientTransport implements Transport { } async start() { - if (this._abortController) { + if (this._abortController && !this._abortController.signal.aborted) { throw new Error( 'StreamableHTTPClientTransport already started! If using Client class, note that connect() calls start() automatically.' ); @@ -509,6 +509,7 @@ export class StreamableHTTPClientTransport implements Transport { } finally { this._cancelReconnection = undefined; this._abortController?.abort(); + this._sessionId = undefined; this.onclose?.(); } } diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 4a23e6db4..6704ae189 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -1924,4 +1924,47 @@ describe('StreamableHTTPClientTransport', () => { expect(onclose).toHaveBeenCalledTimes(1); }); }); + + describe('Transport restart after close()', () => { + it('should allow start() after close() and not send stale session ID', async () => { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + + const fetchMock = globalThis.fetch as Mock; + + // First lifecycle: start, receive a session ID, close + await transport.start(); + + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ + 'content-type': 'application/json', + 'mcp-session-id': 'stale-session-abc' + }), + json: async () => ({ jsonrpc: '2.0', result: {}, id: 'init-1' }) + }); + + await transport.send({ jsonrpc: '2.0', method: 'initialize', params: {}, id: 'init-1' }); + expect(transport.sessionId).toBe('stale-session-abc'); + + await transport.close(); + expect(transport.sessionId).toBeUndefined(); + + // Second lifecycle: start() should not throw + await transport.start(); + + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers(), + text: async () => '' + }); + + await transport.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + // The post-restart request must NOT include the stale session ID + const postRestartHeaders = fetchMock.mock.calls[1]![1]?.headers as Headers; + expect(postRestartHeaders.get('mcp-session-id')).toBeNull(); + }); + }); });