inspector/specification/v2_auth_mid_session.md

44 KiB
Raw Blame History

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.tsx and TUI Auth tab hints
  • EMA legs 2–3 refresh via EmaTransportOAuthProvider when a live OAuthClientProvider is 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:

  1. A normalized AuthChallenge model (what went wrong + what is required).
  2. A single core entry point handleAuthChallenge() (how Inspector responds).
  3. Remote event propagation so the browser can run auth and reconnect.
  4. 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; InspectorClient exposes a delegating wrapper.
  • Web orchestration starts in App.tsx; extract clients/web/src/utils/authChallengeFlow.ts if wiring exceeds ~50 lines.

Web: detection and wire protocol

  • Backend detection: auth-challenge intercept fetch composed with createFetchTracker on the transport passed to createTransportNode — intercept the MCP HTTP Response before the SDK consumes it, parse WWW-Authenticate, short-circuit SDK auth() on the frozen stub (throw or structured failure; do not rely on stub auth()).
  • Dual delivery (mutually exclusive):
    • Command-scoped — failure during an active POST /api/mcp/send (or connect handshake send): return HTTP 200 with { ok: false, kind: "auth_challenge" | "transport_error", … }. Do not also emit SSE for the same incident. Client handles recovery and retries the same JSON-RPC in that call chain (closure holds the message; no pendingRequest echo required).
    • Ambient — failure with no correlated remote API request in flight (e.g. subprocess exit while idle, background MCP stream drop on stateful transports): SSE auth_challenge or transport_error only.
  • Remote API vs upstream errors: reserve HTTP 4xx on the Inspector remote API for true API failures (bad JSON, missing session, x-mcp-remote-auth). Upstream MCP auth/transport outcomes use ok: false + kind on 200 (or a dedicated dependency status if preferred) — do not overload remote 401 with MCP token expiry.
  • 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.
  • Command retry (in scope Phases 2–3): after handleAuthChallenge() succeeds, replay the failed send once in RemoteClientTransport / InspectorClient (bounded; no loop). callTool may stay pending through auth + retry instead of failing then requiring manual retry.
  • Deduplication: in-memory per session, keyed by reason + sorted requiredScopes; suppress duplicates until satisfied or scopes change.
  • Multi-tab: duplicate modals are acceptable until Phase 4 RemoteOAuthStorage; then navigator.locks.request() single-flight per server URL inside handleAuthChallenge().

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 show401AuthHint with the authChallenge event (Phase 4).
  • Web remote: command-scoped auth retry in RemoteClientTransport.send() (Phases 2–3). TUI/CLI direct transport: same pattern via fetch intercept + local retry (Phase 4).

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 scope from WWW-Authenticate if present, else configured oauth.scope, else PRM scopes_supported (resolveEmaScopes order).
  • 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_access only).

RPC retry

  • After every successful recovery (401 refresh, EMA re-mint, step-up), retry the failed MCP request once (bounded; on replay failure surface the tool error, do not loop).
  • Command-scoped (Phases 2–3, in scope): inline /api/mcp/send response → handleAuthChallenge() → reconnect → retry the same JSON-RPC from the caller closure in RemoteClientTransport / InspectorClient.callTool. No SSE pendingRequest needed.
  • Ambient SSE (Phase 5 / rare): when auth or transport failure is delivered only via SSE (no active send), attach context.pendingRequest if a replay target exists; otherwise mark session degraded until the user acts.

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 inline send auth_challenge or ambient SSE; the backend cannot refresh frozen tokens

Test infrastructure — composable server scope requirements

Step-up UX and integration tests need an MCP server that returns HTTP 403 + WWW-Authenticate: Bearer error="insufficient_scope", scope="…" on specific operations while accepting a valid token for others. Use the config-driven composable test server (server-composable, test-server-http.ts) — not hard-coded routes in application code.

Config: requiredScopes on preset refs

Add an optional requiredScopes field on tool, resource, and prompt preset refs in composable config files. resolve-config.ts merges it onto the resolved capability definition; at HTTP startup the server builds a lookup registry from that merged config.

