Skip to content

fix(client): make streamable HTTP auth awaits abortable by requestSignal - #2644

Open
claude[bot] wants to merge 8 commits into
mainfrom
fix/2643-abortable-auth-awaits
Open

fix(client): make streamable HTTP auth awaits abortable by requestSignal#2644
claude[bot] wants to merge 8 commits into
mainfrom
fix/2643-abortable-auth-awaits

Conversation

@claude

@claude claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Requested by Felix Weinberger · Slack thread

Fixes #2643.

Root cause

StreamableHTTPClientTransport threads TransportSendOptions.requestSignal through its fetches and the SSE resume chain, but the auth awaits on the send path never see it:

Since Client.listen()'s teardown relies on requestSignal reaching the transport, these parks are also one of the ways the #2641 listen() wedge stays stuck.

Fix

A raceWithSignal(promise, signal) helper (next to the existing anySignal) races each auth await against the combined request/transport signal, which is now computed before header acquisition on both the POST and GET paths:

  • _commonHeaders(signal?) races token(); terminateSession passes the transport signal too.
  • Both onUnauthorized awaits are raced, and the combined signal is offered to the provider via a new optional UnauthorizedContext.signal field (additive — existing implementations are unaffected) so cooperative providers can cancel their own recovery fetches.
  • Both _stepUpAuthorize awaits are raced.

Abort semantics match the existing discipline: an abort rejects with the signal's reason, is not stamped as an auth-seam escape (it is an intentional teardown, so the negotiation-probe classifier and the send-catch treat it as a plain abort), and the _send catch keeps suppressing onerror for requestSignal aborts. The losing promise gets a no-op rejection handler so it cannot escape as an unhandledRejection; the race listener is removed on settlement so a long-lived transport signal does not accumulate closures per request.

