Skip to content

fix(core): start OAuth on a modern/auto-era connect (#1805) - #1809

Merged
cliffhall merged 5 commits into
v2/mainfrom
fix/1805-era-probe-hides-auth-challenge-ci
Jul 27, 2026
Merged

fix(core): start OAuth on a modern/auto-era connect (#1805)#1809
cliffhall merged 5 commits into
v2/mainfrom
fix/1805-era-probe-hides-auth-challenge-ci

Conversation

@cliffhall

Copy link
Copy Markdown
Member

Closes #1805
Closes #1806

Maintainer-run version of @rinormaloku's #1806 — the same fix, rebased onto current v2/main, with the one review change applied and the full ci chain run locally (external PRs can't trigger workflows without approval, and #1806 had never been through the gate).

Supersedes #1806; please close that PR in favour of this one. Original commit authorship is preserved.

The bug

Connecting to an OAuth-protected HTTP server with Protocol Era = Auto or Modern never started the authorization flow. The same server authorized fine on Legacy, and the user saw a red toast instead of the OAuth redirect:

Failed to connect to "<server>": Version negotiation probe failed: Interactive auth recovery required

Root cause

Era auto/modern sends the SDK's server/discover negotiation probe before anything else, so the probe — not initialize — is where authorization first surfaces. Its classifier reports whatever the transport threw as SdkError(ERA_NEGOTIATION_FAILED), keeping the real error at data.cause. That buried both connect-time auth signals:

  • Remote path (web). The backend always sets interceptAuthChallenges: true, so a 401 becomes AuthChallengeErrorauth_challengeRemoteClientTransport rejects with AuthRecoveryRequiredError (carrying the authorization URL). Wrapped, it no longer matched err instanceof AuthRecoveryRequiredError in App.tsx, and isUnauthorizedError() couldn't see it either (no status/code). It also flipped connection status to error, which isConnectAuthRecoveryError exists to prevent.
  • Direct path (CLI/TUI). No stored tokens means no authProvider, so no interception, so the 401 reached the SDK as a raw SdkHttpError. The classifier ignores the HTTP status — it only looks for a JSON-RPC error body — and verdicts "not a modern server"; pin mode then rethrew that with the 401 discarded entirely: no status, not even a cause.
era error out of client.connect() recovery matched?
legacy AuthRecoveryRequiredError ✅ redirect
auto SdkError / ERA_NEGOTIATION_FAILED ❌ generic toast
modern (pin) SdkError / ERA_NEGOTIATION_FAILED ❌ generic toast

Changes

  • core/auth/challenge.tsfindNestedAuthError() walks cause / data.cause for a nested AuthRecoveryRequiredError / AuthChallengeError. Deliberately not gated on the SDK's error code or message, so it survives SDK rewording. This is the permanent fix.
  • core/mcp/inspectorClient.tsconnect() unwraps the client.connect() rejection at the earliest point: ahead of withDirectAuthRecovery (whose isAuthChallengeError check is shallow), the outer status guard, and every client's connect-error handling. Web, CLI, and TUI are all fixed with no client-side change.
  • core/mcp/inspectorClient.ts — on the direct path, enable interceptAuthChallenges for the probing eras even with no stored tokens, so the 401 becomes a typed AuthChallengeError that survives the probe as data.cause. This half is a workaround for the upstream gap and is now commented as removable — see below.

Review change applied on top of #1806

The era-scoped guard is kept exactly as written; only its comment changed, so its lifespan is explicit for whoever picks up #1807:

// WORKAROUND (#1807, upstream modelcontextprotocol/typescript-sdk#2561):
// remove this clause once the SDK classifies a probe 401/403 as auth-required.

Tests

  • clients/web/src/test/core/auth/challenge.test.ts — 8 cases over the walk (both link names, multi-level, already-typed, absent, non-object/null data, cyclic chain).
  • clients/web/src/test/core/mcp/inspectorClient-era-probe-auth.test.tsconnect() surfaces each typed error on auto/modern, leaves a non-auth probe failure untouched, and keeps the legacy baseline; plus probesProtocolEra().
  • clients/web/src/test/integration/mcp/inspectorClient-modern-era-oauth.test.ts — live modern-era server behind requireAuth: connect() rejects with a recoverable auth error (not a negotiation failure) on both probing eras, and connects reporting the modern era once authorization completes.

Each half of the fix is load-bearing — reverting either one fails the new integration tests.

Gate: npm run ci run locally on this branch — green end to end (validatecoverage per-file ≥90 gate → verify:build-gatesmoke incl. headless-Chromium web boot → Storybook 108 files / 460 tests).

Follow-ups (tracked, not in this PR)

🤖 Generated with Claude Code

https://claude.ai/code/session_016S7h83UKNQ7kvSiFDNksCt

rinormaloku and others added 2 commits July 27, 2026 13:38
The SDK's `server/discover` negotiation probe runs before anything else on
`protocolEra: "auto" | "modern"`, and its classifier reports whatever the
transport threw as `SdkError(ERA_NEGOTIATION_FAILED)` with the real error moved
to `data.cause`. That buried both connect-time auth signals: the remote path's
`AuthRecoveryRequiredError` (matched with `instanceof`, and carrying the
authorization URL) and a direct transport's `AuthChallengeError`. An
OAuth-protected server that authorized fine on the legacy era instead produced a
dead-end "Version negotiation probe failed" — no redirect, and the connection
status flipped to `error` despite `isConnectAuthRecoveryError` existing to
prevent exactly that.

- `findNestedAuthError()` walks `cause` / `data.cause` for either typed error.
  Not gated on the SDK's error code or message, so it survives rewording.
- `connect()` unwraps the rejection at the earliest point, ahead of
  `withDirectAuthRecovery` (whose `isAuthChallengeError` check is shallow), the
  outer status guard, and every client's connect error handling — so web, CLI,
  and TUI are all fixed without a client-side change.
- On the direct path, enable `interceptAuthChallenges` for the probing eras even
  with no stored tokens. Without an authProvider the 401 reaches the SDK as a raw
  `SdkHttpError`, and the classifier ignores the HTTP status (it only looks for a
  JSON-RPC error body) — so pin mode rethrew it with the 401 discarded entirely,
  no status and not even a cause for the walk to find.

Verified against a live dual-era server that answers 401 on both `initialize`
and `server/discover`: `auto` and `modern` now reject with
`AuthRecoveryRequiredError` carrying a real authorization URL. Each half of the
fix is load-bearing — reverting either fails the new integration tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SNjAqoEhUVVkr3UStR6om
…workaround

Per review on #1806: the `|| this.probesProtocolEra()` clause compensates for
an upstream SDK gap (modelcontextprotocol/typescript-sdk#2561, tracked as
#1807). Make its lifespan explicit so whoever picks up #1807 knows this block
is the thing to delete, rather than having to re-derive it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016S7h83UKNQ7kvSiFDNksCt
@cliffhall

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cliffhall's task in 4m 38s —— View job


Review — PR #1809 (fix(core): start OAuth on a modern/auto-era connect)

  • Gather context (diff + surrounding connect() / auth-recovery code)
  • Review core/auth/challenge.ts cause-chain walk
  • Review core/mcp/inspectorClient.ts unwrap + era-scoped intercept workaround
  • Review tests (unit, core, integration)
  • Post review

Verdict: looks good — approve-shaped. The diagnosis is precise, the permanent half (findNestedAuthError) is the right shape and correctly era-agnostic, the workaround half is properly fenced and documented with its removal condition, and the three test layers pin the right things. Nothing blocking. Findings below are one behavioral risk to be aware of, one latent (pre-existing) bug this change makes more reachable, and a few test/style nits.


What's right

  • Precedence in the walk is correct. findNestedAuthErrorDeep checks self → cause → data.cause, so an AuthRecoveryRequiredError wrapping an AuthChallengeError yields the outer one — i.e. the one carrying authorizationUrl, which is what the web redirect path needs. Cycle-safe via seen, and it returns the identical instance so rejects.toBe(...) and instanceof in App.tsx both hold.
  • Consistent with the existing precedent. isUnauthorizedError in core/auth/utils.ts:182 already walks the same two links for the same OAuth never starts on a modern/auto-era connect: the era-negotiation probe hides the auth challenge #1805 reason; this extends the pattern to the typed errors rather than inventing a new mechanism. Not keying off ERA_NEGOTIATION_FAILED or the message text is the right call.
  • Unwrap placement is genuinely the earliest point (core/mcp/inspectorClient.ts:1790) — ahead of withDirectAuthRecovery's shallow isAuthChallengeError, ahead of the outer catch's isConnectAuthRecoveryError status guard (inspectorClient.ts:1967), so web/CLI/TUI are all fixed with zero client change. No unhandled-rejection hazard: the derived promise is what connectPromise.catch(() => {}) and the Promise.race now reference.
  • The workaround is fenced properly — era-scoped, behind directAuthRecovery && isHttpOAuthConfig() && oauthManager, and the comment names both the tracking issue ([Blocked upstream] SDK probe classifier should treat 401/403 as auth-required, and pin mode must not drop the status #1807) and the upstream PR, with an explicit "delete this clause with it".

Findings

1. interceptAuthChallenges on the probing eras also intercepts 403 — which short-circuits auto's legacy fallback (low severity, worth a conscious accept)
core/mcp/inspectorClient.ts:1730

With no stored tokens the clause now turns intercept on for any HTTP server on auto/modern, and parseAuthChallengeFromResponse (core/auth/challenge.ts:257) treats 403 as well as 401 as a challenge. So a server/discover probe answered 403 for a non-auth reason (a WAF/gateway rejecting the unknown method, say) now becomes a typed AuthChallengeError and drives OAuth discovery instead of letting auto fall back to legacy initialize and connect. Pre-PR that only happened when tokens were already stored.

Genuinely rare (a non-auth 401 is essentially nonexistent; a probe-only 403 is uncommon), and the failure mode is an oauthError rather than a hang — so accepting it is reasonable, especially given the clause is temporary. But it's worth a sentence in the workaround comment so whoever removes it knows this side effect existed, or narrowing the intercept to 401 on this path.

2. Latent: after a satisfied recovery, connect() still rejects — pre-existing, but this change widens the door to it

connectPromise is created once, outside runConnect (this predates the PR — see origin/v2/main). When withDirectAuthRecovery gets {kind: "satisfied"} it calls reconnectAfterAuthRecovery() (which fully reconnects) and then retries operation() — but runConnect just re-awaits the already-rejected connectPromise, throws the same error, hits attempt >= 1, and rethrows. Net effect: the client is connected, yet connect() rejects.

That path was already reachable on the legacy era (expired token + refresh token → mcpAuth returns AUTHORIZEDsatisfied, core/mcp/oauthManager.ts:730). This PR makes it reachable on auto/modern too, since a probe 401 with stored-but-expired tokens now arrives at withDirectAuthRecovery as a typed challenge instead of being buried. Not a regression introduced here, and out of scope — but worth a follow-up issue. The narrow fix is for the retry to short-circuit when this.status === "connected" (recreating the connect promise inside runConnect would re-handshake the same client, so the status check is the safer form). Fix this →

3. probesProtocolEra() over-reports for a non-modern pin
core/mcp/inspectorClient.ts:5296

mode !== "legacy" makes { pin: "2025-06-18" } "probing". Inside the repo that can't happen (eraToVersionNegotiation only ever pins MODERN_PROTOCOL_VERSION), and versionNegotiation is a public InspectorClientOptions field, so a direct caller pinning a legacy revision would get intercept enabled needlessly. Harmless in practice; either compare a pin against MODERN_PROTOCOL_VERSION or note in the doc comment that only the repo's own era mapping is in scope.


Nits

  • Unjustified double cast in a test. clients/web/src/test/core/mcp/inspectorClient-era-probe-auth.test.ts:132 uses client as unknown as { probesProtocolEra: () => boolean } with no justifying comment — AGENTS.md requires one for as unknown as. Better still: drop the private-method test and assert the observable effect instead — have the environment transport factory capture its CreateTransportOptions and assert interceptAuthChallenges === true for auto/modern and falsy for legacy. That covers the load-bearing line (inspectorClient.ts:1730) rather than the predicate behind it, and removes the cast (and the () => ({}) as never alongside it).
  • Weak assertion. The "leaves a non-auth probe failure untouched" case matches /Version negotiation|ECONNREFUSED/ — an alternation that passes on either branch, so it doesn't actually pin that the negotiation error survives. Presumably because auto falls back to legacy (raw ECONNREFUSED) while modern pins (wrapped). Splitting it per era, or adding rejects.not.toBeInstanceOf(AuthChallengeError), would make it pin something.
  • Two near-identical walkers. findNestedAuthErrorDeep and isUnauthorizedErrorDeep (core/auth/utils.ts:186) now duplicate the same self → cause → data.cause traversal with cycle guarding, in sibling modules where one already imports the other. A shared walkCauseChain(err, visit) would let the next "…but nested" predicate be one line. Not worth blocking on.
  • findNestedAuthError doesn't traverse AggregateError.errors / an errors array. Fine for today's SDK shapes; a candidate if the probe ever aggregates.
  • Test placement, naming, and the core/auth/index.ts re-export all match the repo conventions; no README/AGENTS.md updates are owed by this diff.

Not verified locally: I read the code only — no node_modules in this checkout, so I didn't re-run the suite. Taking the PR body's locally-green npm run ci at its word.
· branch fix/1805-era-probe-hides-auth-challenge-ci

Review findings on #1809:

- connect() built its handshake promise once, outside the runConnect closure
  withDirectAuthRecovery retries. On a "satisfied" outcome the recovery runs a
  complete nested connect() via reconnectAfterAuthRecovery(), so the retry leg
  re-awaited the already-rejected promise and rejected a connect() whose client
  was in fact connected. Short-circuit the retry when already connected, and
  skip the post-connect block the nested connect already ran (a duplicate
  `connect` event re-triggers every list-state manager's refresh).
- Document the accepted 403 side effect of enabling interceptAuthChallenges
  with no stored tokens, and why every `{ pin }` counts as probing.
- Replace the private-method double cast in the era-probe test with an
  observable assertion on the transport options, and split the non-auth
  failure case per era instead of a message alternation that passed either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016S7h83UKNQ7kvSiFDNksCt
@cliffhall

Copy link
Copy Markdown
Member Author

Thanks — all four addressed in fc0395e. Point by point:

1. Intercept also catches a non-auth 403 — accepted, now documented

Agreed on the analysis and on the trade-off. Kept the behavior (narrowing to 401 would mean threading a status filter through a transport option that is boolean today, for a clause we intend to delete), and wrote the side effect into the workaround comment so it's not rediscovered later:

Known, accepted side effect of turning intercept on with no stored tokens: parseAuthChallengeFromResponse treats 403 as a challenge too, so a probe answered 403 for a non-auth reason (a gateway rejecting the unknown server/discover method, say) now starts OAuth discovery instead of letting "auto" fall back to the legacy initialize. The outcome is a surfaced oauthError, not a hang, and it goes away with this clause.

2. Satisfied recovery rejecting a connected client — real, fixed here

Confirmed by reading the path: reconnectAfterAuthRecovery() → nested connect() (which completes fully) → retry leg re-awaits the hoisted, already-rejected connectPromiseattempt >= 1 → rethrow. Fixed rather than deferred, since it's now reachable on auto/modern precisely because of this PR.

runConnect short-circuits on this.status === "connected" (your suggested form — recreating the promise would re-handshake the same client), and the outer connect() returns early instead of re-running the post-connect block; otherwise the nested connect's work is duplicated and a second connect event re-triggers every list-state manager's refresh.

New clients/web/src/test/core/mcp/inspectorClient-satisfied-recovery-connect.test.ts: a connect-time token_expired challenge, handleAuthChallengesatisfied, second transport completes the handshake. Asserts connect() resolves, status is connected, two transports were created, and exactly one connect event fired. Verified it fails without the fix (rejects with the original 401) and passes with it.

3. probesProtocolEra() and a non-modern pin — pushing back

I don't think the narrowing is right. Per the SDK's own docs on VersionNegotiationMode, { pin } is always probing:

{ pin: '<version>' } — modern era at exactly the pinned revision: the connect-time server/discover must offer it. No fallback — anything else fails loudly with a typed error.

So a pin below MODERN_PROTOCOL_VERSION still sends the probe and still buries a 401 the same way; comparing the pin would reinstate the bug for exactly that config rather than avoid needless work. Kept mode !== "legacy" and put the reasoning (with the quote) in the doc comment instead.

Nits

  • Double cast — gone. That describe now asserts the observable effect: the transport factory captures its CreateTransportOptions and the test checks interceptAuthChallenges is true for auto/modern and falsy for legacy / absent mode / absent option. Covers the load-bearing line rather than the predicate, and the () => ({}) as never went with it.
  • Weak alternation — split per era (auto/ECONNREFUSED/, modern/Version negotiation/) plus not.toBeInstanceOf for both auth error types, so it pins that nothing was unwrapped.
  • Two near-identical walkers — leaving as is. Extracting walkCauseChain means editing core/auth/utils.ts's isUnauthorizedErrorDeep for no behavior change, widening a fix PR's blast radius into the shared auth predicate. Worth doing when a third "…but nested" predicate appears.
  • AggregateError.errors — agreed, not a shape today's SDK produces; left out.

On the follow-ups

#1807 and #1808 are both filed and stay open — neither is fixed here and neither needs to be:

Finding 2 above was a new issue from this review, not either of those — handled in this PR rather than filed.

Gate: npm run ci green end to end on the pushed branch (validate → coverage ≥90 → verify:build-gate → smoke incl. headless-Chromium boot → Storybook 108 files / 460 tests). One earlier run hit an unrelated flake in remote-auth-branches.test.ts ("transport is already dead", 400 vs 200); it passed in isolation and on both full re-runs — not related to this diff, but flagging it as a flake worth watching.

@cliffhall

Copy link
Copy Markdown
Member Author

@claude review — second pass. Since the last review: the satisfied-recovery connect() rejection is fixed (short-circuit + early return, with a regression test verified to fail without the fix), the 403 side effect and the { pin } probing semantics are documented, the private-method double cast is replaced with an observable assertion on interceptAuthChallenges, and the non-auth failure case is split per era. Please focus on the new short-circuit in connect() and its test.

@claude

claude Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cliffhall's task in 3m 57s —— View job


Review — PR #1809, second pass

  • Read the incremental diff (66cd6ce8 + fc0395e4) against the first-pass state
  • Focus review: the connect() short-circuit / early return
  • Review the new regression test
  • Check the three nit fixes (double cast, split alternation, doc comments)
  • Post review

Verdict: the short-circuit is correct, and all four first-pass items are settled. I traced the state machine rather than taking the test's word for it; the reasoning below is why I think the this.status === "connected" discriminator is sound, not merely passing. Nothing blocking. One narrow race and a couple of one-line test strengthenings follow.


The short-circuit — why it holds

core/mcp/inspectorClient.ts:1811-1826 + :1859-1862

  • No false positive on attempt 0. this.status = "connecting" is set at :1783, synchronously in the same try; withDirectAuthRecovery invokes operation() synchronously before its first await (:5344), so nothing can interleave between the assignment and the check. And "connected" is written in exactly one place — connect()'s own post-connect block (:1863) — so the check can only be true if some connect() completed.
  • On the retry leg it is exactly the right condition. reconnectAfterAuthRecovery() runs disconnect() (nulls baseTransport/transport, sets disconnected, :2074-2090) → dropCachedTransport() → a full nested connect() that builds transport B and runs the whole post-connect block. So status === "connected" at the retry leg ⇔ "the nested connect already did the work".
  • No recursion into itself. The nested connect()'s own runConnect sees "connecting" (which it just set), so it can't short-circuit and no-op its own handshake.
  • The early-return scope is right. Everything after :1862 (fetchServerInfo, the connect event, setLoggingLevel, the listChanged/tasks/subscription handler registration) targets this.client — the same instance the nested connect ran against — so the skip loses nothing, and it avoids the duplicate connect event that the fix(state): gate list fetches on server capability (#1350) #1395 ordering comment at :1866 exists to protect.
  • No unhandled rejection introduced. Attempt 0 already awaited (and so handled) connectPromise; the short-circuit adds no new floating reference to it.
  • Status over recreating the promise was the right call, as you said — a second client.connect() would re-handshake the same Protocol, and by the retry leg this.transport is transport B anyway (the outer's A is closed and dropped).

The failure path also stays coherent: if the nested connect throws (refreshed token still rejected, or fetchServerInfo fails), that throw propagates out of the satisfied branch — not caught by withDirectAuthRecovery — into the outer catch at :1994, so connect() still rejects. recoveredByNestedConnect is only ever read after a clean invokeMcpClient.


Findings

1. Narrow race: if the recovered session dies before the retry leg runs, connect() rejects with the stale 401 (very low severity)
core/mcp/inspectorClient.ts:1812

The discriminator is live status, so an onclose/onerror landing between the nested connect completing and the retry leg's synchronous check flips status off "connected", runConnect falls through to await connectPromise, and the original challenge is rethrown — the pre-fix symptom, now behind a race, and reporting a misleading reason (the 401 rather than the close).

Rejecting there is arguably right (the connection is genuinely gone); it's the error identity that's off. The structural form would be to key off "am I the retry leg" rather than live status — e.g. set the flag in withDirectAuthRecovery's satisfied branch, or thread attempt into operation() — and then, when a retry leg finds the session already dead, throw the teardown reason instead of the buried handshake error. Genuinely rare (both events are in the same microtask neighbourhood) and strictly better than the pre-fix behavior either way, so fine to leave.

2. Two one-line strengthenings for the regression test
clients/web/src/test/core/mcp/inspectorClient-satisfied-recovery-connect.test.ts:118-124

The test pins the load-bearing facts (resolves, connected, two transports, exactly one connect) and pins both halves of the fix — without the early return connectEvents would be 2, without the short-circuit it rejects. Two cheap additions:

  • expect(client.getServerInfo()?.name).toBe("recovered-server") — the outer call now skips fetchServerInfo(), and nothing currently asserts the nested connect actually populated it. That's the one thing the early return could plausibly lose, and the HandshakingTransport already returns a distinctive serverInfo for it.
  • A statusChange counter alongside the connect counter — the early return suppresses a duplicate statusChange("connected") too, which is untested today.

A negative case (satisfied recovery → nested connect failsconnect() still rejects, status error) would close the loop on "the short-circuit can never mask a failed re-handshake". Optional — the code path is clear from reading, but it's the one asymmetry between what's tested and what changed.

3. Pre-existing, unchanged: recovery recursion isn't bounded across the nesting boundary

withDirectAuthRecovery's attempt >= 1 guard is per-call, and each nested connect() starts a fresh counter — so a server that 401s even with freshly refreshed tokens gives connect → challenge → satisfied → nested connect → challenge → satisfied → …. Depth is identical pre- and post-fix (the outer's retry never re-handshook either way), so this PR neither introduces nor worsens it. Noting it only because tracing the fix surfaces it; follow-up-issue material at most, not for this PR.


First-pass items — all settled

  • 403 side effect (finding 1). Accepted-and-documented is the right resolution, and I agree threading a status filter through a boolean transport option isn't worth it for a clause you intend to delete. The comment at :1728-1734 says exactly what a future reader needs.
  • Satisfied-recovery rejection (finding 2). Fixed here rather than deferred — reviewed above. Good call fixing it in this PR given the PR is what makes it reachable on auto/modern.
  • probesProtocolEra and a non-modern pin (finding 3). Your pushback is right and I withdraw it. The predicate's question is "does connect send server/discover", and per the SDK any { pin } does — so comparing against MODERN_PROTOCOL_VERSION would reinstate the buried-401 bug for exactly the config I was worried about. The doc comment at :5319-5333 now carries the reasoning with the quote; that's better than the change I suggested.
  • Double cast. Gone, and replaced with the assertion I'd have written: the factory captures CreateTransportOptions and the test checks interceptAuthChallenges for auto/modern/legacy/absent-mode/absent-option. toBeFalsy() for the legacy cases is right rather than loose — the code never sets the flag there, so undefined is the true value.
  • Weak alternation. Split per era plus not.toBeInstanceOf on both auth types, so it now pins the era fork (auto falls back and surfaces the raw error, modern pins and wraps). And the .then(() => undefined, err => err) idiom keeps the must-reject half asserted, since a resolved connect fails at toBeInstanceOf(Error).
  • Shared walkCauseChain / AggregateError.errors. Agreed on both — deferring the extraction until a third nested predicate appears is the right blast-radius call for a fix PR.
  • Follow-ups. [Blocked upstream] SDK probe classifier should treat 401/403 as auth-required, and pin mode must not drop the status #1807 (upstream) and OAuth callback leg dead-ends when discovery state is missing (AuthorizationServerMismatchError) #1808 (callback leg) correctly stay open; neither is in scope here.

One small optional gap in the new intercept test: it only exercises the || this.probesProtocolEra() right arm. A legacy-era case with stored tokens would pin the transportOptions.authProvider left arm and prove the two are independent — but that arm predates this PR, so it's not owed by this diff.

Not verified locally: code reading only — no node_modules in this checkout, so I did not re-run the suite. Taking the locally-green npm run ci at its word (and the remote-auth-branches.test.ts flake you flagged does look unrelated to this diff — it's on the remote transport path, which this PR doesn't touch).
· branch fix/1805-era-probe-hides-auth-challenge-ci

…ries

Second-pass review findings on #1809:

- The satisfied-recovery short-circuit keyed off live connection status, so an
  onclose landing between the nested connect() and the retry leg fell through
  to the rejected handshake promise and reported the stale 401 as the reason
  the session died. withDirectAuthRecovery now passes `attempt` to its
  operation, so connect() identifies the retry leg structurally and reports the
  teardown when the recovered session is already gone.
- Bound nested silent recoveries. The `attempt >= 1` guard is per-call and a
  satisfied outcome recovers via a nested connect() with a fresh counter, so a
  server that keeps rejecting freshly refreshed tokens recursed without bound.
  Counted across the nesting boundary, capped at 3.
- Strengthen the regression test (server info populated by the nested connect,
  no duplicate statusChange) and add the two missing cases: a failed reconnect
  still rejects, and the recursion bound terminates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016S7h83UKNQ7kvSiFDNksCt
@cliffhall

Copy link
Copy Markdown
Member Author

All three addressed in 54f904d — including the one you scoped out, since this PR is handling its own follow-ups rather than filing them.

1. The stale-401 race — fixed structurally

You're right that the error identity was the problem, and the structural form is better than the status proxy. Took your first suggestion: withDirectAuthRecovery now passes attempt into the operation, so connect() identifies the retry leg by what it is rather than by live status:

const runConnect = async (attempt: number): Promise<void> => {
  if (attempt > 0) {
    recoveredByNestedConnect = true;
    if (this.status !== "connected") {
      throw new Error(
        "Connection closed during authorization recovery, after re-authorizing successfully",
      );
    }
    return;
  }
  

So the close-between-legs case now rejects with the teardown reason instead of the buried handshake 401, and the rejected connectPromise is unreachable from the retry leg by construction. invokeMcpClient passes 0 on the no-recovery path; the other call sites take () => … and are unaffected.

2. Test strengthenings — both added, plus the negative case

  • expect(client.getServerInfo()?.name).toBe("recovered-server") — pins that the nested connect populated what the early return now skips.
  • A statusChange counter asserting exactly one "connected" transition.
  • The negative case you called optional: satisfied recovery → nested connect failsconnect() still rejects and status is not connected. Agreed it closes the loop, so it's in.

3. Unbounded recovery recursion — bounded here

Handling it in this PR rather than filing it. authRecoveryDepth is an instance counter incremented around reconnectAfterAuthRecovery() (in a finally), and the recovery is skipped — the challenge rethrown — once it reaches MAX_NESTED_AUTH_RECOVERIES (3). One recovery is the normal case; a couple more absorb a legitimately re-challenged reconnect; past that the credentials plainly aren't satisfying the server, so recovering again would only re-enter the same nested connect.

New test drives the pathological shape (every transport challenges, every challenge reports satisfied) and pins termination at exactly 4 transports — the initial connect plus three bounded recoveries — instead of recursing to a stack overflow.

On the optional left-arm case

Agreed it isn't owed by this diff — the transportOptions.authProvider arm predates the PR and is exercised by the existing direct-recovery suites. Left alone to keep the new test focused on the clause this PR adds.

Gate

npm run ci green end to end on the pushed branch.

One note in case it bites someone else: with FORCE_COLOR set in the shell, four TUI tests fail locally (App.test.tsx, Tabs.test.tsx) — Ink emits the accelerator underline as \e[4mT\e[24mools, which breaks their plain-substring assertions (toContain("Tools")). It reproduces on a clean v2/main and passes with color off, so it's environmental rather than anything in this diff, and CI (no TTY) never sees it. Mentioning it only because it cost a run here.

@cliffhall

Copy link
Copy Markdown
Member Author

@claude review — third pass. Since the second review: the retry leg is now identified by attempt threaded through withDirectAuthRecovery (so a close between the nested connect and the retry leg reports the teardown, not the stale 401), nested silent recoveries are bounded at 3 via authRecoveryDepth, and the regression test gained the server-info/statusChange assertions plus a failed-reconnect case and a recursion-bound case. Please focus on the attempt threading and the recovery bound.

@claude

claude Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cliffhall's task in 5m 3s —— View job


Review — PR #1809, third pass

  • Read the incremental diff (54f904d1) against the second-pass state
  • Focus: attempt threading through withDirectAuthRecovery
  • Focus: the authRecoveryDepth recovery bound
  • Review the new/strengthened tests
  • Post review

Verdict: both changes are correct, and the attempt form is strictly better than the status proxy it replaces. I traced the recursion by hand rather than trusting the tests. Nothing blocking. The one thing I'd actually ask for: the branch this commit adds — the teardown error — is the only new line with no test, and one of the two new tests doesn't exercise the path its name and comment claim.


attempt threading — why it holds

core/mcp/inspectorClient.ts:1828-1856 + :5364-5373

  • attempt > 0 ⇒ a nested connect() completed successfully. The only recursion into attempt + 1 is the satisfied branch (:5413); step_up_confirm / interactive / the error outcome all throw (:5415-5429), and reconnectAfterAuthRecovery() (:5335) is unconditionally disconnect()dropCachedTransport()await this.connect(). If that nested connect throws, the throw escapes the satisfied branch from inside the catch, so the retry leg is never entered. That's a structural implication — exactly what live status only approximated.
  • The stale-401 is now unreachable from the retry leg by construction, not merely unlikely: connectPromise is no longer awaited on any attempt > 0 path, so there is no branch that can rethrow it. The close-between-legs case reports the teardown instead. This is the right shape.
  • Bounded to one retry. attempt >= 1 at :5375 means the operation only ever sees 0 or 1, so attempt > 0 is attempt === 1 — no third leg to reason about.
  • Nothing else is perturbed. Every other invokeMcpClient call site is a zero-arg arrow (:3034, :3064, :3380, :4198…), which is assignable to (attempt: number) => Promise<T> and ignores the argument; and invokeMcpClient passes 0 explicitly on the no-recovery path (:5438), so runConnect can't short-circuit when recovery is off. withDirectAuthRecovery has no call sites outside invokeMcpClient and its own recursion.
  • No unhandled rejection. Attempt 0 always awaited connectPromise (either arm), so its rejection is observed before the retry leg exists.
  • The failure path stays coherent. The new teardown Error is thrown at attempt === 1attempt >= 1 → rethrown → outer catch at :1994. isConnectAuthRecoveryError is false for it, so status lands on "error" and the transport the nested connect built is dropped — correct, the session is gone.

The recovery bound — why it terminates

:546, :5381-5387, :5406-5411

  • The counter wraps precisely the recursive site. authRecoveryDepth is incremented around reconnectAfterAuthRecovery() only, in a finally, so it measures "recoveries currently on the stack" and can't leak. Trace: outer challenge at depth 0 → 1 → nested challenge at 1 → 2 → nested at 2 → 3 → nested challenge sees 3 >= 3 → throws. Four connect()s, four transports — matching the test's toBe(4). The throw unwinds through each finally and out of each satisfied branch, so the outermost connect() rejects with the challenge.
  • The nested connect can't be a silent no-op. connect()'s top guard (:1633 if (this.status === "connected") return;) would make the nested call a no-op and then have the retry leg report success — but reconnectAfterAuthRecovery runs disconnect() first, and disconnect() sets "disconnected" unconditionally after swallowing client.close() errors (:2088-2116), so that can't happen.
  • The bound-trip end state is sane. The innermost catch holds the status (isConnectAuthRecoveryError is true — AuthChallengeError.status === 401 reaches isUnauthorizedError), then dropCachedTransport() closes the transport, whose onclose (:845) settles the status at "disconnected". So no stuck "connecting"; the caller gets the AuthChallengeError.

Findings

1. The new teardown branch is untested — the one gap in an otherwise well-pinned change
core/mcp/inspectorClient.ts:1846-1850

grep for "Connection closed during authorization recovery" hits only the source. All three tests in the new file either reach the retry leg with status "connected" (test 1) or never reach it at all (tests 2–3, where every nested connect throws). So the behavior change this commit is named for — reporting the teardown rather than the stale 401 — is the one thing not asserted, and a regression to return (or to the old status fall-through) would stay green.

It's deterministically reachable without timing games: the nested connect dispatches connect after setting "connected" and before returning, so a test-side connect listener that calls the live transport's onclose?.() flips the status inside the nested call, and the retry leg then takes the throw. Asserting rejects.toThrow(/Connection closed during authorization recovery/) pins the error identity, which is the whole point of the change. Fix this →

2. "still rejects when the reconnect underneath the recovery fails" duplicates the recursion-bound test, and its comment is wrong
clients/web/src/test/core/mcp/inspectorClient-satisfied-recovery-connect.test.ts:139-159

Its setup is byte-identical in behavior to the bound test below it — transport: () => ({ transport: new ChallengingTransport() }) plus handleAuthChallenge → satisfied — so it walks the same four-transport bounded chain and rejects for the same reason, just with weaker assertions (rejects.toThrow(), not.toBe("connected")). The case it means to cover — a nested re-handshake that fails for a non-auth reason, so recovery doesn't re-enter and the outer must surface that error rather than early-returning — isn't exercised. Making transport 2 reject with a plain Error("handshake refused") gets it, and lets the assertion be rejects.toThrow(/handshake refused/).

Its comment also says "the status lands on error", which isn't what happens: the rejection is an AuthChallengeError (status 401), so isConnectAuthRecoveryError is true, connect()'s catch deliberately holds the status, and it settles at "disconnected" via the dropped transport's onclose. The assertion is loose enough to pass either way, which is why it went unnoticed. With the non-auth error above, "error" becomes true and the comment becomes accurate. Fix this →

3. authRecoveryDepth is an in-flight counter, not a per-chain depth (doc nit + one soft edge)
core/mcp/inspectorClient.ts:539-546

The doc says "Nesting depth of in-flight silent auth recoveries", which is exact for a single chain but not for concurrency: two RPCs challenged at the same time each increment, so they share one budget of 3 and a third concurrent recovery could be denied its first attempt. The direction of error is conservative (surface the challenge rather than recover), and concurrent reconnects on one client are already odd, so this is fine — but "recoveries currently in flight (shared across chains)" would describe it truthfully.

Related, worth one sentence in the same comment: the counter wraps only the recursive call site. The other two reconnectAfterAuthRecovery() call sites — runAmbientAuthChallenge (:5299) and completeOAuthFlow (:5454) — are uncounted, so a nested connect() reached through either starts a fresh budget of 3. Neither is recursive itself (ambient is RemoteClientTransport-only and deduped by challenge key; completeOAuthFlow is user-initiated), so nothing is unbounded — but the "capped at 3" reading is per entry point, not global.


Nits

  • No event when the bound trips. The depth check at :5381 sits before the authChallengeCommand / authChallengeAmbient dispatch, so the round that gives up emits nothing at all — not even the oauthError the error outcome branch dispatches at :5428. Consumers keying on those events see a bare rejection with no explanation of why recovery stopped. Cheap to improve (dispatch oauthError alongside the throw), and it'd give the bound test something behavioral to assert beyond the transport count.
  • operation: (attempt: number) => Promise<T> now silently hands a number to a dozen zero-arg arrows. Correct today, but it means a future invokeMcpClient((x) => …) written for some other reason gets attempt typed as its parameter. { attempt } or a plain isRetry: boolean would be self-documenting; not worth churning the call sites for.
  • probesProtocolEra's doc comment (:5346-5358) reads well now — the SDK quote makes the { pin } decision reviewable rather than asserted. Good.
  • Earlier passes' items all remain settled; the third-pass diff touches nothing in core/auth/challenge.ts, so findNestedAuthError stands as reviewed.

Not verified locally: code reading only — no node_modules in this checkout, so I did not re-run the suite. Taking the locally-green npm run ci at its word. (Neither finding above would fail the ≥90 per-file gate — inspectorClient.ts is far too large for two uncovered lines to move it — which is exactly why the untested branch needs to be caught by review rather than by the gate.)
· branch fix/1805-era-probe-hides-auth-challenge-ci

Third-pass review findings on #1809, both about tests added in the previous
commit:

- The retry leg's teardown error had no coverage, so a regression to the old
  status fall-through would have stayed green. Covered deterministically: the
  nested connect dispatches `connect` after setting "connected", so closing the
  live transport from that listener flips the status before the retry leg runs.
  Asserts the error identity, which is the point of the change.
- "still rejects when the reconnect underneath the recovery fails" used a
  challenging transport for every connect, so it walked the same bounded chain
  as the recursion-bound test rather than covering a non-auth reconnect
  failure, and its comment claimed an "error" status the auth path never
  reaches. Now fails the reconnect with a plain error and asserts both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016S7h83UKNQ7kvSiFDNksCt
@cliffhall

Copy link
Copy Markdown
Member Author

Findings 1 and 2 are fair — both are defects in tests I added last round, so they're fixed in f1af65a. The rest I'm declining, and I'm closing the review here rather than running a fourth pass. Reasoning at the end.

1. Teardown branch untested — fixed

You're right that this was the one line the commit was named for and the only one not asserted. Covered with your suggested mechanism: the nested connect dispatches connect after setting "connected", so a listener that closes the live transport flips the status before the retry leg runs. Asserts rejects.toThrow(/Connection closed during authorization recovery/) — the error identity, which is the whole point — plus a non-connected status.

2. Duplicated case with a wrong comment — fixed

Correct on both counts: it was byte-identical in behavior to the bound test, and the comment's "error" claim was never reachable on an auth rejection (isConnectAuthRecoveryError holds the status, which settles at "disconnected"). The reconnect now fails for a non-auth reason (Error("handshake refused")), so recovery doesn't re-enter, and it asserts /handshake refused/ and getStatus() === "error" — both now true, and the case is distinct from its neighbour.

3. authRecoveryDepth doc wording + uncounted call sites — declining

The bound terminates, which is what the finding asked for. Refining the comment's phrasing about a conservative failure direction, and auditing two non-recursive call sites you've already reasoned are safe, is work on a guard that was itself out of scope for this PR — it exists here only because I opted to fix it rather than file it.

Nits — declining

  • oauthError when the bound trips. That's new event semantics for consumers, not a review fix. Adding observable events in a bug-fix PR is exactly the scope creep that should go through its own change.
  • { attempt } vs positional. Churn across a dozen call sites for identical behavior; you note it isn't worth it, and I agree.

Closing the review

Three passes, three "nothing blocking" verdicts. The defect this PR exists for — OAuth never starting on a modern/auto-era connect — was fixed, tested at three layers, and verified against a live server before the first review; everything since has been review-generated work on review-generated code, and passes 2 and 3 were reviewing code the previous pass asked for. That's a loop with no natural end, and each round costs a full ci cycle.

So: merging on the current state. The remaining items above are recorded in this thread for anyone who wants them later, and they're genuinely small — none changes behavior for the bug being fixed.

Gate: npm run ci green end to end on the pushed branch.

@cliffhall

Copy link
Copy Markdown
Member Author

@claude review — fourth pass, scoped confirmation only. The only change since the third pass is f1af65a, which is test-only: it covers the retry leg's teardown branch (asserting the error identity) and rewrites "still rejects when the reconnect underneath the recovery fails" to fail the reconnect for a non-auth reason so it stops duplicating the recursion-bound test, with its comment corrected.

Please verify just those two things are now correct. As stated above, the remaining items from pass 3 (the authRecoveryDepth doc phrasing, the uncounted non-recursive call sites, an oauthError on bound-trip, and { attempt } vs positional) are deliberately not being taken in this PR — no need to re-raise them.

@claude

claude Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cliffhall's task in 1m 44s —— View job


Review — PR #1809, fourth pass (scoped confirmation)

  • Read f1af65a0 and the resulting test file
  • Verify the teardown-branch case
  • Verify the rewritten non-auth reconnect case
  • Post verdict

Both are correct. Confirmed — nothing further from me, and I'm not re-raising the four declined items. I traced each test against the source rather than trusting the names.


1. Teardown branch — covered, and it pins the error identity

inspectorClient-satisfied-recovery-connect.test.ts:196-234 vs core/mcp/inspectorClient.ts:1846-1850

The mechanism works and is deterministic, not timing-dependent:

  • attachTransportListeners (inspectorClient.ts:824-825) assigns baseTransport.onclose directly on the object the environment factory returned — so live.onclose?.() in the listener is the InspectorClient handler, not a no-op on an unwrapped object. That's the load-bearing assumption and it holds.
  • Ordering is synchronous: the nested connect() sets "connected" (:1885), await fetchServerInfo(), then dispatchTypedEvent("connect") (:1899). dispatchEvent is synchronous, so the listener's onclose runs inside that dispatch; the handler's guards are all false (disconnecting false, status is "connected", not "error"), so it writes "disconnected" before the retry leg's synchronous status read.
  • The nested connect then finishes cleanly (no initialLoggingLevel, capabilities {} so no handler registration throws) and returns, so withDirectAuthRecovery recurses to operation(1)attempt > 0 → status is "disconnected" → the teardown Error. attempt >= 1 rethrows it; the outer catch (:1994) sets "error" (isConnectAuthRecoveryError is false for a plain Error), so not.toBe("connected") holds.

It discriminates both regressions, which is what makes it worth having: a revert to a bare return resolves connect() (fails rejects), and a revert to the old live-status fall-through rejects with the AuthChallengeError (fails the regex). And the message is unique to that one throwgrep hits only inspectorClient.ts:1848 and the test — so the assertion can't pass off some other error.

2. "still rejects when the reconnect underneath the recovery fails" — now distinct, and the comment is accurate

:162-194

FailingTransport throws on send, so the nested handshake rejects with a plain Error. Traced: findNestedAuthError returns undefined → nested runConnect(0) throws → nested withDirectAuthRecovery bails at !isAuthChallengeError (:5379) → nested catch sets "error" → rethrow. That throw is raised inside the outer withDirectAuthRecovery's catch block (from await this.reconnectAfterAuthRecovery() at :5407, whose try/finally has no catch), so it escapes to connect()'s catch rather than being re-handled — the outer retry leg is never reached, which is exactly the "short-circuit must not mask a failed re-handshake" claim.

Status genuinely lands on "error" now: the nested catch sets it, dropCachedTransport's close()onclose is held by the !== "error" guard (:844), and the outer catch sets it again. So the corrected comment matches the code, and the case no longer walks the bounded four-transport chain — that's solely the test below it. The erasableSyntaxOnly note on the constructor is accurate too (clients/web/tsconfig.app.json:23).


Nothing else in the diff changed, so everything from passes 1–3 stands as reviewed. Agreed on closing the review here — the two defects fixed in this commit were in review-generated tests, and this pass found no third.

Not verified locally: code reading only (no node_modules in this checkout), so I did not re-run the suite — taking the locally-green npm run ci at its word.
· branch fix/1805-era-probe-hides-auth-challenge-ci

@cliffhall

Copy link
Copy Markdown
Member Author

Fourth pass came back clean — no findings, both test fixes confirmed, and the reviewer explicitly closed out ("nothing further from me, and I'm not re-raising the four declined items"). So the review reached its natural end rather than being cut short.

No further changes are planned on this PR. Why:

  • The bug is fixed and proven. OAuth now starts on a modern/auto-era connect. Covered at three layers — unit (the findNestedAuthError walk), core (connect() surfaces the typed error per era), and a live integration test against a real modern server behind requireAuth — and each half of the fix is load-bearing: reverting either fails the integration tests.
  • Everything after the first push was review-generated. Passes 2 and 3 reviewed code that the previous pass had asked for; pass 3's two findings were defects in tests pass 2 requested. That loop has no fixed point, and pass 4 confirms it has converged.
  • The declined items are deliberate, not overlooked. An oauthError event when the recovery bound trips is new consumer-facing semantics; { attempt } vs positional is churn across a dozen call sites for identical behavior; the authRecoveryDepth doc phrasing and the two uncounted non-recursive call sites concern a guard that was already beyond this PR's scope (added here only because I chose to fix it rather than file it). None changes behavior for the bug being fixed, and all four are recorded in this thread for anyone who wants them later.
  • Scope discipline. This started as a maintainer-run rebase of fix(core): start OAuth on a modern/auto-era connect (#1805) #1806 with one requested comment change. It has already grown a genuine second fix (the satisfied-recovery rejection) plus a recursion bound. Further polish belongs in its own change, not stapled to a fix PR.

npm run ci is green end to end on the pushed branch (f1af65a0). Ready to merge.

Note for merge: this targets v2/main, so the Closes #1805 line won't auto-close — #1805 needs closing manually and its board card moving to Done. #1807 (upstream SDK) and #1808 (callback leg) stay open by design.

@cliffhall
cliffhall merged commit 01df9d8 into v2/main Jul 27, 2026
3 checks passed
@cliffhall
cliffhall deleted the fix/1805-era-probe-hides-auth-challenge-ci branch July 27, 2026 19:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OAuth never starts on a modern/auto-era connect: the era-negotiation probe hides the auth challenge

2 participants