diff --git a/.changeset/streamable-http-restart-close.md b/.changeset/streamable-http-restart-close.md new file mode 100644 index 0000000000..2e85312304 --- /dev/null +++ b/.changeset/streamable-http-restart-close.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Allow StreamableHTTPClientTransport to be restarted after close by clearing its aborted controller. diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index ace0663158..9d2b08412f 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -524,7 +524,8 @@ export class StreamableHTTPClientTransport implements Transport { // `StartSSEOptions`) must honour the per-request abort exactly as the // original POST did — both as a fetch signal and as a "do not surface // onerror" gate. - const isIntentionalAbort = (): boolean => this._abortController?.signal.aborted === true || requestSignal?.aborted === true; + const transportSignal = this._abortController?.signal; + const isIntentionalAbort = (): boolean => transportSignal?.aborted === true || requestSignal?.aborted === true; try { // Try to open an initial SSE stream with GET to listen for server messages @@ -683,9 +684,9 @@ export class StreamableHTTPClientTransport implements Transport { // Honour BOTH the transport-wide abort and the per-request abort // (a listen subscription closed during the backoff delay): do not // resurrect a stream the caller already tore down. - if (this._abortController?.signal.aborted || options.requestSignal?.aborted) return; + if (!this._abortController || this._abortController.signal.aborted || options.requestSignal?.aborted) return; this._startOrAuthSse(options).catch(error => { - if (this._abortController?.signal.aborted || options.requestSignal?.aborted) return; + if (!this._abortController || this._abortController.signal.aborted || options.requestSignal?.aborted) return; this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`)); try { this._scheduleReconnection(options, attemptCount + 1); @@ -719,7 +720,8 @@ export class StreamableHTTPClientTransport implements Transport { // a clean shutdown: no misleading "SSE stream disconnected" onerror, // and no GET+Last-Event-ID reconnect that would resurrect a stream the // caller just tore down. - const isIntentionalAbort = (): boolean => this._abortController?.signal.aborted === true || requestSignal?.aborted === true; + const transportSignal = this._abortController?.signal; + const isIntentionalAbort = (): boolean => transportSignal?.aborted === true || requestSignal?.aborted === true; let lastEventId: string | undefined; // Track whether we've received a priming event (event with ID) @@ -914,6 +916,7 @@ export class StreamableHTTPClientTransport implements Transport { } finally { this._cancelReconnection = undefined; this._abortController?.abort(); + this._abortController = undefined; this.onclose?.(); } } diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index a36bbc0ad3..6cc4ffe6fe 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -267,6 +267,18 @@ describe('StreamableHTTPClientTransport', () => { await reconnectTransport.close().catch(() => {}); }); + it('can be started again after close()', async () => { + await transport.start(); + const firstAbortController = transport['_abortController']; + + await transport.close(); + + expect(firstAbortController?.signal.aborted).toBe(true); + expect(transport['_abortController']).toBeUndefined(); + await expect(transport.start()).resolves.toBeUndefined(); + expect(transport['_abortController']).toBeDefined(); + }); + it('should terminate session with DELETE request', async () => { // First, simulate getting a session ID const message: JSONRPCMessage = {