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 @@ -238,12 +238,15 @@
throw new UnauthorizedError('Failed to authorize');
}
}

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

Check failure on line 249 in packages/client/src/client/sse.ts

View check run for this annotation

Claude / Claude Code Review

SSEClientTransport.close() leaks _resourceMetadataUrl and _scope across restarts

SSEClientTransport.close() now clears _eventSource (enabling restart), but omits _resourceMetadataUrl and _scope — after close()+start(), if the new lifecycle's 401 arrives without a WWW-Authenticate header (valid per RFC 9110), stale values from the prior lifecycle are passed directly to auth() in finishAuth(), targeting the wrong OAuth server. Fix: add this._resourceMetadataUrl = undefined and this._scope = undefined to close().
Comment on lines 241 to 249
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 SSEClientTransport.close() now clears _eventSource (enabling restart), but omits _resourceMetadataUrl and _scope — after close()+start(), if the new lifecycle's 401 arrives without a WWW-Authenticate header (valid per RFC 9110), stale values from the prior lifecycle are passed directly to auth() in finishAuth(), targeting the wrong OAuth server. Fix: add this._resourceMetadataUrl = undefined and this._scope = undefined to close().

Extended reasoning...

What the bug is and how it manifests

SSEClientTransport declares _resourceMetadataUrl (line 71) and _scope (line 72) as instance fields. The PR's change to close() correctly adds this._eventSource = undefined (enabling restart via the start() guard at line 219) and this._abortController = undefined, but does NOT add this._resourceMetadataUrl = undefined or this._scope = undefined. These fields carry OAuth discovery state from one lifecycle and silently contaminate the next.

The specific code path that triggers it

_resourceMetadataUrl and _scope are set in two places: (1) inside _startOrAuth()'s EventSource fetch interceptor at lines 138–139, guarded by if (response.headers.has('www-authenticate')); and (2) inside _send() at lines 276–277, same guard. Both assignments only fire when the 401 response includes a WWW-Authenticate header. The stale values are consumed in finishAuth() at lines 233–234 where they are passed directly to auth() for OAuth token endpoint discovery.

Why existing code does not prevent it

The 401 handling in both _startOrAuth() and _send() only refreshes _resourceMetadataUrl and _scope when www-authenticate is present. Per RFC 9110 §11.6.1 and the MCP OAuth spec, a server may omit the WWW-Authenticate header on subsequent 401 responses once the client has previously received the resource metadata — the server may assume the client already knows where to go. In that case, the conditional assignments are skipped and the stale values from the prior lifecycle persist unchecked.

Impact

Before this PR, start() after close() always threw 'SSEClientTransport already started!' because the start() guard (if (this._eventSource)) saw the non-null _eventSource that close() did not clear. Users were forced to instantiate a new transport, which re-initializes _resourceMetadataUrl = undefined and _scope = undefined in the constructor (lines 87–88). This PR adds this._eventSource = undefined to close(), making same-instance restart possible — but without the corresponding field resets. The primary scenario this PR targets (start → 401 → close → OAuth → start) is exactly the path that triggers the stale-state bug.

Step-by-step proof

  1. Lifecycle 1: transport.start() → SSE GET returns 401 with WWW-Authenticate: Bearer realm="old-auth", resource_metadata="https://old-server/.well-known/resource" → _resourceMetadataUrl is set to the old server URL; _scope is set if provided.
  2. User calls close(). The PR's new code sets _eventSource = undefined, _abortController = undefined, _endpoint = undefined. _resourceMetadataUrl and _scope are NOT cleared — they still point to the old server.
  3. OAuth server is reconfigured. The resource is now served from a different authorization server.
  4. User calls start() — succeeds (newly enabled by this PR; _eventSource is undefined so guard passes).
  5. New SSE GET returns 401 WITHOUT a WWW-Authenticate header (server assumes client already has the resource metadata from lifecycle 1).
  6. The if (response.headers.has('www-authenticate')) guard at line 138 is false; _resourceMetadataUrl is NOT refreshed.
  7. The 401 path reaches finishAuth() or the auth retry → auth() is called with the stale _resourceMetadataUrl (old server URL) and stale _scope. The client attempts token endpoint discovery against the wrong OAuth authorization server, failing to authenticate with the new one.

