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
5 changes: 5 additions & 0 deletions .changeset/fix-transport-restart-after-close.md
Original file line number Diff line number Diff line change
@@ -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).
3 changes: 3 additions & 0 deletions packages/client/src/client/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,10 @@ export class SSEClientTransport implements Transport {

async close(): Promise<void> {
this._abortController?.abort();
this._abortController = undefined;
this._eventSource?.close();
this._eventSource = undefined;
this._endpoint = undefined;
this.onclose?.();
}

Expand Down
3 changes: 2 additions & 1 deletion packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
);
Expand Down Expand Up @@ -509,6 +509,7 @@ export class StreamableHTTPClientTransport implements Transport {
} finally {
this._cancelReconnection = undefined;
this._abortController?.abort();
this._sessionId = undefined;
this.onclose?.();
}
}
Expand Down
43 changes: 43 additions & 0 deletions packages/client/test/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
Loading