inspector/specification/v2_auth_mid_session.md
cliffhall c20fce9ba2 docs(spec): align mid-session auth spec with the token_expired predicate
Copilot review on #2105: specification/v2_auth_mid_session.md still said
checkAuthChallengeSatisfied is satisfied for token_expired / unauthorized by
any valid non-expired token. Only token_expired can short-circuit, and only
for a provably unexpired JWT; invalid_token and unauthorized are always false
(which the code already did before this branch).

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 07:35:40 -04:00

42 KiB
Raw Permalink Blame History

Inspector V2 Auth — Mid-session authorization

Brief | V1 Problems | V2 Scope | V2 Tech Stack | V2 UX | V2 Auth | V2 New Spec Impact

Overview | EMA / XAA | Hardening | Mid-session | Smoke testing | SDK consolidation

Design and implementation reference for mid-session authorization in Inspector: detecting when MCP traffic needs new or elevated credentials, running the correct OAuth or EMA recovery flow, and restoring the session — across web, TUI, and CLI.

For hands-on verification, see v2_auth_smoke_testing.md §5.


Summary

Inspector already supports connect-time OAuth and EMA: the first connect() that hits 401 triggers authenticate() / browser redirect / loopback callback, and tokens land in per-client storage.

Mid-session authorization covers everything after that: a token expires, is revoked, or lacks scopes for a specific operation while the user is working. The server signals this with HTTP 401 (bad/missing token) or 403 + insufficient_scope (valid token, not enough scope — step-up). Inspector normalizes those signals into an AuthChallenge, runs handleAuthChallenge(), and either refreshes silently or starts interactive OAuth.

Client Transport model Auth runs where Recovery highlights
Web Browser → Hono remote backend → MCP server Browser (OAuthManager) POST /api/mcp/auth-state hot-swap; full-page OAuth + resume snapshot
TUI / CLI Direct SDK transport in-process Node (OAuthManager) Loopback callback on 127.0.0.1:6276; user retries action after interactive auth

All three clients share core/auth/challenge.ts and OAuthManager.handleAuthChallenge(). UX and wire protocol differ by client.


Background

Connect-time vs mid-session

Connect-time Mid-session
When First connect with no valid tokens, or reconnect before a remote session exists Any MCP RPC after credentials were supplied
Typical signal connect() throws 401 MCP HTTP 401/403 on an in-flight request
Web handler App.tsx → authenticate() → redirect handleAuthChallenge() → auth-state push and/or redirect
Core handler Same OAuth primitives OAuthManager.handleAuthChallenge()

Both paths use the same token storage, refresh, and authorization-code exchange; only detection and session update mechanics differ.

Step-up authorization (SEP-2350)

Step-up is OAuth for “I have a token, but not permission for this operation.” MCP servers usually express it as:

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

By contrast, 401 means the token is missing, invalid, or expired — fix by refresh or full re-login.

SEP-2350 (in the MCP 2026-07-28 authorization hardening) requires clients to accumulate scopes on step-up:

Situation Client scope behavior
403 step-up Union previously requested scopes with scopes from the challenge — do not drop scopes needed for other tools
401 re-login Replace scope set (user may down-scope at the AS)

Inspector persists granted scopes in OAuthStorage.scope (saveScope()): when the authorization server returns a scope parameter on the token response, that value is authoritative (RFC 6749 §5.1 — including when the grant is a subset of what was requested). When scope is omitted on a successful response, granted scope is assumed to equal what was requested for that exchange. Storage must never claim scopes the access token was not granted.

For step-up, handleAuthChallenge() computes authorizationScopes as the union of stored grant + challenge scopes for the authorization request; saveScope() runs only after a successful grant, using the rules above — not the pre-redirect requested union when the AS returned an explicit smaller scope.

UX consequence: standard-OAuth and web EMA step-up need user-visible consent before proceeding (web modal, TUI Auth tab confirm, CLI y/N). On the web client, EMA insufficient_scope shows the same StepUpAuthModal pattern as standard OAuth, with organization/IdP copy; only after Authorize does Inspector run silent re-mint or start an IdP redirect. TUI/CLI may still re-mint silently after their own confirm prompt — see EMA step-up (web).

