Repository navigation
[v2] StreamableHTTPClientTransport: auth awaits in _send are un-abortable — requestSignal cannot cancel token()/onUnauthorized/metadata-discovery parks #2643
Description
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:
_commonHeaders()(packages/client/src/client/streamableHttp.ts,await this._authProvider?.token()) runs before the per-request signal is combined into a fetch signal. Atoken()that hangs (wedged token refresh, keychain prompt, slow OAuth broker) parkssend()forever; abortingrequestSignalhas no effect because nothing between the caller and the pendingtoken()observes it.onUnauthorized(both the POST_sendpath and the GET_startOrAuthSsepath):await this._authProvider.onUnauthorized({ response, serverUrl, fetchFn })receives noAbortSignal, so a 401-triggered recovery flow cannot be cancelled.- The SDK's own OAuth flow behind the adapter —
auth(...)/_stepUpAuthorize(...)and their protected-resource/authorization-server metadata discovery fetches — receivefetchFnbut 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
requestSignalwith the transport signal before header acquisition, and racetoken()against it (or pass{ signal }totoken()as an optional argument in theAuthProvidercontract). - Add
signal?: AbortSignalto theonUnauthorizedcallback options and to the internalauth(...)/ 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
onerrorstays 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
Ben-Home commentedon Aug 12, 2026
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: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 parksend()forever. The race is the pragmatic middle ground because it doesn't require provider authors to change anything.onUnauthorizedand the metadata-discovery fetches: wrap each inAbortSignal.any([requestSignal, internalTimeout])where available, or a manual race fallback on older runtimes.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 thelisten()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.Ben-Home commentedon Aug 14, 2026
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:
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.
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.
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.
felixweinberger commentedon Sep 29, 2026
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.