* refactor(web): update OAuth E2E tests to use new authentication method - Replaced `runGuidedAuth` with `authenticate` in the InspectorClient OAuth end-to-end tests to support quick authentication with CIMD pre-registration. - Updated comments to reflect the change in authentication method and its support for HTTP test metadata URLs. - Ensured that the tests continue to validate the expected authorization URL after the change. Additionally, imported `ensureCimdClientRegistration` in the OAuthManager to streamline client registration during the authentication process. * Guided-auth removal, OAuth smoke testing, and TUI/CLI support * Added CLI OAuth runner flags tests * Added clarifying comment on port 0 binding * README notes on TUI oauth callback port, prettier fixes for CLI tests * CIMD validation uses inline like EMA IdP * Fixed TUI test failures from merge * test: bring 5 OAuth files over the 90 per-file coverage gate The guided-auth-removal work added/touched several files that fell below the repo's >=90 per-file coverage gate (npm run coverage). CI doesn't run the gate (it's local-only), so validate + test:integration passed while these gaps slipped through. Bring them all back over the line: - core/auth/node/runner-oauth-callback.ts: test bad-URL/non-http-scheme throws, no-port->80 default, and IPv6 hostname bracketing. Annotate the provably-dead guards (empty host, out-of-range port, String(err) fallback, pathname "/" fallback) -- all rejected by new URL() upstream -- with justified v8 ignore comments instead of lowering the gate. - core/auth/cimd.ts: test the AS-doesn't-support-CIMD early return. - core/react/useClientSettingsDraft.ts: test the functional-updater path, the stale-onChange-after-close prev===null guard, and the timer-fires-after-close value===null guard. - ClientSettingsForm.tsx: test the CIMD checkbox toggle and CIMD URL clear. - clients/tui/src/utils/oauthDisplay.ts: test static/dcr registration kinds, the expired IdP session, and the configured-scope/empty-scope branches of formatScopes. npm run coverage now passes (exit 0) across web, cli, tui, launcher. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: cliffhall <cliff@futurescale.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
35 KiB
Inspector V2 — Mid-session authorization
Brief | V2 Scope | Auth hardening | EMA / XAA | Smoke testing
Design for mid-session authorization in Inspector: detecting when MCP traffic needs new or elevated credentials, responding with the correct OAuth or EMA flow, and restoring the connection — across web, TUI, and CLI.
This spec generalizes beyond expired access tokens to include step-up authorization (e.g. a tool call returns 403 with error="insufficient_scope" and the scopes required for that operation — see SEP-2350 below).
Summary
Inspector v2 already supports connect-time OAuth and EMA:
InspectorClient.authenticate()/completeOAuthFlow()(V2 Scope)- Connect-time 401 handling in web
App.tsxand TUI Auth tab hints - EMA legs 2–3 refresh via
EmaTransportOAuthProviderwhen a liveOAuthClientProvideris on the transport (EMA spec)
Gap: Authorization can fail whenever the MCP server rejects the credentials in use — during connect() (including reconnect with a stored token snapshot), after a successful connect (expired or revoked tokens), or on insufficient scope for a specific request. The web client compounds this: MCP runs on the Hono backend with a token snapshot at connect time (createTokenAuthProvider), so the backend cannot complete interactive OAuth or reliable silent refresh on its own.
This spec defines:
- A normalized
AuthChallengemodel (what went wrong + what is required). - A single core entry point
handleAuthChallenge()(how Inspector responds). - Remote event propagation so the browser can run auth and reconnect.
- Phased delivery (recovery → step-up → client parity → RPC replay) — see Architecture.
Architecture
Code layout
- Challenge types live in
core/auth/challenge.ts. OAuthManager.handleAuthChallenge()implements the handler;InspectorClientexposes a delegating wrapper.- Web orchestration starts in
App.tsx; extractclients/web/src/utils/authChallengeFlow.tsif wiring exceeds ~50 lines.
Web: detection and wire protocol
- Backend detection: fetch wrapper on the transport passed to
createTransportNode— intercept the MCP HTTPResponsebefore the SDK consumes it, parseWWW-Authenticate, emit an SSEauth_challengeevent. The frozencreateTokenAuthProviderstub cannot complete interactive OAuth; do not rely on stubauth(). - SSE event type: dedicated
auth_challenge(nottransport_error). - Browser dispatch:
InspectorClientauthChallengetyped event. - HTTP status helpers:
isAuthChallengeError()for mid-session 401 and 403;isUnauthorizedError()remains for connect-time 401 only. - Post-recovery:
disconnect()→connect()to re-snapshot tokens to the backend. No token-push API in v1. - Deduplication: in-memory per session, keyed by
reason+ sortedrequiredScopes; suppress duplicates until satisfied or scopes change. - Multi-tab: duplicate modals are acceptable until Phase 4
RemoteOAuthStorage; thennavigator.locks.request()single-flight per server URL insidehandleAuthChallenge().
TUI / CLI: detection
- Same
handleAuthChallenge()entry via a transport fetch wrapper, before the SDK auth retry path. - Intercept 401 and 403 on streamable HTTP; run union scopes in
handleAuthChallenge()for step-up. Do not rely on the SDK built-in 403 retry (challenge-only scope, no SEP-2350 union). - Legacy SSE transport: 401 only (no 403 step-up in SDK).
- Replace TUI
show401AuthHintwith theauthChallengeevent (Phase 4). - Phase A: rely on SDK in-flight retry where applicable. Phase C adds explicit pending-RPC replay.
The SDK (@modelcontextprotocol/sdk 1.29.0) auto-retries 401/403 on streamable HTTP send() but does not union scopes for step-up — Inspector owns SEP-2350 union in handleAuthChallenge().
Scope and EMA
- Previously requested scopes:
OAuthStorage.scope/saveScope()per server. - After successful authorize:
saveScope(authorizationScopes); step-up uses union; 401 re-login replaces scope. - EMA mint scopes on 401 refresh: challenge
scopefromWWW-Authenticateif present, else configuredoauth.scope, else PRMscopes_supported(resolveEmaScopesorder). - EMA step-up (valid IdP session): silent legs 2–3 re-mint with
authorizationScopes(union). Same toast as 401 refresh. No modal, no resource-AS redirect. Resource MCP scopes are on leg 2/3 token requests, not leg 1 (openid offline_accessonly).
RPC retry
- After every successful recovery (401 refresh, EMA re-mint, step-up), retry the failed MCP request. Phases 1–3 may ship without auto-retry; Phase 5 queues
AuthChallenge.context.pendingRequestand replays once aftersatisfied+ reconnect (bounded).
UX
| Situation | Behavior |
|---|---|
| Silent recovery (refresh / EMA re-mint / EMA step-up) | Brief toast: “Refreshing authorization…” |
| 401 — interactive re-auth required | Toast “Session expired, re-authenticating…” → auto-start redirect (same as connect-time). No confirm modal. |
| 403 step-up — standard OAuth | Blocking modal: scopes, tool context, Authorize / Cancel |
| Cancel on standard-OAuth step-up modal | Stay connected. Failed tool shows error. Other scoped operations may still work. Do not disconnect. |
| 401 — user aborts IdP redirect or callback fails | Stay connected (degraded). Persistent re-auth banner. Auth-gated calls fail until recovery. Do not auto-disconnect. |
Hard failure (kind: "failed") |
Persistent error toast |
TUI: standard-OAuth step-up uses Auth tab message + Cancel semantics. EMA step-up is silent. CLI mirrors when OAuth is wired (Phase 4).
Connection Info showing effective vs pending scopes is out of scope for v1.
When silent recovery fails
Silent path = refresh token grant (standard OAuth) or EMA legs 2–3 re-mint (valid IdP session). Falls through to interactive when silent cannot succeed:
| Protocol | Silent fails when |
|---|---|
| Standard OAuth | No refresh_token; refresh token expired/revoked; AS rejects refresh; no tokens in storage |
| EMA | No IdP session; legs 2–3 mint error (bad resource client creds, AS/network errors) |
| Step-up (403) | Standard OAuth: interactive consent (modal + resource-AS redirect). EMA (valid IdP session): silent legs 2–3 re-mint — same as 401 refresh |
| Web | Silent runs in the browser after SSE auth_challenge; the backend cannot refresh frozen tokens |
Test infrastructure
Extend test-server-oauth.ts with scope-check middleware that returns 403 + insufficient_scope for scoped tool routes.
Normative references
- MCP authorization (2025-11-25) — current stable; includes Step-Up Authorization Flow and Runtime Insufficient Scope Errors
- MCP authorization (draft — 2026-07-28 RC target) — upcoming auth hardening; release candidate announcement lists six authorization SEPs including SEP-2350
- SEP-2350 — Clarify client-side scope accumulation in step-up authorization (issue #2349); merged into the draft spec's step-up flow
- EMA extension — see v2_auth_ema.md
- OAuth 2.0 Bearer Token Usage (
WWW-Authenticate,insufficient_scope) — RFC 6750 §3.1 @modelcontextprotocol/sdk1.29.0 (pinned in repo) —StreamableHTTPClientTransportinvokes SDKauth()on 401 and 403insufficient_scopeduringsend()when anauthProvideris attached; legacySSEClientTransporthandles 401 only onsend()(no 403 step-up)
Goals
Phase A — Mid-session token recovery (implement first)
- One core path for all clients: parse or receive a challenge →
handleAuthChallenge()→ updated tokens or interactive auth. - Web remote architecture: backend detects and emits challenges; browser handles auth (never runs OAuth redirects on the server).
- Support token refresh (silent when possible) for 401 / invalid or expired tokens at runtime.
- EMA-aware: re-run legs 2–3 when the resource token expires; leg 1 only when IdP session is missing or expired (EMA 401 rules).
- Preserve existing connect-time OAuth behavior — no regressions.
Phase B — MCP step-up authorization (after Phase A)
- Implement SEP-2350 client-side scope accumulation when servers emit runtime
403 insufficient_scopechallenges per the draft Step-Up Authorization Flow. - Align with the upcoming 2026-07-28 MCP authorization revision (Inspector targets the draft semantics even while pinned to SDK/spec
2025-11-25today).
Phase C — Pending RPC retry (after Phase A recovery works)
- After any successful auth recovery (401 refresh, EMA re-mint, step-up), automatically retry the MCP request that failed.
- Phases 1–3 may ship without auto-retry; Phase 5 adds queued replay of
AuthChallenge.context.pendingRequest.
Non-goals
- v1 / v1.5 backport (v2 only).
- Client credentials grant (#1225) — separate track.
- SAML EMA leg 1 — out of scope per EMA spec.
- IdP RP-initiated logout (end-session) — local sign-out only today; see EMA spec §Future.
- Defining MCP server or authorization-server wire formats — Inspector consumes whatever the SDK and HTTP responses expose; extensibility hooks documented below.
Terminology
| Term | Meaning |
|---|---|
| Auth challenge | Structured description of why authorization failed and what is required to proceed. Not raw HTTP. |
handleAuthChallenge() |
Core orchestrator: given a challenge, attempt silent satisfaction, else start interactive auth. |
| Connect-time auth | First authorization during InspectorClient.connect() when no token snapshot is sent (web: App.tsx → authenticate() on plain 401). Already implemented for that path. |
| Mid-session auth | Any authorization failure after tokens were supplied to the transport — including reconnect with a stored snapshot, post-connect RPCs, expiry, revocation, and step-up scopes. This spec. |
| Step-up auth | MCP Step-Up Authorization Flow: token is valid but insufficient scope for the current operation. Runtime signal is typically HTTP 403 + WWW-Authenticate: Bearer error="insufficient_scope". Governed by SEP-2350. |
| Recover / refresh | Informal shorthand for satisfying a token_expired (or similar) challenge without user interaction when refresh or EMA re-mint succeeds. Prefer handleAuthChallenge in API names. |
| Token snapshot | Web-only: OAuth tokens copied into POST /api/mcp/connect and frozen in createTokenAuthProvider on the backend. |
Current architecture (why mid-session fails on web)
TUI / CLI Web
───────── ───
InspectorClient InspectorClient (browser)
└─ live OAuthClientProvider └─ live OAuthClientProvider
└─ MCP SDK transport └─ RemoteClientTransport.start()
└─ 401 → provider.auth() └─ snapshots tokens once
└─ Hono backend
└─ createTokenAuthProvider (frozen)
└─ MCP SDK transport
└─ 401 → stub cannot refresh/redirect
TUI/CLI: OAuth authority and MCP transport live in the same process. The SDK can call tokens(), refresh, or fire oauthAuthorizationRequired.
Web: OAuth authority is in the browser; MCP HTTP is on the backend. Only connect-time 401 is wired today when no token snapshot is sent (App.tsx → authenticate()). When the browser does send a snapshot (reconnect with stored tokens, or post-OAuth connect() where the server still rejects the token), failures on /api/mcp/send — including initialize during connect() — do not trigger browser re-auth. The MCP SDK invokes auth() on the frozen createTokenAuthProvider stub; recovery fails and often surfaces as HTTP 500 (e.g. SDK error "OAuth client information must be saveable for dynamic registration") instead of 401, because the stub cannot persist DCR results or run browser redirects.
Known failure: reconnect with stored tokens (pre–Phase 2)
This is the same root cause as mid-session tool-call failures; only the triggering RPC differs (initialize during connect() vs e.g. tools/list after connected).
| Situation | Today (web remote) | After Phase 2 |
|---|---|---|
| No tokens in storage; user clicks Connect | Remote 401 → browser authenticate() → OAuth → connect |
Unchanged (connect-time path) |
| Stored tokens present (expired, revoked, wrong registration, or server-invalidated); user clicks Connect | Token snapshot sent → MCP 401 → stub auth() → 500 / opaque SDK error; user sees Failed to connect, not re-auth |
Fetch wrapper emits auth_challenge → browser handleAuthChallenge() → refresh or interactive re-auth → reconnect |
| Connected; token becomes invalid; user calls a tool | Same stub failure or opaque error on /api/mcp/send |
Same auth_challenge → recovery → reconnect |
Workaround until Phase 2: Clear stored OAuth state for the server (Server Settings or Connection Info), then Connect again to take the no-snapshot connect-time path. Do not rely on proactive JWT exp checks at connect as a substitute for Phase 2 — they only cover clock-expired JWTs, not server-rejected tokens, opaque access tokens, or registration mismatches; challenge detection from the MCP HTTP response remains the source of truth (see Parsing).
SEP-2350 — step-up authorization
SEP-2350 clarifies how MCP clients should behave during step-up authorization — when a server rejects a request because the access token lacks scopes needed for that specific operation.
Inspector implements SEP-2350 in Phase B (after basic mid-session 401 / token-refresh handling). Until then, step-up challenges may surface as opaque tool-call failures.
What the upcoming MCP spec requires
From the draft authorization spec (incorporating SEP-2350):
| Situation | HTTP status | WWW-Authenticate |
Client behavior |
|---|---|---|---|
| No token / invalid / expired token | 401 | scope may guide initial selection |
Refresh if possible; else full (re-)authorization. Replace scope set on full re-login (down-scoping opportunity). |
| Valid token, insufficient scope (runtime) | 403 | error="insufficient_scope", scope="…" per RFC 6750 §3.1 |
Step-up flow — see below. |
Server posture (SEP-2350): servers emit scopes needed for the current operation only, not the union of everything the client was ever granted. Servers remain stateless regarding client scope history.
Client posture (SEP-2350 — Step-Up Authorization Flow step 2):
- Parse
WWW-Authenticatefrom the 403 (or AS error) response. - Compute
requiredScopes = union(previouslyRequestedScopes, challengeScopes)— union of the client's previously requested scope set and the scopes from the current challenge. Do not replace the prior set with the challenge scopes alone (that would drop permissions needed for other tools). - Initiate (re-)authorization with the union scope set.
- Retry the original MCP request with the new token (bounded retries).
Reference implementation discussion: python-sdk PR #2676 (403 → union; 401 → replace).
Inspector mapping for SEP-2350
- Persist
previouslyRequestedScopesper server inOAuthStorage.scopeviasaveScope(). parseAuthChallengeFromResponse()maps 403 +insufficient_scope→AuthChallenge { reason: "insufficient_scope", requiredScopes }.handleAuthChallenge()for standard OAuth: build authorize URL with union scopes (interactive). For EMA (valid IdP session): silent legs 2–3 re-mint withauthorizationScopes— resource scopes are on the leg 2/3 token requests, not leg 1 OIDC scopes.- UX: standard OAuth step-up — modal (“Tool X needs additional permissions…”). EMA step-up — silent toast only (valid IdP session); mint failure surfaces error toast.
Auth challenge model
Type shape (core/auth/challenge.ts)
/** Why authorization failed for this MCP interaction. */
export type AuthChallengeReason =
| "unauthorized" // Generic 401 — details unknown
| "token_expired" // Access token no longer accepted
| "insufficient_scope" // Step-up: more scopes required
| "invalid_token"; // Malformed or wrong audience/resource
/** Normalized challenge for handleAuthChallenge(). */
export interface AuthChallenge {
reason: AuthChallengeReason;
/** Scopes from the current challenge (step-up). Per RFC 6750 §3.1 / MCP Runtime Insufficient Scope Errors — scopes needed for this operation. */
requiredScopes?: string[];
/**
* For step-up (SEP-2350): union of previously requested scopes and requiredScopes.
* Set by handleAuthChallenge before re-authorization; not sent on the wire.
*/
authorizationScopes?: string[];
/** Resource indicator / MCP resource URL when known (EMA RFC 8707). */
resource?: string;
/** Resource authorization server audience when known. */
audience?: string;
/** Optional human-readable detail from server or SDK (for UI, not parsing). */
message?: string;
/** MCP method / tool name that triggered the challenge (for UX: “authorizing for tool X”). */
context?: {
method?: string;
toolName?: string;
/** Phase C: JSON-RPC request to replay after successful recovery. */
pendingRequest?: import("@modelcontextprotocol/sdk/types.js").JSONRPCMessage;
};
/** Opaque raw hints for logging and forward-compatible parsers. */
raw?: {
httpStatus?: number;
wwwAuthenticate?: string;
};
}
Parsing (best effort, extensible)
Challenge construction is layered — do not message-guess. Parse at the point of failure: when the MCP transport returns 401/403, when transport.send() throws, or when onerror fires with an auth status code.
- SDK / transport error — preserve HTTP status /
code(existing pattern incore/mcp/remote/node/server.ts; web usesisAuthChallengeError()for mid-session detection). Treat 401 and 403 separately per MCP Error Handling (401 = invalid/missing token; 403 = insufficient scope). WWW-AuthenticateBearer — parseerror="insufficient_scope",scope="…",error="invalid_token",resource_metadata="…", etc. from the HTTP response headers on the failing MCP request. See Runtime Insufficient Scope Errors.- Future MCP extensions — challenge payloads attached to JSON-RPC errors; map into the same struct without changing
handleAuthChallenge()'s signature.
When parsing fails, use reason: "unauthorized" and still allow interactive re-auth.
Challenge vs connect-time 401
| Connect-time (no snapshot) | Runtime / reconnect (snapshot sent) | |
|---|---|---|
| When | First connect with no stored tokens; initialize gets 401 before any bearer token was sent to the backend |
Reconnect with stored tokens, or any MCP request after a token snapshot was frozen on the backend — including initialize during connect() |
| Detection | connect() throws 401 to the browser |
MCP HTTP 401/403 on backend transport → auth_challenge (Phase 2); today often 500 from stub auth() |
| Handler | authenticate() (today) |
handleAuthChallenge() (this spec) |
| Web follow-up | Redirect or silent connect | Recover tokens in browser → disconnect + connect to re-snapshot → Phase C replays pending RPC |
Both paths may call the same underlying OAuth/EMA primitives (authenticate(), refresh, completeOAuthFlow()); only detection and re-snapshot reconnect differ. Phase 2 unifies recovery for the snapshot path; it does not replace the no-snapshot connect-time path.
Core API — handleAuthChallenge()
Location: OAuthManager.handleAuthChallenge(); InspectorClient exposes a delegating wrapper.
export type AuthChallengeOutcome =
| { kind: "satisfied" } // New tokens in OAuthStorage; caller may reconnect transport
| { kind: "interactive"; authorizationUrl: URL }
| { kind: "failed"; error: Error };
/** Satisfy an auth challenge when possible. */
async handleAuthChallenge(challenge: AuthChallenge): Promise<AuthChallengeOutcome>;
Strategy by protocol and reason
Standard OAuth (protocol: "standard")
| Reason | Silent path | Interactive path |
|---|---|---|
token_expired, invalid_token, unauthorized |
SDK refresh via stored refresh_token when AS supports it |
New authorization code flow (authenticate()) |
insufficient_scope |
Not applicable — need new consent | SEP-2350: authorization URL with authorizationScopes = union(previously requested, requiredScopes) — not replace |
Uses existing BaseOAuthClientProvider, storage, and authenticate() / completeOAuthFlow().
EMA (protocol: "ema")
Per EMA 401 rules: do not fall back to standard resource-OAuth redirect.
| Reason | Silent path | Interactive path |
|---|---|---|
token_expired, unauthorized (resource token) |
refreshEmaResourceTokens() / legs 2–3; scopes: challenge WWW-Authenticate scope → configured oauth.scope → PRM scopes_supported |
— |
| IdP session missing / expired | — | Leg 1 IdP OIDC redirect (startEmaIdpAuthorization) then legs 2–3 |
insufficient_scope |
Silent: re-mint legs 2–3 with authorizationScopes (union) |
Leg 1 IdP redirect when IdP session invalid — separate from step-up UX; then legs 2–3 with union scopes |
Uses EmaTransportOAuthProvider, emaFlow.ts, and resourceContext.ts (extend scope resolution to prefer challenge scopes when present).
After kind: "satisfied"
| Client | Action |
|---|---|
| TUI / CLI | Live provider on transport; SDK may retry in flight. Phase C: replay context.pendingRequest if set. |
| Web | disconnect() → connect() to re-snapshot tokens. Phase C: replay context.pendingRequest after reconnect. |
Until Phase C, the failed tool call may still require a manual retry after recovery.
After kind: "interactive"
Same as connect-time today:
- Web (401): toast → auto-redirect →
/oauth/callback→completeOAuthFlow()→ reconnect. - Web (403, standard OAuth): modal → on Authorize, stash pending server id → redirect →
/oauth/callback→completeOAuthFlow()→ reconnect. - EMA (IdP session missing or expired): leg 1 IdP redirect → callback → legs 2–3 → reconnect.
- TUI / CLI:
oauthAuthorizationRequired→ browser → callback →completeOAuthFlow()→ reconnect if needed.
After kind: "failed" or user Cancel (standard-OAuth step-up modal only)
Do not disconnect the MCP session for recoverable challenges.
| Reason | Cancel / failed outcome |
|---|---|
insufficient_scope (standard OAuth, user Cancelled) |
Stay connected; failed tool shows error; other scoped operations may still work |
insufficient_scope (EMA, silent path) |
No Cancel — auto re-mint; on mint failure → kind: "failed" toast, stay connected (degraded) |
token_expired / unauthorized |
Stay connected (degraded); banner to re-authenticate; auth-gated calls fail until recovery |
Remote wire protocol (web)
Backend reports challenges; browser handles them.
Detection
Auth challenges are detected when MCP traffic fails — on the HTTP response from the MCP server, in transport.send() error handling, or in transport onerror. The backend emits a structured event; the browser runs handleAuthChallenge().
MCP SDK transport (RemoteSession on Hono backend)
└─ HTTP 401/403 or SDK auth failure on send / stream
└─ parseAuthChallengeFromResponse() at this hook
└─ RemoteSession.pushEvent({ type: "auth_challenge", data })
└─ SSE → browser RemoteClientTransport
└─ InspectorClient → handleAuthChallenge()
Web — detection (core/mcp/remote/node/)
Inside an active RemoteSession, when MCP traffic fails with an auth error:
- Fetch wrapper on the backend transport — wrap the fetch passed to
createTransportNode. Intercept the MCP HTTPResponsebefore the SDK consumes it. On 401 or 403, parseWWW-Authenticate, emitauth_challenge, and do not let the SDK callauth()on the frozencreateTokenAuthProviderstub. /api/mcp/send— extend to preserve 403 and map stub-auth failures to structured errors (today: 401 only; stub failures often return 500).- Transport
onerror— secondary path when the SDK reports auth-related failures without a parseable response (preserve status/code; do not collapse to generic 500).
Parse WWW-Authenticate from the response headers on the failing request.
Do not confuse MCP server OAuth with Inspector launcher auth (x-mcp-remote-auth on requests to the Hono API — that is session auth to the remote backend, not MCP server OAuth).
TUI / CLI — detection (direct transport)
Same handleAuthChallenge() entry via transport fetch wrapper (before SDK auth retry):
- Intercept 401 and 403 on streamable HTTP; run
handleAuthChallenge()with SEP-2350 union scopes for step-up. Do not rely on SDK built-in 403 retry alone. - Legacy SSE transport: 401 only (no 403 step-up in SDK).
- Dispatch
authChallengeonInspectorClient(Phase 4 replaces TUIshow401AuthHint). oauthAuthorizationRequiredfires whenhandleAuthChallenge()returnsinteractive.
SSE event
Extend RemoteEvent in core/mcp/remote/types.ts:
export interface RemoteAuthChallengeEvent {
type: "auth_challenge";
data: AuthChallenge & {
/** Server catalog id — browser resolves InspectorClient instance. */
serverId?: string;
};
}
Rules:
- Emit once per recoverable 401/403 auth challenge (dedupe per Architecture §Web: detection and wire protocol).
- Do not mark transport dead for recoverable auth challenges unless the SDK closed the connection.
- Include
requiredScopeswhen parsed fromWWW-Authenticate. - Attach
context.pendingRequestwhen the failing RPC is known (Phase C).
Browser handling
RemoteClientTransportreceivesauth_challengeon SSE.InspectorClientdispatchesauthChallenge.- App calls
handleAuthChallenge(challenge)(viaauthChallengeFlow.tsonce extracted). - On
satisfiedor post-callback success: reconnect active server; Phase C replays pending RPC. - UX per Architecture §UX.
Client matrix
| Concern | Web | TUI | CLI |
|---|---|---|---|
| Challenge detection | SSE auth_challenge from RemoteSession |
InspectorClient auth hook on live transport / provider |
Same as TUI when OAuth wired |
| Auth execution | Browser OAuthManager |
Node OAuthManager |
Node (when implemented) |
| OAuth storage today | BrowserOAuthStorage (sessionStorage) |
NodeOAuthStorage (file) |
None |
| OAuth storage target | RemoteOAuthStorage → shared oauth.json (EMA spec §Shared storage) |
File | File |
| Post-success | Remote reconnect (+ Phase C RPC replay) | Reconnect / SDK retry (+ Phase C) | Same as TUI when OAuth wired |
| Step-up UX | Modal (standard OAuth); silent (EMA) | Same | Same as TUI when OAuth wired |
| EMA IdP config | Client Settings | client.json (Phase 4) |
client.json (Phase 4) |
Relationship to other specs
| Doc | Relationship |
|---|---|
| v2_auth_ema.md | EMA legs 2–3 re-mint on resource-token challenges; scope resolution; no resource-OAuth fallback |
| v2_auth_smoke_testing.md | Manual smokes after implementation; add mid-session / step-up scenarios |
| v2_storage.md | Shared oauth.json via RemoteOAuthStorage |
| v2_scope.md | Mid-session authorization extends “OAuth Handling” |
| v2_auth_hardening.md | Connect-time SEPs (2468, 837, 2352, 2207, 2351); v2 SDK upgrade path; overlaps SEP-2350 scope union |
Phased implementation
Phases 1–2 deliver Phase A (token recovery). Phase 3 delivers Phase B (SEP-2350 step-up). Phase 4 is client parity and shared storage. Phase 5 delivers Phase C (pending RPC replay).
Phase 1 — Foundation (core + types)
- Add
AuthChallenge,AuthChallengeReason,AuthChallengeOutcomeincore/auth/challenge.ts - Add
parseAuthChallengeFromResponse(...)— 401 and 403,WWW-Authenticate, SDK error - Add
isAuthChallengeError()in web utils - Implement
OAuthManager.handleAuthChallenge()for standard OAuth (token_expired/ generic 401 → refresh or interactive) - Unit tests for parser and standard-OAuth branches
Phase 2 — Web remote propagation (401 / token recovery)
- Backend fetch wrapper: detect MCP 401/403 before frozen stub
auth(); emit SSEauth_challenge(applies to/api/mcp/sendand failures during connect handshake, e.g.initialize) - Extend
/api/mcp/sendand connect-time transport failures for 403 and stub-auth error mapping (never surface raw SDK saveable for dynamic registration as an opaque 500 to the browser) - Browser:
RemoteClientTransport→InspectorClientauthChallengeevent →handleAuthChallenge() - On satisfaction: disconnect + reconnect; wire 401 auto-redirect; standard-OAuth step-up modal
- Integration test (mid-session): invalidate access token after connect → challenge → reconnect →
tools/listsucceeds (manual tool retry until Phase 5) - Integration test (reconnect): complete OAuth, invalidate access token (or use expired JWT fixture), disconnect →
connect()→ challenge → recovery → connected (must not throw saveable for dynamic registration) - Integration test (silent refresh, web remote): static client +
refresh_token, invalidate access token only → challenge → silent refresh → reconnect → success (mirror localinspectorClient-oauth-e2erefresh test viacreateRemoteTransport)
Phase 3 — SEP-2350 step-up + EMA scope challenges (Phase B)
- Parse 403
insufficient_scope; scope union viasaveScope(authorizationScopes) - EMA 403: silent legs 2–3 with union scopes (valid IdP session); leg 1 only when IdP session invalid
- Extend
test-server-oauth.tswith 403 +insufficient_scopefixture - Integration test: 403 step-up → union re-auth → tool succeeds (manual retry until Phase 5)
- Backend:
auth_challengefor 403 (included in Phase 2 wrapper; verify step-up path)
Phase 4 — Client parity + storage
- TUI: fetch wrapper +
authChallengeevent (replaceshow401AuthHint) - CLI: wire
environment.oauth; same handler - Web:
RemoteOAuthStorage(sharedoauth.json) +navigator.lockssingle-flight - Multi-tab dedupe once shared storage lands
Phase 5 — Pending RPC replay (Phase C)
- Attach failing JSON-RPC to
AuthChallenge.context.pendingRequestat detection - After
satisfied+ reconnect (web) or satisfied on live transport (TUI/CLI): replay once (bounded) - Integration tests: 401 refresh, EMA re-mint, and 403 step-up all replay the original tool call
- On replay failure: surface tool error; do not loop
Testing
| Layer | What to prove |
|---|---|
| Unit | Challenge parsing; scope merge; EMA scope preference over config |
| Integration (local AS) | Expired token → silent refresh → success (TUI direct transport) |
| Integration (web remote, mid-session) | Invalidate token after connect → SSE auth_challenge → reconnect → tools/list |
| Integration (web remote, reconnect) | Invalidate/expired token before connect() with stored snapshot → challenge → recovery → connected (no stub DCR 500) |
| Integration (web remote, refresh) | Invalidate access token only; refresh_token present → silent refresh → reconnect |
| Integration (Phase C replay) | 401 / EMA / 403 recovery → original tool call replays automatically |
| Integration (SEP-2350 step-up) | MCP server returns 403 insufficient_scope → union re-auth → retried tool call |
| EMA | Invalidate resource JWT only; legs 2–3 re-run; IdP session still valid |
| Manual | Document in v2_auth_smoke_testing.md §Mid-session auth |
File touch list (expected)
| Area | Files |
|---|---|
| Types | core/auth/challenge.ts |
| Handler | core/mcp/oauthManager.ts, core/auth/ema/emaFlow.ts, core/auth/ema/resourceContext.ts |
| Remote | core/mcp/remote/types.ts, core/mcp/remote/node/remote-session.ts, core/mcp/remote/node/server.ts, core/mcp/remote/remoteClientTransport.ts, transport fetch wrapper in core/mcp/node/transport.ts |
| Web app | clients/web/src/App.tsx, clients/web/src/utils/authChallengeFlow.ts, clients/web/src/utils/oauthFlow.ts (isAuthChallengeError) |
| TUI | clients/tui/src/App.tsx |
| Test server | test-servers/src/test-server-oauth.ts |
| Tests | clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts, new remote auth-challenge + Phase C replay tests |