Rationale (Inspector as a testing tool): EMA can often satisfy scope upgrades without a visible IdP prompt when the organization session is still valid. That is convenient in production clients, but Inspector deliberately surfaces the requested scopes first so operators can see what permission elevation is happening while exploring MCP servers — the same visibility standard OAuth step-up already provides on web.

Normative background: MCP authorization — Step-Up Authorization Flow, Runtime Insufficient Scope Errors, RFC 6750 §3.1.


Terminology

Term Meaning
App tab An Inspector view inside one browser window: Tools, Resources, Prompts, etc. (activeTab in App.tsx). Not a browser tab.
Browser tab A top-level browsing context (window/tab) running the Inspector SPA. Each has its own InspectorClient, remote session, and OAuth context.
Auth challenge Normalized { reason, requiredScopes?, … } — not raw HTTP. Built by parseAuthChallengeFromResponse() or received on the remote wire.
handleAuthChallenge() Core orchestrator: silent refresh / EMA re-mint, or start interactive OAuth. Returns satisfied, step_up_confirm (EMA/web deferral), interactive, or failed.
RemoteAuthState Payload for POST /api/mcp/auth-state: fresh oauthTokens (+ optional oauthClient) pushed to the node backend without tearing down the MCP transport.
Command-scoped recovery Failure during an active user action (tool call, connect button). May retry the same JSON-RPC once after silent recovery.
Ambient recovery Failure with no correlated in-flight command (idle SSE). Prepares the session for the next user action — no auto-retry of a specific RPC.
OAuth resume snapshot sessionStorage blob written before a full-page OAuth redirect so the web app can restore server, app tab, and form state after callback.

Architecture

Two transport models

Inspector implements mid-session auth twice, by design — not as a shared transport wrapper.

Web remote (browser proxy):
  Browser RemoteClientTransport
    → POST /api/mcp/send
    → Hono RemoteSession + fetch intercept (frozen stub OAuth provider on node)
    → upstream MCP server
  Recovery: handleAuthChallenge() in browser → POST /api/mcp/auth-state → optional send retry

TUI / CLI direct:
  InspectorClient + live OAuthClientProvider on SDK StreamableHTTPClientTransport
  Recovery: handleAuthChallenge() in-process → token swap / reconnect
  Interactive: runRunnerInteractiveOAuth() + loopback callback (127.0.0.1:6276)

