Skip to content

[v2] StreamableHTTPClientTransport: auth awaits in _send are un-abortable — requestSignal cannot cancel token()/onUnauthorized/metadata-discovery parks #2643

Description

@claude

What happened?

StreamableHTTPClientTransport threads TransportSendOptions.requestSignal through its fetches and SSE reconnect chain, but none of the auth awaits on the send path can see it, so a request that is "cancelled" by its signal can stay parked indefinitely inside the transport:

  1. _commonHeaders() (packages/client/src/client/streamableHttp.ts, await this._authProvider?.token()) runs before the per-request signal is combined into a fetch signal. A token() that hangs (wedged token refresh, keychain prompt, slow OAuth broker) parks send() forever; aborting requestSignal has no effect because nothing between the caller and the pending token() observes it.
  2. onUnauthorized (both the POST _send path and the GET _startOrAuthSse path): await this._authProvider.onUnauthorized({ response, serverUrl, fetchFn }) receives no AbortSignal, so a 401-triggered recovery flow cannot be cancelled.
  3. The SDK's own OAuth flow behind the adapterauth(...) / _stepUpAuthorize(...) and their protected-resource/authorization-server metadata discovery fetches — receive fetchFn but no signal, so a slow/black-holed discovery endpoint parks the send with no way to abort.

Consequences: Protocol.request()'s timeout still rejects the caller's promise (the response-handler timer is independent), but the underlying send remains parked and un-collectable, and paths that suspend on the send itself — notably Client.listen(), whose abort teardown relies on requestSignal reaching the transport — cannot be torn down. This is the streamable-HTTP sibling of the stdio 'drain' park (#2552) and one of the ways the listen() escape in #2641 stays wedged.

What did you expect?

Aborting TransportSendOptions.requestSignal should settle send() promptly no matter which phase it is in — header/token acquisition, 401 recovery, step-up authorization, or metadata discovery — the same way it already aborts the fetch and the SSE resume chain.

Code to reproduce

Observed on the published package (no server needed — the park happens before any fetch):

// node repro.mjs — @modelcontextprotocol/client@2.0.0
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/client";

const transport = new StreamableHTTPClientTransport(new URL("http://127.0.0.1:9/mcp"), {
  authProvider: { token: () => new Promise(() => {}) }, // hung token()
});
await transport.start();

const ac = new AbortController();
const send = transport
  .send({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }, { requestSignal: ac.signal })
  .then(
    () => "resolved (unexpected)",
    (e) => `rejected: ${e?.name ?? e}`,
  );
setTimeout(() => ac.abort(new Error("caller abort")), 100);

console.log(await Promise.race([
  send,
  new Promise((r) => setTimeout(() => r("send() still pending 2s after requestSignal abort — un-abortable park"), 2000)),
]));

Output on 2.0.0 (same code paths on main @ cc4b416):

send() still pending 2s after requestSignal abort — un-abortable park

The onUnauthorized / metadata-discovery variants park the same way, just later in the send (after a 401 instead of before the first fetch).

Suggested fix

Thread the request's abort signal through the auth chain as optional parameters so existing providers keep working unchanged:

  • Combine requestSignal with the transport signal before header acquisition, and race token() against it (or pass { signal } to token() as an optional argument in the AuthProvider contract).
  • Add signal?: AbortSignal to the onUnauthorized callback options and to the internal auth(...) / step-up / metadata-discovery fetch plumbing, defaulting to undefined.
  • On abort, reject the send with the signal's reason (marked as an intentional abort so onerror stays suppressed, matching the existing fetch-abort discipline).

SDK version

@modelcontextprotocol/client@2.0.0 (repro above); same code on main @ cc4b416.

Area

Client / Transports


Generated by Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions