modelcontextprotocol / modelcontextprotocol/typescript-sdk
[v2] StreamableHTTPClientTransport: auth awaits in _send are un-abortable — requestSignal cannot cancel token()/onUnauthorized/metadata-discovery parks
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 13.4k
- Forks
- 2.2k
- Avg merge
- 3d 15h
- Merged PRs (30d)
- 4
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
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in packages/client/src/client/streamableHttp.ts, tracing _commonHeaders(), the POST _send path, and the GET _startOrAuthSse path. Then follow the authProvider token/onUnauthorized calls into auth(), _stepUpAuthorize(), and metadata discovery. Done means requestSignal aborts send() promptly during each listed auth phase, while existing fetch and SSE abort behavior remains intact; use the provided hung-token reproduction to verify.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api, networking
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 45/100