{
  "serverInfo": { "name": "step-up-demo", "version": "1.0.0" },
  "transport": { "type": "streamable-http", "port": 8099 },
  "oauth": {
    "enabled": true,
    "requireAuth": true,
    "scopesSupported": ["mcp", "tools:read", "weather:read", "admin:write"],
    "supportDCR": true,
    "supportRefreshTokens": true
  },
  "tools": [
    { "preset": "echo" },
    { "preset": "get_temp", "requiredScopes": ["weather:read"] },
    { "preset": "add", "requiredScopes": ["admin:write"] }
  ],
  "resources": [
    {
      "preset": "static_text",
      "params": { "uri": "file:///secret.txt", "name": "secret" },
      "requiredScopes": ["secrets:read"]
    }
  ]
}
Field Location Purpose
oauth.scopesSupported Existing Advertises scopes in AS / protected-resource metadata (scopes_supported). List every scope the AS may grant, including those referenced by requiredScopes. Inspector discovers these for connect-time and step-up consent.
requiredScopes New on preset refs Scopes the bearer token must include for this capability. Omitted → only global bearer validity applies (401 if missing/invalid token; no 403 step-up).

Smoke flow: connect with scopes: "mcp tools:read" → unscoped tools work → calling get_temp → 403 insufficient_scope with scope="weather:read" → Inspector step-up → union re-auth → tool succeeds.

Canonical fixture: test-servers/configs/oauth-step-up-demo.json (add with implementation).

Enforcement (HTTP middleware)

MCP step-up is signaled at the streamable-HTTP transport layer, not as a JSON-RPC error inside HTTP 200. Implement enforcement in test-server-oauth.ts (or a small helper it calls), after bearer token validation and before the MCP transport handler:

  1. Parse the incoming JSON-RPC body (method, params).

  2. Resolve the target capability and its requiredScopes from the registry built at startup.

  3. Read granted scopes from the access token (see below).

  4. If the token is valid but lacks required scopes, respond 403 with:

    HTTP/1.1 403 Forbidden
    WWW-Authenticate: Bearer error="insufficient_scope", scope="weather:read"
    Content-Type: application/json
    

    Use the missing scope(s) in the scope= parameter (space-separated if multiple). Body: JSON-RPC error envelope (same pattern as existing 401 middleware).

Method → registry lookup:

MCP method Registry key
tools/call tool name (params.name)
resources/read resource uri (params.uri)
prompts/get prompt name (params.name)
resources/templates/read template name or URI from params

Non-goals for v1 fixtures: do not require step-up on tools/list, resources/list, or prompts/list — real servers typically step up on use, not discovery. If list-level challenges are needed later, add an optional advanced oauth.operations map (see below); not required for Phase 3 smoke tests.

Token scope storage (combined mode prerequisite)

Today combined-mode opaque access tokens are stored in a Set with no granted scope. Scope enforcement requires:

  • storeAccessToken(token, { scope }) at authorization-code and refresh-token issue time (scope from authorize query / stored auth-code data).
  • getAccessTokenScope(token) for middleware checks (space-separated scope string, OAuth convention).
  • Protected-resource mode: read scope from the verified JWT payload when present; fall back to stored metadata for opaque tokens.

Middleware compares granted scopes (split on spaces) against requiredScopes (all must be present).

Optional advanced: oauth.operations

For method-wide defaults (e.g. a baseline scope on every tools/call), an optional oauth.operations map may be added later:

"oauth": {
  "operations": {
    "tools/call": { "requiredScopes": ["tools:execute"] }
  }
}

Effective requirement = union of matching oauth.operations rule(s) and per-capability requiredScopes. Defer until a concrete smoke scenario needs list- or method-level challenges.

Implementation checklist (Phase 3 test server)

  • Extend PresetRef / load-config.ts with optional requiredScopes?: string[]
  • Extend ToolDefinition, ResourceDefinition, PromptDefinition with requiredScopes?: string[]; merge in resolve-config.ts
  • Build scope-requirements registry in test-server-http.ts from resolved ServerConfig
  • Store granted scope on access tokens (combined mode); expose scope lookup for middleware
  • Add scope-check middleware: valid token + missing scope → 403 + insufficient_scope
  • Add test-servers/configs/oauth-step-up-demo.json and document manual smoke steps in v2_auth_smoke_testing.md