Why not unify? The web node backend uses a stub provider (no redirects on the server). TUI/CLI hold a live provider. The v2 TypeScript SDK will own silent 401/403 retry on direct streamable HTTP (#2265); Inspector keeps handleAuthChallenge(), EMA paths, and interactive deferral (modal / confirm / snapshot). See v2_auth_hardening.md.

Explicit non-goals for TUI/CLI: client-side fetch intercept on the SDK transport, AuthRecoveryTransport, or mirroring RemoteClientTransport on direct paths.

Recovery shapes

Shape When MCP retry Page unload
Silent in-process Refresh, EMA re-mint after user confirmed step-up (web modal Authorize), TUI/CLI confirm Yes (command-scoped, once) No
In-app step-up confirm Web EMA insufficient_scope before re-mint / IdP No — modal only; retry after success toast No
Interactive full-page Standard-OAuth step-up, 401 re-login, EMA IdP leg 1 (after web confirm when applicable) No — user retries action after callback Yes (web)
Browser InspectorClient              Hono RemoteSession              MCP server
─────────────────────────            ──────────────────              ──────────
RemoteClientTransport                  StreamableHTTPClientTransport
  OAuthClientProvider (live)             createRemoteAuthProvider (mutable)
       │                                      │
  POST /api/mcp/send ───────────────────────►│ same transport ───────────────►│
       │◄─ { ok: false, auth_challenge }──────│                                │
  handleAuthChallenge()                                                       │
  POST /api/mcp/auth-state { authState } ───►│ setAuthState() → new Bearer    │
  POST /api/mcp/send (retry) ─────────────────►│ same mcp-session-id ────────►│

Why auth-state push? An earlier approach disconnected and reconnected the remote session on every recovery, which broke upstream MCP session continuity. Hot-swapping credentials on the existing transport matches direct streamable-http behavior.

Code map

Area Primary files
Challenge types & parsing core/auth/challenge.ts, core/auth/oauthUx.ts
Handler & storage core/mcp/oauthManager.ts, core/auth/mcpAuth.ts, core/auth/scopes.ts
EMA core/auth/ema/emaFlow.ts, core/auth/ema/resourceContext.ts — see v2_auth_ema.md
Web remote backend core/mcp/remote/node/server.ts, remote-session.ts, core/mcp/node/authChallengeFetch.ts
Web remote client core/mcp/remote/remoteClientTransport.ts, core/mcp/inspectorClient.ts
Web app clients/web/src/App.tsx, lib/oauthResume.ts, utils/pendingReauth.ts, lib/browserTabVisibility.ts, components/groups/StepUpAuthModal/
TUI clients/tui/src/App.tsx, utils/tuiOAuth.ts
CLI clients/cli/src/cliOAuth.ts
Runner OAuth (TUI/CLI) core/auth/node/runner-interactive-oauth.ts, oauth-callback-server.ts
Step-up test fixture test-servers/configs/oauth-step-up-demo.json, test-servers/src/test-server-oauth.ts

Auth challenge model

AuthChallenge (core/auth/challenge.ts)

export type AuthChallengeReason =
  | "unauthorized"
  | "token_expired"
  | "insufficient_scope"
  | "invalid_token";

export interface AuthChallenge {
  reason: AuthChallengeReason;
  requiredScopes?: string[]; // From WWW-Authenticate scope= (this operation)
  authorizationScopes?: string[]; // SEP-2350 union — set before re-auth, not on wire
  resource?: string;
  audience?: string;
  message?: string;
  context?: { method?: string; toolName?: string };
  raw?: { httpStatus?: number; wwwAuthenticate?: string };
}

Parsing

Parsing is layered at the point of failure — not by guessing from error messages:

  1. HTTP 401/403 on the MCP response (fetch intercept or transport error).
  2. WWW-Authenticate: Bearer — error, scope, error_description (quoted and unquoted RFC 6750 forms).
  3. Embedded authChallenge on SDK/transport errors.

Mapping highlights:

  • 403 + insufficient_scope → reason: "insufficient_scope".
  • 401 + invalid_token → invalid_token or token_expired as appropriate.
  • 401 without Bearer error → token_expired (coarse default for silent refresh / reauth UX).
  • Parse failure → unauthorized (still allows interactive re-auth).

isAuthChallengeError() treats mid-session failures only when auth markers are present (challenge object, WWW-Authenticate, or AuthChallengeError) — not bare HTTP status alone.

Connect-time 401 before tokens exist still uses isUnauthorizedError() and authenticate() — separate from mid-session detection.


Core API — handleAuthChallenge()

Location: OAuthManager.handleAuthChallenge(); InspectorClient.handleAuthChallenge() delegates.

export type AuthChallengeOutcome =
  | { kind: "satisfied" }
  | { kind: "interactive"; authorizationUrl: URL; challenge: AuthChallenge }
  | { kind: "failed"; error: Error };

checkAuthChallengeSatisfied(challenge)

Read-only check against current storage (and token expiry helpers). Used before starting visible OAuth — especially when a background browser tab regains focus and another tab may have already re-authenticated. Does not call the authorization server.

  • token_expired: a provably unexpired access token in storage — a JWT whose exp is still in the future (60s skew). Storage records no absolute expiry for an opaque token (expires_in is relative to an issuance instant that is never persisted), so "expiry unknown" returns false: the resource server has said the credential is dead, and no local evidence contradicts it. Otherwise an opaque token would be replayed forever (#2051).
  • invalid_token / unauthorized: always false — the resource server explicitly rejected the credential.
  • insufficient_scope: stored/token scope is a superset of required / union scopes. Empty scope on an insufficient_scope challenge returns false (do not short-circuit).

Strategy by protocol

Standard OAuth

Reason Silent Interactive
token_expired, invalid_token, unauthorized Refresh via refresh_token when supported Authorization code flow (authenticate())
insufficient_scope N/A Authorize with authorizationScopes = union(previous, challenge) via mcpAuth({ forceReauthorization: true }); navigation deferred until UI confirms (web modal / TUI Auth / CLI prompt)

Union scope is held in pendingAuthorizationScope until completeOAuthFlow() succeeds; cleared on failure. On success, saveScope() stores the AS-granted scope (explicit scope on the token response, or the requested union when scope is omitted per RFC 6749 §5.1).

EMA

Per v2_auth_ema.md: no fallback to standard resource-OAuth redirect.

Reason Silent Interactive
Resource token expired / unauthorized Legs 2–3 re-mint —
IdP session missing — Leg 1 IdP OIDC redirect, then legs 2–3
insufficient_scope After user confirms (web modal / TUI / CLI), re-mint legs 2–3 with union scopes when IdP session valid IdP redirect if IdP session invalid

On web, handleAuthChallenge() returns step_up_confirm for EMA insufficient_scope until the user clicks Authorize in StepUpAuthModal; it does not call trySilentEmaAuth() before that. After confirm, silent re-mint runs in-process when possible; otherwise IdP redirect + callback (same as before).

Outcomes — what callers do

Outcome Web remote TUI / CLI direct
satisfied pushAuthState(); command-scoped: retry send once Reconnect or SDK retry (v2); command wrapper may retry RPC
step_up_confirm Throw AuthRecoveryRequiredError (emaStepUpConfirm) → StepUpAuthModal (EMA copy) N/A (web-only deferral)
interactive Throw AuthRecoveryRequiredError (enriched challenge) → App shows modal or redirect + snapshot AuthRecoveryRequiredError or authChallengeInteractive → callback server flow

InspectorClient events (direct transport): authChallengeAmbient (idle SSE / ambient recovery), authChallengeCommand (command-scoped direct recovery — no ambient toast), authChallengeInteractive, authChallengeRecovered. | failed | Toast / banner; stay connected (degraded) | Error message; stay connected (TUI) or exit non-zero (CLI one-shot) |


Web implementation

Detection and delivery

Backend (core/mcp/remote/node/):

  • Auth-challenge intercept fetch on the MCP HTTP transport: on 401/403, parse headers, throw AuthChallengeError before the stub provider invokes SDK auth().
  • createRemoteAuthProvider: mutable credentials; RemoteSession.setAuthState() updates the upstream Bearer without new connect().

Dual delivery — one channel per incident:

Path Trigger Wire Client behavior
Command-scoped Active POST /api/mcp/send HTTP 200 { ok: false, kind: "auth_challenge", authChallenge } handleAuthChallenge() → pushAuthState() → retry same JSON-RPC once
Ambient Transport error while idle SSE auth_challenge handleAmbientAuthChallenge() → push auth state; no RPC retry

authReturnedViaHttp prevents duplicating a command-scoped challenge on SSE.

Inspector API 4xx are reserved for malformed requests / missing session — not for upstream MCP token expiry.

POST /api/mcp/auth-state

interface RemoteSetAuthStateRequest {
  sessionId: string;
  authState: RemoteAuthState;
}

interface RemoteAuthState {
  oauthTokens?: { access_token; token_type; refresh_token?; scope?; … };
  oauthClient?: { client_id; client_secret? };  // reserved for future server-side refresh
}

Called by RemoteClientTransport.pushAuthState() after browser-side recovery. Seeding uses the same shape on POST /api/mcp/connect.

App orchestration (clients/web/src/App.tsx)

Command-scoped paths (tool, prompt, resource, app) share handleCommandScopedAuthRecovery():

  • Standard-OAuth or EMA step-up → StepUpAuthModal (defer redirect / re-mint until Authorize).
  • 401 / EMA IdP (non-step-up) → prepareOAuthRedirect() (auto-redirect + snapshot).
  • Background browser tab hidden → defer to pendingReauth; resume on visibilitychange with checkAuthChallengeSatisfied first.

Ambient path: listens for authChallengeInteractive on InspectorClient (from SSE when silent recovery cannot complete).

Disconnect clears pendingStepUp, pendingReauth, and reAuthBanner.

Step-up confirmation modal

Shown for insufficient_scope on standard OAuth and EMA (web) when recovery will redirect to an AS or re-mint organization permissions.

  • Copy from core/auth/oauthUx.ts (stepUpConfirmMessage, stepUpFollowUpMessage, stepUpModalTitle). EMA uses organization / IdP language; standard OAuth uses resource-AS redirect language.
  • Lists requiredScopes (additional scopes only — not the full SEP-2350 union).
  • Authorize (standard OAuth): write OAuth resume snapshot, pre-redirect toast, beginInteractiveAuthorization().
  • Authorize (EMA): in-progress toast → handleAuthChallenge(..., { confirmedStepUp: true }) → on satisfied, push auth state + success toast (retry hint when command-scoped); on interactive, same snapshot + IdP redirect as other EMA flows; on failed, error toast.
  • Cancel: scoped by StepUpSource (tool / prompt / resource / app / ambient) — only the triggering panel shows error; session stays connected.

Not shown for: token refresh, 401 re-login, connect-time OAuth.

Rationale: Inspector is primarily a testing and exploration client. Surfacing step-up scopes before silent EMA re-mint makes permission elevation visible during manual validation (same UX bar as standard OAuth step-up on web). Production MCP clients may skip this confirm when silent re-mint is acceptable.

OAuth resume snapshot

Full-page redirect (window.location.href) destroys in-memory React state. Before navigate, prepareOAuthRedirect() writes:

interface OAuthResumeSnapshot {
  version: 1;
  serverId: string;
  activeTab: string; // App tab id ("Tools", …)
  authKind: "step_up" | "reauth";
  tabUi: Partial<Record<InspectorTabId, unknown>>; // lifted *UiState shells
  remoteSessionId?: string;
  authChallenge?: AuthChallenge; // step-up: verify scopes after callback
}

Key: mcp-inspector:oauth-resume in sessionStorage (clients/web/src/lib/oauthResume.ts).

Callback flow (InspectorClient.resumeAfterOAuth()):

  1. completeOAuthFlow(code).
  2. Consume snapshot from sessionStorage (read + clear — one-shot).
  3. setupClientForServer(serverId).
  4. If remoteSessionId still valid: attachToSession() + pushAuthState(); else connect() (skipped if already connected).
  5. Restore tabUi and activeTab immediately after consume (before async token work finishes); clear in-flight result panels. Never re-applied on later reconnect.
  6. Step-up: checkAuthChallengeSatisfied(authChallenge) — warning toast if scopes still insufficient.
  7. Success toast: step-up vs reauth copy; user manually retries the action (no auto-replay).

Snapshots per app tab UI, not message logs, tool results, or network bodies. Each snapshot is one-shot: written immediately before a full-page OAuth redirect, consumed (read + cleared from sessionStorage) when the /oauth/callback handler runs, and UI restored once at that moment. Explicit user disconnect clears any pending snapshot and resets activeTab to Servers so a later manual reconnect does not pop back to the OAuth-restored tab; transport/client teardown during connect setup does not clear the snapshot (so connect-time OAuth can still match the server on callback). A later manual reconnect does not read or apply a consumed snapshot.

Multiple browser tabs

Each browser tab has its own RemoteSession. OAuth runtime state is shared file-backed (RemoteOAuthStorage → oauth.json) across tabs on the same backend; SSE auth_challenge is scoped to that tab's session.

When a tab is hidden, interactive OAuth must not steal focus:

  1. Set pendingReauth (in-memory) instead of modal/redirect.
  2. On **visibilitychange → visible:** checkAuthChallengeSatisfied()` first; if still needed, run visible flow.

Command-scoped recovery (user clicked Run in the foreground tab) is not deferred.

Future: shared RemoteOAuthStorage → oauth.json may use optional navigator.locks around silent refresh only — see v2_auth_ema.md §Shared storage.

Web UX reference

Situation Behavior
Silent refresh / EMA re-mint (after confirm) Toast “Refreshing authorization…” or EMA in-progress toast — no modal
401 interactive Toast “Session expired…” → auto-redirect; no confirm modal
403 standard-OAuth step-up Modal → optional pre-redirect toast → redirect → callback restore
403 EMA step-up (web) Modal (organization copy) → in-progress toast → silent re-mint or IdP redirect → success toast
EMA IdP leg 1 (401 / expired IdP) Toast “Re-authenticating…” → auto-redirect
Step-up Cancel Connected; failed action shows error
OAuth abort / callback failure ReAuthBanner; Re-authenticate uses in-session authenticate() when already connected (no disconnect cycle)
Step-up callback, scopes still insufficient Warning toast — not green success
insufficient_scope recovery failure (non-banner reasons) Yellow toast — not ReAuthBanner
Concurrent step-up while modal open Yellow toast — complete or cancel current step-up first

TUI and CLI implementation

Principles

  • Live provider on the SDK transport — same process as MCP.
  • InspectorClient.withDirectAuthRecovery() wraps RPCs: silent handleAuthChallenge() + reconnect; AuthRecoveryRequiredError for interactive.
  • directAuthRecovery: true enables interceptAuthChallenges on createTransportNode until v2 SDK transport owns silent retry.
  • Interactive OAuth uses shared runRunnerInteractiveOAuth() (core/auth/node/runner-interactive-oauth.ts): loopback server, browser redirect, completeOAuthFlow(), optional post-step-up scope check, 15-minute callback timeout (configurable).

Default callback: http://127.0.0.1:6276/oauth/callback (--callback-url / MCP_OAUTH_CALLBACK_URL). Only one TUI/CLI listener on that port at a time.

CLI never spawns TUI/web for auth — completes locally or fails.

TUI UX (clients/tui/src/App.tsx)

Situation Behavior
Silent recovery Auth tab: “Refreshing authorization…”
401 Auto runOAuthAuthentication() (same as connect-time)
403 standard-OAuth step-up Switch to Auth tab; A authorize / C cancel; browser → callback
403 EMA Silent re-mint
Step-up on connected server presentStepUpForServer() selects server + opens Auth tab
Reauth for affected server handleAuthRecoveryRequired() switches server when needed
Clear OAuth Auth tab S

CLI UX (clients/cli/src/cliOAuth.ts)

Situation Behavior
Connect-time 401 connectInspectorWithOAuth() → authorize URL on stdout → callback
Mid-session interactive handleCliAuthRecoveryRequired() / withCliAuthRecoveryRetry() (one retry)
403 standard-OAuth step-up stderr: scope message + Proceed with step-up authorization? [y/N]
Decline step-up Exit non-zero
Insufficient scope after OAuth stderr message from stepUpInsufficientScopeMessage()

SSE limitation

Legacy SSE transport: 401 only on mid-session send() (no 403 step-up). Step-up testing targets streamable HTTP.


Client matrix

Concern Web TUI CLI
Challenge detection Inline send + SSE ambient SDK transport + intercept Same as TUI
Auth execution Browser OAuthManager Node OAuthManager Node OAuthManager
OAuth storage RemoteOAuthStorage → oauth.json (via /api/storage/oauth) NodeOAuthStorage (file) Same file as TUI
Silent recovery auth-state push + send retry Reconnect / SDK retry (v2) One-shot RPC retry
Interactive recovery Modal + full-page redirect + snapshot Auth tab + callback stderr y/N + callback
Step-up confirm StepUpAuthModal Auth tab A / C y/N
EMA step-up Web modal + confirm (then silent or IdP) Confirm on Auth tab, then silent y/N, then silent
Multiple browser tabs Independent sessions; background defer N/A N/A

Test infrastructure

Step-up integration tests and manual smokes use the composable OAuth server (test-servers/build/server-composable.js) with per-capability scope requirements enforced in HTTP middleware (test-server-oauth.ts).

How scopes work at two levels

Level Config field Role
Authorization server oauth.scopesSupported Advertises every scope the AS may grant (PRM / AS metadata). Inspector uses this for connect-time consent and step-up union. Must include all scopes referenced by requiredScopes below.
Per capability requiredScopes on a tool, resource, or prompt preset ref Scopes the access token must already include before that RPC succeeds. Missing scope → 403 + WWW-Authenticate: Bearer error="insufficient_scope", scope="…". Omitted → only global bearer validity (401 if no/invalid token).

Connect-time vs step-up: Inspector catalog OAuth scopes control what the user grants on first connect. The composable server may accept that token for some operations but reject others until step-up adds more scopes.

Example flow with the sample below:

  1. Catalog entry: "oauth": { "scopes": "mcp tools:read" } — user connects successfully.
  2. tools/call echo — no requiredScopes → succeeds.
  3. tools/call get_temp — needs weather:read → 403 insufficient_scope → Inspector step-up with SEP-2350 union (mcp, tools:read, weather:read).
  4. After step-up, get_temp succeeds; resources/read on file:///secret.txt still needs another step-up for secrets:read.

Step-up is enforced on use (tools/call, resources/read, prompts/get), not on discovery (tools/list, resources/list, prompts/list).

Sample composable config

Illustrates server-level scopesSupported plus tool-, resource-, and prompt-level requiredScopes. The checked-in smoke fixture test-servers/configs/oauth-step-up-demo.json is a minimal subset (tools only: echo + get_temp).

{
  "serverInfo": { "name": "step-up-demo", "version": "1.0.0" },
  "transport": { "type": "streamable-http", "port": 8081 },
  "oauth": {
    "enabled": true,
    "mode": "combined",
    "requireAuth": true,
    "scopesSupported": [
      "mcp",
      "tools:read",
      "weather:read",
      "secrets: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"]
    }
  ],
  "prompts": [{ "preset": "simple_prompt", "requiredScopes": ["weather:read"] }]
}

Matching Inspector catalog entry (connect with subset scopes so step-up is exercisable):

"oauth-step-up-demo": {
  "type": "streamable-http",
  "url": "http://127.0.0.1:8081/mcp",
  "oauth": { "scopes": "mcp tools:read" }
}

Run the server:

cd clients/web && npm run test-servers:build
node ../../test-servers/build/server-composable.js \
  --config ../../test-servers/configs/oauth-step-up-demo.json

Enforcement (HTTP middleware)

On each MCP request, after bearer validation:

  1. Parse JSON-RPC method and params.
  2. Look up requiredScopes from the registry built at startup:
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
  1. Compare granted scopes on the access token (stored at token issue time in combined mode) against requiredScopes.

  2. If any required scope is missing, respond 403 with:

    WWW-Authenticate: Bearer error="insufficient_scope", scope="weather:read"
    

    (scope= lists the missing scope(s); body is a JSON-RPC error envelope.)

requiredScopes on preset refs is merged in resolve-config.ts; no application-code routes — config drives behavior.

Verification

Automated (high level): challenge parsing, scope union, checkAuthChallengeSatisfied, oauthResume, runRunnerInteractiveOAuth; integration suites inspectorClient-oauth-remote-mid-session-e2e.test.ts, inspectorClient-oauth-direct-mid-session-e2e.test.ts, CLI oauth-interactive.test.ts / cliOAuth.test.ts.

Manual: v2_auth_smoke_testing.md §5 — required gate W1 + W5–W7, T1–T2 + T4, C1–C2.


Document Relationship
v2_auth_hardening.md Connect-time SEPs; v2 SDK upgrade; direct transport silent retry delegation
v2_auth_ema.md EMA legs 2–3 re-mint; scope resolution; no resource-OAuth fallback
v2_auth_smoke_testing.md Manual OAuth and mid-session validation procedures
v2_storage.md Shared oauth.json via RemoteOAuthStorage on web (implemented)

Future work

  • Optional navigator.locks on shared RemoteOAuthStorage — single-flight silent refresh across browser tabs (see EMA §Shared storage).
  • v2 SDK transport upgrade — delegate direct streamable HTTP silent 401/403 + SEP-2350 union to SDK; remove mcpAuth / client intercept shims where redundant.
  • Server-side token refresh on the node using RemoteAuthState.oauthClient + refresh_token (browser owns refresh today).
  • Connection Info — display effective vs pending scopes.
  • Composable oauth.operations map for method-wide scope defaults in test fixtures.
  • Popup OAuth window as an alternative to full-page resume (not implemented).

Normative references