How to fix it

Add the two missing resets to close():

This mirrors the exact pattern the PR already uses for _sessionId, _lastUpscopingHeader, and _serverRetryMs in StreamableHTTPClientTransport.close(), and follows what the constructor already does at lines 87–88.


async send(message: JSONRPCMessage): Promise<void> {
return this._send(message, false);
Expand Down
28 changes: 23 additions & 5 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,16 +230,21 @@
});
}

private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false): Promise<void> {
const { resumptionToken } = options;

// Capture the signal active when this call started so a close()/start() during the awaits
// below doesn't bind this stale GET to the restarted transport's controller.
const signal = this._abortController?.signal;

try {
// Try to open an initial SSE stream with GET to listen for server messages
// This is optional according to the spec - server may not support it
const headers = await this._commonHeaders();
if (signal?.aborted) return;
const userAccept = headers.get('accept');
const types = [...(userAccept?.split(',').map(s => s.trim().toLowerCase()) ?? []), 'text/event-stream'];
headers.set('accept', [...new Set(types)].join(', '));

Check failure on line 247 in packages/client/src/client/streamableHttp.ts

View check run for this annotation

Claude / Claude Code Review

Missing signal?.aborted guard before 401 auth-retry recursive call in _startOrAuthSse

The 401 auth-retry path in `_startOrAuthSse` is missing a `signal?.aborted` guard before the recursive `return this._startOrAuthSse(options, true)` call, allowing a ghost SSE stream to open on the new lifecycle after a `close()+start()` during token refresh. Add `if (signal?.aborted) return;` immediately before the recursive call to prevent the stale reconnect from binding to the new lifecycle's controller.
Comment on lines 233 to 247
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The 401 auth-retry path in _startOrAuthSse is missing a signal?.aborted guard before the recursive return this._startOrAuthSse(options, true) call, allowing a ghost SSE stream to open on the new lifecycle after a close()+start() during token refresh. Add if (signal?.aborted) return; immediately before the recursive call to prevent the stale reconnect from binding to the new lifecycle's controller.

Extended reasoning...

What the bug is and how it manifests

At the top of _startOrAuthSse (line 238), the PR correctly captures const signal = this._abortController?.signal to prevent a close()+start() during async work from binding a stale GET to the new lifecycle. There is an existing if (signal?.aborted) return; check after await this._commonHeaders() (line 246). However, the 401 auth-retry path (lines 270-277) contains its own async suspension — await this._authProvider.onUnauthorized({...}) — without any corresponding guard before the recursive call.

The specific code path that triggers it

The relevant code is:

if (this._authProvider.onUnauthorized && \!isAuthRetry) {
    await this._authProvider.onUnauthorized({   // async, can suspend hundreds of ms
        response,
        serverUrl: this._url,
        fetchFn: this._fetchWithInit
    });
    await response.text?.().catch(() => {});
    // NO signal?.aborted check here
    return this._startOrAuthSse(options, true);  // recursive call captures fresh S2
}

Why existing code does not prevent it

The signal variable captured at line 238 IS S1 (the old lifecycle). After onUnauthorized() resolves, signal (S1) is already aborted. But the code never inspects signal at this point — it proceeds directly to the recursive call. The recursive invocation then runs const signal = this._abortController?.signal at ITS line 238, which now captures S2 (the new non-aborted controller). The subsequent if (signal?.aborted) return check sees S2 (not aborted) and proceeds to open the GET fetch with S2 as its signal, fully binding the ghost stream to the new lifecycle.

Step-by-step proof

  1. Old-lifecycle _startOrAuthSse(options, false) starts; signal = S1 is captured at line 238.
  2. The GET fetch completes with 401; onUnauthorized() is awaited at line 270 — this triggers a token-refresh network call, suspending for hundreds of ms.
  3. While suspended, user calls transport.close(): S1 is aborted. Then transport.start(): this._abortController = new AbortController() (S2, not aborted).
  4. onUnauthorized() resolves. response.text() is consumed.
  5. return this._startOrAuthSse(options, true) executes at line 277 with NO check of signal (S1).
  6. In the recursive invocation, line 238 runs: const signal = this._abortController?.signal — now S2.
  7. await this._commonHeaders() completes; if (signal?.aborted) return checks S2 — not aborted — passes.
  8. A new GET fetch is issued with signal: S2, opening a ghost SSE stream bound to the new lifecycle.

Impact

The ghost stream is associated with the new lifecycle's controller (S2), so it IS cleaned up on the next close(). However, it carries the old-lifecycle's options — including a potentially stale resumptionToken / Last-Event-ID — to the server, which may replay stale events into the new session or confuse server-side session state. This is precisely the ghost SSE pattern the PR's signal-capture design was meant to prevent, but the auth-retry path was missed.

How to fix it

Add if (signal?.aborted) return; immediately before return this._startOrAuthSse(options, true):

await this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._fetchWithInit });
await response.text?.().catch(() => {});
if (signal?.aborted) return;   // add this guard
return this._startOrAuthSse(options, true);