Normative references

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_scope challenges 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-25 today).

Phase C — Command retry (web remote)

  • After successful auth recovery on a command-scoped failure, automatically retry the failed MCP request once (Phases 2–3). Ambient SSE replay edge cases remain Phase 5.

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):

  1. Parse WWW-Authenticate from the 403 (or AS error) response.
  2. 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).
  3. Initiate (re-)authorization with the union scope set.
  4. 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 previouslyRequestedScopes per server in OAuthStorage.scope via saveScope().
  • 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 with authorizationScopes — 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;
    /** Optional: JSON-RPC to replay for ambient SSE delivery only (no caller closure). */
    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.

  1. SDK / transport error — preserve HTTP status / code (existing pattern in core/mcp/remote/node/server.ts; web uses isAuthChallengeError() for mid-session detection). Treat 401 and 403 separately per MCP Error Handling (401 = invalid/missing token; 403 = insufficient scope).
  2. WWW-Authenticate Bearer — parse error="insufficient_scope", scope="…", error="invalid_token", resource_metadata="…", etc. from the HTTP response headers on the failing MCP request. See Runtime Insufficient Scope Errors.
  3. 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 → inline send retry (Phases 2–3)

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; fetch intercept + send retry after handleAuthChallenge() (Phase 4).
Web disconnect() → connect() to re-snapshot tokens, then inline send retry (Phases 2–3).

Ambient SSE failures without a caller closure may require manual retry or Phase 5 pendingRequest replay.

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. One internal AuthChallenge object; one delivery channel per incident (inline or SSE, never both).

Detection

Auth challenges are detected when MCP traffic fails — on the MCP HTTP response (fetch intercept), in transport.send() error handling, or in transport onerror. The browser runs handleAuthChallenge().

Command-scoped (primary):
  POST /api/mcp/send
    └─ backend transport fetch intercept → parseAuthChallengeFromResponse()
         └─ return 200 { ok: false, kind: "auth_challenge", authChallenge }
              └─ RemoteClientTransport.sendWithAuthRecovery()
                   └─ handleAuthChallenge() → reconnect → retry same JSON-RPC

Ambient (fallback):
  transport.onclose / background MCP failure (no active send)
    └─ RemoteSession.pushEvent({ type: "auth_challenge" | "transport_error", data })
         └─ SSE → shared recoverAuth() handler

Web — detection (core/mcp/remote/node/)

Inside an active RemoteSession, when MCP traffic fails with an auth error:

  • Auth-challenge intercept fetch — composed with createFetchTracker on the fetch passed to createTransportNode. On 401 or 403, parse WWW-Authenticate, short-circuit SDK auth() on the frozen stub.
  • /api/mcp/send — when failure is tied to that request, return structured ok: false body (not opaque 500); do not push SSE for the same failure.
  • Transport onerror / onclose — when no send is correlated, push SSE auth_challenge or transport_error (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).

/api/mcp/send response shape (command-scoped)

type RemoteSendResponse =
  | { ok: true }
  | {
      ok: false;
      kind: "auth_challenge";
      authChallenge: AuthChallenge;
    }
  | {
      ok: false;
      kind: "transport_error";
      error: string;
    };

Reserve HTTP 4xx/5xx on the remote API for Inspector API failures only (malformed body, unknown sessionId, launcher auth).

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 authChallenge on InspectorClient (Phase 4 replaces TUI show401AuthHint).
  • oauthAuthorizationRequired fires when handleAuthChallenge() returns interactive.

SSE event (ambient only)

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:

  • Use SSE only when no active /api/mcp/send is waiting for this failure — never duplicate an inline send response.
  • Emit once per recoverable ambient 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 requiredScopes when parsed from WWW-Authenticate.
  • Attach context.pendingRequest only for ambient cases where replay is desired and no caller closure exists.