Known limitation (called out in #2643): the raced-out auth work itself keeps running — the AuthProvider contract has no mandatory cancellation channel. ctx.signal gives providers the hook; threading the signal all the way through the SDK's internal auth() metadata-discovery fetches is a natural follow-up but touches the OAuth plumbing more broadly, so it is left out of this focused fix.

Tests

Written failing-first against main (cc4b416); new describe block in packages/client/test/client/streamableHttp.test.ts:

  • hung token() + requestSignal abort → send rejects with the abort reason, no onerror, no fetch issued (fails on main: send still pending after 500ms).
  • hung token() + transport.close() → send rejects (fails on main).
  • 401 then hung onUnauthorized + requestSignal abort → send rejects, no onerror, and the provider received ctx.signal in the aborted state (fails on main).
  • token() rejection without an abort → still surfaces as the auth failure (behavior-preservation control; passes before and after).

pnpm --filter @modelcontextprotocol/client test: 33 files, 801 tests passed (805 including the new block on the final run). Lint (ESLint + Prettier) and typecheck clean. Changeset included (patch, @modelcontextprotocol/client).


Generated by Claude Code

token(), onUnauthorized 401 recovery, and step-up authorization were
awaited with no path for the per-request or transport abort signal to
reach them, so a hung auth flow parked send() forever. Race those
awaits against the combined signal and offer it to onUnauthorized via
the new optional UnauthorizedContext.signal field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DThnF41VH9fXQPjGmRySxa
@claude
claude Bot requested a review from a team as a code owner August 11, 2026 05:33
@changeset-bot

changeset-bot Bot commented Aug 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 53c393c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/client Patch
@modelcontextprotocol/core Patch
@modelcontextprotocol/server Patch
@modelcontextprotocol/server-legacy Patch
@modelcontextprotocol/codemod Patch
@modelcontextprotocol/core-internal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Aug 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2644

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2644

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2644

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2644

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2644

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2644

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2644

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2644

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2644

commit: 53c393c

Comment thread packages/client/src/client/streamableHttp.ts
Comment thread packages/client/src/client/streamableHttp.ts
Comment thread packages/client/src/client/streamableHttp.ts
Comment on lines +1045 to +1056
// Per-request abort: when the caller supplies a request-scoped
// signal (the `subscriptions/listen` driver), aborting it cancels
// this POST and its SSE response stream without closing the
// transport. Combined BEFORE header acquisition so the auth chain
// (token(), 401 recovery, step-up) is raced against it too.
const transportSignal = this._abortController?.signal;
const signal =
options?.requestSignal !== undefined && transportSignal !== undefined
? anySignal(transportSignal, options.requestSignal)
: (options?.requestSignal ?? transportSignal);

const headers = await this._commonHeaders(signal);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 Pre-existing issue (not introduced by this PR): the #2641 listen() teardown wedge this PR cites as motivation still survives on the awaited McpSubscription.close() path — wireTeardown() awaits the notifications/cancelled send, which carries no requestSignal, so with a hung token() the raced _commonHeaders only sees the transport-lifetime signal and await subscription.close() parks until the whole transport closes. Fix locus is client.ts's wireTeardown plumbing: don't await the cancelled notification on the public close() path, bound it, or give notification sends an abort channel.

Extended reasoning...

What survives

This PR makes the auth chain abortable wherever a requestSignal reaches _send — which fixes the listen POST itself. But the teardown path the PR description names ("Since Client.listen()'s teardown relies on requestSignal reaching the transport, these parks are also one of the ways the #2641 listen() wedge stays stuck") has a second await that never gets a requestSignal: the notifications/cancelled send.

Code path

  1. McpSubscription.close() (packages/client/src/client/client.ts:2060-2064) awaits wireTeardown().
  2. wireTeardown() (client.ts:2055-2058) calls requestAbort.abort() — which, thanks to this PR, now settles the parked listen POST — and then does await this.notification({ method: 'notifications/cancelled', ... }).catch(() => {}).
  3. Protocol.notification_notificationViaCodecawait this._transport.send(jsonrpcNotification, options) (protocol.ts:1645). NotificationOptions carries only relatedRequestId — there is no requestSignal channel for notifications.
  4. In the patched _send (streamableHttp.ts:1045-1056), a signal-less send collapses the combined signal to options?.requestSignal ?? transportSignal — i.e. the transport-lifetime signal alone.
  5. _commonHeaders(signal) therefore races the hung token() only against this._abortController.signal, which by design never aborts on a subscription-scoped close. raceWithSignal never settles.

The .catch(() => {}) on the notification only swallows a rejection; it cannot unpark a promise that never settles. So await subscription.close() hangs until the entire transport is closed.

Step-by-step proof (the PR's own #2643 scenario)

  1. Modern-era Streamable HTTP connection with an AuthProvider whose token() refresh wedges (returns a never-settling promise), an open subscriptions/listen subscription.
  2. App calls await subscription.close() intending to tear down just the subscription while keeping the transport.
  3. wireTeardown() runs requestAbort.abort() → the listen POST settles (this PR's fix works here).
  4. wireTeardown() then awaits the notifications/cancelled send → _send computes signal = transportSignal (no requestSignal) → _commonHeaders awaits raceWithSignal(token(), transportSignal) → neither side ever settles.
  5. await subscription.close() never resolves. The user-visible [v2] Client.listen() rejections escape as process-level unhandledRejection while the send is in flight; a parked send hangs listen() past its ack timeout #2641 symptom survives on exactly the teardown path the PR names.

Note the sibling teardown callers — the ack-timeout (client.ts:2077) and caller-signal abort (client.ts:2092) — use void wireTeardown().catch(...) and are unaffected; only the awaited public close() path wedges. Client.close() is also safe because _resetConnectionState() settles listen state without awaiting wireTeardown.

Why this is pre-existing, and why it's still worth noting

On main the un-raced await token() parked this same notification send forever — and even a transport close didn't settle it. This PR strictly improves the path (transport close now unparks it) and doesn't touch client.ts at all; the PR description also carefully claims only that these parks are 'one of the ways' the #2641 wedge stays stuck. So this isn't a regression and shouldn't block the PR. It's flagged because the PR interacts with the exact site where the send now parks (the raced _commonHeaders) and cites the listen-teardown wedge as motivation, so the residual gap is a natural follow-up.

Suggested fix (follow-up, in client.ts)

Any of: don't await the cancelled-notification send on the public close() path (mirror the void wireTeardown().catch(...) pattern the sibling callers use); bound the await with a timeout; or add an abort channel to notification sends (e.g. thread a requestSignal through NotificationOptions) so the subscription's requestAbort signal reaches the transport for the cancelled notification too.

Comment on lines +58 to +65
/**
* Abort signal for the request (or transport) whose 401 triggered this
* recovery. The transport stops waiting for `onUnauthorized` when it
* aborts; cooperative implementations should pass it to their own fetches
* so the recovery work stops too. Optional — absent when the transport
* has no lifetime signal to offer.
*/
signal?: AbortSignal;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 The legacy SSE client transport (packages/client/src/client/sse.ts) still contains the exact un-raced auth-await pattern this PR fixes for streamable HTTP: _commonHeaders() awaits token() with no signal, and both onUnauthorized awaits (the _send 401 recovery and the start-path handler) are un-raced and never receive the new UnauthorizedContext.signal, so a hung provider parks send() past close() there. This is a pre-existing issue in code the PR does not touch — flagging as follow-up material, not blocking.

Extended reasoning...

What survives in the sibling transport. This PR races every auth await in streamableHttp.ts against the combined request/transport signal, so a hung AuthProvider.token() or onUnauthorized() can no longer park send() past an abort (#2643). But the legacy SSE transport in the same package retains all three un-raced sites:

  • sse.ts:162-171_commonHeaders() awaits this._authProvider?.token() with no signal and no race.
  • sse.ts:375-386_send's 401 recovery awaits this._authProvider.onUnauthorized({ response, serverUrl, fetchFn }) un-raced, and without the new ctx.signal field.
  • sse.ts:216-223 — the _startOrAuth onerror handler calls onUnauthorized the same way.

Why the transport-lifetime wedge applies. SSEClientTransport has no requestSignal, so the per-request half of #2643 is out of scope — but the transport-lifetime half is not. close() (sse.ts:341) aborts _abortController, and _send's fetch does carry this._abortController?.signal (sse.ts:363). The problem is sequencing: the abort only reaches the fetch's init.signal, which is never hit while the send is parked before the fetch in a hung token(), or after a 401 in a hung onUnauthorized(). Nothing observes the abort in those states.

Step-by-step proof. (1) transport.send(msg) calls _send, which awaits _commonHeaders(); (2) _commonHeaders() awaits this._authProvider.token(), which returns a promise that never settles (wedged refresh, hung broker); (3) the caller invokes transport.close(), which aborts _abortController and fires onclose; (4) the parked send() never reaches line 363 where the signal would matter, has no race against the signal, and therefore never settles — the returned promise hangs forever. This is exactly the shape the PR's new test "transport close() settles a send parked in a hung token()" verifies is fixed for streamable HTTP; the same scenario against SSEClientTransport still hangs.

Why this is in scope to mention. The repo's Completeness convention says: when a PR replaces a pattern, grep the package for surviving instances of the old form — partial migrations leave sibling code paths with the very bug the PR claims to fix (#1657, #1761, #1595). Additionally, the PR modifies the shared UnauthorizedContext interface (auth.ts:58-65, adding signal?) that both transports consume — after this PR, only one of its two in-repo callers populates the field, so providers used with the SSE transport never see ctx.signal.

Why it is not blocking. All three verifiers agreed on pre-existing severity: sse.ts is untouched by this PR, it is the deprecated legacy transport, #2643 is explicitly scoped to streamable HTTP, and the PR describes itself as a focused fix. The fix — the same raceWithSignal(…, this._abortController?.signal) treatment (the helper could move somewhere shared), passing signal into the onUnauthorized context, and the same unstamped-abort discipline in the catch blocks — is a natural, mechanical follow-up.

How to fix (follow-up). Give _commonHeaders() a signal? parameter raced around token(), race both onUnauthorized awaits against this._abortController?.signal, and pass that signal as ctx.signal so cooperative providers can cancel their own recovery fetches — mirroring the streamable HTTP changes in this PR.

- suppress onerror for transport-lifetime aborts too in _send's catch
  and guard the resumptionToken fire-and-forget like reconnect() does
- reject already-aborted sends before invoking authProvider.token()
- pass the raced await's rejection through verbatim so the auth-seam
  stamp (a property on the thrown value, Error or not) and cause
  chains survive; lock in with an identity-preservation test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DThnF41VH9fXQPjGmRySxa
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown
Author

Review addressed in bdacc42 — all three actionable findings were correct on inspection:

  • Spurious onerror: _send's catch now also suppresses onerror when the transport-lifetime signal aborted (matching _handleSseStream's isIntentionalAbort), and the resumptionToken fire-and-forget carries the same both-signals guard _scheduleReconnection's reconnect() uses. The close()-settles-parked-send test now asserts onerror was not called.
  • Eager token(): _commonHeaders rejects already-aborted sends before invoking the provider, so a dead request never starts new auth work.
  • Rejection rewrap: raceWithSignal now passes the loser's rejection through verbatim — the auth-seam stamp is an in-place property on the thrown value (Error or not), and the rewrap would have stripped it on the step-up paths, which have no re-stamping catch. A new test locks in identity preservation of a non-Error rejection end-to-end through send().

On the two pre-existing findings, both real but out of scope here: the awaited wireTeardown()/notifications/cancelled park in McpSubscription.close() is already fixed by #2645 (bounded wait), and the legacy SSE transport's identical un-raced auth awaits (sse.ts) are noted as a follow-up candidate rather than folded into this PR.


Generated by Claude Code

Comment thread packages/client/src/client/streamableHttp.ts Outdated
Comment thread packages/client/src/client/streamableHttp.ts
Comment thread packages/client/src/client/streamableHttp.ts
…eWithSignal

- guard terminateSession's catch and the 202/initialized standalone-GET
  fire-and-forget against transport-lifetime aborts, matching _send
- normalize raceWithSignal's input via Promise.resolve so a plain-JS
  provider returning a bare value keeps working (the pre-race await
  tolerated it) and an executor throw cannot leak the abort listener
- fold the pre-existing StartSSEOptions drop on the resume-via-send
  path: thread onresumptiontoken and onRequestStreamEnd like the
  fresh-POST path so resumed streams keep the token-persistence chain
  and report their terminal end

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DThnF41VH9fXQPjGmRySxa
Comment thread packages/client/src/client/streamableHttp.ts
Comment thread .changeset/abortable-auth-awaits.md Outdated
Comment thread packages/client/src/client/auth.ts
Comment thread packages/client/src/client/streamableHttp.ts Outdated
Comment thread packages/client/src/client/streamableHttp.ts Outdated
Comment on lines +613 to +623
const isIntentionalAbort = (): boolean => this._abortController?.signal.aborted === true || requestSignal?.aborted === true;

try {
// Combined BEFORE header acquisition so the auth chain (token(),
// 401 recovery, step-up) is raced against the same abort as the
// GET itself.
const transportSignal = this._abortController?.signal;
const signal =
requestSignal !== undefined && transportSignal !== undefined
? anySignal(transportSignal, requestSignal)
: (requestSignal ?? transportSignal);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 Pre-existing issue (not introduced by this PR, which only relocates the call sites): the non-native anySignal fallback (Node 20.0–20.2, where AbortSignal.any is absent) removes its listeners only from cleanup(), which runs solely when one of the input signals aborts — so on the common path where a request completes normally, the onA listener (whose closure pins the per-request signal and controller) stays on the long-lived transport signal until close(), accumulating one listener per completed request and hitting MaxListenersExceededWarning after ~10. Fixing it needs a disposal channel — e.g. return { signal, dispose } from anySignal and dispose in a finally in _send/_startOrAuthSse.

Extended reasoning...

What the bug is. The fallback branch of anySignal (streamableHttp.ts:283–304, taken when AbortSignal.any is absent — the function's own comment notes the package's engines floor is >=20, so Node 20.0–20.2 must be served by it) registers onA on the transport-lifetime signal a and onB on the per-request signal b, both {once: true}. The only listener removal lives in cleanup(), which is invoked exclusively from onA/onB — i.e. only when one of the two input signals actually aborts. On the overwhelmingly common path — the request completes normally and neither signal ever aborts — nothing removes onA from a. Its closure references cleanup, which captures b and the fresh AbortController, so the entire per-request signal graph stays pinned on the long-lived transport signal until close() finally aborts a.\n\nThe code path that triggers it. On a modern-era Streamable HTTP session, Protocol.request supplies a requestSignal for every request: protocol.ts:1417–1418 creates requestAbort whenever the era is modern and transport.hasPerRequestStream === true (which StreamableHTTPClientTransport declares), and protocol.ts:1560 passes requestAbort?.signal into every transport.send(). So both call sites this PR relocates — _startOrAuthSse (~line 617) and _send (~line 1092) — take the anySignal(transportSignal, requestSignal) branch on every request. requestAbort.abort() runs only from cancel() (timeout / explicit cancel, protocol.ts:1473) and is skipped once the response is received, so a successfully-completed request never aborts either input signal.\n\nStep-by-step proof (Node 20.0–20.2): (1) client issues request #1_send calls anySignal(transportSignal, requestSignal) → fallback registers onA on transportSignal and onB on requestSignal; (2) the request completes normally → Protocol resolves, requestAbort.abort() is never called → neither onA nor onB fires, cleanup() never runs; (3) onA remains on transportSignal, its closure pinning requestSignal, onB, and the per-request AbortController; (4) repeat for requests #2#11 → after the 11th listener Node emits MaxListenersExceededWarning: Possible EventTarget memory leak detected. 11 abort listeners added to [AbortSignal]; (5) a long-lived session (a gateway, a daemon) grows memory linearly with request count until the transport closes.\n\nWhy existing code doesn't prevent it. The function's own comment (lines 284–288) recognizes exactly this leak class, but only for the case where b aborts ("every request-scoped b that aborts would otherwise leave one listener + closure pinned on a") — the mutual-removal fix it describes never executes when neither signal aborts, which is the normal-completion case. Notably, the PR's own new raceWithSignal was built specifically to avoid this class ("The abort listener is removed as soon as the promise settles so a long-lived transport signal does not accumulate closures per request") — the sibling combinator it sits next to violates that stated discipline. The native AbortSignal.any path (Node ≥20.3) is unaffected: the spec defines GC-safe dependent signals.\n\nImpact. Bounded to runtimes lacking AbortSignal.any (Node 20.0–20.2): one leaked listener + closure per completed request, MaxListenersExceededWarning after ~10 requests, and unbounded linear memory growth over a long-lived session. No behavioral incorrectness — aborts still propagate — so this is a resource leak, not a correctness failure, on a narrow runtime range.\n\nHow to fix. A bare AbortSignal has no completion hook, so the fallback needs the consumer to release it: e.g. have anySignal return { signal, dispose } (native path returns a no-op dispose) and call dispose() in a finally in _send and _startOrAuthSse once the request settles — mirroring the remove-on-settlement discipline raceWithSignal already implements. This is pre-existing: anySignal and both call sites exist on main unchanged; this diff only moves the combined-signal computation above header acquisition (same one anySignal call per request), so it relocates but neither introduces nor worsens the leak. Flagged because the PR touches both call sites and explicitly adopts the opposite discipline in raceWithSignal — a natural follow-up, not a blocker.

…onerror, extract _combinedSignal

- export UnauthorizedContext from the client barrel and document
  ctx.signal forwarding in machine-auth.md and upgrade-to-v2.md
- changeset now mentions the folded resume-observer fix
- drop the duplicate onerror emission in the resume-via-send and
  202/initialized catch blocks (_startOrAuthSse already emits before
  rethrowing); fire onRequestStreamEnd on a genuine resume failure so
  the observed per-request stream does not dead-end silently
- extract _combinedSignal() for the duplicated transport+request
  signal computation (used by _send, _startOrAuthSse, terminateSession)
- use isAborted() in the two onUnauthorized catch guards

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DThnF41VH9fXQPjGmRySxa
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown
Author

Round addressed in f4e9e33: UnauthorizedContext is now exported from the client barrel (with ctx.signal documented in machine-auth.md and upgrade-to-v2.md), the changeset covers the folded resume-observer fix, the resume-via-send and 202/initialized catch blocks no longer double-emit onerror (_startOrAuthSse's catch already reports before rethrowing — verified) and a genuine resume failure now fires onRequestStreamEnd, the duplicated signal computation lives in a _combinedSignal() helper, and the two onUnauthorized catch guards use isAborted().

On the pre-existing non-native anySignal fallback leaking one listener per normally-completed request (Node 20.0–20.2 only): agreed it is real and pre-existing — this diff only relocates the call sites — and a disposal-channel refactor is left as a follow-up candidate rather than folded in here.


Generated by Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline notes (all non-blocking nits or pre-existing items), this run also examined whether auth-phase aborts reject send() with a different error shape than fetch-phase aborts — they do not: abortReasonError passes an Error reason through identity-preserved (including the default DOMException AbortError), so both phases surface the same abort reason; only non-Error reasons are normalized, per the helper's documented contract.

Extended reasoning...

This run's six findings are all nits (duplication/consolidation follow-ups, a narrow already-aborted gating gap on the step-up path, a pre-existing reconnect double-emit) or explicitly pre-existing (withOAuth middleware's un-raced auth awaits), so they do not block the PR — but because findings were posted as inline comments, no approval or deferral body is appropriate. The one candidate issue examined and refuted this run (abort-rejection shape divergence between the raced auth awaits and the fetch path) is recorded above so a later pass does not re-derive it. The PR itself remains auth-sensitive transport-lifecycle work that warrants human review regardless.

Comment on lines 1159 to 1183

if (this._authProvider.onUnauthorized && !isAuthRetry) {
try {
await this._authProvider.onUnauthorized({
response,
serverUrl: this._url,
fetchFn: this._fetchWithInit
});
// Raced against the per-request/transport abort so
// a hung 401 recovery cannot park the send (#2643);
// the signal is also handed to the provider so a
// cooperative implementation can cancel its own work.
await raceWithSignal(
this._authProvider.onUnauthorized({
response,
serverUrl: this._url,
fetchFn: this._fetchWithInit,
signal
}),
signal
);
} catch (error) {
// An abort is an intentional teardown, not an auth
// failure — leave it unstamped.
if (isAborted(signal)) {
throw error;
}
// Auth-seam stamp: covers the SDK's OAuth flow and
// custom onUnauthorized callbacks alike.
throw markAuthSeamEscape(error);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The ~40-line 401-recovery block (raced onUnauthorized with ctx construction, the abort-vs-stamp two-branch catch, the response drain, and the isAuthRetry/UnauthorizedError tail) plus the 403 insufficient_scope step-up race are duplicated near-verbatim between the GET path (_startOrAuthSse) and the POST path (_send) — and this PR's own history had to edit both copies in lockstep twice (c37f361 added the race wrapper + ctx.signal + catch to both; f4e9e33 swapped isAborted() into both). Extracting a shared private helper (e.g. _recoverUnauthorized(response, signal), and/or folding the race+signal into _stepUpAuthorize which already owns method-level stamping) would make the announced follow-up — threading a signal through auth() — a one-site change instead of a four-site lockstep edit.

Extended reasoning...

What the duplication is. The 401-recovery block is character-identical between the GET path (_startOrAuthSse, ~lines 655–698) and the POST path (_send, ~lines 1150–1198) except for one word in a comment ('park the GET' vs 'park the send') and the path-specific retry-recursion target. The shared unit comprises: the WWW-Authenticate extraction + computeScopeUnion preservation, the raced await raceWithSignal(this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._fetchWithInit, signal }), signal), the two-branch catch (if (isAborted(signal)) throw error; else throw markAuthSeamEscape(error)), the response.text?.().catch(() => {}) drain, and the isAuthRetrySdkHttpError / terminal UnauthorizedError tail. The two 403 insufficient_scope step-up races are equally parallel: identical raceWithSignal(this._stepUpAuthorize({...}, stepUpRetries), signal) + 'AUTHORIZED' check + stepUpRetries + 1 recursion (~704–716 vs ~1206–1218).\n\nWhy this PR concretely demonstrates the cost. The pre-PR duplication was a plain un-raced await. This PR grew each copy in lockstep, twice, in its own commit history:\n\n1. c37f361 added the raceWithSignal wrapper, the ctx.signal field, and the two-branch abort/stamp catch — identically to both 401 sites and both step-up sites (four hunks performing the same edit).\n2. f4e9e33 (round three) swapped the inline signal?.aborted === true checks for isAborted(signal) — again applied to both copies.\n\nEach round required remembering both sites by hand. The PR's own review history shows exactly this discipline failing for path-symmetric code: the onerror-suppression guards needed fixes at four separate sites spread across bdacc42/2d4fbe7/f4e9e33 because each path carried its own copy.\n\nWhy existing consolidations don't cover it. The two extractions already performed on review are narrower: _combinedSignal() unified only the five-line signal derivation, and isAborted() unified only the abort-check expression. Neither touches the ~40-line recovery control flow, which remains fully duplicated. Nothing — no type, no test — enforces that the GET and POST paths keep identical auth-recovery semantics; they are held in sync only by author vigilance.\n\nThe drift hazard is live, not hypothetical. The PR description names the natural follow-up: threading the combined signal through the SDK's internal auth() metadata-discovery fetches ("a natural follow-up but touches the OAuth plumbing more broadly"). As the code stands, that follow-up — or any other change to the recovery contract (a second ctx field, a change to the abort/stamp classification) — must land identically at both 401 sites and both step-up sites: a four-site lockstep edit with no mechanism to catch a missed site.\n\nStep-by-step proof of the drift scenario. (1) The follow-up PR adds signal to AuthOptions and threads it through _stepUpAuthorizeauth(). (2) The author edits the POST-path step-up call (the site named in the issue repro) but misses the GET-path copy in _startOrAuthSse — plausible, since the two blocks live ~500 lines apart and are exercised by different test groups. (3) A resumed GET stream hitting a 403 now runs an uncancellable step-up while the fresh POST honors the signal: the GET and POST paths have silently diverged in abort semantics. (4) No test fails, because nothing asserts the paths behave identically. With a shared helper, step (2) is impossible — the change lands once.\n\nHow to fix. Extract a private helper, e.g. private async _recoverUnauthorized(response: Response, signal: AbortSignal | undefined): Promise<void> containing the WWW-Authenticate extraction/scope-union, the raced onUnauthorized call with ctx, the abort-vs-stamp catch, and the drain; both call sites reduce to one call plus their path-specific retry recursion (which stays at the call site — the asymmetries sit outside the extractable unit). For the step-up, either a sibling helper or fold the raceWithSignal(…, signal) into _stepUpAuthorize itself, which already takes method-level responsibility for auth-seam stamping. The helper is private, so the repo's public-API-minimalism principle is not implicated; if anything, REVIEW.md's "one way to do things" supports it.\n\nSeverity. Nit — no behavior difference exists today; both copies are currently correct and identical. The cost is the demonstrated lockstep-maintenance burden (two rounds in this PR alone) and the drift risk for the announced follow-up work. Non-blocking.

Comment thread packages/client/src/client/streamableHttp.ts
Comment thread packages/client/src/client/streamableHttp.ts Outdated
Comment thread packages/client/src/client/streamableHttp.ts
Comment thread packages/client/src/client/streamableHttp.ts
Comment on lines 1101 to +1108

const headers = await this._commonHeaders();
// Per-request abort: when the caller supplies a request-scoped
// signal (the `subscriptions/listen` driver), aborting it cancels
// this POST and its SSE response stream without closing the
// transport.
const signal = this._combinedSignal(options?.requestSignal);

const headers = await this._commonHeaders(signal);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 🟣 Pre-existing issue (this PR does not touch middleware.ts): when withOAuth(provider) is composed in via the transport's fetch option, its await provider.tokens() and 401-branch await auth(provider, ...) never consult init.signal — only the inner next() call forwards it — so while the middleware is parked there, aborting requestSignal or calling close() rejects nothing and the await fetch(url, init) in _send never settles: the exact #2643 hang survives through this documented configuration, from inside the fetch call this PR made abortable. Follow-up material for the broader auth()-signal-threading work the PR description names: withOAuth should observe init?.signal around its tokens()/auth() awaits and thread it into auth()'s fetchFn requests.

Extended reasoning...

What the bug is. withOAuth(provider) (packages/client/src/client/middleware.ts:38-96) is exported public API that users compose into a transport via StreamableHTTPClientTransportOptions.fetch. When it is the transport's fetch function, the promise awaited at this hunk's await (this._fetch ?? fetch)(this._url, init) is the middleware's, and the middleware has three await points that never consult init.signal:

  1. await provider.tokens() in makeRequest (middleware.ts:46) — before any fetch is in flight;
  2. await auth(provider, { serverUrl, resourceMetadataUrl, scope, fetchFn: next }) in the 401 branch (middleware.ts:64-69) — auth() runs metadata discovery and token exchange through fetchFn with requests it constructs itself, which never carry the caller's init.signal;
  3. the retry makeRequest() (middleware.ts:80).

Only the inner next(input, {...init, headers}) call forwards init.signal to a real fetch. While the middleware is parked in (1) or (2), no in-flight fetch is listening to the combined signal — aborting requestSignal or calling close() rejects nothing, and _send's fetch await never settles.

Why the PR's fix doesn't cover it. This PR's raceWithSignal wraps the transport's own auth chain (_authProvider.token(), onUnauthorized, _stepUpAuthorize). In the withOAuth configuration the user typically sets no authProvider at all — the auth chain lives inside the fetch function, past the point where the transport hands off to fetch(url, init). The transport assumes an aborted init.signal makes the fetch reject, which is true for real fetches but false for this middleware. The PR description itself concedes the underlying gap: "threading the signal all the way through the SDK's internal auth() metadata-discovery fetches is a natural follow-up."

Step-by-step proof.

  1. new StreamableHTTPClientTransport(url, { fetch: applyMiddlewares(withOAuth(provider), fetch) }) — no authProvider, so none of the PR's raced awaits engage.
  2. send() computes the combined signal and calls the middleware with init.signal set to it.
  3. The server returns 401; the middleware enters await auth(provider, ...), whose token-endpoint fetch (via fetchFn: next, but with auth()'s own signal-less requests) wedges.
  4. The caller aborts requestSignal (or the app calls transport.close()). The combined signal fires — but no fetch is in flight with that signal attached, and nothing in the middleware observes it.
  5. send() stays pending forever — the exact [v2] StreamableHTTPClientTransport: auth awaits in _send are un-abortable — requestSignal cannot cancel token()/onUnauthorized/metadata-discovery parks #2643 symptom, including the [v2] Client.listen() rejections escape as process-level unhandledRejection while the send is in flight; a parked send hangs listen() past its ack timeout #2641 listen()-teardown reliance the PR description cites.

Why existing code doesn't prevent it. raceWithSignal never wraps the fetch await itself (fetches are assumed abort-responsive), and withOAuth reads nothing from init besides headers. Nothing in the middleware chain propagates abort to the tokens()/auth() parks.

How to fix (follow-up, not this PR). In withOAuth: race the tokens() and auth() awaits against init?.signal (the same raceWithSignal shape this PR introduces), and thread the signal into auth()'s fetchFn calls so discovery/token-exchange requests actually cancel. This is a mechanically distinct site from the sse.ts transport's un-raced auth awaits flagged earlier — a fix there cannot touch middleware.ts, and vice versa.

Why pre-existing. middleware.ts is untouched by this diff, its JSDoc notes MCP transports "should not need this wrapper", and merging this PR strictly improves the abort story. But the wrapper remains exported and documented for exactly this composition, making it (besides sse.ts) the last supported client-package configuration where a hung auth flow still parks send() past its abort — worth tracking in the auth()-signal-threading follow-up.

…reconnect dedup, predicate extraction

- raceWithSignal now takes a producer and runs its aborted fast-path
  BEFORE invoking it, so an abort landing ahead of any of the five auth
  stages (token(), 401 recovery x2, step-up x2) never starts
  side-effectful work; subsumes _commonHeaders' special-case gate
- terminateSession uses _combinedSignal() for the DELETE fetch too
- reconnect() no longer re-reports a failure _startOrAuthSse already
  routed to onerror; one report per failed attempt, retry kept
- extract _isIntentionalAbort(requestSignal?) and use it at all seven
  guard sites (three hand-written variants unified)
- tests: pre-aborted requestSignal never invokes token(); a failed
  reconnect attempt reports onerror exactly once

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DThnF41VH9fXQPjGmRySxa
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown
Author

Round addressed in ce28a32: raceWithSignal now takes a producer and runs its aborted fast-path before invoking it, so an abort ahead of any of the five auth stages (token(), the two 401 recoveries, the two step-ups) never starts side-effectful work — this subsumes the _commonHeaders special-case gate, locked in by a test that a pre-aborted requestSignal rejects send() without ever invoking token(). terminateSession's DELETE now uses _combinedSignal() end to end, reconnect() no longer re-reports failures _startOrAuthSse already routed to onerror (one report per failed attempt, pinned by a new test), and the two-signal teardown predicate is extracted as _isIntentionalAbort() and used at all seven guard sites.

Two findings are deferred rather than folded in, to keep this PR reviewable:

  • 401-recovery extraction across the GET/POST paths: real duplication, but a structural refactor — it belongs in the already-announced follow-up that threads the abort signal through the SDK's internal auth()/metadata-discovery plumbing, where that shared recovery path would be restructured anyway.
  • withOAuth middleware's un-raced tokens()/auth() awaits: pre-existing and untouched by this diff; acknowledged as part of the same follow-up, alongside the sse.ts auth awaits and the non-native anySignal disposal noted earlier.

Generated by Claude Code

ce28a32 deliberately dropped the 'Failed to reconnect SSE stream:'
re-wrap that duplicated each attempt's failure report; the
reconnect-failure-onerror scenario pinned that duplicate's message.
Each failed attempt still reaches onerror exactly once — now as the
underlying failure itself — and the scenario asserts both that and
the wrapper's absence. Retry budget and GET counts unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DThnF41VH9fXQPjGmRySxa
Comment thread packages/client/src/client/streamableHttp.ts
Comment thread packages/client/src/client/streamableHttp.ts
Comment on lines 1092 to 1120
this._startOrAuthSse({
resumptionToken,
replayMessageId: isJSONRPCRequest(message) ? message.id : undefined,
requestSignal: options?.requestSignal
}).catch(error => this.onerror?.(error));
requestSignal: options?.requestSignal,
// Keep the caller's stream observers across the resume,
// matching the fresh-POST path below: without them a
// resume-via-send() dropped later resumption tokens (the
// persistence chain) and never reported the resumed
// stream's terminal end.
onresumptiontoken,
onRequestStreamEnd: options?.onRequestStreamEnd
}).catch(() => {
// `_startOrAuthSse`'s own catch already routed the
// failure to onerror (suppressed for intentional aborts)
// before rethrowing — no second emission here. Same
// abort guard as `_scheduleReconnection`'s reconnect():
// an abort of either signal during the resume (now
// reachable mid-auth-chain too) is intentional teardown.
if (this._isIntentionalAbort(options?.requestSignal)) {
return;
}
// An outright resume failure (network, non-401/403/405
// HTTP error) is TERMINAL for the per-request stream the
// caller is observing — this is the only channel that
// reports it (the 405 case fires inside _startOrAuthSse).
options?.onRequestStreamEnd?.();
});
return;
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 Pre-existing issue at the protocol↔transport seam (this PR rewrites this branch but does not introduce the interaction): on a legacy-era connection, Protocol's cancel path delivers cancellation by POSTing notifications/cancelled with the original request's send options forwarded verbatim — including resumptionToken (protocol.ts:1464) — and this branch short-circuits on resumptionToken before looking at the message, so the cancellation is silently discarded (the server keeps executing the cancelled request) and a rogue resume GET is opened instead. Fix either by sending the cancellation with { relatedRequestId } only in protocol.ts, or by gating this branch on the outbound message actually being the request being resumed.

Extended reasoning...

What the bug is. On a legacy-era streamable HTTP connection (any pre-2026-07-28 negotiated protocol version), cancelling a request that was resumed via resumptionToken never delivers notifications/cancelled to the server. Instead, the cancellation notification is silently swallowed by this branch of _send, and a second GET+Last-Event-ID resume stream is opened for a request the protocol layer has already rejected.

The code path. In Protocol._requestWithSchemaViaCodec (packages/core-internal/src/shared/protocol.ts), streamCloseCancels (protocol.ts:1417) requires codec.era === MODERN_WIRE_REVISION && hasPerRequestStream, so on any legacy-era connection requestAbort === undefined and cancel() takes the notification-POST branch (protocol.ts:1453-1466). That branch forwards the original request's send options verbatim: this._transport.send(cancelledNotification, { relatedRequestId, resumptionToken, onresumptiontoken }) (protocol.ts:1464) — resumptionToken/onresumptiontoken were destructured from the caller's request options at protocol.ts:1367. On the transport side, _send short-circuits on if (resumptionToken) (streamableHttp.ts:1092) before ever inspecting the message: it fire-and-forgets a GET resume via _startOrAuthSse and returns. The notifications/cancelled payload is never POSTed.

Step-by-step proof. (1) A client on a legacy-era connection resumes a long-running request: client.request(req, schema, { resumptionToken: lastEventId, onresumptiontoken }). (2) The request hits DEFAULT_REQUEST_TIMEOUT_MSEC (or the caller aborts options.signal). (3) cancel() runs; requestAbort is undefined, so the notifications/cancelled send goes out carrying the caller's resumptionToken in its options. (4) _send's resumption branch triggers: the cancellation is discarded — the server keeps executing the cancelled request, violating the spec's cancellation contract and the protocol.ts comment's promise that "every other era×transport combination keeps today's notifications/cancelled POST path unchanged" — and a rogue GET with Last-Event-ID is opened for a request whose response handler was just deleted. Since isJSONRPCRequest(notification) is false, replayMessageId is undefined, so nothing remaps the replayed stream.

Why existing code doesn't prevent it. The resumptionToken short-circuit predates this PR (v1 behavior) and assumes the message being sent is the request being resumed; the protocol cancel path blindly forwards the options object's resumptionToken onto a different message. No test covers cancel-during-resume on the legacy era. Modern-era connections are unaffected (cancel is requestAbort.abort(), the else branch).

Why it surfaces on this PR. The PR rewrites exactly this branch and newly threads onresumptiontoken/onRequestStreamEnd into the rogue GET (streamableHttp.ts:1101-1102) — so the caller's token-persistence callback and terminal-stream callback now actively fire for a stream opened by a discarded cancellation, after request() has already rejected; previously the rogue GET at least ran observer-free. Both sides of the root interaction predate the diff, though, which is why this is filed as pre-existing rather than blocking.

How to fix. Two candidate loci: (a) in protocol.ts, send the cancellation notification with { relatedRequestId } only — do not forward the original request's resumptionToken/onresumptiontoken onto a different message; or (b) in _send, only take the resume branch when the outbound message is the request being resumed (i.e. a JSON-RPC request), not a notification. Option (a) is the smaller, more targeted change and keeps the transport branch's contract intact.

… drop; doc the reconnect emission contract

- a reconnect scheduled before the resumed stream delivered any
  ID-bearing event now falls back to options.resumptionToken at both
  _scheduleReconnection sites, so Last-Event-ID is not silently lost
  (a token-less GET starts a fresh stream and the pending response
  never replays); pinned by a fake-timers test
- changeset + upgrade-to-v2.md now state the reconnect emission
  contract: one onerror per failed attempt carrying the underlying
  error, wrapper gone, exhaustion message-text guarantee unchanged

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DThnF41VH9fXQPjGmRySxa
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown
Author

Round addressed in 61062b0: the reconnect emission-contract change is now documented in the changeset and in upgrade-to-v2.md (the exhaustion message-text guarantee is explicitly scoped, with migration guidance for monitors that matched the removed wrapper), and the pre-first-event resumption-token loss is fixed at both _scheduleReconnection sites (lastEventId ?? options.resumptionToken) with a pinning test. The e2e assertion half of the 🔴 was already handled in 123e3e9.

The cross-package cancellation finding (legacy-era notifications/cancelled inheriting the original request's resumptionToken and being swallowed by the resume short-circuit) is confirmed real and is deliberately not folded in here — filed as #2646 with the repro path and both fix options (the clean one lives in core-internal's protocol.ts).


Generated by Claude Code

Comment thread packages/client/src/client/streamableHttp.ts
Comment on lines +1 to +3
---
'@modelcontextprotocol/client': patch
---

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The changeset declares patch, but this PR adds new public API to @modelcontextprotocol/client at stable 2.0.0: UnauthorizedContext is newly exported from the package barrel, and the new optional UnauthorizedContext.signal field is a runtime-populated extension point the docs teach provider authors to adopt — under semver that is a minor bump. Consider changing the front-matter to minor (or splitting the API addition into its own minor changeset) so the new extension point isn't buried in a patch entry.

Extended reasoning...

What the issue is. .changeset/abortable-auth-awaits.md declares '@modelcontextprotocol/client': patch, but the PR's final shape adds consumer-visible API surface to a package sitting at stable 2.0.0 (packages/client/package.json):

  1. packages/client/src/index.ts newly exports the UnauthorizedContext type (added in review round f4e9e33). Per CLAUDE.md § Public API Exports, "Adding a symbol to a package index.ts makes it public API" — and git show HEAD~10 confirms the barrel had zero matches before this PR, so this is the type's nameable public debut.
  2. packages/client/src/client/auth.ts adds the optional UnauthorizedContext.signal field, and both transport paths (_send and _startOrAuthSse) now actually populate it. This is not a type-only tweak: it is a new runtime capability that docs/clients/machine-auth.md and docs/migration/upgrade-to-v2.md both explicitly teach provider authors to adopt ("forward it to your own fetches so the recovery work stops with it").

Step-by-step proof that this is the standard minor-bump marker. (1) A provider author writes onUnauthorized: ctx => { myFetch(url, { signal: ctx.signal }) }. (2) Against 2.0.0, this fails to typecheck — UnauthorizedContext has no signal property and the type isn't even importable from the barrel. (3) Against the version this changeset produces, it typechecks and works at runtime. (4) Code that compiles against 2.0.x-latest but not against 2.0.0 is precisely semver's definition of "new, backwards compatible functionality" — a MINOR bump. With the current changeset, 2.0.1 would ship with an API surface that differs from 2.0.0, which API-diff tooling and consumers treating patch releases as surface-identical will misclassify.

Repo precedent cuts both ways — here is the honest accounting. The package's own changelog records PR #1710 (which introduced the AuthProvider surface, including the line "New UnauthorizedContext type.") under Minor Changes. A refuting verifier correctly countered that packages/client/CHANGELOG.md's 2.0.0 Patch Changes section contains bugfix PRs with incidental new API declared as patch: #2441 shipped the new isJsonContentType() export, and #2384 shipped the new X.isInstance() static guards. Both claims verify. However, both of those patch declarations were made during the 2.0.0-beta prerelease phase (they first shipped in 2.0.0-beta.3), where the bump type had no user-visible consequence — every prerelease bump lands as another -beta.N regardless. The same is true of #1710's minor entry (2.0.0-alpha.1). This PR is the first such incidental API addition landing against a stable 2.0.0, where patch vs. minor is the difference between 2.0.1 and 2.1.0 and actually governs what consumers may assume. So the prerelease-era "bump tracks the change's primary character" precedent doesn't straightforwardly transfer, and strict semver applies with full force for the first time.

Impact. Beyond tooling misclassification, the release notes generated from a patch changeset bury the one thing provider authors must adopt for cooperative cancellation — the ctx.signal extension point — in a patch entry, where nobody scanning Minor Changes for new capabilities will find it.

How to fix. One word in the front-matter: patchminor. Alternatively, split the additive API (UnauthorizedContext export + signal field) into its own minor changeset and keep the abort-race bugfix as patch — changesets takes the max, so the release lands as minor either way, with cleaner notes.

Why nit. Release-metadata correctness only — nothing breaks at runtime if this merges as-is, and given the (prerelease-era) patch precedent the maintainers may reasonably make the opposite call. Non-blocking either way.

Comment on lines +315 to +323
/**
* Normalize an aborted signal's `reason` to an `Error` (matching how the
* listen driver and `fetch` surface aborts) so a raced auth await rejects
* with something callers can inspect.
*/
function abortReasonError(signal: AbortSignal): Error {
const reason: unknown = signal.reason;
return reason instanceof Error ? reason : new Error(String(reason ?? 'Aborted'));
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new module-private abortReasonError helper duplicates, byte-for-byte, the abort-reason normalization the listen driver hand-writes inline at client.ts:2091 (reason instanceof Error ? reason : new Error(String(reason ?? 'Aborted'))), and the helper's own JSDoc says the two sites must agree — but the agreement is enforced only by prose, so drift would be silent. Consider hoisting the normalization into a client-internal shared helper (taking the reason rather than the signal, so both sites can use it) and calling it from both places — the same copy-drift consolidation this PR's review rounds already performed within streamableHttp.ts.

Extended reasoning...

What the finding is. This PR adds abortReasonError(signal) (packages/client/src/client/streamableHttp.ts:315-323), whose body is reason instanceof Error ? reason : new Error(String(reason ?? 'Aborted')). The listen driver in packages/client/src/client/client.ts:2091 contains the identical expression inline: settle({ cause: 'local', error: reason instanceof Error ? reason : new Error(String(reason ?? 'Aborted')) }). The helper's JSDoc explicitly declares the pairing intentional — "matching how the listen driver and fetch surface aborts" — i.e. a send() aborted in the auth chain and a listen() caller-signal abort are meant to reject with the same normalized shape. Yet nothing enforces that: the two copies are lexically unrelated (the client.ts inline never matches a grep for abortReasonError), so a future change to one would silently strand the other.\n\nConcrete verification. A grep across packages/client/src for the instanceof Error ? … : new Error(String(…)) shape finds nine sites, but only two carry the distinctive ?? 'Aborted' fallback: the new helper (streamableHttp.ts:322) and the listen driver (client.ts:2091). The other seven (client.ts:1218/1267/1317/1825/2120/2593, streamableHttp.ts:828) are the plain String(x) idiom with no fallback and no documented coupling. So this is not merely "the idiom appears everywhere" — these two sites are a semantically coupled pair (same fallback string, same purpose, JSDoc-declared agreement) sitting in different files.\n\nStep-by-step drift scenario. (1) A future PR improves abortReasonError to preserve non-Error reasons, e.g. new Error('Aborted', { cause: reason }), or stamps name = 'AbortError' so callers can branch on it. (2) Every raced auth await in the transport now rejects with the new shape; the author greps for abortReasonError, finds only streamableHttp.ts call sites, and ships. (3) listen()'s local-abort settle() at client.ts:2091 keeps the old shape — the same user-initiated abort now surfaces two differently-shaped errors depending on which await the abort happened to land in. (4) No type or test catches it: the copies share no symbol, and no test asserts cross-site shape agreement.\n\nAddressing the refutation. One verifier argued the extraction serves only one extra call site and that the general normalization idiom is pervasive and inline throughout the codebase. The pervasiveness point is fair for the plain String(x) form, but the ?? 'Aborted' variant exists at exactly these two sites and the helper's own JSDoc claims they agree — that documented coupling is what distinguishes this pair from the surrounding idiom. The refutation is also right that the practical cost of drift is bounded (both sites still reject with an Error), and that the shared helper must not create backwards coupling — which is why the fix should be a reason-taking helper in a client-internal util module (or exported module-level from a neutral file, not added to index.ts), never an import of the transport module from client.ts. These caveats shape the fix; they don't erase the finding. On balance — three verifiers confirmed, one refuted — this is a plausible-but-optional cleanup, which is exactly what nit severity encodes.\n\nWhy the PR's own history supports acting. This PR's review rounds performed precisely this class of consolidation within streamableHttp.ts: isAborted(), _combinedSignal(), and _isIntentionalAbort() were each extracted because hand-copied predicates had drifted or nearly drifted across sites. This pair spans two files, where drift is strictly harder to notice than the intra-file cases already fixed.\n\nHow to fix (mechanical, no behavior change). Add a small shared helper in a client-internal module, taking the raw reason (the client.ts site already has the reason in hand; the transport wraps it for signals):\n\nts\nexport function normalizeAbortReason(reason: unknown): Error {\n return reason instanceof Error ? reason : new Error(String(reason ?? 'Aborted'));\n}\n\n\nThen abortReasonError becomes normalizeAbortReason(signal.reason) and client.ts:2091 becomes settle({ cause: 'local', error: normalizeAbortReason(reason) }). Keep it out of the package index.ts (internal helper, per CLAUDE.md export rules). No runtime behavior changes today; the existing test suites pass unchanged.\n\nSeverity. Quality/consistency finding only — no incorrect behavior, no crash, nothing blocks merging: nit.

A custom reconnectionScheduler that throws while scheduling attempt
N+1 ends the retry chain — terminal for an observed per-request
stream, exactly like budget exhaustion, but the catch fired only
onerror so a listen() observer waited forever. Fire the stream-end
callback too, matching the sibling terminal paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DThnF41VH9fXQPjGmRySxa
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown
Author

Latest round: the reconnect re-arm gap is fixed in 53c393c — a reconnectionScheduler that throws while scheduling attempt N+1 ends the retry chain, which is terminal for an observed per-request stream, so the catch now fires options.onRequestStreamEnd?.() alongside onerror (matching the budget-exhaustion and stream-error terminal paths), pinned by a scheduler-throws test.

Two findings are dispositioned rather than changed:

  • abortReasonError vs. the inline normalization in client.ts: agreed it's the same reason instanceof Error ? … : new Error(String(reason ?? 'Aborted')) shape, but consolidating it is a quality-only cross-file/cross-PR move (the client.ts copy is fix(client): bound McpSubscription.close()'s wait on the cancelled-notification send #2645's diff, not this one) — deferred to the same follow-up bundle as the 401-recovery extraction and the other items noted above.
  • Changeset patch vs minor for the new UnauthorizedContext export + signal field: the semver argument is fair. There is repo-specific context that makes this a maintainer call rather than a mechanical edit, though: .changeset/config.json does not set onlyUpdatePeerDependentsWhenOutOfRange, @modelcontextprotocol/client is in the fixed group with server, and all four middleware packages (express/fastify/hono/node) peer-depend on server via workspace:^ — so any non-patch changeset for the fixed group major-bumps the middleware packages 2.0.0 → 3.0.0 (this exact trap was verified via changeset status on fix(auth): treat null optional fields in token responses as absent #2462, see the "Versioning note" there). Leaving the changeset as patch and the patch/minor decision explicitly to @felixweinberger.

Generated by Claude Code

Comment on lines 816 to 827
// Honour BOTH the transport-wide abort and the per-request abort
// (a listen subscription closed during the backoff delay): do not
// resurrect a stream the caller already tore down.
if (this._abortController?.signal.aborted || options.requestSignal?.aborted) return;
this._startOrAuthSse(options).catch(error => {
if (this._abortController?.signal.aborted || options.requestSignal?.aborted) return;
this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`));
if (this._isIntentionalAbort(options.requestSignal)) return;
this._startOrAuthSse(options).catch(() => {
if (this._isIntentionalAbort(options.requestSignal)) return;
// `_startOrAuthSse`'s own catch already routed the failure to
// onerror before rethrowing — re-emitting here would report
// every failed attempt twice. Just schedule the next try.
try {
this._scheduleReconnection(options, attemptCount + 1);
} catch (scheduleError) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 Pre-existing (this PR rewrites the enclosing reconnect() closure but not the slot mechanics): _cancelReconnection is a single instance field shared by all concurrent per-stream reconnect chains (the standalone GET stream plus every primed POST-SSE stream), so every _scheduleReconnection call overwrites the previous chain's cancel handle and close() can cancel at most one pending attempt — a superseded chain's non-unref'd setTimeout stays live up to maxReconnectionDelay (30s default) after close(), and with a custom reconnectionScheduler the JSDoc promise that the cancel fn 'will be called on transport.close()' is violated for all but the last-scheduled chain. Fix: track cancel handles in a Set (add on schedule, remove when the corresponding reconnect() runs) and cancel all members in close().

Extended reasoning...

What the bug is. Reconnection chains in StreamableHTTPClientTransport are per-stream: the standalone GET stream is always isReconnectable, and every per-request POST-SSE stream that received a priming event (canResume = isReconnectable || hasPrimingEvent) runs its own chain through _scheduleReconnection. But the cancel handle lives in one instance field, this._cancelReconnection. Both scheduling branches overwrite it unconditionally — this._cancelReconnection = typeof cancel === 'function' ? cancel : undefined for a custom reconnectionScheduler, this._cancelReconnection = () => clearTimeout(handle) for the default setTimeout — and reconnect()'s first statement (streamableHttp.ts:815) unconditionally clears the slot even when the handle currently stored belongs to a different chain whose timer is still pending. close() then calls the single this._cancelReconnection?.() exactly once.

The code path that triggers it. Two chains pending at once is a routine state — one network drop severs the standalone GET stream and a primed POST-SSE stream simultaneously, and each schedules its own reconnect. Whichever schedules second wins the slot; the first chain's cancel handle is silently lost. There is also a worse mid-flight variant: when chain A's reconnect() fires while chain B's timer is still pending, A's first statement sets the slot to undefined, so a subsequent close() cancels nothing.

Step-by-step proof. (1) The standalone GET stream drops → chain A calls _scheduleReconnection, setTimeout fires later, slot = cancelA. (2) Before A's timer fires, a primed per-request POST-SSE stream drops → chain B schedules, slot = cancelB; cancelA is unreachable. (3) The caller calls transport.close()close() runs this._cancelReconnection?.(), clearing only B's timer, then aborts the transport signal. (4) A's timer — up to maxReconnectionDelay (30 s with defaults), and never unref()'d — stays armed. When it fires, reconnect()'s _isIntentionalAbort guard prevents any network traffic, but the guard runs only when the timer fires: it cannot release the timer at close time. A Node CLI or test process that closed its transport keeps the event loop pinned for up to 30 s before it can exit.

Why existing code doesn't prevent it. The _isIntentionalAbort check inside reconnect() is the only safeguard, and it is post-fire — it stops stream resurrection, not the timer itself. No test creates two concurrent reconnect chains, so the interleaving is uncovered. With a custom reconnectionScheduler the failure is a documented-contract violation rather than just an event-loop pin: the ReconnectionScheduler JSDoc (~line 137) promises the returned cancel function 'will be called on transport.close() to abort the pending reconnection' — exactly the mobile/serverless platform-background-task use case it names — yet with two concurrent chains only the last-scheduled chain's platform task is cancelled. The other stays scheduled on the platform scheduler after close() and wakes into a dead transport.

Why this is pre-existing. git show cc4b416 (main, pre-PR) has the identical single-slot structure; this PR rewrote only the error-reporting body of the reconnect() closure (the double-onerror dedup) and left the slot/cancel mechanics unchanged. It is flagged here because the PR touches this exact closure, making it the natural place to notice — but it should not block this PR.

Impact. Bounded: no network resurrection, no protocol misbehavior, no data loss. The consequences are (a) an event-loop pin of up to maxReconnectionDelay after close() with the default setTimeout path, (b) close() cancelling nothing at all in the mid-flight variant, and (c) a leaked platform background task with a custom scheduler, violating the scheduler's documented cancel contract.

How to fix. Replace the single slot with a Set<() => void>: add each chain's cancel handle when it schedules, have each reconnect() remove its own handle (captured in the closure, instead of nulling a shared field), and have close() iterate the set and cancel every member before aborting. That preserves the one-handle-per-chain lifecycle and makes the ReconnectionScheduler cancel contract hold for every pending chain.

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown
Author

Confirmed real and pre-existing: _cancelReconnection is a single instance slot, both scheduling branches overwrite it, reconnect() clears it unconditionally (orphaning a later chain's handle when an earlier chain fires), and close() invokes it at most once — so with concurrent per-stream reconnect chains only the last-scheduled one is cancellable, a superseded chain's non-unref'd setTimeout can outlive close() by up to the max reconnection delay, and the ReconnectionScheduler cancel contract holds only for the most recent chain. This diff does not touch the slot mechanics (zero _cancelReconnection lines in the branch diff); the practical blast radius is bounded because a post-close firing is stopped by the intentional-abort guards before any wire traffic — the harm is the lingering timer and the broken cancel bookkeeping under concurrency. Adding the suggested Set-of-handles fix to the follow-up bundle (alongside the 401-recovery extraction, sse.ts/withOAuth auth-await racing, anySignal fallback disposal, and abortReasonError consolidation) rather than growing this PR further.


Generated by Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-reviewed the final revision (through 53c393c) and found no new bugs — all findings from the earlier review rounds are addressed or explicitly dispositioned in the thread. Given the scope, a human maintainer look is still worthwhile before merge.

What was reviewed this run:

  • raceWithSignal semantics: aborted fast-path before the producer runs, listener removal on settlement, identity-preserved rejections (auth-seam stamp survives), and no-op handlers on the losing promise.
  • Abort classification (unstamped abort vs markAuthSeamEscape) across _commonHeaders, both onUnauthorized sites, and both step-up awaits — POST and GET paths are symmetric.
  • The reconnect emission contract, the pre-first-event resumption-token fallback, and the terminal onRequestStreamEnd paths now agree with the updated e2e assertion and are pinned by unit tests.

One item deliberately left open for a maintainer: the changeset patch vs minor call for the new UnauthorizedContext export + signal field (see the fixed-group/middleware peer-dep note in the thread).

Extended reasoning...

Overview

This PR makes the streamable HTTP transport's auth awaits (token(), onUnauthorized 401 recovery, insufficient-scope step-up) abortable by the combined request/transport signal, adds the optional UnauthorizedContext.signal extension point (newly exported from the client barrel), and — through four review rounds — also changed the reconnect onerror emission contract, fixed a pre-first-event resumption-token loss, preserved stream observers across resume-via-send(), and fixed a terminal-notification gap when a custom reconnection scheduler throws. Files touched: streamableHttp.ts (substantial), auth.ts, index.ts, docs, changeset, unit and e2e tests.

Security risks

The changes sit in the OAuth/auth-recovery seam of the client transport. No new credential handling or validation logic is introduced — the racing only changes when the transport stops waiting, not what it trusts — and the abort-vs-auth-failure classification is guarded consistently at every raced site. The raced-out auth work continuing in the background is a documented limitation, not an exposure. Still, auth-path code warrants human eyes by default.

Level of scrutiny

High. This is production-critical transport lifecycle code with subtle async semantics (signal combination, listener cleanup, unhandledRejection suppression, identity-preserved rejections for the auth-seam stamp), a user-visible onerror contract change, and new public API on a stable 2.0.0 package. The earlier review rounds surfaced real issues (a CI-breaking e2e assertion, a token-loss bug, two terminal-notification gaps), all of which were fixed with pinning tests — but the density of findings itself argues against shadow-approval.

Other factors

This run of the bug hunting system found no new issues on the final revision, and the prior rounds' findings are all resolved or dispositioned in the thread. The one open item is a semver judgment call (changeset patch vs minor) with a repo-specific fixed-group/peer-dependency trap that the thread documents — that is precisely a maintainer decision, not something an automated approval should make. Test coverage is strong: failing-first unit tests for every new abort path plus behavior-preservation controls, and the gated e2e assertion was updated to the new contract.

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

Labels

None yet

Projects

None yet

1 participant