This mirrors the existing guard after _commonHeaders() and correctly uses the already-captured S1 which is aborted at this point, preventing the recursive call from re-reading the replaced controller.


// Include Last-Event-ID header for resumable streams if provided
if (resumptionToken) {
Expand All @@ -250,7 +255,7 @@
...this._requestInit,
method: 'GET',
headers,
signal: this._abortController?.signal
signal
});

if (!response.ok) {
Expand Down Expand Up @@ -341,11 +346,18 @@
// Calculate next delay based on current attempt count
const delay = this._getNextReconnectionDelay(attemptCount);

// Capture the signal active when this reconnection was scheduled. close() + start()
// replaces this._abortController, so re-reading it later would see the new session's
// controller and allow a stale reconnect to fire into the restarted transport.
const signal = this._abortController?.signal;

const reconnect = (): void => {
this._cancelReconnection = undefined;
if (this._abortController?.signal.aborted) return;
if (signal?.aborted) return;
this._startOrAuthSse(options).catch(error => {
if (signal?.aborted) return;
this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`));
if (signal?.aborted) return;
try {
this._scheduleReconnection(options, attemptCount + 1);
} catch (scheduleError) {
Expand All @@ -369,6 +381,9 @@
}
const { onresumptiontoken, replayMessageId } = options;

// Capture the signal this stream is bound to so we don't reconnect into a restarted transport.
const signal = this._abortController?.signal;

let lastEventId: string | undefined;
// Track whether we've received a priming event (event with ID)
// Per spec, server SHOULD send a priming event with ID before closing
Expand Down Expand Up @@ -436,7 +451,7 @@
// BUT don't reconnect if we already received a response - the request is complete
const canResume = isReconnectable || hasPrimingEvent;
const needsReconnect = canResume && !receivedResponse;
if (needsReconnect && this._abortController && !this._abortController.signal.aborted) {
if (needsReconnect && signal && !signal.aborted) {
this._scheduleReconnection(
{
resumptionToken: lastEventId,
Expand All @@ -455,7 +470,7 @@
// BUT don't reconnect if we already received a response - the request is complete
const canResume = isReconnectable || hasPrimingEvent;
const needsReconnect = canResume && !receivedResponse;
if (needsReconnect && this._abortController && !this._abortController.signal.aborted) {
if (needsReconnect && signal && !signal.aborted) {
// Use the exponential backoff reconnection strategy
try {
this._scheduleReconnection(
Expand All @@ -476,7 +491,7 @@
}

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 @@ -511,6 +526,9 @@
} finally {
this._cancelReconnection = undefined;
this._abortController?.abort();
this._sessionId = undefined;
this._lastUpscopingHeader = undefined;
this._serverRetryMs = undefined;
this.onclose?.();
}
}
Comment on lines 526 to 534
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 close() clears _sessionId, _lastUpscopingHeader, and _serverRetryMs but does NOT clear _resourceMetadataUrl or _scope. After a restart, if the new lifecycle receives a 401 without a WWW-Authenticate header (valid per spec), stale values flow into finishAuth() or the 403 upscoping auth() call, targeting the wrong OAuth server. Fix: add this._resourceMetadataUrl = undefined and this._scope = undefined to the finally block in close().

Extended reasoning...

The PR establishes a clear pattern: all per-lifecycle state must be reset in close() so the transport can be safely reused after restart. It correctly adds this._sessionId = undefined, this._lastUpscopingHeader = undefined, and this._serverRetryMs = undefined to the finally block. However, _resourceMetadataUrl (initialized to undefined in the constructor at line ~194) and _scope (line ~195) are omitted from this cleanup, breaking the same invariant.

Where these fields are set: _resourceMetadataUrl and _scope are set during 401 handling in _startOrAuthSse() (lines 260-261) and _send() (lines 580-581). _scope is also updated in the 403/insufficient_scope path (lines 620, 624). The 403 path is especially subtle: _resourceMetadataUrl is only updated conditionally (if (resourceMetadataUrl) { this._resourceMetadataUrl = resourceMetadataUrl; }), so if a new lifecycle 403 response omits the resource_metadata parameter, the stale lifecycle-1 URL persists.

Where these fields are consumed: Both are passed directly to auth() in finishAuth() (lines 508-509: resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope) and in the 403 upscoping path (lines 631-632). These values drive token endpoint discovery; using the wrong URL means the client contacts the wrong OAuth authorization server.

Step-by-step failure scenario: (1) Lifecycle 1: server returns 401 with WWW-Authenticate header containing resource_metadata=https://old-server/resource, setting _resourceMetadataUrl. (2) close() is called; _resourceMetadataUrl is NOT cleared. (3) OAuth server config changes to a new resource metadata URL. (4) start() is called (newly enabled by this PR). (5) New lifecycle triggers a 401 WITHOUT a www-authenticate header, which is valid per spec when the server assumes the client already has the metadata. The guard at line 258 (if response.headers.has('www-authenticate')) is false, so _resourceMetadataUrl is NOT refreshed. (6) finishAuth() or onUnauthorized calls auth() with the stale _resourceMetadataUrl, performing token endpoint discovery against the wrong server.

Why existing code does not prevent this: The 401 path only updates _resourceMetadataUrl/_scope when the www-authenticate header is present. A spec-compliant server may omit this header on subsequent 401s after the client has already received the metadata. Before this PR, start() after close() threw already started, so users always created a new transport instance that re-initialized both fields to undefined in the constructor. This PR enables same-instance restart without the corresponding field resets, the same class of omission as _sessionId, _lastUpscopingHeader, and _serverRetryMs already fixed by this PR.

Fix: Add this._resourceMetadataUrl = undefined and this._scope = undefined to the finally block in close(), alongside the existing this._sessionId = undefined, following the exact same pattern the PR already uses for the other per-lifecycle fields.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These need to survive close(). The OAuth re-auth flow this PR enables is 401 sets fields -> close() -> finishAuth() reads them at L508-509 -> start(). Clearing them in close() breaks finishAuth(). They're discovery metadata for the fixed _url, not per-session state like _sessionId.

Expand Down
82 changes: 82 additions & 0 deletions packages/client/test/client/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1998,6 +1998,31 @@ describe('StreamableHTTPClientTransport', () => {
expect(onerror).not.toHaveBeenCalled();
});

it('ignores a late-firing reconnect after close() + start()', async () => {
let capturedReconnect: (() => void) | undefined;
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
reconnectionOptions,
reconnectionScheduler: reconnect => {
capturedReconnect = reconnect;
}
});
const onerror = vi.fn();
transport.onerror = onerror;
const fetchMock = globalThis.fetch as Mock;

await transport.start();
triggerReconnection(transport);
await transport.close();
await transport.start();

fetchMock.mockClear();
capturedReconnect?.();
await vi.runAllTimersAsync();

expect(fetchMock).not.toHaveBeenCalled();
expect(onerror).not.toHaveBeenCalled();
});

it('still aborts and fires onclose if the cancel function throws', async () => {
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), {
reconnectionOptions,
Expand All @@ -2017,4 +2042,61 @@ 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();
});

it('should reset server-provided retry delay and upscoping header on close()', async () => {
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'));
await transport.start();

const internal = transport as unknown as { _serverRetryMs?: number; _lastUpscopingHeader?: string };
internal._serverRetryMs = 3000;
internal._lastUpscopingHeader = 'Bearer realm="x"';

await transport.close();

expect(internal._serverRetryMs).toBeUndefined();
expect(internal._lastUpscopingHeader).toBeUndefined();
});
});
});
Loading