Browser handling

Command-scoped (primary):

  1. RemoteClientTransport.send() receives { ok: false, kind: "auth_challenge" }.
  2. sendWithAuthRecovery() calls handleAuthChallenge() (shared with SSE path).
  3. On satisfied or post-callback success: disconnect() → connect() to re-snapshot tokens.
  4. Retry the same JSON-RPC once (bounded); surface error if replay fails.
  5. UX toasts/modals via shared authChallengeFlow.ts if wiring exceeds ~50 lines.

Ambient (SSE fallback):

  1. SSE consumer receives auth_challenge or transport_error.
  2. Same handleAuthChallenge() / degraded-session handler (no automatic RPC retry unless pendingRequest present).
  3. UX per Architecture §UX.

Client matrix

Concern Web TUI CLI
Challenge detection Inline send response (primary); SSE auth_challenge (ambient) Fetch intercept on live transport 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 + inline send retry (Phases 2–3) Reconnect + local send retry (Phase 4) 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 + command-scoped retry). Phase 3 delivers Phase B (SEP-2350 step-up + retry). Phase 4 is client parity and shared storage. Phase 5 covers ambient SSE replay edge cases and hardening.

Phase 1 — Foundation (core + types)

  • Add AuthChallenge, AuthChallengeReason, AuthChallengeOutcome in core/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 auth-challenge intercept fetch: detect MCP 401/403 before frozen stub auth() (applies to /api/mcp/send and failures during connect handshake, e.g. initialize)
  • /api/mcp/send structured response: { ok: false, kind: "auth_challenge", authChallenge } for command-scoped failures; reserve remote HTTP 401 for launcher auth only
  • RemoteClientTransport.sendWithAuthRecovery(): handleAuthChallenge() → reconnect → retry same JSON-RPC once
  • Ambient failures only: SSE auth_challenge / transport_error (never duplicate inline send)
  • 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/list auto-retries and succeeds
  • 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 → auto-retry succeeds

Phase 3 — SEP-2350 step-up + EMA scope challenges (Phase B)

  • Parse 403 insufficient_scope; scope union via saveScope(authorizationScopes)
  • EMA 403: silent legs 2–3 with union scopes (valid IdP session); leg 1 only when IdP session invalid
  • Composable test server: requiredScopes on preset refs + HTTP scope middleware (403 + insufficient_scope) — see Test infrastructure
  • Add test-servers/configs/oauth-step-up-demo.json; manual smoke steps in v2_auth_smoke_testing.md
  • Integration test: 403 step-up → union re-auth → tool auto-retries and succeeds
  • Verify 403 inline send path (same intercept fetch as 401; no SSE duplicate)

Phase 4 — Client parity + storage

  • TUI: fetch wrapper + authChallenge event (replace show401AuthHint)
  • CLI: wire environment.oauth; same handler
  • Web: RemoteOAuthStorage (shared oauth.json) + navigator.locks single-flight
  • Multi-tab dedupe once shared storage lands

Phase 5 — Ambient replay + hardening

  • Ambient SSE auth_challenge: attach context.pendingRequest when replay target exists and no caller closure
  • TUI/CLI direct transport: sendWithAuthRecovery() parity with web (Phase 4 if not done earlier)
  • Integration tests: ambient transport failure paths; replay failure surfaces tool error; no infinite loop
  • Align with stateless MCP remote invoke (future): inline request/response remains primary delivery

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 (command retry) 401 / EMA / 403 recovery → original tool call auto-retries via inline send (Phases 2–3)
Integration (SEP-2350 step-up) MCP server returns 403 insufficient_scope → union re-auth → retried tool call
Composable fixture (manual / CI) oauth-step-up-demo.json: connect with subset of scopesSupported → scoped tool/resource → 403 → step-up UX
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, test-servers/src/test-server-http.ts, test-servers/src/load-config.ts, test-servers/src/resolve-config.ts, test-servers/src/composable-test-server.ts, test-servers/configs/oauth-step-up-demo.json
Tests clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts, new remote auth-challenge + command-retry tests