Summary
Connecting to an OAuth-protected HTTP server with Protocol Era = Auto or Modern never starts the authorization flow. The same server authorizes fine with Protocol Era = Legacy. The user sees 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 makes the SDK send a server/discover negotiation probe before anything else. Its classifier (probeClassifier.ts in @modelcontextprotocol/client) reports whatever the transport threw as SdkError(ERA_NEGOTIATION_FAILED), keeping the original error at data.cause. That hides both connect-time auth signals this codebase keys off:
-
Remote path (web). The backend always runs interceptAuthChallenges: true (core/mcp/remote/node/server.ts), so a 401 becomes AuthChallengeError → auth_challenge → RemoteClientTransport rejects with AuthRecoveryRequiredError (carrying the authorization URL). Wrapped in SdkError, it no longer matches err instanceof AuthRecoveryRequiredError at clients/web/src/App.tsx:2654, and isUnauthorizedError() at :2670 can't see it either (it has no status/code). Also flips connection status to error at core/mcp/inspectorClient.ts:1548, which isConnectAuthRecoveryError exists to prevent.
-
Direct path (CLI/TUI). No stored tokens means no authProvider, so no interception, so the SDK surfaces the raw 401 as SdkHttpError. The probe classifier ignores the HTTP status (it only looks for a JSON-RPC error body), verdicts "legacy", and in pin mode rethrows as ERA_NEGOTIATION_FAILED with the 401 discarded entirely — no status, not even a cause:
SdkError ERA_NEGOTIATION_FAILED — "the server did not offer pinned protocol
version 2026-07-28 via server/discover (no fallback in pin mode)"
Reproduced against a real dual-era OAuth server (401 + WWW-Authenticate: Bearer resource_metadata=… on both initialize and server/discover):
| era |
error out of client.connect() |
recovery matches? |
| legacy |
AuthRecoveryRequiredError |
✅ redirect |
| auto |
SdkError / ERA_NEGOTIATION_FAILED |
❌ generic toast |
| modern (pin) |
SdkError / ERA_NEGOTIATION_FAILED |
❌ generic toast |
core/auth/utils.ts:177-181 already anticipates this wrapper shape, but only walks the chain for UnauthorizedError — not for the two typed errors the Inspector's own paths actually produce.
Not related to the 2026-07-28 authorization SEPs (SEP-2468 iss, SEP-837 application_type, SEP-2352 issuer binding, DCR deprecated in favour of CIMD). Era-legacy authorization against the same server works; only the probe wrapper is at fault.
Fix
core/auth/challenge.ts: add a cause-chain walker that returns a nested AuthRecoveryRequiredError / AuthChallengeError hidden inside a wrapper error.
core/mcp/inspectorClient.ts: unwrap the client.connect() rejection with it, before withDirectAuthRecovery and the outer isConnectAuthRecoveryError check see it. Fixes web, CLI, and TUI in one place — no client-side changes needed.
core/mcp/inspectorClient.ts: on the direct path, enable interceptAuthChallenges for era auto/modern even with no stored tokens, so the 401 becomes a typed AuthChallengeError that survives the probe as data.cause instead of being silently reclassified as "not a modern server".
Workaround until then
Connect once with Protocol Era = Legacy, complete OAuth, then switch to Modern and reconnect — with tokens stored the probe carries the Bearer and server/discover succeeds.
Upstream
Worth reporting separately: classifyHttpError should treat HTTP 401/403 on the probe as auth-required rather than as absence of modern evidence, and pin mode should not drop the status.
Summary
Connecting to an OAuth-protected HTTP server with Protocol Era = Auto or Modern never starts the authorization flow. The same server authorizes fine with Protocol Era = Legacy. The user sees a red toast instead of the OAuth redirect:
Root cause
Era
auto/modernmakes the SDK send aserver/discovernegotiation probe before anything else. Its classifier (probeClassifier.tsin@modelcontextprotocol/client) reports whatever the transport threw asSdkError(ERA_NEGOTIATION_FAILED), keeping the original error atdata.cause. That hides both connect-time auth signals this codebase keys off:Remote path (web). The backend always runs
interceptAuthChallenges: true(core/mcp/remote/node/server.ts), so a 401 becomesAuthChallengeError→auth_challenge→RemoteClientTransportrejects withAuthRecoveryRequiredError(carrying the authorization URL). Wrapped inSdkError, it no longer matcheserr instanceof AuthRecoveryRequiredErroratclients/web/src/App.tsx:2654, andisUnauthorizedError()at:2670can't see it either (it has nostatus/code). Also flips connection status toerroratcore/mcp/inspectorClient.ts:1548, whichisConnectAuthRecoveryErrorexists to prevent.Direct path (CLI/TUI). No stored tokens means no
authProvider, so no interception, so the SDK surfaces the raw 401 asSdkHttpError. The probe classifier ignores the HTTP status (it only looks for a JSON-RPC error body), verdicts "legacy", and in pin mode rethrows asERA_NEGOTIATION_FAILEDwith the 401 discarded entirely — nostatus, not even acause:Reproduced against a real dual-era OAuth server (401 +
WWW-Authenticate: Bearer resource_metadata=…on bothinitializeandserver/discover):client.connect()AuthRecoveryRequiredErrorSdkError/ERA_NEGOTIATION_FAILEDSdkError/ERA_NEGOTIATION_FAILEDcore/auth/utils.ts:177-181already anticipates this wrapper shape, but only walks the chain forUnauthorizedError— not for the two typed errors the Inspector's own paths actually produce.Not related to the
2026-07-28authorization SEPs (SEP-2468iss, SEP-837application_type, SEP-2352 issuer binding, DCR deprecated in favour of CIMD). Era-legacy authorization against the same server works; only the probe wrapper is at fault.Fix
core/auth/challenge.ts: add a cause-chain walker that returns a nestedAuthRecoveryRequiredError/AuthChallengeErrorhidden inside a wrapper error.core/mcp/inspectorClient.ts: unwrap theclient.connect()rejection with it, beforewithDirectAuthRecoveryand the outerisConnectAuthRecoveryErrorcheck see it. Fixes web, CLI, and TUI in one place — no client-side changes needed.core/mcp/inspectorClient.ts: on the direct path, enableinterceptAuthChallengesfor eraauto/moderneven with no stored tokens, so the 401 becomes a typedAuthChallengeErrorthat survives the probe asdata.causeinstead of being silently reclassified as "not a modern server".Workaround until then
Connect once with Protocol Era = Legacy, complete OAuth, then switch to Modern and reconnect — with tokens stored the probe carries the Bearer and
server/discoversucceeds.Upstream
Worth reporting separately:
classifyHttpErrorshould treat HTTP 401/403 on the probe asauth-requiredrather than as absence of modern evidence, and pin mode should not drop the status.