Inspector V2 Auth

July 26, 2026 · View on GitHub

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.

ClientTransport modelAuth runs whereRecovery highlights
WebBrowser → Hono remote backend → MCP serverBrowser (OAuthManager)POST /api/mcp/auth-state hot-swap; full-page OAuth + resume snapshot
TUI / CLIDirect SDK transport in-processNode (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-timeMid-session
WhenFirst connect with no valid tokens, or reconnect before a remote session existsAny MCP RPC after credentials were supplied
Typical signalconnect() throws 401MCP HTTP 401/403 on an in-flight request
Web handlerApp.tsxauthenticate() → redirecthandleAuthChallenge()auth-state push and/or redirect
Core handlerSame OAuth primitivesOAuthManager.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:

SituationClient scope behavior
403 step-upUnion previously requested scopes with scopes from the challenge — do not drop scopes needed for other tools
401 re-loginReplace 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

TermMeaning
App tabAn Inspector view inside one browser window: Tools, Resources, Prompts, etc. (activeTab in App.tsx). Not a browser tab.
Browser tabA top-level browsing context (window/tab) running the Inspector SPA. Each has its own InspectorClient, remote session, and OAuth context.
Auth challengeNormalized { 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.
RemoteAuthStatePayload for POST /api/mcp/auth-state: fresh oauthTokens (+ optional oauthClient) pushed to the node backend without tearing down the MCP transport.
Command-scoped recoveryFailure during an active user action (tool call, connect button). May retry the same JSON-RPC once after silent recovery.
Ambient recoveryFailure 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 snapshotsessionStorage 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

ShapeWhenMCP retryPage unload
Silent in-processRefresh, EMA re-mint after user confirmed step-up (web modal Authorize), TUI/CLI confirmYes (command-scoped, once)No
In-app step-up confirmWeb EMA insufficient_scope before re-mint / IdPNo — modal only; retry after success toastNo
Interactive full-pageStandard-OAuth step-up, 401 re-login, EMA IdP leg 1 (after web confirm when applicable)No — user retries action after callbackYes (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

AreaPrimary files
Challenge types & parsingcore/auth/challenge.ts, core/auth/oauthUx.ts
Handler & storagecore/mcp/oauthManager.ts, core/auth/mcpAuth.ts, core/auth/scopes.ts
EMAcore/auth/ema/emaFlow.ts, core/auth/ema/resourceContext.ts — see v2_auth_ema.md
Web remote backendcore/mcp/remote/node/server.ts, remote-session.ts, core/mcp/node/authChallengeFetch.ts
Web remote clientcore/mcp/remote/remoteClientTransport.ts, core/mcp/inspectorClient.ts
Web appclients/web/src/App.tsx, lib/oauthResume.ts, utils/pendingReauth.ts, lib/browserTabVisibility.ts, components/groups/StepUpAuthModal/
TUIclients/tui/src/App.tsx, utils/tuiOAuth.ts
CLIclients/cli/src/cliOAuth.ts
Runner OAuth (TUI/CLI)core/auth/node/runner-interactive-oauth.ts, oauth-callback-server.ts
Step-up test fixturetest-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: Bearererror, scope, error_description (quoted and unquoted RFC 6750 forms).
  3. Embedded authChallenge on SDK/transport errors.

Mapping highlights:

  • 403 + insufficient_scopereason: "insufficient_scope".
  • 401 + invalid_tokeninvalid_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 / unauthorized: valid non-expired access token in storage.
  • 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

ReasonSilentInteractive
token_expired, invalid_token, unauthorizedRefresh via refresh_token when supportedAuthorization code flow (authenticate())
insufficient_scopeN/AAuthorize 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.

ReasonSilentInteractive
Resource token expired / unauthorizedLegs 2–3 re-mint
IdP session missingLeg 1 IdP OIDC redirect, then legs 2–3
insufficient_scopeAfter user confirms (web modal / TUI / CLI), re-mint legs 2–3 with union scopes when IdP session validIdP 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

OutcomeWeb remoteTUI / CLI direct
satisfiedpushAuthState(); command-scoped: retry send onceReconnect or SDK retry (v2); command wrapper may retry RPC
step_up_confirmThrow AuthRecoveryRequiredError (emaStepUpConfirm) → StepUpAuthModal (EMA copy)N/A (web-only deferral)
interactiveThrow AuthRecoveryRequiredError (enriched challenge) → App shows modal or redirect + snapshotAuthRecoveryRequiredError 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:

PathTriggerWireClient behavior
Command-scopedActive POST /api/mcp/sendHTTP 200 { ok: false, kind: "auth_challenge", authChallenge }handleAuthChallenge()pushAuthState()retry same JSON-RPC once
AmbientTransport error while idleSSE auth_challengehandleAmbientAuthChallenge() → 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 (RemoteOAuthStorageoauth.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 RemoteOAuthStorageoauth.json may use optional navigator.locks around silent refresh only — see v2_auth_ema.md §Shared storage.

Web UX reference

SituationBehavior
Silent refresh / EMA re-mint (after confirm)Toast “Refreshing authorization…” or EMA in-progress toast — no modal
401 interactiveToast “Session expired…” → auto-redirect; no confirm modal
403 standard-OAuth step-upModal → 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 CancelConnected; failed action shows error
OAuth abort / callback failureReAuthBanner; Re-authenticate uses in-session authenticate() when already connected (no disconnect cycle)
Step-up callback, scopes still insufficientWarning toast — not green success
insufficient_scope recovery failure (non-banner reasons)Yellow toast — not ReAuthBanner
Concurrent step-up while modal openYellow 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)

SituationBehavior
Silent recoveryAuth tab: “Refreshing authorization…”
401Auto runOAuthAuthentication() (same as connect-time)
403 standard-OAuth step-upSwitch to Auth tab; A authorize / C cancel; browser → callback
403 EMASilent re-mint
Step-up on connected serverpresentStepUpForServer() selects server + opens Auth tab
Reauth for affected serverhandleAuthRecoveryRequired() switches server when needed
Clear OAuthAuth tab S

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

SituationBehavior
Connect-time 401connectInspectorWithOAuth() → authorize URL on stdout → callback
Mid-session interactivehandleCliAuthRecoveryRequired() / withCliAuthRecoveryRetry() (one retry)
403 standard-OAuth step-upstderr: scope message + Proceed with step-up authorization? [y/N]
Decline step-upExit non-zero
Insufficient scope after OAuthstderr 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

ConcernWebTUICLI
Challenge detectionInline send + SSE ambientSDK transport + interceptSame as TUI
Auth executionBrowser OAuthManagerNode OAuthManagerNode OAuthManager
OAuth storageRemoteOAuthStorageoauth.json (via /api/storage/oauth)NodeOAuthStorage (file)Same file as TUI
Silent recoveryauth-state push + send retryReconnect / SDK retry (v2)One-shot RPC retry
Interactive recoveryModal + full-page redirect + snapshotAuth tab + callbackstderr y/N + callback
Step-up confirmStepUpAuthModalAuth tab A / Cy/N
EMA step-upWeb modal + confirm (then silent or IdP)Confirm on Auth tab, then silenty/N, then silent
Multiple browser tabsIndependent sessions; background deferN/AN/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

LevelConfig fieldRole
Authorization serveroauth.scopesSupportedAdvertises 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 capabilityrequiredScopes on a tool, resource, or prompt preset refScopes 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:read403 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 methodRegistry key
tools/calltool name (params.name)
resources/readresource uri (params.uri)
prompts/getprompt name (params.name)
resources/templates/readtemplate 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.


DocumentRelationship
v2_auth_hardening.mdConnect-time SEPs; v2 SDK upgrade; direct transport silent retry delegation
v2_auth_ema.mdEMA legs 2–3 re-mint; scope resolution; no resource-OAuth fallback
v2_auth_smoke_testing.mdManual OAuth and mid-session validation procedures
v2_storage.mdShared 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