Skip to content

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

Closed as not planned

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 adapter — auth(...) / _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

Activity

  1. added
    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes
    on Aug 11, 2026
  2. Ben-Home commented on Aug 12, 2026

    @Ben-Home

    Solid trace. The fix shape that follows from this is to give every auth await on the send path an AbortSignal derived from requestSignal, rather than special-casing each call site:

    1. Extend the auth provider surface so token acquisition can observe cancellation, e.g. token({ signal }), but don't block on that: the transport can race the token promise against the signal (Promise.race with an abort rejection) so a hung provider can't park send() forever. The race is the pragmatic middle ground because it doesn't require provider authors to change anything.
    2. Same race around onUnauthorized and the metadata-discovery fetches: wrap each in AbortSignal.any([requestSignal, internalTimeout]) where available, or a manual race fallback on older runtimes.
    3. Protocol.request()'s response timer rejecting the caller is necessary but not sufficient: the transport must also settle the underlying send, otherwise every wedged exchange leaks a parked task plus whatever connection state it holds. That's exactly the listen() teardown path you flagged.

    The token()-without-signal API shape is the same place we had to add cancellation shims in our own MCP client integrations at corpusiq.io. The race pattern is the lowest-friction fix because it is transport-local and provider-agnostic.

  3. Ben-Home commented on Aug 14, 2026

    @Ben-Home

    Agreed on the gap - the auth path is the last segment of _send that does not observe requestSignal, which makes the whole abort contract inconsistent. Three concrete suggestions, in increasing order of effort:

    1. Race the auth call against the signal. Wrap the token() await so the send path can unblock even when the auth provider never observes the signal:

    const tokenPromise = this.authProvider?.token();
    const abortPromise = new Promise((
    , reject) => {
    const onAbort = () => reject(new Error("send aborted while awaiting token"));
    if (signal.aborted) onAbort(); else signal.addEventListener("abort", onAbort, { once: true });
    });
    const token = await Promise.race([tokenPromise, abortPromise]);

    This gives every caller an escape hatch without requiring the provider to cooperate. A wedged refresh can still leak the underlying promise, but send() returns control, which is the contract that matters.

    1. Extend onUnauthorized to receive the AbortSignal. The 401-recovery flow is exactly where cancellation matters most - a slow re-auth triggered by a 401 on a request the caller has already abandoned is the worst case. Adding an optional signal parameter to the interface is backward compatible for existing providers and lets the transport pass requestSignal through on both the POST _send path and the GET _startOrAuthSse path.

    2. For metadata discovery, use AbortSignal.any([requestSignal, timeoutSignal]) and apply a hard cap to the discovery call. Discovery is bounded work (a couple of HTTP fetches) so a timeout, not just abort, is the right tool; abort alone still hangs if the provider ignores it.

    The general rule we apply at corpusiq.io across our 36+ OAuth connectors: every await that can block on an external IdP must be bounded by either the caller's signal or a timeout, because token refresh and discovery are the two calls most likely to hang for reasons outside your process (IdP outage, keychain prompt, network partition). An abort that stops at the transport boundary is a partial fix; the auth provider interface itself should accept the signal so cancellation composes all the way down.

    One asymmetry worth checking while you are in here: the SSE reconnect chain already threads requestSignal, so the auth awaits on that path are the odd ones out. If you land the race pattern in _commonHeaders() and pass the signal into onUnauthorized, the GET and POST paths end up with the same guarantee and the parked-request class you describe disappears on both.

  4. added
    spec-2026-07-28Concerns the SDK's implementation of the 2026-07-28 MCP spec revision
    on Aug 18, 2026
  5. felixweinberger commented on Sep 29, 2026

    @felixweinberger
    Contributor

    No report of this being hit, and the fix in #2644 needs new public API on the auth provider. A cancelled request still ends for the caller through its timeout. Closing as not planned.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    spec-2026-07-28Concerns the SDK's implementation of the 2026-07-28 MCP spec revisionv2Ideas, 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