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.
| 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:
- HTTP 401/403 on the MCP response (fetch intercept or transport error).
WWW-Authenticate: Bearer—error,scope,error_description(quoted and unquoted RFC 6750 forms).- Embedded
authChallengeon SDK/transport errors.
Mapping highlights:
- 403 +
insufficient_scope→reason: "insufficient_scope". - 401 +
invalid_token→invalid_tokenortoken_expiredas 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 aninsufficient_scopechallenge 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
AuthChallengeErrorbefore the stub provider invokes SDKauth(). createRemoteAuthProvider: mutable credentials;RemoteSession.setAuthState()updates the upstream Bearer without newconnect().
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 onvisibilitychangewithcheckAuthChallengeSatisfiedfirst.
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 })→ onsatisfied, push auth state + success toast (retry hint when command-scoped); oninteractive, same snapshot + IdP redirect as other EMA flows; onfailed, 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()):
completeOAuthFlow(code).- Consume snapshot from
sessionStorage(read + clear — one-shot). setupClientForServer(serverId).- If
remoteSessionIdstill valid:attachToSession()+pushAuthState(); elseconnect()(skipped if already connected). - Restore
tabUiandactiveTabimmediately after consume (before async token work finishes); clear in-flight result panels. Never re-applied on later reconnect. - Step-up:
checkAuthChallengeSatisfied(authChallenge)— warning toast if scopes still insufficient. - 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:
- Set
pendingReauth(in-memory) instead of modal/redirect. - 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: silenthandleAuthChallenge()+ reconnect;AuthRecoveryRequiredErrorfor interactive.directAuthRecovery: trueenablesinterceptAuthChallengesoncreateTransportNodeuntil 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:
- Catalog entry:
"oauth": { "scopes": "mcp tools:read" }— user connects successfully. tools/call echo— norequiredScopes→ succeeds.tools/call get_temp— needsweather:read→ 403 insufficient_scope → Inspector step-up with SEP-2350 union (mcp,tools:read,weather:read).- After step-up,
get_tempsucceeds;resources/readonfile:///secret.txtstill needs another step-up forsecrets: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:
- Parse JSON-RPC
methodandparams. - Look up
requiredScopesfrom 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 |
-
Compare granted scopes on the access token (stored at token issue time in combined mode) against
requiredScopes. -
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.
Related specifications
| 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.lockson sharedRemoteOAuthStorage— 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.operationsmap for method-wide scope defaults in test fixtures. - Popup OAuth window as an alternative to full-page resume (not implemented).