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. 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.
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.
- 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
What happened?
StreamableHTTPClientTransportthreadsTransportSendOptions.requestSignalthrough 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.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 — notablyClient.listen(), whose abort teardown relies onrequestSignalreaching the transport — cannot be torn down. This is the streamable-HTTP sibling of the stdio'drain'park (#2552) and one of the ways thelisten()escape in #2641 stays wedged.What did you expect?
Aborting
TransportSendOptions.requestSignalshould settlesend()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):
Output on 2.0.0 (same code paths on
main@ cc4b416):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:
requestSignalwith the transport signal before header acquisition, and racetoken()against it (or pass{ signal }totoken()as an optional argument in theAuthProvidercontract).signal?: AbortSignalto theonUnauthorizedcallback options and to the internalauth(...)/ step-up / metadata-discovery fetch plumbing, defaulting to undefined.onerrorstays suppressed, matching the existing fetch-abort discipline).SDK version
@modelcontextprotocol/client@2.0.0(repro above); same code onmain@ cc4b416.Area
Client / Transports
Generated by Claude Code