From 1f31e0f1f89bfab0215989d704996cc1f4c26746 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 29 Jun 2026 13:48:56 -0700 Subject: [PATCH 01/11] Spec updates (command response payloads versus SSE only) before impementation. --- specification/v2_auth_mid_session.md | 248 +++++++++++++++++++++------ 1 file changed, 194 insertions(+), 54 deletions(-) diff --git a/specification/v2_auth_mid_session.md b/specification/v2_auth_mid_session.md index 0f35851b8..da882149f 100644 --- a/specification/v2_auth_mid_session.md +++ b/specification/v2_auth_mid_session.md @@ -33,11 +33,14 @@ This spec defines: ### Web: detection and wire protocol -- **Backend detection:** fetch wrapper on the transport passed to `createTransportNode` — intercept the MCP HTTP `Response` before the SDK consumes it, parse `WWW-Authenticate`, emit an SSE `auth_challenge` event. The frozen `createTokenAuthProvider` stub cannot complete interactive OAuth; do not rely on stub `auth()`. -- **SSE event type:** dedicated `auth_challenge` (not `transport_error`). -- **Browser dispatch:** `InspectorClient` `authChallenge` typed event. +- **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()`. @@ -47,7 +50,7 @@ This spec defines: - 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). -- Phase A: rely on SDK in-flight retry where applicable. Phase C adds explicit pending-RPC replay. +- 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()`. @@ -60,7 +63,9 @@ The SDK (`@modelcontextprotocol/sdk` 1.29.0) auto-retries 401/403 on streamable ### RPC retry -- After every successful recovery (401 refresh, EMA re-mint, step-up), **retry the failed MCP request**. Phases 1–3 may ship without auto-retry; Phase 5 queues `AuthChallenge.context.pendingRequest` and replays once after `satisfied` + reconnect (bounded). +- 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 @@ -86,11 +91,111 @@ Silent path = refresh token grant (standard OAuth) or EMA legs 2–3 re-mint (va | Standard OAuth | No `refresh_token`; refresh token expired/revoked; AS rejects refresh; no tokens in storage | | EMA | No IdP session; legs 2–3 mint error (bad resource client creds, AS/network errors) | | Step-up (403) | Standard OAuth: interactive consent (modal + resource-AS redirect). EMA (valid IdP session): silent legs 2–3 re-mint — same as 401 refresh | -| Web | Silent runs in the browser after SSE `auth_challenge`; the backend cannot refresh frozen tokens | +| 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. + +```json +{ + "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 + 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. -### Test infrastructure +Middleware compares granted scopes (split on spaces) against `requiredScopes` (all must be present). -Extend `test-server-oauth.ts` with scope-check middleware that returns **403** + `insufficient_scope` for scoped tool routes. +#### 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: + +```json +"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](v2_auth_smoke_testing.md) ## Normative references @@ -116,10 +221,9 @@ Extend `test-server-oauth.ts` with scope-check middleware that returns **403** + - Implement **[SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350)** client-side scope accumulation when servers emit runtime **`403 insufficient_scope`** challenges per the [draft Step-Up Authorization Flow](https://modelcontextprotocol.io/specification/draft/basic/authorization#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 — Pending RPC retry (after Phase A recovery works) +### Phase C — Command retry (web remote) -- After any successful auth recovery (401 refresh, EMA re-mint, step-up), **automatically retry the MCP request that failed**. -- Phases 1–3 may ship without auto-retry; Phase 5 adds queued replay of `AuthChallenge.context.pendingRequest`. +- 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 @@ -243,7 +347,7 @@ export interface AuthChallenge { context?: { method?: string; toolName?: string; - /** Phase C: JSON-RPC request to replay after successful recovery. */ + /** Optional: JSON-RPC to replay for ambient SSE delivery only (no caller closure). */ pendingRequest?: import("@modelcontextprotocol/sdk/types.js").JSONRPCMessage; }; @@ -272,7 +376,7 @@ When parsing fails, use `reason: "unauthorized"` and still allow interactive re- | **When** | First connect with no stored tokens; `initialize` gets 401 before any bearer token was sent to the backend | Reconnect with stored tokens, or any MCP request after a token snapshot was frozen on the backend — **including `initialize` during `connect()`** | | **Detection** | `connect()` throws **401** to the browser | MCP HTTP **401/403** on backend transport → **`auth_challenge`** (Phase 2); today often **500** from stub `auth()` | | **Handler** | `authenticate()` (today) | `handleAuthChallenge()` (this spec) | -| **Web follow-up** | Redirect or silent connect | Recover tokens in browser → **disconnect + connect** to re-snapshot → Phase C replays pending RPC | +| **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. @@ -317,10 +421,10 @@ Uses `EmaTransportOAuthProvider`, `emaFlow.ts`, and `resourceContext.ts` (extend | Client | Action | | ------ | ------ | -| **TUI / CLI** | Live provider on transport; SDK may retry in flight. Phase C: replay `context.pendingRequest` if set. | -| **Web** | **`disconnect()` → `connect()`** to re-snapshot tokens. Phase C: replay `context.pendingRequest` after reconnect. | +| **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). | -Until Phase C, the failed tool call may still require a manual retry after recovery. +Ambient SSE failures without a caller closure may require manual retry or Phase 5 `pendingRequest` replay. ### After `kind: "interactive"` @@ -343,33 +447,57 @@ Do **not** disconnect the MCP session for recoverable challenges. ## Remote wire protocol (web) -Backend **reports** challenges; browser **handles** them. +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 HTTP response from the MCP server, in `transport.send()` error handling, or in transport `onerror`. The backend emits a structured event; the browser runs `handleAuthChallenge()`. +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()`. ```text -MCP SDK transport (RemoteSession on Hono backend) - └─ HTTP 401/403 or SDK auth failure on send / stream - └─ parseAuthChallengeFromResponse() at this hook - └─ RemoteSession.pushEvent({ type: "auth_challenge", data }) - └─ SSE → browser RemoteClientTransport - └─ InspectorClient → handleAuthChallenge() +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: -- **Fetch wrapper on the backend transport** — wrap the fetch passed to `createTransportNode`. Intercept the MCP HTTP `Response` **before** the SDK consumes it. On **401** or **403**, parse `WWW-Authenticate`, emit `auth_challenge`, and do not let the SDK call `auth()` on the frozen `createTokenAuthProvider` stub. -- **`/api/mcp/send`** — extend to preserve **403** and map stub-auth failures to structured errors (today: **401** only; stub failures often return **500**). -- **Transport `onerror`** — secondary path when the SDK reports auth-related failures without a parseable response (preserve status/code; do not collapse to generic 500). +- **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) + +```typescript +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): @@ -379,7 +507,7 @@ Same **`handleAuthChallenge()`** entry via **transport fetch wrapper** (before S - Dispatch **`authChallenge`** on `InspectorClient` (Phase 4 replaces TUI `show401AuthHint`). - **`oauthAuthorizationRequired`** fires when `handleAuthChallenge()` returns `interactive`. -### SSE event +### SSE event (ambient only) Extend `RemoteEvent` in `core/mcp/remote/types.ts`: @@ -395,28 +523,37 @@ export interface RemoteAuthChallengeEvent { **Rules:** -- Emit **once per recoverable 401/403 auth challenge** (dedupe per [Architecture §Web: detection and wire protocol](#web-detection-and-wire-protocol)). +- 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](#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`** when the failing RPC is known (Phase C). +- Attach **`context.pendingRequest`** only for ambient cases where replay is desired and no caller closure exists. ### Browser handling -1. `RemoteClientTransport` receives `auth_challenge` on SSE. -2. `InspectorClient` dispatches **`authChallenge`**. -3. App calls `handleAuthChallenge(challenge)` (via `authChallengeFlow.ts` once extracted). -4. On `satisfied` or post-callback success: reconnect active server; Phase C replays pending RPC. -5. UX per [Architecture §UX](#ux). +**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](#ux). ## Client matrix | Concern | Web | TUI | CLI | | ------- | --- | --- | --- | -| Challenge detection | SSE `auth_challenge` from `RemoteSession` | `InspectorClient` auth hook on live transport / provider | Same as TUI when OAuth wired | +| 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](v2_auth_ema.md)) | File | File | -| Post-success | Remote reconnect (+ Phase C RPC replay) | Reconnect / SDK retry (+ Phase C) | Same as TUI when OAuth wired | +| 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) | @@ -432,7 +569,7 @@ export interface RemoteAuthChallengeEvent { ## Phased implementation -Phases 1–2 deliver **Phase A** (token recovery). Phase 3 delivers **Phase B** (SEP-2350 step-up). Phase 4 is client parity and shared storage. Phase 5 delivers **Phase C** (pending RPC replay). +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) @@ -444,21 +581,23 @@ Phases 1–2 deliver **Phase A** (token recovery). Phase 3 delivers **Phase B** ### Phase 2 — Web remote propagation (401 / token recovery) -- [ ] Backend fetch wrapper: detect MCP **401/403** before frozen stub `auth()`; emit SSE **`auth_challenge`** (applies to **`/api/mcp/send` and failures during connect handshake**, e.g. `initialize`) -- [ ] Extend `/api/mcp/send` and **connect-time** transport failures for **403** and stub-auth error mapping (never surface raw SDK *saveable for dynamic registration* as an opaque **500** to the browser) -- [ ] Browser: `RemoteClientTransport` → `InspectorClient` **`authChallenge`** event → `handleAuthChallenge()` +- [ ] 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` succeeds (manual tool retry until Phase 5) +- [ ] 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 → success (mirror local `inspectorClient-oauth-e2e` refresh test via `createRemoteTransport`) +- [ ] 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 -- [ ] Extend **`test-server-oauth.ts`** with **403** + `insufficient_scope` fixture -- [ ] Integration test: 403 step-up → union re-auth → tool succeeds (manual retry until Phase 5) -- [ ] Backend: **`auth_challenge`** for **403** (included in Phase 2 wrapper; verify step-up path) +- [ ] Composable test server: **`requiredScopes`** on preset refs + HTTP scope middleware (**403** + `insufficient_scope`) — see [Test infrastructure](#test-infrastructure--composable-server-scope-requirements) +- [ ] Add **`test-servers/configs/oauth-step-up-demo.json`**; manual smoke steps in [v2_auth_smoke_testing.md](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 @@ -467,12 +606,12 @@ Phases 1–2 deliver **Phase A** (token recovery). Phase 3 delivers **Phase B** - [ ] Web: `RemoteOAuthStorage` (shared `oauth.json`) + `navigator.locks` single-flight - [ ] Multi-tab dedupe once shared storage lands -### Phase 5 — Pending RPC replay (Phase C) +### Phase 5 — Ambient replay + hardening -- [ ] Attach failing JSON-RPC to `AuthChallenge.context.pendingRequest` at detection -- [ ] After `satisfied` + reconnect (web) or satisfied on live transport (TUI/CLI): **replay once** (bounded) -- [ ] Integration tests: 401 refresh, EMA re-mint, and 403 step-up all replay the original tool call -- [ ] On replay failure: surface tool error; do not loop +- [ ] 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 @@ -483,8 +622,9 @@ Phases 1–2 deliver **Phase A** (token recovery). Phase 3 delivers **Phase B** | Integration (web remote, mid-session) | Invalidate token after connect → SSE `auth_challenge` → reconnect → `tools/list` | | Integration (web remote, reconnect) | Invalidate/expired token before `connect()` with stored snapshot → challenge → recovery → connected (no stub DCR **500**) | | Integration (web remote, refresh) | Invalidate access token only; `refresh_token` present → silent refresh → reconnect | -| Integration (Phase C replay) | 401 / EMA / 403 recovery → original tool call replays automatically | +| Integration (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](v2_auth_smoke_testing.md) §Mid-session auth | @@ -497,6 +637,6 @@ Phases 1–2 deliver **Phase A** (token recovery). Phase 3 delivers **Phase B** | Remote | `core/mcp/remote/types.ts`, `core/mcp/remote/node/remote-session.ts`, `core/mcp/remote/node/server.ts`, `core/mcp/remote/remoteClientTransport.ts`, transport fetch wrapper in `core/mcp/node/transport.ts` | | Web app | `clients/web/src/App.tsx`, `clients/web/src/utils/authChallengeFlow.ts`, `clients/web/src/utils/oauthFlow.ts` (`isAuthChallengeError`) | | TUI | `clients/tui/src/App.tsx` | -| Test server | `test-servers/src/test-server-oauth.ts` | -| Tests | `clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts`, new remote auth-challenge + Phase C replay tests | +| 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 | From 562bdd00f112b2ef0fb003e9b4240402f3664124 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Thu, 2 Jul 2026 16:35:31 -0700 Subject: [PATCH 02/11] All of the mid-session and step-up auth work --- clients/cli/README.md | 16 +- clients/cli/__tests__/README.md | 6 +- clients/cli/__tests__/cliOAuth.test.ts | 230 +++ .../cli/__tests__/oauth-interactive.test.ts | 242 +++ clients/cli/src/cli.ts | 28 +- clients/cli/src/cliOAuth.ts | 184 ++ clients/tui/__tests__/App.test.tsx | 112 +- clients/tui/__tests__/AuthTab.test.tsx | 89 + .../tui/__tests__/PromptTestModal.test.tsx | 31 + .../tui/__tests__/ResourceTestModal.test.tsx | 29 + clients/tui/__tests__/tuiOAuth.test.ts | 49 + clients/tui/src/App.tsx | 459 ++++- clients/tui/src/components/AuthTab.tsx | 116 +- .../tui/src/components/PromptTestModal.tsx | 8 + clients/tui/src/components/PromptsTab.tsx | 7 + .../tui/src/components/ResourceTestModal.tsx | 8 + clients/tui/src/components/ResourcesTab.tsx | 9 +- clients/tui/src/components/ToolTestModal.tsx | 8 + clients/tui/src/utils/tuiOAuth.ts | 36 + clients/web/src/App.test.tsx | 165 ++ clients/web/src/App.tsx | 1495 ++++++++++++++--- .../groups/ReAuthBanner/ReAuthBanner.test.tsx | 30 + .../groups/ReAuthBanner/ReAuthBanner.tsx | 36 + .../StepUpAuthModal/StepUpAuthModal.test.tsx | 120 ++ .../StepUpAuthModal/StepUpAuthModal.tsx | 87 + .../src/components/screens/screenUiState.ts | 12 + .../InspectorView/InspectorView.stories.tsx | 13 + .../InspectorView/InspectorView.test.tsx | 127 +- .../views/InspectorView/InspectorView.tsx | 24 +- .../web/src/test/core/auth/challenge.test.ts | 237 +++ .../web/src/test/core/auth/mcpAuth.test.ts | 105 ++ .../web/src/test/core/auth/oauthUx.test.ts | 82 + .../web/src/test/core/auth/providers.test.ts | 17 + .../auth/runner-interactive-oauth.test.ts | 283 ++++ clients/web/src/test/core/auth/scopes.test.ts | 37 + clients/web/src/test/core/auth/utils.test.ts | 30 + .../core/mcp/authResyncPropagation.test.ts | 288 ++++ .../mcp/inspectorClient-ambient-auth.test.ts | 42 + .../core/mcp/node/authChallengeFetch.test.ts | 52 + .../src/test/core/mcp/oauthManager.test.ts | 629 ++++++- .../mcp/remote/remoteClientTransport.test.ts | 131 ++ .../test/core/mcp/test-server-scope.test.ts | 153 ++ .../core/remote-tokenAuthProvider.test.ts | 68 +- ...lient-oauth-direct-mid-session-e2e.test.ts | 243 +++ ...lient-oauth-remote-mid-session-e2e.test.ts | 715 ++++++++ .../integration/mcp/inspectorClient.test.ts | 27 + .../mcp/remote/remote-session.test.ts | 110 ++ .../remote/remoteClientTransport-unit.test.ts | 106 +- .../integration/mcp/remote/transport.test.ts | 10 +- .../test-server-protected-resource.test.ts | 14 + .../src/utils/browserTabVisibility.test.ts | 44 + clients/web/src/utils/browserTabVisibility.ts | 20 + clients/web/src/utils/inspectorTabs.ts | 24 + clients/web/src/utils/oauthResume.test.ts | 225 +++ clients/web/src/utils/oauthResume.ts | 278 +++ clients/web/src/utils/oauthUx.test.ts | 80 + clients/web/src/utils/oauthUx.ts | 12 + clients/web/src/utils/pendingReauth.test.ts | 18 + clients/web/src/utils/pendingReauth.ts | 20 + core/auth/challenge.ts | 331 ++++ core/auth/connection-state.ts | 5 +- core/auth/index.ts | 47 + core/auth/mcpAuth.ts | 156 ++ core/auth/node/index.ts | 9 + core/auth/node/runner-interactive-oauth.ts | 136 ++ core/auth/oauthUx.ts | 236 +++ core/auth/providers.ts | 25 +- core/auth/scopes.ts | 43 + core/auth/utils.ts | 65 + core/client/runner.ts | 3 +- core/mcp/inspectorClient.ts | 449 ++++- core/mcp/inspectorClientEventTarget.ts | 12 + core/mcp/node/authChallengeFetch.ts | 34 + core/mcp/node/transport.ts | 16 +- core/mcp/oauthManager.ts | 451 ++++- core/mcp/remote/index.ts | 2 + core/mcp/remote/node/remote-session.ts | 150 ++ core/mcp/remote/node/server.ts | 121 +- core/mcp/remote/node/tokenAuthProvider.ts | 83 +- core/mcp/remote/remoteClientTransport.ts | 425 ++++- core/mcp/remote/types.ts | 112 +- core/mcp/types.ts | 13 + specification/v2_auth_ema.md | 20 +- specification/v2_auth_hardening.md | 62 +- specification/v2_auth_mid_session.md | 879 +++++----- specification/v2_auth_smoke_testing.md | 302 +++- test-servers/configs/oauth-step-up-demo.json | 22 + test-servers/configs/xaa-ema-http.json | 7 +- test-servers/src/composable-test-server.ts | 10 + test-servers/src/load-config.ts | 2 + test-servers/src/preset-registry.ts | 4 + test-servers/src/resolve-config.ts | 16 +- test-servers/src/test-server-fixtures.ts | 16 + test-servers/src/test-server-http.ts | 11 + test-servers/src/test-server-oauth-jwt.ts | 49 +- test-servers/src/test-server-oauth.ts | 445 ++++- test-servers/tsconfig.json | 4 +- 97 files changed, 11437 insertions(+), 1212 deletions(-) create mode 100644 clients/cli/__tests__/cliOAuth.test.ts create mode 100644 clients/cli/__tests__/oauth-interactive.test.ts create mode 100644 clients/cli/src/cliOAuth.ts create mode 100644 clients/tui/__tests__/tuiOAuth.test.ts create mode 100644 clients/tui/src/utils/tuiOAuth.ts create mode 100644 clients/web/src/components/groups/ReAuthBanner/ReAuthBanner.test.tsx create mode 100644 clients/web/src/components/groups/ReAuthBanner/ReAuthBanner.tsx create mode 100644 clients/web/src/components/groups/StepUpAuthModal/StepUpAuthModal.test.tsx create mode 100644 clients/web/src/components/groups/StepUpAuthModal/StepUpAuthModal.tsx create mode 100644 clients/web/src/test/core/auth/challenge.test.ts create mode 100644 clients/web/src/test/core/auth/mcpAuth.test.ts create mode 100644 clients/web/src/test/core/auth/oauthUx.test.ts create mode 100644 clients/web/src/test/core/auth/runner-interactive-oauth.test.ts create mode 100644 clients/web/src/test/core/auth/scopes.test.ts create mode 100644 clients/web/src/test/core/mcp/authResyncPropagation.test.ts create mode 100644 clients/web/src/test/core/mcp/inspectorClient-ambient-auth.test.ts create mode 100644 clients/web/src/test/core/mcp/node/authChallengeFetch.test.ts create mode 100644 clients/web/src/test/core/mcp/test-server-scope.test.ts create mode 100644 clients/web/src/test/integration/mcp/inspectorClient-oauth-direct-mid-session-e2e.test.ts create mode 100644 clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts create mode 100644 clients/web/src/utils/browserTabVisibility.test.ts create mode 100644 clients/web/src/utils/browserTabVisibility.ts create mode 100644 clients/web/src/utils/inspectorTabs.ts create mode 100644 clients/web/src/utils/oauthResume.test.ts create mode 100644 clients/web/src/utils/oauthResume.ts create mode 100644 clients/web/src/utils/oauthUx.test.ts create mode 100644 clients/web/src/utils/oauthUx.ts create mode 100644 clients/web/src/utils/pendingReauth.test.ts create mode 100644 clients/web/src/utils/pendingReauth.ts create mode 100644 core/auth/challenge.ts create mode 100644 core/auth/mcpAuth.ts create mode 100644 core/auth/node/runner-interactive-oauth.ts create mode 100644 core/auth/oauthUx.ts create mode 100644 core/auth/scopes.ts create mode 100644 core/mcp/node/authChallengeFetch.ts create mode 100644 test-servers/configs/oauth-step-up-demo.json diff --git a/clients/cli/README.md b/clients/cli/README.md index ce03c465d..9f9b4db67 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -102,9 +102,15 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en ### CLI-specific (OAuth for HTTP servers) -The CLI **reuses** OAuth tokens from `~/.mcp-inspector/storage/oauth.json` (same file as the TUI). Complete first-time authorization in the **web** or **TUI** client, then run one-shot CLI commands against HTTP/SSE servers without signing in again. +The CLI runs the same loopback callback server as the TUI (`http://127.0.0.1:6276/oauth/callback` by default). On connect **401** or mid-session interactive auth (re-login / step-up), it: -The CLI does **not** start a local callback server or retry connect on 401. If tokens are missing or expired, connect fails; `ConsoleNavigation` may print an authorize URL to stdout, but the CLI cannot finish the redirect flow. Use the TUI for interactive runner OAuth until Phase 4 adds a CLI callback server. +1. Starts the callback listener on `--callback-url` (or `MCP_OAUTH_CALLBACK_URL`) +2. Prints the authorization URL to the console (`ConsoleNavigation`) +3. Waits for the browser redirect, exchanges the code, and retries connect or the failed RPC + +**Step-up (standard OAuth):** when an RPC needs extra scopes, the CLI prompts on stderr: `Proceed with step-up authorization? [y/N]`. **y** continues; **N** exits with an error. EMA step-up re-mints silently (no prompt). + +**Shared OAuth storage:** the CLI **reuses** tokens from `~/.mcp-inspector/storage/oauth.json` when they already exist (same file as other Inspector clients). That is passive file sharing, not launching another app. **Shared with TUI** (config only, not interactive login): @@ -119,9 +125,9 @@ The CLI does **not** start a local callback server or retry connect on 401. If t | ------- | ---------------- | | **Web** | `http://localhost:6274/oauth/callback` | | **TUI** | `http://127.0.0.1:6276/oauth/callback` (interactive — callback server) | -| **CLI** | `http://127.0.0.1:6276/oauth/callback` (redirect URI in OAuth metadata only; no listener) | +| **CLI** | `http://127.0.0.1:6276/oauth/callback` (interactive — same callback server as TUI) | -Register `http://127.0.0.1:6276/oauth/callback` on static or enterprise IdPs that require pre-registered redirect URIs before using the **TUI** (or when your OAuth app expects that URI). Override with `--callback-url` or `MCP_OAUTH_CALLBACK_URL`. The CLI passes this value as `redirect_uri` when an OAuth flow runs, but does not listen on the port. +Register `http://127.0.0.1:6276/oauth/callback` on static or enterprise IdPs that require pre-registered redirect URIs before using the **TUI** or **CLI**. Override with `--callback-url` or `MCP_OAUTH_CALLBACK_URL`. Only one process should bind the default port at a time. #### Flags @@ -141,7 +147,7 @@ npx @modelcontextprotocol/inspector --cli --catalog mcp.json --server my-http-se --method tools/list ``` -See [EMA / enterprise-managed auth](../../specification/v2_auth_ema.md) and [OAuth smoke testing](../../specification/v2_auth_smoke_testing.md) for configuration details and staging servers. +See [EMA / enterprise-managed auth](../../specification/v2_auth_ema.md) and [OAuth smoke testing](../../specification/v2_auth_smoke_testing.md) (§3 Stytch/CIMD; [§5 mid-session manual validation](v2_auth_smoke_testing.md#5-mid-session-auth--step-up--manual-validation) — CLI **C1–C2**). ## Why use the CLI? diff --git a/clients/cli/__tests__/README.md b/clients/cli/__tests__/README.md index b1fcca9eb..09aec386f 100644 --- a/clients/cli/__tests__/README.md +++ b/clients/cli/__tests__/README.md @@ -17,6 +17,7 @@ npm run test:cli # cli.test.ts npm run test:cli-tools # tools.test.ts npm run test:cli-headers # headers.test.ts npm run test:cli-metadata # metadata.test.ts +npx vitest run oauth-interactive.test.ts cliOAuth.test.ts # OAuth interactive smoke parity ``` ## How the CLI is exercised @@ -42,7 +43,10 @@ root provides a further end-to-end check of the binary.) - `metadata.test.ts` - Metadata functionality: General metadata, tool-specific metadata, parsing, merging, validation - `methods.test.ts` - Method/option-validation paths not covered elsewhere (resource templates, missing/invalid options, the `--` target separator) - `error-handler.test.ts` - The binary's `handleError` error sink, exercised in-process with `process.exit` stubbed -- `e2e.test.ts` - Out-of-process spawn of the built binary (exit codes + boot) +- `oauth-runner.test.ts` - OAuth flag wiring (`--client-config`, `--callback-url`, CIMD overrides) +- `cliOAuth.test.ts` - Unit tests for `cliOAuth.ts` (step-up confirm, helper wiring, retry) +- `oauth-interactive.test.ts` - **Integration** smoke parity for CLI interactive OAuth: connect-time callback server + step-up **y/N** against composable `TestServerHttp` (auto-completes authorize URL programmatically; not a subprocess binary e2e) +- `e2e.test.ts` - Out-of-process spawn of the built binary (exit codes + boot; no OAuth) ## Helpers diff --git a/clients/cli/__tests__/cliOAuth.test.ts b/clients/cli/__tests__/cliOAuth.test.ts new file mode 100644 index 000000000..42178e295 --- /dev/null +++ b/clients/cli/__tests__/cliOAuth.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; +import { MutableRedirectUrlProvider } from "@inspector/core/auth/index.js"; +import * as runnerInteractive from "@inspector/core/auth/node/runner-interactive-oauth.js"; +import { + handleCliAuthRecoveryRequired, + isStandardOAuthStepUp, + runCliInteractiveOAuth, + withCliAuthRecoveryRetry, +} from "../src/cliOAuth.js"; + +describe("cliOAuth", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("isStandardOAuthStepUp", () => { + it("returns true for insufficient_scope on non-EMA servers", () => { + expect( + isStandardOAuthStepUp( + { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, + {}, + ), + ).toBe(true); + }); + + it("returns false for EMA servers", () => { + expect( + isStandardOAuthStepUp( + { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, + { enterpriseManaged: true }, + ), + ).toBe(false); + }); + }); + + it("runCliInteractiveOAuth writes success to stderr", async () => { + vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({ + kind: "success", + }); + const client = { + authenticate: vi.fn(), + beginInteractiveAuthorization: vi.fn(), + completeOAuthFlow: vi.fn(), + checkAuthChallengeSatisfied: vi.fn(), + }; + const redirectUrlProvider = new MutableRedirectUrlProvider(); + const stderrSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + + await runCliInteractiveOAuth(client, redirectUrlProvider, { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }); + + expect(stderrSpy).toHaveBeenCalledWith("Authorization complete.\n"); + }); + + it("runCliInteractiveOAuth throws when scopes remain insufficient", async () => { + const challenge = { + reason: "insufficient_scope" as const, + requiredScopes: ["weather:read"], + }; + vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({ + kind: "insufficient_scope", + challenge, + }); + const client = { + authenticate: vi.fn(), + beginInteractiveAuthorization: vi.fn(), + completeOAuthFlow: vi.fn(), + checkAuthChallengeSatisfied: vi.fn(), + }; + + await expect( + runCliInteractiveOAuth( + client, + new MutableRedirectUrlProvider(), + { hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" }, + { authChallenge: challenge }, + ), + ).rejects.toThrow(/required scopes were not granted/); + }); + + it("handleCliAuthRecoveryRequired declines standard step-up when user says no", async () => { + const runSpy = vi + .spyOn(runnerInteractive, "runRunnerInteractiveOAuth") + .mockResolvedValue({ kind: "success" }); + const client = { + authenticate: vi.fn(), + beginInteractiveAuthorization: vi.fn(), + completeOAuthFlow: vi.fn(), + checkAuthChallengeSatisfied: vi.fn(), + }; + const error = new AuthRecoveryRequiredError( + new URL("https://as.example/authorize"), + { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, + ); + + await expect( + handleCliAuthRecoveryRequired( + client, + error, + new MutableRedirectUrlProvider(), + { hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" }, + {}, + async () => false, + ), + ).rejects.toThrow("Step-up authorization declined."); + + expect(runSpy).not.toHaveBeenCalled(); + }); + + it("handleCliAuthRecoveryRequired runs OAuth after step-up confirm", async () => { + const runSpy = vi + .spyOn(runnerInteractive, "runRunnerInteractiveOAuth") + .mockResolvedValue({ kind: "success" }); + const client = { + authenticate: vi.fn(), + beginInteractiveAuthorization: vi.fn(), + completeOAuthFlow: vi.fn(), + checkAuthChallengeSatisfied: vi.fn(), + }; + const authorizationUrl = new URL("https://as.example/authorize"); + const challenge = { + reason: "insufficient_scope" as const, + requiredScopes: ["weather:read"], + }; + const error = new AuthRecoveryRequiredError(authorizationUrl, challenge); + + await handleCliAuthRecoveryRequired( + client, + error, + new MutableRedirectUrlProvider(), + { hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" }, + {}, + async () => true, + ); + + expect(runSpy).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUrl, + authChallenge: challenge, + }), + ); + }); + + it("handleCliAuthRecoveryRequired skips step-up when storage already satisfies", async () => { + const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth"); + const client = { + authenticate: vi.fn(), + beginInteractiveAuthorization: vi.fn(), + completeOAuthFlow: vi.fn(), + checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true), + }; + const error = new AuthRecoveryRequiredError( + new URL("https://as.example/authorize"), + { + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }, + ); + + await handleCliAuthRecoveryRequired( + client, + error, + new MutableRedirectUrlProvider(), + { hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" }, + ); + + expect(runSpy).not.toHaveBeenCalled(); + }); + + it("handleCliAuthRecoveryRequired skips OAuth when storage already satisfies reauth", async () => { + const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth"); + const client = { + authenticate: vi.fn(), + beginInteractiveAuthorization: vi.fn(), + completeOAuthFlow: vi.fn(), + checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true), + }; + const error = new AuthRecoveryRequiredError( + new URL("https://as.example/authorize"), + { reason: "token_expired" }, + ); + + await handleCliAuthRecoveryRequired( + client, + error, + new MutableRedirectUrlProvider(), + { hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" }, + ); + + expect(runSpy).not.toHaveBeenCalled(); + }); + + it("withCliAuthRecoveryRetry reruns the operation after interactive recovery", async () => { + vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({ + kind: "success", + }); + const client = { + authenticate: vi.fn(), + beginInteractiveAuthorization: vi.fn(), + completeOAuthFlow: vi.fn(), + checkAuthChallengeSatisfied: vi.fn(), + }; + const fn = vi + .fn() + .mockRejectedValueOnce( + new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), { + reason: "unauthorized", + }), + ) + .mockResolvedValueOnce("ok"); + + const result = await withCliAuthRecoveryRetry( + client, + new MutableRedirectUrlProvider(), + { hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" }, + { enterpriseManaged: true }, + fn, + async () => true, + ); + + expect(result).toBe("ok"); + expect(fn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/clients/cli/__tests__/oauth-interactive.test.ts b/clients/cli/__tests__/oauth-interactive.test.ts new file mode 100644 index 000000000..358caaf7a --- /dev/null +++ b/clients/cli/__tests__/oauth-interactive.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + TestServerHttp, + waitForOAuthWellKnown, + getDefaultServerConfig, + createOAuthTestServerConfig, + clearOAuthTestData, +} from "@modelcontextprotocol/inspector-test-server"; +import { InspectorClient } from "@inspector/core/mcp/index.js"; +import { createTransportNode } from "@inspector/core/mcp/node/index.js"; +import { + CallbackNavigation, + MutableRedirectUrlProvider, +} from "@inspector/core/auth/index.js"; +import { NodeOAuthStorage } from "@inspector/core/auth/node/index.js"; +import { + connectInspectorWithOAuth, + withCliAuthRecoveryRetry, +} from "../src/cliOAuth.js"; +import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; + +const oauthTestStatePath = join( + tmpdir(), + `mcp-oauth-${process.pid}-cli-interactive.json`, +); + +async function completeOAuthAuthorization( + authorizationUrl: URL, +): Promise { + let response = await fetch(authorizationUrl.toString(), { + redirect: "manual", + }); + + // Local composable test AS shows an HTML consent page on GET; approve via POST. + if (response.status === 200) { + const body = new URLSearchParams(authorizationUrl.searchParams); + response = await fetch( + `${authorizationUrl.origin}${authorizationUrl.pathname}`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + redirect: "manual", + }, + ); + } + + if (response.status !== 302 && response.status !== 301) { + throw new Error( + `Expected redirect (302/301), got ${response.status}: ${await response.text()}`, + ); + } + const location = response.headers.get("location"); + if (!location) { + throw new Error("Missing Location header"); + } + const redirect = new URL(location, authorizationUrl.origin); + const code = redirect.searchParams.get("code"); + if (!code) { + throw new Error("Missing authorization code"); + } + return code; +} + +function createAutoCompleteNavigation( + redirectUrlProvider: MutableRedirectUrlProvider, +) { + return new CallbackNavigation(async (url) => { + const code = await completeOAuthAuthorization(url); + const redirect = redirectUrlProvider.redirectUrl; + if (!redirect) { + throw new Error("redirectUrl not set"); + } + await fetch(`${redirect}?code=${encodeURIComponent(code)}`); + }); +} + +const callbackUrlConfig = { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", +}; +const presetRedirectUrl = "http://127.0.0.1:6276/oauth/callback"; + +describe("CLI interactive OAuth (integration)", () => { + let mcpServer: TestServerHttp | null = null; + + afterEach(async () => { + if (mcpServer) { + await mcpServer.stop(); + mcpServer = null; + } + clearOAuthTestData(); + try { + rmSync(oauthTestStatePath, { force: true }); + } catch { + // ignore + } + }, 30_000); + + it("connects to an OAuth-protected server via the loopback callback server", async () => { + const serverConfig = { + ...getDefaultServerConfig(), + serverType: "streamable-http" as const, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportDCR: true, + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const redirectUrlProvider = new MutableRedirectUrlProvider(); + redirectUrlProvider.redirectUrl = presetRedirectUrl; + const navigation = createAutoCompleteNavigation(redirectUrlProvider); + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + { + environment: { + transport: createTransportNode, + oauth: { + storage: new NodeOAuthStorage(oauthTestStatePath), + navigation, + redirectUrlProvider, + }, + }, + directAuthRecovery: true, + oauth: {}, + }, + ); + + await connectInspectorWithOAuth( + client, + { type: "streamable-http", url: `${serverUrl}/mcp` }, + redirectUrlProvider, + callbackUrlConfig, + ); + + const tools = await client.listTools(); + expect(tools.tools.length).toBeGreaterThan(0); + await client.disconnect(); + }, 30_000); + + it("retries an RPC after step-up authorization when the user confirms", async () => { + const baseConfig = getDefaultServerConfig(); + + const serverConfig = { + ...baseConfig, + serverType: "streamable-http" as const, + tools: baseConfig.tools!.map((tool) => + tool.name === "get_temp" + ? { ...tool, requiredScopes: ["weather:read"] } + : tool, + ), + ...createOAuthTestServerConfig({ + requireAuth: true, + supportRefreshTokens: true, + supportDCR: true, + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const redirectUrlProvider = new MutableRedirectUrlProvider(); + redirectUrlProvider.redirectUrl = presetRedirectUrl; + const navigation = createAutoCompleteNavigation(redirectUrlProvider); + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + { + environment: { + transport: createTransportNode, + oauth: { + storage: new NodeOAuthStorage(oauthTestStatePath), + navigation, + redirectUrlProvider, + }, + }, + directAuthRecovery: true, + oauth: { + scope: "mcp tools:read", + }, + }, + ); + + await connectInspectorWithOAuth( + client, + { type: "streamable-http", url: `${serverUrl}/mcp` }, + redirectUrlProvider, + callbackUrlConfig, + ); + + const tools = await client.listTools(); + const getTempTool = tools.tools.find((tool) => tool.name === "get_temp"); + expect(getTempTool).toBeDefined(); + + let stepUpPrompted = false; + const originalWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk, ...rest) => { + const text = typeof chunk === "string" ? chunk : String(chunk); + if (text.includes("Proceed with step-up authorization?")) { + stepUpPrompted = true; + } + return originalWrite(chunk, ...rest); + }) as typeof process.stderr.write; + + try { + const result = await withCliAuthRecoveryRetry( + client, + redirectUrlProvider, + callbackUrlConfig, + {}, + () => + client.callTool(getTempTool!, { + city: "NYC", + units: "C", + }), + async () => true, + ); + + expect(stepUpPrompted).toBe(true); + expect(result.success).toBe(true); + } finally { + process.stderr.write = originalWrite; + await client.disconnect(); + } + }, 30_000); +}); diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index a8039cbe6..ddd92d10f 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -28,6 +28,10 @@ import { MutableRedirectUrlProvider, } from "@inspector/core/auth/index.js"; import { NodeOAuthStorage } from "@inspector/core/auth/node/index.js"; +import { + connectInspectorWithOAuth, + withCliAuthRecoveryRetry, +} from "./cliOAuth.js"; import { DEFAULT_RUNNER_OAUTH_CALLBACK_URL, formatRunnerOAuthRedirectUrl, @@ -122,9 +126,7 @@ async function callMethod( null; let managedPromptsState: ManagedPromptsState | null = null; - try { - await inspectorClient.connect(); - + const runMethod = async (): Promise => { let result: McpResponse; if (args.method === "tools/list" || args.method === "tools/call") { @@ -248,6 +250,26 @@ async function callMethod( ); } + return result; + }; + + try { + await connectInspectorWithOAuth( + inspectorClient, + serverConfig, + redirectUrlProvider, + callbackUrlConfig!, + serverSettings, + ); + + const result = await withCliAuthRecoveryRetry( + inspectorClient, + redirectUrlProvider, + callbackUrlConfig!, + serverSettings, + runMethod, + ); + await awaitableLog(JSON.stringify(result, null, 2)); } finally { managedToolsState?.destroy(); diff --git a/clients/cli/src/cliOAuth.ts b/clients/cli/src/cliOAuth.ts new file mode 100644 index 000000000..24aa60582 --- /dev/null +++ b/clients/cli/src/cliOAuth.ts @@ -0,0 +1,184 @@ +import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; +import { + AuthRecoveryRequiredError, + isStandardOAuthStepUp as isCoreStandardOAuthStepUp, + isUnauthorizedError, + stepUpConfirmMessage, + stepUpInsufficientScopeMessage, + MutableRedirectUrlProvider, +} from "@inspector/core/auth/index.js"; +import { + createOAuthCallbackServer, + runRunnerInteractiveOAuth, +} from "@inspector/core/auth/node/index.js"; +import type { RunnerOAuthCallbackConfig } from "@inspector/core/auth/node/runner-oauth-callback.js"; +import type { InspectorClient } from "@inspector/core/mcp/index.js"; +import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; +import { isOAuthCapableServerConfig } from "@inspector/core/client/runner.js"; +import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; +import { createInterface } from "node:readline/promises"; + +/** Standard-OAuth step-up (not EMA silent re-mint). */ +export function isStandardOAuthStepUp( + challenge: AuthChallenge, + settings?: InspectorServerSettings, +): boolean { + return isCoreStandardOAuthStepUp(challenge, { + enterpriseManaged: settings?.enterpriseManaged, + }); +} + +async function confirmStepUpFromStdin(): Promise { + const rl = createInterface({ input: process.stdin, output: process.stderr }); + try { + const answer = await rl.question(""); + const normalized = answer.trim().toLowerCase(); + return normalized === "y" || normalized === "yes"; + } finally { + rl.close(); + } +} + +async function promptStepUpConfirm( + challenge: AuthChallenge, + confirmStepUp: () => Promise, +): Promise { + process.stderr.write(`${stepUpConfirmMessage(challenge)}\n`); + process.stderr.write("Proceed with step-up authorization? [y/N] "); + return confirmStepUp(); +} + +export async function runCliInteractiveOAuth( + client: InspectorClient, + redirectUrlProvider: MutableRedirectUrlProvider, + callbackUrlConfig: RunnerOAuthCallbackConfig, + options?: { + authorizationUrl?: URL; + authChallenge?: AuthChallenge; + }, +): Promise { + const result = await runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: callbackUrlConfig, + createCallbackServer: createOAuthCallbackServer, + authorizationUrl: options?.authorizationUrl, + authChallenge: options?.authChallenge, + }); + + if (result.kind === "insufficient_scope") { + throw new Error(stepUpInsufficientScopeMessage(result.challenge)); + } + if (result.kind === "success") { + process.stderr.write("Authorization complete.\n"); + } +} + +export async function handleCliAuthRecoveryRequired( + client: InspectorClient, + error: AuthRecoveryRequiredError, + redirectUrlProvider: MutableRedirectUrlProvider, + callbackUrlConfig: RunnerOAuthCallbackConfig, + serverSettings?: InspectorServerSettings, + confirmStepUp: () => Promise = confirmStepUpFromStdin, +): Promise { + if (isStandardOAuthStepUp(error.authChallenge, serverSettings)) { + if (await client.checkAuthChallengeSatisfied(error.authChallenge)) { + return; + } + const proceed = await promptStepUpConfirm( + error.authChallenge, + confirmStepUp, + ); + if (!proceed) { + throw new Error("Step-up authorization declined."); + } + } else if (await client.checkAuthChallengeSatisfied(error.authChallenge)) { + return; + } + + await runCliInteractiveOAuth(client, redirectUrlProvider, callbackUrlConfig, { + authorizationUrl: error.authorizationUrl, + ...(error.authChallenge.reason === "insufficient_scope" && { + authChallenge: error.authChallenge, + }), + }); +} + +export async function connectInspectorWithOAuth( + inspectorClient: InspectorClient, + serverConfig: MCPServerConfig, + redirectUrlProvider: MutableRedirectUrlProvider, + callbackUrlConfig: RunnerOAuthCallbackConfig, + serverSettings?: InspectorServerSettings, +): Promise { + try { + await inspectorClient.connect(); + } catch (err) { + if (!isOAuthCapableServerConfig(serverConfig)) { + throw err; + } + + if (err instanceof AuthRecoveryRequiredError) { + if ( + await inspectorClient.checkAuthChallengeSatisfied(err.authChallenge) + ) { + await inspectorClient.connect(); + return; + } + await handleCliAuthRecoveryRequired( + inspectorClient, + err, + redirectUrlProvider, + callbackUrlConfig, + serverSettings, + ); + await inspectorClient.connect(); + return; + } + + if (isUnauthorizedError(err)) { + await inspectorClient.disconnect().catch(() => {}); + await runCliInteractiveOAuth( + inspectorClient, + redirectUrlProvider, + callbackUrlConfig, + ); + await inspectorClient.connect(); + return; + } + + throw err; + } +} + +/** + * Run `fn` once; on {@link AuthRecoveryRequiredError}, complete interactive OAuth + * and retry `fn` a single time (no further recovery attempts). + */ +export async function withCliAuthRecoveryRetry( + inspectorClient: InspectorClient, + redirectUrlProvider: MutableRedirectUrlProvider, + callbackUrlConfig: RunnerOAuthCallbackConfig, + serverSettings: InspectorServerSettings | undefined, + fn: () => Promise, + confirmStepUp: () => Promise = confirmStepUpFromStdin, +): Promise { + try { + return await fn(); + } catch (err) { + if (!(err instanceof AuthRecoveryRequiredError)) { + throw err; + } + await handleCliAuthRecoveryRequired( + inspectorClient, + err, + redirectUrlProvider, + callbackUrlConfig, + serverSettings, + confirmStepUp, + ); + process.stderr.write("Authorization complete. Retrying…\n"); + return await fn(); + } +} diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index ddefbecc9..7ea98657b 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -55,6 +55,9 @@ const h = vi.hoisted(() => { clearOAuthTokens: vi.fn(), completeOAuthFlow: vi.fn(async (): Promise => {}), getOAuthState: vi.fn(async () => undefined), + callTool: vi.fn(), + checkAuthChallengeSatisfied: vi.fn(async () => false), + handleAuthChallenge: vi.fn(async () => ({ kind: "satisfied" as const })), }; // Captured options from the most recent callbackServer.start(), so a test can // drive the onCallback / onError handlers the OAuth flows register. @@ -95,6 +98,11 @@ const h = vi.hoisted(() => { completeOAuthFlow = (...a: unknown[]) => clientSpies.completeOAuthFlow(...a); getOAuthState = (...a: unknown[]) => clientSpies.getOAuthState(...a); + callTool = (...a: unknown[]) => clientSpies.callTool(...a); + checkAuthChallengeSatisfied = (...a: unknown[]) => + clientSpies.checkAuthChallengeSatisfied(...a); + handleAuthChallenge = (...a: unknown[]) => + clientSpies.handleAuthChallenge(...a); readResource = vi.fn(async () => ({ result: { contents: [{ uri: "file://x", text: "hello" }] }, })); @@ -185,16 +193,25 @@ vi.mock("@inspector/core/auth/index.js", async (importOriginal) => { }, }; }); -vi.mock("@inspector/core/auth/node/index.js", () => ({ - createOAuthCallbackServer: h.createOAuthCallbackServer, - NodeOAuthStorage: class {}, -})); +vi.mock("@inspector/core/auth/node/index.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + createOAuthCallbackServer: h.createOAuthCallbackServer, + NodeOAuthStorage: class {}, + }; +}); vi.mock("../src/utils/openUrl.js", () => ({ openUrl: h.openUrl, })); import App from "../src/App.js"; import type { TuiServer } from "../src/tui-servers.js"; +import { + AuthRecoveryRequiredError, + EMA_STEP_UP_PENDING_URL, +} from "@inspector/core/auth/challenge.js"; const tick = () => new Promise((r) => setTimeout(r, 25)); const callbackUrlConfig = { hostname: "127.0.0.1", port: 0, pathname: "/cb" }; @@ -234,6 +251,27 @@ function oneHttp(): Record { }; } +function oneEmaHttp(): Record { + return { + ema: { + config: { type: "streamable-http", url: "http://localhost:8080/mcp" }, + settings: { + requestTimeout: 0, + metadata: [], + headers: [], + env: [], + roots: [], + maxFetchRequests: 1000, + taskTtl: 0, + connectionTimeout: 0, + oauthClientId: "client_ema_test", + oauthScopes: "tools:read", + enterpriseManaged: true, + }, + } as never, + }; +} + // Mixed catalog: an OAuth-capable http server first (auto-selected) followed by // a stdio server � drives per-server tab gating + the tab-switch-away effects. function httpThenStdio(): Record { @@ -466,6 +504,11 @@ beforeEach(() => { h.clientSpies.completeOAuthFlow.mockResolvedValue(undefined); h.clientSpies.getOAuthState.mockReset(); h.clientSpies.getOAuthState.mockResolvedValue(undefined); + h.clientSpies.callTool.mockReset(); + h.clientSpies.checkAuthChallengeSatisfied.mockReset(); + h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); + h.clientSpies.handleAuthChallenge.mockReset(); + h.clientSpies.handleAuthChallenge.mockResolvedValue({ kind: "satisfied" }); }); afterEach(() => { @@ -936,4 +979,65 @@ describe("App (OAuth flows)", () => { await h.cb.opts!.onCallback({ code: "x" }); await expectFrame(r, "cb-string"); }); + + it("shows EMA step-up confirmation on tool auth recovery instead of auto OAuth", async () => { + const challenge = { + reason: "insufficient_scope" as const, + requiredScopes: ["env:read"], + authorizationScopes: ["tools:read", "env:read"], + context: { toolName: "get-env" }, + }; + h.clientSpies.callTool.mockRejectedValue( + new AuthRecoveryRequiredError(EMA_STEP_UP_PENDING_URL, challenge, { + emaStepUpConfirm: true, + }), + ); + h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); + h.ctrl.status = "connected"; + h.ctrl.tools = [sampleTool]; + const r = await mount(oneEmaHttp()); + await press(r, ["t", TAB, ENTER]); + await expectFrame(r, "MOCK_FORM"); + await press(r, [ENTER]); + await waitUntil(() => h.clientSpies.callTool.mock.calls.length > 0); + await expectFrame(r, "organization before it can continue"); + const frame = r.lastFrame() ?? ""; + expect(frame).toMatch(/organization|get-env/i); + expect(frame).not.toMatch(/opens browser/i); + expect(frame).not.toMatch(/OAuth: authenticating/i); + expect(h.callbackStart).not.toHaveBeenCalled(); + }); + + it("runs confirmed EMA step-up via handleAuthChallenge when user authorizes", async () => { + const challenge = { + reason: "insufficient_scope" as const, + requiredScopes: ["env:read"], + authorizationScopes: ["tools:read", "env:read"], + context: { toolName: "get-env" }, + }; + h.clientSpies.callTool.mockRejectedValue( + new AuthRecoveryRequiredError(EMA_STEP_UP_PENDING_URL, challenge, { + emaStepUpConfirm: true, + }), + ); + h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); + h.clientSpies.handleAuthChallenge.mockResolvedValue({ kind: "satisfied" }); + h.ctrl.status = "connected"; + h.ctrl.tools = [sampleTool]; + const r = await mount(oneEmaHttp()); + await press(r, ["t", TAB, ENTER]); + await expectFrame(r, "MOCK_FORM"); + await press(r, [ENTER]); + await waitUntil(() => h.clientSpies.callTool.mock.calls.length > 0); + await expectFrame(r, "organization before it can continue"); + await press(r, ["a"]); + await waitUntil( + () => h.clientSpies.handleAuthChallenge.mock.calls.length > 0, + ); + expect(h.clientSpies.handleAuthChallenge).toHaveBeenCalledWith(challenge, { + confirmedStepUp: true, + }); + expect(h.callbackStart).not.toHaveBeenCalled(); + await expectFrame(r, "Step-up authorization succeeded"); + }); }); diff --git a/clients/tui/__tests__/AuthTab.test.tsx b/clients/tui/__tests__/AuthTab.test.tsx index 431391d2f..fbefcf2e0 100644 --- a/clients/tui/__tests__/AuthTab.test.tsx +++ b/clients/tui/__tests__/AuthTab.test.tsx @@ -71,6 +71,15 @@ const baseProps = { connectionStatus: "disconnected" as const, }; +const pendingStepUp = { + challenge: { + reason: "insufficient_scope" as const, + requiredScopes: ["env:read"], + authorizationScopes: ["tools:read", "env:read"], + }, + enterpriseManaged: true, +}; + describe("AuthTab", () => { it("renders the placeholder when there is no server", () => { const { lastFrame } = render( @@ -220,6 +229,86 @@ describe("AuthTab", () => { expect(lastFrame() ?? "").toContain("OAuth Details"); }); + it("navigates step-up choices with arrows and activates with Enter", async () => { + const onAuthorizeStepUp = vi.fn(); + const onCancelStepUp = vi.fn(); + const { client } = makeClient(sampleOAuthState); + const { stdin } = render( + , + ); + await tick(); + stdin.write("\r"); + await tick(); + expect(onAuthorizeStepUp).toHaveBeenCalledTimes(1); + expect(onCancelStepUp).not.toHaveBeenCalled(); + + onAuthorizeStepUp.mockClear(); + stdin.write(DOWN); + await tick(); + stdin.write("\r"); + await tick(); + expect(onCancelStepUp).toHaveBeenCalledTimes(1); + expect(onAuthorizeStepUp).not.toHaveBeenCalled(); + }); + + it("shows step-up footer with selection hints when focused", async () => { + const { client } = makeClient(sampleOAuthState); + const { lastFrame } = render( + , + ); + await tick(); + expect(lastFrame() ?? "").toContain("↑/↓ select, Enter confirm"); + }); + + it("refreshes OAuth state when connection becomes connected", async () => { + const { client, getOAuthState } = makeClient(undefined); + const { lastFrame, rerender } = render( + , + ); + await tick(); + expect(getOAuthState).toHaveBeenCalledTimes(1); + expect(lastFrame() ?? "").toContain("No OAuth information yet"); + + getOAuthState.mockResolvedValue(sampleOAuthState); + rerender( + , + ); + await tick(); + expect(getOAuthState).toHaveBeenCalledTimes(2); + expect(lastFrame() ?? "").toContain("Authorized"); + expect(lastFrame() ?? "").toContain("OAuth Details"); + }); + it("refreshes OAuth state when oauthComplete fires", async () => { const { client, getOAuthState, fire, listeners } = makeClient(sampleOAuthState); diff --git a/clients/tui/__tests__/PromptTestModal.test.tsx b/clients/tui/__tests__/PromptTestModal.test.tsx index 1a1d05825..c5058257f 100644 --- a/clients/tui/__tests__/PromptTestModal.test.tsx +++ b/clients/tui/__tests__/PromptTestModal.test.tsx @@ -2,6 +2,7 @@ import React from "react"; import { describe, it, expect, vi, afterEach } from "vitest"; import { render } from "ink-testing-library"; import type { InspectorClient } from "@inspector/core/mcp/index.js"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import type { Prompt } from "@modelcontextprotocol/sdk/types.js"; // ScrollView: passthrough so the results JSX actually mounts (and is counted @@ -341,4 +342,34 @@ describe("PromptTestModal", () => { await tick(); expect(true).toBe(true); }); + + it("delegates AuthRecoveryRequiredError to onAuthRecoveryRequired and closes", async () => { + const recovery = new AuthRecoveryRequiredError( + new URL("https://auth.example.com/authorize"), + { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, + ); + const getPrompt = vi.fn().mockRejectedValue(recovery); + const onClose = vi.fn(); + const onAuthRecoveryRequired = vi.fn(); + setFormSubmitValue({ topic: "x" }); + + const { stdin } = render( + , + ); + + await tick(); + stdin.write(ENTER); + await tick(); + await tick(); + + expect(onAuthRecoveryRequired).toHaveBeenCalledWith(recovery); + expect(onClose).toHaveBeenCalledTimes(1); + }); }); diff --git a/clients/tui/__tests__/ResourceTestModal.test.tsx b/clients/tui/__tests__/ResourceTestModal.test.tsx index 37103ba77..d9de004d6 100644 --- a/clients/tui/__tests__/ResourceTestModal.test.tsx +++ b/clients/tui/__tests__/ResourceTestModal.test.tsx @@ -2,6 +2,7 @@ import React from "react"; import { describe, it, expect, vi, afterEach } from "vitest"; import { render } from "ink-testing-library"; import type { InspectorClient } from "@inspector/core/mcp/index.js"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; // ScrollView passthrough so the results JSX actually mounts (and is covered). vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js")); @@ -264,4 +265,32 @@ describe("ResourceTestModal", () => { await tick(); api.unmount(); }); + + it("delegates AuthRecoveryRequiredError to onAuthRecoveryRequired and closes", async () => { + const recovery = new AuthRecoveryRequiredError( + new URL("https://auth.example.com/authorize"), + { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, + ); + const read = vi.fn().mockRejectedValue(recovery); + const onClose = vi.fn(); + const onAuthRecoveryRequired = vi.fn(); + const api = render( + , + ); + await tick(); + setSubmitValue({ name: "world" }); + api.stdin.write("\r"); + await tick(); + await tick(); + expect(onAuthRecoveryRequired).toHaveBeenCalledWith(recovery); + expect(onClose).toHaveBeenCalledTimes(1); + api.unmount(); + }); }); diff --git a/clients/tui/__tests__/tuiOAuth.test.ts b/clients/tui/__tests__/tuiOAuth.test.ts new file mode 100644 index 000000000..125c57f4b --- /dev/null +++ b/clients/tui/__tests__/tuiOAuth.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { + isStandardOAuthStepUp, + isStepUpConfirmation, + stepUpConfirmMessage, + stepUpInsufficientScopeMessage, +} from "../src/utils/tuiOAuth.js"; + +describe("tuiOAuth", () => { + it("detects standard OAuth step-up", () => { + expect( + isStandardOAuthStepUp( + { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, + { enterpriseManaged: false }, + ), + ).toBe(true); + expect( + isStandardOAuthStepUp( + { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, + { enterpriseManaged: true }, + ), + ).toBe(false); + }); + + it("detects EMA step-up confirmation", () => { + expect( + isStepUpConfirmation( + { reason: "insufficient_scope", requiredScopes: ["env:read"] }, + { enterpriseManaged: true }, + ), + ).toBe(true); + expect( + isStepUpConfirmation( + { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, + { enterpriseManaged: false }, + ), + ).toBe(true); + }); + + it("formats step-up messages", () => { + const challenge = { + reason: "insufficient_scope" as const, + requiredScopes: ["weather:read"], + context: { toolName: "get_temp" }, + }; + expect(stepUpConfirmMessage(challenge)).toMatch(/get_temp/); + expect(stepUpInsufficientScopeMessage(challenge)).toMatch(/get_temp/); + }); +}); diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 763eaa01f..86445f0a6 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -45,7 +45,10 @@ import { CallbackNavigation, MutableRedirectUrlProvider, isUnauthorizedError, + AuthRecoveryRequiredError, } from "@inspector/core/auth/index.js"; +import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; +import type { TypedEvent } from "@inspector/core/mcp/inspectorClientEventTarget.js"; import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js"; import type { ClientConfig } from "@inspector/core/client/types.js"; import { @@ -57,9 +60,15 @@ import { createOAuthCallbackServer, type OAuthCallbackServer, NodeOAuthStorage, + runRunnerInteractiveOAuth, } from "@inspector/core/auth/node/index.js"; import { getTuiLogger } from "./logger.js"; import { openUrl } from "./utils/openUrl.js"; +import { + isStepUpConfirmation, + stepUpInsufficientScopeMessage, +} from "./utils/tuiOAuth.js"; +import { emaStepUpFailureMessage } from "@inspector/core/auth/oauthUx.js"; import { Tabs } from "./components/Tabs.js"; import { type TabType, tabs as tabList } from "./components/tabsConfig.js"; import { InfoTab } from "./components/InfoTab.js"; @@ -141,14 +150,6 @@ function App({ callbackUrlConfig, }: AppProps) { const { exit } = useApp(); - const callbackServerBaseOptions = useMemo( - () => ({ - port: callbackUrlConfig.port, - hostname: callbackUrlConfig.hostname, - path: callbackUrlConfig.pathname, - }), - [callbackUrlConfig], - ); useEffect(() => { getTuiLogger().info( @@ -174,9 +175,22 @@ function App({ >("idle"); const [oauthMessage, setOauthMessage] = useState(null); const [oauthRevision, setOauthRevision] = useState(0); + const [pendingStepUp, setPendingStepUp] = useState<{ + serverName: string; + challenge: AuthChallenge; + authorizationUrl: URL; + enterpriseManaged?: boolean; + } | null>(null); + const pendingStepUpRef = useRef(pendingStepUp); + useEffect(() => { + pendingStepUpRef.current = pendingStepUp; + }, [pendingStepUp]); const [connectError, setConnectError] = useState(null); const oauthInProgressRef = useRef(false); const callbackServerRef = useRef(null); + const selectedServerRef = useRef(null); + const mcpServersRef = useRef(mcpServers); + const inspectorClientsRef = useRef>({}); // Tool test modal state const [toolTestModal, setToolTestModal] = useState<{ @@ -419,12 +433,22 @@ function App({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Clear OAuth status when switching servers + // Clear OAuth status when switching servers; drop step-up for other servers. useEffect(() => { setOauthStatus("idle"); setOauthMessage(null); + const stepUp = pendingStepUpRef.current; + if (stepUp && selectedServer && stepUp.serverName !== selectedServer) { + setPendingStepUp(null); + } }, [selectedServer]); + useEffect(() => { + selectedServerRef.current = selectedServer; + mcpServersRef.current = mcpServers; + inspectorClientsRef.current = inspectorClients; + }, [selectedServer, mcpServers, inspectorClients]); + // Switch away from Auth tab when server is not OAuth-capable useEffect(() => { if ( @@ -534,79 +558,184 @@ function App({ selectedManagedPromptsState, ); - // Shared ref for OAuth callback server; stop before starting new (avoids EADDRINUSE when prior auth failed without redirect) + // Connect — on 401 or mid-session auth recovery, run OAuth then retry. + const runOAuthAuthentication = useCallback( + async (options?: { + challenge?: AuthChallenge; + authorizationUrl?: URL; + /** When set, run OAuth for this server (may differ from the selected server). */ + serverName?: string; + }) => { + const serverName = options?.serverName ?? selectedServer; + if (!serverName) { + return; + } + const client = inspectorClientsRef.current[serverName]; + const serverEntry = mcpServersRef.current[serverName]; + const serverConfig = serverEntry?.config; + if ( + !client || + !serverConfig || + !isOAuthCapableServerConfig(serverConfig) + ) { + return; + } + if (oauthInProgressRef.current) return; + oauthInProgressRef.current = true; + getTuiLogger().info( + { server: serverName }, + "OAuth authentication started", + ); + const existing = callbackServerRef.current; + if (existing) { + await existing.stop(); + callbackServerRef.current = null; + } + const redirectUrlProvider = redirectUrlProvidersRef.current[serverName]; + if (!redirectUrlProvider) { + oauthInProgressRef.current = false; + return; + } + try { + const result = await runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: callbackUrlConfig, + createCallbackServer: createOAuthCallbackServer, + onCallbackServer: (server) => { + callbackServerRef.current = server; + }, + authorizationUrl: options?.authorizationUrl, + authChallenge: options?.challenge, + }); - // Connect — on 401, run OAuth then retry (same pattern as web App.tsx). - const runOAuthAuthentication = useCallback(async () => { - if ( - !selectedServer || - !selectedInspectorClient || - !selectedServerConfig || - !isOAuthCapableServerConfig(selectedServerConfig) - ) { - return; - } - if (oauthInProgressRef.current) return; - oauthInProgressRef.current = true; - getTuiLogger().info( - { server: selectedServer }, - "OAuth authentication started", - ); - const existing = callbackServerRef.current; - if (existing) { - await existing.stop(); - callbackServerRef.current = null; - } - const callbackServer = createOAuthCallbackServer(); - callbackServerRef.current = callbackServer; - let flowResolve: () => void; - let flowReject: (err: Error) => void; - const flowDone = new Promise((resolve, reject) => { - flowResolve = resolve; - flowReject = reject; - }); - try { - const { redirectUrl } = await callbackServer.start({ - ...callbackServerBaseOptions, - onCallback: async (params) => { - try { - await selectedInspectorClient!.completeOAuthFlow(params.code); - flowResolve!(); - } catch (err) { - flowReject!(err instanceof Error ? err : new Error(String(err))); - } finally { - callbackServerRef.current = null; + if (result.kind === "insufficient_scope") { + setOauthStatus("error"); + setOauthMessage(stepUpInsufficientScopeMessage(result.challenge)); + return; + } + if (result.kind === "success" || result.kind === "already_authorized") { + setOauthRevision((n) => n + 1); + } + } finally { + oauthInProgressRef.current = false; + callbackServerRef.current = null; + } + }, + [selectedServer, callbackUrlConfig], + ); + + const presentStepUpForServer = useCallback( + ( + serverName: string, + challenge: AuthChallenge, + authorizationUrl: URL, + enterpriseManaged?: boolean, + ) => { + const pending = pendingStepUpRef.current; + if (pending && pending.serverName !== serverName) { + setOauthMessage( + "A step-up prompt is already open. Complete or decline it before continuing.", + ); + return; + } + if (pending?.serverName === serverName) { + setOauthMessage( + "Updated step-up request — review the scopes on the Auth tab.", + ); + } else { + setOauthMessage(null); + } + setSelectedServer(serverName); + setPendingStepUp({ + serverName, + challenge, + authorizationUrl, + enterpriseManaged, + }); + setActiveTab("auth"); + setOauthStatus("idle"); + setFocus("tabContentList"); + }, + [], + ); + + const handleAuthRecoveryRequired = useCallback( + (serverName: string, error: AuthRecoveryRequiredError) => { + const serverEntry = mcpServersRef.current[serverName]; + const settings = serverEntry?.settings; + const client = inspectorClientsRef.current[serverName]; + const needsStepUpConfirm = + error.emaStepUpConfirm || + isStepUpConfirmation(error.authChallenge, settings); + if (needsStepUpConfirm) { + void (async () => { + if ( + client && + (await client.checkAuthChallengeSatisfied(error.authChallenge)) + ) { + setOauthStatus("idle"); + setOauthMessage("Authorization updated. Retry your action."); + setOauthRevision((n) => n + 1); + return; } - }, - onError: (params) => { - flowReject!( - new Error( - params.error_description ?? params.error ?? "OAuth error", - ), + presentStepUpForServer( + serverName, + error.authChallenge, + error.authorizationUrl, + settings?.enterpriseManaged, ); - void callbackServer.stop(); - callbackServerRef.current = null; - }, - }); - const redirectUrlProvider = - redirectUrlProvidersRef.current[selectedServer]; - if (redirectUrlProvider) { - redirectUrlProvider.redirectUrl = redirectUrl; + })(); + return; } - const authUrl = await selectedInspectorClient.authenticate(); - if (authUrl !== undefined) { - await flowDone; + void (async () => { + if ( + client && + (await client.checkAuthChallengeSatisfied(error.authChallenge)) + ) { + setOauthStatus("idle"); + setOauthMessage("Authorization updated. Retry your action."); + setOauthRevision((n) => n + 1); + return; + } + const needsSwitch = selectedServerRef.current !== serverName; + if (needsSwitch) { + setSelectedServer(serverName); + setActiveTab("auth"); + setOauthMessage( + `Authentication required for "${serverName}". Re-authenticating…`, + ); + } else { + setOauthMessage(null); + } + setOauthStatus("authenticating"); + try { + await runOAuthAuthentication({ + challenge: error.authChallenge, + authorizationUrl: error.authorizationUrl, + serverName, + }); + setOauthStatus("idle"); + setOauthMessage("Authorization updated. Retry your action."); + } catch (authErr) { + const authMsg = + authErr instanceof Error ? authErr.message : String(authErr); + setOauthStatus("error"); + setOauthMessage(authMsg); + } + })(); + }, + [presentStepUpForServer, runOAuthAuthentication], + ); + + const onAuthRecoveryRequired = useCallback( + (error: AuthRecoveryRequiredError) => { + if (selectedServer) { + handleAuthRecoveryRequired(selectedServer, error); } - setOauthRevision((n) => n + 1); - } finally { - oauthInProgressRef.current = false; - } - }, [ - selectedServer, - selectedInspectorClient, - selectedServerConfig, - callbackServerBaseOptions, - ]); + }, + [selectedServer, handleAuthRecoveryRequired], + ); const handleConnect = useCallback(async () => { if (!selectedServer || !selectedInspectorClient || !selectedServerConfig) { @@ -618,6 +747,7 @@ function App({ setConnectError(null); setOauthStatus("idle"); setOauthMessage(null); + setOauthRevision((n) => n + 1); }; try { @@ -639,13 +769,14 @@ function App({ try { setOauthStatus("authenticating"); setOauthMessage(null); - // Tear down the failed handshake transport so the post-OAuth connect - // creates a fresh transport with tokens from storage (same as web, - // which reloads the client after redirect). await disconnectInspector(); await runOAuthAuthentication(); await finishConnect(); } catch (authErr) { + if (authErr instanceof AuthRecoveryRequiredError) { + handleAuthRecoveryRequired(selectedServer, authErr); + return; + } const authMsg = authErr instanceof Error ? authErr.message : String(authErr); setConnectError(authMsg); @@ -659,6 +790,11 @@ function App({ } return; } + + if (err instanceof AuthRecoveryRequiredError && selectedServer) { + handleAuthRecoveryRequired(selectedServer, err); + return; + } } }, [ selectedServer, @@ -667,8 +803,63 @@ function App({ connectInspector, disconnectInspector, runOAuthAuthentication, + handleAuthRecoveryRequired, ]); + useEffect(() => { + const cleanups: Array<() => void> = []; + + for (const [serverName, client] of Object.entries(inspectorClients)) { + const onAmbient = (): void => { + if (selectedServerRef.current !== serverName) return; + setOauthStatus("idle"); + setOauthMessage("Refreshing authorization…"); + }; + const onRecovered = (): void => { + if (selectedServerRef.current !== serverName) return; + setOauthMessage(null); + setOauthRevision((n) => n + 1); + }; + const onInteractive = ( + event: TypedEvent<"authChallengeInteractive">, + ): void => { + handleAuthRecoveryRequired( + serverName, + new AuthRecoveryRequiredError( + event.detail.authorizationUrl, + event.detail.challenge, + ), + ); + }; + const onOAuthError = (event: TypedEvent<"oauthError">): void => { + if (selectedServerRef.current !== serverName) return; + const message = + event.detail.error instanceof Error + ? event.detail.error.message + : String(event.detail.error); + setOauthStatus("error"); + setOauthMessage(message); + }; + + client.addEventListener("authChallengeAmbient", onAmbient); + client.addEventListener("authChallengeRecovered", onRecovered); + client.addEventListener("authChallengeInteractive", onInteractive); + client.addEventListener("oauthError", onOAuthError); + cleanups.push(() => { + client.removeEventListener("authChallengeAmbient", onAmbient); + client.removeEventListener("authChallengeRecovered", onRecovered); + client.removeEventListener("authChallengeInteractive", onInteractive); + client.removeEventListener("oauthError", onOAuthError); + }); + } + + return () => { + for (const cleanup of cleanups) { + cleanup(); + } + }; + }, [inspectorClients, handleAuthRecoveryRequired]); + // Disconnect handler const handleDisconnect = useCallback(async () => { if (!selectedServer) return; @@ -1110,13 +1301,6 @@ function App({ const nextTab = tabAccelerators[input.toLowerCase()]!; setActiveTab(nextTab); setFocus(nextTab === "auth" ? "tabContentList" : "tabs"); - } else if ( - activeTab === "auth" && - showAuthTab && - input.toLowerCase() === "s" - ) { - void handleClearOAuth(); - setFocus("tabContentList"); } else if (key.tab && !key.shift) { // Flat focus order: servers -> tabs -> list -> details -> wrap to servers const focusOrder: FocusArea[] = @@ -1470,6 +1654,86 @@ function App({ oauthStatus={oauthStatus} oauthMessage={oauthMessage} oauthRevision={oauthRevision} + pendingStepUp={ + pendingStepUp?.serverName === selectedServer + ? { + challenge: pendingStepUp.challenge, + authorizationScopes: + pendingStepUp.challenge.authorizationScopes, + enterpriseManaged: pendingStepUp.enterpriseManaged, + } + : null + } + onAuthorizeStepUp={() => { + if (!pendingStepUp || !selectedInspectorClient) return; + const { challenge, authorizationUrl, enterpriseManaged } = + pendingStepUp; + setPendingStepUp(null); + setOauthStatus("authenticating"); + void (async () => { + try { + if (enterpriseManaged) { + const outcome = + await selectedInspectorClient.handleAuthChallenge( + challenge, + { confirmedStepUp: true }, + ); + if (outcome.kind === "satisfied") { + await disconnectInspector().catch(() => {}); + await connectInspector(); + setOauthStatus("idle"); + setOauthMessage( + "Step-up authorization succeeded. Retry your action.", + ); + setOauthRevision((n) => n + 1); + return; + } + if (outcome.kind === "interactive") { + await runOAuthAuthentication({ + challenge: outcome.challenge, + authorizationUrl: outcome.authorizationUrl, + }); + setOauthStatus("idle"); + setOauthMessage( + "Step-up authorization succeeded. Retry your action.", + ); + return; + } + if (outcome.kind === "failed") { + setOauthStatus("error"); + setOauthMessage( + emaStepUpFailureMessage(outcome.error.message), + ); + return; + } + setOauthStatus("idle"); + setOauthMessage( + "Step-up authorization succeeded. Retry your action.", + ); + return; + } + await runOAuthAuthentication({ + challenge, + authorizationUrl, + }); + setOauthStatus("idle"); + setOauthMessage( + "Step-up authorization succeeded. Retry your action.", + ); + } catch (authErr) { + const authMsg = + authErr instanceof Error + ? authErr.message + : String(authErr); + setOauthStatus("error"); + setOauthMessage(authMsg); + } + })(); + }} + onCancelStepUp={() => { + setPendingStepUp(null); + setOauthMessage("Authorization cancelled."); + }} width={contentWidth} height={contentHeight} focused={ @@ -1517,8 +1781,14 @@ function App({ inspectorClient: selectedInspectorClient, }); }} + onAuthRecoveryRequired={onAuthRecoveryRequired} modalOpen={ - !!(toolTestModal || resourceTestModal || detailsModal) + !!( + toolTestModal || + resourceTestModal || + promptTestModal || + detailsModal + ) } /> ) : activeTab === "prompts" && @@ -1552,6 +1822,7 @@ function App({ inspectorClient: selectedInspectorClient, }); }} + onAuthRecoveryRequired={onAuthRecoveryRequired} modalOpen={ !!( toolTestModal || @@ -1680,6 +1951,10 @@ function App({ width={dimensions.width} height={dimensions.height} onClose={() => setToolTestModal(null)} + onAuthRecoveryRequired={(error) => { + onAuthRecoveryRequired(error); + setToolTestModal(null); + }} /> )} @@ -1691,6 +1966,10 @@ function App({ width={dimensions.width} height={dimensions.height} onClose={() => setResourceTestModal(null)} + onAuthRecoveryRequired={(error) => { + onAuthRecoveryRequired(error); + setResourceTestModal(null); + }} /> )} @@ -1701,6 +1980,10 @@ function App({ width={dimensions.width} height={dimensions.height} onClose={() => setPromptTestModal(null)} + onAuthRecoveryRequired={(error) => { + onAuthRecoveryRequired(error); + setPromptTestModal(null); + }} /> )} diff --git a/clients/tui/src/components/AuthTab.tsx b/clients/tui/src/components/AuthTab.tsx index a3247194a..c495a4e53 100644 --- a/clients/tui/src/components/AuthTab.tsx +++ b/clients/tui/src/components/AuthTab.tsx @@ -8,12 +8,19 @@ import type { ConnectionStatus, } from "@inspector/core/mcp/index.js"; import type { OAuthConnectionState } from "@inspector/core/auth/types.js"; +import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; import { formatAuthProtocol, formatClientRegistrationKind, formatIdpSession, formatScopes, } from "../utils/oauthDisplay.js"; +import { + stepUpAuthorizeActionLabel, + stepUpConfirmMessage, + stepUpFollowUpMessage, + stepUpModalTitle, +} from "../utils/tuiOAuth.js"; interface AuthTabProps { serverName: string | null; @@ -22,6 +29,13 @@ interface AuthTabProps { oauthStatus: "idle" | "authenticating" | "error"; oauthMessage: string | null; oauthRevision: number; + pendingStepUp?: { + challenge: AuthChallenge; + authorizationScopes?: string[]; + enterpriseManaged?: boolean; + } | null; + onAuthorizeStepUp?: () => void; + onCancelStepUp?: () => void; width: number; height: number; focused?: boolean; @@ -44,6 +58,9 @@ export function AuthTab({ oauthStatus, oauthMessage, oauthRevision, + pendingStepUp, + onAuthorizeStepUp, + onCancelStepUp, width, height, focused = false, @@ -58,6 +75,7 @@ export function AuthTab({ >(undefined); const [clearedConfirmation, setClearedConfirmation] = useState(false); const [lastClearDisconnected, setLastClearDisconnected] = useState(false); + const [stepUpChoiceIndex, setStepUpChoiceIndex] = useState(0); const refreshOAuthState = useCallback(async () => { if (!inspectorClient) { @@ -70,13 +88,17 @@ export function AuthTab({ useEffect(() => { void refreshOAuthState(); - }, [refreshOAuthState, oauthRevision]); + }, [refreshOAuthState, oauthRevision, connectionStatus]); useEffect(() => { setClearedConfirmation(false); setLastClearDisconnected(false); }, [oauthRevision]); + useEffect(() => { + setStepUpChoiceIndex(0); + }, [pendingStepUp]); + useEffect(() => { if (!inspectorClient) return; @@ -93,6 +115,34 @@ export function AuthTab({ (input: string, key: Key) => { if (!focused) return; + if (pendingStepUp) { + if (key.upArrow) { + setStepUpChoiceIndex((i) => Math.max(0, i - 1)); + return; + } + if (key.downArrow) { + setStepUpChoiceIndex((i) => Math.min(1, i + 1)); + return; + } + if (key.return) { + if (stepUpChoiceIndex === 0) { + onAuthorizeStepUp?.(); + } else { + onCancelStepUp?.(); + } + return; + } + if (input.toLowerCase() === "a") { + onAuthorizeStepUp?.(); + return; + } + if (input.toLowerCase() === "c") { + onCancelStepUp?.(); + return; + } + return; + } + if (key.upArrow && scrollViewRef.current) { scrollViewRef.current.scrollBy(-1); } else if (key.downArrow && scrollViewRef.current) { @@ -139,6 +189,66 @@ export function AuthTab({ {oauthStatus === "error" && oauthMessage && ( {oauthMessage} )} + {oauthStatus === "idle" && oauthMessage && ( + {oauthMessage} + )} + + {pendingStepUp ? ( + + + {stepUpModalTitle({ + enterpriseManaged: pendingStepUp.enterpriseManaged, + })} + + + {stepUpConfirmMessage(pendingStepUp.challenge, { + enterpriseManaged: pendingStepUp.enterpriseManaged, + })}{" "} + {stepUpFollowUpMessage({ + enterpriseManaged: pendingStepUp.enterpriseManaged, + })} + + {(() => { + const scopes = + pendingStepUp.authorizationScopes ?? + pendingStepUp.challenge.requiredScopes; + if (!scopes?.length) return null; + return ( + + + {pendingStepUp.enterpriseManaged + ? "Permissions requested:" + : "Scopes requested:"} + + {scopes.map((scope) => ( + + {" "} + • {scope} + + ))} + + ); + })()} + + + {(() => { + const label = stepUpAuthorizeActionLabel({ + enterpriseManaged: pendingStepUp.enterpriseManaged, + }); + return ( + <> + {label[0]} + {label.slice(1)} + + ); + })()} + + + Cancel + + + + ) : null} {oauthState ? ( @@ -229,7 +339,9 @@ export function AuthTab({ backgroundColor="gray" > - S {isLiveConnection ? "clear+disconnect" : "clear"}, ↑/↓ scroll + {pendingStepUp + ? "↑/↓ select, Enter confirm, A authorize, C cancel" + : `S ${isLiveConnection ? "clear+disconnect" : "clear"}, ↑/↓ scroll`} )} diff --git a/clients/tui/src/components/PromptTestModal.tsx b/clients/tui/src/components/PromptTestModal.tsx index c10d03457..dfd284b3d 100644 --- a/clients/tui/src/components/PromptTestModal.tsx +++ b/clients/tui/src/components/PromptTestModal.tsx @@ -2,6 +2,7 @@ import React, { useState } from "react"; import { Box, Text, useInput, type Key } from "ink"; import { Form } from "ink-form"; import { InspectorClient } from "@inspector/core/mcp/index.js"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import type { Prompt, GetPromptResult, @@ -29,6 +30,7 @@ interface PromptTestModalProps { width: number; height: number; onClose: () => void; + onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void; } type ModalState = "form" | "loading" | "results"; @@ -47,6 +49,7 @@ export function PromptTestModal({ width, height, onClose, + onAuthRecoveryRequired, }: PromptTestModalProps) { const [state, setState] = useState("form"); const [result, setResult] = useState(null); @@ -144,6 +147,11 @@ export function PromptTestModal({ }); setState("results"); } catch (error) { + if (error instanceof AuthRecoveryRequiredError) { + onAuthRecoveryRequired?.(error); + onClose(); + return; + } const duration = Date.now() - startTime; const errorMessage = getErrorMessage(error); diff --git a/clients/tui/src/components/PromptsTab.tsx b/clients/tui/src/components/PromptsTab.tsx index c41291a12..f2a79d05f 100644 --- a/clients/tui/src/components/PromptsTab.tsx +++ b/clients/tui/src/components/PromptsTab.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from "react"; import { Box, Text, useInput, type Key } from "ink"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; import type { InspectorClient } from "@inspector/core/mcp/index.js"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import type { Prompt, PromptArgument, @@ -18,6 +19,7 @@ interface PromptsTabProps { focusedPane?: "list" | "details" | null; onViewDetails?: (prompt: Prompt & { result?: GetPromptResult }) => void; onFetchPrompt?: (prompt: Prompt) => void; + onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void; modalOpen?: boolean; } @@ -29,6 +31,7 @@ export function PromptsTab({ focusedPane = null, onViewDetails, onFetchPrompt, + onAuthRecoveryRequired, modalOpen = false, }: PromptsTabProps) { const visibleCount = Math.max(1, height - 7); @@ -64,6 +67,10 @@ export function PromptsTab({ }); } } catch (error) { + if (error instanceof AuthRecoveryRequiredError) { + onAuthRecoveryRequired?.(error); + return; + } setError( error instanceof Error ? error.message : "Failed to get prompt", ); diff --git a/clients/tui/src/components/ResourceTestModal.tsx b/clients/tui/src/components/ResourceTestModal.tsx index fe6dade72..8d52ee127 100644 --- a/clients/tui/src/components/ResourceTestModal.tsx +++ b/clients/tui/src/components/ResourceTestModal.tsx @@ -2,6 +2,7 @@ import React, { useState } from "react"; import { Box, Text, useInput, type Key } from "ink"; import { Form } from "ink-form"; import { InspectorClient } from "@inspector/core/mcp/index.js"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; import { uriTemplateToForm } from "../utils/uriTemplateToForm.js"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; @@ -30,6 +31,7 @@ interface ResourceTestModalProps { width: number; height: number; onClose: () => void; + onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void; } type ModalState = "form" | "loading" | "results"; @@ -49,6 +51,7 @@ export function ResourceTestModal({ width, height, onClose, + onAuthRecoveryRequired, }: ResourceTestModalProps) { const [state, setState] = useState("form"); const [result, setResult] = useState(null); @@ -150,6 +153,11 @@ export function ResourceTestModal({ }); setState("results"); } catch (error) { + if (error instanceof AuthRecoveryRequiredError) { + onAuthRecoveryRequired?.(error); + onClose(); + return; + } const duration = Date.now() - startTime; const errorMessage = getErrorMessage(error); diff --git a/clients/tui/src/components/ResourcesTab.tsx b/clients/tui/src/components/ResourcesTab.tsx index abcc6eaea..df976708f 100644 --- a/clients/tui/src/components/ResourcesTab.tsx +++ b/clients/tui/src/components/ResourcesTab.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useMemo } from "react"; import { Box, Text, useInput, type Key } from "ink"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; import type { InspectorClient } from "@inspector/core/mcp/index.js"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import type { Resource, ReadResourceResult, @@ -27,6 +28,7 @@ interface ResourcesTabProps { ) => void; onFetchResource?: (resource: Resource) => void; onFetchTemplate?: (template: ResourceTemplate) => void; + onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void; modalOpen?: boolean; } @@ -41,6 +43,7 @@ export function ResourcesTab({ onViewDetails, onFetchResource, onFetchTemplate, + onAuthRecoveryRequired, modalOpen = false, }: ResourcesTabProps) { const [error, setError] = useState(null); @@ -169,6 +172,10 @@ export function ResourcesTab({ await inspectorClient.readResource(shouldFetchResource); setResourceContent(invocation.result); } catch (err) { + if (err instanceof AuthRecoveryRequiredError) { + onAuthRecoveryRequired?.(err); + return; + } setError( err instanceof Error ? err.message : "Failed to read resource", ); @@ -180,7 +187,7 @@ export function ResourcesTab({ }; fetchContent(); - }, [shouldFetchResource, inspectorClient]); + }, [shouldFetchResource, inspectorClient, onAuthRecoveryRequired]); const listWidth = Math.floor(width * 0.4); const detailWidth = width - listWidth; diff --git a/clients/tui/src/components/ToolTestModal.tsx b/clients/tui/src/components/ToolTestModal.tsx index dca992e4d..a556c9a67 100644 --- a/clients/tui/src/components/ToolTestModal.tsx +++ b/clients/tui/src/components/ToolTestModal.tsx @@ -2,6 +2,7 @@ import React, { useState } from "react"; import { Box, Text, useInput, type Key } from "ink"; import { Form } from "ink-form"; import { InspectorClient } from "@inspector/core/mcp/index.js"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import type { Tool, CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { JsonValue } from "@inspector/core/mcp/index.js"; import { schemaToForm } from "../utils/schemaToForm.js"; @@ -13,6 +14,7 @@ interface ToolTestModalProps { width: number; height: number; onClose: () => void; + onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void; } type ModalState = "form" | "loading" | "results"; @@ -31,6 +33,7 @@ export function ToolTestModal({ width, height, onClose, + onAuthRecoveryRequired, }: ToolTestModalProps) { const [state, setState] = useState("form"); const [result, setResult] = useState(null); @@ -150,6 +153,11 @@ export function ToolTestModal({ } setState("results"); } catch (error) { + if (error instanceof AuthRecoveryRequiredError) { + onAuthRecoveryRequired?.(error); + onClose(); + return; + } const duration = Date.now() - startTime; const errorObj = error instanceof Error diff --git a/clients/tui/src/utils/tuiOAuth.ts b/clients/tui/src/utils/tuiOAuth.ts new file mode 100644 index 000000000..668151f7a --- /dev/null +++ b/clients/tui/src/utils/tuiOAuth.ts @@ -0,0 +1,36 @@ +import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; +import { + isStandardOAuthStepUp as isCoreStandardOAuthStepUp, + isStepUpConfirmation as isCoreStepUpConfirmation, + stepUpConfirmMessage, + stepUpInsufficientScopeMessage, +} from "@inspector/core/auth/oauthUx.js"; +import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; + +export { stepUpConfirmMessage, stepUpInsufficientScopeMessage }; + +export { + stepUpAuthorizeActionLabel, + stepUpFollowUpMessage, + stepUpModalTitle, +} from "@inspector/core/auth/oauthUx.js"; + +/** Standard-OAuth step-up (not EMA silent re-mint). */ +export function isStandardOAuthStepUp( + challenge: AuthChallenge, + settings?: InspectorServerSettings, +): boolean { + return isCoreStandardOAuthStepUp(challenge, { + enterpriseManaged: settings?.enterpriseManaged, + }); +} + +/** Standard or EMA step-up that requires in-app confirmation before OAuth. */ +export function isStepUpConfirmation( + challenge: AuthChallenge, + settings?: InspectorServerSettings, +): boolean { + return isCoreStepUpConfirmation(challenge, { + enterpriseManaged: settings?.enterpriseManaged, + }); +} diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index 23808833a..eef9a6405 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -75,6 +75,8 @@ vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => { getRoots = vi.fn().mockReturnValue([]); setRoots = vi.fn().mockResolvedValue(undefined); setServerSettings = vi.fn(); + resumeAfterOAuth = vi.fn().mockResolvedValue(undefined); + checkAuthChallengeSatisfied = vi.fn().mockResolvedValue(true); } const instances: FakeInspectorClient[] = []; return { @@ -257,6 +259,8 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({ getPromptState?: { status?: string }; readResourceState?: { status?: string }; currentLogLevel?: string; + activeTab?: string; + onActiveTabChange: (tab: string) => void; onToggleConnection: (id: string) => void; onToolsUiChange: (next: { selectedToolName?: string; @@ -314,6 +318,10 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({ {props.readResourceState?.status ?? "none"} {props.currentLogLevel} + {props.activeTab ?? "none"} + + + + ); +} diff --git a/clients/web/src/components/groups/StepUpAuthModal/StepUpAuthModal.test.tsx b/clients/web/src/components/groups/StepUpAuthModal/StepUpAuthModal.test.tsx new file mode 100644 index 000000000..1fb86efa5 --- /dev/null +++ b/clients/web/src/components/groups/StepUpAuthModal/StepUpAuthModal.test.tsx @@ -0,0 +1,120 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { StepUpAuthModal } from "./StepUpAuthModal"; + +const stepUpChallenge: AuthChallenge = { + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + authorizationScopes: ["mcp", "tools:read", "weather:read"], + context: { toolName: "get_temp" }, +}; + +describe("StepUpAuthModal", () => { + it("does not render when closed", () => { + renderWithMantine( + , + ); + expect( + screen.queryByText(/Additional permissions required/i), + ).not.toBeInTheDocument(); + }); + + it("lists additional scopes from the challenge, not the SEP-2350 union", () => { + renderWithMantine( + , + ); + expect( + screen.getByText(/Additional permissions required/i), + ).toBeInTheDocument(); + expect(screen.getByText(/get_temp/)).toBeInTheDocument(); + expect(screen.getByText("weather:read")).toBeInTheDocument(); + expect(screen.queryByText("tools:read")).not.toBeInTheDocument(); + expect(screen.queryByText("mcp")).not.toBeInTheDocument(); + expect( + screen.getByText( + /redirected to authorize, then returned to the inspector/i, + ), + ).toBeInTheDocument(); + }); + + it("uses EMA copy when enterpriseManaged is true", () => { + renderWithMantine( + , + ); + expect( + screen.getByText(/Additional organization permissions required/i), + ).toBeInTheDocument(); + expect( + screen.getByText(/enterprise identity provider/i), + ).toBeInTheDocument(); + expect( + screen.queryByText( + /redirected to authorize, then returned to the inspector/i, + ), + ).not.toBeInTheDocument(); + }); + + it("falls back to requiredScopes when authorizationScopes is empty", () => { + renderWithMantine( + , + ); + expect(screen.getByText("admin:write")).toBeInTheDocument(); + }); + + it("calls onAuthorize when Authorize is clicked", async () => { + const user = userEvent.setup(); + const onAuthorize = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: /^Authorize$/ })); + expect(onAuthorize).toHaveBeenCalledOnce(); + }); + + it("calls onCancel when Cancel is clicked", async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: /^Cancel$/ })); + expect(onCancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/clients/web/src/components/groups/StepUpAuthModal/StepUpAuthModal.tsx b/clients/web/src/components/groups/StepUpAuthModal/StepUpAuthModal.tsx new file mode 100644 index 000000000..c69c54236 --- /dev/null +++ b/clients/web/src/components/groups/StepUpAuthModal/StepUpAuthModal.tsx @@ -0,0 +1,87 @@ +import { Button, Group, List, Modal, Stack, Text } from "@mantine/core"; +import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; +import { + stepUpAdditionalScopes, + stepUpConfirmMessage, + stepUpFollowUpMessage, + stepUpModalTitle, +} from "@inspector/core/auth/oauthUx.js"; + +export interface StepUpAuthModalProps { + opened: boolean; + challenge: AuthChallenge | null; + /** Effective consent scope set (SEP-2350 union). */ + authorizationScopes?: string[]; + /** Enterprise-managed (EMA) server — organization/IdP copy instead of resource AS. */ + enterpriseManaged?: boolean; + onAuthorize: () => void | Promise; + onCancel: () => void; +} + +const Actions = Group.withProps({ justify: "flex-end", gap: "sm", mt: "md" }); + +export function StepUpAuthModal({ + opened, + challenge, + authorizationScopes, + enterpriseManaged, + onAuthorize, + onCancel, +}: StepUpAuthModalProps) { + const additionalScopes = challenge ? stepUpAdditionalScopes(challenge) : []; + const ema = enterpriseManaged === true; + + return ( + + + + {challenge + ? stepUpConfirmMessage(challenge, { enterpriseManaged: ema }) + : stepUpConfirmMessage( + { reason: "insufficient_scope" }, + { enterpriseManaged: ema }, + )}{" "} + {stepUpFollowUpMessage({ enterpriseManaged: ema })} + + {additionalScopes.length > 0 ? ( + + + {ema + ? "Additional permissions needed" + : "Additional scopes needed"} + + + {additionalScopes.map((scope) => ( + + + {scope} + + + ))} + + {!ema && + authorizationScopes && + authorizationScopes.length > additionalScopes.length ? ( + + The authorization server may also show scopes you already + granted during sign-in. + + ) : null} + + ) : null} + + + + + + + ); +} diff --git a/clients/web/src/components/screens/screenUiState.ts b/clients/web/src/components/screens/screenUiState.ts index f35d6fa74..a0a85f237 100644 --- a/clients/web/src/components/screens/screenUiState.ts +++ b/clients/web/src/components/screens/screenUiState.ts @@ -62,3 +62,15 @@ export const EMPTY_NETWORK_UI: NetworkUiState = { filterText: "", visibleCategories: ALL_CATEGORIES_VISIBLE, }; + +/** Registry for OAuth resume snapshot restore (data-only; setters live in App). */ +export const TAB_UI_REGISTRY = { + Apps: { empty: EMPTY_APPS_UI }, + Tools: { empty: EMPTY_TOOLS_UI }, + Prompts: { empty: EMPTY_PROMPTS_UI }, + Resources: { empty: EMPTY_RESOURCES_UI }, + Tasks: { empty: EMPTY_TASKS_UI }, + Logs: { empty: EMPTY_LOGS_UI }, + History: { empty: EMPTY_HISTORY_UI }, + Network: { empty: EMPTY_NETWORK_UI }, +} as const; diff --git a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx index 13e4fb81c..779c3ccb2 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx @@ -14,6 +14,7 @@ import type { ServerEntry, } from "@inspector/core/mcp/types.js"; import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; import { expect, fn } from "storybook/test"; import { InspectorView } from "./InspectorView"; import { @@ -400,6 +401,18 @@ const meta: Meta = { onCloseApp: fn(), onAppError: fn(), onRefreshApps: fn(), + activeTab: "Servers", + onActiveTabChange: fn(), + }, + render: (args) => { + const [activeTab, setActiveTab] = useState(args.activeTab ?? "Servers"); + return ( + + ); }, }; diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index 940da8017..72882b0d1 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -18,7 +18,6 @@ import { } from "../../../test/renderWithMantine"; import { InspectorView, type InspectorViewProps } from "./InspectorView"; import type { BridgeFactory } from "../../elements/AppRenderer/AppRenderer"; -import type { AppsUiState } from "../../screens/AppsScreen/AppsScreen"; import { EMPTY_TOOLS_UI, EMPTY_APPS_UI, @@ -130,21 +129,23 @@ function makeProps( onCloseApp: vi.fn(), onAppError: vi.fn(), onRefreshApps: vi.fn(), + activeTab: "Servers", + onActiveTabChange: vi.fn(), ...overrides, }; } -// Most tests render the view fully prop-driven (every callback is a spy). A few -// interactions — selecting an App and watching it auto-launch — depend on the -// parent-owned selection state actually updating, since the view is controlled -// (#1417). This host holds the App-tab selection/form state and threads it back -// in as controlled props, mirroring how App.tsx owns it in the real wiring. -function StatefulInspectorView({ props }: { props: InspectorViewProps }) { - const [appsUi, setAppsUi] = useState( - props.appsUi ?? EMPTY_APPS_UI, - ); +function StatefulInspectorViewHost(props: InspectorViewProps) { + const [activeTab, setActiveTab] = useState(props.activeTab ?? "Servers"); + const [appsUi, setAppsUi] = useState(props.appsUi ?? EMPTY_APPS_UI); return ( - + ); } @@ -210,7 +211,7 @@ const sampleTask: Task = { describe("InspectorView", () => { it("renders the empty-server-list placeholder when no servers are configured", () => { - renderWithMantine(); + renderWithMantine(); expect( screen.getByText("No servers configured. Add a server to get started."), ).toBeInTheDocument(); @@ -218,7 +219,7 @@ describe("InspectorView", () => { it("renders the server card from the input list", () => { renderWithMantine( - , + , ); expect(screen.getByText("Alpha")).toBeInTheDocument(); }); @@ -227,7 +228,7 @@ describe("InspectorView", () => { const onToggleConnection = vi.fn(); const user = userEvent.setup(); renderWithMantine( - , ); @@ -237,7 +238,7 @@ describe("InspectorView", () => { it("renders the connected header when connectionStatus + initializeResult are set", () => { renderWithMantine( - { it("surfaces the negotiated protocol version on the active connected card", () => { renderWithMantine( - { it("does not show a protocol version on the card while disconnected", () => { renderWithMantine( - { // version is somehow absent — the connected header/modal must still render // (gated on serverInfo, not the version), and the card label stays hidden. renderWithMantine( - { it("snaps activeTab back to Servers when connection drops", async () => { const { rerender } = renderWithMantine( - { ); rerender( - { }); it("disables non-Servers tabs while disconnected", () => { - renderWithMantine(); + renderWithMantine(); // The disconnected ViewHeader doesn't render the tab Select at all — // only the connected branch does. Asserting on the empty-state copy is // enough; a follow-up could deepen this once the disconnected header @@ -359,7 +360,7 @@ describe("InspectorView", () => { it("hides the Network tab when the active server is stdio", async () => { renderWithMantine( - { serverInfo: { name: "Beta", version: "1.0.0" }, }; renderWithMantine( - { it("hides the Tools tab when the server does not advertise the tools capability", async () => { renderWithMantine( - { it("shows the Tools tab when the server advertises tools even with an empty list", async () => { renderWithMantine( - { it("hides the Logs tab when the server does not advertise the logging capability", async () => { renderWithMantine( - { it("shows the Logs tab when the server advertises the logging capability", async () => { renderWithMantine( - { it("keeps History available regardless of advertised server capabilities", async () => { // History is a local client-side log — never gated on server capabilities. renderWithMantine( - { it("hides the Apps tab when app tools exist but the server omits the tools capability", async () => { renderWithMantine( - { _meta: { ui: { resourceUri: "not-a-ui-uri" } }, }; renderWithMantine( - { inputSchema: { type: "object" }, }; renderWithMantine( - { it("shows the Apps tab when the server exposes one or more MCP App tools", async () => { renderWithMantine( - { inputSchema: { type: "object" }, }; const { rerender } = renderWithMantine( - { // A tools/list_changed refresh adds an app tool — the tab appears reactively. rerender( - { it("snaps activeTab back to Servers when the Apps tab disappears after a refresh", async () => { const user = userEvent.setup(); const { rerender } = renderWithMantine( - { // The app tool goes away (server switch / list-changed) — the Apps tab is // pulled from availableTabs and the activeTab fallback lands on Servers. rerender( - { it("hides the Prompts tab when the server does not advertise the prompts capability", async () => { renderWithMantine( - { it("shows the Prompts tab when the server advertises prompts even with an empty list", async () => { renderWithMantine( - { it("hides the Resources tab when the server does not advertise the resources capability", async () => { renderWithMantine( - { it("shows the Resources tab when the server advertises resources even with empty lists", async () => { renderWithMantine( - { it("hides the Tasks tab when the server does not advertise the tasks capability", async () => { renderWithMantine( - { it("shows the Tasks tab when the server advertises tasks even with no tasks yet", async () => { renderWithMantine( - { it("recomputes tabs from the new capability set when reconnecting to a different server", async () => { // First server advertises tasks but not logging. const { rerender } = renderWithMantine( - { // Reconnect to a server that advertises logging but not tasks — the tabs // recompute purely from the new capability set. rerender( - { const onSetLogLevel = vi.fn(); const user = userEvent.setup(); renderWithMantine( - { it("persists Logs sort direction to localStorage and restores it on remount", async () => { const user = userEvent.setup(); const { unmount } = renderWithMantine( - { unmount(); renderWithMantine( - { const user = userEvent.setup(); window.localStorage.setItem("inspector.sortDirection.history", "garbage"); renderWithMantine( - { }, }; const { unmount } = renderWithMantine( - { unmount(); renderWithMantine( - { }, }; renderWithMantine( - { connection: { status: "disconnected" }, }; renderWithMantine( - { }; // Live session on alpha → beta starts out dimmed/inert. const { rerender } = renderWithMantine( - { // id still points at alpha. The other cards must re-enable anyway; only a // *live* session should dim them. rerender( - { it("toggles the Servers list compact state from the list toggle", async () => { const user = userEvent.setup(); renderWithMantine( - , + , ); // Servers default to expanded (compact=false), so the toggle reads // "Collapse all"; clicking it flips serversCompact via the inline callback. @@ -1106,7 +1107,7 @@ describe("InspectorView", () => { connection: { status: "connected" }, }; renderWithMantine( - { connection: { status: "connected" }, }; renderWithMantine( - { it("routes toolsListChanged to the Tools screen indicator", async () => { renderWithMantine( - { it("shares the tools flag with the Apps screen (apps are filtered tools)", async () => { renderWithMantine( - { it("routes promptsListChanged to the Prompts screen indicator", async () => { renderWithMantine( - { it("routes resourcesListChanged to the Resources screen indicator", async () => { renderWithMantine( - { it("does not show the indicator on a screen whose flag is false (no cross-wiring)", async () => { renderWithMantine( - void; + // Logging level. The MCP `logging/setLevel` request has no echo // notification, so the parent keeps the optimistic current value. currentLogLevel: LoggingLevel; @@ -455,11 +460,12 @@ export function InspectorView({ onCloseApp, onAppError, onRefreshApps, + activeTab: activeTabProp, + onActiveTabChange, }: InspectorViewProps) { // UI-only state. Connection state, primitive lists, and all action - // dispatching live in the parent; this component only owns navigation - // (which tab is visible) and a couple of view-local toggles. - const [selectedTab, setSelectedTab] = useState(SERVERS_TAB); + // dispatching live in the parent; this component only owns view-local + // toggles (sort direction, list compact). Tab selection is lifted (#1417). const [logsSort, setLogsSort] = useSortDirection("logs"); const [historySort, setHistorySort] = useSortDirection("history"); @@ -557,9 +563,9 @@ export function InspectorView({ // `[Servers]` and the view renders Servers without us having to imperatively // reset the state (and trip the `set-state-in-effect` lint). When the // connection comes back, the previous selection pops in again because - // `selectedTab` is preserved. - const activeTab = availableTabs.includes(selectedTab) - ? selectedTab + // the parent's `activeTab` is preserved. + const activeTab = availableTabs.includes(activeTabProp) + ? activeTabProp : SERVERS_TAB; // Merge the parent's `serversInput` (static config) with the runtime @@ -627,7 +633,7 @@ export function InspectorView({ latencyMs={latencyMs} activeTab={activeTab} availableTabs={availableTabs} - onTabChange={setSelectedTab} + onTabChange={onActiveTabChange} onDisconnect={onDisconnect} onToggleTheme={onToggleTheme} onOpenClientSettings={onOpenClientSettings} diff --git a/clients/web/src/test/core/auth/challenge.test.ts b/clients/web/src/test/core/auth/challenge.test.ts new file mode 100644 index 000000000..59690a009 --- /dev/null +++ b/clients/web/src/test/core/auth/challenge.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect } from "vitest"; +import { + AuthChallengeError, + AuthRecoveryRequiredError, + isAuthChallengeError, + isConnectAuthRecoveryError, + parseAuthChallengeFromError, + parseAuthChallengeFromResponse, + parseScopeString, + parseWwwAuthenticateBearer, + unionAuthorizationScopes, +} from "@inspector/core/auth/challenge.js"; + +describe("parseWwwAuthenticateBearer", () => { + it("parses insufficient_scope and scope parameters", () => { + expect( + parseWwwAuthenticateBearer( + 'Bearer error="insufficient_scope", scope="weather:read admin:write"', + ), + ).toEqual({ + error: "insufficient_scope", + scope: "weather:read admin:write", + resourceMetadata: undefined, + errorDescription: undefined, + }); + }); + + it("parses invalid_token and error_description", () => { + expect( + parseWwwAuthenticateBearer( + 'Bearer error="invalid_token", error_description="Token expired"', + ), + ).toEqual({ + error: "invalid_token", + scope: undefined, + resourceMetadata: undefined, + errorDescription: "Token expired", + }); + }); + + it("parses unquoted RFC 6750 parameters", () => { + expect( + parseWwwAuthenticateBearer( + "Bearer error=insufficient_scope, scope=weather:read", + ), + ).toEqual({ + error: "insufficient_scope", + scope: "weather:read", + resourceMetadata: undefined, + errorDescription: undefined, + }); + }); + + it("returns empty object for non-Bearer challenges", () => { + expect(parseWwwAuthenticateBearer('Basic realm="test"')).toEqual({}); + }); +}); + +describe("parseScopeString", () => { + it("splits space-separated scopes", () => { + expect(parseScopeString("mcp tools:read")).toEqual(["mcp", "tools:read"]); + }); + + it("returns empty array for blank input", () => { + expect(parseScopeString(undefined)).toEqual([]); + expect(parseScopeString(" ")).toEqual([]); + }); +}); + +describe("unionAuthorizationScopes", () => { + it("unions previous and required scopes without duplicates", () => { + expect( + unionAuthorizationScopes("mcp tools:read", [ + "tools:read", + "weather:read", + ]), + ).toEqual(["mcp", "tools:read", "weather:read"]); + }); + + it("returns required scopes when no previous scope exists", () => { + expect(unionAuthorizationScopes(undefined, ["weather:read"])).toEqual([ + "weather:read", + ]); + }); +}); + +describe("parseAuthChallengeFromResponse", () => { + it("maps 401 invalid_token to invalid_token reason", () => { + const response = new Response(null, { + status: 401, + headers: { + "WWW-Authenticate": 'Bearer error="invalid_token"', + }, + }); + + expect(parseAuthChallengeFromResponse(response)).toEqual({ + reason: "invalid_token", + raw: { + httpStatus: 401, + wwwAuthenticate: 'Bearer error="invalid_token"', + }, + }); + }); + + it("maps 401 without error to token_expired", () => { + const response = new Response(null, { status: 401 }); + expect(parseAuthChallengeFromResponse(response)?.reason).toBe( + "token_expired", + ); + }); + + it("maps 401 insufficient_scope to insufficient_scope", () => { + const response = new Response(null, { + status: 401, + headers: { + "WWW-Authenticate": + 'Bearer error="insufficient_scope", scope="weather:read"', + }, + }); + + expect(parseAuthChallengeFromResponse(response)).toMatchObject({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }); + }); + + it("maps 403 insufficient_scope with required scopes", () => { + const response = new Response(null, { + status: 403, + headers: { + "WWW-Authenticate": + 'Bearer error="insufficient_scope", scope="weather:read"', + }, + }); + + expect( + parseAuthChallengeFromResponse(response, { toolName: "get_temp" }), + ).toMatchObject({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + context: { toolName: "get_temp" }, + }); + }); + + it("returns undefined for non-auth statuses", () => { + const response = new Response(null, { status: 500 }); + expect(parseAuthChallengeFromResponse(response)).toBeUndefined(); + }); +}); + +describe("parseAuthChallengeFromError", () => { + it("extracts embedded authChallenge objects", () => { + const challenge = { + reason: "token_expired" as const, + }; + expect(parseAuthChallengeFromError({ authChallenge: challenge })).toEqual( + challenge, + ); + }); + + it("builds a challenge from status and WWW-Authenticate on errors", () => { + expect( + parseAuthChallengeFromError({ + status: 403, + wwwAuthenticate: + 'Bearer error="insufficient_scope", scope="admin:write"', + }), + ).toMatchObject({ + reason: "insufficient_scope", + requiredScopes: ["admin:write"], + }); + }); + + it("returns undefined for bare 401 without auth markers", () => { + expect(parseAuthChallengeFromError({ status: 401 })).toBeUndefined(); + }); +}); + +describe("isAuthChallengeError", () => { + it("detects AuthChallengeError instances", () => { + const err = new AuthChallengeError({ reason: "token_expired" }, 401); + expect(isAuthChallengeError(err)).toBe(true); + }); + + it("detects 401 and 403 with WWW-Authenticate as auth challenges", () => { + expect( + isAuthChallengeError({ + status: 401, + wwwAuthenticate: 'Bearer error="invalid_token"', + }), + ).toBe(true); + expect( + isAuthChallengeError({ + status: 403, + wwwAuthenticate: 'Bearer error="insufficient_scope"', + }), + ).toBe(true); + expect(isAuthChallengeError({ status: 500 })).toBe(false); + }); + + it("does not treat bare 401/403 status without auth markers as auth challenge", () => { + expect(isAuthChallengeError({ status: 401 })).toBe(false); + expect(isAuthChallengeError({ status: 403 })).toBe(false); + }); + + it("does not treat connect-time unauthorized wording as auth challenge", () => { + expect(isAuthChallengeError(new Error("network failed"))).toBe(false); + }); +}); + +describe("isConnectAuthRecoveryError", () => { + it("treats AuthRecoveryRequiredError and 401 connect failures as recoverable", () => { + expect( + isConnectAuthRecoveryError( + new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), { + reason: "unauthorized", + }), + ), + ).toBe(true); + const unauthorized = new Error("Unauthorized") as Error & { + status?: number; + }; + unauthorized.status = 401; + expect(isConnectAuthRecoveryError(unauthorized)).toBe(true); + }); + + it("does not treat other handshake failures as recoverable", () => { + expect(isConnectAuthRecoveryError(new Error("Connection timed out"))).toBe( + false, + ); + expect( + isConnectAuthRecoveryError( + new AuthChallengeError({ reason: "token_expired" }, 403), + ), + ).toBe(false); + }); +}); diff --git a/clients/web/src/test/core/auth/mcpAuth.test.ts b/clients/web/src/test/core/auth/mcpAuth.test.ts new file mode 100644 index 000000000..f973574b0 --- /dev/null +++ b/clients/web/src/test/core/auth/mcpAuth.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; + +const { + sdkAuth, + discoverOAuthServerInfo, + startAuthorization, + selectResourceURL, +} = vi.hoisted(() => ({ + sdkAuth: vi.fn(), + discoverOAuthServerInfo: vi.fn(), + startAuthorization: vi.fn(), + selectResourceURL: vi.fn(), +})); + +vi.mock("@modelcontextprotocol/sdk/client/auth.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@modelcontextprotocol/sdk/client/auth.js") + >(); + return { + ...actual, + auth: sdkAuth, + discoverOAuthServerInfo, + selectResourceURL, + startAuthorization, + }; +}); + +import { mcpAuth } from "@inspector/core/auth/mcpAuth.js"; + +function makeProvider(): OAuthClientProvider { + return { + redirectUrl: "http://localhost/callback", + clientMetadata: { + redirect_uris: ["http://localhost/callback"], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + client_name: "test", + client_uri: "https://example.com", + scope: "", + }, + clientInformation: vi.fn().mockResolvedValue({ client_id: "cid" }), + tokens: vi.fn(), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockReturnValue("verifier"), + }; +} + +describe("mcpAuth", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("delegates to SDK auth() when forceReauthorization is not set", async () => { + sdkAuth.mockResolvedValue("AUTHORIZED"); + const provider = makeProvider(); + + const result = await mcpAuth(provider, { + serverUrl: "https://mcp.example.com", + authorizationCode: "code", + fetchFn: fetch, + }); + + expect(result).toBe("AUTHORIZED"); + expect(sdkAuth).toHaveBeenCalledWith(provider, { + serverUrl: "https://mcp.example.com", + authorizationCode: "code", + scope: undefined, + resourceMetadataUrl: undefined, + fetchFn: fetch, + }); + expect(discoverOAuthServerInfo).not.toHaveBeenCalled(); + }); + + it("uses discovery + startAuthorization when forceReauthorization is true", async () => { + discoverOAuthServerInfo.mockResolvedValue({ + authorizationServerUrl: "https://as.example.com", + authorizationServerMetadata: { issuer: "https://as.example.com" }, + resourceMetadata: undefined, + }); + selectResourceURL.mockResolvedValue(undefined); + startAuthorization.mockResolvedValue({ + authorizationUrl: new URL("https://as.example.com/authorize"), + codeVerifier: "cv", + }); + const provider = makeProvider(); + + const result = await mcpAuth(provider, { + serverUrl: "https://mcp.example.com", + scope: "mcp weather:read", + forceReauthorization: true, + fetchFn: fetch, + }); + + expect(result).toBe("REDIRECT"); + expect(sdkAuth).not.toHaveBeenCalled(); + expect(startAuthorization).toHaveBeenCalled(); + expect(provider.saveCodeVerifier).toHaveBeenCalledWith("cv"); + expect(provider.redirectToAuthorization).toHaveBeenCalled(); + }); +}); diff --git a/clients/web/src/test/core/auth/oauthUx.test.ts b/clients/web/src/test/core/auth/oauthUx.test.ts new file mode 100644 index 000000000..5cbe86d15 --- /dev/null +++ b/clients/web/src/test/core/auth/oauthUx.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest"; +import { + emaStepUpSuccessMessage, + isEmaStepUp, + isStepUpConfirmation, + stepUpAdditionalScopes, + stepUpConfirmMessage, + stepUpFollowUpMessage, + stepUpModalTitle, + stepUpAuthorizeActionLabel, +} from "@inspector/core/auth/oauthUx.js"; +import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; + +describe("oauthUx step-up copy", () => { + const challenge: AuthChallenge = { + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + authorizationScopes: ["mcp", "tools:read", "weather:read"], + context: { toolName: "get_temp" }, + }; + + it("stepUpConfirmMessage prefers tool context over scope union", () => { + expect(stepUpConfirmMessage(challenge)).toMatch(/get_temp/); + expect(stepUpConfirmMessage(challenge)).not.toMatch(/tools:read/); + }); + + it("stepUpConfirmMessage lists only requiredScopes when no tool context", () => { + expect( + stepUpConfirmMessage({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + authorizationScopes: ["mcp", "tools:read", "weather:read"], + }), + ).toBe("This operation needs additional scope: weather:read."); + }); + + it("stepUpConfirmMessage uses organization language for EMA", () => { + expect( + stepUpConfirmMessage(challenge, { enterpriseManaged: true }), + ).toMatch(/organization/i); + expect(stepUpFollowUpMessage({ enterpriseManaged: true })).toMatch( + /identity provider/i, + ); + expect(stepUpModalTitle({ enterpriseManaged: true })).toMatch( + /organization/i, + ); + expect(stepUpAuthorizeActionLabel({ enterpriseManaged: true })).toBe( + "Authorize", + ); + expect(stepUpAuthorizeActionLabel()).toBe("Authorize (opens browser)"); + }); + + it("isStepUpConfirmation covers standard OAuth and EMA insufficient_scope", () => { + expect(isStepUpConfirmation(challenge)).toBe(true); + expect(isStepUpConfirmation(challenge, { enterpriseManaged: true })).toBe( + true, + ); + expect(isEmaStepUp(challenge, { enterpriseManaged: true })).toBe(true); + expect(isEmaStepUp(challenge)).toBe(false); + expect( + isStepUpConfirmation( + { reason: "token_expired" }, + { + enterpriseManaged: true, + }, + ), + ).toBe(false); + }); + + it("emaStepUpSuccessMessage suggests retry only for command-scoped recovery", () => { + expect(emaStepUpSuccessMessage()).toBe( + "Organization permissions were updated.", + ); + expect(emaStepUpSuccessMessage({ recoverySource: "tool" })).toMatch( + /Retry your action/, + ); + }); + + it("stepUpAdditionalScopes returns requiredScopes only", () => { + expect(stepUpAdditionalScopes(challenge)).toEqual(["weather:read"]); + }); +}); diff --git a/clients/web/src/test/core/auth/providers.test.ts b/clients/web/src/test/core/auth/providers.test.ts index f8bcea488..d20f1b544 100644 --- a/clients/web/src/test/core/auth/providers.test.ts +++ b/clients/web/src/test/core/auth/providers.test.ts @@ -327,6 +327,23 @@ describe("OAuthNavigation", () => { expect(navCallback).toHaveBeenCalledWith(url); }); + it("redirectToAuthorization captures URL but skips navigation when suppressed", () => { + const storage = makeStorage(); + const navCallback = vi.fn(); + const provider = makeProvider(storage, navCallback); + const url = new URL("https://mcp.example.com/authorize"); + + provider.setSuppressAuthorizationNavigation(true); + provider.redirectToAuthorization(url); + + expect(provider.getCapturedAuthUrl()).toBe(url); + expect(navCallback).not.toHaveBeenCalled(); + + provider.setSuppressAuthorizationNavigation(false); + provider.redirectToAuthorization(url); + expect(navCallback).toHaveBeenCalledWith(url); + }); + it("delegates token and scope persistence to storage", async () => { const storage = makeStorage(); const provider = makeProvider(storage); diff --git a/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts b/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts new file mode 100644 index 000000000..05d3f810f --- /dev/null +++ b/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + runRunnerInteractiveOAuth, + type RunnerInteractiveOAuthClient, +} from "@inspector/core/auth/node/runner-interactive-oauth.js"; +import type { + OAuthCallbackServer, + OAuthCallbackServerStartOptions, +} from "@inspector/core/auth/node/oauth-callback-server.js"; +import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; + +function mockClient( + overrides: Partial = {}, +): RunnerInteractiveOAuthClient { + return { + authenticate: vi.fn(async () => undefined), + beginInteractiveAuthorization: vi.fn(async () => {}), + completeOAuthFlow: vi.fn(async () => {}), + checkAuthChallengeSatisfied: vi.fn(async () => true), + ...overrides, + }; +} + +interface MockCallbackHandlers { + onCallback?: OAuthCallbackServerStartOptions["onCallback"]; + onError?: OAuthCallbackServerStartOptions["onError"]; +} + +function createMockCallbackServer(handlers: { + current: MockCallbackHandlers; +}): OAuthCallbackServer { + return { + start: vi.fn(async (opts: OAuthCallbackServerStartOptions) => { + handlers.current = { + onCallback: opts.onCallback, + onError: opts.onError, + }; + return { + port: 6276, + redirectUrl: "http://127.0.0.1:6276/oauth/callback", + }; + }), + stop: vi.fn(async () => {}), + } as unknown as OAuthCallbackServer; +} + +/** Drive the loopback callback the way a browser redirect would. */ +async function simulateCallback( + handlers: MockCallbackHandlers, + code = "auth-code-123", +): Promise { + if (!handlers.onCallback) { + throw new Error("onCallback not registered"); + } + await handlers.onCallback({ code }); +} + +describe("runRunnerInteractiveOAuth", () => { + const handlers: { current: MockCallbackHandlers } = { current: {} }; + + afterEach(() => { + handlers.current = {}; + vi.restoreAllMocks(); + }); + + it("returns already_authorized when authenticate yields no URL", async () => { + const client = mockClient({ + authenticate: vi.fn(async () => undefined), + }); + const redirectUrlProvider = { redirectUrl: "" }; + + const result = await runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => createMockCallbackServer(handlers), + }); + + expect(result).toEqual({ kind: "already_authorized" }); + expect(client.completeOAuthFlow).not.toHaveBeenCalled(); + }); + + it("completes connect-time OAuth via authenticate and callback", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const client = mockClient({ + authenticate: vi.fn(async () => { + await simulateCallback(handlers.current); + return new URL("https://as.example/authorize"); + }), + }); + + const result = await runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => createMockCallbackServer(handlers), + }); + + expect(result).toEqual({ kind: "success" }); + expect(client.completeOAuthFlow).toHaveBeenCalledWith("auth-code-123"); + expect(redirectUrlProvider.redirectUrl).toBe( + "http://127.0.0.1:6276/oauth/callback", + ); + }); + + it("uses beginInteractiveAuthorization when authorizationUrl is provided", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const authorizationUrl = new URL( + "https://as.example/authorize?state=step-up", + ); + const client = mockClient({ + beginInteractiveAuthorization: vi.fn(async () => { + await simulateCallback(handlers.current, "step-up-code"); + }), + }); + + const result = await runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + authorizationUrl, + createCallbackServer: () => createMockCallbackServer(handlers), + }); + + expect(result).toEqual({ kind: "success" }); + expect(client.beginInteractiveAuthorization).toHaveBeenCalledWith( + authorizationUrl, + ); + expect(client.authenticate).not.toHaveBeenCalled(); + expect(client.completeOAuthFlow).toHaveBeenCalledWith("step-up-code"); + }); + + it("returns insufficient_scope when post-step-up check fails", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const challenge: AuthChallenge = { + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }; + const client = mockClient({ + beginInteractiveAuthorization: vi.fn(async () => { + await simulateCallback(handlers.current); + }), + checkAuthChallengeSatisfied: vi.fn(async () => false), + }); + + const result = await runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + authorizationUrl: new URL("https://as.example/authorize"), + authChallenge: challenge, + createCallbackServer: () => createMockCallbackServer(handlers), + }); + + expect(result).toEqual({ kind: "insufficient_scope", challenge }); + }); + + it("propagates completeOAuthFlow failures", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const client = mockClient({ + authenticate: vi.fn(async () => { + await simulateCallback(handlers.current); + return new URL("https://as.example/authorize"); + }), + completeOAuthFlow: vi.fn(async () => { + throw new Error("token exchange failed"); + }), + }); + + await expect( + runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => createMockCallbackServer(handlers), + }), + ).rejects.toThrow("token exchange failed"); + }); + + it("propagates OAuth callback errors from the authorization server", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const client = mockClient({ + authenticate: vi.fn(async () => { + handlers.current.onError?.({ + error: "access_denied", + error_description: "user cancelled", + }); + return new URL("https://as.example/authorize"); + }), + }); + + await expect( + runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => createMockCallbackServer(handlers), + }), + ).rejects.toThrow("user cancelled"); + }); + + it("times out when the browser callback never arrives", async () => { + vi.useFakeTimers(); + try { + const redirectUrlProvider = { redirectUrl: "" }; + const client = mockClient({ + authenticate: vi.fn( + async () => new URL("https://as.example/authorize"), + ), + }); + + const promise = runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + callbackTimeoutMs: 1000, + createCallbackServer: () => createMockCallbackServer(handlers), + }); + + const assertRejects = + expect(promise).rejects.toThrow(/timed out after 1s/); + await vi.advanceTimersByTimeAsync(1000); + await assertRejects; + } finally { + vi.useRealTimers(); + } + }); + + it("invokes onCallbackServer with the live listener", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const onCallbackServer = vi.fn(); + const mockServer = createMockCallbackServer(handlers); + const client = mockClient({ + authenticate: vi.fn(async () => { + await simulateCallback(handlers.current); + return new URL("https://as.example/authorize"); + }), + }); + + await runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + onCallbackServer, + createCallbackServer: () => mockServer, + }); + + expect(onCallbackServer).toHaveBeenCalledTimes(1); + expect(onCallbackServer).toHaveBeenCalledWith(mockServer); + }); +}); diff --git a/clients/web/src/test/core/auth/scopes.test.ts b/clients/web/src/test/core/auth/scopes.test.ts new file mode 100644 index 000000000..ebb474c12 --- /dev/null +++ b/clients/web/src/test/core/auth/scopes.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { + computeScopeUnion, + isStrictScopeSuperset, +} from "@inspector/core/auth/scopes.js"; + +describe("scopes", () => { + describe("computeScopeUnion", () => { + it("unions and dedupes scope tokens", () => { + expect( + computeScopeUnion("mcp tools:read", "tools:read weather:read"), + ).toBe("mcp tools:read weather:read"); + }); + + it("returns undefined when all inputs are empty", () => { + expect(computeScopeUnion(undefined, "", undefined)).toBeUndefined(); + }); + }); + + describe("isStrictScopeSuperset", () => { + it("returns true when union adds a new scope", () => { + expect( + isStrictScopeSuperset("mcp tools:read weather:read", "mcp tools:read"), + ).toBe(true); + }); + + it("returns false when union is covered by current grant", () => { + expect(isStrictScopeSuperset("mcp tools:read", "mcp tools:read")).toBe( + false, + ); + }); + + it("treats missing token scope as empty (forces re-auth)", () => { + expect(isStrictScopeSuperset("mcp weather:read", undefined)).toBe(true); + }); + }); +}); diff --git a/clients/web/src/test/core/auth/utils.test.ts b/clients/web/src/test/core/auth/utils.test.ts index 712b95ee6..cbfa7186f 100644 --- a/clients/web/src/test/core/auth/utils.test.ts +++ b/clients/web/src/test/core/auth/utils.test.ts @@ -5,7 +5,9 @@ import { generateOAuthState, parseOAuthState, generateOAuthErrorDescription, + formatOAuthFailureDetail, } from "@inspector/core/auth/utils.js"; +import { ZodError } from "zod"; describe("parseHttpUrl", () => { it("parses valid URLs", () => { @@ -93,3 +95,31 @@ describe("generateOAuthErrorDescription", () => { ); }); }); + +describe("formatOAuthFailureDetail", () => { + const zodTokenJson = `[ { "expected": "string", "code": "invalid_type", "path": [ "access_token" ], "message": "Invalid input" }, { "expected": "string", "code": "invalid_type", "path": [ "token_type" ], "message": "Invalid input" } ]`; + + it("replaces serialized Zod token-response issues with readable copy", () => { + expect(formatOAuthFailureDetail(zodTokenJson)).toBe( + "The authorization server did not return valid tokens. Check your OAuth client ID and secret, then try again.", + ); + }); + + it("formats ZodError instances", () => { + const err = new ZodError([ + { + code: "invalid_type", + expected: "string", + path: ["access_token"], + message: "Invalid input", + }, + ]); + expect(formatOAuthFailureDetail(err)).toMatch(/valid tokens/i); + }); + + it("passes through ordinary error messages", () => { + expect(formatOAuthFailureDetail(new Error("Network timeout"))).toBe( + "Network timeout", + ); + }); +}); diff --git a/clients/web/src/test/core/mcp/authResyncPropagation.test.ts b/clients/web/src/test/core/mcp/authResyncPropagation.test.ts new file mode 100644 index 000000000..0b4da48c0 --- /dev/null +++ b/clients/web/src/test/core/mcp/authResyncPropagation.test.ts @@ -0,0 +1,288 @@ +/** + * Remote transport contract: HTTP ack + SSE payload, auth retry on send. + */ + +import { describe, it, expect, vi } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js"; +import { MessageTrackingTransport } from "@inspector/core/mcp/messageTrackingTransport.js"; +import { RemoteClientTransport } from "@inspector/core/mcp/remote/remoteClientTransport.js"; + +const config = { + type: "streamable-http" as const, + url: "http://localhost/mcp", +}; + +/** SSE stream that can push MCP message events after /api/mcp/send accepts a request. */ +function createPushableSseStream() { + const encoder = new TextEncoder(); + let controller: ReadableStreamDefaultController | null = null; + const stream = new ReadableStream({ + start(c) { + controller = c; + c.enqueue(encoder.encode(": keepalive\n\n")); + }, + }); + const pushMessage = (message: unknown) => { + const payload = JSON.stringify({ type: "message", data: message }); + controller?.enqueue(encoder.encode(`data: ${payload}\n\n`)); + }; + return { + response: new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + pushMessage, + }; +} + +describe("RemoteClientTransport send contract", () => { + it("waits for SSE response after HTTP ok:true and completes within 1s", async () => { + const sse = createPushableSseStream(); + const fetchFn = vi + .fn() + .mockImplementation(async (input, init) => { + const url = String(input); + if (url.endsWith("/api/mcp/connect") && init?.method === "POST") { + return new Response(JSON.stringify({ sessionId: "s1" }), { + status: 200, + }); + } + if (url.includes("/api/mcp/events")) { + return sse.response; + } + if (url.endsWith("/api/mcp/send") && init?.method === "POST") { + const body = JSON.parse(String(init.body)) as { + message: { id?: string | number; method?: string }; + }; + sse.pushMessage({ + jsonrpc: "2.0", + id: body.message.id, + result: { tools: [] }, + }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }); + + const transport = new RemoteClientTransport( + { + baseUrl: "http://remote.test/", + fetchFn: fetchFn as unknown as typeof fetch, + sseResponseTimeoutMs: 2000, + }, + config, + ); + + await transport.start(); + + const started = Date.now(); + await transport.send({ jsonrpc: "2.0", id: 7, method: "tools/list" }); + expect(Date.now() - started).toBeLessThan(1000); + + await transport.close(); + }); + + it("times out within 5s when HTTP ok:true but no SSE response arrives", async () => { + const sse = createPushableSseStream(); + const fetchFn = vi + .fn() + .mockImplementation(async (input, init) => { + const url = String(input); + if (url.endsWith("/api/mcp/connect") && init?.method === "POST") { + return new Response(JSON.stringify({ sessionId: "s1" }), { + status: 200, + }); + } + if (url.includes("/api/mcp/events")) { + return sse.response; + } + if (url.endsWith("/api/mcp/send") && init?.method === "POST") { + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }); + + const transport = new RemoteClientTransport( + { + baseUrl: "http://remote.test/", + fetchFn: fetchFn as unknown as typeof fetch, + sseResponseTimeoutMs: 500, + }, + config, + ); + + await transport.start(); + + const started = Date.now(); + await expect( + transport.send({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + ).rejects.toThrow(/Timed out waiting for MCP response on SSE/i); + expect(Date.now() - started).toBeLessThan(5000); + + await transport.close(); + }); + + it("retries send once after satisfied auth challenge and pushAuthState", async () => { + const sse = createPushableSseStream(); + let sendCalls = 0; + let connectCalls = 0; + const fetchFn = vi + .fn() + .mockImplementation(async (input, init) => { + const url = String(input); + if (url.endsWith("/api/mcp/connect") && init?.method === "POST") { + connectCalls += 1; + return new Response( + JSON.stringify({ sessionId: `s${connectCalls}` }), + { status: 200 }, + ); + } + if (url.includes("/api/mcp/events")) { + return sse.response; + } + if (url.endsWith("/api/mcp/send") && init?.method === "POST") { + sendCalls += 1; + const body = JSON.parse(String(init.body)) as { + message: { id?: string | number }; + }; + if (sendCalls === 1) { + return new Response( + JSON.stringify({ + ok: false, + kind: "auth_challenge", + authChallenge: { reason: "token_expired" }, + }), + { status: 200 }, + ); + } + sse.pushMessage({ + jsonrpc: "2.0", + id: body.message.id, + result: { tools: [{ name: "echo" }] }, + }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }); + + const pushAuthState = vi.fn().mockResolvedValue(undefined); + + const transport = new RemoteClientTransport( + { + baseUrl: "http://remote.test/", + fetchFn: fetchFn as unknown as typeof fetch, + sseResponseTimeoutMs: 2000, + authRecovery: { + handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "satisfied" }), + pushAuthState, + }, + }, + config, + ); + + await transport.start(); + + const started = Date.now(); + await transport.send({ jsonrpc: "2.0", id: 1, method: "tools/list" }); + expect(Date.now() - started).toBeLessThan(5000); + expect(sendCalls).toBe(2); + expect(connectCalls).toBe(1); + expect(pushAuthState).toHaveBeenCalledTimes(1); + + await transport.close(); + }); + + it("SDK listTools succeeds through full stack after auth retry", async () => { + let sse = createPushableSseStream(); + let sendCalls = 0; + const fetchFn = vi + .fn() + .mockImplementation(async (input, init) => { + const url = String(input); + if (url.endsWith("/api/mcp/connect") && init?.method === "POST") { + return new Response(JSON.stringify({ sessionId: "s1" }), { + status: 200, + }); + } + if (url.includes("/api/mcp/events")) { + sse = createPushableSseStream(); + return sse.response; + } + if (url.endsWith("/api/mcp/auth-state") && init?.method === "POST") { + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + if (url.endsWith("/api/mcp/send") && init?.method === "POST") { + sendCalls += 1; + const body = JSON.parse(String(init.body)) as { + message: { method?: string; id?: string | number }; + }; + if (body.message.method === "initialize") { + sse.pushMessage({ + jsonrpc: "2.0", + id: body.message.id, + result: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: "test", version: "1.0.0" }, + }, + }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + if (body.message.method === "tools/list") { + if (sendCalls === 2) { + return new Response( + JSON.stringify({ + ok: false, + kind: "auth_challenge", + authChallenge: { reason: "token_expired" }, + }), + { status: 200 }, + ); + } + sse.pushMessage({ + jsonrpc: "2.0", + id: body.message.id, + result: { + tools: [{ name: "echo", inputSchema: { type: "object" } }], + }, + }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }); + + const authProvider = { + tokens: vi.fn().mockResolvedValue({ + access_token: "refreshed", + token_type: "Bearer", + }), + }; + + const transport = new RemoteClientTransport( + { + baseUrl: "http://remote.test/", + fetchFn: fetchFn as unknown as typeof fetch, + sseResponseTimeoutMs: 2000, + authProvider: authProvider as never, + }, + config, + ); + transport.setAuthRecovery({ + handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "satisfied" }), + pushAuthState: () => transport.pushAuthState(), + }); + const wrapped = new MessageTrackingTransport(transport, {}); + const client = new Client({ name: "test", version: "1.0.0" }); + await client.connect(wrapped); + + const started = Date.now(); + const result = await client.listTools(); + expect(Date.now() - started).toBeLessThan(5000); + expect(result.tools).toHaveLength(1); + + await transport.close(); + }); +}); diff --git a/clients/web/src/test/core/mcp/inspectorClient-ambient-auth.test.ts b/clients/web/src/test/core/mcp/inspectorClient-ambient-auth.test.ts new file mode 100644 index 000000000..d3804f34e --- /dev/null +++ b/clients/web/src/test/core/mcp/inspectorClient-ambient-auth.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { RemoteClientTransport } from "@inspector/core/mcp/remote/remoteClientTransport.js"; + +describe("InspectorClient ambient auth dedup", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("joins concurrent handleAmbientAuthChallenge calls for the same challenge", async () => { + const client = Object.create(InspectorClient.prototype) as InspectorClient; + const handleAuthChallenge = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve({ kind: "satisfied" as const }), 20); + }), + ); + (client as unknown as { oauthManager: unknown }).oauthManager = { + handleAuthChallenge, + }; + ( + client as unknown as { + ambientAuthChallengeInFlight: Map>; + } + ).ambientAuthChallengeInFlight = new Map(); + (client as unknown as { baseTransport: unknown }).baseTransport = + Object.create(RemoteClientTransport.prototype); + vi.spyOn(client, "pushRemoteAuthState").mockResolvedValue(undefined); + vi.spyOn(client, "dispatchTypedEvent").mockImplementation(() => {}); + + const challenge = { + reason: "token_expired" as const, + }; + + await Promise.all([ + client.handleAmbientAuthChallenge(challenge), + client.handleAmbientAuthChallenge(challenge), + ]); + + expect(handleAuthChallenge).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clients/web/src/test/core/mcp/node/authChallengeFetch.test.ts b/clients/web/src/test/core/mcp/node/authChallengeFetch.test.ts new file mode 100644 index 000000000..b512d59a0 --- /dev/null +++ b/clients/web/src/test/core/mcp/node/authChallengeFetch.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from "vitest"; +import { createAuthChallengeInterceptFetch } from "@inspector/core/mcp/node/authChallengeFetch.js"; +import { AuthChallengeError } from "@inspector/core/auth/challenge.js"; + +describe("createAuthChallengeInterceptFetch", () => { + it("passes through successful responses", async () => { + const baseFetch = vi.fn(async () => new Response("ok", { status: 200 })); + const fetchFn = createAuthChallengeInterceptFetch(baseFetch); + const res = await fetchFn("https://example.com/mcp"); + expect(res.status).toBe(200); + expect(await res.text()).toBe("ok"); + }); + + it("throws AuthChallengeError on 401", async () => { + const baseFetch = vi.fn( + async () => + new Response(null, { + status: 401, + headers: { + "WWW-Authenticate": 'Bearer error="invalid_token"', + }, + }), + ); + const fetchFn = createAuthChallengeInterceptFetch(baseFetch); + await expect(fetchFn("https://example.com/mcp")).rejects.toBeInstanceOf( + AuthChallengeError, + ); + }); + + it("throws AuthChallengeError on 403 insufficient_scope", async () => { + const baseFetch = vi.fn( + async () => + new Response(null, { + status: 403, + headers: { + "WWW-Authenticate": + 'Bearer error="insufficient_scope", scope="weather:read"', + }, + }), + ); + const fetchFn = createAuthChallengeInterceptFetch(baseFetch); + try { + await fetchFn("https://example.com/mcp"); + throw new Error("expected throw"); + } catch (err) { + expect(err).toBeInstanceOf(AuthChallengeError); + expect((err as AuthChallengeError).authChallenge.reason).toBe( + "insufficient_scope", + ); + } + }); +}); diff --git a/clients/web/src/test/core/mcp/oauthManager.test.ts b/clients/web/src/test/core/mcp/oauthManager.test.ts index 23eeb92dd..328ceca81 100644 --- a/clients/web/src/test/core/mcp/oauthManager.test.ts +++ b/clients/web/src/test/core/mcp/oauthManager.test.ts @@ -14,19 +14,16 @@ import { emaClientNotConfiguredMessage, } from "@inspector/core/auth/ema/clientConfigError.js"; import * as emaFlow from "@inspector/core/auth/ema/emaFlow.js"; -import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; +import { mcpAuth } from "@inspector/core/auth/mcpAuth.js"; -// Mock the SDK auth() entry point so quick-flow paths don't hit the network. -// Other named exports of the module are left intact for code that uses them. -vi.mock("@modelcontextprotocol/sdk/client/auth.js", async (importOriginal) => { +// Mock mcpAuth so OAuthManager tests do not hit the network. +vi.mock("@inspector/core/auth/mcpAuth.js", async (importOriginal) => { const actual = - await importOriginal< - typeof import("@modelcontextprotocol/sdk/client/auth.js") - >(); - return { ...actual, auth: vi.fn() }; + await importOriginal(); + return { ...actual, mcpAuth: vi.fn() }; }); -const mockedAuth = vi.mocked(auth); +const mockedMcpAuth = vi.mocked(mcpAuth); const SERVER_URL = "https://example.com/mcp"; @@ -100,7 +97,7 @@ function storageOf(params: OAuthManagerParams): MockStorage { describe("OAuthManager", () => { beforeEach(() => { - mockedAuth.mockReset(); + mockedMcpAuth.mockReset(); }); describe("setOAuthConfig", () => { @@ -398,7 +395,7 @@ describe("OAuthManager", () => { const capturedUrl = new URL( "https://auth.example.com/authorize?state=abc", ); - mockedAuth.mockResolvedValue("REDIRECT"); + mockedMcpAuth.mockResolvedValue("REDIRECT"); const parseSpy = vi .spyOn(await import("@inspector/core/auth/utils.js"), "parseOAuthState") .mockReturnValue({ @@ -412,6 +409,7 @@ describe("OAuthManager", () => { const params = createMockParams({ onBeforeOAuthRedirect }); // A configured scope exercises the saveScope branch in createOAuthProvider. params.initialConfig.scope = "read write"; + storageOf(params).getScope.mockReturnValue(undefined); storageOf(params).getClientInformation.mockResolvedValue({ client_id: "cid", }); @@ -442,8 +440,40 @@ describe("OAuthManager", () => { captureSpy.mockRestore(); }); + it("preserves stored scope instead of resetting to config scope", async () => { + const capturedUrl = new URL( + "https://auth.example.com/authorize?state=abc", + ); + mockedMcpAuth.mockResolvedValue("REDIRECT"); + const params = createMockParams(); + params.initialConfig.scope = "mcp tools:read"; + storageOf(params).getScope.mockReturnValue("mcp tools:read weather:read"); + storageOf(params).getClientInformation.mockResolvedValue({ + client_id: "cid", + }); + const manager = new OAuthManager(params); + const captureSpy = vi + .spyOn( + (await import("@inspector/core/auth/providers.js")) + .BaseOAuthClientProvider.prototype, + "getCapturedAuthUrl", + ) + .mockReturnValue(capturedUrl); + + await manager.authenticate(); + + expect(storageOf(params).saveScope).not.toHaveBeenCalled(); + expect(mockedMcpAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + scope: "mcp tools:read weather:read", + }), + ); + captureSpy.mockRestore(); + }); + it("throws when auth() unexpectedly returns AUTHORIZED", async () => { - mockedAuth.mockResolvedValue("AUTHORIZED"); + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); const manager = new OAuthManager(createMockParams()); await expect(manager.authenticate()).rejects.toThrow( "Unexpected: auth() returned AUTHORIZED without authorization code", @@ -451,7 +481,7 @@ describe("OAuthManager", () => { }); it("throws when no authorization URL is captured", async () => { - mockedAuth.mockResolvedValue("REDIRECT"); + mockedMcpAuth.mockResolvedValue("REDIRECT"); const manager = new OAuthManager(createMockParams()); // Default provider captures nothing (auth() is mocked and never redirects). await expect(manager.authenticate()).rejects.toThrow( @@ -463,7 +493,7 @@ describe("OAuthManager", () => { const capturedUrl = new URL( "https://auth.example.com/authorize?state=zzz", ); - mockedAuth.mockResolvedValue("REDIRECT"); + mockedMcpAuth.mockResolvedValue("REDIRECT"); const parseSpy = vi .spyOn(await import("@inspector/core/auth/utils.js"), "parseOAuthState") .mockReturnValue(null); @@ -489,7 +519,7 @@ describe("OAuthManager", () => { describe("completeOAuthFlow (quick, standard)", () => { it("completes via the quick path and dispatches complete", async () => { const tokens = { access_token: "QT", token_type: "Bearer" }; - mockedAuth.mockResolvedValue("AUTHORIZED"); + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); const params = createMockParams(); storageOf(params).getTokens.mockResolvedValue(tokens); storageOf(params).getClientInformation.mockResolvedValue({ @@ -505,7 +535,7 @@ describe("OAuthManager", () => { it("throws and dispatches error when auth() is not AUTHORIZED", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - mockedAuth.mockResolvedValue("REDIRECT"); + mockedMcpAuth.mockResolvedValue("REDIRECT"); const params = createMockParams(); const manager = new OAuthManager(params); @@ -517,7 +547,7 @@ describe("OAuthManager", () => { }); it("throws when tokens cannot be retrieved after authorization", async () => { - mockedAuth.mockResolvedValue("AUTHORIZED"); + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); const params = createMockParams(); storageOf(params).getTokens.mockResolvedValue(undefined); const manager = new OAuthManager(params); @@ -527,6 +557,55 @@ describe("OAuthManager", () => { ); expect(params.dispatchOAuthError).toHaveBeenCalled(); }); + + it("clears pending step-up scope when completeOAuthFlow fails", async () => { + const capturedUrl = new URL( + "https://auth.example.com/authorize?state=step-up", + ); + mockedMcpAuth + .mockResolvedValueOnce("REDIRECT") + .mockResolvedValueOnce("REDIRECT") + .mockResolvedValueOnce("AUTHORIZED"); + const params = createMockParams(); + storageOf(params).getScope.mockReturnValue("mcp"); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "access", + refresh_token: "refresh", + token_type: "Bearer", + scope: "mcp", + }); + storageOf(params).getClientInformation.mockResolvedValue({ + client_id: "cid", + }); + const manager = new OAuthManager(params); + const captureSpy = vi + .spyOn( + (await import("@inspector/core/auth/providers.js")) + .BaseOAuthClientProvider.prototype, + "getCapturedAuthUrl", + ) + .mockReturnValue(capturedUrl); + + await manager.handleAuthChallenge({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }); + + await expect(manager.completeOAuthFlow("bad-code")).rejects.toThrow(); + + storageOf(params).getTokens.mockResolvedValue({ + access_token: "access", + token_type: "Bearer", + scope: "mcp", + }); + await manager.completeOAuthFlow("good-code"); + + expect(storageOf(params).saveScope).toHaveBeenLastCalledWith( + SERVER_URL, + "mcp", + ); + captureSpy.mockRestore(); + }); }); describe("completeOAuthFlow (EMA)", () => { @@ -717,6 +796,522 @@ describe("OAuthManager", () => { }); }); + describe("checkAuthChallengeSatisfied", () => { + it("returns false when no tokens in storage", async () => { + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue(undefined); + const manager = new OAuthManager(params); + + expect( + await manager.checkAuthChallengeSatisfied({ + reason: "insufficient_scope", + requiredScopes: ["tools:write"], + }), + ).toBe(false); + }); + + it("returns true for token_expired when a usable access token exists", async () => { + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + }); + const manager = new OAuthManager(params); + + expect( + await manager.checkAuthChallengeSatisfied({ reason: "token_expired" }), + ).toBe(true); + }); + + it("returns true when stored scope covers step-up union", async () => { + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + scope: "mcp tools:read tools:write", + }); + storageOf(params).getScope.mockReturnValue("mcp tools:read tools:write"); + const manager = new OAuthManager(params); + + expect( + await manager.checkAuthChallengeSatisfied({ + reason: "insufficient_scope", + requiredScopes: ["tools:write"], + }), + ).toBe(true); + }); + + it("returns false when step-up union exceeds granted scope", async () => { + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + scope: "mcp tools:read", + }); + storageOf(params).getScope.mockReturnValue("mcp tools:read"); + const manager = new OAuthManager(params); + + expect( + await manager.checkAuthChallengeSatisfied({ + reason: "insufficient_scope", + requiredScopes: ["tools:write"], + }), + ).toBe(false); + }); + + it("returns false for insufficient_scope with no scopes in the challenge", async () => { + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + }); + storageOf(params).getScope.mockReturnValue(undefined); + const manager = new OAuthManager(params); + + expect( + await manager.checkAuthChallengeSatisfied({ + reason: "insufficient_scope", + }), + ).toBe(false); + }); + + it("short-circuits handleAuthChallenge when scope already satisfied", async () => { + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + scope: "mcp tools:read tools:write", + }); + storageOf(params).getScope.mockReturnValue("mcp tools:read tools:write"); + const manager = new OAuthManager(params); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + requiredScopes: ["tools:write"], + }); + + expect(outcome).toEqual({ kind: "satisfied" }); + expect(mockedMcpAuth).not.toHaveBeenCalled(); + }); + + it("does not short-circuit token_expired at handleAuthChallenge entry", async () => { + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + }); + const manager = new OAuthManager(params); + + const outcome = await manager.handleAuthChallenge({ + reason: "token_expired", + }); + + expect(outcome).toEqual({ kind: "satisfied" }); + expect(mockedMcpAuth).toHaveBeenCalled(); + }); + }); + + describe("handleAuthChallenge", () => { + it("returns satisfied when silent refresh succeeds", async () => { + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); + const manager = new OAuthManager(createMockParams()); + + const outcome = await manager.handleAuthChallenge({ + reason: "token_expired", + }); + + expect(outcome).toEqual({ kind: "satisfied" }); + }); + + it("returns interactive when refresh requires redirect without navigating", async () => { + const capturedUrl = new URL( + "https://auth.example.com/authorize?state=abc", + ); + mockedMcpAuth.mockResolvedValue("REDIRECT"); + const params = createMockParams(); + const manager = new OAuthManager(params); + const captureSpy = vi + .spyOn( + (await import("@inspector/core/auth/providers.js")) + .BaseOAuthClientProvider.prototype, + "getCapturedAuthUrl", + ) + .mockReturnValue(capturedUrl); + + const outcome = await manager.handleAuthChallenge({ + reason: "token_expired", + }); + + expect(outcome).toEqual( + expect.objectContaining({ + kind: "interactive", + authorizationUrl: capturedUrl, + }), + ); + expect( + params.initialConfig.navigation!.navigateToAuthorization, + ).not.toHaveBeenCalled(); + expect(manager.getOAuthFlowStep()).toBe("authorization_code"); + captureSpy.mockRestore(); + }); + + it("uses catalog scope for reauth interactive flows", async () => { + const capturedUrl = new URL( + "https://auth.example.com/authorize?state=reauth", + ); + mockedMcpAuth.mockResolvedValue("REDIRECT"); + const params = createMockParams(); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ scope: "catalog:scope" }); + storageOf(params).getScope.mockReturnValue("stored union scope"); + const captureSpy = vi + .spyOn( + (await import("@inspector/core/auth/providers.js")) + .BaseOAuthClientProvider.prototype, + "getCapturedAuthUrl", + ) + .mockReturnValue(capturedUrl); + + await manager.handleAuthChallenge({ reason: "token_expired" }); + + expect(mockedMcpAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ scope: "catalog:scope" }), + ); + captureSpy.mockRestore(); + }); + + it("returns failed with step-up message when silent refresh grants insufficient scope", async () => { + mockedMcpAuth + .mockResolvedValueOnce("AUTHORIZED") + .mockResolvedValueOnce("AUTHORIZED"); + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "access", + token_type: "Bearer", + scope: "mcp tools:read", + }); + storageOf(params).getScope.mockReturnValue("mcp tools:read"); + const manager = new OAuthManager(params); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + context: { toolName: "get_weather" }, + }); + + expect(outcome.kind).toBe("failed"); + if (outcome.kind === "failed") { + expect(outcome.error.message).toMatch(/get_weather/); + } + }); + + it("returns failed when no authorization URL is captured", async () => { + mockedMcpAuth.mockResolvedValue("REDIRECT"); + const manager = new OAuthManager(createMockParams()); + + const outcome = await manager.handleAuthChallenge({ + reason: "unauthorized", + }); + + expect(outcome.kind).toBe("failed"); + if (outcome.kind === "failed") { + expect(outcome.error.message).toMatch( + /Failed to capture authorization URL/, + ); + } + }); + + it("returns interactive for insufficient_scope without navigating", async () => { + const capturedUrl = new URL( + "https://auth.example.com/authorize?state=step-up", + ); + mockedMcpAuth.mockResolvedValue("REDIRECT"); + const params = createMockParams(); + storageOf(params).getScope.mockReturnValue("mcp tools:read"); + const manager = new OAuthManager(params); + const captureSpy = vi + .spyOn( + (await import("@inspector/core/auth/providers.js")) + .BaseOAuthClientProvider.prototype, + "getCapturedAuthUrl", + ) + .mockReturnValue(capturedUrl); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }); + + expect(outcome).toEqual( + expect.objectContaining({ + kind: "interactive", + authorizationUrl: capturedUrl, + }), + ); + expect( + params.initialConfig.navigation!.navigateToAuthorization, + ).not.toHaveBeenCalled(); + captureSpy.mockRestore(); + }); + + it("unions scopes and starts interactive step-up for insufficient_scope", async () => { + const capturedUrl = new URL( + "https://auth.example.com/authorize?state=step-up", + ); + mockedMcpAuth.mockResolvedValue("REDIRECT"); + const params = createMockParams(); + storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "access", + refresh_token: "refresh", + token_type: "Bearer", + scope: "mcp tools:read", + }); + const manager = new OAuthManager(params); + const captureSpy = vi + .spyOn( + (await import("@inspector/core/auth/providers.js")) + .BaseOAuthClientProvider.prototype, + "getCapturedAuthUrl", + ) + .mockReturnValue(capturedUrl); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }); + + expect(outcome).toEqual( + expect.objectContaining({ + kind: "interactive", + authorizationUrl: capturedUrl, + }), + ); + expect(storageOf(params).saveScope).not.toHaveBeenCalled(); + expect(mockedMcpAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + scope: "mcp tools:read weather:read", + forceReauthorization: true, + }), + ); + captureSpy.mockRestore(); + }); + + it("returns satisfied for EMA silent refresh", async () => { + const refreshSpy = vi + .spyOn(emaFlow, "refreshEmaResourceTokens") + .mockResolvedValue(undefined); + const authUrl = new URL("https://idp.example.com/authorize?state=ema"); + const startSpy = vi + .spyOn(emaFlow, "startEmaIdpAuthorization") + .mockResolvedValue(authUrl); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const outcome = await manager.handleAuthChallenge({ + reason: "token_expired", + }); + + expect(outcome).toEqual( + expect.objectContaining({ + kind: "interactive", + authorizationUrl: authUrl, + }), + ); + expect( + params.initialConfig.navigation!.navigateToAuthorization, + ).not.toHaveBeenCalled(); + refreshSpy.mockRestore(); + startSpy.mockRestore(); + }); + + it("returns step_up_confirm for EMA insufficient_scope until user confirms", async () => { + const silentSpy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "success" }); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + storageOf(params).getScope.mockReturnValue("mcp"); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + scope: "mcp", + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }); + + expect(outcome.kind).toBe("step_up_confirm"); + if (outcome.kind === "step_up_confirm") { + expect(outcome.challenge.authorizationScopes).toEqual([ + "mcp", + "weather:read", + ]); + } + expect(silentSpy).not.toHaveBeenCalled(); + silentSpy.mockRestore(); + }); + + it("step_up_confirm lists only scopes not already granted when AS sends full tool requirements", async () => { + const silentSpy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "success" }); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + scope: "mcp tools:read", + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + requiredScopes: ["tools:read", "env:read"], + }); + + expect(outcome.kind).toBe("step_up_confirm"); + if (outcome.kind === "step_up_confirm") { + expect(outcome.challenge.requiredScopes).toEqual(["env:read"]); + expect(outcome.challenge.authorizationScopes).toEqual([ + "mcp", + "tools:read", + "env:read", + ]); + } + silentSpy.mockRestore(); + }); + + it("returns satisfied for EMA insufficient_scope after user confirms", async () => { + const silentSpy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "success" }); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + let storedScope = "mcp"; + storageOf(params).getScope.mockImplementation(() => storedScope); + storageOf(params).saveScope.mockImplementation(async (_url, scope) => { + storedScope = scope; + }); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + scope: "mcp", + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const outcome = await manager.handleAuthChallenge( + { + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }, + { confirmedStepUp: true }, + ); + + expect(outcome).toEqual({ kind: "satisfied" }); + expect(storageOf(params).saveScope).toHaveBeenCalledWith( + SERVER_URL, + "mcp weather:read", + ); + expect(silentSpy).toHaveBeenCalled(); + silentSpy.mockRestore(); + }); + + it("completeOAuthFlow mints EMA tokens with pending step-up union scope", async () => { + const silentSpy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "no_idp_session" }); + const authUrl = new URL("https://idp.example.com/authorize?state=ema"); + const startSpy = vi + .spyOn(emaFlow, "startEmaIdpAuthorization") + .mockResolvedValue(authUrl); + const mintSpy = vi + .spyOn(emaFlow, "completeEmaIdpAuthorizationAndMint") + .mockResolvedValue({ access_token: "tok", token_type: "Bearer" }); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "old", + token_type: "Bearer", + scope: "mcp tools:read", + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true, scope: "mcp" }); + + const outcome = await manager.handleAuthChallenge( + { + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }, + { confirmedStepUp: true }, + ); + expect(outcome.kind).toBe("interactive"); + + await manager.completeOAuthFlow("auth-code"); + + expect(mintSpy).toHaveBeenCalledWith( + expect.objectContaining({ scope: "mcp tools:read weather:read" }), + "auth-code", + ); + expect(storageOf(params).saveScope).toHaveBeenCalledWith( + SERVER_URL, + "mcp tools:read weather:read", + ); + + silentSpy.mockRestore(); + startSpy.mockRestore(); + mintSpy.mockRestore(); + }); + }); + describe("createOAuthProviderForTransport", () => { it("returns a plain provider for standard OAuth", async () => { const manager = new OAuthManager(createMockParams()); diff --git a/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts b/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts index cb43f7ed3..64a1328d5 100644 --- a/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts +++ b/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts @@ -41,6 +41,28 @@ function sseStreamResponse(): Response { }); } +function createPushableSseStream() { + const encoder = new TextEncoder(); + let controller: ReadableStreamDefaultController | null = null; + const stream = new ReadableStream({ + start(c) { + controller = c; + c.enqueue(encoder.encode(": keepalive\n\n")); + }, + }); + const pushMessage = (message: unknown) => { + const payload = JSON.stringify({ type: "message", data: message }); + controller?.enqueue(encoder.encode(`data: ${payload}\n\n`)); + }; + return { + response: new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + pushMessage, + }; +} + describe("RemoteClientTransport", () => { it("send() throws when called before start()", async () => { const transport = new RemoteClientTransport( @@ -384,4 +406,113 @@ describe("RemoteClientTransport", () => { const parsed = JSON.parse(seenBodies[0]!) as Record; expect("settings" in parsed).toBe(false); }); + + it("retries send once after satisfied auth challenge and pushAuthState", async () => { + let sessionCounter = 0; + let sendAttempts = 0; + let authStateUpdates = 0; + let sse = createPushableSseStream(); + const fetchFn = vi + .fn() + .mockImplementation(async (input, init) => { + const url = String(input); + if (url.endsWith("/api/mcp/connect") && init?.method === "POST") { + sessionCounter += 1; + return new Response( + JSON.stringify({ sessionId: `session-${sessionCounter}` }), + { status: 200 }, + ); + } + if (url.includes("/api/mcp/events")) { + sse = createPushableSseStream(); + return sse.response; + } + if (url.endsWith("/api/mcp/auth-state") && init?.method === "POST") { + authStateUpdates += 1; + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + if (url.endsWith("/api/mcp/send") && init?.method === "POST") { + sendAttempts += 1; + const body = JSON.parse(String(init.body)) as { + message: { id?: string | number }; + }; + if (sendAttempts === 1) { + return new Response( + JSON.stringify({ + ok: false, + kind: "auth_challenge", + authChallenge: { reason: "token_expired" }, + }), + { status: 200 }, + ); + } + sse.pushMessage({ + jsonrpc: "2.0", + id: body.message.id, + result: { tools: [] }, + }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }); + + const authProvider = { + tokens: vi.fn().mockResolvedValue({ + access_token: "refreshed", + token_type: "Bearer", + }), + }; + + const transport = new RemoteClientTransport( + { + baseUrl, + fetchFn: fetchFn as unknown as typeof fetch, + sseResponseTimeoutMs: 2000, + authProvider: authProvider as never, + }, + config, + ); + transport.setAuthRecovery({ + handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "satisfied" }), + pushAuthState: () => transport.pushAuthState(), + }); + + await transport.start(); + await transport.send({ jsonrpc: "2.0", id: 1, method: "tools/list" }); + + expect(sendAttempts).toBe(2); + expect(sessionCounter).toBe(1); + expect(authStateUpdates).toBe(1); + await transport.close(); + }); + + it("forwards ambient auth_challenge SSE events to onAuthChallenge", async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ sessionId: "abc" }), { status: 200 }), + ) + .mockResolvedValueOnce( + sseResponse( + 'data: {"type":"auth_challenge","data":{"reason":"token_expired"}}\n\n', + ), + ); + const onAuthChallenge = vi.fn(); + const transport = new RemoteClientTransport( + { + baseUrl, + fetchFn: fetchFn as unknown as typeof fetch, + onAuthChallenge, + }, + config, + ); + await transport.start(); + await vi.waitFor(() => expect(onAuthChallenge).toHaveBeenCalled(), { + timeout: 1000, + interval: 10, + }); + expect(onAuthChallenge.mock.calls[0]?.[0]).toEqual({ + reason: "token_expired", + }); + }); }); diff --git a/clients/web/src/test/core/mcp/test-server-scope.test.ts b/clients/web/src/test/core/mcp/test-server-scope.test.ts new file mode 100644 index 000000000..ce5f8cce9 --- /dev/null +++ b/clients/web/src/test/core/mcp/test-server-scope.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import type { Request, Response, NextFunction } from "express"; +import { + buildScopeRequirementRegistry, + clearOAuthTestData, + createScopeCheckMiddleware, + mintTestAccessToken, + scopeRequirementRegistryHasEntries, +} from "@modelcontextprotocol/inspector-test-server"; +import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server"; + +function createMinimalConfig( + overrides: Partial = {}, +): ServerConfig { + return { + serverInfo: { name: "scope-test", version: "1.0.0" }, + tools: [ + { + name: "get_temp", + description: "mock", + requiredScopes: ["weather:read"], + handler: async () => ({ + content: [{ type: "text" as const, text: "ok" }], + }), + }, + ], + ...overrides, + }; +} + +function invokeScopeMiddleware( + registry: ReturnType, + body: unknown, + token: string, + oauthTokenScopes?: string[], +): { + status: number; + headers: Record; + body?: unknown; + next: boolean; +} { + const middleware = createScopeCheckMiddleware(registry); + const headers: Record = {}; + let status = 200; + let responseBody: unknown; + let nextCalled = false; + + const req = { + body, + oauthToken: token, + ...(oauthTokenScopes !== undefined ? { oauthTokenScopes } : {}), + } as Request & { oauthToken: string; oauthTokenScopes?: string[] }; + + const res = { + status(code: number) { + status = code; + return this; + }, + setHeader(name: string, value: string) { + headers[name.toLowerCase()] = value; + }, + json(payload: unknown) { + responseBody = payload; + }, + } as unknown as Response; + + middleware(req, res, (() => { + nextCalled = true; + }) as NextFunction); + + return { status, headers, body: responseBody, next: nextCalled }; +} + +describe("test server scope requirements", () => { + beforeEach(() => { + clearOAuthTestData(); + }); + + it("builds registry from capability requiredScopes", () => { + const registry = buildScopeRequirementRegistry(createMinimalConfig()); + expect(scopeRequirementRegistryHasEntries(registry)).toBe(true); + expect(registry.tools.get("get_temp")).toEqual(["weather:read"]); + }); + + it("returns 403 insufficient_scope when token lacks required scope", () => { + const token = mintTestAccessToken("mcp tools:read"); + const registry = buildScopeRequirementRegistry(createMinimalConfig()); + const result = invokeScopeMiddleware( + registry, + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "get_temp", arguments: { city: "NYC", units: "C" } }, + }, + token, + ); + + expect(result.next).toBe(false); + expect(result.status).toBe(403); + expect(result.headers["www-authenticate"]).toContain("insufficient_scope"); + expect(result.headers["www-authenticate"]).toContain("weather:read"); + expect(result.headers["www-authenticate"]).not.toContain("tools:read"); + }); + + it("allows request when token includes required scopes", () => { + const token = mintTestAccessToken("mcp tools:read weather:read"); + const registry = buildScopeRequirementRegistry(createMinimalConfig()); + const result = invokeScopeMiddleware( + registry, + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "get_temp", arguments: { city: "NYC", units: "C" } }, + }, + token, + ); + + expect(result.next).toBe(true); + expect(result.status).toBe(200); + }); + + it("uses oauthTokenScopes attached by bearer middleware (external JWT path)", () => { + const registry = buildScopeRequirementRegistry({ + ...createMinimalConfig(), + tools: [ + { + name: "echo", + description: "mock", + requiredScopes: ["tools:read"], + handler: async () => ({ + content: [{ type: "text" as const, text: "ok" }], + }), + }, + ], + }); + const result = invokeScopeMiddleware( + registry, + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "echo", arguments: { message: "hi" } }, + }, + "external.jwt.not.in.internal.map", + ["tools:read"], + ); + + expect(result.next).toBe(true); + expect(result.status).toBe(200); + }); +}); diff --git a/clients/web/src/test/core/remote-tokenAuthProvider.test.ts b/clients/web/src/test/core/remote-tokenAuthProvider.test.ts index cfd12dc7a..b23234637 100644 --- a/clients/web/src/test/core/remote-tokenAuthProvider.test.ts +++ b/clients/web/src/test/core/remote-tokenAuthProvider.test.ts @@ -1,28 +1,53 @@ import { describe, it, expect } from "vitest"; -import { createTokenAuthProvider } from "@inspector/core/mcp/remote/node/tokenAuthProvider.js"; +import { createRemoteAuthProvider } from "@inspector/core/mcp/remote/node/tokenAuthProvider.js"; -describe("createTokenAuthProvider", () => { - it("returns undefined when tokens are not provided", () => { - expect(createTokenAuthProvider(undefined)).toBeUndefined(); +describe("createRemoteAuthProvider", () => { + it("returns undefined when no auth state is provided", () => { + expect(createRemoteAuthProvider(undefined)).toBeUndefined(); + expect(createRemoteAuthProvider({})).toBeUndefined(); }); it("returns a provider whose tokens() resolves with the supplied tokens", async () => { const tokens = { access_token: "abc", token_type: "Bearer" }; - const provider = createTokenAuthProvider(tokens); - expect(provider).toBeDefined(); - await expect(provider!.tokens()).resolves.toEqual(tokens); + const handle = createRemoteAuthProvider({ oauthTokens: tokens }); + expect(handle).toBeDefined(); + await expect(handle!.provider.tokens()).resolves.toEqual(tokens); }); - it("exposes no-op stubs for the auxiliary OAuthClientProvider methods", async () => { - const provider = createTokenAuthProvider({ - access_token: "abc", + it("updates tokens via setAuthState without replacing the provider", async () => { + const handle = createRemoteAuthProvider({ + oauthTokens: { access_token: "old", token_type: "Bearer" }, + }); + handle!.setAuthState({ + oauthTokens: { + access_token: "new", + token_type: "Bearer", + refresh_token: "rt", + }, + }); + await expect(handle!.provider.tokens()).resolves.toEqual({ + access_token: "new", token_type: "Bearer", + refresh_token: "rt", + }); + expect(handle!.getAuthState().oauthTokens?.access_token).toBe("new"); + }); + + it("exposes clientInformation when oauthClient is set", async () => { + const handle = createRemoteAuthProvider({ + oauthClient: { client_id: "cid", client_secret: "sec" }, + }); + await expect(handle!.provider.clientInformation()).resolves.toEqual({ + client_id: "cid", + client_secret: "sec", }); - expect(provider).toBeDefined(); - // The aux methods are no-op stubs that satisfy the OAuthClientProvider - // surface; the underlying object type widens via the `as unknown as` - // cast in the source, so we narrow here for the test assertions. - const p = provider! as unknown as { + }); + + it("exposes no-op stubs for auxiliary OAuthClientProvider methods", async () => { + const handle = createRemoteAuthProvider({ + oauthTokens: { access_token: "abc", token_type: "Bearer" }, + }); + const p = handle!.provider as unknown as { clientInformation: () => Promise; saveTokens: (t: { access_token: string; @@ -37,8 +62,12 @@ describe("createTokenAuthProvider", () => { await expect(p.clientInformation()).resolves.toBeUndefined(); await expect( - p.saveTokens({ access_token: "noop", token_type: "Bearer" }), + p.saveTokens({ access_token: "saved", token_type: "Bearer" }), ).resolves.toBeUndefined(); + await expect(handle!.provider.tokens()).resolves.toEqual({ + access_token: "saved", + token_type: "Bearer", + }); expect(p.codeVerifier()).toBeUndefined(); await expect(p.saveCodeVerifier("v")).resolves.toBeUndefined(); expect(() => p.clear()).not.toThrow(); @@ -49,10 +78,9 @@ describe("createTokenAuthProvider", () => { }); it("exposes clientMetadata so SDK auth() does not throw on 401 retry", () => { - const provider = createTokenAuthProvider({ - access_token: "abc", - token_type: "Bearer", + const handle = createRemoteAuthProvider({ + oauthTokens: { access_token: "abc", token_type: "Bearer" }, }); - expect(provider!.clientMetadata.scope).toBe(""); + expect(handle!.provider.clientMetadata.scope).toBe(""); }); }); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth-direct-mid-session-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth-direct-mid-session-e2e.test.ts new file mode 100644 index 000000000..605d5558e --- /dev/null +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth-direct-mid-session-e2e.test.ts @@ -0,0 +1,243 @@ +/** + * Mid-session OAuth recovery over direct (TUI/CLI) transport. + * Uses createAuthChallengeInterceptFetch + handleAuthChallenge on InspectorClient. + */ + +import { describe, it, expect, beforeEach, afterEach, afterAll } from "vitest"; +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; +import { NodeOAuthStorage } from "@inspector/core/auth/node/storage-node.js"; +import { + TestServerHttp, + waitForOAuthWellKnown, + getDefaultServerConfig, + createOAuthTestServerConfig, + clearOAuthTestData, + invalidateAccessToken, +} from "@modelcontextprotocol/inspector-test-server"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; +import { + createOAuthClientConfig, + completeOAuthAuthorization, +} from "../helpers/oauth-client-fixtures.js"; +import { ConsoleNavigation } from "@inspector/core/auth/providers.js"; +import type { InspectorClientOptions } from "@inspector/core/mcp/inspectorClient.js"; +import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; + +const oauthTestStatePath = join( + tmpdir(), + `mcp-oauth-${process.pid}-direct-mid-session-e2e.json`, +); + +function createTestOAuthConfig( + options: Parameters[0], +) { + return { + ...createOAuthClientConfig(options), + storage: new NodeOAuthStorage(oauthTestStatePath), + }; +} + +describe("InspectorClient direct mid-session OAuth", () => { + let mcpServer: TestServerHttp | null = null; + const testRedirectUrl = "http://localhost:3000/oauth/callback"; + + beforeEach(() => { + clearOAuthTestData(); + }); + + afterEach(async () => { + if (mcpServer) { + await mcpServer.stop(); + mcpServer = null; + } + }, 30_000); + + afterAll(() => { + try { + rmSync(oauthTestStatePath, { force: true }); + } catch { + // ignore + } + }); + + it("recovers from invalidated access token after connect via silent refresh without disconnect", async () => { + const staticClientId = "test-direct-mid-session"; + const staticClientSecret = "test-secret-direct-mid-session"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: "streamable-http" as const, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportRefreshTokens: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: new ConsoleNavigation(), + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + directAuthRecovery: true, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); + await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl)); + await client.connect(); + + const tokens = await client.getOAuthTokens(); + expect(tokens?.refresh_token).toBeDefined(); + invalidateAccessToken(tokens!.access_token); + + const toolsResult = await client.listTools(); + expect(toolsResult.tools.length).toBeGreaterThan(0); + expect(client.getStatus()).toBe("connected"); + + await client.disconnect(); + }, 30_000); + + it("step-up after insufficient_scope throws AuthRecoveryRequiredError then succeeds after OAuth", async () => { + const staticClientId = "test-direct-step-up"; + const staticClientSecret = "test-secret-direct-step-up"; + + const baseConfig = getDefaultServerConfig(); + const serverConfig = { + ...baseConfig, + serverType: "streamable-http" as const, + tools: baseConfig.tools!.map((tool) => + tool.name === "get_temp" + ? { ...tool, requiredScopes: ["weather:read"] } + : tool, + ), + ...createOAuthTestServerConfig({ + requireAuth: true, + supportRefreshTokens: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + scope: "mcp tools:read", + }); + + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: new ConsoleNavigation(), + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + directAuthRecovery: true, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); + await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl)); + await client.connect(); + + const toolsResult = await client.listTools(); + const getTempTool = toolsResult.tools.find((t) => t.name === "get_temp"); + expect(getTempTool).toBeDefined(); + + let recovery: AuthRecoveryRequiredError | undefined; + try { + await client.callTool(getTempTool!, { city: "NYC", units: "C" }); + } catch (error) { + if (error instanceof AuthRecoveryRequiredError) { + recovery = error; + } else { + throw error; + } + } + expect(recovery?.authChallenge.reason).toBe("insufficient_scope"); + expect(recovery!.authorizationUrl.searchParams.get("scope")).toContain( + "weather:read", + ); + + const stepUpCode = await completeOAuthAuthorization( + recovery!.authorizationUrl, + ); + await client.completeOAuthFlow(stepUpCode); + + const result = await client.callTool(getTempTool!, { + city: "NYC", + units: "C", + }); + expect(result.success).toBe(true); + + await client.disconnect(); + }, 30_000); +}); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts new file mode 100644 index 000000000..f2e0bb666 --- /dev/null +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts @@ -0,0 +1,715 @@ +/** + * Mid-session OAuth recovery over the web remote transport. + * Verifies inline auth_challenge on /api/mcp/send, silent refresh, auth-state push, and retry. + */ + +import { + describe, + it, + expect, + beforeEach, + afterEach, + afterAll, + vi, +} from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { serve } from "@hono/node-server"; +import type { ServerType } from "@hono/node-server"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { createRemoteTransport } from "@inspector/core/mcp/remote/createRemoteTransport.js"; +import { createRemoteFetch } from "@inspector/core/mcp/remote/createRemoteFetch.js"; +import { NodeOAuthStorage } from "@inspector/core/auth/node/storage-node.js"; +import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js"; +import { + TestServerHttp, + waitForOAuthWellKnown, + getDefaultServerConfig, + createOAuthTestServerConfig, + clearOAuthTestData, + invalidateAccessToken, +} from "@modelcontextprotocol/inspector-test-server"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; +import { + createOAuthClientConfig, + completeOAuthAuthorization, +} from "../helpers/oauth-client-fixtures.js"; +import { ConsoleNavigation } from "@inspector/core/auth/providers.js"; +import type { InspectorClientOptions } from "@inspector/core/mcp/inspectorClient.js"; +import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; + +const oauthTestStatePath = join( + tmpdir(), + `mcp-oauth-${process.pid}-remote-mid-session-e2e.json`, +); + +function createTestOAuthConfig( + options: Parameters[0], +) { + return { + ...createOAuthClientConfig(options), + storage: new NodeOAuthStorage(oauthTestStatePath), + }; +} + +async function startRemoteServer(port: number): Promise<{ + baseUrl: string; + server: ServerType; + authToken: string; +}> { + const { app, authToken } = createRemoteApp({ + initialConfig: { defaultEnvironment: {} }, + }); + return new Promise((resolve, reject) => { + const server = serve( + { fetch: app.fetch, port, hostname: "127.0.0.1" }, + (info) => { + const actualPort = + info && typeof info === "object" && "port" in info + ? (info as { port: number }).port + : port; + resolve({ + baseUrl: `http://127.0.0.1:${actualPort}`, + server, + authToken, + }); + }, + ); + server.on("error", reject); + }); +} + +describe("InspectorClient remote mid-session OAuth", () => { + let remoteServer: ServerType | null = null; + let mcpServer: TestServerHttp | null = null; + let remoteBaseUrl: string | undefined; + let remoteAuthToken: string | undefined; + const testRedirectUrl = "http://localhost:3000/oauth/callback"; + + beforeEach(() => { + clearOAuthTestData(); + }); + + afterEach(async () => { + if (mcpServer) { + await mcpServer.stop(); + mcpServer = null; + } + if (remoteServer) { + await new Promise((resolve) => { + remoteServer!.close(() => resolve()); + }); + remoteServer = null; + } + remoteBaseUrl = undefined; + remoteAuthToken = undefined; + }, 30_000); + + afterAll(() => { + try { + rmSync(oauthTestStatePath, { force: true }); + } catch { + // ignore + } + }); + + async function setupRemoteServer(): Promise { + const tmp = mkdtempSync(join(tmpdir(), "inspector-remote-mid-session-")); + const { baseUrl, server, authToken } = await startRemoteServer(0); + remoteBaseUrl = baseUrl; + remoteAuthToken = authToken; + remoteServer = server; + rmSync(tmp, { recursive: true, force: true }); + } + + it("recovers from invalidated access token after connect via silent refresh and auth-state push", async () => { + await setupRemoteServer(); + + const staticClientId = "test-remote-mid-session"; + const staticClientSecret = "test-secret-mid-session"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: "streamable-http" as const, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportRefreshTokens: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + + const clientConfig: InspectorClientOptions = { + environment: { + transport: createRemoteTransport({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + fetch: createRemoteFetch({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + oauth: { + storage: oauthConfig.storage, + navigation: new ConsoleNavigation(), + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); + await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl)); + await client.connect(); + + const tokens = await client.getOAuthTokens(); + expect(tokens?.refresh_token).toBeDefined(); + invalidateAccessToken(tokens!.access_token); + + const toolsResult = await client.listTools(); + expect(toolsResult.tools.length).toBeGreaterThan(0); + expect(client.getStatus()).toBe("connected"); + + await client.disconnect(); + }, 15_000); + + it("recovers on reconnect when stored access token was invalidated before connect", async () => { + await setupRemoteServer(); + + const staticClientId = "test-remote-reconnect-mid-session"; + const staticClientSecret = "test-secret-reconnect-mid-session"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: "streamable-http" as const, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportRefreshTokens: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + + const clientConfig: InspectorClientOptions = { + environment: { + transport: createRemoteTransport({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + fetch: createRemoteFetch({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + oauth: { + storage: oauthConfig.storage, + navigation: new ConsoleNavigation(), + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); + await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl)); + await client.connect(); + + const tokens = await client.getOAuthTokens(); + invalidateAccessToken(tokens!.access_token); + + await client.connect(); + + expect(client.getStatus()).toBe("connected"); + await client.disconnect(); + }); + + it("step-up re-auth after insufficient_scope lets scoped tool succeed", async () => { + await setupRemoteServer(); + + const staticClientId = "test-remote-step-up"; + const staticClientSecret = "test-secret-step-up"; + + const baseConfig = getDefaultServerConfig(); + const serverConfig = { + ...baseConfig, + tools: baseConfig.tools!.map((tool) => + tool.name === "get_temp" + ? { ...tool, requiredScopes: ["weather:read"] } + : tool, + ), + serverType: "streamable-http" as const, + ...createOAuthTestServerConfig({ + requireAuth: true, + scopesSupported: ["mcp", "tools:read", "weather:read"], + supportRefreshTokens: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + scope: "mcp tools:read", + }); + + const clientConfig: InspectorClientOptions = { + environment: { + transport: createRemoteTransport({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + fetch: createRemoteFetch({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + oauth: { + storage: oauthConfig.storage, + navigation: new ConsoleNavigation(), + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); + await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl)); + await client.connect(); + + const toolsResult = await client.listTools(); + const echoTool = toolsResult.tools.find((t) => t.name === "echo"); + const getTempTool = toolsResult.tools.find((t) => t.name === "get_temp"); + expect(echoTool).toBeDefined(); + expect(getTempTool).toBeDefined(); + + await client.callTool(echoTool!, { message: "hello" }); + + const remoteSessionId = client.getRemoteBackendSessionId(); + expect(remoteSessionId).toBeDefined(); + + let recovery: AuthRecoveryRequiredError | undefined; + try { + await client.callTool(getTempTool!, { city: "NYC", units: "C" }); + } catch (error) { + if (error instanceof AuthRecoveryRequiredError) { + recovery = error; + } else { + throw error; + } + } + expect(recovery?.authChallenge.reason).toBe("insufficient_scope"); + + const mcpServerUrl = `${serverUrl}/mcp`; + const scopeBeforeStepUp = oauthConfig.storage.getScope(mcpServerUrl); + expect(scopeBeforeStepUp).toContain("mcp"); + expect(scopeBeforeStepUp).not.toContain("weather:read"); + + expect(recovery!.authorizationUrl.searchParams.get("scope")).toContain( + "weather:read", + ); + + const stepUpCode = await completeOAuthAuthorization( + recovery!.authorizationUrl, + ); + + // Mirror `/oauth/callback`: new InspectorClient instance, same persisted OAuth + // storage, reattach to the live remote backend session. + const callbackClient = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + clientConfig, + ); + await callbackClient.resumeAfterOAuth(stepUpCode, { remoteSessionId }); + + expect(callbackClient.getStatus()).toBe("connected"); + + const scopeAfterStepUp = oauthConfig.storage.getScope(mcpServerUrl); + expect(scopeAfterStepUp).toContain("weather:read"); + expect(scopeAfterStepUp).toContain("mcp"); + + const callbackTools = await callbackClient.listTools(); + const callbackGetTemp = callbackTools.tools.find( + (t) => t.name === "get_temp", + ); + expect(callbackGetTemp).toBeDefined(); + + const result = await callbackClient.callTool(callbackGetTemp!, { + city: "NYC", + units: "C", + }); + expect(result.success).toBe(true); + const content = result.result?.content; + expect(Array.isArray(content)).toBe(true); + expect(content?.[0]).toHaveProperty("type", "text"); + expect("text" in content![0] && content![0].text).toContain("25"); + + await callbackClient.disconnect(); + await client.disconnect(); + }, 30_000); + + it("resumeAfterOAuth falls back to connect when remote session is dead", async () => { + await setupRemoteServer(); + + const staticClientId = "test-remote-resume-fallback"; + const staticClientSecret = "test-secret-resume-fallback"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: "streamable-http" as const, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportRefreshTokens: false, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + + const clientConfig: InspectorClientOptions = { + environment: { + transport: createRemoteTransport({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + fetch: createRemoteFetch({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + oauth: { + storage: oauthConfig.storage, + navigation: new ConsoleNavigation(), + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); + const initialCode = await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(initialCode); + await client.connect(); + + const mcpServerUrl = `${serverUrl}/mcp`; + await oauthConfig.storage.clearTokens(mcpServerUrl); + const reauthUrl = await client.authenticate(); + if (!reauthUrl) throw new Error("Expected reauth URL"); + const code = await completeOAuthAuthorization(reauthUrl); + + const callbackClient = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + clientConfig, + ); + const connectSpy = vi.spyOn(callbackClient, "connect"); + await callbackClient.resumeAfterOAuth(code, { + remoteSessionId: "dead-remote-session-id", + }); + expect(connectSpy).toHaveBeenCalled(); + connectSpy.mockRestore(); + + expect(callbackClient.getStatus()).toBe("connected"); + const toolsResult = await callbackClient.listTools(); + expect(toolsResult.tools.length).toBeGreaterThan(0); + + await callbackClient.disconnect(); + await client.disconnect(); + }, 30_000); + + it("dispatches authChallengeInteractive for ambient interactive recovery", async () => { + await setupRemoteServer(); + + const staticClientId = "test-remote-ambient-interactive"; + const staticClientSecret = "test-secret-ambient-interactive"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: "streamable-http" as const, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportRefreshTokens: false, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + { + environment: { + transport: createRemoteTransport({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + fetch: createRemoteFetch({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + oauth: { + storage: oauthConfig.storage, + navigation: new ConsoleNavigation(), + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }, + ); + + const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); + await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl)); + await client.connect(); + + const tokens = await client.getOAuthTokens(); + invalidateAccessToken(tokens!.access_token); + + const interactive = vi.fn(); + client.addEventListener("authChallengeInteractive", interactive); + + await client.handleAmbientAuthChallenge({ reason: "token_expired" }); + + expect(interactive).toHaveBeenCalledOnce(); + expect(interactive.mock.calls[0]![0].detail.challenge.reason).toBe( + "token_expired", + ); + expect( + interactive.mock.calls[0]![0].detail.authorizationUrl, + ).toBeInstanceOf(URL); + + await client.disconnect(); + }, 15_000); + + it("recovers from ambient auth notification after idle token invalidation", async () => { + await setupRemoteServer(); + + const staticClientId = "test-remote-ambient-auth"; + const staticClientSecret = "test-secret-ambient-auth"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: "streamable-http" as const, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportRefreshTokens: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + + const clientConfig: InspectorClientOptions = { + environment: { + transport: createRemoteTransport({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + fetch: createRemoteFetch({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + oauth: { + storage: oauthConfig.storage, + navigation: new ConsoleNavigation(), + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); + await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl)); + await client.connect(); + + const tokens = await client.getOAuthTokens(); + invalidateAccessToken(tokens!.access_token); + + const recovered = vi.fn(); + client.addEventListener("authChallengeRecovered", recovered); + + await client.handleAmbientAuthChallenge({ reason: "token_expired" }); + + expect(recovered).toHaveBeenCalled(); + + const toolsResult = await client.listTools(); + expect(toolsResult.tools.length).toBeGreaterThan(0); + expect(client.getStatus()).toBe("connected"); + + await client.disconnect(); + }, 15_000); +}); diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 97c291c3f..6b23727c9 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -341,6 +341,33 @@ describe("InspectorClient", () => { // (the same end state any other handshake failure would produce). expect(client.getStatus()).toBe("error"); }); + + it("holds status at connecting when connect fails with a recoverable 401", async () => { + const unauthorizedTransport = { + start: async () => { + const err = new Error("Unauthorized") as Error & { status?: number }; + err.status = 401; + throw err; + }, + send: async () => {}, + close: async () => {}, + onclose: undefined, + onerror: undefined, + onmessage: undefined, + sessionId: undefined, + }; + const fakeFactory = () => ({ + transport: + unauthorizedTransport as unknown as import("@modelcontextprotocol/sdk/shared/transport.js").Transport, + }); + client = new InspectorClient( + { type: "streamable-http", url: "http://localhost:8081/mcp" }, + { environment: { transport: fakeFactory } }, + ); + + await expect(client.connect()).rejects.toMatchObject({ status: 401 }); + expect(client.getStatus()).toBe("connecting"); + }); }); describe("Message Tracking", () => { diff --git a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts index cf95564f6..e1dca9fe6 100644 --- a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts @@ -10,6 +10,7 @@ import { describe, it, expect, vi } from "vitest"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { RemoteSession } from "@inspector/core/mcp/remote/node/remote-session.js"; import type { FetchRequestEntryBase } from "@inspector/core/mcp/types.js"; +import { AuthChallengeError } from "@inspector/core/auth/challenge.js"; function makeFetchEntry( overrides: Partial = {}, @@ -173,4 +174,113 @@ describe("RemoteSession", () => { "2026-01-01T00:00:00.000Z", ); }); + + it("waitForRequestResponse resolves when a matching JSON-RPC response arrives", async () => { + const session = new RemoteSession("s-wait"); + const wait = session.waitForRequestResponse(42); + session.onMessage({ jsonrpc: "2.0", id: 42, result: { tools: [] } }); + await expect(wait).resolves.toBeUndefined(); + }); + + it("handleTransportAuthError rejects active request waits during send", async () => { + const session = new RemoteSession("s-auth"); + session.beginSend(); + const wait = session.waitForRequestResponse(1); + const err = new AuthChallengeError({ reason: "token_expired" }, 401); + expect(session.handleTransportAuthError(err)).toBe(true); + await expect(wait).rejects.toBe(err); + session.endSend(); + }); + + it("handleTransportAuthError pushes ambient auth when no send is active", () => { + const session = new RemoteSession("s-ambient"); + const received: unknown[] = []; + session.setEventConsumer((event) => { + if (event.type === "auth_challenge") received.push(event.data); + }); + const err = new AuthChallengeError({ reason: "token_expired" }, 401); + expect(session.handleTransportAuthError(err)).toBe(true); + expect(received).toHaveLength(1); + }); + + it("does not push SSE auth while a send is active (command path owns delivery)", () => { + const session = new RemoteSession("s-active"); + const received: unknown[] = []; + session.setEventConsumer((event) => { + if (event.type === "auth_challenge") received.push(event.data); + }); + const err = new AuthChallengeError({ reason: "token_expired" }, 401); + session.beginSend(); + expect(session.handleTransportAuthError(err)).toBe(true); + expect(received).toHaveLength(0); + session.endSend(); + }); + + it("does not duplicate on SSE until the HTTP echo suppress window expires", () => { + vi.useFakeTimers(); + const session = new RemoteSession("s-echo"); + const received: unknown[] = []; + session.setEventConsumer((event) => { + if (event.type === "auth_challenge") received.push(event.data); + }); + const err = new AuthChallengeError({ reason: "token_expired" }, 401); + session.beginSend(); + session.noteAuthChallengeDeliveredViaHttp(); + session.endSend(); + expect(session.handleTransportAuthError(err)).toBe(true); + expect(received).toHaveLength(0); + expect(session.handleTransportAuthError(err)).toBe(true); + expect(received).toHaveLength(0); + session.beginSend(); + session.endSend(); + expect(session.handleTransportAuthError(err)).toBe(true); + expect(received).toHaveLength(0); + vi.advanceTimersByTime(RemoteSession.AUTH_HTTP_ECHO_SUPPRESS_MS + 1); + expect(session.handleTransportAuthError(err)).toBe(true); + expect(received).toHaveLength(1); + vi.useRealTimers(); + }); + + it("does not clear HTTP auth suppression when a concurrent send starts", () => { + const session = new RemoteSession("s-concurrent"); + const received: unknown[] = []; + session.setEventConsumer((event) => { + if (event.type === "auth_challenge") received.push(event.data); + }); + const err = new AuthChallengeError({ reason: "token_expired" }, 401); + session.beginSend(); + session.noteAuthChallengeDeliveredViaHttp(); + session.endSend(); + session.beginSend(); + expect(session.handleTransportAuthError(err)).toBe(true); + expect(received).toHaveLength(0); + session.endSend(); + }); + + it("waitForRequestResponse rejects after timeout", async () => { + vi.useFakeTimers(); + const session = new RemoteSession("s-timeout"); + const wait = session.waitForRequestResponse(99, 1000); + const rejection = expect(wait).rejects.toThrow(/timed out/); + await vi.advanceTimersByTimeAsync(1000); + await rejection; + vi.useRealTimers(); + }); + + it("setAuthState updates the session auth provider", async () => { + const { createRemoteAuthProvider } = + await import("@inspector/core/mcp/remote/node/tokenAuthProvider.js"); + const handle = createRemoteAuthProvider({ + oauthTokens: { access_token: "old", token_type: "Bearer" }, + })!; + const session = new RemoteSession("auth-state"); + session.setAuthProviderHandle(handle); + session.setAuthState({ + oauthTokens: { access_token: "new", token_type: "Bearer" }, + }); + await expect(handle.provider.tokens()).resolves.toEqual({ + access_token: "new", + token_type: "Bearer", + }); + }); }); diff --git a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts index 4e57718ee..559b29d88 100644 --- a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts @@ -53,10 +53,22 @@ function sseFrame(event: RemoteEvent): string { } interface MockFetchPlan { - connect?: () => Response | Promise; - events?: () => Response | Promise; - send?: () => Response | Promise; - disconnect?: () => Response | Promise; + connect?: ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Response | Promise; + events?: ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Response | Promise; + send?: ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Response | Promise; + disconnect?: ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Response | Promise; } /** @@ -64,24 +76,28 @@ interface MockFetchPlan { * handlers, defaulting to sensible success responses when a handler is omitted. */ function mockFetch(plan: MockFetchPlan): typeof fetch { - const fn = vi.fn(async (input: RequestInfo | URL): Promise => { - const url = typeof input === "string" ? input : input.toString(); - if (url.includes("/api/mcp/connect")) { - return plan.connect - ? plan.connect() - : jsonResponse({ sessionId: "sess-1" }); - } - if (url.includes("/api/mcp/events")) { - return plan.events ? plan.events() : sseResponse([]); - } - if (url.includes("/api/mcp/send")) { - return plan.send ? plan.send() : jsonResponse({ ok: true }); - } - if (url.includes("/api/mcp/disconnect")) { - return plan.disconnect ? plan.disconnect() : jsonResponse({ ok: true }); - } - throw new Error(`unexpected fetch to ${url}`); - }); + const fn = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/mcp/connect")) { + return plan.connect + ? plan.connect(input, init) + : jsonResponse({ sessionId: "sess-1" }); + } + if (url.includes("/api/mcp/events")) { + return plan.events ? plan.events(input, init) : sseResponse([]); + } + if (url.includes("/api/mcp/send")) { + return plan.send ? plan.send(input, init) : jsonResponse({ ok: true }); + } + if (url.includes("/api/mcp/disconnect")) { + return plan.disconnect + ? plan.disconnect(input, init) + : jsonResponse({ ok: true }); + } + throw new Error(`unexpected fetch to ${url}`); + }, + ); return fn as unknown as typeof fetch; } @@ -172,7 +188,9 @@ describe("RemoteClientTransport (focused branch coverage)", () => { ); await t.start(); expect(connectBody).toMatchObject({ - oauthTokens: { access_token: "AT", refresh_token: "RT" }, + authState: { + oauthTokens: { access_token: "AT", refresh_token: "RT" }, + }, }); await t.close(); }); @@ -497,17 +515,38 @@ describe("RemoteClientTransport (focused branch coverage)", () => { it("posts a message including relatedRequestId and succeeds", async () => { let sentBody: { relatedRequestId?: unknown } | undefined; + const encoder = new TextEncoder(); + let sseController: ReadableStreamDefaultController | null = + null; + const pushSseMessage = (message: JSONRPCMessage) => { + const payload = JSON.stringify({ type: "message", data: message }); + sseController?.enqueue(encoder.encode(`data: ${payload}\n\n`)); + }; const fetchFn = vi.fn( async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === "string" ? input : input.toString(); if (url.includes("/connect")) return jsonResponse({ sessionId: "s" }); - if (url.includes("/events")) + if (url.includes("/events")) { return new Response( - new ReadableStream({ start() {} }), + new ReadableStream({ + start(controller) { + sseController = controller; + controller.enqueue(encoder.encode(": keepalive\n\n")); + }, + }), { status: 200 }, ); + } if (url.includes("/send")) { sentBody = JSON.parse(init!.body as string); + const requestId = ( + sentBody as { message: { id?: string | number } } + ).message.id; + pushSseMessage({ + jsonrpc: "2.0", + id: requestId!, + result: {}, + }); return jsonResponse({ ok: true }); } return jsonResponse({ ok: true }); @@ -517,6 +556,7 @@ describe("RemoteClientTransport (focused branch coverage)", () => { { baseUrl: "http://remote.test", fetchFn: fetchFn as unknown as typeof fetch, + sseResponseTimeoutMs: 2000, }, CONFIG, ); @@ -577,6 +617,22 @@ describe("RemoteClientTransport (focused branch coverage)", () => { }); }); + describe("attachToSession", () => { + it("opens SSE for an existing session without POST /connect", async () => { + const connect = vi.fn(() => jsonResponse({ sessionId: "new" })); + const events = vi.fn(() => sseResponse([])); + const t = makeTransport({ connect, events }); + await t.attachToSession("existing-sess"); + expect(connect).not.toHaveBeenCalled(); + expect(events).toHaveBeenCalledWith( + expect.stringContaining("sessionId=existing-sess"), + expect.anything(), + ); + expect(t.getRemoteBackendSessionId()).toBe("existing-sess"); + await t.close(); + }); + }); + describe("sessionId getter", () => { it("always returns undefined (intentional)", () => { const t = makeTransport({}); diff --git a/clients/web/src/test/integration/mcp/remote/transport.test.ts b/clients/web/src/test/integration/mcp/remote/transport.test.ts index 1f8c2d442..35f9ec70e 100644 --- a/clients/web/src/test/integration/mcp/remote/transport.test.ts +++ b/clients/web/src/test/integration/mcp/remote/transport.test.ts @@ -1340,7 +1340,7 @@ describe("Remote transport e2e", () => { expect((await res.json()).error).toMatch(/Failed to create transport:/); }); - it("/api/mcp/send returns 500 when transport is dead", async () => { + it("/api/mcp/send returns transport_error when transport is dead", async () => { mcpHttpServer = createTestServerHttp({ serverInfo: createTestServerInfo(), tools: [createEchoTool()], @@ -1387,9 +1387,11 @@ describe("Remote transport e2e", () => { message: { jsonrpc: "2.0", id: 99, method: "ping" }, }), }); - // Either the dead-transport short-circuit (500) or a downstream send - // failure (500) — either way, the path is covered. - expect(sendRes.status).toBeGreaterThanOrEqual(500); + expect(sendRes.status).toBe(200); + expect(await sendRes.json()).toMatchObject({ + ok: false, + kind: "transport_error", + }); }); it("/api/log accepts non-JSON body silently via the catch fallback", async () => { diff --git a/clients/web/src/test/integration/mcp/test-server-protected-resource.test.ts b/clients/web/src/test/integration/mcp/test-server-protected-resource.test.ts index f9dd93c35..487ae29d1 100644 --- a/clients/web/src/test/integration/mcp/test-server-protected-resource.test.ts +++ b/clients/web/src/test/integration/mcp/test-server-protected-resource.test.ts @@ -7,6 +7,7 @@ import { loadConfig, resolveConfig, ExternalAccessTokenValidator, + extractScopesFromJwtPayload, TestServerHttp, createExternalResourceOAuthTestServerConfig, } from "@modelcontextprotocol/inspector-test-server"; @@ -59,6 +60,19 @@ describe("protected-resource OAuth config", () => { }); describe("ExternalAccessTokenValidator", () => { + it("extracts scopes from JWT payload claims", () => { + expect(extractScopesFromJwtPayload({ scope: "mcp tools:read" })).toEqual([ + "mcp", + "tools:read", + ]); + expect( + extractScopesFromJwtPayload({ scp: ["tools:read", "env:read"] }), + ).toEqual(["tools:read", "env:read"]); + expect(extractScopesFromJwtPayload({ scopes: ["tools:read"] })).toEqual([ + "tools:read", + ]); + }); + it("accepts JWTs signed by the AS and rejects wrong issuer", async () => { const issuer = "https://as.example"; const jwksUri = "https://as.example/jwks"; diff --git a/clients/web/src/utils/browserTabVisibility.test.ts b/clients/web/src/utils/browserTabVisibility.test.ts new file mode 100644 index 000000000..c4c9e98f4 --- /dev/null +++ b/clients/web/src/utils/browserTabVisibility.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + isBrowserTabVisible, + onBrowserTabVisible, +} from "./browserTabVisibility.js"; + +describe("browserTabVisibility", () => { + beforeEach(() => { + vi.stubGlobal("document", { + visibilityState: "visible", + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("isBrowserTabVisible reflects document.visibilityState", () => { + expect(isBrowserTabVisible()).toBe(true); + (document as { visibilityState: string }).visibilityState = "hidden"; + expect(isBrowserTabVisible()).toBe(false); + }); + + it("onBrowserTabVisible invokes callback when becoming visible", () => { + let handler: (() => void) | undefined; + vi.mocked(document.addEventListener).mockImplementation((_event, fn) => { + handler = fn as () => void; + }); + + const callback = vi.fn(); + onBrowserTabVisible(callback); + expect(handler).toBeDefined(); + + (document as { visibilityState: string }).visibilityState = "hidden"; + handler!(); + expect(callback).not.toHaveBeenCalled(); + + (document as { visibilityState: string }).visibilityState = "visible"; + handler!(); + expect(callback).toHaveBeenCalledOnce(); + }); +}); diff --git a/clients/web/src/utils/browserTabVisibility.ts b/clients/web/src/utils/browserTabVisibility.ts new file mode 100644 index 000000000..4e5fae1d9 --- /dev/null +++ b/clients/web/src/utils/browserTabVisibility.ts @@ -0,0 +1,20 @@ +/** True when this browser tab is the foreground tab (Page Visibility API). */ +export function isBrowserTabVisible(): boolean { + return ( + typeof document !== "undefined" && document.visibilityState === "visible" + ); +} + +/** Subscribe to this browser tab becoming visible. Returns an unsubscribe function. */ +export function onBrowserTabVisible(callback: () => void): () => void { + if (typeof document === "undefined") { + return () => {}; + } + const handler = (): void => { + if (document.visibilityState === "visible") { + callback(); + } + }; + document.addEventListener("visibilitychange", handler); + return () => document.removeEventListener("visibilitychange", handler); +} diff --git a/clients/web/src/utils/inspectorTabs.ts b/clients/web/src/utils/inspectorTabs.ts new file mode 100644 index 000000000..0cc65de59 --- /dev/null +++ b/clients/web/src/utils/inspectorTabs.ts @@ -0,0 +1,24 @@ +/** + * Inspector main-view tab identifiers. Match labels used in ViewHeader / + * InspectorView (`"Tools"`, `"Resources"`, …). + */ + +export const INSPECTOR_SERVERS_TAB = "Servers"; + +/** Tabs with liftable `*UiState` in App.tsx (Servers has no ui snapshot). */ +export const INSPECTOR_TAB_IDS = [ + "Apps", + "Tools", + "Prompts", + "Resources", + "Tasks", + "Logs", + "History", + "Network", +] as const; + +export type InspectorTabId = (typeof INSPECTOR_TAB_IDS)[number]; + +export function isInspectorTabId(value: string): value is InspectorTabId { + return (INSPECTOR_TAB_IDS as readonly string[]).includes(value); +} diff --git a/clients/web/src/utils/oauthResume.test.ts b/clients/web/src/utils/oauthResume.test.ts new file mode 100644 index 000000000..2ed83dc9d --- /dev/null +++ b/clients/web/src/utils/oauthResume.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + applyOAuthResumeUi, + buildTabUiSnapshot, + clearOAuthResumeSnapshot, + consumeOAuthResumeSnapshot, + oauthResumeInsufficientScopeMessage, + oauthResumeToastMessage, + OAUTH_PENDING_SERVER_KEY, + readOAuthResumeSnapshot, + restoreTabUiFromSnapshot, + writeOAuthResumeSnapshot, + type OAuthResumeSnapshot, +} from "./oauthResume.js"; +import { + EMPTY_TOOLS_UI, + EMPTY_PROMPTS_UI, + EMPTY_RESOURCES_UI, + EMPTY_APPS_UI, + EMPTY_TASKS_UI, + EMPTY_LOGS_UI, + EMPTY_HISTORY_UI, + EMPTY_NETWORK_UI, +} from "../components/screens/screenUiState.js"; + +describe("oauthResume", () => { + const storage = new Map(); + + beforeEach(() => { + storage.clear(); + vi.stubGlobal("sessionStorage", { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + removeItem: (key: string) => { + storage.delete(key); + }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("consumeOAuthResumeSnapshot reads once then clears storage", () => { + const snapshot: OAuthResumeSnapshot = { + version: 1, + serverId: "srv-1", + activeTab: "Tools", + authKind: "reauth", + tabUi: {}, + }; + writeOAuthResumeSnapshot(snapshot); + expect(consumeOAuthResumeSnapshot()).toEqual(snapshot); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + expect(consumeOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("clearOAuthResumeSnapshot removes pending redirect state (explicit disconnect)", () => { + writeOAuthResumeSnapshot({ + version: 1, + serverId: "srv-1", + activeTab: "Tools", + authKind: "reauth", + tabUi: {}, + }); + clearOAuthResumeSnapshot(); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + expect(consumeOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("round-trips OAuthResumeSnapshot", () => { + const snapshot: OAuthResumeSnapshot = { + version: 1, + serverId: "srv-1", + activeTab: "Tools", + authKind: "step_up", + tabUi: { + Tools: { + ...EMPTY_TOOLS_UI, + selectedToolName: "echo", + formValues: { message: "hi" }, + }, + }, + remoteSessionId: "remote-abc", + }; + writeOAuthResumeSnapshot(snapshot); + expect(readOAuthResumeSnapshot()).toEqual(snapshot); + expect(storage.get(OAUTH_PENDING_SERVER_KEY)).toBeUndefined(); + clearOAuthResumeSnapshot(); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("builds and restores tab ui snapshots", () => { + const toolsUi = { + ...EMPTY_TOOLS_UI, + selectedToolName: "get_temp", + formValues: { city: "NYC" }, + }; + const tabUi = buildTabUiSnapshot({ + toolsUi, + promptsUi: EMPTY_PROMPTS_UI, + resourcesUi: EMPTY_RESOURCES_UI, + appsUi: EMPTY_APPS_UI, + tasksUi: EMPTY_TASKS_UI, + logsUi: EMPTY_LOGS_UI, + historyUi: EMPTY_HISTORY_UI, + networkUi: EMPTY_NETWORK_UI, + }); + const setToolsUi = vi.fn(); + restoreTabUiFromSnapshot(tabUi, { + setToolsUi, + setPromptsUi: vi.fn(), + setResourcesUi: vi.fn(), + setAppsUi: vi.fn(), + setTasksUi: vi.fn(), + setLogsUi: vi.fn(), + setHistoryUi: vi.fn(), + setNetworkUi: vi.fn(), + }); + expect(setToolsUi).toHaveBeenCalledWith(toolsUi); + }); + + it("falls back to legacy pending server key", () => { + storage.set(OAUTH_PENDING_SERVER_KEY, "legacy-srv"); + const snapshot = readOAuthResumeSnapshot(); + expect(snapshot?.serverId).toBe("legacy-srv"); + expect(snapshot?.activeTab).toBe("Servers"); + expect(snapshot?.authKind).toBe("reauth"); + }); + + it("returns toast copy by auth kind", () => { + expect(oauthResumeToastMessage("step_up", { recoverySource: "tool" })).toBe( + "Step-up authorization succeeded. Retry your action.", + ); + expect(oauthResumeToastMessage("step_up")).toBe( + "Step-up authorization succeeded.", + ); + expect(oauthResumeToastMessage("reauth", { recoverySource: "tool" })).toBe( + "Authentication succeeded. Retry your action.", + ); + expect(oauthResumeToastMessage("reauth")).toBe("Authentication succeeded."); + }); + + it("returns insufficient-scope message with tool context", () => { + expect( + oauthResumeInsufficientScopeMessage({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + context: { toolName: "get_temp" }, + }), + ).toMatch(/get_temp/); + }); + + it("round-trips authChallenge on step-up snapshot", () => { + const challenge = { + reason: "insufficient_scope" as const, + requiredScopes: ["weather:read"], + context: { toolName: "get_temp" }, + }; + const snapshot: OAuthResumeSnapshot = { + version: 1, + serverId: "srv-1", + activeTab: "Tools", + authKind: "step_up", + tabUi: {}, + authChallenge: challenge, + }; + writeOAuthResumeSnapshot(snapshot); + expect(readOAuthResumeSnapshot()?.authChallenge).toEqual(challenge); + }); + + it("rejects snapshots with invalid tabUi keys", () => { + writeOAuthResumeSnapshot({ + version: 1, + serverId: "srv-1", + activeTab: "Tools", + authKind: "reauth", + tabUi: { NotATab: {} }, + } as OAuthResumeSnapshot); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("applyOAuthResumeUi restores tab ui, active tab, and clears in-flight panels", () => { + const toolsUi = { + ...EMPTY_TOOLS_UI, + selectedToolName: "get_temp", + formValues: { city: "NYC" }, + }; + const snapshot: OAuthResumeSnapshot = { + version: 1, + serverId: "srv-1", + activeTab: "Tools", + authKind: "step_up", + tabUi: { Tools: toolsUi }, + }; + const setToolsUi = vi.fn(); + const setActiveTab = vi.fn(); + const clearToolCallState = vi.fn(); + const clearGetPromptState = vi.fn(); + const clearReadResourceState = vi.fn(); + + applyOAuthResumeUi(snapshot, { + setToolsUi, + setPromptsUi: vi.fn(), + setResourcesUi: vi.fn(), + setAppsUi: vi.fn(), + setTasksUi: vi.fn(), + setLogsUi: vi.fn(), + setHistoryUi: vi.fn(), + setNetworkUi: vi.fn(), + setActiveTab, + clearToolCallState, + clearGetPromptState, + clearReadResourceState, + }); + + expect(setToolsUi).toHaveBeenCalledWith(toolsUi); + expect(setActiveTab).toHaveBeenCalledWith("Tools"); + expect(clearToolCallState).toHaveBeenCalledOnce(); + expect(clearGetPromptState).toHaveBeenCalledOnce(); + expect(clearReadResourceState).toHaveBeenCalledOnce(); + }); +}); diff --git a/clients/web/src/utils/oauthResume.ts b/clients/web/src/utils/oauthResume.ts new file mode 100644 index 000000000..81167ffad --- /dev/null +++ b/clients/web/src/utils/oauthResume.ts @@ -0,0 +1,278 @@ +/** + * Persist inspector shell state across full-page OAuth redirects. + * Serializes only liftable `*UiState` shells — not message logs, fetch bodies, + * tool results, or managed primitive lists. + */ + +import { + EMPTY_APPS_UI, + EMPTY_HISTORY_UI, + EMPTY_LOGS_UI, + EMPTY_NETWORK_UI, + EMPTY_PROMPTS_UI, + EMPTY_RESOURCES_UI, + EMPTY_TASKS_UI, + EMPTY_TOOLS_UI, +} from "../components/screens/screenUiState.js"; +import type { AppsUiState } from "../components/screens/AppsScreen/AppsScreen.js"; +import type { HistoryUiState } from "../components/screens/HistoryScreen/HistoryScreen.js"; +import type { LogsUiState } from "../components/screens/LoggingScreen/LoggingScreen.js"; +import type { NetworkUiState } from "../components/screens/NetworkScreen/NetworkScreen.js"; +import type { PromptsUiState } from "../components/screens/PromptsScreen/PromptsScreen.js"; +import type { ResourcesUiState } from "../components/screens/ResourcesScreen/ResourcesScreen.js"; +import type { TasksUiState } from "../components/screens/TasksScreen/TasksScreen.js"; +import type { ToolsUiState } from "../components/screens/ToolsScreen/ToolsScreen.js"; +import { + INSPECTOR_SERVERS_TAB, + type InspectorTabId, + isInspectorTabId, +} from "./inspectorTabs.js"; +import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; +import { + oauthResumeSuccessMessage, + stepUpInsufficientScopeMessage, + type OAuthRecoverySource, +} from "@inspector/core/auth/oauthUx.js"; +import { OAUTH_PENDING_SERVER_KEY } from "./oauthFlow.js"; + +export const OAUTH_RESUME_KEY = "mcp-inspector:oauth-resume"; + +export { OAUTH_PENDING_SERVER_KEY }; + +export type OAuthResumeAuthKind = "step_up" | "reauth"; + +export interface OAuthResumeSnapshot { + version: 1; + serverId: string; + activeTab: string; + authKind: OAuthResumeAuthKind; + /** + * Per-tab lifted UI state (`*UiState` only). Keys are {@link InspectorTabId}. + */ + tabUi: Partial>; + /** Hono remote session id for auth-state push after callback. */ + remoteSessionId?: string; + /** Step-up challenge at redirect time; used to verify scope satisfaction after callback. */ + authChallenge?: AuthChallenge; + /** Command-scoped recovery source when redirect was triggered by a user action. */ + recoverySource?: OAuthRecoverySource; +} + +export interface LiftedTabUiState { + toolsUi: ToolsUiState; + promptsUi: PromptsUiState; + resourcesUi: ResourcesUiState; + appsUi: AppsUiState; + tasksUi: TasksUiState; + logsUi: LogsUiState; + historyUi: HistoryUiState; + networkUi: NetworkUiState; +} + +export interface TabUiSetters { + setToolsUi: (next: ToolsUiState) => void; + setPromptsUi: (next: PromptsUiState) => void; + setResourcesUi: (next: ResourcesUiState) => void; + setAppsUi: (next: AppsUiState) => void; + setTasksUi: (next: TasksUiState) => void; + setLogsUi: (next: LogsUiState) => void; + setHistoryUi: (next: HistoryUiState) => void; + setNetworkUi: (next: NetworkUiState) => void; +} + +export function buildTabUiSnapshot( + state: LiftedTabUiState, +): Partial> { + return { + Apps: state.appsUi, + Tools: state.toolsUi, + Prompts: state.promptsUi, + Resources: state.resourcesUi, + Tasks: state.tasksUi, + Logs: state.logsUi, + History: state.historyUi, + Network: state.networkUi, + }; +} + +export function restoreTabUiFromSnapshot( + tabUi: Partial> | undefined, + setters: TabUiSetters, +): void { + if (!tabUi) { + return; + } + for (const tabId of Object.keys(tabUi) as InspectorTabId[]) { + if (!isInspectorTabId(tabId)) { + continue; + } + const value = tabUi[tabId]; + switch (tabId) { + case "Tools": + setters.setToolsUi( + (value as ToolsUiState | undefined) ?? EMPTY_TOOLS_UI, + ); + break; + case "Prompts": + setters.setPromptsUi( + (value as PromptsUiState | undefined) ?? EMPTY_PROMPTS_UI, + ); + break; + case "Resources": + setters.setResourcesUi( + (value as ResourcesUiState | undefined) ?? EMPTY_RESOURCES_UI, + ); + break; + case "Apps": + setters.setAppsUi((value as AppsUiState | undefined) ?? EMPTY_APPS_UI); + break; + case "Tasks": + setters.setTasksUi( + (value as TasksUiState | undefined) ?? EMPTY_TASKS_UI, + ); + break; + case "Logs": + setters.setLogsUi((value as LogsUiState | undefined) ?? EMPTY_LOGS_UI); + break; + case "History": + setters.setHistoryUi( + (value as HistoryUiState | undefined) ?? EMPTY_HISTORY_UI, + ); + break; + case "Network": + setters.setNetworkUi( + (value as NetworkUiState | undefined) ?? EMPTY_NETWORK_UI, + ); + break; + default: { + const _exhaustive: never = tabId; + void _exhaustive; + } + } + } +} + +export function writeOAuthResumeSnapshot(snapshot: OAuthResumeSnapshot): void { + if (typeof window === "undefined") { + return; + } + try { + window.sessionStorage.setItem(OAUTH_RESUME_KEY, JSON.stringify(snapshot)); + } catch { + // Best-effort — privacy mode / quota. + } +} + +export function readOAuthResumeSnapshot(): OAuthResumeSnapshot | undefined { + if (typeof window === "undefined") { + return undefined; + } + try { + const raw = window.sessionStorage.getItem(OAUTH_RESUME_KEY); + if (!raw) { + return readLegacyPendingServerSnapshot(); + } + const parsed = JSON.parse(raw) as OAuthResumeSnapshot; + if ( + parsed?.version !== 1 || + typeof parsed.serverId !== "string" || + !isOAuthResumeAuthKind(parsed.authKind) || + typeof parsed.activeTab !== "string" || + !isValidTabUiSnapshot(parsed.tabUi) + ) { + return undefined; + } + return parsed; + } catch { + return undefined; + } +} + +/** Read the pending snapshot and remove it from storage (one-shot). */ +export function consumeOAuthResumeSnapshot(): OAuthResumeSnapshot | undefined { + const snapshot = readOAuthResumeSnapshot(); + if (snapshot) { + clearOAuthResumeSnapshot(); + } + return snapshot; +} + +function readLegacyPendingServerSnapshot(): OAuthResumeSnapshot | undefined { + try { + const serverId = window.sessionStorage.getItem(OAUTH_PENDING_SERVER_KEY); + if (!serverId) { + return undefined; + } + return { + version: 1, + serverId, + activeTab: INSPECTOR_SERVERS_TAB, + authKind: "reauth", + tabUi: {}, + }; + } catch { + return undefined; + } +} + +export function clearOAuthResumeSnapshot(): void { + if (typeof window === "undefined") { + return; + } + try { + window.sessionStorage.removeItem(OAUTH_RESUME_KEY); + window.sessionStorage.removeItem(OAUTH_PENDING_SERVER_KEY); + } catch { + // ignore + } +} + +export function oauthResumeToastMessage( + authKind: OAuthResumeAuthKind, + options?: { recoverySource?: OAuthRecoverySource }, +): string { + return oauthResumeSuccessMessage(authKind, options); +} + +/** Post-callback copy when step-up OAuth completed but scopes still do not satisfy the challenge. */ +export function oauthResumeInsufficientScopeMessage( + challenge: AuthChallenge, +): string { + return stepUpInsufficientScopeMessage(challenge); +} + +function isOAuthResumeAuthKind(value: unknown): value is OAuthResumeAuthKind { + return value === "step_up" || value === "reauth"; +} + +function isValidTabUiSnapshot( + tabUi: unknown, +): tabUi is Partial> { + if (tabUi === undefined) { + return true; + } + if (typeof tabUi !== "object" || tabUi === null || Array.isArray(tabUi)) { + return false; + } + return Object.keys(tabUi).every((key) => isInspectorTabId(key)); +} + +/** Setters used when restoring App shell state after `/oauth/callback`. */ +export interface OAuthResumeUiSetters extends TabUiSetters { + setActiveTab: (tab: string) => void; + clearToolCallState: () => void; + clearGetPromptState: () => void; + clearReadResourceState: () => void; +} + +/** Restore tab selection, per-tab UI, and clear in-flight result panels. One-shot: callers must not invoke twice for the same redirect. */ +export function applyOAuthResumeUi( + snapshot: OAuthResumeSnapshot, + setters: OAuthResumeUiSetters, +): void { + restoreTabUiFromSnapshot(snapshot.tabUi, setters); + setters.setActiveTab(snapshot.activeTab); + setters.clearToolCallState(); + setters.clearGetPromptState(); + setters.clearReadResourceState(); +} diff --git a/clients/web/src/utils/oauthUx.test.ts b/clients/web/src/utils/oauthUx.test.ts new file mode 100644 index 000000000..e6e6f1365 --- /dev/null +++ b/clients/web/src/utils/oauthUx.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { + authRecoveryRestoredMessage, + isReAuthBannerReason, + oauthPreRedirectToastCopy, + oauthResumeSuccessMessage, + reAuthBannerMessage, +} from "./oauthUx.js"; + +describe("oauthUx", () => { + it("returns pre-redirect toast for standard OAuth reauth", () => { + expect(oauthPreRedirectToastCopy("reauth", { serverName: "srv" })).toEqual({ + title: 'Session expired for "srv"', + message: "Session expired, re-authenticating…", + }); + }); + + it("returns no pre-redirect toast for connect-time OAuth", () => { + expect( + oauthPreRedirectToastCopy("reauth", { + serverName: "srv", + context: "connect", + }), + ).toBeUndefined(); + expect( + oauthPreRedirectToastCopy("reauth", { + serverName: "srv", + enterpriseManaged: true, + context: "connect", + }), + ).toBeUndefined(); + }); + + it("returns pre-redirect toast for EMA reauth", () => { + expect( + oauthPreRedirectToastCopy("reauth", { + serverName: "srv", + enterpriseManaged: true, + }), + ).toEqual({ + title: 'Re-authenticating "srv"', + message: "Re-authenticating…", + }); + }); + + it("returns pre-redirect toast for step-up", () => { + expect(oauthPreRedirectToastCopy("step_up", { serverName: "srv" })).toEqual( + { + title: 'Step-up authorization for "srv"', + message: "Redirecting to authorize additional permissions…", + }, + ); + }); + + it("identifies re-auth banner challenge reasons", () => { + expect(isReAuthBannerReason("unauthorized")).toBe(true); + expect(isReAuthBannerReason("token_expired")).toBe(true); + expect(isReAuthBannerReason("invalid_token")).toBe(true); + expect(isReAuthBannerReason("insufficient_scope")).toBe(false); + }); + + it("builds re-auth banner message", () => { + expect( + reAuthBannerMessage({ serverName: "demo", detail: "Sign in again." }), + ).toBe('Authentication for "demo" needs attention. Sign in again.'); + }); + + it("returns success toast copy only for action-triggered recovery", () => { + expect( + oauthResumeSuccessMessage("reauth", { recoverySource: "tool" }), + ).toMatch(/Retry your action/); + expect(oauthResumeSuccessMessage("reauth")).not.toMatch( + /Retry your action/, + ); + expect(authRecoveryRestoredMessage({ recoverySource: "prompt" })).toMatch( + /Retry your action/, + ); + expect(authRecoveryRestoredMessage()).not.toMatch(/Retry your action/); + }); +}); diff --git a/clients/web/src/utils/oauthUx.ts b/clients/web/src/utils/oauthUx.ts new file mode 100644 index 000000000..4485c48a4 --- /dev/null +++ b/clients/web/src/utils/oauthUx.ts @@ -0,0 +1,12 @@ +export { + authRecoveryRestoredMessage, + isActionTriggeredOAuthRecovery, + oauthPreRedirectToastCopy, + oauthResumeAbandonedMessage, + oauthResumeSuccessMessage, + isReAuthBannerReason, + reAuthBannerMessage, + type OAuthInteractiveAuthKind, + type OAuthPreRedirectContext, + type OAuthRecoverySource, +} from "@inspector/core/auth/oauthUx.js"; diff --git a/clients/web/src/utils/pendingReauth.test.ts b/clients/web/src/utils/pendingReauth.test.ts new file mode 100644 index 000000000..94eadcb5c --- /dev/null +++ b/clients/web/src/utils/pendingReauth.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from "vitest"; +import type { PendingReauth } from "./pendingReauth.js"; + +describe("PendingReauth", () => { + it("carries step-up source through deferred resume", () => { + const pending: PendingReauth = { + serverId: "srv-1", + challenge: { + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }, + authorizationUrl: new URL("https://as.example/authorize"), + authKind: "step_up", + source: "tool", + }; + expect(pending.source).toBe("tool"); + }); +}); diff --git a/clients/web/src/utils/pendingReauth.ts b/clients/web/src/utils/pendingReauth.ts new file mode 100644 index 000000000..8e839c2f9 --- /dev/null +++ b/clients/web/src/utils/pendingReauth.ts @@ -0,0 +1,20 @@ +import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; +import type { OAuthRecoverySource } from "@inspector/core/auth/oauthUx.js"; +import type { OAuthResumeAuthKind } from "./oauthResume.js"; + +/** Origin of a deferred or resumed auth recovery flow (matches web `StepUpSource`). */ +export type PendingReauthSource = OAuthRecoverySource; + +/** Deferred ambient interactive recovery for a background browser tab. */ +export interface PendingReauth { + serverId: string; + challenge: AuthChallenge; + authorizationUrl: URL; + authKind: OAuthResumeAuthKind; + source: PendingReauthSource; +} + +/** + * In-memory only — survives tab visibility changes but not a full page reload. + * OAuth resume snapshot is written only once interactive redirect starts. + */ diff --git a/core/auth/challenge.ts b/core/auth/challenge.ts new file mode 100644 index 000000000..631e61601 --- /dev/null +++ b/core/auth/challenge.ts @@ -0,0 +1,331 @@ +import { isUnauthorizedError } from "./utils.js"; + +/** Why authorization failed for this MCP interaction. */ +export type AuthChallengeReason = + | "unauthorized" + | "token_expired" + | "insufficient_scope" + | "invalid_token"; + +/** Normalized challenge for handleAuthChallenge(). */ +export interface AuthChallenge { + reason: AuthChallengeReason; + + /** Scopes from the current challenge (step-up). */ + 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; + + /** Optional UX hints when known (not used for ambient RPC replay). */ + context?: { + method?: string; + toolName?: string; + }; + + /** Opaque raw hints for logging and forward-compatible parsers. */ + raw?: { + httpStatus?: number; + wwwAuthenticate?: string; + }; +} + +export type AuthChallengeOutcome = + | { kind: "satisfied" } + | { kind: "interactive"; authorizationUrl: URL; challenge: AuthChallenge } + | { kind: "step_up_confirm"; challenge: AuthChallenge } + | { kind: "failed"; error: Error }; + +/** Placeholder URL when EMA step-up awaits in-app confirmation (no redirect yet). */ +export const EMA_STEP_UP_PENDING_URL = new URL("mcp-inspector:ema-step-up"); + +export interface HandleAuthChallengeOptions { + /** User confirmed step-up in Inspector UI — run silent EMA re-mint / IdP redirect. */ + confirmedStepUp?: boolean; +} + +export interface ParseAuthChallengeContext { + method?: string; + toolName?: string; +} + +export class AuthChallengeError extends Error { + readonly authChallenge: AuthChallenge; + readonly status: number; + + constructor( + authChallenge: AuthChallenge, + status: number, + message?: string, + ) { + super(message ?? `Auth challenge: ${authChallenge.reason}`); + this.name = "AuthChallengeError"; + this.authChallenge = authChallenge; + this.status = status; + } +} + +/** Thrown when interactive auth recovery was started and the caller should wait for callback. */ +export class AuthRecoveryRequiredError extends Error { + readonly authorizationUrl: URL; + readonly authChallenge: AuthChallenge; + /** EMA insufficient_scope awaiting user confirmation in Inspector (no redirect yet). */ + readonly emaStepUpConfirm?: boolean; + + constructor( + authorizationUrl: URL, + authChallenge: AuthChallenge, + options?: { emaStepUpConfirm?: boolean }, + ) { + super("Interactive auth recovery required"); + this.name = "AuthRecoveryRequiredError"; + this.authorizationUrl = authorizationUrl; + this.authChallenge = authChallenge; + this.emaStepUpConfirm = options?.emaStepUpConfirm; + } +} + +/** + * Connect-time failures the app can recover via OAuth redirect. These are not + * terminal connection errors — the UI should stay on "connecting" until the + * redirect or an explicit recovery failure. + */ +export function isConnectAuthRecoveryError(err: unknown): boolean { + if (err instanceof AuthRecoveryRequiredError) return true; + return isUnauthorizedError(err); +} + +export interface WwwAuthenticateBearerParams { + error?: string; + scope?: string; + resourceMetadata?: string; + errorDescription?: string; +} + +/** Parse the first `WWW-Authenticate: Bearer …` challenge (RFC 6750). */ +export function parseWwwAuthenticateBearer( + header: string, +): WwwAuthenticateBearerParams { + const match = header.match(/Bearer\s+(.+)/i); + if (!match) { + return {}; + } + + const params: Record = {}; + const paramRegex = /([\w-]+)(?:="([^"]*)"|=([^\s,]+))?/g; + let m: RegExpExecArray | null; + while ((m = paramRegex.exec(match[1])) !== null) { + const value = m[2] ?? m[3]; + if (value !== undefined) { + params[m[1].toLowerCase()] = value; + } + } + + return { + error: params.error, + scope: params.scope, + resourceMetadata: params.resource_metadata, + errorDescription: params.error_description, + }; +} + +/** Split an OAuth scope string into individual scopes (space-separated). */ +export function parseScopeString(scope: string | undefined): string[] { + if (!scope?.trim()) { + return []; + } + return scope.trim().split(/\s+/).filter(Boolean); +} + +/** + * SEP-2350: union of previously requested scopes and scopes from the current challenge. + * Preserves order: previous scopes first, then any new required scopes. + */ +export function unionAuthorizationScopes( + previousScope: string | undefined, + requiredScopes: string[], +): string[] { + const seen = new Set(); + const result: string[] = []; + + for (const scope of [ + ...parseScopeString(previousScope), + ...requiredScopes.filter(Boolean), + ]) { + if (!seen.has(scope)) { + seen.add(scope); + result.push(scope); + } + } + + return result; +} + +function reasonFromHttpResponse( + status: number, + bearer: WwwAuthenticateBearerParams, +): AuthChallengeReason { + if (status === 403) { + if (bearer.error === "insufficient_scope") { + return "insufficient_scope"; + } + return "unauthorized"; + } + + if (bearer.error === "insufficient_scope") { + return "insufficient_scope"; + } + + if (bearer.error === "invalid_token") { + return "invalid_token"; + } + + // Bare 401 without a Bearer error code — treat as expired token for silent + // refresh / reauth UX (connect-time 401 uses isUnauthorizedError separately). + if (status === 401) { + return "token_expired"; + } + + return "unauthorized"; +} + +/** + * Build an AuthChallenge from an MCP HTTP response (401 / 403). + * Returns undefined when the response is not an auth challenge. + */ +export function parseAuthChallengeFromResponse( + response: Response, + context?: ParseAuthChallengeContext, +): AuthChallenge | undefined { + const status = response.status; + if (status !== 401 && status !== 403) { + return undefined; + } + + const wwwAuthenticate = response.headers.get("WWW-Authenticate") ?? undefined; + const bearer = wwwAuthenticate + ? parseWwwAuthenticateBearer(wwwAuthenticate) + : {}; + const requiredScopes = parseScopeString(bearer.scope); + + return { + reason: reasonFromHttpResponse(status, bearer), + ...(requiredScopes.length > 0 ? { requiredScopes } : {}), + ...(bearer.errorDescription ? { message: bearer.errorDescription } : {}), + ...(context ? { context } : {}), + raw: { + httpStatus: status, + ...(wwwAuthenticate ? { wwwAuthenticate } : {}), + }, + }; +} + +/** Best-effort challenge extraction from SDK / transport errors. */ +export function parseAuthChallengeFromError( + err: unknown, + context?: ParseAuthChallengeContext, +): AuthChallenge | undefined { + if (err instanceof AuthChallengeError) { + return err.authChallenge; + } + + if (typeof err !== "object" || err === null) { + return undefined; + } + + const authChallenge = (err as { authChallenge?: AuthChallenge }) + .authChallenge; + if (authChallenge?.reason) { + return { + ...authChallenge, + ...(context + ? { + context: { + ...authChallenge.context, + ...context, + }, + } + : {}), + }; + } + + const status = + (err as { status?: number }).status ?? + (err as { code?: number }).code; + if (status !== 401 && status !== 403) { + return undefined; + } + + const wwwAuthenticate = + authChallenge?.raw?.wwwAuthenticate ?? + (err as { wwwAuthenticate?: string }).wwwAuthenticate ?? + (err as { headers?: { get?: (name: string) => string | null } }).headers + ?.get?.("WWW-Authenticate") ?? + undefined; + + if (!wwwAuthenticate?.length) { + return undefined; + } + + const bearer = parseWwwAuthenticateBearer(wwwAuthenticate); + const requiredScopes = parseScopeString(bearer.scope); + + return { + reason: reasonFromHttpResponse(status, bearer), + ...(requiredScopes.length > 0 ? { requiredScopes } : {}), + ...(context ? { context } : {}), + raw: { + httpStatus: status, + wwwAuthenticate, + }, + }; +} + +/** + * True for mid-session auth failures (HTTP 401 or 403 on MCP traffic). + * Connect-time 401 detection remains {@link isUnauthorizedError} in utils.ts. + * + * Bare HTTP status codes alone are not treated as auth challenges — require + * {@link AuthChallengeError}, an embedded `authChallenge`, or `WWW-Authenticate`. + */ +export function isAuthChallengeError(err: unknown): boolean { + if (err instanceof AuthChallengeError) { + return true; + } + + if (typeof err !== "object" || err === null) { + return false; + } + + const authChallenge = (err as { authChallenge?: AuthChallenge }).authChallenge; + if (authChallenge?.reason) { + return true; + } + + const status = + (err as { status?: number }).status ?? (err as { code?: number }).code; + if (status !== 401 && status !== 403) { + return false; + } + + const wwwAuthenticate = + authChallenge?.raw?.wwwAuthenticate ?? + (err as { wwwAuthenticate?: string }).wwwAuthenticate ?? + (err as { headers?: { get?: (name: string) => string | null } }).headers + ?.get?.("WWW-Authenticate") ?? + undefined; + + return wwwAuthenticate !== undefined && wwwAuthenticate.length > 0; +} diff --git a/core/auth/connection-state.ts b/core/auth/connection-state.ts index 3ae4466b0..cf5176224 100644 --- a/core/auth/connection-state.ts +++ b/core/auth/connection-state.ts @@ -19,7 +19,10 @@ export interface BuildOAuthConnectionStateParams { flowState?: OAuthFlowState; } -function isAccessTokenUsable(tokens: OAuthTokens | undefined): boolean { +/** True when persisted tokens include a non-expired access token (JWT exp when parseable). */ +export function isAccessTokenUsable( + tokens: OAuthTokens | undefined, +): boolean { if (!tokens?.access_token) return false; return !isJwtExpired(tokens.access_token); } diff --git a/core/auth/index.ts b/core/auth/index.ts index 7d159c9b7..e9e2e80d5 100644 --- a/core/auth/index.ts +++ b/core/auth/index.ts @@ -24,6 +24,12 @@ export type { BuildOAuthConnectionStateParams } from "./connection-state.js"; export { ensureCimdClientRegistration } from "./cimd.js"; +export { mcpAuth, type McpAuthOptions, type McpAuthResult } from "./mcpAuth.js"; +export { + computeScopeUnion, + isStrictScopeSuperset, +} from "./scopes.js"; + // Storage export type { OAuthStorage, IdpSessionState, SaveClientInformationOptions } from "./storage.js"; export { getServerSpecificKey, OAUTH_STORAGE_KEYS } from "./storage.js"; @@ -49,9 +55,50 @@ export { generateOAuthState, parseOAuthState, generateOAuthErrorDescription, + formatOAuthFailureDetail, isUnauthorizedError, } from "./utils.js"; +export type { + AuthChallenge, + AuthChallengeReason, + AuthChallengeOutcome, + HandleAuthChallengeOptions, + ParseAuthChallengeContext, + WwwAuthenticateBearerParams, +} from "./challenge.js"; +export { + AuthChallengeError, + AuthRecoveryRequiredError, + parseAuthChallengeFromError, + parseAuthChallengeFromResponse, + parseScopeString, + parseWwwAuthenticateBearer, + unionAuthorizationScopes, + isAuthChallengeError, + isConnectAuthRecoveryError, + EMA_STEP_UP_PENDING_URL, +} from "./challenge.js"; + +export { + isStandardOAuthStepUp, + isEmaStepUp, + isStepUpConfirmation, + stepUpConfirmMessage, + stepUpFollowUpMessage, + stepUpModalTitle, + stepUpAuthorizeActionLabel, + emaStepUpInProgressMessage, + emaStepUpSuccessMessage, + emaStepUpFailureMessage, + stepUpAdditionalScopes, + stepUpInsufficientScopeMessage, + oauthPreRedirectToastCopy, + isReAuthBannerReason, + reAuthBannerMessage, + type OAuthInteractiveAuthKind, +} from "./oauthUx.js"; + // Discovery export { discoverScopes } from "./discovery.js"; diff --git a/core/auth/mcpAuth.ts b/core/auth/mcpAuth.ts new file mode 100644 index 000000000..9e52a7695 --- /dev/null +++ b/core/auth/mcpAuth.ts @@ -0,0 +1,156 @@ +/** + * v2-shaped OAuth orchestrator for standard MCP resource authorization. + * + * **Upgrade path:** On `@modelcontextprotocol/client` v2, replace this module + * with `export { auth as mcpAuth, type AuthOptions as McpAuthOptions } from + * "@modelcontextprotocol/client"` and delete `authorizeWithoutRefresh`. + * + * Until then, `mcpAuth` delegates to SDK v1 `auth()` except when + * `forceReauthorization: true` — then it runs discovery + `startAuthorization` + * (the same path v2 `auth()` takes when refresh cannot widen scope). + */ + +import { + auth as sdkAuth, + discoverOAuthServerInfo, + isHttpsUrl, + registerClient, + selectResourceURL, + startAuthorization, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { InvalidClientMetadataError } from "@modelcontextprotocol/sdk/server/auth/errors.js"; + +export type McpAuthResult = "AUTHORIZED" | "REDIRECT"; + +/** + * Options aligned with v2 SDK `AuthOptions`. v2-only fields are accepted now + * and forwarded on upgrade; v1 passthrough ignores unsupported fields. + */ +export interface McpAuthOptions { + serverUrl: string | URL; + authorizationCode?: string; + /** RFC 9207 callback `iss` — forwarded on v2 upgrade; ignored by v1 SDK `auth()`. */ + iss?: string; + scope?: string; + resourceMetadataUrl?: URL; + fetchFn?: FetchLike; + /** v2 SEP-2468 — ignored until SDK v2 upgrade. */ + skipIssuerMetadataValidation?: boolean; + /** + * Skip refresh and start an authorization-code flow. Required for step-up + * when the union scope exceeds the current token grant (RFC 6749 §6). + */ + forceReauthorization?: boolean; +} + +export async function mcpAuth( + provider: OAuthClientProvider, + options: McpAuthOptions, +): Promise { + if (options.forceReauthorization) { + if (options.authorizationCode !== undefined) { + throw new Error( + "forceReauthorization cannot be combined with authorizationCode", + ); + } + return authorizeWithoutRefresh(provider, options); + } + + return sdkAuth(provider, { + serverUrl: options.serverUrl, + authorizationCode: options.authorizationCode, + scope: options.scope, + resourceMetadataUrl: options.resourceMetadataUrl, + fetchFn: options.fetchFn, + }); +} + +async function authorizeWithoutRefresh( + provider: OAuthClientProvider, + options: McpAuthOptions, +): Promise<"REDIRECT"> { + const { serverUrl, scope, resourceMetadataUrl, fetchFn } = options; + + const serverInfo = await discoverOAuthServerInfo(serverUrl, { + resourceMetadataUrl, + fetchFn, + }); + const { + authorizationServerUrl, + authorizationServerMetadata: metadata, + resourceMetadata, + } = serverInfo; + + await provider.saveDiscoveryState?.({ + authorizationServerUrl: String(authorizationServerUrl), + resourceMetadataUrl: resourceMetadataUrl?.toString(), + resourceMetadata, + authorizationServerMetadata: metadata, + }); + + const resource = await selectResourceURL( + serverUrl, + provider, + resourceMetadata, + ); + + const resolvedScope = + scope || + resourceMetadata?.scopes_supported?.join(" ") || + provider.clientMetadata.scope; + + let clientInformation = await Promise.resolve(provider.clientInformation()); + if (!clientInformation) { + const supportsUrlBasedClientId = + metadata?.client_id_metadata_document_supported === true; + const clientMetadataUrl = provider.clientMetadataUrl; + if (clientMetadataUrl && !isHttpsUrl(clientMetadataUrl)) { + throw new InvalidClientMetadataError( + `clientMetadataUrl must be a valid HTTPS URL with a non-root pathname, got: ${clientMetadataUrl}`, + ); + } + const shouldUseUrlBasedClientId = + supportsUrlBasedClientId && clientMetadataUrl; + if (shouldUseUrlBasedClientId) { + clientInformation = { client_id: clientMetadataUrl }; + await provider.saveClientInformation?.(clientInformation); + } else { + if (!provider.saveClientInformation) { + throw new Error( + "OAuth client information must be saveable for dynamic registration", + ); + } + const fullInformation = await registerClient(authorizationServerUrl, { + metadata, + clientMetadata: provider.clientMetadata, + scope: resolvedScope, + fetchFn, + }); + await provider.saveClientInformation(fullInformation); + clientInformation = fullInformation; + } + } + + const state = provider.state ? await provider.state() : undefined; + const redirectUrl = provider.redirectUrl; + if (!redirectUrl) { + throw new Error("redirectUrl is required for authorization_code flow"); + } + const { authorizationUrl, codeVerifier } = await startAuthorization( + authorizationServerUrl, + { + metadata, + clientInformation, + state, + redirectUrl, + scope: resolvedScope, + resource, + }, + ); + + await provider.saveCodeVerifier(codeVerifier); + await provider.redirectToAuthorization(authorizationUrl); + return "REDIRECT"; +} diff --git a/core/auth/node/index.ts b/core/auth/node/index.ts index 1075a3a3a..b4fdab894 100644 --- a/core/auth/node/index.ts +++ b/core/auth/node/index.ts @@ -23,3 +23,12 @@ export { parseRunnerOAuthCallbackUrl, } from "./runner-oauth-callback.js"; export type { RunnerOAuthCallbackConfig } from "./runner-oauth-callback.js"; +export { + runRunnerInteractiveOAuth, +} from "./runner-interactive-oauth.js"; +export type { + RunRunnerInteractiveOAuthOptions, + RunnerInteractiveOAuthClient, + RunnerInteractiveOAuthRedirectProvider, + RunnerInteractiveOAuthResult, +} from "./runner-interactive-oauth.js"; diff --git a/core/auth/node/runner-interactive-oauth.ts b/core/auth/node/runner-interactive-oauth.ts new file mode 100644 index 000000000..ab6a9aad0 --- /dev/null +++ b/core/auth/node/runner-interactive-oauth.ts @@ -0,0 +1,136 @@ +import type { AuthChallenge } from "../challenge.js"; +import { + createOAuthCallbackServer, + type OAuthCallbackServer, +} from "./oauth-callback-server.js"; +import type { RunnerOAuthCallbackConfig } from "./runner-oauth-callback.js"; + +/** Minimal InspectorClient surface for runner interactive OAuth. */ +export interface RunnerInteractiveOAuthClient { + authenticate(): Promise; + beginInteractiveAuthorization(authorizationUrl: URL): Promise; + completeOAuthFlow(authorizationCode: string): Promise; + checkAuthChallengeSatisfied(challenge: AuthChallenge): Promise; +} + +export interface RunnerInteractiveOAuthRedirectProvider { + redirectUrl: string; +} + +export type RunnerInteractiveOAuthResult = + | { kind: "success" } + | { kind: "already_authorized" } + | { kind: "insufficient_scope"; challenge: AuthChallenge }; + +/** Default wait for loopback OAuth callback (15 minutes). */ +export const DEFAULT_RUNNER_INTERACTIVE_OAUTH_TIMEOUT_MS = 15 * 60 * 1000; + +export interface RunRunnerInteractiveOAuthOptions { + client: RunnerInteractiveOAuthClient; + redirectUrlProvider: RunnerInteractiveOAuthRedirectProvider; + callbackListen: RunnerOAuthCallbackConfig; + /** When set, use deferred interactive authorization (mid-session step-up / re-login). */ + authorizationUrl?: URL; + /** When set, verify scopes after a successful token exchange (SEP-2350 step-up). */ + authChallenge?: AuthChallenge; + createCallbackServer?: () => OAuthCallbackServer; + /** Invoked after the listener binds; hosts may keep a ref for unmount cleanup. */ + onCallbackServer?: (server: OAuthCallbackServer) => void; + /** Max wait for browser callback; defaults to {@link DEFAULT_RUNNER_INTERACTIVE_OAUTH_TIMEOUT_MS}. */ + callbackTimeoutMs?: number; +} + +/** + * Run interactive OAuth for Node runners (TUI / CLI): loopback callback server, + * browser redirect, authorization-code exchange via {@link completeOAuthFlow}. + */ +export async function runRunnerInteractiveOAuth( + options: RunRunnerInteractiveOAuthOptions, +): Promise { + const createServer = + options.createCallbackServer ?? createOAuthCallbackServer; + const server = createServer(); + + let flowResolve!: () => void; + let flowReject!: (err: Error) => void; + const flowDone = new Promise((resolve, reject) => { + flowResolve = resolve; + flowReject = reject; + }); + + try { + const { redirectUrl } = await server.start({ + hostname: options.callbackListen.hostname, + port: options.callbackListen.port, + path: options.callbackListen.pathname, + onCallback: async (params) => { + try { + await options.client.completeOAuthFlow(params.code); + flowResolve(); + } catch (err) { + flowReject(err instanceof Error ? err : new Error(String(err))); + } + }, + onError: (params) => { + flowReject( + new Error( + params.error_description ?? params.error ?? "OAuth error", + ), + ); + }, + }); + + options.onCallbackServer?.(server); + options.redirectUrlProvider.redirectUrl = redirectUrl; + + const timeoutMs = + options.callbackTimeoutMs ?? DEFAULT_RUNNER_INTERACTIVE_OAUTH_TIMEOUT_MS; + let timeoutId: ReturnType | undefined; + const waitForCallback = Promise.race([ + flowDone.finally(() => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + }), + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject( + new Error( + `OAuth callback timed out after ${Math.round(timeoutMs / 1000)}s`, + ), + ); + }, timeoutMs); + }), + ]); + + if (options.authorizationUrl) { + await options.client.beginInteractiveAuthorization( + options.authorizationUrl, + ); + await waitForCallback; + } else { + const authUrl = await options.client.authenticate(); + if (authUrl !== undefined) { + await waitForCallback; + } else { + return { kind: "already_authorized" }; + } + } + + if (options.authChallenge) { + const satisfied = await options.client.checkAuthChallengeSatisfied( + options.authChallenge, + ); + if (!satisfied) { + return { + kind: "insufficient_scope", + challenge: options.authChallenge, + }; + } + } + + return { kind: "success" }; + } finally { + await server.stop().catch(() => {}); + } +} diff --git a/core/auth/oauthUx.ts b/core/auth/oauthUx.ts new file mode 100644 index 000000000..df7fdc26e --- /dev/null +++ b/core/auth/oauthUx.ts @@ -0,0 +1,236 @@ +import type { AuthChallenge } from "./challenge.js"; + +export type OAuthInteractiveAuthKind = "step_up" | "reauth"; + +/** Origin of an interactive OAuth recovery flow (command, ambient, connect, etc.). */ +export type OAuthRecoverySource = + | "tool" + | "prompt" + | "resource" + | "ambient" + | "app"; + +export function isActionTriggeredOAuthRecovery( + source: OAuthRecoverySource | undefined, +): boolean { + return ( + source === "tool" || + source === "prompt" || + source === "resource" || + source === "app" + ); +} + +export function oauthResumeSuccessMessage( + authKind: OAuthInteractiveAuthKind, + options?: { recoverySource?: OAuthRecoverySource }, +): string { + const retry = isActionTriggeredOAuthRecovery(options?.recoverySource); + if (authKind === "step_up") { + return retry + ? "Step-up authorization succeeded. Retry your action." + : "Step-up authorization succeeded."; + } + return retry + ? "Authentication succeeded. Retry your action." + : "Authentication succeeded."; +} + +export function authRecoveryRestoredMessage(options?: { + recoverySource?: OAuthRecoverySource; +}): string { + const retry = isActionTriggeredOAuthRecovery(options?.recoverySource); + return retry + ? "Session credentials were updated. Retry your action." + : "Session credentials were updated."; +} + +export function oauthResumeAbandonedMessage( + authKind: OAuthInteractiveAuthKind, + options?: { recoverySource?: OAuthRecoverySource }, +): string { + if (authKind === "reauth") { + return "Sign-in was not completed. Re-authenticate to restore access."; + } + return isActionTriggeredOAuthRecovery(options?.recoverySource) + ? "Step-up authorization was not completed. Retry your action." + : "Step-up authorization was not completed."; +} + +/** Standard-OAuth step-up (not EMA silent re-mint). */ +export function isStandardOAuthStepUp( + challenge: AuthChallenge, + options?: { enterpriseManaged?: boolean }, +): boolean { + return ( + challenge.reason === "insufficient_scope" && !options?.enterpriseManaged + ); +} + +/** EMA step-up (`insufficient_scope` on an enterprise-managed server). */ +export function isEmaStepUp( + challenge: AuthChallenge, + options?: { enterpriseManaged?: boolean }, +): boolean { + return ( + challenge.reason === "insufficient_scope" && + options?.enterpriseManaged === true + ); +} + +/** Any step-up that should show the Inspector confirmation modal before proceeding. */ +export function isStepUpConfirmation( + challenge: AuthChallenge, + options?: { enterpriseManaged?: boolean }, +): boolean { + return ( + isStandardOAuthStepUp(challenge, options) || + isEmaStepUp(challenge, options) + ); +} + +export function stepUpConfirmMessage( + challenge: AuthChallenge, + options?: { enterpriseManaged?: boolean }, +): string { + const toolName = challenge.context?.toolName?.trim(); + if (toolName) { + return options?.enterpriseManaged + ? `Tool "${toolName}" needs additional permissions from your organization before it can continue.` + : `Tool "${toolName}" needs additional OAuth scopes before it can continue.`; + } + const additional = challenge.requiredScopes?.filter(Boolean); + if (additional?.length) { + const label = additional.length === 1 ? "scope" : "scopes"; + return options?.enterpriseManaged + ? `This operation needs additional organization ${label}: ${additional.join(", ")}.` + : `This operation needs additional ${label}: ${additional.join(", ")}.`; + } + return options?.enterpriseManaged + ? "This operation needs additional permissions from your organization before it can continue." + : "This operation needs additional OAuth scopes before it can continue."; +} + +/** Body copy below the step-up summary (what happens on Authorize). */ +export function stepUpFollowUpMessage(options?: { + enterpriseManaged?: boolean; +}): string { + return options?.enterpriseManaged + ? "Inspector will request the additional permissions from your enterprise identity provider. You may be asked to sign in if your organization session expired." + : "You will be redirected to authorize, then returned to the inspector."; +} + +/** Step-up confirm action label (e.g. TUI menu item). EMA re-mints in-process when possible. */ +export function stepUpAuthorizeActionLabel(options?: { + enterpriseManaged?: boolean; +}): string { + return options?.enterpriseManaged + ? "Authorize" + : "Authorize (opens browser)"; +} + +export function stepUpModalTitle(options?: { + enterpriseManaged?: boolean; +}): string { + return options?.enterpriseManaged + ? "Additional organization permissions required" + : "Additional permissions required"; +} + +/** Toast while EMA step-up is in progress after user confirms. */ +export function emaStepUpInProgressMessage(): string { + return "Requesting additional permissions from your organization…"; +} + +/** Toast when EMA step-up completes successfully. */ +export function emaStepUpSuccessMessage(options?: { + recoverySource?: OAuthRecoverySource; +}): string { + const retry = isActionTriggeredOAuthRecovery(options?.recoverySource); + return retry + ? "Organization permissions were updated. Retry your action." + : "Organization permissions were updated."; +} + +/** Toast when EMA step-up fails after user confirmation. */ +export function emaStepUpFailureMessage(detail?: string): string { + return detail?.trim() + ? detail + : "Could not obtain the additional permissions from your organization."; +} + +/** Scopes the current operation still lacks (from the resource-server challenge). */ +export function stepUpAdditionalScopes(challenge: AuthChallenge): string[] { + return challenge.requiredScopes?.filter(Boolean) ?? []; +} + +export function stepUpInsufficientScopeMessage( + challenge: AuthChallenge, +): string { + const toolName = challenge.context?.toolName?.trim(); + if (toolName) { + return `Authorization completed, but required permissions for tool "${toolName}" were not granted. Grant the requested scopes on the authorization server, then retry.`; + } + const missing = + challenge.authorizationScopes?.filter(Boolean) ?? + challenge.requiredScopes?.filter(Boolean); + if (missing?.length) { + return `Authorization completed, but required scopes were not granted (${missing.join(", ")}). Grant the requested permissions on the authorization server, then retry your action.`; + } + return "Authorization completed, but the required permissions were not granted. Grant the requested scopes on the authorization server, then retry your action."; +} + +export type OAuthPreRedirectContext = "connect" | "session_recovery"; + +/** Pre-redirect toast copy for interactive OAuth. */ +export function oauthPreRedirectToastCopy( + authKind: OAuthInteractiveAuthKind, + options: { + serverName?: string; + enterpriseManaged?: boolean; + /** Fresh connect handshake — no existing session to recover. */ + context?: OAuthPreRedirectContext; + }, +): { title: string; message: string } | undefined { + if (options.context === "connect") { + return undefined; + } + const name = options.serverName; + if (authKind === "step_up") { + return { + title: name ? `Step-up authorization for "${name}"` : "Step-up authorization", + message: "Redirecting to authorize additional permissions…", + }; + } + if (options.enterpriseManaged) { + return { + title: name ? `Re-authenticating "${name}"` : "Re-authenticating", + message: "Re-authenticating…", + }; + } + return { + title: name ? `Session expired for "${name}"` : "Session expired", + message: "Session expired, re-authenticating…", + }; +} + +/** Challenge reasons that warrant a persistent re-auth banner (degraded session). */ +export function isReAuthBannerReason( + reason: AuthChallenge["reason"] | undefined, +): boolean { + return ( + reason === "token_expired" || + reason === "unauthorized" || + reason === "invalid_token" + ); +} + +export function reAuthBannerMessage(options: { + serverName?: string; + detail?: string; +}): string { + const prefix = options.serverName + ? `Authentication for "${options.serverName}" needs attention.` + : "Authentication needs attention."; + return options.detail ? `${prefix} ${options.detail}` : prefix; +} diff --git a/core/auth/providers.ts b/core/auth/providers.ts index b01c9fb7f..4b3007283 100644 --- a/core/auth/providers.ts +++ b/core/auth/providers.ts @@ -102,6 +102,7 @@ export type OAuthProviderConfig = { export class BaseOAuthClientProvider implements OAuthClientProvider { private capturedAuthUrl: URL | null = null; private eventTarget: EventTarget | null = null; + private suppressAuthorizationNavigation = false; protected serverUrl: string; protected storage: OAuthStorage; @@ -138,6 +139,11 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { this.capturedAuthUrl = null; } + /** Capture authorize URL without navigating (step-up confirmation modal). */ + setSuppressAuthorizationNavigation(suppress: boolean): void { + this.suppressAuthorizationNavigation = suppress; + } + get scope(): string | undefined { return this.storage.getScope(this.serverUrl); } @@ -217,17 +223,16 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { // Capture URL for return value this.capturedAuthUrl = authorizationUrl; - // Dispatch event if event target is set - if (this.eventTarget) { - this.eventTarget.dispatchEvent( - new CustomEvent("oauthAuthorizationRequired", { - detail: { url: authorizationUrl }, - }), - ); + if (!this.suppressAuthorizationNavigation) { + if (this.eventTarget) { + this.eventTarget.dispatchEvent( + new CustomEvent("oauthAuthorizationRequired", { + detail: { url: authorizationUrl }, + }), + ); + } + this.navigation.navigateToAuthorization(authorizationUrl); } - - // Original navigation behavior - this.navigation.navigateToAuthorization(authorizationUrl); } async saveCodeVerifier(codeVerifier: string): Promise { diff --git a/core/auth/scopes.ts b/core/auth/scopes.ts new file mode 100644 index 000000000..2ec9aa6d7 --- /dev/null +++ b/core/auth/scopes.ts @@ -0,0 +1,43 @@ +/** + * SEP-2350 scope helpers aligned with v2 `@modelcontextprotocol/client` exports. + * On SDK v2 upgrade, delete this module and import `computeScopeUnion` / + * `isStrictScopeSuperset` from the client package instead. + */ + +import { + parseScopeString, + unionAuthorizationScopes, +} from "./challenge.js"; + +/** Union space-delimited scope strings (order-preserving, deduped). */ +export function computeScopeUnion( + ...scopes: ReadonlyArray +): string | undefined { + let merged: string | undefined; + for (const scope of scopes) { + merged = unionAuthorizationScopes(merged, parseScopeString(scope)).join( + " ", + ); + if (!merged) { + merged = undefined; + } + } + return merged; +} + +/** + * Whether `union` contains a scope token not present in `current`. + * When the AS omits `scope` on the token response, `current` is empty and any + * non-empty union is a strict superset — step-up must re-authorize, not refresh. + */ +export function isStrictScopeSuperset( + union: string | undefined, + current: string | undefined, +): boolean { + if (!union) return false; + const currentSet = new Set((current ?? "").split(/\s+/).filter(Boolean)); + for (const token of union.split(/\s+/)) { + if (token && !currentSet.has(token)) return true; + } + return false; +} diff --git a/core/auth/utils.ts b/core/auth/utils.ts index 638a40807..9f699f02d 100644 --- a/core/auth/utils.ts +++ b/core/auth/utils.ts @@ -1,4 +1,69 @@ import type { CallbackParams } from "./types.js"; +import { ZodError } from "zod"; + +type ZodIssueLike = { + path?: unknown[]; + message?: string; + code?: string; +}; + +function isZodIssueArray(value: unknown): value is ZodIssueLike[] { + return ( + Array.isArray(value) && + value.length > 0 && + typeof value[0] === "object" && + value[0] !== null && + "code" in value[0] + ); +} + +function formatZodIssues(issues: ZodIssueLike[]): string { + const tokenResponseIssue = issues.some( + (issue) => + Array.isArray(issue.path) && + (issue.path.includes("access_token") || + issue.path.includes("token_type")), + ); + if (tokenResponseIssue) { + return "The authorization server did not return valid tokens. Check your OAuth client ID and secret, then try again."; + } + return issues + .map((issue) => { + const path = + Array.isArray(issue.path) && issue.path.length + ? issue.path.join(".") + : "input"; + return `${path}: ${issue.message ?? "invalid"}`; + }) + .join(" "); +} + +/** + * Human-readable detail for OAuth failure toasts/banners (never raw Zod JSON). + */ +export function formatOAuthFailureDetail(detail: unknown): string { + if (detail instanceof ZodError) { + return formatZodIssues(detail.issues); + } + const raw = + detail instanceof Error + ? detail.message + : typeof detail === "string" + ? detail + : String(detail); + const trimmed = raw.trim(); + if (trimmed.startsWith("[")) { + try { + const parsed: unknown = JSON.parse(trimmed); + if (isZodIssueArray(parsed)) { + return formatZodIssues(parsed); + } + } catch { + // fall through + } + } + return raw; +} /** * Parse a string as an absolute URL. On failure, throws with `label` and the diff --git a/core/client/runner.ts b/core/client/runner.ts index 5a9f38d7b..41bc12368 100644 --- a/core/client/runner.ts +++ b/core/client/runner.ts @@ -53,7 +53,7 @@ export function buildRunnerClientAuthOptions( cliOverrides?: RunnerClientConfigOverrides, ): Pick< InspectorClientOptions, - "oauth" | "enterpriseManagedAuth" | "installEnterpriseManagedAuth" + "oauth" | "enterpriseManagedAuth" | "installEnterpriseManagedAuth" | "directAuthRecovery" > { const activeIdp = getActiveEnterpriseManagedAuthIdp(clientConfig); const activeCimdUrl = getActiveCimdClientMetadataUrl(clientConfig); @@ -112,5 +112,6 @@ export function buildRunnerClientAuthOptions( ...(clientConfig.enterpriseManagedAuth && { installEnterpriseManagedAuth: clientConfig.enterpriseManagedAuth, }), + ...(oauth && { directAuthRecovery: true }), }; } diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 7567d7258..c029ccf0f 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -118,11 +118,26 @@ import { UrlElicitationLoopError, } from "./urlElicitation.js"; import { ToolCallCancelledError } from "./toolCallCancelledError.js"; -import type { OAuthConnectionState, OAuthFlowState, OAuthStep } from "../auth/types.js"; +import type { + OAuthConnectionState, + OAuthFlowState, + OAuthStep, +} from "../auth/types.js"; +import { + AuthRecoveryRequiredError, + EMA_STEP_UP_PENDING_URL, + isAuthChallengeError, + isConnectAuthRecoveryError, + parseAuthChallengeFromError, + type AuthChallenge, + type AuthChallengeOutcome, + type HandleAuthChallengeOptions, +} from "../auth/challenge.js"; import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; import { silentLogger, type InspectorLogger } from "../logging/logger.js"; import { createFetchTracker } from "./fetchTracking.js"; import { OAuthManager, type OAuthManagerConfig } from "./oauthManager.js"; +import { RemoteClientTransport } from "./remote/remoteClientTransport.js"; /** Internal record for a receiver task (server polls us for status/result). */ interface ReceiverTaskRecord { @@ -181,6 +196,8 @@ export class InspectorClient extends InspectorClientEventTarget { private baseTransport: Transport | null = null; /** True when the cached transport was built with an OAuth authProvider attached. */ private transportHasAuthProvider = false; + /** Dedupes concurrent ambient auth challenges (reason + scopes). */ + private ambientAuthChallengeInFlight = new Map>(); private pipeStderr: boolean; private initialLoggingLevel?: LoggingLevel; private sample: boolean; @@ -249,6 +266,14 @@ export class InspectorClient extends InspectorClientEventTarget { // Session ID (for OAuth state and saveSession event; persistence is in FetchRequestLogState) private sessionId?: string; private transportConfig: MCPServerConfig; + /** null until first transport is built; then true for in-process OAuth runners. */ + private directAuthRecoveryActive: boolean | null = null; + /** + * Opt-in from {@link InspectorClientOptions.directAuthRecovery}: when true and + * the live transport is direct (not {@link RemoteClientTransport}), RPCs use + * fetch intercept + {@link withDirectAuthRecovery}. + */ + private readonly directAuthRecovery: boolean; constructor( transportConfig: MCPServerConfig, @@ -276,6 +301,7 @@ export class InspectorClient extends InspectorClientEventTarget { ? options.defaultMetadata : undefined; this.serverSettings = options.serverSettings; + this.directAuthRecovery = options.directAuthRecovery ?? false; // Only set roots if explicitly provided (even if empty array) - this enables roots capability this.roots = options.roots; // Initialize listChangedNotifications config (default: all enabled) @@ -426,7 +452,10 @@ export class InspectorClient extends InspectorClientEventTarget { }; this.dispatchTypedEvent("message", entry); }, - trackNotification: (message: JSONRPCNotification, origin: MessageOrigin) => { + trackNotification: ( + message: JSONRPCNotification, + origin: MessageOrigin, + ) => { const entry: MessageEntry = { id: crypto.randomUUID(), timestamp: new Date(), @@ -761,12 +790,40 @@ export class InspectorClient extends InspectorClientEventTarget { await oauthManager.createOAuthProviderForTransport(); } } + if ( + this.directAuthRecovery && + this.directAuthRecoveryActive !== false && + this.isHttpOAuthConfig() && + oauthManager && + transportOptions.authProvider + ) { + transportOptions.interceptAuthChallenges = true; + } this.transportHasAuthProvider = !!transportOptions.authProvider; const { transport: baseTransport } = this.transportClientFactory( this.transportConfig, transportOptions, ); this.baseTransport = baseTransport; + if (this.directAuthRecovery) { + this.directAuthRecoveryActive = !( + baseTransport instanceof RemoteClientTransport + ); + } + if ( + baseTransport instanceof RemoteClientTransport && + oauthManager && + this.isHttpOAuthConfig() + ) { + baseTransport.setAuthRecovery({ + handleAuthChallenge: (challenge, options) => + oauthManager.handleAuthChallenge(challenge, options), + pushAuthState: () => this.pushRemoteAuthState(), + }); + baseTransport.setOnAuthChallenge((challenge) => { + void this.handleAmbientAuthChallenge(challenge); + }); + } const messageTracking = this.createMessageTrackingCallbacks(); this.transport = new MessageTrackingTransport( baseTransport, @@ -789,38 +846,38 @@ export class InspectorClient extends InspectorClientEventTarget { // connect() starts clean and the upstream socket isn't left hanging. const connectTimeoutMs = this.serverSettings?.connectionTimeout ?? 0; const connectPromise = this.client.connect(this.transport); - if (connectTimeoutMs > 0) { - // Absorb any late rejection from `connectPromise` — when the timeout - // wins the race and `disconnect()` tears the transport down, real - // transports (SSE / streamable-http) reject the in-flight handshake - // *after* Promise.race has already settled. Without a handler here - // Node emits an unhandledRejection warning (and in test runs vitest - // can surface it as a suite failure). Only needed on the race path; - // the await-only branch below propagates rejection cleanly through - // the outer try/catch. - connectPromise.catch(() => {}); - let timer: ReturnType | undefined; - const timeoutPromise = new Promise((_, reject) => { - timer = setTimeout( - () => - reject( - new Error( - `Connection timed out after ${connectTimeoutMs} ms`, + const runConnect = async (): Promise => { + if (connectTimeoutMs > 0) { + connectPromise.catch(() => {}); + let timer: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new Error( + `Connection timed out after ${connectTimeoutMs} ms`, + ), ), - ), - connectTimeoutMs, - ); - }); - try { - await Promise.race([connectPromise, timeoutPromise]); - } catch (err) { + connectTimeoutMs, + ); + }); + try { + await Promise.race([connectPromise, timeoutPromise]); + } finally { + if (timer) clearTimeout(timer); + } + } else { + await connectPromise; + } + }; + + try { + await this.invokeMcpClient(runConnect); + } catch (err) { + if (connectTimeoutMs > 0) { await this.disconnect().catch(() => {}); - throw err; - } finally { - if (timer) clearTimeout(timer); } - } else { - await connectPromise; + throw err; } this.status = "connected"; this.dispatchTypedEvent("statusChange", this.status); @@ -1116,8 +1173,10 @@ export class InspectorClient extends InspectorClientEventTarget { // and routing work, and we inject the caller's progressToken into dispatched events. } } catch (error) { - this.status = "error"; - this.dispatchTypedEvent("statusChange", this.status); + if (!isConnectAuthRecoveryError(error)) { + this.status = "error"; + this.dispatchTypedEvent("statusChange", this.status); + } if (this.baseTransport && !this.transportHasAuthProvider) { await this.dropCachedTransport(); } @@ -1221,7 +1280,10 @@ export class InspectorClient extends InspectorClientEventTarget { this.instructions = undefined; this.protocolVersion = undefined; this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples); - this.dispatchTypedEvent("pendingElicitationsChange", this.pendingElicitations); + this.dispatchTypedEvent( + "pendingElicitationsChange", + this.pendingElicitations, + ); this.dispatchTypedEvent("capabilitiesChange", this.capabilities); this.dispatchTypedEvent("serverInfoChange", this.serverInfo); this.dispatchTypedEvent("instructionsChange", this.instructions); @@ -1558,9 +1620,11 @@ export class InspectorClient extends InspectorClientEventTarget { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), ...(cursor ? { cursor } : {}), }; - const response = await this.client.listTools( - params, - this.getRequestOptions(metadata?.progressToken), + const response = await this.invokeMcpClient(() => + this.client!.listTools( + params, + this.getRequestOptions(metadata?.progressToken), + ), ); const tools = [...(response.tools || [])]; return { tools, nextCursor: response.nextCursor }; @@ -1803,13 +1867,17 @@ export class InspectorClient extends InspectorClientEventTarget { // Both branches yield a CallToolResult: request() parsed it with // CallToolResultSchema above, callTool() returns the same shape — so the // `as CallToolResult` casts below are safe. - const result = options?.skipOutputValidation - ? await client.request( - { method: "tools/call", params: callParams }, - CallToolResultSchema, - requestOptions, - ) - : await client.callTool(callParams, undefined, requestOptions); + const result = await this.invokeMcpClient( + () => + options?.skipOutputValidation + ? client.request( + { method: "tools/call", params: callParams }, + CallToolResultSchema, + requestOptions, + ) + : client.callTool(callParams, undefined, requestOptions), + { method: "tools/call", toolName: tool.name }, + ); // On the bypass path the result was delivered without the SDK's strict // output validation. Run that check ourselves, non-fatally, so callers can @@ -2197,9 +2265,11 @@ export class InspectorClient extends InspectorClientEventTarget { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), ...(cursor ? { cursor } : {}), }; - const response = await this.client.listResources( - params, - this.getRequestOptions(metadata?.progressToken), + const response = await this.invokeMcpClient(() => + this.client!.listResources( + params, + this.getRequestOptions(metadata?.progressToken), + ), ); return { resources: response.resources || [], @@ -2225,9 +2295,13 @@ export class InspectorClient extends InspectorClientEventTarget { uri, ...(effectiveMeta ? { _meta: effectiveMeta } : {}), }; - const result = await this.client.readResource( - params, - this.getRequestOptions(metadata?.progressToken), + const result = await this.invokeMcpClient( + () => + this.client!.readResource( + params, + this.getRequestOptions(metadata?.progressToken), + ), + { method: "resources/read" }, ); const invocation: ResourceReadInvocation = { result, @@ -2317,9 +2391,13 @@ export class InspectorClient extends InspectorClientEventTarget { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), ...(cursor ? { cursor } : {}), }; - const response = await this.client.listResourceTemplates( - params, - this.getRequestOptions(metadata?.progressToken), + const response = await this.invokeMcpClient( + () => + this.client!.listResourceTemplates( + params, + this.getRequestOptions(metadata?.progressToken), + ), + { method: "resources/templates/list" }, ); return { resourceTemplates: response.resourceTemplates || [], @@ -2345,9 +2423,11 @@ export class InspectorClient extends InspectorClientEventTarget { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), ...(cursor ? { cursor } : {}), }; - const response = await this.client.listPrompts( - params, - this.getRequestOptions(metadata?.progressToken), + const response = await this.invokeMcpClient(() => + this.client!.listPrompts( + params, + this.getRequestOptions(metadata?.progressToken), + ), ); return { prompts: response.prompts || [], @@ -2380,9 +2460,13 @@ export class InspectorClient extends InspectorClientEventTarget { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), }; - const result = await this.client.getPrompt( - params, - this.getRequestOptions(metadata?.progressToken), + const result = await this.invokeMcpClient( + () => + this.client!.getPrompt( + params, + this.getRequestOptions(metadata?.progressToken), + ), + { method: "prompts/get", toolName: name }, ); const invocation: PromptGetInvocation = { @@ -2437,9 +2521,17 @@ export class InspectorClient extends InspectorClientEventTarget { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), }; - const response = await this.client.complete( - params, - this.getRequestOptions(metadata?.progressToken), + const response = await this.invokeMcpClient( + () => + this.client!.complete( + params, + this.getRequestOptions(metadata?.progressToken), + ), + { + method: "completion/complete", + toolName: + ref.type === "ref/prompt" ? ref.name : ref.uri, + }, ); return { @@ -2720,11 +2812,240 @@ export class InspectorClient extends InspectorClientEventTarget { return this.ensureOAuthManager().authenticate(); } + /** + * Satisfy a mid-session auth challenge (token refresh, step-up, or interactive re-auth). + */ + async handleAuthChallenge( + challenge: AuthChallenge, + options?: HandleAuthChallengeOptions, + ): Promise { + return this.ensureOAuthManager().handleAuthChallenge(challenge, options); + } + + /** + * Re-read OAuth storage and test whether a challenge is already satisfied. + * See {@link OAuthManager.checkAuthChallengeSatisfied}. + */ + async checkAuthChallengeSatisfied( + challenge: AuthChallenge, + ): Promise { + return this.ensureOAuthManager().checkAuthChallengeSatisfied(challenge); + } + + /** + * Push recovered OAuth auth state to the remote backend (same MCP session). + */ + async pushRemoteAuthState(): Promise { + if (!(this.baseTransport instanceof RemoteClientTransport)) { + return; + } + await this.baseTransport.pushAuthState(); + } + + /** + * Handle an ambient (SSE) auth challenge when no command-scoped send is active. + * Recovers session tokens on the remote backend; does not retry RPCs. + */ + async handleAmbientAuthChallenge(challenge: AuthChallenge): Promise { + const key = this.ambientAuthChallengeKey(challenge); + const existing = this.ambientAuthChallengeInFlight.get(key); + if (existing) { + return existing; + } + + const promise = this.runAmbientAuthChallenge(challenge); + this.ambientAuthChallengeInFlight.set(key, promise); + try { + await promise; + } finally { + if (this.ambientAuthChallengeInFlight.get(key) === promise) { + this.ambientAuthChallengeInFlight.delete(key); + } + } + } + + private async runAmbientAuthChallenge( + challenge: AuthChallenge, + ): Promise { + try { + this.dispatchTypedEvent("authChallengeAmbient", { challenge }); + const oauthManager = this.oauthManager; + if (!oauthManager) { + return; + } + + const outcome = await oauthManager.handleAuthChallenge(challenge); + if (outcome.kind === "satisfied") { + if (this.baseTransport instanceof RemoteClientTransport) { + await this.pushRemoteAuthState(); + } else { + await this.reconnectAfterAuthRecovery(); + } + this.dispatchTypedEvent("authChallengeRecovered", { challenge }); + } else if (outcome.kind === "step_up_confirm") { + this.dispatchTypedEvent("authChallengeInteractive", { + challenge: outcome.challenge, + authorizationUrl: EMA_STEP_UP_PENDING_URL, + }); + } else if (outcome.kind === "interactive") { + this.dispatchTypedEvent("authChallengeInteractive", { + challenge: outcome.challenge, + authorizationUrl: outcome.authorizationUrl, + }); + } else { + this.dispatchTypedEvent("oauthError", { error: outcome.error }); + } + } catch (error) { + this.dispatchTypedEvent("oauthError", { + error: error instanceof Error ? error : new Error(String(error)), + }); + } + } + + private ambientAuthChallengeKey(challenge: AuthChallenge): string { + const requiredScopes = [...(challenge.requiredScopes ?? [])] + .sort() + .join(" "); + const authorizationScopes = [...(challenge.authorizationScopes ?? [])] + .sort() + .join(" "); + return `${challenge.reason}:${requiredScopes}:${authorizationScopes}`; + } + + /** + * Full disconnect + reconnect after ambient auth recovery on direct transports. + */ + private async reconnectAfterAuthRecovery(): Promise { + await this.disconnect().catch(() => {}); + await this.dropCachedTransport(); + await this.connect(); + } + + /** Direct (non-remote) OAuth transports recover via fetch intercept + handleAuthChallenge. */ + private usesDirectAuthRecovery(): boolean { + return this.directAuthRecovery && this.directAuthRecoveryActive === true; + } + + private async withDirectAuthRecovery( + operation: () => Promise, + context?: { method?: string; toolName?: string }, + attempt = 0, + ): Promise { + try { + return await operation(); + } catch (err) { + if (attempt >= 1 || !this.usesDirectAuthRecovery()) { + throw err; + } + if (!isAuthChallengeError(err)) { + throw err; + } + const challenge = parseAuthChallengeFromError(err, context); + if (!challenge) { + throw err; + } + + if (context?.method || context?.toolName) { + this.dispatchTypedEvent("authChallengeCommand", { challenge }); + } else { + this.dispatchTypedEvent("authChallengeAmbient", { challenge }); + } + const outcome = await this.handleAuthChallenge(challenge); + if (outcome.kind === "satisfied") { + // Reconnect aborts activeToolCallAbortController; clear it so callTool + // retries are not immediately rejected with "Disconnected". + if (this.activeToolCallAbortController) { + this.activeToolCallAbortController = undefined; + } + await this.reconnectAfterAuthRecovery(); + this.dispatchTypedEvent("authChallengeRecovered", { challenge }); + return this.withDirectAuthRecovery(operation, context, attempt + 1); + } + if (outcome.kind === "step_up_confirm") { + throw new AuthRecoveryRequiredError( + EMA_STEP_UP_PENDING_URL, + outcome.challenge, + { emaStepUpConfirm: true }, + ); + } + if (outcome.kind === "interactive") { + throw new AuthRecoveryRequiredError( + outcome.authorizationUrl, + outcome.challenge, + ); + } + this.dispatchTypedEvent("oauthError", { error: outcome.error }); + throw outcome.error; + } + } + + private async invokeMcpClient( + operation: () => Promise, + context?: { method?: string; toolName?: string }, + ): Promise { + if (!this.usesDirectAuthRecovery()) { + return operation(); + } + return this.withDirectAuthRecovery(operation, context); + } + /** * Completes OAuth flow with authorization code from the redirect callback. + * Direct transports reconnect after token exchange so the live MCP session + * picks up the new Bearer token (mirrors silent recovery reconnect). + */ + async completeOAuthFlow(authorizationCode: string, iss?: string): Promise { + await this.ensureOAuthManager().completeOAuthFlow(authorizationCode, iss); + if (this.usesDirectAuthRecovery()) { + await this.reconnectAfterAuthRecovery(); + } + } + + /** + * Navigate to the authorization server for interactive recovery. + */ + async beginInteractiveAuthorization(authorizationUrl: URL): Promise { + return this.ensureOAuthManager().beginInteractiveAuthorization( + authorizationUrl, + ); + } + + /** Remote Hono session id when using {@link RemoteClientTransport}. */ + getRemoteBackendSessionId(): string | undefined { + if (this.baseTransport instanceof RemoteClientTransport) { + return this.baseTransport.getRemoteBackendSessionId(); + } + return undefined; + } + + /** + * Finish OAuth after a full-page redirect and reconnect (or reattach) the MCP session. */ - async completeOAuthFlow(authorizationCode: string): Promise { - return this.ensureOAuthManager().completeOAuthFlow(authorizationCode); + async resumeAfterOAuth( + authorizationCode: string, + options?: { remoteSessionId?: string }, + ): Promise { + await this.completeOAuthFlow(authorizationCode); + + const remoteSessionId = options?.remoteSessionId; + const transport = this.baseTransport; + + if (remoteSessionId && transport instanceof RemoteClientTransport) { + try { + await transport.attachToSession(remoteSessionId); + await transport.pushAuthState(); + if (this.status !== "connected") { + await this.connect(); + } + return; + } catch { + // Session expired during OAuth round trip — fall back to fresh connect. + } + } + + if (this.status !== "connected") { + await this.connect(); + } } /** diff --git a/core/mcp/inspectorClientEventTarget.ts b/core/mcp/inspectorClientEventTarget.ts index a5f9d8822..bc9ffa1a4 100644 --- a/core/mcp/inspectorClientEventTarget.ts +++ b/core/mcp/inspectorClientEventTarget.ts @@ -39,6 +39,7 @@ import type { SamplingCreateMessage } from "./samplingCreateMessage.js"; import type { ElicitationCreateMessage } from "./elicitationCreateMessage.js"; import type { JsonValue } from "../json/jsonUtils.js"; import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; +import type { AuthChallenge } from "../auth/challenge.js"; /** Task with createdAt optional so we can emit synthetic tasks (e.g. on result/error) that omit it. */ export type TaskWithOptionalCreatedAt = Omit & { @@ -148,6 +149,17 @@ export interface InspectorClientEventMap { oauthComplete: { tokens: OAuthTokens }; oauthAuthorizationRequired: { url: URL }; oauthError: { error: Error }; + /** Ambient (SSE) auth challenge while no command-scoped send is active. */ + authChallengeAmbient: { challenge: AuthChallenge }; + /** Command-scoped direct-transport auth challenge (no ambient toast). */ + authChallengeCommand: { challenge: AuthChallenge }; + /** Ambient auth recovery completed (remote auth state pushed). */ + authChallengeRecovered: { challenge: AuthChallenge }; + /** Interactive auth required; App orchestrates redirect (step-up modal or reauth). */ + authChallengeInteractive: { + challenge: AuthChallenge; + authorizationUrl: URL; + }; } /** diff --git a/core/mcp/node/authChallengeFetch.ts b/core/mcp/node/authChallengeFetch.ts new file mode 100644 index 000000000..e404b767a --- /dev/null +++ b/core/mcp/node/authChallengeFetch.ts @@ -0,0 +1,34 @@ +import { + AuthChallengeError, + parseAuthChallengeFromResponse, +} from "../../auth/challenge.js"; + +/** + * Wrap fetch so MCP HTTP 401/403 responses become {@link AuthChallengeError} + * before the SDK invokes `auth()` on a frozen remote token provider. + */ +export function createAuthChallengeInterceptFetch( + baseFetch: typeof fetch, +): typeof fetch { + return async (input, init) => { + const response = await baseFetch(input, init); + if (response.status !== 401 && response.status !== 403) { + return response; + } + + const challenge = parseAuthChallengeFromResponse(response); + if (!challenge) { + return response; + } + + // Release the connection before throwing so the SDK transport is not left + // with a half-read 401/403 body on streamable HTTP. + await response.body?.cancel().catch(() => {}); + + throw new AuthChallengeError( + challenge, + response.status, + `MCP auth challenge (${response.status})`, + ); + }; +} \ No newline at end of file diff --git a/core/mcp/node/transport.ts b/core/mcp/node/transport.ts index 81ebe5ddc..919a1fa3c 100644 --- a/core/mcp/node/transport.ts +++ b/core/mcp/node/transport.ts @@ -12,6 +12,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { createFetchTracker } from "../fetchTracking.js"; +import { createAuthChallengeInterceptFetch } from "./authChallengeFetch.js"; /** * Build the wire `headers` record from `settings.headers`, dropping rows with @@ -46,9 +47,13 @@ export function createTransportNode( onFetchResponseBody, authProvider, settings, + interceptAuthChallenges = false, } = options; const baseFetch = optionsFetchFn ?? globalThis.fetch; + const fetchWithOptionalAuthIntercept = interceptAuthChallenges + ? createAuthChallengeInterceptFetch(baseFetch) + : baseFetch; if (serverType === "stdio") { const stdioConfig = config as StdioServerConfig; @@ -79,7 +84,8 @@ export function createTransportNode( const url = new URL(sseConfig.url); const sseFetch = - (sseConfig.eventSourceInit?.fetch as typeof fetch) || baseFetch; + (sseConfig.eventSourceInit?.fetch as typeof fetch) || + fetchWithOptionalAuthIntercept; const trackedFetch = onFetchRequest ? createFetchTracker(sseFetch, { trackRequest: onFetchRequest, @@ -101,11 +107,11 @@ export function createTransportNode( }; const postFetch = onFetchRequest - ? createFetchTracker(baseFetch, { + ? createFetchTracker(fetchWithOptionalAuthIntercept, { trackRequest: onFetchRequest, updateResponseBody: onFetchResponseBody, }) - : baseFetch; + : fetchWithOptionalAuthIntercept; const transport = new SSEClientTransport(url, { authProvider, @@ -128,11 +134,11 @@ export function createTransportNode( }; const transportFetch = onFetchRequest - ? createFetchTracker(baseFetch, { + ? createFetchTracker(fetchWithOptionalAuthIntercept, { trackRequest: onFetchRequest, updateResponseBody: onFetchResponseBody, }) - : baseFetch; + : fetchWithOptionalAuthIntercept; const transport = new StreamableHTTPClientTransport(url, { authProvider, diff --git a/core/mcp/oauthManager.ts b/core/mcp/oauthManager.ts index 298d39ad8..8d38362df 100644 --- a/core/mcp/oauthManager.ts +++ b/core/mcp/oauthManager.ts @@ -8,8 +8,8 @@ import { BaseOAuthClientProvider } from "../auth/providers.js"; import type { OAuthFlowState, OAuthStep } from "../auth/types.js"; import { EMPTY_OAUTH_FLOW_STATE } from "../auth/types.js"; import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; -import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; import type { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { mcpAuth } from "../auth/mcpAuth.js"; import { parseOAuthState } from "../auth/utils.js"; import type { EnterpriseManagedAuthIdpConfig } from "../client/types.js"; import type { ClientConfig } from "../client/types.js"; @@ -24,12 +24,27 @@ import { import { buildOAuthConnectionState, hasPersistedOAuthServerState, + isAccessTokenUsable, isServerOAuthConfigured, protocolFromOAuthConfig, } from "../auth/connection-state.js"; import { ensureCimdClientRegistration } from "../auth/cimd.js"; import type { OAuthConnectionState } from "../auth/types.js"; import { EmaTransportOAuthProvider } from "../auth/ema/transportProvider.js"; +import type { + AuthChallenge, + AuthChallengeOutcome, + HandleAuthChallengeOptions, +} from "../auth/challenge.js"; +import { + parseScopeString, + unionAuthorizationScopes, +} from "../auth/challenge.js"; +import { + computeScopeUnion, + isStrictScopeSuperset, +} from "../auth/scopes.js"; +import { stepUpInsufficientScopeMessage } from "../auth/oauthUx.js"; import type { InspectorClientEnvironment, InspectorClientOptions, @@ -60,10 +75,14 @@ export class OAuthManager { private params: OAuthManagerParams; private oauthConfig: OAuthManagerConfig; private oauthFlowState: OAuthFlowState | null = null; + /** SEP-2350 union scope pending until interactive step-up completes. */ + private pendingAuthorizationScope: string | undefined; + private authChallengeMutex: Promise = Promise.resolve(); constructor(params: OAuthManagerParams) { this.params = params; this.oauthConfig = { ...params.initialConfig }; + this.pendingAuthorizationScope = undefined; } setOAuthConfig(config: { @@ -104,7 +123,8 @@ export class OAuthManager { provider.setEventTarget(this.params.getEventTarget()); - if (this.oauthConfig.scope) { + const storedScope = this.oauthConfig.storage.getScope(serverUrl); + if (storedScope === undefined && this.oauthConfig.scope) { await provider.saveScope(this.oauthConfig.scope); } @@ -146,7 +166,11 @@ export class OAuthManager { idp, resourceClientId: this.oauthConfig.clientId, resourceClientSecret: this.oauthConfig.clientSecret, - scope: this.oauthConfig.scope, + scope: + computeScopeUnion( + this.oauthConfig.scope, + this.oauthConfig.storage.getScope(this.getServerUrl()), + ) || this.oauthConfig.scope, redirectUrl: this.oauthConfig.redirectUrlProvider.getRedirectUrl(), storage: this.oauthConfig.storage, fetchFn: this.params.effectiveAuthFetch, @@ -181,12 +205,8 @@ export class OAuthManager { } } - this.oauthConfig.navigation!.navigateToAuthorization(authorizationUrl); - this.oauthFlowState = { - ...EMPTY_OAUTH_FLOW_STATE, - oauthStep: "authorization_code", - authorizationUrl, - }; + this.requireNavigation().navigateToAuthorization(authorizationUrl); + await this.recordAuthorizationCodeFlowState(authorizationUrl); return authorizationUrl; } @@ -206,7 +226,7 @@ export class OAuthManager { fetchFn: this.params.effectiveAuthFetch, }); - const result = await auth(provider, { + const result = await mcpAuth(provider, { serverUrl, scope: provider.scope, fetchFn: this.params.effectiveAuthFetch, @@ -241,14 +261,28 @@ export class OAuthManager { return capturedUrl; } - async completeOAuthFlow(authorizationCode: string): Promise { + async completeOAuthFlow( + authorizationCode: string, + iss?: string, + ): Promise { try { if (this.isEnterpriseManaged()) { - const config = this.getEmaFlowConfig(); + const scopeForMint = + this.pendingAuthorizationScope ?? this.getEmaFlowConfig().scope; + const config = scopeForMint + ? { ...this.getEmaFlowConfig(), scope: scopeForMint } + : this.getEmaFlowConfig(); const tokens = await completeEmaIdpAuthorizationAndMint( config, authorizationCode, ); + if (this.pendingAuthorizationScope) { + await this.oauthConfig.storage!.saveScope( + this.getServerUrl(), + this.pendingAuthorizationScope, + ); + } + this.pendingAuthorizationScope = undefined; const completedAt = Date.now(); this.oauthFlowState = { ...EMPTY_OAUTH_FLOW_STATE, @@ -263,9 +297,10 @@ export class OAuthManager { const provider = await this.createOAuthProvider(); const serverUrl = this.getServerUrl(); - const result = await auth(provider, { + const result = await mcpAuth(provider, { serverUrl, authorizationCode, + iss, fetchFn: this.params.effectiveAuthFetch, }); @@ -280,6 +315,13 @@ export class OAuthManager { throw new Error("Failed to retrieve tokens after authorization"); } + const scopeToPersist = + this.pendingAuthorizationScope ?? tokens.scope; + if (scopeToPersist) { + await provider.saveScope(scopeToPersist); + } + this.pendingAuthorizationScope = undefined; + const clientInfo = await provider.clientInformation(); const completedAt = Date.now(); this.oauthFlowState = this.oauthFlowState @@ -300,6 +342,7 @@ export class OAuthManager { this.params.dispatchOAuthComplete({ tokens }); } catch (error) { + this.pendingAuthorizationScope = undefined; this.params.dispatchOAuthError({ error: error instanceof Error ? error : new Error(String(error)), }); @@ -329,6 +372,7 @@ export class OAuthManager { this.oauthConfig.storage.clear(serverUrl); this.oauthFlowState = null; + this.pendingAuthorizationScope = undefined; } async isOAuthAuthorized(): Promise { @@ -382,6 +426,387 @@ export class OAuthManager { return tokens !== undefined; } + /** + * Re-read persisted OAuth state and determine whether `challenge` is already + * satisfied without an authorization-server round-trip. + * + * Returns `true` for `insufficient_scope` when stored + token scope cover the + * SEP-2350 union. For `token_expired` / `unauthorized` / `invalid_token`, + * returns `true` when a usable access token is already in storage. + */ + async checkAuthChallengeSatisfied( + challenge: AuthChallenge, + ): Promise { + const storage = this.oauthConfig.storage; + if (!storage) { + return false; + } + + const serverUrl = this.getServerUrl(); + const tokens = await storage.getTokens(serverUrl); + if (!tokens?.access_token) { + return false; + } + + if (challenge.reason !== "insufficient_scope") { + return ( + challenge.reason === "token_expired" || + challenge.reason === "unauthorized" || + challenge.reason === "invalid_token" + ) && isAccessTokenUsable(tokens); + } + + const enriched = await this.enrichChallengeWithAuthorizationScopes( + challenge, + tokens.scope, + ); + const scopeForAuth = + enriched.authorizationScopes?.join(" ") ?? + enriched.requiredScopes?.join(" "); + if (!scopeForAuth?.trim()) { + return false; + } + + const effectiveScope = computeScopeUnion( + storage.getScope(serverUrl), + tokens.scope, + ); + return !isStrictScopeSuperset(scopeForAuth, effectiveScope); + } + + /** + * Satisfy a mid-session auth challenge when possible (silent refresh/re-mint or + * interactive redirect). + * + * Only `insufficient_scope` short-circuits on {@link checkAuthChallengeSatisfied} + * here — `token_expired` / `unauthorized` still attempt silent refresh even when + * storage holds a locally-valid token (the resource server may have invalidated it). + * Callers use {@link checkAuthChallengeSatisfied} directly before visible OAuth. + * + * Recovery is serialized per server so parallel challenges cannot race on + * `pendingAuthorizationScope` or OAuth provider state. + */ + async handleAuthChallenge( + challenge: AuthChallenge, + options?: HandleAuthChallengeOptions, + ): Promise { + if ( + challenge.reason === "insufficient_scope" && + (await this.checkAuthChallengeSatisfied(challenge)) + ) { + return { kind: "satisfied" }; + } + + const prior = this.authChallengeMutex; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + this.authChallengeMutex = gate; + await prior; + try { + if ( + challenge.reason === "insufficient_scope" && + (await this.checkAuthChallengeSatisfied(challenge)) + ) { + return { kind: "satisfied" }; + } + return await this.runHandleAuthChallenge(challenge, options); + } finally { + release(); + } + } + + private async runHandleAuthChallenge( + challenge: AuthChallenge, + options?: HandleAuthChallengeOptions, + ): Promise { + if (this.isEnterpriseManaged()) { + return this.handleEnterpriseManagedAuthChallenge(challenge, options); + } + return this.handleStandardAuthChallenge(challenge); + } + + private async enrichChallengeWithAuthorizationScopes( + challenge: AuthChallenge, + grantedTokenScope?: string, + ): Promise { + if (challenge.reason !== "insufficient_scope") { + return challenge; + } + + const storage = this.oauthConfig.storage; + const serverUrl = this.getServerUrl(); + + if (grantedTokenScope === undefined && storage) { + const tokens = await storage.getTokens(serverUrl); + grantedTokenScope = tokens?.scope; + } + + const previousScope = computeScopeUnion( + storage?.getScope(serverUrl), + grantedTokenScope, + ); + const requiredFromChallenge = challenge.requiredScopes?.filter(Boolean) ?? []; + const grantedSet = new Set(parseScopeString(previousScope)); + const missingRequired = requiredFromChallenge.filter( + (scope) => !grantedSet.has(scope), + ); + const requiredScopes = + missingRequired.length > 0 ? missingRequired : requiredFromChallenge; + + const authorizationScopes = unionAuthorizationScopes( + previousScope, + requiredFromChallenge, + ); + + return { + ...challenge, + requiredScopes, + authorizationScopes, + }; + } + + private resolveEmaScopeForChallenge(challenge: AuthChallenge): string | undefined { + if (challenge.reason === "insufficient_scope") { + const fromChallenge = challenge.requiredScopes?.join(" ").trim(); + if (fromChallenge) { + return fromChallenge; + } + } + return this.oauthConfig.scope?.trim() || undefined; + } + + private emaFlowConfigForChallenge(challenge: AuthChallenge): EmaFlowConfig { + const base = this.getEmaFlowConfig(); + const enriched = challenge.authorizationScopes; + if (enriched && enriched.length > 0) { + return { ...base, scope: enriched.join(" ") }; + } + const scope = this.resolveEmaScopeForChallenge(challenge); + return scope ? { ...base, scope } : base; + } + + private async handleEnterpriseManagedAuthChallenge( + challenge: AuthChallenge, + options?: HandleAuthChallengeOptions, + ): Promise { + const enriched = await this.enrichChallengeWithAuthorizationScopes(challenge); + + if (enriched.reason === "insufficient_scope" && !options?.confirmedStepUp) { + return { kind: "step_up_confirm", challenge: enriched }; + } + + const config = this.emaFlowConfigForChallenge(enriched); + + if (enriched.reason === "insufficient_scope") { + const silent = await trySilentEmaAuth(config); + if (silent.status === "success") { + if (enriched.authorizationScopes?.length) { + await this.oauthConfig.storage!.saveScope( + this.getServerUrl(), + enriched.authorizationScopes.join(" "), + ); + } + if (await this.checkAuthChallengeSatisfied(enriched)) { + return { kind: "satisfied" }; + } + } + if (silent.status === "mint_failed") { + return { kind: "failed", error: silent.error }; + } + } else { + const tokens = await refreshEmaResourceTokens(config); + if (tokens) { + return { kind: "satisfied" }; + } + } + + try { + const authorizationUrl = await startEmaIdpAuthorization(config); + if ( + enriched.reason === "insufficient_scope" && + enriched.authorizationScopes?.length + ) { + this.pendingAuthorizationScope = + enriched.authorizationScopes.join(" "); + } + return { kind: "interactive", authorizationUrl, challenge: enriched }; + } catch (error) { + return { + kind: "failed", + error: error instanceof Error ? error : new Error(String(error)), + }; + } + } + + private async handleStandardAuthChallenge( + challenge: AuthChallenge, + ): Promise { + const provider = await this.createOAuthProvider(); + const serverUrl = this.getServerUrl(); + const tokens = await provider.tokens(); + const enriched = await this.enrichChallengeWithAuthorizationScopes( + challenge, + tokens?.scope, + ); + + provider.clearCapturedAuthUrl(); + + await ensureCimdClientRegistration({ + serverUrl, + provider, + fetchFn: this.params.effectiveAuthFetch, + }); + + const scopeForAuth = + enriched.reason === "insufficient_scope" + ? enriched.authorizationScopes?.join(" ") + : this.oauthConfig.scope?.trim() || + (enriched.requiredScopes?.length + ? enriched.requiredScopes.join(" ") + : undefined); + + provider.setSuppressAuthorizationNavigation(true); + let result: Awaited>; + try { + result = await mcpAuth(provider, { + serverUrl, + scope: scopeForAuth, + fetchFn: this.params.effectiveAuthFetch, + ...(enriched.reason === "insufficient_scope" && { + forceReauthorization: isStrictScopeSuperset( + scopeForAuth, + tokens?.scope, + ), + }), + }); + } finally { + provider.setSuppressAuthorizationNavigation(false); + } + + if (result === "AUTHORIZED") { + if (enriched.reason === "insufficient_scope") { + if (await this.checkAuthChallengeSatisfied(enriched)) { + if (scopeForAuth) { + await provider.saveScope(scopeForAuth); + } + return { kind: "satisfied" }; + } + const forced = await this.tryForceReauthorizationForStepUp( + provider, + serverUrl, + scopeForAuth, + enriched, + ); + if (forced) { + return forced; + } + return { + kind: "failed", + error: new Error(stepUpInsufficientScopeMessage(enriched)), + }; + } else { + return { kind: "satisfied" }; + } + } + + const capturedUrl = provider.getCapturedAuthUrl(); + if (!capturedUrl) { + return { + kind: "failed", + error: new Error("Failed to capture authorization URL"), + }; + } + + if (enriched.reason === "insufficient_scope" && scopeForAuth) { + this.pendingAuthorizationScope = scopeForAuth; + } + + const clientInfo = await provider.clientInformation(); + await this.recordAuthorizationCodeFlowState(capturedUrl, clientInfo); + + return { + kind: "interactive", + authorizationUrl: capturedUrl, + challenge: enriched, + }; + } + + /** Start interactive OAuth after handleAuthChallenge returns `interactive`. */ + async beginInteractiveAuthorization(authorizationUrl: URL): Promise { + const stateParam = authorizationUrl.searchParams.get("state"); + if (stateParam && this.params.onBeforeOAuthRedirect) { + const parsedState = parseOAuthState(stateParam); + if (parsedState?.authId) { + await this.params.onBeforeOAuthRedirect(parsedState.authId); + } + } + + this.requireNavigation().navigateToAuthorization(authorizationUrl); + + const provider = await this.createOAuthProvider(); + const clientInfo = await provider.clientInformation(); + await this.recordAuthorizationCodeFlowState(authorizationUrl, clientInfo); + + this.params.dispatchOAuthAuthorizationRequired({ url: authorizationUrl }); + } + + private requireNavigation(): NonNullable { + const navigation = this.oauthConfig.navigation; + if (!navigation) { + throw new Error("OAuth navigation is required."); + } + return navigation; + } + + private async recordAuthorizationCodeFlowState( + authorizationUrl: URL, + oauthClientInfo?: OAuthClientInformation | null, + ): Promise { + this.oauthFlowState = { + ...EMPTY_OAUTH_FLOW_STATE, + oauthStep: "authorization_code", + authorizationUrl, + oauthClientInfo: oauthClientInfo ?? null, + }; + } + + /** + * Silent refresh returned AUTHORIZED but token scope still lacks the step-up + * union — force a fresh authorization redirect. + */ + private async tryForceReauthorizationForStepUp( + provider: BaseOAuthClientProvider, + serverUrl: string, + scopeForAuth: string | undefined, + enriched: AuthChallenge, + ): Promise { + provider.clearCapturedAuthUrl(); + provider.setSuppressAuthorizationNavigation(true); + let result: Awaited>; + try { + result = await mcpAuth(provider, { + serverUrl, + scope: scopeForAuth, + fetchFn: this.params.effectiveAuthFetch, + forceReauthorization: true, + }); + } finally { + provider.setSuppressAuthorizationNavigation(false); + } + if (result !== "AUTHORIZED") { + return null; + } + if (await this.checkAuthChallengeSatisfied(enriched)) { + if (scopeForAuth) { + await provider.saveScope(scopeForAuth); + } + return { kind: "satisfied" }; + } + return null; + } + /** * Create an OAuth provider for transport auth (connect()). * Used only when isHttpOAuthConfig() is true. diff --git a/core/mcp/remote/index.ts b/core/mcp/remote/index.ts index 959acece2..0ac361b6d 100644 --- a/core/mcp/remote/index.ts +++ b/core/mcp/remote/index.ts @@ -6,6 +6,7 @@ export { RemoteClientTransport, type RemoteTransportOptions, + type AuthRecoveryHandlers, } from "./remoteClientTransport.js"; export { createRemoteTransport, @@ -26,6 +27,7 @@ export { export type { RemoteConnectRequest, RemoteConnectResponse, + RemoteSendResponse, RemoteEvent, } from "./types.js"; export { API_SERVER_ENV_VARS, LEGACY_AUTH_TOKEN_ENV } from "./constants.js"; diff --git a/core/mcp/remote/node/remote-session.ts b/core/mcp/remote/node/remote-session.ts index af4c81dd7..760ed6317 100644 --- a/core/mcp/remote/node/remote-session.ts +++ b/core/mcp/remote/node/remote-session.ts @@ -6,12 +6,21 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; import type { FetchRequestEntryBase } from "../../types.js"; import type { RemoteEvent } from "../types.js"; +import type { AuthChallenge } from "../../../auth/challenge.js"; +import { AuthChallengeError } from "../../../auth/challenge.js"; +import type { RemoteAuthProviderHandle } from "./tokenAuthProvider.js"; +import type { RemoteAuthState } from "../types.js"; export interface SessionEvent { type: RemoteEvent["type"]; data: unknown; } +type RequestWait = { + resolve: () => void; + reject: (error: Error) => void; +}; + export class RemoteSession { public readonly sessionId: string; public transport!: Transport; @@ -19,11 +28,31 @@ export class RemoteSession { private eventConsumer: ((event: SessionEvent) => void) | null = null; private transportDead: boolean = false; private transportError: string | null = null; + private activeSendCount = 0; + /** + * Suppress duplicate ambient SSE auth echoes after HTTP command-scoped delivery. + * Stale transport `onerror` may arrive after the send completes. + */ + private authHttpEchoSuppressUntilMs = 0; + static readonly AUTH_HTTP_ECHO_SUPPRESS_MS = 30_000; + private readonly requestWaits = new Map(); + private authProviderHandle: RemoteAuthProviderHandle | null = null; constructor(sessionId: string) { this.sessionId = sessionId; } + setAuthProviderHandle(handle: RemoteAuthProviderHandle | null): void { + this.authProviderHandle = handle; + } + + setAuthState(authState: RemoteAuthState): void { + if (!this.authProviderHandle) { + throw new Error("Session has no OAuth auth provider"); + } + this.authProviderHandle.setAuthState(authState); + } + setTransport(transport: Transport): void { this.transport = transport; } @@ -72,6 +101,119 @@ export class RemoteSession { return this.eventConsumer !== null; } + beginSend(): void { + this.activeSendCount++; + } + + endSend(): void { + if (this.activeSendCount > 0) { + this.activeSendCount--; + } + } + + /** Command-scoped auth was returned on HTTP; suppress async onerror echo on SSE. */ + noteAuthChallengeDeliveredViaHttp(): void { + this.extendAuthHttpEchoSuppression(); + } + + private extendAuthHttpEchoSuppression(): void { + this.authHttpEchoSuppressUntilMs = + Date.now() + RemoteSession.AUTH_HTTP_ECHO_SUPPRESS_MS; + } + + private shouldSuppressAuthEcho(): boolean { + return Date.now() < this.authHttpEchoSuppressUntilMs; + } + + hasActiveSend(): boolean { + return this.activeSendCount > 0; + } + + pushAuthChallenge(challenge: AuthChallenge): void { + this.pushEvent({ type: "auth_challenge", data: challenge }); + } + + /** + * Streamable HTTP `transport.send()` can return before the JSON-RPC response + * arrives. The remote send handler awaits this so `/api/mcp/send` returns + * `auth_challenge` or `ok: true` only after the MCP round-trip (or auth error). + */ + waitForRequestResponse( + requestId: string | number, + timeoutMs = 60_000, + ): Promise { + return new Promise((resolve, reject) => { + const timer = + timeoutMs > 0 + ? setTimeout(() => { + this.requestWaits.delete(requestId); + reject( + new Error( + `MCP request ${String(requestId)} timed out after ${timeoutMs}ms`, + ), + ); + }, timeoutMs) + : undefined; + + this.requestWaits.set(requestId, { + resolve: () => { + if (timer !== undefined) { + clearTimeout(timer); + } + resolve(); + }, + reject: (error) => { + if (timer !== undefined) { + clearTimeout(timer); + } + reject(error); + }, + }); + }); + } + + cancelRequestWait(requestId: string | number): void { + this.requestWaits.delete(requestId); + } + + rejectActiveRequestWaits(error: Error): void { + for (const wait of this.requestWaits.values()) { + wait.reject(error); + } + this.requestWaits.clear(); + } + + /** + * Auth errors from the MCP transport. + * - During an active send: command path owns delivery (HTTP); never SSE. + * - After HTTP already returned auth_challenge: swallow async onerror echo. + * - Otherwise: ambient SSE (idle session). + */ + handleTransportAuthError(error: unknown): error is AuthChallengeError { + if (!(error instanceof AuthChallengeError)) { + return false; + } + this.rejectActiveRequestWaits(error); + if (this.hasActiveSend()) { + this.extendAuthHttpEchoSuppression(); + return true; + } + if (this.shouldSuppressAuthEcho()) { + return true; + } + this.pushAuthChallenge(error.authChallenge); + return true; + } + + private settleRequestWait(requestId: string | number): void { + const wait = this.requestWaits.get(requestId); + if (!wait) { + return; + } + this.requestWaits.delete(requestId); + wait.resolve(); + } + pushEvent(event: SessionEvent): void { if (this.eventConsumer) { this.eventConsumer(event); @@ -81,6 +223,14 @@ export class RemoteSession { } onMessage(message: JSONRPCMessage): void { + if ( + "id" in message && + message.id !== null && + message.id !== undefined && + ("result" in message || "error" in message) + ) { + this.settleRequestWait(message.id); + } this.pushEvent({ type: "message", data: message }); } diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 5ec0687a4..3df7f60cd 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -26,7 +26,13 @@ import type { Context, Env, Next } from "hono"; import { streamSSE } from "hono/streaming"; import { watch as chokidarWatch, type FSWatcher } from "chokidar"; import { createTransportNode } from "../../node/transport.js"; -import type { RemoteConnectRequest, RemoteSendRequest } from "../types.js"; +import type { + RemoteConnectRequest, + RemoteSendRequest, + RemoteSetAuthStateRequest, +} from "../types.js"; +import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; +import { AuthChallengeError } from "../../../auth/challenge.js"; import { DEFAULT_MAX_FETCH_REQUESTS, DEFAULT_TASK_TTL_MS, @@ -52,7 +58,7 @@ import { } from "../../serverList.js"; import { resolveImportSource } from "../../import/resolveSource.js"; import { RemoteSession } from "./remote-session.js"; -import { createTokenAuthProvider } from "./tokenAuthProvider.js"; +import { createRemoteAuthProvider } from "./tokenAuthProvider.js"; import { API_SERVER_ENV_VARS } from "../constants.js"; import { KeychainUnavailableError, @@ -321,6 +327,20 @@ function forwardLogEvent( } } +function requestIdForSendWait( + message: JSONRPCMessage, +): string | number | undefined { + if ( + "method" in message && + "id" in message && + message.id !== null && + message.id !== undefined + ) { + return message.id; + } + return undefined; +} + export function createRemoteApp( options: RemoteServerOptions, ): CreateRemoteAppResult { @@ -525,9 +545,12 @@ export function createRemoteApp( const session = new RemoteSession(sessionId); let transport: Awaited>["transport"]; + let authHandle: ReturnType; try { - // Create authProvider from tokens if provided - const authProvider = createTokenAuthProvider(body.oauthTokens); + const initialAuthState = + body.authState ?? + (body.oauthTokens ? { oauthTokens: body.oauthTokens } : undefined); + authHandle = createRemoteAuthProvider(initialAuthState); const result = createTransportNode(config, { pipeStderr: true, @@ -535,8 +558,12 @@ export function createRemoteApp( onFetchRequest: (entry) => session.onFetchRequest(entry), onFetchResponseBody: (id, body) => session.onFetchResponseBody(id, body), - authProvider, + authProvider: authHandle?.provider, settings: body.settings, + // Always intercept 401/403 on the node MCP transport. Without this, the + // SDK's built-in auth() retry on a frozen token snapshot can hang or + // succeed locally while /api/mcp/send still awaits a JSON-RPC response. + interceptAuthChallenges: true, }); transport = result.transport; } catch (err) { @@ -544,6 +571,7 @@ export function createRemoteApp( return c.json({ error: `Failed to create transport: ${msg}` }, 500); } + session.setAuthProviderHandle(authHandle ?? null); session.setTransport(transport); transport.onmessage = (msg) => session.onMessage(msg); @@ -558,6 +586,10 @@ export function createRemoteApp( // Set up error handlers BEFORE calling start() so we catch failures during start transport.onerror = (err) => { + if (session.handleTransportAuthError(err)) { + originalOnerror?.(err); + return; + } transportFailed = true; transportError = err instanceof Error ? err.message : String(err); originalOnerror?.(err); @@ -609,6 +641,19 @@ export function createRemoteApp( } } catch (err) { // transport.start() threw - this is the expected failure path + if (err instanceof AuthChallengeError) { + try { + await transport.close(); + } catch { + // Best-effort cleanup when connect fails with auth challenge. + } + session.noteAuthChallengeDeliveredViaHttp(); + return c.json({ + ok: false, + kind: "auth_challenge", + authChallenge: err.authChallenge, + }); + } const msg = err instanceof Error ? err.message : String(err); // Preserve 401 only when the transport/SDK reports it (no message guessing) const status = @@ -648,22 +693,41 @@ export function createRemoteApp( // Check if transport is dead - return error immediately (matches local behavior) if (session.isTransportDead()) { const errorMsg = session.getTransportError() || "Transport closed"; - return c.json({ error: errorMsg }, 500); + return c.json({ ok: false, kind: "transport_error", error: errorMsg }); } + session.beginSend(); + const requestId = requestIdForSendWait(message); + let responseWait: Promise | undefined; + if (requestId !== undefined) { + responseWait = session.waitForRequestResponse(requestId); + // Auth errors may reject the wait via onerror while send() also throws. + void responseWait.catch(() => {}); + } try { await session.transport.send(message, { relatedRequestId: relatedRequestId as string | number | undefined, }); + if (responseWait) { + await responseWait; + } return c.json({ ok: true }); } catch (err) { + if (requestId !== undefined) { + session.cancelRequestWait(requestId); + } + if (err instanceof AuthChallengeError) { + session.noteAuthChallengeDeliveredViaHttp(); + return c.json({ + ok: false, + kind: "auth_challenge", + authChallenge: err.authChallenge, + }); + } const msg = err instanceof Error ? err.message : String(err); - // Preserve 401 only when the transport/SDK reports it (no message guessing) - const status = - (err as { code?: number; status?: number }).code ?? - (err as { code?: number; status?: number }).status; - const is401 = status === 401; - return c.json({ error: msg }, is401 ? 401 : 500); + return c.json({ ok: false, kind: "transport_error", error: msg }); + } finally { + session.endSend(); } }); @@ -746,6 +810,39 @@ export function createRemoteApp( return c.json({ ok: true }); }); + app.post("/api/mcp/auth-state", async (c) => { + let body: RemoteSetAuthStateRequest; + try { + body = (await c.req.json()) as RemoteSetAuthStateRequest; + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + + const { sessionId, authState } = body; + if (!sessionId || !authState) { + return c.json({ error: "Missing sessionId or authState" }, 400); + } + + const session = sessions.get(sessionId); + if (!session) { + return c.json({ error: "Session not found" }, 404); + } + + if (session.isTransportDead()) { + const errorMsg = session.getTransportError() || "Transport closed"; + return c.json({ ok: false, kind: "transport_error", error: errorMsg }); + } + + try { + session.setAuthState(authState); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return c.json({ error: msg }, 400); + } + + return c.json({ ok: true }); + }); + app.post("/api/fetch", async (c) => { let body: { url: string; diff --git a/core/mcp/remote/node/tokenAuthProvider.ts b/core/mcp/remote/node/tokenAuthProvider.ts index ea51c4b61..3793eb721 100644 --- a/core/mcp/remote/node/tokenAuthProvider.ts +++ b/core/mcp/remote/node/tokenAuthProvider.ts @@ -1,29 +1,48 @@ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; import type { + OAuthClientInformation, OAuthClientMetadata, OAuthTokens, } from "@modelcontextprotocol/sdk/shared/auth.js"; -import type { RemoteConnectRequest } from "../types.js"; +import type { RemoteAuthState } from "../types.js"; const REMOTE_OAUTH_STUB_METADATA: OAuthClientMetadata = { redirect_uris: [], scope: "", }; +export interface RemoteAuthProviderHandle { + provider: OAuthClientProvider; + setAuthState: (authState: RemoteAuthState) => void; + getAuthState: () => RemoteAuthState; +} + +function cloneAuthState(state: RemoteAuthState): RemoteAuthState { + return { + ...(state.oauthTokens && { + oauthTokens: { ...state.oauthTokens }, + }), + ...(state.oauthClient && { + oauthClient: { ...state.oauthClient }, + }), + }; +} + /** - * Simple OAuth client provider that just returns tokens. - * Used by the remote server to inject Bearer tokens into transport requests. - * - * The SDK may invoke {@link auth} on 401; stubs must satisfy the full provider - * surface so that path fails with a clear error instead of throwing on - * undefined `clientMetadata.scope`. + * Mutable OAuth provider for the remote backend MCP transport. + * Browser (or CLI) owns interactive OAuth; this injects Bearer tokens and can + * be hot-updated via {@link RemoteAuthProviderHandle.setAuthState}. */ -export function createTokenAuthProvider( - tokens: RemoteConnectRequest["oauthTokens"], -): OAuthClientProvider | undefined { - if (!tokens) return undefined; +export function createRemoteAuthProvider( + initialAuthState?: RemoteAuthState, +): RemoteAuthProviderHandle | undefined { + if (!initialAuthState?.oauthTokens && !initialAuthState?.oauthClient) { + return undefined; + } - return { + let authState = cloneAuthState(initialAuthState); + + const provider = { get clientMetadata(): OAuthClientMetadata { return REMOTE_OAUTH_STUB_METADATA; }, @@ -31,13 +50,31 @@ export function createTokenAuthProvider( return ""; }, async tokens(): Promise { - return tokens as OAuthTokens; + return authState.oauthTokens as OAuthTokens | undefined; }, - async clientInformation() { - return undefined; + async clientInformation(): Promise { + if (!authState.oauthClient?.client_id) { + return undefined; + } + return { + client_id: authState.oauthClient.client_id, + ...(authState.oauthClient.client_secret && { + client_secret: authState.oauthClient.client_secret, + }), + }; }, - async saveTokens() { - // No-op + async saveTokens(tokens: OAuthTokens) { + authState = { + ...authState, + oauthTokens: { + access_token: tokens.access_token, + token_type: tokens.token_type, + expires_in: tokens.expires_in, + refresh_token: tokens.refresh_token, + scope: tokens.scope, + id_token: tokens.id_token, + }, + }; }, codeVerifier() { return undefined; @@ -46,7 +83,7 @@ export function createTokenAuthProvider( // No-op }, clear() { - // No-op + authState = {}; }, async redirectToAuthorization() { throw new Error( @@ -57,4 +94,14 @@ export function createTokenAuthProvider( return ""; }, } as unknown as OAuthClientProvider; + + return { + provider, + setAuthState(next: RemoteAuthState) { + authState = cloneAuthState(next); + }, + getAuthState() { + return cloneAuthState(authState); + }, + }; } diff --git a/core/mcp/remote/remoteClientTransport.ts b/core/mcp/remote/remoteClientTransport.ts index b145c7aa1..306d91aa4 100644 --- a/core/mcp/remote/remoteClientTransport.ts +++ b/core/mcp/remote/remoteClientTransport.ts @@ -13,11 +13,33 @@ import type { } from "@modelcontextprotocol/sdk/types.js"; import type { InspectorServerSettings, StderrLogEntry } from "../types.js"; import type { FetchRequestEntryBase } from "../types.js"; +import type { + AuthChallenge, + AuthChallengeOutcome, + HandleAuthChallengeOptions, +} from "../../auth/challenge.js"; +import { + AuthChallengeError, + AuthRecoveryRequiredError, + EMA_STEP_UP_PENDING_URL, +} from "../../auth/challenge.js"; import type { RemoteConnectRequest, RemoteConnectResponse, + RemoteAuthState, RemoteEvent, + RemoteSendResponse, } from "./types.js"; +import { oauthTokensToRemoteAuthState } from "./types.js"; + +export interface AuthRecoveryHandlers { + handleAuthChallenge( + challenge: AuthChallenge, + options?: HandleAuthChallengeOptions, + ): Promise; + /** Push recovered auth state to the remote backend (same session). */ + pushAuthState?: (authState?: RemoteAuthState) => Promise; +} export interface RemoteTransportOptions { /** Base URL of the remote server (e.g. http://localhost:3000) */ @@ -47,6 +69,84 @@ export interface RemoteTransportOptions { * custom headers (SSE / streamable-http). */ settings?: InspectorServerSettings; + + /** Mid-session auth recovery (handle challenge on command-scoped send). */ + authRecovery?: AuthRecoveryHandlers; + + /** Ambient auth challenges delivered via SSE (no active send). */ + onAuthChallenge?: (challenge: AuthChallenge) => void; + + /** Max wait for a JSON-RPC response on SSE after HTTP `{ ok: true }`. Default 60s. */ + sseResponseTimeoutMs?: number; +} + +const DEFAULT_SSE_RESPONSE_TIMEOUT_MS = 60_000; + +type SseResponseWait = { + resolve: () => void; + reject: (error: Error) => void; +}; + +function requestIdForMessage( + message: JSONRPCMessage, +): string | number | undefined { + if ( + "method" in message && + "id" in message && + message.id !== null && + message.id !== undefined + ) { + return message.id; + } + return undefined; +} + +function isConnectAuthChallenge( + json: RemoteConnectResponse, +): json is Extract { + return ( + typeof json === "object" && + json !== null && + "ok" in json && + json.ok === false && + "kind" in json && + json.kind === "auth_challenge" + ); +} + +function isConnectTransportError( + json: RemoteConnectResponse, +): json is Extract { + return ( + typeof json === "object" && + json !== null && + "ok" in json && + json.ok === false && + "kind" in json && + json.kind === "transport_error" + ); +} + +function legacySessionId(json: RemoteConnectResponse): string | undefined { + if ( + typeof json === "object" && + json !== null && + "sessionId" in json && + typeof json.sessionId === "string" && + !("ok" in json) + ) { + return json.sessionId; + } + if ( + typeof json === "object" && + json !== null && + "ok" in json && + json.ok === true && + "sessionId" in json + ) { + return json.sessionId; + } + return undefined; } /** @@ -109,7 +209,10 @@ export class RemoteClientTransport implements Transport { private eventStreamReader: ReadableStreamDefaultReader | null = null; private eventStreamAbort: AbortController | null = null; + private eventStreamConsumeTask: Promise | null = null; + private restartingEventStream = false; private closed = false; + private readonly sseResponseWaits = new Map(); private readonly options: RemoteTransportOptions; private readonly config: import("../types.js").MCPServerConfig; @@ -124,6 +227,24 @@ export class RemoteClientTransport implements Transport { return undefined; } + /** Remote Hono session id (distinct from MCP protocol session). */ + getRemoteBackendSessionId(): string | undefined { + return this._sessionId; + } + + /** + * Reattach to an existing remote backend session after a full-page OAuth + * redirect. Opens the SSE event stream without POST /connect. + */ + async attachToSession(sessionId: string): Promise { + if (this.closed) { + this.closed = false; + } + await this.stopEventStream(); + this._sessionId = sessionId; + await this.openEventStream(); + } + constructor( options: RemoteTransportOptions, config: import("../types.js").MCPServerConfig, @@ -132,6 +253,16 @@ export class RemoteClientTransport implements Transport { this.config = config; } + setAuthRecovery(handlers: AuthRecoveryHandlers | undefined): void { + this.options.authRecovery = handlers; + } + + setOnAuthChallenge( + handler: ((challenge: AuthChallenge) => void) | undefined, + ): void { + this.options.onAuthChallenge = handler; + } + private get fetchFn(): typeof fetch { return this.options.fetchFn ?? globalThis.fetch; } @@ -150,28 +281,62 @@ export class RemoteClientTransport implements Transport { return h; } + private async recoverFromAuthChallenge( + challenge: AuthChallenge, + retry: () => Promise, + ): Promise { + const recovery = this.options.authRecovery; + if (!recovery) { + throw new AuthChallengeError( + challenge, + challenge.raw?.httpStatus ?? 401, + ); + } + + const outcome = await recovery.handleAuthChallenge(challenge); + if (outcome.kind === "satisfied") { + await retry(); + return; + } + if (outcome.kind === "step_up_confirm") { + throw new AuthRecoveryRequiredError( + EMA_STEP_UP_PENDING_URL, + outcome.challenge, + { emaStepUpConfirm: true }, + ); + } + if (outcome.kind === "interactive") { + throw new AuthRecoveryRequiredError( + outcome.authorizationUrl, + outcome.challenge, + ); + } + throw outcome.error; + } + async start(): Promise { + if (this._sessionId && !this.closed) { + return; + } + return this.startWithRecovery(0); + } + + private async startWithRecovery(retryCount: number): Promise { /* v8 ignore next -- the sessionId getter is hardcoded to return undefined (see its doc comment), so this guard is never taken; it exists to satisfy the Transport contract. */ if (this.sessionId) return; if (this.closed) throw new Error("Transport is closed"); - // Extract OAuth tokens from authProvider if available - let oauthTokens: RemoteConnectRequest["oauthTokens"] | undefined; + let authState: RemoteAuthState | undefined; if (this.options.authProvider) { const tokens = await this.options.authProvider.tokens(); if (tokens) { - oauthTokens = { - access_token: tokens.access_token, - token_type: tokens.token_type, - expires_in: tokens.expires_in, - refresh_token: tokens.refresh_token, - }; + authState = oauthTokensToRemoteAuthState(tokens); } } const body: RemoteConnectRequest = { config: this.config, - oauthTokens, + ...(authState && { authState }), ...(this.options.settings && { settings: this.options.settings }), }; @@ -183,20 +348,40 @@ export class RemoteClientTransport implements Transport { if (!res.ok) { const text = await res.text(); - // Preserve the status code in the error so callers can detect 401 const error = new Error(`Remote connect failed (${res.status}): ${text}`); (error as { status?: number }).status = res.status; throw error; } const json = (await res.json()) as RemoteConnectResponse; - this._sessionId = json.sessionId; - if (!this._sessionId) { + if (isConnectAuthChallenge(json)) { + if (retryCount >= 1) { + throw new AuthChallengeError( + json.authChallenge, + json.authChallenge.raw?.httpStatus ?? 401, + ); + } + await this.recoverFromAuthChallenge(json.authChallenge, () => + this.startWithRecovery(retryCount + 1), + ); + return; + } + + if (isConnectTransportError(json)) { + throw new Error(`Remote connect failed: ${json.error}`); + } + + const sessionId = legacySessionId(json); + if (!sessionId) { throw new Error("Remote did not return sessionId"); } - // Open SSE event stream + this._sessionId = sessionId; + await this.openEventStream(); + } + + private async openEventStream(): Promise { this.eventStreamAbort = new AbortController(); const eventRes = await this.fetchFn( `${this.baseUrl}/api/mcp/events?sessionId=${encodeURIComponent(this._sessionId!)}`, @@ -221,7 +406,30 @@ export class RemoteClientTransport implements Transport { } this.eventStreamReader = bodyStream.getReader(); - this.consumeEventStream(); + this.eventStreamConsumeTask = this.consumeEventStream(); + } + + /** Stop the SSE consumer and release the reader before opening a new stream. */ + private async stopEventStream(): Promise { + this.restartingEventStream = true; + try { + this.eventStreamAbort?.abort(); + const reader = this.eventStreamReader; + if (reader) { + try { + await reader.cancel(); + } catch { + // Ignore cancel errors during close + } + } + this.eventStreamReader = null; + if (this.eventStreamConsumeTask) { + await this.eventStreamConsumeTask; + this.eventStreamConsumeTask = null; + } + } finally { + this.restartingEventStream = false; + } } private async consumeEventStream(): Promise { @@ -236,7 +444,9 @@ export class RemoteClientTransport implements Transport { const parsed = JSON.parse(data) as RemoteEvent; if (parsed.type === "message") { - this.onmessage?.(parsed.data as JSONRPCMessage, undefined); + const msg = parsed.data as JSONRPCMessage; + this.settleSseResponseWait(msg); + this.onmessage?.(msg, undefined); } else if ( parsed.type === "fetch_request" && this.options.onFetchRequest @@ -262,32 +472,32 @@ export class RemoteClientTransport implements Transport { timestamp: new Date(parsed.data.timestamp), message: parsed.data.message, }); + } else if (parsed.type === "auth_challenge") { + this.options.onAuthChallenge?.(parsed.data); } else if (parsed.type === "transport_error") { - // Transport died - notify client and close (matches local behavior) const error = new Error(parsed.data.error); if (parsed.data.code !== undefined) { (error as { code?: number | string }).code = parsed.data.code; } - this.onerror?.(error); - // Also trigger onclose to match local transport behavior - if (!this.closed) { - this.closed = true; - this.onclose?.(); + if (!this.restartingEventStream) { + this.onerror?.(error); + if (!this.closed) { + this.closed = true; + this.onclose?.(); + } } } } catch (err) { - // JSON parse error or other processing error - report but continue this.onerror?.(err instanceof Error ? err : new Error(String(err))); } } } catch (err) { - // Stream reading error (network issue, abort, etc.) if (!this.closed && err instanceof Error && err.name !== "AbortError") { this.onerror?.(err); } } finally { this.eventStreamReader = null; - if (!this.closed) { + if (!this.closed && !this.restartingEventStream) { this.closed = true; this.onclose?.(); } @@ -298,6 +508,167 @@ export class RemoteClientTransport implements Transport { message: JSONRPCMessage, options?: TransportSendOptions, ): Promise { + return this.postSend(message, options, 0); + } + + /** + * Push auth state to the remote backend without tearing down the session. + * Used after mid-session OAuth recovery in the web client. + */ + async pushAuthState(authState?: RemoteAuthState): Promise { + if (!this._sessionId) { + throw new Error("Transport not started"); + } + if (this.closed) { + throw new Error("Transport is closed"); + } + + const state = authState ?? (await this.buildAuthStateFromProvider()); + if (!state.oauthTokens && !state.oauthClient) { + throw new Error("No auth state to push"); + } + + const res = await this.fetchFn(`${this.baseUrl}/api/mcp/auth-state`, { + method: "POST", + headers: this.headers, + body: JSON.stringify({ sessionId: this._sessionId, authState: state }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Remote auth-state update failed (${res.status}): ${text}`); + } + + const json = (await res.json()) as { ok?: boolean; error?: string }; + if (!json.ok) { + throw new Error(json.error ?? "Remote auth-state update failed"); + } + } + + private async buildAuthStateFromProvider(): Promise { + if (!this.options.authProvider) { + throw new Error("No auth provider configured"); + } + const tokens = await this.options.authProvider.tokens(); + if (!tokens) { + throw new Error("No OAuth tokens available"); + } + return oauthTokensToRemoteAuthState(tokens); + } + + private get sseResponseTimeoutMs(): number { + return this.options.sseResponseTimeoutMs ?? DEFAULT_SSE_RESPONSE_TIMEOUT_MS; + } + + private settleSseResponseWait(message: JSONRPCMessage): void { + if ( + !("id" in message) || + message.id === null || + message.id === undefined || + (!("result" in message) && !("error" in message)) + ) { + return; + } + const wait = this.sseResponseWaits.get(message.id); + if (!wait) { + return; + } + this.sseResponseWaits.delete(message.id); + wait.resolve(); + } + + private cancelSseResponseWait(requestId: string | number): void { + this.sseResponseWaits.delete(requestId); + } + + private cancelAllSseWaits(error: Error): void { + for (const wait of this.sseResponseWaits.values()) { + wait.reject(error); + } + this.sseResponseWaits.clear(); + } + + private waitForSseResponse(requestId: string | number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.sseResponseWaits.delete(requestId); + reject( + new Error( + `Timed out waiting for MCP response on SSE (${this.sseResponseTimeoutMs}ms)`, + ), + ); + }, this.sseResponseTimeoutMs); + this.sseResponseWaits.set(requestId, { + resolve: () => { + clearTimeout(timer); + resolve(); + }, + reject: (error) => { + clearTimeout(timer); + reject(error); + }, + }); + }); + } + + private async postSend( + message: JSONRPCMessage, + options: TransportSendOptions | undefined, + retryCount: number, + ): Promise { + const requestId = requestIdForMessage(message); + let sseWait: Promise | undefined; + if (requestId !== undefined) { + sseWait = this.waitForSseResponse(requestId); + void sseWait.catch(() => {}); + } + + let json: RemoteSendResponse; + try { + json = await this.requestSend(message, options); + } catch (error) { + if (requestId !== undefined) { + this.cancelSseResponseWait(requestId); + } + throw error; + } + + if (json.ok) { + if (sseWait) { + await sseWait; + } + return; + } + + if (requestId !== undefined) { + this.cancelSseResponseWait(requestId); + } + + if (json.kind === "auth_challenge") { + // Send-time recovery requires pushAuthState on the existing remote session. + // Connect-time recovery (startWithRecovery) retries with fresh authState in + // the connect body instead — see startWithRecovery(). + if (retryCount >= 1 || !this.options.authRecovery?.pushAuthState) { + throw new AuthChallengeError( + json.authChallenge, + json.authChallenge.raw?.httpStatus ?? 401, + ); + } + + await this.recoverFromAuthChallenge(json.authChallenge, async () => { + await this.options.authRecovery!.pushAuthState!(); + return this.postSend(message, options, retryCount + 1); + }); + return; + } + + throw new Error(json.error); + } + + private async requestSend( + message: JSONRPCMessage, + options?: TransportSendOptions, + ): Promise { if (!this._sessionId) { throw new Error("Transport not started"); } @@ -325,14 +696,16 @@ export class RemoteClientTransport implements Transport { (error as { status?: number }).status = res.status; throw error; } + + return (await res.json()) as RemoteSendResponse; } async close(): Promise { if (this.closed) return; this.closed = true; - this.eventStreamAbort?.abort(); - this.eventStreamReader = null; + this.cancelAllSseWaits(new Error("Transport closed")); + await this.stopEventStream(); if (this._sessionId) { try { diff --git a/core/mcp/remote/types.ts b/core/mcp/remote/types.ts index c2cc1728b..7c3ffea52 100644 --- a/core/mcp/remote/types.ts +++ b/core/mcp/remote/types.ts @@ -8,6 +8,50 @@ import type { InspectorServerSettings, } from "../types.js"; import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; +import type { AuthChallenge } from "../../auth/challenge.js"; +import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; + +/** OAuth token set pushed to the remote backend for upstream MCP Bearer auth. */ +export interface RemoteMcpOAuthTokens { + access_token: string; + token_type: string; + expires_in?: number; + refresh_token?: string; + scope?: string; + id_token?: string; +} + +/** + * Auth credentials for the upstream MCP transport on the remote backend. + * Extensible so server-side token refresh can be added without a new API shape. + */ +export interface RemoteAuthState { + /** Bearer tokens for HTTP MCP transports (streamable-http / SSE). */ + oauthTokens?: RemoteMcpOAuthTokens; + /** + * OAuth client credentials for server-side refresh at the token endpoint. + * Optional today while the browser owns interactive OAuth and refresh. + */ + oauthClient?: { + client_id: string; + client_secret?: string; + }; +} + +export function oauthTokensToRemoteAuthState( + tokens: OAuthTokens | RemoteMcpOAuthTokens, +): RemoteAuthState { + return { + oauthTokens: { + access_token: tokens.access_token, + token_type: tokens.token_type, + expires_in: tokens.expires_in, + refresh_token: tokens.refresh_token, + scope: tokens.scope, + id_token: tokens.id_token, + }, + }; +} export interface RemoteConnectRequest { /** MCP server config (stdio, sse, or streamable-http) */ @@ -18,19 +62,60 @@ export interface RemoteConnectRequest { * from the legacy `config.headers` field (which has been removed). */ settings?: InspectorServerSettings; - /** Optional OAuth tokens for Bearer authentication (for HTTP transports) */ - oauthTokens?: { - access_token: string; - token_type: string; - expires_in?: number; - refresh_token?: string; - }; + /** + * Initial auth for upstream MCP HTTP transports. + * Prefer {@link authState}; {@link oauthTokens} is accepted for compatibility. + */ + authState?: RemoteAuthState; + /** @deprecated Prefer {@link authState}. */ + oauthTokens?: RemoteMcpOAuthTokens; } -export interface RemoteConnectResponse { +export interface RemoteSetAuthStateRequest { sessionId: string; + authState: RemoteAuthState; +} + +export interface RemoteSetAuthStateResponse { + ok: true; +} + +export interface RemoteConnectResponseSuccess { + ok: true; + sessionId: string; +} + +export interface RemoteConnectResponseAuthChallenge { + ok: false; + kind: "auth_challenge"; + authChallenge: AuthChallenge; +} + +export interface RemoteConnectResponseTransportError { + ok: false; + kind: "transport_error"; + error: string; } +export type RemoteConnectResponse = + | RemoteConnectResponseSuccess + | RemoteConnectResponseAuthChallenge + | RemoteConnectResponseTransportError + | { sessionId: string }; + +export type RemoteSendResponse = + | { ok: true } + | { + ok: false; + kind: "auth_challenge"; + authChallenge: AuthChallenge; + } + | { + ok: false; + kind: "transport_error"; + error: string; + }; + export interface RemoteSendRequest { message: JSONRPCMessage; /** Optional, for associating response with request (e.g. streamable-http) */ @@ -42,7 +127,8 @@ export type RemoteEventType = | "fetch_request" | "fetch_request_body_update" | "stdio_log" - | "transport_error"; + | "transport_error" + | "auth_challenge"; export interface RemoteEventMessage { type: "message"; @@ -72,9 +158,15 @@ export interface RemoteEventTransportError { }; } +export interface RemoteEventAuthChallenge { + type: "auth_challenge"; + data: AuthChallenge; +} + export type RemoteEvent = | RemoteEventMessage | RemoteEventFetchRequest | RemoteEventFetchRequestBodyUpdate | RemoteEventStdioLog - | RemoteEventTransportError; + | RemoteEventTransportError + | RemoteEventAuthChallenge; diff --git a/core/mcp/types.ts b/core/mcp/types.ts index d9c8c480b..424556b1f 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -545,6 +545,12 @@ export interface CreateTransportOptions { * Stdio ignores this — headers are not applicable. */ settings?: InspectorServerSettings; + + /** + * When true, wrap HTTP transport fetch with auth-challenge detection so 401/403 + * become {@link AuthChallengeError} before the SDK calls `auth()` on a frozen provider. + */ + interceptAuthChallenges?: boolean; } export interface CreateTransportResult { @@ -746,6 +752,13 @@ export interface InspectorClientOptions { */ installEnterpriseManagedAuth?: ClientConfig["enterpriseManagedAuth"]; + /** + * When true, direct transports (TUI/CLI) route MCP 401/403 through + * `handleAuthChallenge()` via fetch intercept instead of the SDK auth() path. + * Web remote clients should leave this false (default). + */ + directAuthRecovery?: boolean; + /** * Optional session ID. If not provided, will be extracted from OAuth state * when OAuth flow starts. Passed in saveSession event for FetchRequestLogState. diff --git a/specification/v2_auth_ema.md b/specification/v2_auth_ema.md index 1ac358801..702c39aa3 100644 --- a/specification/v2_auth_ema.md +++ b/specification/v2_auth_ema.md @@ -365,6 +365,22 @@ Leg 1 reuses the existing OAuth redirect/callback/PKCE machinery but **targets t **401 re-auth:** on EMA connections, re-run legs 2–3 (and leg 1 only if the cached ID Token is missing or expired). Do not fall back to standard resource authorization-code OAuth. Mid-session detection, step-up scopes, and web remote propagation are specified in **[Mid-session auth](v2_auth_mid_session.md)**. +### EMA step-up (web confirmation) + +When an EMA server returns **403** + `insufficient_scope`, `OAuthManager.handleAuthChallenge()` returns **`step_up_confirm`** until the web user clicks **Authorize** in `StepUpAuthModal`. Only then does Inspector call `trySilentEmaAuth()` (legs 2–3 re-mint with SEP-2350 union scopes) or start IdP leg 1 if the organization session is missing. + +**Why confirm on web?** EMA can often upgrade scopes silently when the IdP session is still valid. Inspector deliberately shows the additional scopes first — same pattern as standard OAuth step-up — because it is a **testing and exploration** client: operators should see permission elevation while validating MCP servers, not discover it only after the fact. + +**Web UX after Authorize:** + +| Outcome | User sees | +| ------- | --------- | +| `satisfied` (silent re-mint) | Blue in-progress toast → green “Organization permissions were updated” (+ “Retry your action” when step-up was triggered by a tool/prompt/resource/app) | +| `interactive` (IdP redirect) | Pre-redirect toast → full-page IdP callback → same resume snapshot behavior as other EMA flows | +| `failed` | Red toast with error detail; triggering panel shows failure | + +TUI/CLI still use their existing confirm prompts (Auth tab **A**/**C**, CLI **y/N**) before silent re-mint — no web modal. + **EMA resource token tagging + sign-out:** per-server `oauth.enterpriseManaged` in `mcp.json` is **config** (routing); the OAuth store does not read the catalog on sign-out. Instead, when EMA legs 2–3 persist a resource access token, the store tags that entry (`ServerOAuthState.enterpriseManaged: true` via `SaveTokensOptions`). Sign-out uses that tag to find and clear EMA resource state inside the same `OAuthStorage` blob without the client enumerating MCP servers. This avoids clearing standard OAuth servers and avoids clearing EMA catalog entries that were never connected. ## Inspector mapping @@ -448,7 +464,7 @@ Design decisions for EMA are complete. Remaining work is the phased plan and che #### Phase 4 — Other clients (after web works) 13. ~~Wire TUI/CLI to load `client.json` and pass `enterpriseManagedAuth` into `InspectorClient`.~~ **Done** — `loadRunnerClientConfig` + `buildRunnerClientAuthOptions` in TUI/CLI entrypoints. EMA sign-out (`clearEmaIdpSession`) is already client-agnostic in `core/` — Phase 4 clients can call it without catalog enumeration once Client Settings / logout UX lands. -14. ~~TUI interactive EMA + standard OAuth connect~~ **Done (TUI)** — 401 → `authenticate()` → `OAuthCallbackServer` on `6276` → reconnect; Auth tab OAuth snapshot + **S** clear (disconnects when connected). **Still open:** dedicated Client Settings UX in terminal, CLI local callback server for interactive login, EMA-specific terminal UX refinements. +14. ~~TUI interactive EMA + standard OAuth connect~~ **Done (TUI)** — 401 → `authenticate()` → `OAuthCallbackServer` on `6276` → reconnect; Auth tab OAuth snapshot + **S** clear (disconnects when connected). **Still open:** dedicated Client Settings UX in terminal, [CLI interactive OAuth](v2_auth_mid_session.md#tui-and-cli-implementation), EMA-specific terminal UX refinements. ## Implementation checklist @@ -493,7 +509,7 @@ Design decisions for EMA are complete. Remaining work is the phased plan and che - [x] Wire TUI/CLI to load client config → `InspectorClientOptions.enterpriseManagedAuth` (+ CIMD / per-server OAuth via `buildRunnerClientAuthOptions`) - [x] TUI interactive EMA + standard OAuth connect (401 → authenticate → callback → reconnect; Auth tab; keychain secret rehydration via `loadServerEntries`) -- [ ] TUI/CLI polish: Client Settings dialog, CLI interactive callback server, terminal EMA UX refinements +- [ ] TUI/CLI polish: Client Settings dialog, [CLI interactive OAuth](v2_auth_mid_session.md#tui-and-cli-implementation), terminal EMA UX refinements ### Later diff --git a/specification/v2_auth_hardening.md b/specification/v2_auth_hardening.md index 5f9a42b0e..a567bc597 100644 --- a/specification/v2_auth_hardening.md +++ b/specification/v2_auth_hardening.md @@ -25,10 +25,11 @@ The [2026-07-28 release candidate](https://blog.modelcontextprotocol.io/posts/20 | Concern | Mid-session spec | This doc | | ------- | ---------------- | -------- | -| **SEP-2350** scope union on 403 step-up | `handleAuthChallenge()` owns union + UX for runtime challenges | Connect-time step-up inherits SDK union after v2 upgrade; mid-session still uses `saveScope(authorizationScopes)` in the challenge handler | +| **SEP-2350** scope union on 403 step-up | `handleAuthChallenge()` + `mcpAuth({ forceReauthorization })` for **web remote**, EMA, and **interactive** defer; union persisted on `completeOAuthFlow` success | **Direct streamable HTTP silent step-up:** v2 SDK transport ([#2265](https://github.com/modelcontextprotocol/typescript-sdk/pull/2265)). **Do not** duplicate with client-side fetch intercept or `AuthRecoveryTransport` on TUI/CLI | | **SEP-2207** refresh / `offline_access` | EMA legs 2–3 re-mint; standard OAuth silent refresh | Connect-time scope selection and DCR metadata | | **SEP-2468** `iss` validation | Applies to any interactive re-auth redirect (401 mid-session) | Connect-time callback wiring is the first place to land `iss` passthrough | | **SEP-2352** issuer-bound credentials | Re-register after AS migration may surface as mid-session failure | Storage and `invalidateCredentials` must be correct before mid-session recovery can succeed | +| **TUI/CLI mid-session** | [TUI and CLI implementation](v2_auth_mid_session.md#tui-and-cli-implementation): provider + UX; silent retry via v2 SDK | Provider v2 hooks (`invalidateCredentials`, issuer-keyed storage, `application_type`) | Implement mid-session auth now. Land auth hardening on the v2 SDK upgrade path without blocking mid-session work — use compatible storage and callback shapes so both tracks merge cleanly. @@ -53,8 +54,17 @@ Inspector today uses **`@modelcontextprotocol/sdk` ^1.29.0** (v1 monolith). Auth Connect-time standard OAuth EMA Web remote backend ─────────────────────────── ─── ────────────────── SDK auth() + BaseOAuthClientProvider Local wire (ema/) Frozen token stub -OAuthStorage keyed by serverUrl IdP OIDC in idpOidc.ts No interactive OAuth -completeOAuthFlow(code) only No SDK CrossAppAccess Mid-session → browser +OAuthStorage keyed by serverUrl IdP OIDC in idpOidc.ts No interactive OAuth on node +completeOAuthFlow(code, iss?) v2 CrossAppAccess optional Mid-session → browser + (evaluate vs ema/) fetch intercept (permanent) + RemoteClientTransport (permanent) + +TUI / CLI direct (not web remote): +────────────────────────────────── +StreamableHTTPClientTransport + live OAuthClientProvider in-process +v2 SDK: silent 401/403 retry + unionScopes on streamable HTTP (#2265) +Inspector: handleAuthChallenge for EMA + interactive defer; TUI/CLI UX only +Do NOT: fetch intercept or AuthRecoveryTransport on client SDK transport ``` ### What the v2 SDK provides (after upgrade) @@ -62,7 +72,7 @@ completeOAuthFlow(code) only No SDK CrossAppAccess Mid-session | SEP | SDK status (Jun 2026) | Inspector gets | | --- | --------------------- | -------------- | | SEP-2207 | Implemented on v2 `main` ([#2199](https://github.com/modelcontextprotocol/typescript-sdk/issues/2199)) | `offline_access` augmentation in authorize scope for standard OAuth via `auth()` | -| SEP-2350 | Open PR [#2265](https://github.com/modelcontextprotocol/typescript-sdk/pull/2265) | `unionScopes()` + 403 retry uses union on streamable HTTP direct transport | +| SEP-2350 | Open PR [#2265](https://github.com/modelcontextprotocol/typescript-sdk/pull/2265) | `unionScopes()` + 403 retry uses union on **streamable HTTP direct transport**; SSE unchanged (401 only) | | SEP-2352 | Open PR [#2271](https://github.com/modelcontextprotocol/typescript-sdk/pull/2271) | AS migration detection → `invalidateCredentials` → re-DCR | | SEP-2468 | Open PR [#2272](https://github.com/modelcontextprotocol/typescript-sdk/pull/2272) | `iss` validation in code-exchange path when host passes `iss` | | SEP-837 | Open [#2198](https://github.com/modelcontextprotocol/typescript-sdk/issues/2198) | `application_type` in DCR request body | @@ -76,7 +86,7 @@ completeOAuthFlow(code) only No SDK CrossAppAccess Mid-session | SEP-837 | Ensure web vs TUI/CLI set correct client type (`web` / `native`) in provider metadata or environment | SDK may infer from redirect URI; Inspector must not mis-declare cross-environment | | SEP-2352 | Key `OAuthStorage` credentials by AS `issuer` (not only `serverUrl`); implement `invalidateCredentials` on `BaseOAuthClientProvider`; migrate shared `oauth.json` | Storage is Inspector-owned; SDK calls provider hooks | | SEP-2207 | EMA leg 1 already requests `openid offline_access` (`IDP_OIDC_SCOPES`); verify standard OAuth path after upgrade | EMA bypasses SDK authorize for leg 1 | -| SEP-2350 | Mid-session union in `handleAuthChallenge()` + `saveScope(authorizationScopes)`; web/EMA paths bypass SDK transport retry | See [Mid-session auth](v2_auth_mid_session.md) | +| SEP-2350 | **Web remote:** mid-session union in `handleAuthChallenge()` (bypasses SDK — proxy architecture). **TUI/CLI direct silent step-up:** v2 SDK transport after #2265. **Interactive defer** (step-up modal / Auth tab confirm): Inspector `setSuppressAuthorizationNavigation` + `mcpAuth({ forceReauthorization })` | See [Mid-session auth § Core API](v2_auth_mid_session.md#core-api--handleauthchallenge) and [TUI and CLI implementation](v2_auth_mid_session.md#tui-and-cli-implementation) | | SEP-2351 | Verify discovery after upgrade; no duplicate discovery logic in Inspector | Uses SDK `discoverOAuthProtectedResourceMetadata` today | **Do not duplicate** SDK `auth.ts` logic (RFC 9207 decision table, AS migration edge cases, scope union helpers). Upgrade and wire. @@ -89,15 +99,29 @@ completeOAuthFlow(code) only No SDK CrossAppAccess Mid-session 2. **Wire** callback parameters, storage, and client-type metadata in Inspector. 3. **Extend** EMA and mid-session paths locally where the SDK has no EMA/XAA API. 4. **Upgrade** from `@modelcontextprotocol/sdk` v1 to v2 packages as a gated step — not a silent dependency bump. +5. **Do not unify transport layers** between web remote and TUI/CLI — web keeps `RemoteClientTransport` + node fetch intercept; TUI/CLI rely on v2 SDK transport for silent direct retry. + +### Transport ownership (v2 upgrade) + +| Path | Silent 401/403 + RPC retry | Interactive OAuth | Inspector integration | +| ---- | -------------------------- | ----------------- | --------------------- | +| **Web remote** | `RemoteClientTransport.postSend()` + `pushAuthState` (unchanged) | Browser redirect + resume snapshot | Node fetch intercept only (frozen stub) | +| **TUI/CLI direct (streamable HTTP)** | **v2 SDK transport** (`token()` + retry; #2265 union on 403) | Provider `redirectToAuthorization` + host callback | `handleAuthChallenge()` for EMA + defer-navigate; catch `UnauthorizedError` / `SdkHttpError` | +| **TUI/CLI direct (SSE)** | v2 SDK 401 retry only | Same as streamable | No 403 step-up on SSE | + +**v2 SDK hooks (direct):** `AuthProvider` with `token()` + optional `onUnauthorized()`; full `OAuthClientProvider` adapted internally. Inspector extends `BaseOAuthClientProvider` toward the v2 contract — **not** a parallel fetch intercept on the client transport. + +**Delete on upgrade:** `core/auth/mcpAuth.ts` body → re-export SDK `auth`; delete `core/auth/scopes.ts` → import `unionScopes` from SDK. **Keep:** `handleAuthChallenge()`, web `RemoteClientTransport`, node `createAuthChallengeInterceptFetch`. ### Order of work | Step | When | Work | | ---- | ---- | ---- | -| 1 | **Now (parallel with mid-session auth)** | Compatible prep: callback type includes optional `iss`; storage schema reserves `authorizationServerIssuer`; mid-session `saveScope(union)` per [Mid-session auth](v2_auth_mid_session.md) | +| 1 | **Now (parallel with [TUI/CLI mid-session](v2_auth_mid_session.md#tui-and-cli-implementation))** | Compatible prep: callback `iss`; storage schema reserves `authorizationServerIssuer`; provider v2-shaped hooks; direct mid-session e2e baseline; **no** client-side fetch intercept / `AuthRecoveryTransport` | | 2 | **When v2 SDK auth PRs merge** | Upgrade to `@modelcontextprotocol/client`; run existing OAuth integration tests | -| 3 | **Immediately after upgrade** | SEP-2468 callback passthrough; SEP-2352 storage + `invalidateCredentials`; SEP-837 client type per environment | -| 4 | **Verify** | Smoke scenarios in [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md); add hardening-specific cases | +| 3 | **Immediately after upgrade** | SEP-2468 callback passthrough; SEP-2352 storage + `invalidateCredentials`; SEP-837 `application_type` per environment; wire provider `onUnauthorized` → `handleAuthChallenge` where interactive defer needed | +| 4 | **TUI/CLI Phase C** | Interactive mid-session UX (Auth tab / callback server) — can overlap step 3 | +| 5 | **Verify** | Smoke scenarios in [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md); direct mid-session e2e green on v2 | Land order in the SDK (maintainer plan): **SEP-2350 → SEP-2352 → SEP-2468**, with **SEP-837** in the same auth-release series. @@ -132,9 +156,12 @@ Land order in the SDK (maintainer plan): **SEP-2350 → SEP-2352 → SEP-2468**, #### SEP-2350 — step-up scope accumulation -- **Connect-time (direct transport):** v2 SDK transport 403 retry after [#2265](https://github.com/modelcontextprotocol/typescript-sdk/pull/2265). -- **Mid-session:** `handleAuthChallenge()` computes `authorizationScopes = union(previous, required)`; `saveScope` after success. Standard OAuth step-up = interactive; EMA step-up = silent legs 2–3 when IdP session valid. -- **401 re-login:** Replace scope set (not union) — mid-session and connect-time. +- **Web remote (mid-session):** `handleAuthChallenge()` union + `pushAuthState` — SDK transport not involved (proxy). +- **TUI/CLI direct (silent, streamable HTTP):** v2 SDK transport 403 retry with `unionScopes()` after [#2265](https://github.com/modelcontextprotocol/typescript-sdk/pull/2265). +- **Interactive step-up (all clients):** `handleAuthChallenge()` + `mcpAuth({ forceReauthorization })`; `setSuppressAuthorizationNavigation` until user confirms (web modal / TUI Auth tab); `saveScope` on `completeOAuthFlow` success. +- **EMA step-up:** silent legs 2–3 when IdP session valid — Inspector `handleAuthChallenge()`, not SDK transport alone. +- **401 re-login:** Replace scope set (not union) — all clients. +- **SSE:** 401 only; no 403 step-up in v1 or v2 SDK. #### SEP-2351 — discovery suffix @@ -148,6 +175,8 @@ Land order in the SDK (maintainer plan): **SEP-2350 → SEP-2352 → SEP-2468**, - **Client credentials grant** ([#1225](https://github.com/modelcontextprotocol/inspector/issues/1225)) — separate track. - Full MCP **2026-07-28 stateless transport** migration (separate from auth hardening). - Server-side scope-challenge middleware in Inspector test servers beyond what [Mid-session auth](v2_auth_mid_session.md) requires for 403 fixtures. +- **`AuthRecoveryTransport`** or client-side fetch intercept on TUI/CLI SDK transport (defer silent retry to v2 SDK; web node intercept stays). +- Unifying web remote and TUI/CLI at the transport wrapper layer. ## Testing @@ -166,12 +195,15 @@ Document manual scenarios in [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md | Area | Files | | ---- | ----- | -| Upgrade | Root and client `package.json`; import paths v1 → v2 | +| Upgrade | Root and client `package.json`; import paths v1 → `@modelcontextprotocol/client` | | Callback | `clients/web/src/App.tsx`, `core/auth/node/oauth-callback-server.ts`, `core/mcp/oauthManager.ts`, `core/mcp/inspectorClient.ts` | -| Provider | `core/auth/providers.ts`, `core/auth/browser/providers.ts` | +| Provider | `core/auth/providers.ts`, `core/auth/browser/providers.ts` — v2 hooks: `invalidateCredentials`, issuer-keyed storage, `application_type` | | Storage | `core/auth/store.ts`, `core/auth/oauth-storage.ts`, `core/mcp/remote/node/remoteOAuthStorage.ts` (if landed) | -| Mid-session overlap | `core/auth/challenge.ts`, `core/mcp/oauthManager.ts` (`handleAuthChallenge`, `saveScope`) — see [Mid-session auth](v2_auth_mid_session.md) | -| Tests | `clients/web/src/test/core/auth/`, `clients/web/src/test/integration/mcp/inspectorClient-oauth*.test.ts` | +| Web remote (unchanged on v2) | `core/mcp/remote/remoteClientTransport.ts`, `core/mcp/node/authChallengeFetch.ts`, `core/mcp/remote/node/server.ts` | +| TUI/CLI | [Mid-session § TUI and CLI](v2_auth_mid_session.md#tui-and-cli-implementation): `clients/tui/src/App.tsx`; `clients/cli/src/cliOAuth.ts`; provider wiring in `core/mcp/inspectorClient.ts` | +| Mid-session overlap | `core/auth/challenge.ts`, `core/mcp/oauthManager.ts` (`handleAuthChallenge`, `saveScope`) | +| Delete on upgrade | `core/auth/mcpAuth.ts` (re-export SDK), `core/auth/scopes.ts` (use SDK `unionScopes`) | +| Tests | `inspectorClient-oauth*.test.ts`, `inspectorClient-oauth-direct-mid-session-e2e.test.ts` (planned) | | Smoke doc | `specification/v2_auth_smoke_testing.md` | ## Related specs diff --git a/specification/v2_auth_mid_session.md b/specification/v2_auth_mid_session.md index da882149f..4089bfb27 100644 --- a/specification/v2_auth_mid_session.md +++ b/specification/v2_auth_mid_session.md @@ -2,641 +2,556 @@ ### [Brief](README.md) | [V2 Scope](v2_scope.md) | [Auth hardening](v2_auth_hardening.md) | [EMA / XAA](v2_auth_ema.md) | [Smoke testing](v2_auth_smoke_testing.md) -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. +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**. -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](#sep-2350-step-up-authorization) below). +For hands-on verification, see [v2_auth_smoke_testing.md §5](v2_auth_smoke_testing.md#5-mid-session-auth--step-up--manual-validation). + +--- ## Summary -Inspector v2 already supports **connect-time** OAuth and EMA: +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. -- `InspectorClient.authenticate()` / `completeOAuthFlow()` ([V2 Scope](v2_scope.md)) -- 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](v2_auth_ema.md)) +**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. -**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. +| 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 | -This spec defines: +All three clients share **`core/auth/challenge.ts`** and **`OAuthManager.handleAuthChallenge()`**. UX and wire protocol differ by client. -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). +--- -## Architecture +## Background -### Code layout +### Connect-time vs mid-session -- 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. +| | 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()` | -### Web: detection and wire protocol +Both paths use the same token storage, refresh, and authorization-code exchange; only **detection** and **session update** mechanics differ. -- **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()`. +### Step-up authorization (SEP-2350) -### TUI / CLI: detection +**Step-up** is OAuth for “I have a token, but not permission for *this* operation.” MCP servers usually express it as: -- 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). +```http +HTTP/1.1 403 Forbidden +WWW-Authenticate: Bearer error="insufficient_scope", scope="weather:read" +``` -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()`. +By contrast, **401** means the token is missing, invalid, or expired — fix by refresh or full re-login. -### Scope and EMA +**[SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350)** (in the MCP 2026-07-28 authorization hardening) requires clients to **accumulate scopes** on step-up: -- **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). +| 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) | -### RPC retry +Inspector persists the previously requested set in `OAuthStorage.scope` (`saveScope()`), computes `authorizationScopes` as the union in `handleAuthChallenge()`, and only persists the union after a **successful** `completeOAuthFlow()`. -- 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 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)](v2_auth_ema.md#ema-step-up-web-confirmation). -### UX +**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. -| 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 | +Normative background: [MCP authorization — Step-Up Authorization Flow](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#step-up-authorization-flow), [Runtime Insufficient Scope Errors](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#runtime-insufficient-scope-errors), [RFC 6750 §3.1](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1). -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. +## Terminology -### When silent recovery fails +| 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. | -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 | +## Architecture -### Test infrastructure — composable server scope requirements +### Two transport models -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. +Inspector implements mid-session auth twice, by design — not as a shared transport wrapper. -#### Config: `requiredScopes` on preset refs +```text +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) +``` -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. +**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](https://github.com/modelcontextprotocol/typescript-sdk/pull/2265)); Inspector keeps **`handleAuthChallenge()`**, EMA paths, and **interactive deferral** (modal / confirm / snapshot). See [v2_auth_hardening.md](v2_auth_hardening.md). -```json -{ - "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"] - } - ] -} +**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) | + +```text +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 ────────►│ ``` -| 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). | +**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. -**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. +### Code map -Canonical fixture: `test-servers/configs/oauth-step-up-demo.json` (add with implementation). +| 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](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`, `utils/oauthResume.ts`, `utils/pendingReauth.ts`, `utils/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` | -#### 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: +## Auth challenge model -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: +### `AuthChallenge` (`core/auth/challenge.ts`) - ```http - HTTP/1.1 403 Forbidden - WWW-Authenticate: Bearer error="insufficient_scope", scope="weather:read" - Content-Type: application/json - ``` +```typescript +export type AuthChallengeReason = + | "unauthorized" + | "token_expired" + | "insufficient_scope" + | "invalid_token"; - Use the **missing** scope(s) in the `scope=` parameter (space-separated if multiple). Body: JSON-RPC error envelope (same pattern as existing 401 middleware). +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 }; +} +``` -**Method → registry lookup:** +### Parsing -| 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` | +Parsing is **layered** at the point of failure — not by guessing from error messages: -**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. +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. -#### Token scope storage (combined mode prerequisite) +Mapping highlights: -Today combined-mode opaque access tokens are stored in a `Set` with **no granted scope**. Scope enforcement requires: +- **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). -- **`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. +`isAuthChallengeError()` treats mid-session failures only when auth markers are present (challenge object, `WWW-Authenticate`, or `AuthChallengeError`) — not bare HTTP status alone. -Middleware compares granted scopes (split on spaces) against `requiredScopes` (all must be present). +Connect-time **401** before tokens exist still uses `isUnauthorizedError()` and `authenticate()` — separate from mid-session detection. -#### 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: +## Core API — `handleAuthChallenge()` -```json -"oauth": { - "operations": { - "tools/call": { "requiredScopes": ["tools:execute"] } - } -} +**Location:** `OAuthManager.handleAuthChallenge()`; `InspectorClient.handleAuthChallenge()` delegates. + +```typescript +export type AuthChallengeOutcome = + | { kind: "satisfied" } + | { kind: "interactive"; authorizationUrl: URL; challenge: AuthChallenge } + | { kind: "failed"; error: Error }; ``` -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. +### `checkAuthChallengeSatisfied(challenge)` -#### Implementation checklist (Phase 3 test server) +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. -- [ ] 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](v2_auth_smoke_testing.md) +- **`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). -## Normative references +### Strategy by protocol -- [MCP authorization (2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) — current stable; includes [Step-Up Authorization Flow](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#step-up-authorization-flow) and [Runtime Insufficient Scope Errors](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#runtime-insufficient-scope-errors) -- [MCP authorization (draft — 2026-07-28 RC target)](https://modelcontextprotocol.io/specification/draft/basic/authorization) — upcoming auth hardening; [release candidate announcement](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) lists six authorization SEPs including **SEP-2350** -- **[SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350)** — *Clarify client-side scope accumulation in step-up authorization* ([issue #2349](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2349)); merged into the draft spec's step-up flow -- [EMA extension](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization) — see [v2_auth_ema.md](v2_auth_ema.md) -- OAuth 2.0 Bearer Token Usage (`WWW-Authenticate`, `insufficient_scope`) — [RFC 6750 §3.1](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1) -- `@modelcontextprotocol/sdk` **1.29.0** (pinned in repo) — `StreamableHTTPClientTransport` invokes SDK `auth()` on **401** and **403 `insufficient_scope`** during `send()` when an `authProvider` is attached; legacy `SSEClientTransport` handles **401 only** on `send()` (no 403 step-up) +#### Standard OAuth -## Goals +| 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) | -### Phase A — Mid-session token recovery (implement first) +Union scope is held in `pendingAuthorizationScope` until `completeOAuthFlow()` succeeds; cleared on failure. -- **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](v2_auth_ema.md)). -- Preserve existing connect-time OAuth behavior — no regressions. +#### EMA -### Phase B — MCP step-up authorization (after Phase A) +Per [v2_auth_ema.md](v2_auth_ema.md): **no** fallback to standard resource-OAuth redirect. -- Implement **[SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350)** client-side scope accumulation when servers emit runtime **`403 insufficient_scope`** challenges per the [draft Step-Up Authorization Flow](https://modelcontextprotocol.io/specification/draft/basic/authorization#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). +| 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 | -### Phase C — Command retry (web remote) +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). -- 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. +### Outcomes — what callers do -## Non-goals +| 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 | -- v1 / v1.5 backport (v2 only). -- **Client credentials grant** ([#1225](https://github.com/modelcontextprotocol/inspector/issues/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. +**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) | -## 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](https://modelcontextprotocol.io/specification/draft/basic/authorization#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](#sep-2350-step-up-authorization)**. | -| **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. | +## Web implementation -## Current architecture (why mid-session fails on web) +### Detection and delivery -```text -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 -``` +**Backend** (`core/mcp/remote/node/`): -**TUI/CLI:** OAuth authority and MCP transport live in the same process. The SDK can call `tokens()`, refresh, or fire `oauthAuthorizationRequired`. +- **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()`. -**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. +**Dual delivery — one channel per incident:** -### Known failure: reconnect with stored tokens (pre–Phase 2) +| 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** | -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). +`authReturnedViaHttp` prevents duplicating a command-scoped challenge on SSE. -| 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 | +Inspector API **4xx** are reserved for malformed requests / missing session — not for upstream MCP token expiry. -**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](#parsing-best-effort-extensible)). +### `POST /api/mcp/auth-state` -## SEP-2350 — step-up authorization +```typescript +interface RemoteSetAuthStateRequest { + sessionId: string; + authState: RemoteAuthState; +} -[SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/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**. +interface RemoteAuthState { + oauthTokens?: { access_token; token_type; refresh_token?; scope?; … }; + oauthClient?: { client_id; client_secret? }; // reserved for future server-side refresh +} +``` -**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. +Called by `RemoteClientTransport.pushAuthState()` after browser-side recovery. Seeding uses the same shape on `POST /api/mcp/connect`. -### What the upcoming MCP spec requires +### App orchestration (`clients/web/src/App.tsx`) -From the [draft authorization spec](https://modelcontextprotocol.io/specification/draft/basic/authorization) (incorporating SEP-2350): +**Command-scoped paths** (tool, prompt, resource, app) share `handleCommandScopedAuthRecovery()`: -| 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](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1) | **Step-up flow** — see below. | +- 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. -**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. +**Ambient path:** listens for `authChallengeInteractive` on `InspectorClient` (from SSE when silent recovery cannot complete). -**Client posture (SEP-2350 — Step-Up Authorization Flow step 2):** +**Disconnect** clears `pendingStepUp`, `pendingReauth`, and `reAuthBanner`. -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). +### Step-up confirmation modal -Reference implementation discussion: [python-sdk PR #2676](https://github.com/modelcontextprotocol/python-sdk/pull/2676) (403 → union; 401 → replace). +Shown for **`insufficient_scope`** on **standard OAuth** and **EMA (web)** when recovery will **redirect** to an AS or **re-mint** organization permissions. -### Inspector mapping for SEP-2350 +- 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](#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. -- 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. +Not shown for: token refresh, 401 re-login, connect-time OAuth. -## Auth challenge model +**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. -### Type shape (`core/auth/challenge.ts`) +### OAuth resume snapshot + +Full-page redirect (`window.location.href`) destroys in-memory React state. Before navigate, `prepareOAuthRedirect()` writes: ```typescript -/** 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 +interface OAuthResumeSnapshot { + version: 1; + serverId: string; + activeTab: string; // App tab id ("Tools", …) + authKind: "step_up" | "reauth"; + tabUi: Partial>; // lifted *UiState shells + remoteSessionId?: string; + authChallenge?: AuthChallenge; // step-up: verify scopes after callback +} +``` -/** Normalized challenge for handleAuthChallenge(). */ -export interface AuthChallenge { - reason: AuthChallengeReason; +Key: `mcp-inspector:oauth-resume` in `sessionStorage` (`clients/web/src/utils/oauthResume.ts`). - /** Scopes from the current challenge (step-up). Per RFC 6750 §3.1 / MCP Runtime Insufficient Scope Errors — scopes needed for this operation. */ - requiredScopes?: string[]; +**Callback flow** (`InspectorClient.resumeAfterOAuth()`): - /** - * 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[]; +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). - /** Resource indicator / MCP resource URL when known (EMA RFC 8707). */ - resource?: string; +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. - /** Resource authorization server audience when known. */ - audience?: string; +### Multiple browser tabs - /** Optional human-readable detail from server or SDK (for UI, not parsing). */ - message?: string; +Each browser tab has its own `RemoteSession` and (today) `BrowserOAuthStorage` in `sessionStorage`. SSE `auth_challenge` is scoped to that session. - /** 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; - }; -} -``` +When a tab is **hidden**, **interactive** OAuth must not steal focus: -### Parsing (best effort, extensible) +1. Set **`pendingReauth`** (in-memory) instead of modal/redirect. +2. On **`visibilitychange` → visible`:** `checkAuthChallengeSatisfied()` first; if still needed, run visible flow. -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. +Command-scoped recovery (user clicked Run in the foreground tab) is **not** deferred. -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](https://modelcontextprotocol.io/specification/draft/basic/authorization#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](https://modelcontextprotocol.io/specification/draft/basic/authorization#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. +Future: shared `RemoteOAuthStorage` → `oauth.json` may use optional `navigator.locks` around silent refresh only — see [v2_auth_ema.md §Shared storage](v2_auth_ema.md). -When parsing fails, use `reason: "unauthorized"` and still allow interactive re-auth. +### Web UX reference -### Challenge vs connect-time 401 +| 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 | -| | 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. +## TUI and CLI implementation -## Core API — `handleAuthChallenge()` +### Principles -**Location:** `OAuthManager.handleAuthChallenge()`; `InspectorClient` exposes a delegating wrapper. +- **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). -```typescript -export type AuthChallengeOutcome = - | { kind: "satisfied" } // New tokens in OAuthStorage; caller may reconnect transport - | { kind: "interactive"; authorizationUrl: URL } - | { kind: "failed"; error: Error }; +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. -/** Satisfy an auth challenge when possible. */ -async handleAuthChallenge(challenge: AuthChallenge): Promise; -``` +CLI never spawns TUI/web for auth — completes locally or fails. -### Strategy by protocol and reason +### TUI UX (`clients/tui/src/App.tsx`) -#### 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](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350):** authorization URL with **`authorizationScopes`** = union(previously requested, `requiredScopes`) — not replace | +| 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** | -Uses existing `BaseOAuthClientProvider`, storage, and `authenticate()` / `completeOAuthFlow()`. +### CLI UX (`clients/cli/src/cliOAuth.ts`) -#### EMA (`protocol: "ema"`) +| 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()` | -Per [EMA 401 rules](v2_auth_ema.md): **do not** fall back to standard resource-OAuth redirect. +### SSE limitation -| 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 | +Legacy **SSE** transport: **401 only** on mid-session `send()` (no 403 step-up). Step-up testing targets **streamable HTTP**. -Uses `EmaTransportOAuthProvider`, `emaFlow.ts`, and `resourceContext.ts` (extend scope resolution to prefer challenge scopes when present). +--- -### After `kind: "satisfied"` +## Client matrix -| 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). | +| 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 | `BrowserOAuthStorage` (sessionStorage) | `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 | -Ambient SSE failures without a caller closure may require manual retry or Phase 5 `pendingRequest` replay. +--- -### After `kind: "interactive"` +## Test infrastructure -Same as connect-time today: +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`). -- **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. +### How scopes work at two levels -### After `kind: "failed"` or user **Cancel** (standard-OAuth step-up modal only) +| 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). | -Do **not** disconnect the MCP session for recoverable challenges. +**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. -| 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 | +Example flow with the sample below: -## Remote wire protocol (web) +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`. -Backend **reports** challenges; browser **handles** them. One internal `AuthChallenge` object; **one delivery channel per incident** (inline **or** SSE, never both). +Step-up is enforced on **use** (`tools/call`, `resources/read`, `prompts/get`), not on discovery (`tools/list`, `resources/list`, `prompts/list`). -### Detection +### Sample composable config -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()`. +Illustrates server-level `scopesSupported` plus tool-, resource-, and prompt-level `requiredScopes`. The checked-in smoke fixture [`test-servers/configs/oauth-step-up-demo.json`](../test-servers/configs/oauth-step-up-demo.json) is a **minimal** subset (tools only: `echo` + `get_temp`). -```text -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 +```json +{ + "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"] } + ] +} ``` -#### Web — detection (`core/mcp/remote/node/`) +**Matching Inspector catalog entry** (connect with subset scopes so step-up is exercisable): -Inside an active `RemoteSession`, when MCP traffic fails with an auth error: +```json +"oauth-step-up-demo": { + "type": "streamable-http", + "url": "http://127.0.0.1:8081/mcp", + "oauth": { "scopes": "mcp tools:read" } +} +``` -- **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). +**Run the server:** -Parse `WWW-Authenticate` from the response headers on the failing request. +```bash +cd clients/web && npm run test-servers:build +node ../../test-servers/build/server-composable.js \ + --config ../../test-servers/configs/oauth-step-up-demo.json +``` -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). +### Enforcement (HTTP middleware) -#### `/api/mcp/send` response shape (command-scoped) +On each MCP request, after bearer validation: -```typescript -type RemoteSendResponse = - | { ok: true } - | { - ok: false; - kind: "auth_challenge"; - authChallenge: AuthChallenge; - } - | { - ok: false; - kind: "transport_error"; - error: string; - }; -``` +1. Parse JSON-RPC `method` and `params`. +2. Look up `requiredScopes` from the registry built at startup: -Reserve HTTP **4xx/5xx** on the remote API for Inspector API failures only (malformed body, unknown `sessionId`, launcher auth). +| 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` | -#### TUI / CLI — detection (direct transport) +3. Compare granted scopes on the access token (stored at token issue time in combined mode) against `requiredScopes`. +4. If any required scope is missing, respond **403** with: -Same **`handleAuthChallenge()`** entry via **transport fetch wrapper** (before SDK auth retry): + ```http + WWW-Authenticate: Bearer error="insufficient_scope", scope="weather:read" + ``` -- 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`. + (`scope=` lists the missing scope(s); body is a JSON-RPC error envelope.) -### SSE event (ambient only) +`requiredScopes` on preset refs is merged in `resolve-config.ts`; no application-code routes — config drives behavior. -Extend `RemoteEvent` in `core/mcp/remote/types.ts`: +### Verification -```typescript -export interface RemoteAuthChallengeEvent { - type: "auth_challenge"; - data: AuthChallenge & { - /** Server catalog id — browser resolves InspectorClient instance. */ - serverId?: string; - }; -} -``` +**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`. -**Rules:** +**Manual:** [v2_auth_smoke_testing.md §5](v2_auth_smoke_testing.md#5-mid-session-auth--step-up--manual-validation) — required gate **W1 + W5–W7**, **T1–T2 + T4**, **C1–C2**. -- 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](#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 +## Related specifications -**Command-scoped (primary):** +| Document | Relationship | +| -------- | ------------ | +| [v2_auth_hardening.md](v2_auth_hardening.md) | Connect-time SEPs; v2 SDK upgrade; direct transport silent retry delegation | +| [v2_auth_ema.md](v2_auth_ema.md) | EMA legs 2–3 re-mint; scope resolution; no resource-OAuth fallback | +| [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) | Manual OAuth and mid-session validation procedures | +| [v2_storage.md](v2_storage.md) | Target: shared `oauth.json` via `RemoteOAuthStorage` on web | -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):** +## Future work -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](#ux). +- **Web default `RemoteOAuthStorage`** — shared `oauth.json` with TUI/CLI; optional `navigator.locks` for silent refresh single-flight across browser tabs. +- **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). -## 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](v2_auth_ema.md)) | 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](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](v2_auth_smoke_testing.md) | Manual smokes after implementation; add mid-session / step-up scenarios | -| [v2_storage.md](v2_storage.md) | Shared `oauth.json` via `RemoteOAuthStorage` | -| [v2_scope.md](v2_scope.md) | Mid-session authorization extends “OAuth Handling” | -| [v2_auth_hardening.md](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](#test-infrastructure--composable-server-scope-requirements) -- [ ] Add **`test-servers/configs/oauth-step-up-demo.json`**; manual smoke steps in [v2_auth_smoke_testing.md](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](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 | +## Normative references +- [MCP authorization (2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) +- [MCP authorization (draft — 2026-07-28 RC)](https://modelcontextprotocol.io/specification/draft/basic/authorization) +- [SEP-2350 — client-side scope accumulation in step-up](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350) +- [EMA extension](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization) +- [RFC 6750 §3.1](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1) — Bearer `insufficient_scope` diff --git a/specification/v2_auth_smoke_testing.md b/specification/v2_auth_smoke_testing.md index 23df077da..947100d12 100644 --- a/specification/v2_auth_smoke_testing.md +++ b/specification/v2_auth_smoke_testing.md @@ -475,7 +475,7 @@ Same two phases (prefer **`stytch-mcp-demo`** URL): ### Procedure (CLI) -Reuse tokens from a prior web/TUI session, or run after interactive auth in TUI: +Interactive OAuth uses the loopback callback server (same as TUI). First-time connect or mid-session step-up: ```bash mcp-inspector --cli --catalog configs/mcp.json --server stytch-mcp-demo \ @@ -483,7 +483,11 @@ mcp-inspector --cli --catalog configs/mcp.json --server stytch-mcp-demo \ --method tools/list ``` -Interactive OAuth on CLI prints the authorize URL to stdout (`ConsoleNavigation`); prefer TUI for first-time login smokes. +**Expect:** stdout prints `Please navigate to: …` → open in browser → redirect to `http://127.0.0.1:6276/oauth/callback` → stderr `Authorization complete.` → JSON on stdout. + +Reuse tokens from a prior web/TUI/CLI session when `~/.mcp-inspector/storage/oauth.json` already has valid tokens for that server (passive file sharing). + +Mid-session / step-up manual validation: [§5](v2_auth_smoke_testing.md#5-mid-session-auth--step-up--manual-validation) (web **W1 + W5–W7**, TUI **T1–T2 + T4**, CLI **C1–C2** required; **W8–W11** recommended after auth recovery changes). ### Alternative: DCR on Stytch (no metadata hosting) @@ -523,6 +527,293 @@ Register **`http://127.0.0.1:6276/oauth/callback`** on the xaa.dev IdP before le --- +## 5. Mid-session auth + step-up — manual validation + +**Purpose:** Manual checklist for mid-session OAuth recovery across **web**, **TUI**, and **CLI** after changes to auth / step-up UX. Design: [v2_auth_mid_session.md](v2_auth_mid_session.md). + +**Fixture:** local composable server `test-servers/configs/oauth-step-up-demo.json` (`echo` unscoped, `get_temp` requires `weather:read`). Catalog scope **`mcp tools:read` only** (omit `weather:read`) so `get_temp` triggers step-up. + +### Required vs optional + +Run **required** smokes before release or after any auth UX change. **Optional** smokes extend coverage; run when time allows or when touching the listed area. + +| ID | Client | When | Required? | +| -- | ------ | ---- | --------- | +| **W1** | Web | Step-up modal + OAuth resume | **Yes** | +| W2–W4 | Web | Connect-time, silent refresh, multi-tab | Optional | +| **W5–W11** | Web | P0/P1/P2 code-review UX (below) | **Recommended** after auth recovery changes | +| **T1, T2, T4** | TUI | Connect, step-up, clear OAuth | **Yes** | +| T3, T5–T6 | TUI | Modal path, tab/server selection | Optional | +| **C1, C2** | CLI | Connect + step-up **y/N** | **Yes** | +| C3 | CLI | Built launcher subprocess | Optional | + +**Minimum release gate:** **W1** + **W5–W7** (web), **T1–T2** + **T4** (TUI), **C1–C2** (CLI). + +### What CI covers vs what you verify manually + +| Capability | Web | TUI | CLI | Automated in CI? | +| ---------- | --- | --- | --- | ---------------- | +| Core `handleAuthChallenge()` / token exchange | Remote transport | Direct transport | Direct (`cliOAuth.ts`) | Yes — `inspectorClient-oauth-*-mid-session-e2e.test.ts`, `oauth-interactive.test.ts` | +| Silent mid-session refresh (valid refresh token) | Yes | Yes (reconnect) | N/A (one-shot) | Yes — remote e2e | +| Connect-time 401 → interactive OAuth | Yes | Yes | Yes | Partial — core/CLI integration; **not** full UI/binary | +| Step-up confirm before second OAuth | Modal | Auth tab **A** / **C** | stderr **y/N** | Partial — unit/modal tests; **not** real browser + Ink/terminal | +| OAuth resume after full-page redirect | Yes (`6274`) | N/A (loopback) | N/A (loopback) | Partial — `oauthResume` unit tests; **not** browser tab restore | +| Tab + form restore after step-up | Yes | N/A | N/A | **Manual only** | +| Network log survives redirect | Yes | N/A | N/A | **Manual only** | +| Multi–browser-tab defer (background tab) | Yes | N/A | N/A | **Manual only** | +| Tool / prompt / resource modal → auth recovery | Yes | Yes | CLI one-shot RPC only | Partial — component unit tests | +| Loopback callback server (`6276`) | N/A | Yes | Yes | Partial — integration tests auto-complete authorize URL | +| Step-up **decline** (stay connected / exit cleanly) | Modal Cancel | Auth **C** | **N** | **Manual only** | +| ReAuthBanner reconnect without disconnect | Yes | N/A | N/A | **Manual only** (W7) | +| Abandoned step-up / reauth snapshot → banner | Yes | N/A | N/A | **Manual only** (W6) | +| Step-up pre-redirect toast | Yes | N/A | N/A | **Manual only** (W5) | +| Background-tab defer + resume on visibility | Yes | N/A | N/A | **Manual only** (W8) | +| Concurrent step-up → warning toast | Yes | TUI overwrite message | N/A | **Manual only** (W9 / T5) | +| Partial consent after step-up → warning toast | Yes | Yes | Yes (stderr message) | Partial — unit copy only (W10) | +| Prompt / resource / app command-scoped recovery | Yes | Yes | N/A (tools only in CLI) | Partial — no full UI e2e (W11) | +| Built launcher → CLI binary subprocess | N/A | N/A | Yes | `scripts/smoke-cli.mjs` — **no OAuth** | + +Run this section when touching mid-session auth UX. CI proves protocol/core paths; **you** prove each client’s interactive UX with a real browser (and terminal for CLI **y/N**). + +### Shared setup + +1. **Composable OAuth server** (terminal A): + ```bash + cd clients/web && npm run test-servers:build + node ../../test-servers/build/server-composable.js \ + --config ../../test-servers/configs/oauth-step-up-demo.json + ``` + Confirm stderr: `Composable server listening at http://127.0.0.1:8081/mcp`. + +2. **Catalog entry** — add to `~/.mcp-inspector/mcp.json` (all three clients): + ```json + "oauth-step-up-demo": { + "type": "streamable-http", + "url": "http://127.0.0.1:8081/mcp", + "oauth": { "scopes": "mcp tools:read" } + } + ``` + +3. **Clean OAuth state** before each client’s run: + - **Web:** Server Settings → OAuth → **Clear stored OAuth state** (or Connection Info → **Clear and disconnect**) + - **TUI / CLI:** Auth tab → **S** (TUI), or remove the server key from `~/.mcp-inspector/storage/oauth.json` / use a fresh `HOME` (CLI) + +4. **Ports:** composable server **8081**; web callback **6274**; TUI/CLI callback **6276** (only one TUI/CLI listener at a time). + +--- + +### Web — manual validation + +**Start web** (terminal B): `npm run web:dev -- --catalog configs/mcp.json` (or your `mcp.json` with the entry above). + +#### W1 — Step-up modal + OAuth resume (**required**) + +Primary path; CI does **not** exercise modal UX or post-redirect tab restore. + +1. Connect to **oauth-step-up-demo** → browser OAuth → **connected**. +2. **Tools** → **echo** → **Run** → success. +3. **Tools** → **get_temp** (city `NYC`, units `C`) → **Run**. +4. **Expect:** **“Additional permissions required”** modal (scopes include `weather:read`); **no** immediate redirect. +5. **Cancel** → modal closes, error shown, still **connected** → **echo** still works. +6. **Run get_temp** again → **Authorize** → redirect to AS on `:8081` → approve → `http://localhost:6274/oauth/callback` → return to app. +7. **Expect:** **Tools** tab, **get_temp** form restored, toast **“Step-up authorization succeeded. Retry your action.”**, network log from pre-redirect still visible. +8. **Run get_temp** → temperature result. + +#### W2 — Connect-time OAuth with no prior session (optional) + +1. Clear OAuth state, disconnect. +2. Connect → full-page OAuth → callback → toast **“Authentication succeeded. Retry your action.”** + +#### W3 — Silent mid-session refresh (optional) + +1. Connect, run **echo** successfully. +2. Invalidate access token only (devtools → `sessionStorage` → `mcp-inspector-oauth`), keep refresh token. +3. **Run echo** again → success **without** leaving the page. + +> CI: `inspectorClient-oauth-remote-mid-session-e2e.test.ts`. Skip W3 if W1 passed and time is short. + +#### W4 — Multi–browser-tab defer (optional, manual only) + +1. Tab A: connect, start step-up on **get_temp**, **do not** click Authorize yet. +2. Tab B: same server, run **echo** → should **not** redirect Tab B to OAuth while Tab A’s modal is open. +3. Tab A: **Authorize** → complete flow. + +#### W5 — Step-up pre-redirect toast (**recommended**, MR-219) + +1. Complete W1 steps 1–3 to open the step-up modal. +2. Click **Authorize**. +3. **Expect:** blue toast **“Step-up authorization for …”** / **“Redirecting to authorize additional permissions…”** appears **before** the browser leaves the page. +4. Complete OAuth → W1 steps 7–8. + +#### W6 — Abandoned step-up → re-auth banner (**recommended**, MR-111) + +1. Complete W1 steps 1–3; click **Authorize** and reach the AS consent page on `:8081`. +2. **Do not** approve — navigate back to `http://localhost:6274/` (or close the OAuth tab and open the inspector URL without callback query params). +3. **Expect:** persistent red **Re-authentication required** banner mentioning step-up was not completed. +4. Dismiss or use **Re-authenticate** (see W7). + +#### W7 — ReAuthBanner reconnect without disconnect (**recommended**, MR-218) + +**Prerequisite:** connected session + re-auth banner visible (from W6, OAuth callback error, or invalidate tokens and trigger a `token_expired` / `unauthorized` recovery that shows the banner — not an `insufficient_scope` toast). + +1. Confirm Connection toggle still shows **connected** (session not torn down). +2. Click banner **Re-authenticate**. +3. **Expect:** **no** disconnect/reconnect cycle; browser OAuth opens (or silent restore if tokens still valid). +4. After successful OAuth, banner clears; **echo** still works without manual reconnect. + +#### W8 — Background-tab defer + resume (**recommended**, MR-104) + +1. Connect; open step-up modal on **get_temp** (W1 steps 1–3) but **do not** click Authorize yet. +2. Switch away from the inspector tab (another browser tab or app) so the page is **hidden** (`document.visibilityState === "hidden"` — DevTools → **Rendering** → *Emulate page visibility hidden* also works). +3. Return to the inspector tab (or disable emulation). +4. **Expect:** deferred recovery runs — step-up modal or OAuth flow resumes without losing the connected session. +5. Complete step-up; run **get_temp** successfully. + +#### W9 — Concurrent step-up blocked (**recommended**, MR-110) + +1. Connect; open step-up modal on **get_temp**; leave it open. +2. Trigger a second step-up need (e.g. open step-up again via a second **Run** on **get_temp**, or a second scoped action if the fixture supports it). +3. **Expect:** yellow toast **“Step-up authorization in progress…”**; first modal remains; no second redirect. + +#### W10 — Partial consent after step-up (**optional**) + +1. Complete step-up **Authorize** flow but on the AS consent screen **deny** or omit `weather:read` if the AS offers granular scopes. +2. **Expect:** return to app with **warning** toast about permissions not granted — **not** the green **“Step-up authorization succeeded…”** toast. +3. **Run get_temp** again → step-up modal reappears. + +#### W11 — Prompt / resource / app recovery (**optional**, MR-003) + +The default `oauth-step-up-demo.json` fixture is **tools-only**. To smoke command-scoped recovery on other surfaces, temporarily add a scoped prompt or resource to the composable config (see [v2_auth_mid_session.md § Test infrastructure](v2_auth_mid_session.md#test-infrastructure)) and restart the server. + +1. Connect with `mcp tools:read` only. +2. **Prompts** → run a scoped **Get** (or **Resources** → **Read**, or **Apps** → open) that returns `403 insufficient_scope`. +3. **Expect:** same step-up modal / redirect / resume behavior as W1 — not an unhandled error. Cancel should only affect that panel’s in-flight state (MR-106). + +--- + +### TUI — manual validation + +**Start TUI:** `mcp-inspector --tui --catalog ~/.mcp-inspector/mcp.json` (or `--config`). No web dev required. + +#### T1 — Connect-time OAuth on 401 (**required**) + +CI does **not** run Ink + real browser callback. + +1. Clear OAuth state (**Auth** tab → **S**, or fresh `oauth.json`). +2. Select **oauth-step-up-demo** → **Connect** (`c`). +3. **Expect:** Auth tab shows authenticating; terminal/OS opens authorize URL (or log shows URL); browser completes redirect to `http://127.0.0.1:6276/oauth/callback`. +4. **Expect:** connected; **Tools** tab lists **echo** and **get_temp**. + +#### T2 — Step-up confirm on Auth tab (**required**) + +Requires connected session with **`mcp tools:read` only**. If connect already granted `weather:read`, clear state and retry, or skip to T3 only if `get_temp` succeeds without step-up. + +1. **Tools** tab → **get_temp** → run with city/units. +2. **Expect:** focus moves to **Auth** tab; message that extra scopes are needed; prompts **A** (authorize) / **C** (cancel). +3. Press **C** → message “Authorization cancelled.” → still connected → **echo** still works. +4. Run **get_temp** again → press **A** → browser OAuth → callback on **6276**. +5. **Expect:** Auth message **“Step-up authorization succeeded. Retry your action.”** +6. Run **get_temp** again → success. + +#### T3 — Tool test modal auth recovery (optional) + +1. From **Tools**, open test modal for **get_temp**, trigger step-up as in T2. +2. **Expect:** same Auth tab step-up flow (not a separate modal). + +#### T4 — Clear OAuth state (**required**, quick) + +1. While connected, **Auth** tab → **S**. +2. **Expect:** tokens cleared, disconnected if was connected. + +#### T5 — Step-up while on another tab (**optional**, MR-004) + +1. Connect (T1); stay on **Tools** tab (not Auth). +2. Run **get_temp** → step-up needed. +3. **Expect:** focus moves to **Auth** tab; **oauth-step-up-demo** remains selected; step-up prompt visible (T2 step 2). + +#### T6 — Concurrent step-up overwrite message (**optional**, MR-110) + +1. Connect; open step-up prompt on Auth tab (T2 step 2); leave it open. +2. Trigger another step-up (run **get_temp** again). +3. **Expect:** Auth tab message about an existing step-up prompt; no silent loss of the first prompt. + +> CI: `inspectorClient-oauth-direct-mid-session-e2e.test.ts` (core client, no Ink). TUI component tests: `App.test.tsx`, `tuiOAuth.test.ts`. + +--- + +### CLI — manual validation + +Uses loopback callback **`http://127.0.0.1:6276/oauth/callback`** (override with `--callback-url`). **Do not** run TUI and CLI OAuth smokes simultaneously on 6276. + +#### C1 — Connect-time OAuth + `tools/list` (**required**) + +CI auto-completes authorize URL; **you** use a real browser. + +1. Shared setup steps 1–3 (web dev **not** required). +2. ```bash + mcp-inspector --cli --catalog ~/.mcp-inspector/mcp.json --server oauth-step-up-demo \ + --method tools/list + ``` +3. **Expect stdout:** `Please navigate to: http://127.0.0.1:8081/oauth/authorize?...` +4. Open URL → approve → redirect to **6276** → “OAuth complete”. +5. **Expect stderr:** `Authorization complete.` +6. **Expect stdout:** JSON with `echo` and `get_temp`. + +#### C2 — Step-up **y/N** + `tools/call get_temp` (**required**) + +Session from C1 must have **`mcp tools:read` only**. If step-up does not prompt, clear OAuth state and retry C1. + +1. ```bash + mcp-inspector --cli --catalog ~/.mcp-inspector/mcp.json --server oauth-step-up-demo \ + --method tools/call --tool-name get_temp --tool-arg city=NYC --tool-arg 'units="C"' + ``` +2. **Expect stderr:** scope message + `Proceed with step-up authorization? [y/N]`. +3. Type **`n`** → exit ≠ 0, “Step-up authorization declined.” +4. Re-run; type **`y`** → authorize URL on stdout → browser → **6276** callback. +5. **Expect stderr:** `Authorization complete. Retrying…` then stdout JSON with temperature. + +#### C3 — Subprocess binary path (optional) + +`scripts/smoke-cli.mjs` (`npm run smoke:cli`) exercises the **built launcher + binary** for catalog/config/`tools/list` — **not** OAuth. Optional sanity check after CLI auth changes; C1/C2 remain the OAuth manual gate. + +> CI: `clients/cli/__tests__/oauth-interactive.test.ts`, `cliOAuth.test.ts` (in-process `cliOAuth.ts`, not subprocess). + +--- + +### Manual sign-off checklist + +| # | Check | Web | TUI | CLI | +| - | ----- | --- | --- | --- | +| 1 | Connect-time OAuth with empty storage | W1 prep / W2 | T1 | C1 | +| 2 | Unscoped tool works after connect | W1 step 2 | T1 | C1 output | +| 3 | Step-up prompt before second OAuth | W1 step 4 | T2 step 2 | C2 step 2 | +| 4 | Decline step-up, stay usable | W1 step 5 | T2 step 3 | C2 step 3 | +| 5 | Accept step-up + complete browser OAuth | W1 step 6–8 | T2 step 4–6 | C2 step 4–5 | +| 6 | Clear OAuth state | Settings | T4 | delete `oauth.json` / fresh HOME | +| 7 | Step-up pre-redirect toast | W5 | — | — | +| 8 | Abandoned step-up → banner | W6 | — | — | +| 9 | ReAuthBanner reconnect (no disconnect) | W7 | — | — | +| 10 | Background-tab defer + resume | W8 | — | — | +| 11 | Concurrent step-up warning | W9 | T6 | — | +| 12 | Partial consent warning toast | W10 | T2 (if AS allows) | C2 (if AS allows) | +| 13 | Prompt / resource / app recovery | W11 | T3 | — | + +### Troubleshooting + +| Symptom | Likely cause | +| ------- | ------------- | +| Redirect with no modal / no step-up prompt | Scopes already include `weather:read`; clear OAuth state and ensure catalog has `"scopes": "mcp tools:read"` only | +| Web callback “could not be matched” | Missing `mcp-inspector:oauth-resume` in `sessionStorage` — use modal **Authorize**, not manual URL | +| TUI/CLI `EADDRINUSE` on 6276 | Another TUI/CLI (or stale process) holds callback port | +| CLI step-up never prompts | Connect-time recovery unioned scopes — clear tokens and retry C1 | +| `get_temp` fails after web step-up without re-run | Expected — click **Run** again (no auto-replay after full-page OAuth) | +| ReAuthBanner **Re-authenticate** disconnects session | Should **not** happen when already connected — see W7 | +| Step-up shows toast instead of modal | `insufficient_scope` recovery failures use a toast, not ReAuthBanner (MR-206) | +| No pre-redirect toast on step-up Authorize | See W5 — blue toast expected before redirect (MR-219) | +| Port conflict on 8081 | Change `transport.port` in config and catalog URL | + +--- + ## What to verify (all smokes) | Check | Where | @@ -549,14 +840,17 @@ Register **`http://127.0.0.1:6276/oauth/callback`** on the xaa.dev IdP before le | Static | GitHub MCP + your OAuth App | `test-static-client` / `test-static-secret` on TestServerHttp | | CIMD | **Stytch demo MCP** (`stytch-as-demo.val.run`) + [MCPJam metadata URL](#cimd-credentials-for-smoke-mcpjam) (or local composable) | `createClientMetadataServer()` in e2e | | EMA | xaa.dev staging | `inspectorClient-ema-e2e.test.ts` + mocks | +| Mid-session / step-up (web remote) | §5 **W1** + **W5–W7** (required gate); W2–W4, W8–W11 optional | `inspectorClient-oauth-remote-mid-session-e2e.test.ts`; web unit (`oauthResume`, `StepUpAuthModal`) | +| Mid-session / step-up (TUI direct) | §5 **T1–T2, T4** (required); T3, T5–T6 optional | `inspectorClient-oauth-direct-mid-session-e2e.test.ts` (core); `App.test.tsx`, `tuiOAuth.test.ts` (no Ink+browser) | +| Mid-session / step-up (CLI direct) | §5 **C1–C2** (required) | `clients/cli/__tests__/oauth-interactive.test.ts`, `cliOAuth.test.ts` (in-process; not subprocess) | ## Known gaps (Inspector) -See **[Mid-session authorization](v2_auth_mid_session.md)** for the design to address mid-session 401, token refresh, and step-up scope challenges (including web remote reconnect). +**Mid-session auth** is implemented for web (remote transport), TUI, and CLI — see [Mid-session authorization](v2_auth_mid_session.md). **Remaining:** shared `RemoteOAuthStorage` on web, optional idle SSE E2E, v2 SDK transport upgrade for direct silent retry. See **[Auth hardening (MCP 2026-07-28)](v2_auth_hardening.md)** for connect-time OAuth hardening (SEP-2468, SEP-837, SEP-2352, SEP-2207, SEP-2350, SEP-2351) and the v2 SDK upgrade strategy. -- **CLI interactive OAuth:** no local callback server yet — reuse tokens from web/TUI or complete auth in TUI first; CLI prints authorize URLs to stdout when a new login is required. +- **Mid-session auth:** see [§5 manual validation](v2_auth_smoke_testing.md#5-mid-session-auth--step-up--manual-validation) — CI covers core protocol; **W1 + W5–W7 / T1–T2 + T4 / C1–C2** are the required manual gate per client; **W8–W11, T3, T5–T6, C3** extend P0/P1/P2 UX coverage. - **Client credentials grant:** not implemented ([#1225](https://github.com/modelcontextprotocol/inspector/issues/1225)). ## References diff --git a/test-servers/configs/oauth-step-up-demo.json b/test-servers/configs/oauth-step-up-demo.json new file mode 100644 index 000000000..3efbc0e5e --- /dev/null +++ b/test-servers/configs/oauth-step-up-demo.json @@ -0,0 +1,22 @@ +{ + "serverInfo": { + "name": "oauth-step-up-demo", + "version": "1.0.0" + }, + "tools": [ + { "preset": "echo" }, + { "preset": "get_temp", "requiredScopes": ["weather:read"] } + ], + "oauth": { + "enabled": true, + "mode": "combined", + "requireAuth": true, + "scopesSupported": ["mcp", "tools:read", "weather:read"], + "supportRefreshTokens": true, + "supportDCR": true + }, + "transport": { + "type": "streamable-http", + "port": 8081 + } +} diff --git a/test-servers/configs/xaa-ema-http.json b/test-servers/configs/xaa-ema-http.json index ee5736d88..7e51e0c1b 100644 --- a/test-servers/configs/xaa-ema-http.json +++ b/test-servers/configs/xaa-ema-http.json @@ -3,7 +3,10 @@ "name": "xaa-ema-resource", "version": "1.0.0" }, - "tools": [{ "preset": "echo" }, { "preset": "get_temp" }], + "tools": [ + { "preset": "echo", "requiredScopes": ["tools:read"] }, + { "preset": "get_env", "requiredScopes": ["tools:read", "env:read"] } + ], "transport": { "type": "streamable-http", "port": 8080 @@ -13,7 +16,7 @@ "mode": "protected-resource", "authorizationServers": ["https://auth.resource.xaa.dev"], "requireAuth": true, - "scopesSupported": ["mcp"], + "scopesSupported": ["mcp", "tools:read", "env:read"], "accessTokenIssuers": ["https://auth.resource.xaa.dev"], "jwksUri": "https://auth.resource.xaa.dev/jwks", "resource": "http://localhost:8080/", diff --git a/test-servers/src/composable-test-server.ts b/test-servers/src/composable-test-server.ts index 44ca60572..181238a95 100644 --- a/test-servers/src/composable-test-server.ts +++ b/test-servers/src/composable-test-server.ts @@ -100,6 +100,8 @@ export interface ToolDefinition { name: string; description: string; inputSchema?: ToolInputSchema; + /** OAuth scopes required to invoke this tool (enforced at HTTP layer). */ + requiredScopes?: string[]; /** Optional Zod object schema for tool output; when set, handler must return structuredContent. */ outputSchema?: unknown; handler: ( @@ -113,6 +115,8 @@ export interface TaskToolDefinition { name: string; description: string; inputSchema?: ToolInputSchema; + /** OAuth scopes required to invoke this tool (enforced at HTTP layer). */ + requiredScopes?: string[]; execution?: { taskSupport: "required" | "optional" }; handler: ToolTaskHandler; } @@ -123,11 +127,15 @@ export interface ResourceDefinition { description?: string; mimeType?: string; text?: string; + /** OAuth scopes required to read this resource (enforced at HTTP layer). */ + requiredScopes?: string[]; } export interface PromptDefinition { name: string; description?: string; + /** OAuth scopes required to fetch this prompt (enforced at HTTP layer). */ + requiredScopes?: string[]; promptString: string; // The prompt text with optional {argName} placeholders argsSchema?: PromptArgsSchema; // Can include completable() schemas // Optional completion callbacks keyed by argument name @@ -145,6 +153,8 @@ export interface ResourceTemplateDefinition { name: string; uriTemplate: string; // URI template with {variable} placeholders (RFC 6570) description?: string; + /** OAuth scopes required to read resources from this template (enforced at HTTP layer). */ + requiredScopes?: string[]; inputSchema?: ZodRawShapeCompat; // Schema for template variables handler: ( uri: URL, diff --git a/test-servers/src/load-config.ts b/test-servers/src/load-config.ts index 34b90102f..7ae9f4c45 100644 --- a/test-servers/src/load-config.ts +++ b/test-servers/src/load-config.ts @@ -10,6 +10,8 @@ import YAML from "yaml"; export interface PresetRef { preset: string; params?: Record; + /** OAuth scopes the bearer token must include to use this capability. */ + requiredScopes?: string[]; } export interface ConfigFileOAuth { diff --git a/test-servers/src/preset-registry.ts b/test-servers/src/preset-registry.ts index 5e2f3ea9f..e58567cd3 100644 --- a/test-servers/src/preset-registry.ts +++ b/test-servers/src/preset-registry.ts @@ -12,6 +12,7 @@ import type { } from "./composable-test-server.js"; import { createEchoTool, + createGetEnvTool, createAddTool, createGetSumTool, createWriteToStderrTool, @@ -78,6 +79,9 @@ function resolveToolPreset( switch (name) { case "echo": return createEchoTool(); + case "get-env": + case "get_env": + return createGetEnvTool(); case "add": return createAddTool(); case "get_sum": diff --git a/test-servers/src/resolve-config.ts b/test-servers/src/resolve-config.ts index 01513a6bb..921e54e12 100644 --- a/test-servers/src/resolve-config.ts +++ b/test-servers/src/resolve-config.ts @@ -14,7 +14,17 @@ import { createTestServerInfo } from "./test-server-fixtures.js"; import { resolvePreset } from "./preset-registry.js"; import type { ConfigFile, PresetRef } from "./load-config.js"; -function resolvePresetRefs( +function mergeRequiredScopes( + item: T, + ref: PresetRef, +): T { + if (!ref.requiredScopes?.length) { + return item; + } + return { ...item, requiredScopes: ref.requiredScopes }; +} + +function resolvePresetRefs( refs: Array | undefined, type: "tool" | "resource" | "resourceTemplate" | "prompt", ): T[] { @@ -31,7 +41,9 @@ function resolvePresetRefs( } const resolved = resolvePreset(type, presetName, ref.params); const arr = Array.isArray(resolved) ? resolved : [resolved]; - result.push(...(arr as T[])); + for (const item of arr) { + result.push(mergeRequiredScopes(item as unknown as T, ref)); + } } } return result; diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts index 95c8cbeda..9be3f4d52 100644 --- a/test-servers/src/test-server-fixtures.ts +++ b/test-servers/src/test-server-fixtures.ts @@ -169,6 +169,22 @@ export function createEchoTool(): ToolDefinition { }; } +/** + * Create a "get-env" tool matching @modelcontextprotocol/server-everything. + * Returns the server process environment as pretty-printed JSON text. + */ +export function createGetEnvTool(): ToolDefinition { + return { + name: "get-env", + description: + "Returns all environment variables, helpful for debugging MCP server configuration", + inputSchema: {}, + handler: async () => { + return toToolResult(JSON.stringify(process.env, null, 2)); + }, + }; +} + /** * Create a tool that writes a message to stderr. Used to test stderr capture/piping. */ diff --git a/test-servers/src/test-server-http.ts b/test-servers/src/test-server-http.ts index b85f368cc..4ebb5a37d 100644 --- a/test-servers/src/test-server-http.ts +++ b/test-servers/src/test-server-http.ts @@ -11,6 +11,9 @@ import type { ServerConfig } from "./test-server-fixtures.js"; import { setupOAuthRoutes, createBearerTokenMiddleware, + buildScopeRequirementRegistry, + scopeRequirementRegistryHasEntries, + createScopeCheckMiddleware, } from "./test-server-oauth.js"; import { setTestServerControl, @@ -195,6 +198,10 @@ export class TestServerHttp { const mcpMiddleware: express.RequestHandler[] = []; if (this.config.oauth?.enabled && this.config.oauth.requireAuth) { mcpMiddleware.push(createBearerTokenMiddleware(this.config.oauth)); + const scopeRegistry = buildScopeRequirementRegistry(this.config); + if (scopeRequirementRegistryHasEntries(scopeRegistry)) { + mcpMiddleware.push(createScopeCheckMiddleware(scopeRegistry)); + } } // Set up Express route to handle MCP requests @@ -328,6 +335,10 @@ export class TestServerHttp { const sseMiddleware: express.RequestHandler[] = []; if (this.config.oauth?.enabled && this.config.oauth.requireAuth) { sseMiddleware.push(createBearerTokenMiddleware(this.config.oauth)); + const scopeRegistry = buildScopeRequirementRegistry(this.config); + if (scopeRequirementRegistryHasEntries(scopeRegistry)) { + sseMiddleware.push(createScopeCheckMiddleware(scopeRegistry)); + } } // One McpServer per connection (same pattern as streamable-http) diff --git a/test-servers/src/test-server-oauth-jwt.ts b/test-servers/src/test-server-oauth-jwt.ts index 2df746d4b..519e0408a 100644 --- a/test-servers/src/test-server-oauth-jwt.ts +++ b/test-servers/src/test-server-oauth-jwt.ts @@ -33,6 +33,34 @@ function audienceMatches(aud: unknown, expected: string): boolean { return false; } +function parseScopeString(scope: string | undefined): string[] { + if (!scope?.trim()) { + return []; + } + return scope.trim().split(/\s+/).filter(Boolean); +} + +/** Extract granted OAuth scopes from a verified JWT access token payload. */ +export function extractScopesFromJwtPayload( + payload: Record, +): string[] { + if (typeof payload.scope === "string") { + return parseScopeString(payload.scope); + } + if (Array.isArray(payload.scp)) { + return payload.scp.filter((s): s is string => typeof s === "string"); + } + if (Array.isArray(payload.scopes)) { + return payload.scopes.filter((s): s is string => typeof s === "string"); + } + return []; +} + +export interface ValidatedAccessToken { + valid: boolean; + scopes: string[]; +} + export class ExternalAccessTokenValidator { private jwks: ReturnType | null = null; private allowedIssuers = new Set(); @@ -97,11 +125,15 @@ export class ExternalAccessTokenValidator { }); } - async validateAccessToken(token: string): Promise { + async validateAccessTokenWithScopes( + token: string, + ): Promise { if (!this.jwks) { await this.ensureReady(); } - if (!this.jwks) return false; + if (!this.jwks) { + return { valid: false, scopes: [] }; + } try { const { payload } = await jwtVerify(token, this.jwks, { @@ -111,13 +143,20 @@ export class ExternalAccessTokenValidator { if (this.config.resourceAudience) { const aud = payload.aud; if (!audienceMatches(aud, this.config.resourceAudience)) { - return false; + return { valid: false, scopes: [] }; } } - return true; + return { + valid: true, + scopes: extractScopesFromJwtPayload(payload as Record), + }; } catch { - return false; + return { valid: false, scopes: [] }; } } + + async validateAccessToken(token: string): Promise { + return (await this.validateAccessTokenWithScopes(token)).valid; + } } diff --git a/test-servers/src/test-server-oauth.ts b/test-servers/src/test-server-oauth.ts index 94416e0bd..231ba20a1 100644 --- a/test-servers/src/test-server-oauth.ts +++ b/test-servers/src/test-server-oauth.ts @@ -11,6 +11,11 @@ import express from "express"; import type { ServerConfig } from "./composable-test-server.js"; import { ExternalAccessTokenValidator } from "./test-server-oauth-jwt.js"; +type OAuthRequest = Request & { + oauthToken?: string; + oauthTokenScopes?: string[]; +}; + /** * OAuth configuration from ServerConfig */ @@ -87,14 +92,21 @@ export function createBearerTokenMiddleware( const token = authHeader.substring(7); // Remove "Bearer " prefix let valid = false; + let grantedScopes: string[] = []; if (mode === "protected-resource") { try { - valid = await externalValidator!.validateAccessToken(token); + const validated = + await externalValidator!.validateAccessTokenWithScopes(token); + valid = validated.valid; + grantedScopes = validated.scopes; } catch { valid = false; } } else { valid = isValidToken(token); + if (valid) { + grantedScopes = getAccessTokenScopes(token); + } } if (!valid) { @@ -115,7 +127,9 @@ export function createBearerTokenMiddleware( } // Attach token info to request for use in handlers - (req as Request & { oauthToken?: string }).oauthToken = token; + const oauthReq = req as OAuthRequest; + oauthReq.oauthToken = token; + oauthReq.oauthTokenScopes = grantedScopes; next(); }; } @@ -190,86 +204,218 @@ function setupMetadataEndpoints( } /** - * Set up OAuth authorization endpoint - * For test servers, this auto-approves requests and redirects with authorization code + * Set up OAuth authorization endpoint. + * Shows a simple consent page so users know they reached the test authorization server. */ function setupAuthorizationEndpoint( app: express.Application, config: OAuthConfig, ): void { app.get("/oauth/authorize", async (req: Request, res: Response) => { - const { - client_id, - redirect_uri, - response_type, - scope, - state, - code_challenge, - code_challenge_method, - } = req.query; - - // Validate required parameters - if (!client_id || !redirect_uri || !response_type) { - res.status(400).json({ - error: "invalid_request", - error_description: "Missing required parameters", - }); + const parsed = await parseAuthorizationRequest(req.query, config); + if (!parsed.ok) { + res.status(parsed.status).json(parsed.body); return; } - if (response_type !== "code") { - res.status(400).json({ error: "unsupported_response_type" }); - return; - } + res.type("html").send(renderOAuthConsentPage(parsed.value)); + }); - // Validate client (check static clients, DCR, or CIMD) - const client = await findClient(client_id as string, config); - if (!client) { - res.status(400).json({ error: "invalid_client" }); - return; - } + app.post( + "/oauth/authorize", + express.urlencoded({ extended: true }), + async (req: Request, res: Response) => { + const parsed = await parseAuthorizationRequest(req.body, config); + if (!parsed.ok) { + res.status(parsed.status).json(parsed.body); + return; + } - // Validate redirect_uri - if ( - client.redirectUris && - !client.redirectUris.includes(redirect_uri as string) - ) { - res.status(400).json({ + completeAuthorizationRedirect(res, parsed.value); + }, + ); +} + +interface AuthorizationRequestParams { + clientId: string; + redirectUri: string; + responseType: string; + scope?: string; + state?: string; + codeChallenge?: string; + codeChallengeMethod?: string; +} + +type AuthorizationRequestResult = + | { ok: true; value: AuthorizationRequestParams } + | { ok: false; status: number; body: Record }; + +async function parseAuthorizationRequest( + input: Record, + config: OAuthConfig, +): Promise { + const client_id = input.client_id; + const redirect_uri = input.redirect_uri; + const response_type = input.response_type; + const scope = input.scope; + const state = input.state; + const code_challenge = input.code_challenge; + const code_challenge_method = input.code_challenge_method; + + if ( + typeof client_id !== "string" || + typeof redirect_uri !== "string" || + typeof response_type !== "string" + ) { + return { + ok: false, + status: 400, + body: { + error: "invalid_request", + error_description: "Missing required parameters", + }, + }; + } + + if (response_type !== "code") { + return { ok: false, status: 400, body: { error: "unsupported_response_type" } }; + } + + const client = await findClient(client_id, config); + if (!client) { + return { ok: false, status: 400, body: { error: "invalid_client" } }; + } + + if ( + client.redirectUris && + !client.redirectUris.includes(redirect_uri) + ) { + return { + ok: false, + status: 400, + body: { error: "invalid_request", error_description: "Invalid redirect_uri", - }); - return; - } + }, + }; + } - // Validate PKCE - if (code_challenge_method && code_challenge_method !== "S256") { - res.status(400).json({ + if ( + typeof code_challenge_method === "string" && + code_challenge_method !== "S256" + ) { + return { + ok: false, + status: 400, + body: { error: "invalid_request", error_description: "Unsupported code_challenge_method", - }); - return; - } + }, + }; + } - // For test servers, auto-approve and generate authorization code - const authCode = generateAuthorizationCode(); + return { + ok: true, + value: { + clientId: client_id, + redirectUri: redirect_uri, + responseType: response_type, + ...(typeof scope === "string" ? { scope } : {}), + ...(typeof state === "string" ? { state } : {}), + ...(typeof code_challenge === "string" + ? { codeChallenge: code_challenge } + : {}), + ...(typeof code_challenge_method === "string" + ? { codeChallengeMethod: code_challenge_method } + : {}), + }, + }; +} - // Store authorization code temporarily (in production, use proper storage) - storeAuthorizationCode(authCode, { - clientId: client_id as string, - redirectUri: redirect_uri as string, - codeChallenge: code_challenge as string | undefined, - scope: scope as string | undefined, - }); +function completeAuthorizationRedirect( + res: Response, + params: AuthorizationRequestParams, +): void { + const authCode = generateAuthorizationCode(); + storeAuthorizationCode(authCode, { + clientId: params.clientId, + redirectUri: params.redirectUri, + codeChallenge: params.codeChallenge, + scope: params.scope, + }); - // Redirect with authorization code - const redirectUrl = new URL(redirect_uri as string); - redirectUrl.searchParams.set("code", authCode); - if (state) { - redirectUrl.searchParams.set("state", state as string); - } + const redirectUrl = new URL(params.redirectUri); + redirectUrl.searchParams.set("code", authCode); + if (params.state) { + redirectUrl.searchParams.set("state", params.state); + } + res.redirect(redirectUrl.href); +} - res.redirect(redirectUrl.href); - }); +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function renderOAuthConsentPage(params: AuthorizationRequestParams): string { + const scopeList = parseScopeString(params.scope); + const scopeItems = + scopeList.length > 0 + ? scopeList + .map((scope) => `
  • ${escapeHtml(scope)}
  • `) + .join("") + : "
  • No scopes requested
  • "; + + const hiddenFields = [ + ["client_id", params.clientId], + ["redirect_uri", params.redirectUri], + ["response_type", params.responseType], + ...(params.scope ? [["scope", params.scope] as const] : []), + ...(params.state ? [["state", params.state] as const] : []), + ...(params.codeChallenge + ? [["code_challenge", params.codeChallenge] as const] + : []), + ...(params.codeChallengeMethod + ? [["code_challenge_method", params.codeChallengeMethod] as const] + : []), + ] + .map( + ([name, value]) => + ``, + ) + .join("\n"); + + return ` + + + + + Authorize — MCP test server + + + +

    Authorize MCP Inspector

    +

    You were redirected to the local composable test authorization server.

    +

    Client: ${escapeHtml(params.clientId)}

    +

    Requested scopes:

    +
      ${scopeItems}
    +
    + ${hiddenFields} + +
    + +`; } /** @@ -382,7 +528,9 @@ function setupTokenEndpoint( } // Generate access token - const accessToken = generateAccessToken(); + const tokenScope = + authCodeData.scope || config.scopesSupported?.[0] || "mcp"; + const accessToken = generateAccessToken(tokenScope); const tokenExpiration = config.tokenExpirationSeconds || 3600; const response: { @@ -395,7 +543,7 @@ function setupTokenEndpoint( access_token: accessToken, token_type: "Bearer", expires_in: tokenExpiration, - scope: authCodeData.scope || config.scopesSupported?.[0] || "mcp", + scope: tokenScope, }; // Add refresh token if supported @@ -422,14 +570,16 @@ function setupTokenEndpoint( return; } - const accessToken = generateAccessToken(); + const tokenScope = + refreshTokenData.scope || config.scopesSupported?.[0] || "mcp"; + const accessToken = generateAccessToken(tokenScope); const tokenExpiration = config.tokenExpirationSeconds || 3600; res.json({ access_token: accessToken, token_type: "Bearer", expires_in: tokenExpiration, - scope: refreshTokenData.scope || config.scopesSupported?.[0] || "mcp", + scope: tokenScope, }); } else { res.status(400).json({ error: "unsupported_grant_type" }); @@ -501,6 +651,8 @@ interface RegisteredClient { const authorizationCodes = new Map(); const accessTokens = new Set(); +/** Granted OAuth scope string per access token (space-separated). */ +const accessTokenScopes = new Map(); const refreshTokens = new Map(); const registeredClients = new Map(); @@ -627,9 +779,10 @@ function getAuthorizationCode(code: string): AuthorizationCodeData | null { return data; } -function generateAccessToken(): string { +function generateAccessToken(scope?: string): string { const token = `test_access_token_${Date.now()}_${Math.random().toString(36).substring(7)}`; accessTokens.add(token); + accessTokenScopes.set(token, scope?.trim() || "mcp"); return token; } @@ -663,12 +816,169 @@ function isValidToken(token: string): boolean { return accessTokens.has(token); } +function parseScopeString(scope: string | undefined): string[] { + if (!scope?.trim()) { + return []; + } + return scope.trim().split(/\s+/).filter(Boolean); +} + +/** Test helper: mint an access token with the given granted scopes. */ +export function mintTestAccessToken(scope: string): string { + return generateAccessToken(scope); +} + +/** Granted scopes for a test-server access token (empty when unknown). */ +export function getAccessTokenScopes(token: string): string[] { + return parseScopeString(accessTokenScopes.get(token)); +} + +export interface ScopeRequirementRegistry { + tools: Map; + resources: Map; + prompts: Map; +} + +/** Build lookup tables from merged ServerConfig capability definitions. */ +export function buildScopeRequirementRegistry( + config: ServerConfig, +): ScopeRequirementRegistry { + const registry: ScopeRequirementRegistry = { + tools: new Map(), + resources: new Map(), + prompts: new Map(), + }; + + for (const tool of config.tools ?? []) { + if (tool.requiredScopes?.length) { + registry.tools.set(tool.name, tool.requiredScopes); + } + } + for (const resource of config.resources ?? []) { + if (resource.requiredScopes?.length) { + registry.resources.set(resource.uri, resource.requiredScopes); + } + } + for (const prompt of config.prompts ?? []) { + if (prompt.requiredScopes?.length) { + registry.prompts.set(prompt.name, prompt.requiredScopes); + } + } + + return registry; +} + +export function scopeRequirementRegistryHasEntries( + registry: ScopeRequirementRegistry, +): boolean { + return ( + registry.tools.size > 0 || + registry.resources.size > 0 || + registry.prompts.size > 0 + ); +} + +function parseMcpOperation(body: unknown): { + method?: string; + target?: string; +} { + if (!body || typeof body !== "object") { + return {}; + } + const rpc = body as Record; + const method = typeof rpc.method === "string" ? rpc.method : undefined; + const params = + rpc.params && typeof rpc.params === "object" + ? (rpc.params as Record) + : undefined; + + if (method === "tools/call" && typeof params?.name === "string") { + return { method, target: params.name }; + } + if (method === "resources/read" && typeof params?.uri === "string") { + return { method, target: params.uri }; + } + if (method === "prompts/get" && typeof params?.name === "string") { + return { method, target: params.name }; + } + + return { method }; +} + +function tokenHasRequiredScopes( + granted: string[], + required: string[], +): boolean { + const grantedSet = new Set(granted); + return required.every((scope) => grantedSet.has(scope)); +} + +/** + * Enforce per-capability OAuth scopes after bearer validation. + * Returns 403 + insufficient_scope when the token is valid but lacks scope. + */ +export function createScopeCheckMiddleware( + registry: ScopeRequirementRegistry, +): express.RequestHandler { + return (req: Request, res: Response, next: express.NextFunction) => { + const oauthReq = req as OAuthRequest; + const token = oauthReq.oauthToken; + if (!token) { + return next(); + } + + const { method, target } = parseMcpOperation(req.body); + if (!method || !target) { + return next(); + } + + let requiredScopes: string[] | undefined; + if (method === "tools/call") { + requiredScopes = registry.tools.get(target); + } else if (method === "resources/read") { + requiredScopes = registry.resources.get(target); + } else if (method === "prompts/get") { + requiredScopes = registry.prompts.get(target); + } + + if (!requiredScopes?.length) { + return next(); + } + + const granted = + oauthReq.oauthTokenScopes ?? getAccessTokenScopes(token); + if (tokenHasRequiredScopes(granted, requiredScopes)) { + return next(); + } + + const grantedSet = new Set(granted); + const missingScopes = requiredScopes.filter((scope) => !grantedSet.has(scope)); + const scopeHeader = missingScopes.join(" ") || requiredScopes.join(" "); + res.status(403); + res.setHeader("Content-Type", "application/json"); + res.setHeader( + "WWW-Authenticate", + `Bearer error="insufficient_scope", scope="${scopeHeader}"`, + ); + res.json({ + jsonrpc: "2.0", + error: { + code: -32603, + message: `Forbidden: insufficient scope (403). Required: ${scopeHeader}`, + }, + id: null, + }); + return; + }; +} + /** * Clear all OAuth test data (useful for test cleanup) */ export function clearOAuthTestData(): void { authorizationCodes.clear(); accessTokens.clear(); + accessTokenScopes.clear(); refreshTokens.clear(); registeredClients.clear(); dcrRequests.length = 0; @@ -689,4 +999,5 @@ export function getDCRRequests(): Array<{ redirect_uris: string[] }> { */ export function invalidateAccessToken(token: string): void { accessTokens.delete(token); + accessTokenScopes.delete(token); } diff --git a/test-servers/tsconfig.json b/test-servers/tsconfig.json index 7eda8adbc..3f2a2079a 100644 --- a/test-servers/tsconfig.json +++ b/test-servers/tsconfig.json @@ -10,7 +10,9 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node"], + "typeRoots": ["../clients/web/node_modules/@types"] }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "build"] From 26876a98d77b3022a05ba62397bcb6b3e19bbb49 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Thu, 2 Jul 2026 16:44:04 -0700 Subject: [PATCH 03/11] Added pretest target to build test server --- clients/web/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/clients/web/package.json b/clients/web/package.json index 14f3c1cca..b51cb69bd 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -17,6 +17,7 @@ "build:runner": "tsup --config tsup.runner.config.ts", "build:storybook": "storybook build", "validate": "npm run format:check && npm run lint && npm run build && npm run test", + "pretest": "npm run test-servers:build", "test": "vitest run --project=unit", "test:watch": "vitest --project=unit", "test:coverage": "npm run test-servers:build && vitest run --project=unit --project=integration --coverage", From 0635d09e8311735f074ca3ef0ac44b4b16d5c1ef Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Thu, 2 Jul 2026 17:07:25 -0700 Subject: [PATCH 04/11] Fixed CI failure (test fixture) and added local CI script --- AGENTS.md | 9 ++++---- .../helpers/oauth-client-fixtures.ts | 22 ++++++++++++++----- package.json | 3 +++ 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fed0eca19..1b84fa8fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -189,14 +189,15 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i ### Lint-fixed, Formatted code - ALWAYS do `npm run format` before committing — it auto-fixes any Prettier issues. `validate` runs `format:check` (the non-fixing variant) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it. -- ALWAYS do `npm run validate` before pushing any changes — from the repo root it chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each delegates to that client's own `npm run validate` = `format:check` + `lint` + `build` + `test` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). +- **`npm run ci` before pushing** — runs the same steps as `.github/workflows/main.yml` (minus `npm install`): `validate` → web `test:integration` → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). Use this when you want CI parity; expect several minutes. **`npm run validate`** remains the fast loop during development (unit tests only — no web integration, smoke, or Storybook). +- ALWAYS do `npm run format` before committing, then **`npm run ci`** (or at minimum `npm run validate`) before pushing. From the repo root, `validate` chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each delegates to that client's own `npm run validate` = `format:check` + `lint` + `build` + `test` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && build && test`. - - Before pushing, also run **`npm run coverage`** — `validate` is fast and does NOT enforce the per-file gate (or, for web, run the integration project); `coverage` does both. CI does **not** run `coverage` (the gate is local-only); it runs `validate` plus a standalone web `test:integration` step. -- **`smoke` is a separate top-level target, NOT part of `validate`.** Run it (or the individual `smoke:*`) after a build/validate: `npm run validate && npm run smoke`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the web `test:integration` step, then `smoke`. Storybook is the only CI step left out (see below). + - Optionally also run **`npm run coverage`** when you want the local-only per-file ≥90 gate — CI does **not** run `coverage`; it relies on `validate` + web `test:integration` instead. +- **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). - `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. - `smoke:web` (`scripts/smoke-web.mjs`) starts `mcp-inspector --web` (prod, no `--dev`) against the built `clients/web/dist` and asserts `GET /` serves the SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. Prod `--web` serves from `clients/web/dist`, which ships in the published package but is absent in a fresh checkout — the runner builds it on demand (`build:client` = `vite build`) on first launch, or exits with an actionable error if that build can't run (see `clients/web/server/ensure-web-build.ts` and the launcher README). `--dev` runs Vite directly and never needs `dist`. - `smoke:cli` (`scripts/smoke-cli.mjs`) drives `mcp-inspector --cli` through the built launcher against the bundled stdio test server via a temp `--catalog`: it asserts `tools/list` returns the server's tools (real connect over stdio), the default writable catalog is seeded empty on first run, a missing read-only `--config` errors without seeding, and `--catalog` + `--config` is rejected. `smoke:tui` (`scripts/smoke-tui.mjs`) launches `mcp-inspector --tui --catalog ` and asserts the Ink app renders its first frame (the "MCP Servers" panel) within a timeout, then SIGTERMs it — a shallow boot/render check, not full interaction. **`smoke:tui` is local-only: it self-skips when `process.env.CI` is set**, because the Ink TUI needs a real TTY (raw mode) that headless CI lacks — so run it (via `npm run smoke`) on your own machine before pushing. Both build `test-servers/build` on demand if it's missing. -- Also run `npm run test:storybook` from `clients/web/` before pushing — it executes every story's `play` function in headless Chromium via `@vitest/browser-playwright` (~10s). CI runs this as a separate step (from `clients/web`) after `validate`; failures block merge. It is kept out of `validate` because it needs the Playwright browser binary and is much slower than the unit suite. (There is no root-level `test:storybook` aggregate — run it in the web client.) +- Storybook play-function tests (`clients/web` `test:storybook`) run in headless Chromium via `@vitest/browser-playwright` (~10s). They are part of `npm run ci` (which installs Playwright chromium first); kept out of `validate` because they need the browser binary and are slower than the unit suite. ### Typescript instructions - Use TypeScript for all new code diff --git a/clients/web/src/test/integration/helpers/oauth-client-fixtures.ts b/clients/web/src/test/integration/helpers/oauth-client-fixtures.ts index eb1b23ac9..f46e4780e 100644 --- a/clients/web/src/test/integration/helpers/oauth-client-fixtures.ts +++ b/clients/web/src/test/integration/helpers/oauth-client-fixtures.ts @@ -137,9 +137,8 @@ export async function createClientMetadataServer( } /** - * Helper function to programmatically complete OAuth authorization - * Makes HTTP GET request to authorization URL and extracts authorization code - * The test server's authorization endpoint auto-approves and redirects with code + * Programmatically complete OAuth against the local composable test AS. + * GET shows an HTML consent page; approve via POST (same as CLI test helper). * * @param authorizationUrl - The authorization URL from oauthAuthorizationRequired event * @returns Authorization code extracted from redirect URL @@ -147,10 +146,23 @@ export async function createClientMetadataServer( export async function completeOAuthAuthorization( authorizationUrl: URL, ): Promise { - const response = await fetch(authorizationUrl.toString(), { + let response = await fetch(authorizationUrl.toString(), { redirect: "manual", }); + if (response.status === 200) { + const body = new URLSearchParams(authorizationUrl.searchParams); + response = await fetch( + `${authorizationUrl.origin}${authorizationUrl.pathname}`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + redirect: "manual", + }, + ); + } + if (response.status !== 302 && response.status !== 301) { throw new Error( `Expected redirect (302/301), got ${response.status}: ${await response.text()}`, @@ -162,7 +174,7 @@ export async function completeOAuthAuthorization( throw new Error("No Location header in redirect response"); } - const redirectUrlObj = new URL(redirectUrl); + const redirectUrlObj = new URL(redirectUrl, authorizationUrl.origin); const code = redirectUrlObj.searchParams.get("code"); if (!code) { throw new Error(`No authorization code in redirect URL: ${redirectUrl}`); diff --git a/package.json b/package.json index f49ae384f..dd56fe208 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,9 @@ "build:tui": "cd clients/tui && npm run build", "build:web": "cd clients/web && npm run build", "build:launcher": "cd clients/launcher && npm run build", + "ci": "npm run validate && npm run ci:integration && npm run smoke && npm run ci:storybook", + "ci:integration": "cd clients/web && npm run test:integration", + "ci:storybook": "cd clients/web && npx playwright install chromium && npm run test:storybook", "validate": "npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher", "validate:cli": "cd clients/cli && npm run validate", "validate:tui": "cd clients/tui && npm run validate", From 52023ff1c3081840bade9a8b1cb4980abfcede29 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Thu, 2 Jul 2026 17:55:34 -0700 Subject: [PATCH 05/11] fix(auth): clear OAuth callback timer and reorder EMA scope persist Clear the loopback callback timeout in runner-interactive-oauth finally so already_authorized and error paths do not hang the CLI/TUI. Check EMA step-up satisfaction against minted token scope before persisting the requested union, matching the standard OAuth path. Co-authored-by: Cursor --- .../auth/runner-interactive-oauth.test.ts | 28 ++++++++++ .../src/test/core/mcp/oauthManager.test.ts | 56 ++++++++++++++++++- core/auth/node/runner-interactive-oauth.ts | 6 +- core/mcp/oauthManager.ts | 12 ++-- 4 files changed, 92 insertions(+), 10 deletions(-) diff --git a/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts b/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts index 05d3f810f..eac480ee8 100644 --- a/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts +++ b/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts @@ -84,6 +84,34 @@ describe("runRunnerInteractiveOAuth", () => { expect(client.completeOAuthFlow).not.toHaveBeenCalled(); }); + it("clears the callback timeout on already_authorized", async () => { + vi.useFakeTimers(); + try { + const client = mockClient({ + authenticate: vi.fn(async () => undefined), + }); + const redirectUrlProvider = { redirectUrl: "" }; + + const result = await runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + callbackTimeoutMs: 60_000, + createCallbackServer: () => createMockCallbackServer(handlers), + }); + + expect(result).toEqual({ kind: "already_authorized" }); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(60_000); + } finally { + vi.useRealTimers(); + } + }); + it("completes connect-time OAuth via authenticate and callback", async () => { const redirectUrlProvider = { redirectUrl: "" }; const client = mockClient({ diff --git a/clients/web/src/test/core/mcp/oauthManager.test.ts b/clients/web/src/test/core/mcp/oauthManager.test.ts index 328ceca81..13fe1bc49 100644 --- a/clients/web/src/test/core/mcp/oauthManager.test.ts +++ b/clients/web/src/test/core/mcp/oauthManager.test.ts @@ -1215,9 +1215,6 @@ describe("OAuthManager", () => { }); it("returns satisfied for EMA insufficient_scope after user confirms", async () => { - const silentSpy = vi - .spyOn(emaFlow, "trySilentEmaAuth") - .mockResolvedValue({ status: "success" }); const params = createMockParams({ enterpriseManagedAuth: { idp: { @@ -1237,6 +1234,14 @@ describe("OAuthManager", () => { token_type: "Bearer", scope: "mcp", }); + const silentSpy = vi.spyOn(emaFlow, "trySilentEmaAuth").mockImplementation(async () => { + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + scope: "mcp weather:read", + }); + return { status: "success" }; + }); const manager = new OAuthManager(params); manager.setOAuthConfig({ enterpriseManaged: true }); @@ -1257,6 +1262,51 @@ describe("OAuthManager", () => { silentSpy.mockRestore(); }); + it("does not persist union scope or return satisfied when silent EMA mint is down-scoped", async () => { + const silentSpy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "success" }); + const authUrl = new URL("https://idp.example.com/authorize?state=ema"); + const startSpy = vi + .spyOn(emaFlow, "startEmaIdpAuthorization") + .mockResolvedValue(authUrl); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + storageOf(params).getScope.mockReturnValue("mcp"); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + scope: "mcp", + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const outcome = await manager.handleAuthChallenge( + { + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }, + { confirmedStepUp: true }, + ); + + expect(outcome).toEqual( + expect.objectContaining({ + kind: "interactive", + authorizationUrl: authUrl, + }), + ); + expect(storageOf(params).saveScope).not.toHaveBeenCalled(); + silentSpy.mockRestore(); + startSpy.mockRestore(); + }); + it("completeOAuthFlow mints EMA tokens with pending step-up union scope", async () => { const silentSpy = vi .spyOn(emaFlow, "trySilentEmaAuth") diff --git a/core/auth/node/runner-interactive-oauth.ts b/core/auth/node/runner-interactive-oauth.ts index ab6a9aad0..77de2b17e 100644 --- a/core/auth/node/runner-interactive-oauth.ts +++ b/core/auth/node/runner-interactive-oauth.ts @@ -58,6 +58,8 @@ export async function runRunnerInteractiveOAuth( flowReject = reject; }); + let timeoutId: ReturnType | undefined; + try { const { redirectUrl } = await server.start({ hostname: options.callbackListen.hostname, @@ -85,7 +87,6 @@ export async function runRunnerInteractiveOAuth( const timeoutMs = options.callbackTimeoutMs ?? DEFAULT_RUNNER_INTERACTIVE_OAUTH_TIMEOUT_MS; - let timeoutId: ReturnType | undefined; const waitForCallback = Promise.race([ flowDone.finally(() => { if (timeoutId !== undefined) { @@ -131,6 +132,9 @@ export async function runRunnerInteractiveOAuth( return { kind: "success" }; } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } await server.stop().catch(() => {}); } } diff --git a/core/mcp/oauthManager.ts b/core/mcp/oauthManager.ts index 8d38362df..96c95e198 100644 --- a/core/mcp/oauthManager.ts +++ b/core/mcp/oauthManager.ts @@ -602,13 +602,13 @@ export class OAuthManager { if (enriched.reason === "insufficient_scope") { const silent = await trySilentEmaAuth(config); if (silent.status === "success") { - if (enriched.authorizationScopes?.length) { - await this.oauthConfig.storage!.saveScope( - this.getServerUrl(), - enriched.authorizationScopes.join(" "), - ); - } if (await this.checkAuthChallengeSatisfied(enriched)) { + if (enriched.authorizationScopes?.length) { + await this.oauthConfig.storage!.saveScope( + this.getServerUrl(), + enriched.authorizationScopes.join(" "), + ); + } return { kind: "satisfied" }; } } From 8f1ed7588d2758235c9e2c5e4cc2db298e6bff59 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Thu, 2 Jul 2026 18:03:18 -0700 Subject: [PATCH 06/11] Prettier fix on last commit --- .../web/src/test/core/mcp/oauthManager.test.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/clients/web/src/test/core/mcp/oauthManager.test.ts b/clients/web/src/test/core/mcp/oauthManager.test.ts index 13fe1bc49..262942177 100644 --- a/clients/web/src/test/core/mcp/oauthManager.test.ts +++ b/clients/web/src/test/core/mcp/oauthManager.test.ts @@ -1234,14 +1234,16 @@ describe("OAuthManager", () => { token_type: "Bearer", scope: "mcp", }); - const silentSpy = vi.spyOn(emaFlow, "trySilentEmaAuth").mockImplementation(async () => { - storageOf(params).getTokens.mockResolvedValue({ - access_token: "tok", - token_type: "Bearer", - scope: "mcp weather:read", + const silentSpy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockImplementation(async () => { + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + scope: "mcp weather:read", + }); + return { status: "success" }; }); - return { status: "success" }; - }); const manager = new OAuthManager(params); manager.setOAuthConfig({ enterpriseManaged: true }); From a066873e37540fdabe757822c537856cfe66312e Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Thu, 2 Jul 2026 18:36:44 -0700 Subject: [PATCH 07/11] Fixes for remaining issues --- clients/tui/src/App.tsx | 96 ++++++++++++++----- clients/web/src/test/core/auth/scopes.test.ts | 30 ++++++ ...spectorClient-direct-auth-recovery.test.ts | 50 ++++++++++ .../src/test/core/mcp/oauthManager.test.ts | 94 ++++++++++++++++++ .../test/core/mcp/test-server-scope.test.ts | 44 +++++++++ .../mcp/remote/remote-session.test.ts | 12 +++ core/auth/scopes.ts | 32 +++++++ core/mcp/oauthManager.ts | 60 ++++++++---- core/mcp/remote/node/remote-session.ts | 5 + core/mcp/remote/remoteClientTransport.ts | 5 + specification/v2_auth_mid_session.md | 6 +- test-servers/src/test-server-oauth.ts | 24 ++++- 12 files changed, 415 insertions(+), 43 deletions(-) create mode 100644 clients/web/src/test/core/mcp/inspectorClient-direct-auth-recovery.test.ts diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 86445f0a6..01b05bc57 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -559,16 +559,23 @@ function App({ ); // Connect — on 401 or mid-session auth recovery, run OAuth then retry. + type TuiOAuthRunResult = + | "success" + | "already_authorized" + | "insufficient_scope" + | "skipped" + | "unsupported"; + const runOAuthAuthentication = useCallback( async (options?: { challenge?: AuthChallenge; authorizationUrl?: URL; /** When set, run OAuth for this server (may differ from the selected server). */ serverName?: string; - }) => { + }): Promise => { const serverName = options?.serverName ?? selectedServer; if (!serverName) { - return; + return "unsupported"; } const client = inspectorClientsRef.current[serverName]; const serverEntry = mcpServersRef.current[serverName]; @@ -578,9 +585,11 @@ function App({ !serverConfig || !isOAuthCapableServerConfig(serverConfig) ) { - return; + return "unsupported"; + } + if (oauthInProgressRef.current) { + return "skipped"; } - if (oauthInProgressRef.current) return; oauthInProgressRef.current = true; getTuiLogger().info( { server: serverName }, @@ -594,7 +603,7 @@ function App({ const redirectUrlProvider = redirectUrlProvidersRef.current[serverName]; if (!redirectUrlProvider) { oauthInProgressRef.current = false; - return; + return "unsupported"; } try { const result = await runRunnerInteractiveOAuth({ @@ -612,11 +621,13 @@ function App({ if (result.kind === "insufficient_scope") { setOauthStatus("error"); setOauthMessage(stepUpInsufficientScopeMessage(result.challenge)); - return; + return "insufficient_scope"; } if (result.kind === "success" || result.kind === "already_authorized") { setOauthRevision((n) => n + 1); + return result.kind; } + return "unsupported"; } finally { oauthInProgressRef.current = false; callbackServerRef.current = null; @@ -710,13 +721,21 @@ function App({ } setOauthStatus("authenticating"); try { - await runOAuthAuthentication({ + const oauthResult = await runOAuthAuthentication({ challenge: error.authChallenge, authorizationUrl: error.authorizationUrl, serverName, }); - setOauthStatus("idle"); - setOauthMessage("Authorization updated. Retry your action."); + if ( + oauthResult === "success" || + oauthResult === "already_authorized" + ) { + setOauthStatus("idle"); + setOauthMessage("Authorization updated. Retry your action."); + } else if (oauthResult === "skipped") { + setOauthStatus("idle"); + setOauthMessage("OAuth already in progress."); + } } catch (authErr) { const authMsg = authErr instanceof Error ? authErr.message : String(authErr); @@ -770,8 +789,16 @@ function App({ setOauthStatus("authenticating"); setOauthMessage(null); await disconnectInspector(); - await runOAuthAuthentication(); - await finishConnect(); + const oauthResult = await runOAuthAuthentication(); + if ( + oauthResult === "success" || + oauthResult === "already_authorized" + ) { + await finishConnect(); + } else if (oauthResult === "skipped") { + setOauthStatus("idle"); + setOauthMessage("OAuth already in progress."); + } } catch (authErr) { if (authErr instanceof AuthRecoveryRequiredError) { handleAuthRecoveryRequired(selectedServer, authErr); @@ -1299,8 +1326,15 @@ function App({ ); if (tabAccelerators[input.toLowerCase()]) { const nextTab = tabAccelerators[input.toLowerCase()]!; - setActiveTab(nextTab); - setFocus(nextTab === "auth" ? "tabContentList" : "tabs"); + const authStepUpAccelerator = + input.toLowerCase() === "a" && + nextTab === "auth" && + activeTab === "auth" && + pendingStepUp?.serverName === selectedServer; + if (!authStepUpAccelerator) { + setActiveTab(nextTab); + setFocus(nextTab === "auth" ? "tabContentList" : "tabs"); + } } else if (key.tab && !key.shift) { // Flat focus order: servers -> tabs -> list -> details -> wrap to servers const focusOrder: FocusArea[] = @@ -1689,14 +1723,22 @@ function App({ return; } if (outcome.kind === "interactive") { - await runOAuthAuthentication({ + const oauthResult = await runOAuthAuthentication({ challenge: outcome.challenge, authorizationUrl: outcome.authorizationUrl, }); - setOauthStatus("idle"); - setOauthMessage( - "Step-up authorization succeeded. Retry your action.", - ); + if ( + oauthResult === "success" || + oauthResult === "already_authorized" + ) { + setOauthStatus("idle"); + setOauthMessage( + "Step-up authorization succeeded. Retry your action.", + ); + } else if (oauthResult === "skipped") { + setOauthStatus("idle"); + setOauthMessage("OAuth already in progress."); + } return; } if (outcome.kind === "failed") { @@ -1712,14 +1754,22 @@ function App({ ); return; } - await runOAuthAuthentication({ + const oauthResult = await runOAuthAuthentication({ challenge, authorizationUrl, }); - setOauthStatus("idle"); - setOauthMessage( - "Step-up authorization succeeded. Retry your action.", - ); + if ( + oauthResult === "success" || + oauthResult === "already_authorized" + ) { + setOauthStatus("idle"); + setOauthMessage( + "Step-up authorization succeeded. Retry your action.", + ); + } else if (oauthResult === "skipped") { + setOauthStatus("idle"); + setOauthMessage("OAuth already in progress."); + } } catch (authErr) { const authMsg = authErr instanceof Error diff --git a/clients/web/src/test/core/auth/scopes.test.ts b/clients/web/src/test/core/auth/scopes.test.ts index ebb474c12..bc9066611 100644 --- a/clients/web/src/test/core/auth/scopes.test.ts +++ b/clients/web/src/test/core/auth/scopes.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest"; import { computeScopeUnion, isStrictScopeSuperset, + resolveEffectiveGrantedScope, + resolvePersistedScopeAfterGrant, } from "@inspector/core/auth/scopes.js"; describe("scopes", () => { @@ -34,4 +36,32 @@ describe("scopes", () => { expect(isStrictScopeSuperset("mcp weather:read", undefined)).toBe(true); }); }); + + describe("resolvePersistedScopeAfterGrant", () => { + it("prefers explicit granted scope over requested", () => { + expect(resolvePersistedScopeAfterGrant("mcp", "mcp weather:read")).toBe( + "mcp", + ); + }); + + it("falls back to requested scope when grant omits scope", () => { + expect( + resolvePersistedScopeAfterGrant(undefined, "mcp weather:read"), + ).toBe("mcp weather:read"); + }); + }); + + describe("resolveEffectiveGrantedScope", () => { + it("uses token scope when present even if storage overstates grant", () => { + expect(resolveEffectiveGrantedScope("mcp weather:read", "mcp")).toBe( + "mcp", + ); + }); + + it("falls back to stored scope when token omits scope", () => { + expect(resolveEffectiveGrantedScope("mcp tools:read", undefined)).toBe( + "mcp tools:read", + ); + }); + }); }); diff --git a/clients/web/src/test/core/mcp/inspectorClient-direct-auth-recovery.test.ts b/clients/web/src/test/core/mcp/inspectorClient-direct-auth-recovery.test.ts new file mode 100644 index 000000000..95d366638 --- /dev/null +++ b/clients/web/src/test/core/mcp/inspectorClient-direct-auth-recovery.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { AuthChallengeError } from "@inspector/core/auth/challenge.js"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; + +describe("InspectorClient direct auth recovery retry bound", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("retries an operation once after satisfied recovery, then gives up", async () => { + const client = Object.create(InspectorClient.prototype) as InspectorClient; + const internals = client as unknown as { + directAuthRecovery: boolean; + directAuthRecoveryActive: boolean; + reconnectAfterAuthRecovery: ReturnType; + withDirectAuthRecovery: ( + operation: () => Promise, + context?: { method?: string; toolName?: string }, + attempt?: number, + ) => Promise; + }; + internals.directAuthRecovery = true; + internals.directAuthRecoveryActive = true; + internals.reconnectAfterAuthRecovery = vi.fn().mockResolvedValue(undefined); + + const challenge = { + reason: "insufficient_scope" as const, + requiredScopes: ["weather:read"], + }; + const operation = vi + .fn() + .mockRejectedValue(new AuthChallengeError(challenge, 403)); + + vi.spyOn(client, "handleAuthChallenge").mockResolvedValue({ + kind: "satisfied", + }); + vi.spyOn(client, "dispatchTypedEvent").mockImplementation(() => {}); + + await expect( + internals.withDirectAuthRecovery.call(client, operation, { + method: "tools/call", + toolName: "get_temp", + }), + ).rejects.toBeInstanceOf(AuthChallengeError); + + expect(operation).toHaveBeenCalledTimes(2); + expect(client.handleAuthChallenge).toHaveBeenCalledTimes(1); + expect(internals.reconnectAfterAuthRecovery).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clients/web/src/test/core/mcp/oauthManager.test.ts b/clients/web/src/test/core/mcp/oauthManager.test.ts index 262942177..1ace33db4 100644 --- a/clients/web/src/test/core/mcp/oauthManager.test.ts +++ b/clients/web/src/test/core/mcp/oauthManager.test.ts @@ -606,6 +606,54 @@ describe("OAuthManager", () => { ); captureSpy.mockRestore(); }); + + it("persists granted scope when AS down-scopes the token response", async () => { + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); + const params = createMockParams(); + storageOf(params).getScope.mockReturnValue("mcp"); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "access", + token_type: "Bearer", + scope: "mcp", + }); + storageOf(params).getClientInformation.mockResolvedValue({ + client_id: "cid", + }); + const manager = new OAuthManager(params); + ( + manager as unknown as { pendingAuthorizationScope: string | undefined } + ).pendingAuthorizationScope = "mcp weather:read"; + + await manager.completeOAuthFlow("code"); + + expect(storageOf(params).saveScope).toHaveBeenCalledWith( + SERVER_URL, + "mcp", + ); + }); + + it("persists requested scope when the token response omits scope", async () => { + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "access", + token_type: "Bearer", + }); + storageOf(params).getClientInformation.mockResolvedValue({ + client_id: "cid", + }); + const manager = new OAuthManager(params); + ( + manager as unknown as { pendingAuthorizationScope: string | undefined } + ).pendingAuthorizationScope = "mcp weather:read"; + + await manager.completeOAuthFlow("code"); + + expect(storageOf(params).saveScope).toHaveBeenCalledWith( + SERVER_URL, + "mcp weather:read", + ); + }); }); describe("completeOAuthFlow (EMA)", () => { @@ -823,6 +871,34 @@ describe("OAuthManager", () => { ).toBe(true); }); + it("returns false for invalid_token even when a locally valid token exists", async () => { + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + expires_in: 3600, + }); + const manager = new OAuthManager(params); + + expect( + await manager.checkAuthChallengeSatisfied({ reason: "invalid_token" }), + ).toBe(false); + }); + + it("returns false for unauthorized even when a locally valid token exists", async () => { + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + expires_in: 3600, + }); + const manager = new OAuthManager(params); + + expect( + await manager.checkAuthChallengeSatisfied({ reason: "unauthorized" }), + ).toBe(false); + }); + it("returns true when stored scope covers step-up union", async () => { const params = createMockParams(); storageOf(params).getTokens.mockResolvedValue({ @@ -859,6 +935,24 @@ describe("OAuthManager", () => { ).toBe(false); }); + it("ignores inflated stored scope when token scope is explicit", async () => { + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + scope: "mcp", + }); + storageOf(params).getScope.mockReturnValue("mcp weather:read"); + const manager = new OAuthManager(params); + + expect( + await manager.checkAuthChallengeSatisfied({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }), + ).toBe(false); + }); + it("returns false for insufficient_scope with no scopes in the challenge", async () => { const params = createMockParams(); storageOf(params).getTokens.mockResolvedValue({ diff --git a/clients/web/src/test/core/mcp/test-server-scope.test.ts b/clients/web/src/test/core/mcp/test-server-scope.test.ts index ce5f8cce9..903ab5f2f 100644 --- a/clients/web/src/test/core/mcp/test-server-scope.test.ts +++ b/clients/web/src/test/core/mcp/test-server-scope.test.ts @@ -121,6 +121,50 @@ describe("test server scope requirements", () => { expect(result.status).toBe(200); }); + it("enforces requiredScopes on resource templates for resources/read", () => { + const registry = buildScopeRequirementRegistry({ + ...createMinimalConfig({ tools: [] }), + resourceTemplates: [ + { + name: "files", + uriTemplate: "file:///{path}", + requiredScopes: ["files:read"], + handler: async () => ({ + contents: [{ uri: "file:///tmp/x", text: "ok" }], + }), + }, + ], + }); + expect(registry.resourceTemplates.get("file:///{path}")).toEqual([ + "files:read", + ]); + + const denied = invokeScopeMiddleware( + registry, + { + jsonrpc: "2.0", + id: 2, + method: "resources/read", + params: { uri: "file:///tmp/example.txt" }, + }, + mintTestAccessToken("mcp"), + ); + expect(denied.next).toBe(false); + expect(denied.status).toBe(403); + + const allowed = invokeScopeMiddleware( + registry, + { + jsonrpc: "2.0", + id: 3, + method: "resources/read", + params: { uri: "file:///tmp/example.txt" }, + }, + mintTestAccessToken("mcp files:read"), + ); + expect(allowed.next).toBe(true); + }); + it("uses oauthTokenScopes attached by bearer middleware (external JWT path)", () => { const registry = buildScopeRequirementRegistry({ ...createMinimalConfig(), diff --git a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts index e1dca9fe6..960273a2d 100644 --- a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts @@ -267,6 +267,18 @@ describe("RemoteSession", () => { vi.useRealTimers(); }); + it("cancelRequestWait clears the timeout for a pending wait", async () => { + vi.useFakeTimers(); + const session = new RemoteSession("s-cancel"); + const wait = session.waitForRequestResponse(42, 5000); + const rejection = expect(wait).rejects.toThrow(/cancelled/); + session.cancelRequestWait(42); + await rejection; + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(5000); + vi.useRealTimers(); + }); + it("setAuthState updates the session auth provider", async () => { const { createRemoteAuthProvider } = await import("@inspector/core/mcp/remote/node/tokenAuthProvider.js"); diff --git a/core/auth/scopes.ts b/core/auth/scopes.ts index 2ec9aa6d7..d858bd0f2 100644 --- a/core/auth/scopes.ts +++ b/core/auth/scopes.ts @@ -41,3 +41,35 @@ export function isStrictScopeSuperset( } return false; } + +/** + * Scope to persist after a successful token grant (RFC 6749 §5.1). + * When the AS returns `scope`, it is the authoritative full grant. + * When `scope` is omitted on success, granted equals what was requested. + */ +export function resolvePersistedScopeAfterGrant( + grantedScope: string | undefined, + requestedScope: string | undefined, +): string | undefined { + const granted = grantedScope?.trim(); + if (granted) { + return granted; + } + const requested = requestedScope?.trim(); + return requested || undefined; +} + +/** + * Scope coverage for satisfaction checks: prefer the token's explicit grant; + * when omitted, fall back to stored scope (RFC implied grant on prior success). + */ +export function resolveEffectiveGrantedScope( + storedScope: string | undefined, + tokenScope: string | undefined, +): string | undefined { + const granted = tokenScope?.trim(); + if (granted) { + return granted; + } + return computeScopeUnion(storedScope, tokenScope); +} diff --git a/core/mcp/oauthManager.ts b/core/mcp/oauthManager.ts index 96c95e198..880f8c440 100644 --- a/core/mcp/oauthManager.ts +++ b/core/mcp/oauthManager.ts @@ -43,6 +43,8 @@ import { import { computeScopeUnion, isStrictScopeSuperset, + resolveEffectiveGrantedScope, + resolvePersistedScopeAfterGrant, } from "../auth/scopes.js"; import { stepUpInsufficientScopeMessage } from "../auth/oauthUx.js"; import type { @@ -276,10 +278,15 @@ export class OAuthManager { config, authorizationCode, ); - if (this.pendingAuthorizationScope) { + const requestedScope = this.pendingAuthorizationScope; + const scopeToPersist = resolvePersistedScopeAfterGrant( + tokens.scope, + requestedScope, + ); + if (scopeToPersist) { await this.oauthConfig.storage!.saveScope( this.getServerUrl(), - this.pendingAuthorizationScope, + scopeToPersist, ); } this.pendingAuthorizationScope = undefined; @@ -315,8 +322,11 @@ export class OAuthManager { throw new Error("Failed to retrieve tokens after authorization"); } - const scopeToPersist = - this.pendingAuthorizationScope ?? tokens.scope; + const requestedScope = this.pendingAuthorizationScope; + const scopeToPersist = resolvePersistedScopeAfterGrant( + tokens.scope, + requestedScope, + ); if (scopeToPersist) { await provider.saveScope(scopeToPersist); } @@ -431,8 +441,9 @@ export class OAuthManager { * satisfied without an authorization-server round-trip. * * Returns `true` for `insufficient_scope` when stored + token scope cover the - * SEP-2350 union. For `token_expired` / `unauthorized` / `invalid_token`, - * returns `true` when a usable access token is already in storage. + * SEP-2350 union. For `token_expired`, returns `true` when a usable access + * token is already in storage. `invalid_token` and `unauthorized` always + * return `false` — the resource server explicitly rejected the credential. */ async checkAuthChallengeSatisfied( challenge: AuthChallenge, @@ -450,10 +461,8 @@ export class OAuthManager { if (challenge.reason !== "insufficient_scope") { return ( - challenge.reason === "token_expired" || - challenge.reason === "unauthorized" || - challenge.reason === "invalid_token" - ) && isAccessTokenUsable(tokens); + challenge.reason === "token_expired" && isAccessTokenUsable(tokens) + ); } const enriched = await this.enrichChallengeWithAuthorizationScopes( @@ -467,7 +476,7 @@ export class OAuthManager { return false; } - const effectiveScope = computeScopeUnion( + const effectiveScope = resolveEffectiveGrantedScope( storage.getScope(serverUrl), tokens.scope, ); @@ -603,10 +612,17 @@ export class OAuthManager { const silent = await trySilentEmaAuth(config); if (silent.status === "success") { if (await this.checkAuthChallengeSatisfied(enriched)) { - if (enriched.authorizationScopes?.length) { + const minted = await this.oauthConfig.storage!.getTokens( + this.getServerUrl(), + ); + const scopeToPersist = resolvePersistedScopeAfterGrant( + minted?.scope, + enriched.authorizationScopes?.join(" "), + ); + if (scopeToPersist) { await this.oauthConfig.storage!.saveScope( this.getServerUrl(), - enriched.authorizationScopes.join(" "), + scopeToPersist, ); } return { kind: "satisfied" }; @@ -688,8 +704,13 @@ export class OAuthManager { if (result === "AUTHORIZED") { if (enriched.reason === "insufficient_scope") { if (await this.checkAuthChallengeSatisfied(enriched)) { - if (scopeForAuth) { - await provider.saveScope(scopeForAuth); + const freshTokens = await provider.tokens(); + const scopeToPersist = resolvePersistedScopeAfterGrant( + freshTokens?.scope, + scopeForAuth, + ); + if (scopeToPersist) { + await provider.saveScope(scopeToPersist); } return { kind: "satisfied" }; } @@ -799,8 +820,13 @@ export class OAuthManager { return null; } if (await this.checkAuthChallengeSatisfied(enriched)) { - if (scopeForAuth) { - await provider.saveScope(scopeForAuth); + const freshTokens = await provider.tokens(); + const scopeToPersist = resolvePersistedScopeAfterGrant( + freshTokens?.scope, + scopeForAuth, + ); + if (scopeToPersist) { + await provider.saveScope(scopeToPersist); } return { kind: "satisfied" }; } diff --git a/core/mcp/remote/node/remote-session.ts b/core/mcp/remote/node/remote-session.ts index 760ed6317..83abd5ddb 100644 --- a/core/mcp/remote/node/remote-session.ts +++ b/core/mcp/remote/node/remote-session.ts @@ -173,7 +173,12 @@ export class RemoteSession { } cancelRequestWait(requestId: string | number): void { + const wait = this.requestWaits.get(requestId); + if (!wait) { + return; + } this.requestWaits.delete(requestId); + wait.reject(new Error("MCP request wait cancelled")); } rejectActiveRequestWaits(error: Error): void { diff --git a/core/mcp/remote/remoteClientTransport.ts b/core/mcp/remote/remoteClientTransport.ts index 306d91aa4..b9fde2f85 100644 --- a/core/mcp/remote/remoteClientTransport.ts +++ b/core/mcp/remote/remoteClientTransport.ts @@ -578,7 +578,12 @@ export class RemoteClientTransport implements Transport { } private cancelSseResponseWait(requestId: string | number): void { + const wait = this.sseResponseWaits.get(requestId); + if (!wait) { + return; + } this.sseResponseWaits.delete(requestId); + wait.reject(new Error("SSE response wait cancelled")); } private cancelAllSseWaits(error: Error): void { diff --git a/specification/v2_auth_mid_session.md b/specification/v2_auth_mid_session.md index 4089bfb27..e944f4663 100644 --- a/specification/v2_auth_mid_session.md +++ b/specification/v2_auth_mid_session.md @@ -54,7 +54,9 @@ By contrast, **401** means the token is missing, invalid, or expired — fix by | **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 the previously requested set in `OAuthStorage.scope` (`saveScope()`), computes `authorizationScopes` as the union in `handleAuthChallenge()`, and only persists the union after a **successful** `completeOAuthFlow()`. +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)](v2_auth_ema.md#ema-step-up-web-confirmation). @@ -214,7 +216,7 @@ Read-only check against **current storage** (and token expiry helpers). Used bef | `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. +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 diff --git a/test-servers/src/test-server-oauth.ts b/test-servers/src/test-server-oauth.ts index 231ba20a1..4ea0ac301 100644 --- a/test-servers/src/test-server-oauth.ts +++ b/test-servers/src/test-server-oauth.ts @@ -837,6 +837,13 @@ export interface ScopeRequirementRegistry { tools: Map; resources: Map; prompts: Map; + resourceTemplates: Map; +} + +function resourceUriMatchesTemplate(uri: string, uriTemplate: string): boolean { + const brace = uriTemplate.indexOf("{"); + const prefix = brace >= 0 ? uriTemplate.slice(0, brace) : uriTemplate; + return uri.startsWith(prefix); } /** Build lookup tables from merged ServerConfig capability definitions. */ @@ -847,6 +854,7 @@ export function buildScopeRequirementRegistry( tools: new Map(), resources: new Map(), prompts: new Map(), + resourceTemplates: new Map(), }; for (const tool of config.tools ?? []) { @@ -864,6 +872,11 @@ export function buildScopeRequirementRegistry( registry.prompts.set(prompt.name, prompt.requiredScopes); } } + for (const template of config.resourceTemplates ?? []) { + if (template.requiredScopes?.length) { + registry.resourceTemplates.set(template.uriTemplate, template.requiredScopes); + } + } return registry; } @@ -874,7 +887,8 @@ export function scopeRequirementRegistryHasEntries( return ( registry.tools.size > 0 || registry.resources.size > 0 || - registry.prompts.size > 0 + registry.prompts.size > 0 || + registry.resourceTemplates.size > 0 ); } @@ -937,6 +951,14 @@ export function createScopeCheckMiddleware( requiredScopes = registry.tools.get(target); } else if (method === "resources/read") { requiredScopes = registry.resources.get(target); + if (!requiredScopes?.length) { + for (const [uriTemplate, scopes] of registry.resourceTemplates) { + if (resourceUriMatchesTemplate(target, uriTemplate)) { + requiredScopes = scopes; + break; + } + } + } } else if (method === "prompts/get") { requiredScopes = registry.prompts.get(target); } From f4b6095719c87c0bd21ea0c890f9e3f95b31d65f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 3 Jul 2026 12:54:37 -0400 Subject: [PATCH 08/11] =?UTF-8?q?test:=20close=20=E2=89=A590%=20per-file?= =?UTF-8?q?=20coverage=20gaps=20for=20mid-session=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mid-session auth work passed `validate`/CI but failed the local-only `npm run test:coverage` per-file gate (≥90% lines/statements/functions/ branches) on 12 files. Add targeted unit/integration tests so every changed file clears the gate; annotate provably-dead defensive branches with justified `/* v8 ignore */` comments rather than relaxing the gate. Files brought to ≥90 on all four dimensions: - core/auth: mcpAuth, oauthUx, utils, challenge, node/runner-interactive-oauth - core/mcp: oauthManager, inspectorClient, node/authChallengeFetch, remote/remoteClientTransport, remote/node/{remote-session,server} - clients/web/src/utils: oauthResume, browserTabVisibility `npm run test:coverage` now passes (3575 tests, 0 threshold failures); `npm run validate` green across all four clients. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DZyeWManYTvcXxjcrhxSQc --- .../web/src/test/core/auth/challenge.test.ts | 156 ++++ .../web/src/test/core/auth/mcpAuth.test.ts | 232 +++++- .../web/src/test/core/auth/oauthUx.test.ts | 254 +++++++ .../auth/runner-interactive-oauth.test.ts | 137 +++- clients/web/src/test/core/auth/utils.test.ts | 144 +++- ...pectorClient-authRecovery-branches.test.ts | 297 ++++++++ .../core/mcp/node/authChallengeFetch.test.ts | 34 + .../src/test/core/mcp/oauthManager.test.ts | 716 ++++++++++++++++++ .../mcp/remote/remote-auth-branches.test.ts | 269 +++++++ .../mcp/remote/remote-session.test.ts | 45 ++ .../remote/remoteClientTransport-unit.test.ts | 683 +++++++++++++++++ .../mcp/remote/server-extra-coverage.test.ts | 109 +++ .../src/utils/browserTabVisibility.test.ts | 79 ++ clients/web/src/utils/oauthResume.test.ts | 270 +++++++ core/auth/node/runner-interactive-oauth.ts | 2 + core/mcp/inspectorClient.ts | 9 +- core/mcp/node/authChallengeFetch.ts | 1 + core/mcp/oauthManager.ts | 19 +- core/mcp/remote/node/server.ts | 4 +- core/mcp/remote/remoteClientTransport.ts | 24 +- 20 files changed, 3447 insertions(+), 37 deletions(-) create mode 100644 clients/web/src/test/core/mcp/inspectorClient-authRecovery-branches.test.ts create mode 100644 clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts diff --git a/clients/web/src/test/core/auth/challenge.test.ts b/clients/web/src/test/core/auth/challenge.test.ts index 59690a009..bafacc01a 100644 --- a/clients/web/src/test/core/auth/challenge.test.ts +++ b/clients/web/src/test/core/auth/challenge.test.ts @@ -146,6 +146,39 @@ describe("parseAuthChallengeFromResponse", () => { const response = new Response(null, { status: 500 }); expect(parseAuthChallengeFromResponse(response)).toBeUndefined(); }); + + it("carries error_description into the challenge message", () => { + const response = new Response(null, { + status: 401, + headers: { + "WWW-Authenticate": + 'Bearer error="invalid_token", error_description="Token expired"', + }, + }); + + expect(parseAuthChallengeFromResponse(response)).toEqual({ + reason: "invalid_token", + message: "Token expired", + raw: { + httpStatus: 401, + wwwAuthenticate: + 'Bearer error="invalid_token", error_description="Token expired"', + }, + }); + }); + + it("maps 403 with a non-scope error to unauthorized", () => { + const response = new Response(null, { + status: 403, + headers: { + "WWW-Authenticate": 'Bearer error="invalid_token"', + }, + }); + + expect(parseAuthChallengeFromResponse(response)?.reason).toBe( + "unauthorized", + ); + }); }); describe("parseAuthChallengeFromError", () => { @@ -174,6 +207,82 @@ describe("parseAuthChallengeFromError", () => { it("returns undefined for bare 401 without auth markers", () => { expect(parseAuthChallengeFromError({ status: 401 })).toBeUndefined(); }); + + it("returns the challenge directly for AuthChallengeError instances", () => { + const err = new AuthChallengeError({ reason: "invalid_token" }, 401); + expect(parseAuthChallengeFromError(err)).toEqual({ + reason: "invalid_token", + }); + }); + + it("returns undefined for non-object and null errors", () => { + expect(parseAuthChallengeFromError("boom")).toBeUndefined(); + expect(parseAuthChallengeFromError(null)).toBeUndefined(); + }); + + it("merges context into an embedded authChallenge", () => { + expect( + parseAuthChallengeFromError( + { + authChallenge: { reason: "token_expired", context: { method: "x" } }, + }, + { toolName: "get_temp" }, + ), + ).toEqual({ + reason: "token_expired", + context: { method: "x", toolName: "get_temp" }, + }); + }); + + it("falls back to the numeric code when status is absent", () => { + expect( + parseAuthChallengeFromError({ + code: 403, + wwwAuthenticate: 'Bearer error="insufficient_scope", scope="admin"', + }), + ).toMatchObject({ + reason: "insufficient_scope", + requiredScopes: ["admin"], + raw: { httpStatus: 403 }, + }); + }); + + it("returns undefined when the status is neither 401 nor 403", () => { + expect(parseAuthChallengeFromError({ status: 500 })).toBeUndefined(); + }); + + it("reads WWW-Authenticate from a headers.get accessor", () => { + expect( + parseAuthChallengeFromError({ + status: 401, + headers: { + get: (name: string) => + name === "WWW-Authenticate" ? 'Bearer error="invalid_token"' : null, + }, + }), + ).toMatchObject({ + reason: "invalid_token", + raw: { httpStatus: 401, wwwAuthenticate: 'Bearer error="invalid_token"' }, + }); + }); + + it("reads WWW-Authenticate from an embedded raw challenge", () => { + expect( + parseAuthChallengeFromError({ + status: 401, + authChallenge: { raw: { wwwAuthenticate: "Bearer realm=mcp" } }, + }), + ).toMatchObject({ + reason: "token_expired", + raw: { httpStatus: 401, wwwAuthenticate: "Bearer realm=mcp" }, + }); + }); + + it("returns undefined for an empty WWW-Authenticate header", () => { + expect( + parseAuthChallengeFromError({ status: 401, wwwAuthenticate: "" }), + ).toBeUndefined(); + }); }); describe("isAuthChallengeError", () => { @@ -206,6 +315,53 @@ describe("isAuthChallengeError", () => { it("does not treat connect-time unauthorized wording as auth challenge", () => { expect(isAuthChallengeError(new Error("network failed"))).toBe(false); }); + + it("returns false for non-object and null errors", () => { + expect(isAuthChallengeError("boom")).toBe(false); + expect(isAuthChallengeError(null)).toBe(false); + }); + + it("detects an embedded authChallenge with a reason", () => { + expect( + isAuthChallengeError({ authChallenge: { reason: "token_expired" } }), + ).toBe(true); + }); + + it("uses the numeric code when status is absent", () => { + expect( + isAuthChallengeError({ + code: 403, + wwwAuthenticate: 'Bearer error="insufficient_scope"', + }), + ).toBe(true); + }); + + it("reads WWW-Authenticate from a headers.get accessor", () => { + expect( + isAuthChallengeError({ + status: 401, + headers: { + get: (name: string) => + name === "WWW-Authenticate" ? "Bearer realm=mcp" : null, + }, + }), + ).toBe(true); + }); + + it("reads WWW-Authenticate from an embedded raw challenge", () => { + expect( + isAuthChallengeError({ + status: 401, + authChallenge: { raw: { wwwAuthenticate: "Bearer realm=mcp" } }, + }), + ).toBe(true); + }); + + it("returns false for an empty WWW-Authenticate header", () => { + expect(isAuthChallengeError({ status: 401, wwwAuthenticate: "" })).toBe( + false, + ); + }); }); describe("isConnectAuthRecoveryError", () => { diff --git a/clients/web/src/test/core/auth/mcpAuth.test.ts b/clients/web/src/test/core/auth/mcpAuth.test.ts index f973574b0..79af5d79f 100644 --- a/clients/web/src/test/core/auth/mcpAuth.test.ts +++ b/clients/web/src/test/core/auth/mcpAuth.test.ts @@ -6,11 +6,15 @@ const { discoverOAuthServerInfo, startAuthorization, selectResourceURL, + registerClient, + isHttpsUrl, } = vi.hoisted(() => ({ sdkAuth: vi.fn(), discoverOAuthServerInfo: vi.fn(), startAuthorization: vi.fn(), selectResourceURL: vi.fn(), + registerClient: vi.fn(), + isHttpsUrl: vi.fn(), })); vi.mock("@modelcontextprotocol/sdk/client/auth.js", async (importOriginal) => { @@ -24,12 +28,17 @@ vi.mock("@modelcontextprotocol/sdk/client/auth.js", async (importOriginal) => { discoverOAuthServerInfo, selectResourceURL, startAuthorization, + registerClient, + isHttpsUrl, }; }); import { mcpAuth } from "@inspector/core/auth/mcpAuth.js"; +import { InvalidClientMetadataError } from "@modelcontextprotocol/sdk/server/auth/errors.js"; -function makeProvider(): OAuthClientProvider { +function makeProvider( + overrides: Partial = {}, +): OAuthClientProvider { return { redirectUrl: "http://localhost/callback", clientMetadata: { @@ -47,12 +56,38 @@ function makeProvider(): OAuthClientProvider { redirectToAuthorization: vi.fn(), saveCodeVerifier: vi.fn(), codeVerifier: vi.fn().mockReturnValue("verifier"), + ...overrides, }; } +/** + * Arrange the discovery + authorization mocks used by the + * `forceReauthorization` (authorize-without-refresh) path. + */ +function stubForcePath( + discovery: Partial<{ + authorizationServerUrl: string; + authorizationServerMetadata: Record; + resourceMetadata: unknown; + }> = {}, +): void { + discoverOAuthServerInfo.mockResolvedValue({ + authorizationServerUrl: "https://as.example.com", + authorizationServerMetadata: { issuer: "https://as.example.com" }, + resourceMetadata: undefined, + ...discovery, + }); + selectResourceURL.mockResolvedValue(undefined); + startAuthorization.mockResolvedValue({ + authorizationUrl: new URL("https://as.example.com/authorize"), + codeVerifier: "cv", + }); +} + describe("mcpAuth", () => { beforeEach(() => { vi.clearAllMocks(); + isHttpsUrl.mockReturnValue(true); }); it("delegates to SDK auth() when forceReauthorization is not set", async () => { @@ -76,17 +111,24 @@ describe("mcpAuth", () => { expect(discoverOAuthServerInfo).not.toHaveBeenCalled(); }); + it("rejects forceReauthorization combined with authorizationCode", async () => { + const provider = makeProvider(); + + await expect( + mcpAuth(provider, { + serverUrl: "https://mcp.example.com", + authorizationCode: "code", + forceReauthorization: true, + }), + ).rejects.toThrow( + "forceReauthorization cannot be combined with authorizationCode", + ); + expect(sdkAuth).not.toHaveBeenCalled(); + expect(discoverOAuthServerInfo).not.toHaveBeenCalled(); + }); + it("uses discovery + startAuthorization when forceReauthorization is true", async () => { - discoverOAuthServerInfo.mockResolvedValue({ - authorizationServerUrl: "https://as.example.com", - authorizationServerMetadata: { issuer: "https://as.example.com" }, - resourceMetadata: undefined, - }); - selectResourceURL.mockResolvedValue(undefined); - startAuthorization.mockResolvedValue({ - authorizationUrl: new URL("https://as.example.com/authorize"), - codeVerifier: "cv", - }); + stubForcePath(); const provider = makeProvider(); const result = await mcpAuth(provider, { @@ -102,4 +144,172 @@ describe("mcpAuth", () => { expect(provider.saveCodeVerifier).toHaveBeenCalledWith("cv"); expect(provider.redirectToAuthorization).toHaveBeenCalled(); }); + + it("persists discovery state and forwards the selected resource + state", async () => { + stubForcePath({ + resourceMetadata: { resource: "https://mcp.example.com" }, + }); + selectResourceURL.mockResolvedValue(new URL("https://mcp.example.com/r")); + const saveDiscoveryState = vi.fn().mockResolvedValue(undefined); + const state = vi.fn().mockResolvedValue("state-123"); + const provider = makeProvider({ saveDiscoveryState, state }); + + await mcpAuth(provider, { + serverUrl: "https://mcp.example.com", + resourceMetadataUrl: new URL("https://mcp.example.com/.well-known/x"), + forceReauthorization: true, + }); + + expect(saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: "https://as.example.com", + resourceMetadataUrl: "https://mcp.example.com/.well-known/x", + }), + ); + expect(state).toHaveBeenCalled(); + expect(startAuthorization).toHaveBeenCalledWith( + "https://as.example.com", + expect.objectContaining({ + state: "state-123", + resource: new URL("https://mcp.example.com/r"), + }), + ); + }); + + it("derives scope from resourceMetadata.scopes_supported when no scope given", async () => { + stubForcePath({ + resourceMetadata: { scopes_supported: ["mcp", "weather:read"] }, + }); + const provider = makeProvider(); + + await mcpAuth(provider, { + serverUrl: "https://mcp.example.com", + forceReauthorization: true, + }); + + expect(startAuthorization).toHaveBeenCalledWith( + "https://as.example.com", + expect.objectContaining({ scope: "mcp weather:read" }), + ); + }); + + it("falls back to clientMetadata.scope when nothing else provides scope", async () => { + stubForcePath(); + const provider = makeProvider({ + clientMetadata: { + ...makeProvider().clientMetadata, + scope: "fallback:scope", + }, + }); + + await mcpAuth(provider, { + serverUrl: "https://mcp.example.com", + forceReauthorization: true, + }); + + expect(startAuthorization).toHaveBeenCalledWith( + "https://as.example.com", + expect.objectContaining({ scope: "fallback:scope" }), + ); + }); + + it("dynamically registers a client when no client information exists", async () => { + stubForcePath(); + registerClient.mockResolvedValue({ client_id: "registered" }); + const saveClientInformation = vi.fn().mockResolvedValue(undefined); + const provider = makeProvider({ + clientInformation: vi.fn().mockResolvedValue(undefined), + saveClientInformation, + }); + + const result = await mcpAuth(provider, { + serverUrl: "https://mcp.example.com", + scope: "mcp", + forceReauthorization: true, + }); + + expect(result).toBe("REDIRECT"); + expect(registerClient).toHaveBeenCalledWith( + "https://as.example.com", + expect.objectContaining({ scope: "mcp" }), + ); + expect(saveClientInformation).toHaveBeenCalledWith({ + client_id: "registered", + }); + }); + + it("uses a URL-based client id when the AS supports it", async () => { + stubForcePath({ + authorizationServerMetadata: { + issuer: "https://as.example.com", + client_id_metadata_document_supported: true, + }, + }); + isHttpsUrl.mockReturnValue(true); + const saveClientInformation = vi.fn().mockResolvedValue(undefined); + const provider = makeProvider({ + clientInformation: vi.fn().mockResolvedValue(undefined), + clientMetadataUrl: "https://app.example.com/client.json", + saveClientInformation, + }); + + await mcpAuth(provider, { + serverUrl: "https://mcp.example.com", + forceReauthorization: true, + }); + + expect(registerClient).not.toHaveBeenCalled(); + expect(saveClientInformation).toHaveBeenCalledWith({ + client_id: "https://app.example.com/client.json", + }); + }); + + it("rejects a non-HTTPS clientMetadataUrl", async () => { + stubForcePath(); + isHttpsUrl.mockReturnValue(false); + const provider = makeProvider({ + clientInformation: vi.fn().mockResolvedValue(undefined), + clientMetadataUrl: "http://insecure.example.com/client.json", + saveClientInformation: vi.fn(), + }); + + await expect( + mcpAuth(provider, { + serverUrl: "https://mcp.example.com", + forceReauthorization: true, + }), + ).rejects.toBeInstanceOf(InvalidClientMetadataError); + expect(registerClient).not.toHaveBeenCalled(); + }); + + it("requires saveClientInformation for dynamic registration", async () => { + stubForcePath(); + const provider = makeProvider({ + clientInformation: vi.fn().mockResolvedValue(undefined), + saveClientInformation: undefined, + }); + + await expect( + mcpAuth(provider, { + serverUrl: "https://mcp.example.com", + forceReauthorization: true, + }), + ).rejects.toThrow( + "OAuth client information must be saveable for dynamic registration", + ); + expect(registerClient).not.toHaveBeenCalled(); + }); + + it("requires a redirectUrl for the authorization_code flow", async () => { + stubForcePath(); + const provider = makeProvider({ redirectUrl: undefined }); + + await expect( + mcpAuth(provider, { + serverUrl: "https://mcp.example.com", + forceReauthorization: true, + }), + ).rejects.toThrow("redirectUrl is required for authorization_code flow"); + expect(startAuthorization).not.toHaveBeenCalled(); + }); }); diff --git a/clients/web/src/test/core/auth/oauthUx.test.ts b/clients/web/src/test/core/auth/oauthUx.test.ts index 5cbe86d15..f209c9ab2 100644 --- a/clients/web/src/test/core/auth/oauthUx.test.ts +++ b/clients/web/src/test/core/auth/oauthUx.test.ts @@ -1,11 +1,22 @@ import { describe, it, expect } from "vitest"; import { + authRecoveryRestoredMessage, + emaStepUpFailureMessage, + emaStepUpInProgressMessage, emaStepUpSuccessMessage, + isActionTriggeredOAuthRecovery, isEmaStepUp, + isReAuthBannerReason, + isStandardOAuthStepUp, isStepUpConfirmation, + oauthPreRedirectToastCopy, + oauthResumeAbandonedMessage, + oauthResumeSuccessMessage, + reAuthBannerMessage, stepUpAdditionalScopes, stepUpConfirmMessage, stepUpFollowUpMessage, + stepUpInsufficientScopeMessage, stepUpModalTitle, stepUpAuthorizeActionLabel, } from "@inspector/core/auth/oauthUx.js"; @@ -34,6 +45,48 @@ describe("oauthUx step-up copy", () => { ).toBe("This operation needs additional scope: weather:read."); }); + it("stepUpConfirmMessage uses plural label and organization language for multiple EMA scopes", () => { + expect( + stepUpConfirmMessage( + { + reason: "insufficient_scope", + requiredScopes: ["weather:read", "weather:write"], + }, + { enterpriseManaged: true }, + ), + ).toBe( + "This operation needs additional organization scopes: weather:read, weather:write.", + ); + }); + + it("stepUpConfirmMessage uses plural label for multiple standard scopes", () => { + expect( + stepUpConfirmMessage({ + reason: "insufficient_scope", + requiredScopes: ["weather:read", "weather:write"], + }), + ).toBe( + "This operation needs additional scopes: weather:read, weather:write.", + ); + }); + + it("stepUpConfirmMessage falls back to generic standard copy with no tool or scopes", () => { + expect(stepUpConfirmMessage({ reason: "insufficient_scope" })).toBe( + "This operation needs additional OAuth scopes before it can continue.", + ); + }); + + it("stepUpConfirmMessage falls back to generic EMA copy with no tool or scopes", () => { + expect( + stepUpConfirmMessage( + { reason: "insufficient_scope", requiredScopes: ["", ""] }, + { enterpriseManaged: true }, + ), + ).toBe( + "This operation needs additional permissions from your organization before it can continue.", + ); + }); + it("stepUpConfirmMessage uses organization language for EMA", () => { expect( stepUpConfirmMessage(challenge, { enterpriseManaged: true }), @@ -41,15 +94,25 @@ describe("oauthUx step-up copy", () => { expect(stepUpFollowUpMessage({ enterpriseManaged: true })).toMatch( /identity provider/i, ); + expect(stepUpFollowUpMessage()).toMatch(/redirected to authorize/i); expect(stepUpModalTitle({ enterpriseManaged: true })).toMatch( /organization/i, ); + expect(stepUpModalTitle()).toBe("Additional permissions required"); expect(stepUpAuthorizeActionLabel({ enterpriseManaged: true })).toBe( "Authorize", ); expect(stepUpAuthorizeActionLabel()).toBe("Authorize (opens browser)"); }); + it("isStandardOAuthStepUp is true only for non-EMA insufficient_scope", () => { + expect(isStandardOAuthStepUp(challenge)).toBe(true); + expect(isStandardOAuthStepUp(challenge, { enterpriseManaged: true })).toBe( + false, + ); + expect(isStandardOAuthStepUp({ reason: "token_expired" })).toBe(false); + }); + it("isStepUpConfirmation covers standard OAuth and EMA insufficient_scope", () => { expect(isStepUpConfirmation(challenge)).toBe(true); expect(isStepUpConfirmation(challenge, { enterpriseManaged: true })).toBe( @@ -67,6 +130,10 @@ describe("oauthUx step-up copy", () => { ).toBe(false); }); + it("emaStepUpInProgressMessage describes requesting organization permissions", () => { + expect(emaStepUpInProgressMessage()).toMatch(/organization/i); + }); + it("emaStepUpSuccessMessage suggests retry only for command-scoped recovery", () => { expect(emaStepUpSuccessMessage()).toBe( "Organization permissions were updated.", @@ -76,7 +143,194 @@ describe("oauthUx step-up copy", () => { ); }); + it("emaStepUpFailureMessage returns detail when present, else generic copy", () => { + expect(emaStepUpFailureMessage("boom")).toBe("boom"); + expect(emaStepUpFailureMessage(" ")).toBe( + "Could not obtain the additional permissions from your organization.", + ); + expect(emaStepUpFailureMessage()).toBe( + "Could not obtain the additional permissions from your organization.", + ); + }); + it("stepUpAdditionalScopes returns requiredScopes only", () => { expect(stepUpAdditionalScopes(challenge)).toEqual(["weather:read"]); }); + + it("stepUpAdditionalScopes returns empty array when requiredScopes undefined", () => { + expect(stepUpAdditionalScopes({ reason: "insufficient_scope" })).toEqual( + [], + ); + }); +}); + +describe("oauthUx recovery-source predicates", () => { + it("isActionTriggeredOAuthRecovery is true for action sources, false otherwise", () => { + for (const source of ["tool", "prompt", "resource", "app"] as const) { + expect(isActionTriggeredOAuthRecovery(source)).toBe(true); + } + expect(isActionTriggeredOAuthRecovery("ambient")).toBe(false); + expect(isActionTriggeredOAuthRecovery(undefined)).toBe(false); + }); +}); + +describe("oauthUx resume/restore copy", () => { + it("oauthResumeSuccessMessage step_up varies with retry", () => { + expect( + oauthResumeSuccessMessage("step_up", { recoverySource: "tool" }), + ).toBe("Step-up authorization succeeded. Retry your action."); + expect(oauthResumeSuccessMessage("step_up")).toBe( + "Step-up authorization succeeded.", + ); + }); + + it("oauthResumeSuccessMessage reauth varies with retry", () => { + expect( + oauthResumeSuccessMessage("reauth", { recoverySource: "prompt" }), + ).toBe("Authentication succeeded. Retry your action."); + expect(oauthResumeSuccessMessage("reauth")).toBe( + "Authentication succeeded.", + ); + }); + + it("authRecoveryRestoredMessage varies with retry", () => { + expect(authRecoveryRestoredMessage({ recoverySource: "resource" })).toBe( + "Session credentials were updated. Retry your action.", + ); + expect(authRecoveryRestoredMessage()).toBe( + "Session credentials were updated.", + ); + }); + + it("oauthResumeAbandonedMessage reauth is retry-agnostic", () => { + expect( + oauthResumeAbandonedMessage("reauth", { recoverySource: "tool" }), + ).toBe("Sign-in was not completed. Re-authenticate to restore access."); + expect(oauthResumeAbandonedMessage("reauth")).toBe( + "Sign-in was not completed. Re-authenticate to restore access.", + ); + }); + + it("oauthResumeAbandonedMessage step_up varies with retry", () => { + expect( + oauthResumeAbandonedMessage("step_up", { recoverySource: "app" }), + ).toBe("Step-up authorization was not completed. Retry your action."); + expect(oauthResumeAbandonedMessage("step_up")).toBe( + "Step-up authorization was not completed.", + ); + }); +}); + +describe("oauthUx insufficient-scope resolution copy", () => { + it("prefers tool context", () => { + expect( + stepUpInsufficientScopeMessage({ + reason: "insufficient_scope", + context: { toolName: "get_temp" }, + }), + ).toMatch(/tool "get_temp"/); + }); + + it("uses authorizationScopes when present and no tool", () => { + expect( + stepUpInsufficientScopeMessage({ + reason: "insufficient_scope", + authorizationScopes: ["a", "b"], + requiredScopes: ["c"], + }), + ).toBe( + "Authorization completed, but required scopes were not granted (a, b). Grant the requested permissions on the authorization server, then retry your action.", + ); + }); + + it("falls back to requiredScopes when authorizationScopes absent", () => { + expect( + stepUpInsufficientScopeMessage({ + reason: "insufficient_scope", + requiredScopes: ["c"], + }), + ).toMatch(/\(c\)/); + }); + + it("uses generic copy when no tool or scopes", () => { + expect( + stepUpInsufficientScopeMessage({ reason: "insufficient_scope" }), + ).toBe( + "Authorization completed, but the required permissions were not granted. Grant the requested scopes on the authorization server, then retry your action.", + ); + }); +}); + +describe("oauthUx pre-redirect toast copy", () => { + it("returns undefined for a fresh connect handshake", () => { + expect( + oauthPreRedirectToastCopy("reauth", { context: "connect" }), + ).toBeUndefined(); + }); + + it("step_up toast includes server name when provided", () => { + expect(oauthPreRedirectToastCopy("step_up", { serverName: "svc" })).toEqual( + { + title: 'Step-up authorization for "svc"', + message: "Redirecting to authorize additional permissions…", + }, + ); + expect(oauthPreRedirectToastCopy("step_up", {})).toEqual({ + title: "Step-up authorization", + message: "Redirecting to authorize additional permissions…", + }); + }); + + it("enterprise-managed reauth toast re-authenticates", () => { + expect( + oauthPreRedirectToastCopy("reauth", { + serverName: "svc", + enterpriseManaged: true, + }), + ).toEqual({ + title: 'Re-authenticating "svc"', + message: "Re-authenticating…", + }); + expect( + oauthPreRedirectToastCopy("reauth", { enterpriseManaged: true }), + ).toEqual({ title: "Re-authenticating", message: "Re-authenticating…" }); + }); + + it("default reauth toast signals an expired session", () => { + expect(oauthPreRedirectToastCopy("reauth", { serverName: "svc" })).toEqual({ + title: 'Session expired for "svc"', + message: "Session expired, re-authenticating…", + }); + expect(oauthPreRedirectToastCopy("reauth", {})).toEqual({ + title: "Session expired", + message: "Session expired, re-authenticating…", + }); + }); +}); + +describe("oauthUx re-auth banner", () => { + it("isReAuthBannerReason is true for degraded-session reasons", () => { + for (const reason of [ + "token_expired", + "unauthorized", + "invalid_token", + ] as const) { + expect(isReAuthBannerReason(reason)).toBe(true); + } + expect(isReAuthBannerReason("insufficient_scope")).toBe(false); + expect(isReAuthBannerReason(undefined)).toBe(false); + }); + + it("reAuthBannerMessage varies with server name and detail", () => { + expect( + reAuthBannerMessage({ serverName: "svc", detail: "Token expired." }), + ).toBe('Authentication for "svc" needs attention. Token expired.'); + expect(reAuthBannerMessage({ serverName: "svc" })).toBe( + 'Authentication for "svc" needs attention.', + ); + expect(reAuthBannerMessage({ detail: "Token expired." })).toBe( + "Authentication needs attention. Token expired.", + ); + expect(reAuthBannerMessage({})).toBe("Authentication needs attention."); + }); }); diff --git a/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts b/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts index eac480ee8..1dbf740fe 100644 --- a/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts +++ b/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts @@ -3,12 +3,24 @@ import { runRunnerInteractiveOAuth, type RunnerInteractiveOAuthClient, } from "@inspector/core/auth/node/runner-interactive-oauth.js"; -import type { - OAuthCallbackServer, - OAuthCallbackServerStartOptions, +import { + createOAuthCallbackServer, + type OAuthCallbackServer, + type OAuthCallbackServerStartOptions, } from "@inspector/core/auth/node/oauth-callback-server.js"; import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; +vi.mock( + "@inspector/core/auth/node/oauth-callback-server.js", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@inspector/core/auth/node/oauth-callback-server.js") + >(); + return { ...actual, createOAuthCallbackServer: vi.fn() }; + }, +); + function mockClient( overrides: Partial = {}, ): RunnerInteractiveOAuthClient { @@ -308,4 +320,123 @@ describe("runRunnerInteractiveOAuth", () => { expect(onCallbackServer).toHaveBeenCalledTimes(1); expect(onCallbackServer).toHaveBeenCalledWith(mockServer); }); + + it("wraps a non-Error completeOAuthFlow rejection", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const client = mockClient({ + authenticate: vi.fn(async () => { + await simulateCallback(handlers.current).catch(() => {}); + return new URL("https://as.example/authorize"); + }), + completeOAuthFlow: vi.fn(async () => { + throw "string failure"; + }), + }); + + await expect( + runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => createMockCallbackServer(handlers), + }), + ).rejects.toThrow("string failure"); + }); + + it("falls back to params.error when no error_description is present", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const client = mockClient({ + authenticate: vi.fn(async () => { + handlers.current.onError?.({ error: "access_denied" }); + return new URL("https://as.example/authorize"); + }), + }); + + await expect( + runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => createMockCallbackServer(handlers), + }), + ).rejects.toThrow("access_denied"); + }); + + it("defaults to createOAuthCallbackServer when none is provided", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + vi.mocked(createOAuthCallbackServer).mockReturnValue( + createMockCallbackServer(handlers), + ); + const client = mockClient({ + authenticate: vi.fn(async () => undefined), + }); + + const result = await runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + }); + + expect(result).toEqual({ kind: "already_authorized" }); + expect(createOAuthCallbackServer).toHaveBeenCalled(); + }); + + it("swallows a server.stop() rejection during cleanup", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const mockServer = createMockCallbackServer(handlers); + vi.mocked(mockServer.stop).mockRejectedValue(new Error("stop failed")); + const client = mockClient({ + authenticate: vi.fn(async () => { + await simulateCallback(handlers.current); + return new URL("https://as.example/authorize"); + }), + }); + + const result = await runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => mockServer, + }); + + expect(result).toEqual({ kind: "success" }); + expect(mockServer.stop).toHaveBeenCalled(); + }); + + it("cleans up when the callback server fails to start", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const mockServer = createMockCallbackServer(handlers); + vi.mocked(mockServer.start).mockRejectedValue(new Error("bind failed")); + const client = mockClient(); + + await expect( + runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => mockServer, + }), + ).rejects.toThrow("bind failed"); + expect(mockServer.stop).toHaveBeenCalled(); + }); }); diff --git a/clients/web/src/test/core/auth/utils.test.ts b/clients/web/src/test/core/auth/utils.test.ts index cbfa7186f..d743fa0c3 100644 --- a/clients/web/src/test/core/auth/utils.test.ts +++ b/clients/web/src/test/core/auth/utils.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, afterEach, vi } from "vitest"; import { parseHttpUrl, parseOAuthCallbackParams, @@ -6,8 +6,9 @@ import { parseOAuthState, generateOAuthErrorDescription, formatOAuthFailureDetail, + isUnauthorizedError, } from "@inspector/core/auth/utils.js"; -import { ZodError } from "zod"; +import { z, ZodError } from "zod"; describe("parseHttpUrl", () => { it("parses valid URLs", () => { @@ -19,6 +20,12 @@ describe("parseHttpUrl", () => { it("throws on invalid URLs", () => { expect(() => parseHttpUrl("not-a-url", "test")).toThrow(/Invalid test/); }); + + it("includes the trimmed offending value in the thrown message", () => { + expect(() => parseHttpUrl(" bad url ", "Server URL")).toThrow( + /Invalid Server URL: "bad url"/, + ); + }); }); describe("parseOAuthCallbackParams", () => { @@ -122,4 +129,137 @@ describe("formatOAuthFailureDetail", () => { "Network timeout", ); }); + + it("passes through plain strings that are not bracketed JSON", () => { + expect(formatOAuthFailureDetail("something went wrong")).toBe( + "something went wrong", + ); + }); + + it("stringifies non-string/non-error values", () => { + expect(formatOAuthFailureDetail(42)).toBe("42"); + expect(formatOAuthFailureDetail(null)).toBe("null"); + }); + + it("formats a real ZodError instance (non-token path)", () => { + const err = z.string().safeParse(123).error!; + const result = formatOAuthFailureDetail(err); + expect(result).not.toMatch(/valid tokens/i); + // Root-level failure has an empty path → "input" label + expect(result).toMatch(/^input: /); + }); + + it("joins non-token issues as `path: message`", () => { + const err = new ZodError([ + { + code: "invalid_type", + expected: "string", + path: ["client", "id"], + message: "Required", + }, + ]); + expect(formatOAuthFailureDetail(err)).toBe("client.id: Required"); + }); + + it("labels an issue with an empty path as `input` and defaults a missing message to `invalid`", () => { + const err = new ZodError([ + { + code: "custom", + path: [], + } as unknown as z.core.$ZodIssue, + ]); + expect(formatOAuthFailureDetail(err)).toBe("input: invalid"); + }); + + it("formats a bracketed JSON string of non-token zod issues", () => { + const json = `[ { "code": "invalid_type", "path": [ "scope" ], "message": "Required" } ]`; + expect(formatOAuthFailureDetail(json)).toBe("scope: Required"); + }); + + it("returns the raw string when bracketed JSON is invalid", () => { + const bad = "[ not valid json"; + expect(formatOAuthFailureDetail(bad)).toBe(bad); + }); + + it("returns the raw string when bracketed JSON is not a zod-issue array", () => { + // Parses fine, but empty array / first element lacks `code` + expect(formatOAuthFailureDetail("[]")).toBe("[]"); + const notIssues = `[ { "foo": "bar" } ]`; + expect(formatOAuthFailureDetail(notIssues)).toBe(notIssues); + }); + + it("returns the raw string when bracketed JSON is a non-array value", () => { + const obj = `[1, 2, 3]`; + // Array of numbers: first element is not an object → not a zod-issue array + expect(formatOAuthFailureDetail(obj)).toBe(obj); + }); +}); + +describe("generateOAuthState fallback", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("uses the Math.random fallback when crypto is unavailable", () => { + vi.stubGlobal("crypto", undefined); + const state = generateOAuthState(); + expect(state).toMatch(/^[a-f0-9]{64}$/i); + }); +}); + +describe("isUnauthorizedError", () => { + it("returns true for an object with status 401", () => { + expect(isUnauthorizedError({ status: 401 })).toBe(true); + }); + + it("returns true for an object with code 401", () => { + expect(isUnauthorizedError({ code: 401 })).toBe(true); + }); + + it("returns true when an Error message matches the transport wording", () => { + expect( + isUnauthorizedError(new Error("Connection failed for server (401)")), + ).toBe(true); + }); + + it("returns true when a non-error value stringifies to matching wording", () => { + expect(isUnauthorizedError("request failed with status (401)")).toBe(true); + }); + + it("returns false for unrelated (401) mentions without `failed`", () => { + expect(isUnauthorizedError(new Error("error code (401) noted"))).toBe( + false, + ); + }); + + it("returns false for a non-401 object", () => { + expect(isUnauthorizedError({ status: 500 })).toBe(false); + }); + + it("returns false for null", () => { + expect(isUnauthorizedError(null)).toBe(false); + }); +}); + +describe("generateOAuthErrorDescription without description/uri", () => { + it("omits the details and more-info lines when absent", () => { + const message = generateOAuthErrorDescription({ + successful: false, + error: "server_error", + error_description: null, + error_uri: null, + }); + expect(message).toBe("Error: server_error."); + }); +}); + +describe("parseOAuthCallbackParams error without description", () => { + it("returns null description/uri when only error is present", () => { + expect(parseOAuthCallbackParams("?error=access_denied")).toEqual({ + successful: false, + error: "access_denied", + error_description: null, + error_uri: null, + }); + }); }); diff --git a/clients/web/src/test/core/mcp/inspectorClient-authRecovery-branches.test.ts b/clients/web/src/test/core/mcp/inspectorClient-authRecovery-branches.test.ts new file mode 100644 index 000000000..2052a35cc --- /dev/null +++ b/clients/web/src/test/core/mcp/inspectorClient-authRecovery-branches.test.ts @@ -0,0 +1,297 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { AuthChallengeError } from "@inspector/core/auth/challenge.js"; +import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { RemoteClientTransport } from "@inspector/core/mcp/remote/remoteClientTransport.js"; + +/** + * Additional branch coverage for the mid-session auth recovery paths + * (`handleAmbientAuthChallenge` / `runAmbientAuthChallenge`, `withDirectAuthRecovery`, + * `pushRemoteAuthState`, `resumeAfterOAuth`). Complements + * inspectorClient-ambient-auth.test.ts and inspectorClient-direct-auth-recovery.test.ts, + * which each cover a single "happy path" through these methods. + * + * Uses the same `Object.create(InspectorClient.prototype)` technique as the + * sibling test files so each test can wire up only the private fields its + * branch needs, without a live transport or a real OAuthManager. + */ + +type FakeOAuthManager = { + handleAuthChallenge: ReturnType; + completeOAuthFlow?: ReturnType; +}; + +type Internals = { + oauthManager: FakeOAuthManager | null; + baseTransport: unknown; + ambientAuthChallengeInFlight: Map>; + directAuthRecovery: boolean; + directAuthRecoveryActive: boolean | null; + activeToolCallAbortController?: AbortController; + status: string; + reconnectAfterAuthRecovery: () => Promise; + withDirectAuthRecovery: ( + operation: () => Promise, + context?: { method?: string; toolName?: string }, + attempt?: number, + ) => Promise; +}; + +function makeClient(): InspectorClient { + const client = Object.create(InspectorClient.prototype) as InspectorClient; + (client as unknown as Internals).ambientAuthChallengeInFlight = new Map(); + return client; +} + +function internalsOf(client: InspectorClient): Internals { + return client as unknown as Internals; +} + +describe("InspectorClient pushRemoteAuthState", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("is a no-op when the base transport is not a RemoteClientTransport", async () => { + const client = makeClient(); + internalsOf(client).baseTransport = null; + + await expect(client.pushRemoteAuthState()).resolves.toBeUndefined(); + }); +}); + +describe("InspectorClient runAmbientAuthChallenge branches", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + const challenge: AuthChallenge = { reason: "token_expired" }; + + it("returns without dispatching a recovery outcome when no oauthManager is configured", async () => { + const client = makeClient(); + internalsOf(client).oauthManager = null; + const dispatch = vi + .spyOn(client, "dispatchTypedEvent") + .mockImplementation(() => {}); + + await client.handleAmbientAuthChallenge(challenge); + + // Only the initial "ambient" announcement fires; the early return means no + // outcome-specific event (recovered/interactive/oauthError) is dispatched. + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith("authChallengeAmbient", { + challenge, + }); + }); + + it("reconnects (rather than pushing remote auth state) for a non-remote transport on a satisfied outcome", async () => { + const client = makeClient(); + const handleAuthChallenge = vi + .fn() + .mockResolvedValue({ kind: "satisfied" }); + internalsOf(client).oauthManager = { handleAuthChallenge }; + internalsOf(client).baseTransport = {}; // not a RemoteClientTransport instance + const reconnect = vi.fn().mockResolvedValue(undefined); + internalsOf(client).reconnectAfterAuthRecovery = reconnect; + const pushRemoteAuthState = vi + .spyOn(client, "pushRemoteAuthState") + .mockResolvedValue(undefined); + const dispatch = vi + .spyOn(client, "dispatchTypedEvent") + .mockImplementation(() => {}); + + await client.handleAmbientAuthChallenge(challenge); + + expect(reconnect).toHaveBeenCalledTimes(1); + expect(pushRemoteAuthState).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledWith("authChallengeRecovered", { + challenge, + }); + }); + + it("dispatches an interactive event with the EMA pending URL for a step_up_confirm outcome", async () => { + const client = makeClient(); + const outcomeChallenge: AuthChallenge = { + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }; + const handleAuthChallenge = vi.fn().mockResolvedValue({ + kind: "step_up_confirm", + challenge: outcomeChallenge, + }); + internalsOf(client).oauthManager = { handleAuthChallenge }; + const dispatch = vi + .spyOn(client, "dispatchTypedEvent") + .mockImplementation(() => {}); + + await client.handleAmbientAuthChallenge(challenge); + + expect(dispatch).toHaveBeenCalledWith( + "authChallengeInteractive", + expect.objectContaining({ challenge: outcomeChallenge }), + ); + }); + + it("dispatches oauthError for a failed outcome", async () => { + const client = makeClient(); + const error = new Error("recovery failed"); + const handleAuthChallenge = vi + .fn() + .mockResolvedValue({ kind: "failed", error }); + internalsOf(client).oauthManager = { handleAuthChallenge }; + const dispatch = vi + .spyOn(client, "dispatchTypedEvent") + .mockImplementation(() => {}); + + await client.handleAmbientAuthChallenge(challenge); + + expect(dispatch).toHaveBeenCalledWith("oauthError", { error }); + }); + + it("wraps a non-Error thrown from handleAuthChallenge before dispatching oauthError", async () => { + const client = makeClient(); + const handleAuthChallenge = vi.fn().mockRejectedValue("boom-string"); + internalsOf(client).oauthManager = { handleAuthChallenge }; + const dispatch = vi + .spyOn(client, "dispatchTypedEvent") + .mockImplementation(() => {}); + + await client.handleAmbientAuthChallenge(challenge); + + const call = dispatch.mock.calls.find(([name]) => name === "oauthError"); + expect(call).toBeDefined(); + const detail = call?.[1] as { error: Error }; + expect(detail.error).toBeInstanceOf(Error); + expect(detail.error.message).toBe("boom-string"); + }); + + it("passes through a real Error thrown from handleAuthChallenge unwrapped", async () => { + const client = makeClient(); + const thrown = new Error("direct failure"); + const handleAuthChallenge = vi.fn().mockRejectedValue(thrown); + internalsOf(client).oauthManager = { handleAuthChallenge }; + const dispatch = vi + .spyOn(client, "dispatchTypedEvent") + .mockImplementation(() => {}); + + await client.handleAmbientAuthChallenge(challenge); + + const call = dispatch.mock.calls.find(([name]) => name === "oauthError"); + expect(call?.[1]).toEqual({ error: thrown }); + }); +}); + +describe("InspectorClient withDirectAuthRecovery branches", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("rethrows immediately without consulting handleAuthChallenge for a non-auth-challenge error", async () => { + const client = makeClient(); + internalsOf(client).directAuthRecovery = true; + internalsOf(client).directAuthRecoveryActive = true; + const operation = vi.fn().mockRejectedValue(new Error("network down")); + const handleAuthChallenge = vi.spyOn(client, "handleAuthChallenge"); + + await expect( + internalsOf(client).withDirectAuthRecovery.call(client, operation), + ).rejects.toThrow("network down"); + + expect(operation).toHaveBeenCalledTimes(1); + expect(handleAuthChallenge).not.toHaveBeenCalled(); + }); + + it("clears an active tool-call abort controller and retries after a satisfied recovery", async () => { + const client = makeClient(); + internalsOf(client).directAuthRecovery = true; + internalsOf(client).directAuthRecoveryActive = true; + const abortController = new AbortController(); + internalsOf(client).activeToolCallAbortController = abortController; + const reconnect = vi.fn().mockResolvedValue(undefined); + internalsOf(client).reconnectAfterAuthRecovery = reconnect; + + const challenge = { reason: "token_expired" as const }; + const operation = vi + .fn() + .mockRejectedValueOnce(new AuthChallengeError(challenge, 401)) + .mockResolvedValueOnce("ok"); + vi.spyOn(client, "handleAuthChallenge").mockResolvedValue({ + kind: "satisfied", + }); + vi.spyOn(client, "dispatchTypedEvent").mockImplementation(() => {}); + + const result = await internalsOf(client).withDirectAuthRecovery.call( + client, + operation, + ); + + expect(result).toBe("ok"); + expect(internalsOf(client).activeToolCallAbortController).toBeUndefined(); + expect(reconnect).toHaveBeenCalledTimes(1); + expect(operation).toHaveBeenCalledTimes(2); + }); + + it("throws AuthRecoveryRequiredError with emaStepUpConfirm for a step_up_confirm outcome", async () => { + const client = makeClient(); + internalsOf(client).directAuthRecovery = true; + internalsOf(client).directAuthRecoveryActive = true; + const challenge = { + reason: "insufficient_scope" as const, + requiredScopes: ["weather:read"], + }; + const operation = vi + .fn() + .mockRejectedValue(new AuthChallengeError(challenge, 403)); + vi.spyOn(client, "handleAuthChallenge").mockResolvedValue({ + kind: "step_up_confirm", + challenge, + }); + vi.spyOn(client, "dispatchTypedEvent").mockImplementation(() => {}); + + await expect( + internalsOf(client).withDirectAuthRecovery.call(client, operation), + ).rejects.toMatchObject({ + name: "AuthRecoveryRequiredError", + emaStepUpConfirm: true, + }); + expect(operation).toHaveBeenCalledTimes(1); + }); +}); + +describe("InspectorClient resumeAfterOAuth branches", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("reattaches a remote session, pushes auth state, and reconnects when not connected", async () => { + const client = makeClient(); + internalsOf(client).oauthManager = { + handleAuthChallenge: vi.fn(), + completeOAuthFlow: vi.fn().mockResolvedValue(undefined), + }; + internalsOf(client).directAuthRecovery = false; + internalsOf(client).directAuthRecoveryActive = null; + internalsOf(client).status = "disconnected"; + + const transport = Object.create( + RemoteClientTransport.prototype, + ) as RemoteClientTransport; + const attachToSession = vi.fn().mockResolvedValue(undefined); + const pushAuthState = vi.fn().mockResolvedValue(undefined); + (transport as unknown as { attachToSession: unknown }).attachToSession = + attachToSession; + (transport as unknown as { pushAuthState: unknown }).pushAuthState = + pushAuthState; + internalsOf(client).baseTransport = transport; + + const connectSpy = vi.spyOn(client, "connect").mockResolvedValue(undefined); + + await client.resumeAfterOAuth("auth-code", { + remoteSessionId: "session-1", + }); + + expect(attachToSession).toHaveBeenCalledWith("session-1"); + expect(pushAuthState).toHaveBeenCalledTimes(1); + expect(connectSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clients/web/src/test/core/mcp/node/authChallengeFetch.test.ts b/clients/web/src/test/core/mcp/node/authChallengeFetch.test.ts index b512d59a0..f94b7241d 100644 --- a/clients/web/src/test/core/mcp/node/authChallengeFetch.test.ts +++ b/clients/web/src/test/core/mcp/node/authChallengeFetch.test.ts @@ -49,4 +49,38 @@ describe("createAuthChallengeInterceptFetch", () => { ); } }); + + it("cancels a present response body before throwing", async () => { + const response = new Response("challenge body", { + status: 401, + headers: { "WWW-Authenticate": 'Bearer error="invalid_token"' }, + }); + const cancelSpy = vi.spyOn(response.body!, "cancel"); + const baseFetch = vi.fn(async () => response); + const fetchFn = createAuthChallengeInterceptFetch(baseFetch); + + await expect(fetchFn("https://example.com/mcp")).rejects.toBeInstanceOf( + AuthChallengeError, + ); + expect(cancelSpy).toHaveBeenCalled(); + }); + + it("swallows a body.cancel() rejection and still throws the challenge", async () => { + const response = new Response("challenge body", { + status: 403, + headers: { + "WWW-Authenticate": + 'Bearer error="insufficient_scope", scope="weather:read"', + }, + }); + vi.spyOn(response.body!, "cancel").mockRejectedValue( + new Error("cancel failed"), + ); + const baseFetch = vi.fn(async () => response); + const fetchFn = createAuthChallengeInterceptFetch(baseFetch); + + await expect(fetchFn("https://example.com/mcp")).rejects.toBeInstanceOf( + AuthChallengeError, + ); + }); }); diff --git a/clients/web/src/test/core/mcp/oauthManager.test.ts b/clients/web/src/test/core/mcp/oauthManager.test.ts index 1ace33db4..02d764f25 100644 --- a/clients/web/src/test/core/mcp/oauthManager.test.ts +++ b/clients/web/src/test/core/mcp/oauthManager.test.ts @@ -244,6 +244,24 @@ describe("OAuthManager", () => { const manager = new OAuthManager(params); expect(await manager.getOAuthTokens()).toBeUndefined(); }); + + it("returns tokens from in-memory flow state without querying storage", async () => { + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); + const params = createMockParams(); + const tokens = { access_token: "cached", token_type: "Bearer" }; + storageOf(params).getTokens.mockResolvedValue(tokens); + storageOf(params).getClientInformation.mockResolvedValue({ + client_id: "cid", + }); + const manager = new OAuthManager(params); + await manager.completeOAuthFlow("code"); + storageOf(params).getTokens.mockClear(); + + const result = await manager.getOAuthTokens(); + + expect(result).toEqual(tokens); + expect(storageOf(params).getTokens).not.toHaveBeenCalled(); + }); }); describe("isOAuthAuthorized", () => { @@ -804,6 +822,65 @@ describe("OAuthManager", () => { startSpy.mockRestore(); parseSpy.mockRestore(); }); + + it("skips onBeforeOAuthRedirect when none is configured", async () => { + const silentSpy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "no_idp_session" }); + const authUrl = new URL( + "https://idp.example.com/authorize?state=no-callback", + ); + const startSpy = vi + .spyOn(emaFlow, "startEmaIdpAuthorization") + .mockResolvedValue(authUrl); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const result = await manager.authenticate(); + + expect(result).toEqual(authUrl); + expect( + params.initialConfig.navigation!.navigateToAuthorization, + ).toHaveBeenCalledWith(authUrl); + + silentSpy.mockRestore(); + startSpy.mockRestore(); + }); + + it("skips onBeforeOAuthRedirect when the authorization state has no authId", async () => { + const silentSpy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "no_idp_session" }); + const authUrl = new URL( + "https://idp.example.com/authorize?state=no-authid", + ); + const startSpy = vi + .spyOn(emaFlow, "startEmaIdpAuthorization") + .mockResolvedValue(authUrl); + const parseSpy = vi + .spyOn(await import("@inspector/core/auth/utils.js"), "parseOAuthState") + .mockReturnValue(null); + const params = emaParams(); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + await manager.authenticate(); + + expect(params.onBeforeOAuthRedirect).not.toHaveBeenCalled(); + + silentSpy.mockRestore(); + startSpy.mockRestore(); + parseSpy.mockRestore(); + }); }); describe("refreshEnterpriseManagedTokens", () => { @@ -1505,4 +1582,643 @@ describe("OAuthManager", () => { expect(state?.serverUrl).toBe(SERVER_URL); }); }); + + describe("getOAuthState (storage not configured)", () => { + it("returns undefined when storage is not configured", async () => { + const params = createMockParams({ + initialConfig: { + redirectUrlProvider: { + getRedirectUrl: vi + .fn() + .mockReturnValue("http://localhost/callback"), + }, + navigation: { navigateToAuthorization: vi.fn() }, + } as OAuthManagerConfig, + }); + const manager = new OAuthManager(params); + await expect(manager.getOAuthState()).resolves.toBeUndefined(); + }); + }); + + describe("checkAuthChallengeSatisfied (storage not configured)", () => { + it("returns false when storage is not configured", async () => { + const params = createMockParams({ + initialConfig: { + redirectUrlProvider: { + getRedirectUrl: vi + .fn() + .mockReturnValue("http://localhost/callback"), + }, + navigation: { navigateToAuthorization: vi.fn() }, + } as OAuthManagerConfig, + }); + const manager = new OAuthManager(params); + expect( + await manager.checkAuthChallengeSatisfied({ + reason: "insufficient_scope", + }), + ).toBe(false); + }); + }); + + describe("handleAuthChallenge (additional branch coverage)", () => { + it("resolves via the second satisfaction check inside the mutex", async () => { + const params = createMockParams(); + const insufficientTokens = { + access_token: "a1", + token_type: "Bearer", + scope: "mcp tools:read", + }; + const sufficientTokens = { + access_token: "a2", + token_type: "Bearer", + scope: "mcp tools:read weather:read", + }; + storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params) + .getTokens.mockResolvedValueOnce(insufficientTokens) + .mockResolvedValue(sufficientTokens); + const manager = new OAuthManager(params); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }); + + expect(outcome).toEqual({ kind: "satisfied" }); + expect(mockedMcpAuth).not.toHaveBeenCalled(); + }); + + it("persists broadened scope when silent refresh already satisfies the step-up scope", async () => { + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); + const params = createMockParams(); + const insufficientTokens = { + access_token: "a1", + refresh_token: "r1", + token_type: "Bearer", + scope: "mcp tools:read", + }; + const sufficientTokens = { + access_token: "a2", + refresh_token: "r2", + token_type: "Bearer", + scope: "mcp tools:read weather:read", + }; + storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params) + .getTokens.mockResolvedValueOnce(insufficientTokens) + .mockResolvedValueOnce(insufficientTokens) + .mockResolvedValueOnce(insufficientTokens) + .mockResolvedValue(sufficientTokens); + const manager = new OAuthManager(params); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }); + + expect(outcome).toEqual({ kind: "satisfied" }); + expect(storageOf(params).saveScope).toHaveBeenCalledWith( + SERVER_URL, + "mcp tools:read weather:read", + ); + }); + + it("returns satisfied without persisting scope when the fresh grant has no scope to record", async () => { + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); + const params = createMockParams(); + // scope: "" (not omitted) avoids enrichChallengeWithAuthorizationScopes' + // internal extra getTokens() re-fetch, which only triggers when the + // passed grantedTokenScope is `undefined`. + const noScopeTokens = { + access_token: "a", + token_type: "Bearer", + scope: "", + }; + const midScopeTokens = { + access_token: "b", + token_type: "Bearer", + scope: "newscope", + }; + const finalNoScopeTokens = { + access_token: "c", + token_type: "Bearer", + scope: "", + }; + storageOf(params) + .getTokens.mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(noScopeTokens) + .mockResolvedValueOnce(midScopeTokens) + .mockResolvedValueOnce(finalNoScopeTokens); + const manager = new OAuthManager(params); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + }); + + expect(outcome).toEqual({ kind: "satisfied" }); + expect(storageOf(params).saveScope).not.toHaveBeenCalled(); + }); + + it("forces reauthorization and persists the granted scope when the retry succeeds", async () => { + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); + const params = createMockParams(); + const noScopeTokens = { + access_token: "a", + token_type: "Bearer", + scope: "", + }; + const midScopeTokens = { + access_token: "b", + token_type: "Bearer", + scope: "newscope", + }; + const grantedTokens = { + access_token: "d", + token_type: "Bearer", + scope: "granted:scope", + }; + storageOf(params) + .getTokens.mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(noScopeTokens) + .mockResolvedValueOnce(noScopeTokens) + .mockResolvedValueOnce(midScopeTokens) + .mockResolvedValueOnce(grantedTokens); + const manager = new OAuthManager(params); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + }); + + expect(outcome).toEqual({ kind: "satisfied" }); + expect(mockedMcpAuth).toHaveBeenCalledTimes(2); + expect(mockedMcpAuth).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.objectContaining({ forceReauthorization: true }), + ); + expect(storageOf(params).saveScope).toHaveBeenCalledWith( + SERVER_URL, + "granted:scope", + ); + }); + + it("forces reauthorization without persisting scope when the retry grants no explicit scope", async () => { + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); + const params = createMockParams(); + const noScopeTokens = { + access_token: "a", + token_type: "Bearer", + scope: "", + }; + const midScopeTokens = { + access_token: "b", + token_type: "Bearer", + scope: "newscope", + }; + storageOf(params) + .getTokens.mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(noScopeTokens) + .mockResolvedValueOnce(noScopeTokens) + .mockResolvedValueOnce(midScopeTokens) + .mockResolvedValueOnce(noScopeTokens); + const manager = new OAuthManager(params); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + }); + + expect(outcome).toEqual({ kind: "satisfied" }); + expect(storageOf(params).saveScope).not.toHaveBeenCalled(); + }); + + it("returns failed when the forced reauthorization retry does not complete", async () => { + mockedMcpAuth + .mockResolvedValueOnce("AUTHORIZED") + .mockResolvedValueOnce("REDIRECT"); + const params = createMockParams(); + storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "access", + token_type: "Bearer", + scope: "mcp tools:read", + }); + const manager = new OAuthManager(params); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }); + + expect(outcome.kind).toBe("failed"); + if (outcome.kind === "failed") { + expect(outcome.error.message).toMatch(/weather:read/); + } + }); + + it("falls back to challenge requiredScopes when no catalog scope is configured", async () => { + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); + const params = createMockParams(); + const manager = new OAuthManager(params); + + const outcome = await manager.handleAuthChallenge({ + reason: "unauthorized", + requiredScopes: ["fallback:scope"], + }); + + expect(outcome).toEqual({ kind: "satisfied" }); + expect(mockedMcpAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ scope: "fallback:scope" }), + ); + }); + }); + + describe("completeOAuthFlow (non-Error rejection)", () => { + it("wraps non-Error throw values in the dispatched error", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + mockedMcpAuth.mockRejectedValue("plain-string-failure"); + const params = createMockParams(); + const manager = new OAuthManager(params); + + await expect(manager.completeOAuthFlow("code")).rejects.toBe( + "plain-string-failure", + ); + expect(params.dispatchOAuthError).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.any(Error) }), + ); + const dispatchedError = ( + params.dispatchOAuthError as ReturnType + ).mock.calls[0][0].error as Error; + expect(dispatchedError.message).toBe("plain-string-failure"); + errorSpy.mockRestore(); + }); + }); + + describe("handleEnterpriseManagedAuthChallenge (additional branch coverage)", () => { + it("returns failed when EMA silent re-mint fails during confirmed step-up", async () => { + const mintError = new Error("mint failed during step-up"); + const silentSpy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "mint_failed", error: mintError }); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const outcome = await manager.handleAuthChallenge( + { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, + { confirmedStepUp: true }, + ); + + expect(outcome).toEqual({ kind: "failed", error: mintError }); + silentSpy.mockRestore(); + }); + + it("returns satisfied for EMA token_expired when refreshEmaResourceTokens succeeds, using the configured fallback scope", async () => { + const refreshSpy = vi + .spyOn(emaFlow, "refreshEmaResourceTokens") + .mockResolvedValue({ access_token: "R", token_type: "Bearer" }); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ + enterpriseManaged: true, + scope: "fallback:scope", + }); + + const outcome = await manager.handleAuthChallenge({ + reason: "token_expired", + }); + + expect(outcome).toEqual({ kind: "satisfied" }); + expect(refreshSpy).toHaveBeenCalledWith( + expect.objectContaining({ scope: "fallback:scope" }), + ); + refreshSpy.mockRestore(); + }); + + it("returns failed when starting the EMA IdP authorization throws an Error", async () => { + const refreshSpy = vi + .spyOn(emaFlow, "refreshEmaResourceTokens") + .mockResolvedValue(undefined); + const startSpy = vi + .spyOn(emaFlow, "startEmaIdpAuthorization") + .mockRejectedValue(new Error("idp unreachable")); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const outcome = await manager.handleAuthChallenge({ + reason: "token_expired", + }); + + expect(outcome.kind).toBe("failed"); + if (outcome.kind === "failed") { + expect(outcome.error.message).toBe("idp unreachable"); + } + refreshSpy.mockRestore(); + startSpy.mockRestore(); + }); + + it("wraps non-Error throw values when starting EMA IdP authorization fails", async () => { + const refreshSpy = vi + .spyOn(emaFlow, "refreshEmaResourceTokens") + .mockResolvedValue(undefined); + const startSpy = vi + .spyOn(emaFlow, "startEmaIdpAuthorization") + .mockRejectedValue("idp offline"); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const outcome = await manager.handleAuthChallenge({ + reason: "token_expired", + }); + + expect(outcome.kind).toBe("failed"); + if (outcome.kind === "failed") { + expect(outcome.error.message).toBe("idp offline"); + } + refreshSpy.mockRestore(); + startSpy.mockRestore(); + }); + + it("returns interactive for EMA insufficient_scope with no prior scope and no configured fallback", async () => { + const silentSpy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "no_idp_session" }); + const authUrl = new URL( + "https://idp.example.com/authorize?state=ema-empty", + ); + const startSpy = vi + .spyOn(emaFlow, "startEmaIdpAuthorization") + .mockResolvedValue(authUrl); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const outcome = await manager.handleAuthChallenge( + { reason: "insufficient_scope" }, + { confirmedStepUp: true }, + ); + + expect(outcome).toEqual( + expect.objectContaining({ + kind: "interactive", + authorizationUrl: authUrl, + }), + ); + silentSpy.mockRestore(); + startSpy.mockRestore(); + }); + }); + + describe("beginInteractiveAuthorization", () => { + it("records flow state, navigates, and dispatches when state carries an authId", async () => { + const authorizationUrl = new URL( + "https://auth.example.com/authorize?state=begin-1", + ); + const onBeforeOAuthRedirect = vi.fn().mockResolvedValue(undefined); + const parseSpy = vi + .spyOn(await import("@inspector/core/auth/utils.js"), "parseOAuthState") + .mockReturnValue({ + execution: "quick", + authId: "begin-auth-id", + } as ReturnType< + typeof import("@inspector/core/auth/utils.js").parseOAuthState + >); + const params = createMockParams({ onBeforeOAuthRedirect }); + storageOf(params).getClientInformation.mockResolvedValue({ + client_id: "cid", + }); + const manager = new OAuthManager(params); + + await manager.beginInteractiveAuthorization(authorizationUrl); + + expect(onBeforeOAuthRedirect).toHaveBeenCalledWith("begin-auth-id"); + expect( + params.initialConfig.navigation!.navigateToAuthorization, + ).toHaveBeenCalledWith(authorizationUrl); + expect(manager.getOAuthFlowStep()).toBe("authorization_code"); + expect(manager.getOAuthFlowState()?.oauthClientInfo).toEqual({ + client_id: "cid", + }); + expect(params.dispatchOAuthAuthorizationRequired).toHaveBeenCalledWith({ + url: authorizationUrl, + }); + + parseSpy.mockRestore(); + }); + + it("skips onBeforeOAuthRedirect when there is no state param", async () => { + const authorizationUrl = new URL("https://auth.example.com/authorize"); + const onBeforeOAuthRedirect = vi.fn(); + const params = createMockParams({ onBeforeOAuthRedirect }); + const manager = new OAuthManager(params); + + await manager.beginInteractiveAuthorization(authorizationUrl); + + expect(onBeforeOAuthRedirect).not.toHaveBeenCalled(); + expect( + params.initialConfig.navigation!.navigateToAuthorization, + ).toHaveBeenCalledWith(authorizationUrl); + expect(params.dispatchOAuthAuthorizationRequired).toHaveBeenCalledWith({ + url: authorizationUrl, + }); + }); + + it("skips onBeforeOAuthRedirect when state param has no authId", async () => { + const authorizationUrl = new URL( + "https://auth.example.com/authorize?state=zzz", + ); + const parseSpy = vi + .spyOn(await import("@inspector/core/auth/utils.js"), "parseOAuthState") + .mockReturnValue(null); + const onBeforeOAuthRedirect = vi.fn(); + const params = createMockParams({ onBeforeOAuthRedirect }); + const manager = new OAuthManager(params); + + await manager.beginInteractiveAuthorization(authorizationUrl); + + expect(onBeforeOAuthRedirect).not.toHaveBeenCalled(); + + parseSpy.mockRestore(); + }); + + it("throws when navigation is not configured", async () => { + const params = createMockParams({ + initialConfig: { + storage: createMockParams().initialConfig.storage, + redirectUrlProvider: { + getRedirectUrl: vi + .fn() + .mockReturnValue("http://localhost/callback"), + }, + } as OAuthManagerConfig, + }); + const manager = new OAuthManager(params); + + await expect( + manager.beginInteractiveAuthorization( + new URL("https://auth.example.com/authorize"), + ), + ).rejects.toThrow("OAuth navigation is required."); + }); + }); + + describe("createOAuthProvider (clientId not configured)", () => { + it("skips savePreregisteredClientInformation when clientId is not configured", async () => { + mockedMcpAuth.mockResolvedValue("REDIRECT"); + const capturedUrl = new URL( + "https://auth.example.com/authorize?state=no-client-id", + ); + const params = createMockParams(); + params.initialConfig.clientId = undefined; + const manager = new OAuthManager(params); + const captureSpy = vi + .spyOn( + (await import("@inspector/core/auth/providers.js")) + .BaseOAuthClientProvider.prototype, + "getCapturedAuthUrl", + ) + .mockReturnValue(capturedUrl); + + await manager.authenticate(); + + expect( + storageOf(params).savePreregisteredClientInformation, + ).not.toHaveBeenCalled(); + captureSpy.mockRestore(); + }); + }); + + describe("completeOAuthFlow (oauthClientInfo null fallback)", () => { + it("stores null clientInfo when none is available and no flow state pre-exists", async () => { + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + }); + const manager = new OAuthManager(params); + + await manager.completeOAuthFlow("code"); + + expect(manager.getOAuthFlowState()?.oauthClientInfo).toBeNull(); + }); + + it("stores null clientInfo when none is available and flow state already exists", async () => { + const capturedUrl = new URL( + "https://auth.example.com/authorize?state=existing-flow", + ); + mockedMcpAuth + .mockResolvedValueOnce("REDIRECT") + .mockResolvedValueOnce("AUTHORIZED"); + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "access", + token_type: "Bearer", + scope: "mcp", + }); + const manager = new OAuthManager(params); + const captureSpy = vi + .spyOn( + (await import("@inspector/core/auth/providers.js")) + .BaseOAuthClientProvider.prototype, + "getCapturedAuthUrl", + ) + .mockReturnValue(capturedUrl); + + await manager.handleAuthChallenge({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }); + expect(manager.getOAuthFlowState()).toBeDefined(); + + await manager.completeOAuthFlow("code"); + + expect(manager.getOAuthFlowState()?.oauthClientInfo).toBeNull(); + captureSpy.mockRestore(); + }); + }); + + describe("handleEnterpriseManagedAuthChallenge (scopeToPersist false arm)", () => { + it("returns satisfied without persisting scope when the EMA mint has no scope to record", async () => { + const silentSpy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "success" }); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + storageOf(params) + .getTokens.mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ + access_token: "g", + token_type: "Bearer", + scope: "granted", + }) + .mockResolvedValueOnce({ access_token: "g2", token_type: "Bearer" }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const outcome = await manager.handleAuthChallenge( + { reason: "insufficient_scope" }, + { confirmedStepUp: true }, + ); + + expect(outcome).toEqual({ kind: "satisfied" }); + expect(storageOf(params).saveScope).not.toHaveBeenCalled(); + silentSpy.mockRestore(); + }); + }); }); diff --git a/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts b/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts new file mode 100644 index 000000000..3a2100e52 --- /dev/null +++ b/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts @@ -0,0 +1,269 @@ +/** + * Supplemental coverage for createRemoteApp's /api/mcp/* routes + * (core/mcp/remote/node/server.ts), targeting branches the broader e2e + * suites (transport.test.ts, connect-crash.test.ts) don't reach: + * + * - POST /api/mcp/connect: connect-time AuthChallengeError (401 upstream) + * - POST /api/mcp/send: transport-dead short-circuit + * - POST /api/mcp/disconnect: unknown sessionId (no-op, still 200) + * - POST /api/mcp/auth-state: every guard branch (missing fields, unknown + * session, dead transport, no OAuth provider on the session, success) + */ + +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { createServer } from "node:http"; +import type { Server } from "node:http"; +import { serve } from "@hono/node-server"; +import type { ServerType } from "@hono/node-server"; +import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js"; +import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; +import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; + +interface Harness { + baseUrl: string; + server: ServerType; +} + +async function start(): Promise { + const { app } = createRemoteApp({ + dangerouslyOmitAuth: true, + initialConfig: { defaultEnvironment: {} }, + }); + return new Promise((resolve, reject) => { + const server = serve( + { fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, + (info) => { + const port = + info && typeof info === "object" && "port" in info + ? (info as { port: number }).port + : 0; + resolve({ baseUrl: `http://127.0.0.1:${port}`, server }); + }, + ); + server.on("error", reject); + }); +} + +async function stop(h: Harness): Promise { + await new Promise((resolve) => h.server.close(() => resolve())); +} + +async function connect( + h: Harness, + config: MCPServerConfig, + authState?: { oauthTokens: { access_token: string; token_type: string } }, +): Promise { + return fetch(`${h.baseUrl}/api/mcp/connect`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ config, ...(authState && { authState }) }), + }); +} + +/** A raw HTTP server that returns 401 for every request (no MCP semantics). */ +async function startUnauthorizedUpstream(): Promise<{ + url: string; + server: Server; +}> { + const server = createServer((_req, res) => { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "unauthorized" })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + return { url: `http://127.0.0.1:${port}/mcp`, server }; +} + +/** Connect a stdio session whose process crashes almost immediately, then + * give the onclose handler time to mark the session's transport dead. */ +async function connectDeadSession(h: Harness): Promise { + const config: MCPServerConfig = { + type: "stdio", + command: process.execPath, + args: ["-e", "process.stderr.write('dying\\n'); process.exit(1);"], + }; + const res = await connect(h, config); + expect(res.status).toBe(200); + const { sessionId } = (await res.json()) as { sessionId: string }; + // Give the subprocess time to exit and the transport's onclose handler + // to mark the session dead (mirrors connect-crash.test.ts's technique). + await new Promise((resolve) => setTimeout(resolve, 300)); + return sessionId; +} + +describe("server.ts /api/mcp/* branch coverage", () => { + let h: Harness; + beforeEach(async () => { + h = await start(); + }); + afterEach(async () => { + await stop(h); + }); + + describe("POST /api/mcp/connect", () => { + it("returns 500 (not 401) when the upstream SSE connection is rejected for a non-auth reason", async () => { + // The SDK's SSEClientTransport wraps every connection failure — including + // a 401 — in its own SseError, so `err instanceof AuthChallengeError` at + // the connect catch site is unreachable via the real SSE/streamable-http + // SDK transports today (streamable-http's start() makes no network call + // at all; SSE's start() discards the thrown error's subclass). This test + // instead pins the *reachable* generic-failure branch: a non-401 refusal + // still surfaces as a 500 with the wrapped message, not a misleading 401. + const upstream = await startUnauthorizedUpstream(); + try { + const res = await connect(h, { + type: "sse", + url: upstream.url, + }); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/Failed to start transport/); + } finally { + await new Promise((r) => upstream.server.close(() => r())); + } + }); + }); + + describe("POST /api/mcp/send", () => { + it("short-circuits with a transport_error when the session's transport is already dead", async () => { + const sessionId = await connectDeadSession(h); + const res = await fetch(`${h.baseUrl}/api/mcp/send`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId, + message: { jsonrpc: "2.0", id: 1, method: "tools/list" }, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + ok: boolean; + kind?: string; + error?: string; + }; + expect(body.ok).toBe(false); + expect(body.kind).toBe("transport_error"); + expect(typeof body.error).toBe("string"); + }); + }); + + describe("POST /api/mcp/disconnect", () => { + it("returns ok:true as a no-op when the sessionId is unknown", async () => { + const res = await fetch(`${h.baseUrl}/api/mcp/disconnect`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId: "does-not-exist" }), + }); + expect(res.status).toBe(200); + expect((await res.json()).ok).toBe(true); + }); + }); + + describe("POST /api/mcp/auth-state", () => { + async function postAuthState(body: unknown): Promise { + return fetch(`${h.baseUrl}/api/mcp/auth-state`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } + + it("returns 400 when sessionId is missing", async () => { + const res = await postAuthState({ + authState: { oauthTokens: { access_token: "a", token_type: "Bearer" } }, + }); + expect(res.status).toBe(400); + expect((await res.json()).error).toMatch( + /Missing sessionId or authState/, + ); + }); + + it("returns 400 when authState is missing", async () => { + const res = await postAuthState({ sessionId: "some-session" }); + expect(res.status).toBe(400); + expect((await res.json()).error).toMatch( + /Missing sessionId or authState/, + ); + }); + + it("returns 400 on invalid JSON body", async () => { + const res = await fetch(`${h.baseUrl}/api/mcp/auth-state`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{not json", + }); + expect(res.status).toBe(400); + expect((await res.json()).error).toMatch(/Invalid JSON body/); + }); + + it("returns 404 when the session is not found", async () => { + const res = await postAuthState({ + sessionId: "unknown-session-id", + authState: { oauthTokens: { access_token: "a", token_type: "Bearer" } }, + }); + expect(res.status).toBe(404); + expect((await res.json()).error).toMatch(/Session not found/); + }); + + it("returns a transport_error when the session's transport is already dead", async () => { + const sessionId = await connectDeadSession(h); + const res = await postAuthState({ + sessionId, + authState: { oauthTokens: { access_token: "a", token_type: "Bearer" } }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean; kind?: string }; + expect(body.ok).toBe(false); + expect(body.kind).toBe("transport_error"); + }); + + it("returns 400 when the session has no OAuth auth provider (connected without authState)", async () => { + const { command, args } = getTestMcpServerCommand(); + const res = await connect(h, { type: "stdio", command, args }); + expect(res.status).toBe(200); + const { sessionId } = (await res.json()) as { sessionId: string }; + + const authRes = await postAuthState({ + sessionId, + authState: { oauthTokens: { access_token: "a", token_type: "Bearer" } }, + }); + expect(authRes.status).toBe(400); + expect((await authRes.json()).error).toMatch( + /Session has no OAuth auth provider/, + ); + + await fetch(`${h.baseUrl}/api/mcp/disconnect`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId }), + }); + }); + + it("returns ok:true and hot-swaps the token when the session has an OAuth auth provider", async () => { + const { command, args } = getTestMcpServerCommand(); + const res = await connect( + h, + { type: "stdio", command, args }, + { oauthTokens: { access_token: "initial", token_type: "Bearer" } }, + ); + expect(res.status).toBe(200); + const { sessionId } = (await res.json()) as { sessionId: string }; + + const authRes = await postAuthState({ + sessionId, + authState: { + oauthTokens: { access_token: "rotated", token_type: "Bearer" }, + }, + }); + expect(authRes.status).toBe(200); + expect((await authRes.json()).ok).toBe(true); + + await fetch(`${h.baseUrl}/api/mcp/disconnect`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId }), + }); + }); + }); +}); diff --git a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts index 960273a2d..bb52a33b9 100644 --- a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts @@ -182,6 +182,29 @@ describe("RemoteSession", () => { await expect(wait).resolves.toBeUndefined(); }); + it("endSend is a no-op when no send is active", () => { + const session = new RemoteSession("s-endsend-noop"); + expect(session.hasActiveSend()).toBe(false); + // No matching beginSend() — activeSendCount is already 0. + session.endSend(); + expect(session.hasActiveSend()).toBe(false); + }); + + it("waitForRequestResponse with timeoutMs=0 never schedules a timer and still resolves", async () => { + const session = new RemoteSession("s-no-timeout"); + const wait = session.waitForRequestResponse(7, 0); + session.onMessage({ jsonrpc: "2.0", id: 7, result: {} }); + await expect(wait).resolves.toBeUndefined(); + }); + + it("cancelRequestWait rejects a timeoutMs=0 wait without a timer to clear", async () => { + const session = new RemoteSession("s-no-timeout-cancel"); + const wait = session.waitForRequestResponse(8, 0); + const rejection = expect(wait).rejects.toThrow(/cancelled/); + session.cancelRequestWait(8); + await rejection; + }); + it("handleTransportAuthError rejects active request waits during send", async () => { const session = new RemoteSession("s-auth"); session.beginSend(); @@ -279,6 +302,28 @@ describe("RemoteSession", () => { vi.useRealTimers(); }); + it("setAuthState throws when no auth provider handle is set", () => { + const session = new RemoteSession("no-auth-provider"); + expect(() => + session.setAuthState({ + oauthTokens: { access_token: "x", token_type: "Bearer" }, + }), + ).toThrow(/Session has no OAuth auth provider/); + }); + + it("cancelRequestWait is a no-op when the requestId has no pending wait", () => { + const session = new RemoteSession("s-cancel-noop"); + // No wait was ever registered for this id — should not throw. + expect(() => session.cancelRequestWait(123)).not.toThrow(); + }); + + it("handleTransportAuthError returns false for a non-AuthChallengeError", () => { + const session = new RemoteSession("s-not-auth-error"); + expect(session.handleTransportAuthError(new Error("plain error"))).toBe( + false, + ); + }); + it("setAuthState updates the session auth provider", async () => { const { createRemoteAuthProvider } = await import("@inspector/core/mcp/remote/node/tokenAuthProvider.js"); diff --git a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts index 559b29d88..4e07b3aa3 100644 --- a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts @@ -16,6 +16,11 @@ import type { RemoteTransportOptions } from "@inspector/core/mcp/remote/remoteCl import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; import type { RemoteEvent } from "@inspector/core/mcp/remote/types.js"; import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; +import { + AuthChallengeError, + AuthRecoveryRequiredError, + EMA_STEP_UP_PENDING_URL, +} from "@inspector/core/auth/challenge.js"; const CONFIG: MCPServerConfig = { type: "sse", @@ -69,6 +74,10 @@ interface MockFetchPlan { input: RequestInfo | URL, init?: RequestInit, ) => Response | Promise; + authState?: ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Response | Promise; } /** @@ -87,6 +96,11 @@ function mockFetch(plan: MockFetchPlan): typeof fetch { if (url.includes("/api/mcp/events")) { return plan.events ? plan.events(input, init) : sseResponse([]); } + if (url.includes("/api/mcp/auth-state")) { + return plan.authState + ? plan.authState(input, init) + : jsonResponse({ ok: true }); + } if (url.includes("/api/mcp/send")) { return plan.send ? plan.send(input, init) : jsonResponse({ ok: true }); } @@ -101,6 +115,28 @@ function mockFetch(plan: MockFetchPlan): typeof fetch { return fn as unknown as typeof fetch; } +/** Push arbitrary RemoteEvent frames onto a controllable SSE stream. */ +function createPushableEventStream() { + const encoder = new TextEncoder(); + let controller: ReadableStreamDefaultController | null = null; + const stream = new ReadableStream({ + start(c) { + controller = c; + c.enqueue(encoder.encode(": keepalive\n\n")); + }, + }); + const push = (event: RemoteEvent) => { + controller?.enqueue(encoder.encode(sseFrame(event))); + }; + return { + response: new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + push, + }; +} + function makeTransport( plan: MockFetchPlan, extra: Partial = {}, @@ -119,6 +155,19 @@ function makeTransport( /** Wait a tick so the detached consumeEventStream loop can advance. */ const tick = () => new Promise((r) => setTimeout(r, 10)); +/** + * An SSE response whose stream stays open (never closes on its own) until the + * transport cancels it. Use for tests that need the transport to remain + * started/open past the initial start() call — the plan's default `events()` + * handler returns an already-closed empty stream, which auto-closes the + * transport shortly after start() resolves. + */ +function openEventsResponse(): Response { + return new Response(new ReadableStream({ start() {} }), { + status: 200, + }); +} + describe("RemoteClientTransport (focused branch coverage)", () => { describe("start()", () => { it("throws Remote connect failed with status on non-OK connect", async () => { @@ -664,4 +713,638 @@ describe("RemoteClientTransport (focused branch coverage)", () => { expect(seen[0]!["x-mcp-remote-auth"]).toBeUndefined(); }); }); + + describe("start() connect-time auth challenge and error branches", () => { + it("accepts the ok:true connect response shape (ok-wrapped sessionId)", async () => { + const t = makeTransport({ + connect: () => jsonResponse({ ok: true, sessionId: "sess-ok" }), + }); + await t.start(); + expect(t.getRemoteBackendSessionId()).toBe("sess-ok"); + await t.close(); + }); + + it("throws a formatted error when connect returns a transport_error", async () => { + const t = makeTransport({ + connect: () => + jsonResponse({ + ok: false, + kind: "transport_error", + error: "upstream unreachable", + }), + }); + await expect(t.start()).rejects.toThrow( + /Remote connect failed: upstream unreachable/, + ); + }); + + it("throws AuthChallengeError immediately when connect returns an auth_challenge and no authRecovery is configured", async () => { + const t = makeTransport({ + connect: () => + jsonResponse({ + ok: false, + kind: "auth_challenge", + authChallenge: { reason: "token_expired" }, + }), + }); + try { + await t.start(); + throw new Error("expected start() to throw"); + } catch (e) { + expect(e).toBeInstanceOf(AuthChallengeError); + expect((e as AuthChallengeError).status).toBe(401); + } + }); + + it("preserves the raw httpStatus on the AuthChallengeError when no authRecovery is configured", async () => { + const t = makeTransport({ + connect: () => + jsonResponse({ + ok: false, + kind: "auth_challenge", + authChallenge: { + reason: "invalid_token", + raw: { httpStatus: 403 }, + }, + }), + }); + try { + await t.start(); + throw new Error("expected start() to throw"); + } catch (e) { + expect((e as AuthChallengeError).status).toBe(403); + } + }); + + it("throws AuthRecoveryRequiredError with the EMA pending URL for a step_up_confirm outcome", async () => { + const t = makeTransport( + { + connect: () => + jsonResponse({ + ok: false, + kind: "auth_challenge", + authChallenge: { reason: "insufficient_scope" }, + }), + }, + { + authRecovery: { + handleAuthChallenge: vi.fn().mockResolvedValue({ + kind: "step_up_confirm", + challenge: { reason: "insufficient_scope" }, + }), + }, + }, + ); + try { + await t.start(); + throw new Error("expected start() to throw"); + } catch (e) { + expect(e).toBeInstanceOf(AuthRecoveryRequiredError); + const err = e as AuthRecoveryRequiredError; + expect(err.authorizationUrl).toBe(EMA_STEP_UP_PENDING_URL); + expect(err.emaStepUpConfirm).toBe(true); + } + }); + + it("throws AuthRecoveryRequiredError with the authorization URL for an interactive outcome", async () => { + const authorizationUrl = new URL("https://idp.example/authorize"); + const t = makeTransport( + { + connect: () => + jsonResponse({ + ok: false, + kind: "auth_challenge", + authChallenge: { reason: "unauthorized" }, + }), + }, + { + authRecovery: { + handleAuthChallenge: vi.fn().mockResolvedValue({ + kind: "interactive", + authorizationUrl, + challenge: { reason: "unauthorized" }, + }), + }, + }, + ); + try { + await t.start(); + throw new Error("expected start() to throw"); + } catch (e) { + const err = e as AuthRecoveryRequiredError; + expect(err.authorizationUrl).toBe(authorizationUrl); + expect(err.emaStepUpConfirm).toBeUndefined(); + } + }); + + it("rethrows the recovery error for a failed outcome", async () => { + const recoveryError = new Error("recovery failed"); + const t = makeTransport( + { + connect: () => + jsonResponse({ + ok: false, + kind: "auth_challenge", + authChallenge: { reason: "unauthorized" }, + }), + }, + { + authRecovery: { + handleAuthChallenge: vi + .fn() + .mockResolvedValue({ kind: "failed", error: recoveryError }), + }, + }, + ); + await expect(t.start()).rejects.toBe(recoveryError); + }); + + it("succeeds after a satisfied outcome retries connect and receives a session", async () => { + let connectAttempt = 0; + const t = makeTransport( + { + connect: () => { + connectAttempt += 1; + if (connectAttempt === 1) { + return jsonResponse({ + ok: false, + kind: "auth_challenge", + authChallenge: { reason: "token_expired" }, + }); + } + return jsonResponse({ sessionId: "recovered-session" }); + }, + events: () => openEventsResponse(), + }, + { + authRecovery: { + handleAuthChallenge: vi + .fn() + .mockResolvedValue({ kind: "satisfied" }), + }, + }, + ); + await t.start(); + expect(connectAttempt).toBe(2); + expect(t.getRemoteBackendSessionId()).toBe("recovered-session"); + await t.close(); + }); + + it("throws AuthChallengeError when a retried connect still returns an auth_challenge", async () => { + const t = makeTransport( + { + connect: () => + jsonResponse({ + ok: false, + kind: "auth_challenge", + authChallenge: { reason: "token_expired" }, + }), + }, + { + authRecovery: { + handleAuthChallenge: vi + .fn() + .mockResolvedValue({ kind: "satisfied" }), + }, + }, + ); + await expect(t.start()).rejects.toBeInstanceOf(AuthChallengeError); + }); + }); + + describe("attachToSession while closed", () => { + it("un-closes a previously closed transport before reattaching", async () => { + const events = vi.fn(() => openEventsResponse()); + const t = makeTransport({ events }); + await t.close(); // never started; closed=true, no sessionId so disconnect is skipped + expect((t as unknown as { closed: boolean }).closed).toBe(true); + await t.attachToSession("resumed-session"); + expect((t as unknown as { closed: boolean }).closed).toBe(false); + expect(t.getRemoteBackendSessionId()).toBe("resumed-session"); + await t.close(); + }); + }); + + describe("fetchFn default", () => { + it("falls back to globalThis.fetch when no fetchFn option is configured", () => { + const t = new RemoteClientTransport( + { baseUrl: "http://remote.test" }, + CONFIG, + ); + expect((t as unknown as { fetchFn: typeof fetch }).fetchFn).toBe( + globalThis.fetch, + ); + }); + }); + + describe("parseSSE trailing buffer", () => { + it("ignores a trailing buffer line that is neither an event: nor a data: field", async () => { + const onmessage = vi.fn(); + const t = makeTransport({ + events: () => sseResponse([": trailing comment with no newline"]), + }); + t.onmessage = onmessage; + await t.start(); + await tick(); + expect(onmessage).not.toHaveBeenCalled(); + }); + }); + + describe("consumeEventStream stops after transport_error closes it", () => { + it("does not process a message frame that follows a transport_error frame in the same batch", async () => { + const m: JSONRPCMessage = { jsonrpc: "2.0", id: 99, result: {} }; + const onmessage = vi.fn(); + const onerror = vi.fn(); + const onclose = vi.fn(); + const t = makeTransport({ + events: () => + sseResponse([ + sseFrame({ type: "transport_error", data: { error: "died" } }), + sseFrame({ type: "message", data: m }), + ]), + }); + t.onmessage = onmessage; + t.onerror = onerror; + t.onclose = onclose; + await t.start(); + await tick(); + expect(onerror).toHaveBeenCalledTimes(1); + expect(onclose).toHaveBeenCalledTimes(1); + expect(onmessage).not.toHaveBeenCalled(); + }); + }); + + describe("consumeEventStream restart / re-entrancy suppression", () => { + it("suppresses transport_error handling entirely while a reconnect is in flight", async () => { + const sse = createPushableEventStream(); + const onerror = vi.fn(); + const onclose = vi.fn(); + const t = makeTransport({ events: () => sse.response }); + t.onerror = onerror; + t.onclose = onclose; + await t.start(); + await tick(); + // Simulate a reconnect (attachToSession/close) already in flight on this + // transport when a stale transport_error frame from the old stream lands. + ( + t as unknown as { restartingEventStream: boolean } + ).restartingEventStream = true; + sse.push({ type: "transport_error", data: { error: "stale" } }); + await tick(); + expect(onerror).not.toHaveBeenCalled(); + expect(onclose).not.toHaveBeenCalled(); + ( + t as unknown as { restartingEventStream: boolean } + ).restartingEventStream = false; + await t.close(); + }); + + it("does not double-fire onclose when the onerror handler synchronously closes the transport", async () => { + const t = makeTransport({ + events: () => + sseResponse([ + sseFrame({ type: "transport_error", data: { error: "boom" } }), + ]), + }); + const onclose = vi.fn(); + let closePromise: Promise | undefined; + t.onerror = () => { + closePromise = t.close(); + }; + t.onclose = onclose; + await t.start(); + await tick(); + await closePromise; + expect(onclose).toHaveBeenCalledTimes(1); + }); + }); + + describe("settleSseResponseWait guard", () => { + it("ignores a message-type SSE event with no id (notification)", async () => { + const onmessage = vi.fn(); + const t = makeTransport({ + events: () => + sseResponse([ + sseFrame({ + type: "message", + data: { + jsonrpc: "2.0", + method: "notifications/progress", + params: {}, + }, + }), + ]), + }); + t.onmessage = onmessage; + await t.start(); + await tick(); + expect(onmessage).toHaveBeenCalledTimes(1); + }); + + it("ignores a message-type SSE event that has an id but neither result nor error", async () => { + const onmessage = vi.fn(); + const t = makeTransport({ + events: () => + sseResponse([ + sseFrame({ + type: "message", + data: { jsonrpc: "2.0", id: 3, method: "sampling/createMessage" }, + }), + ]), + }); + t.onmessage = onmessage; + await t.start(); + await tick(); + expect(onmessage).toHaveBeenCalledTimes(1); + }); + }); + + describe("cancelSseResponseWait no-op when already settled", () => { + it("no-ops when the SSE response for the id already settled before the auth_challenge HTTP reply arrives", async () => { + const sse = createPushableEventStream(); + const t = makeTransport({ + events: () => sse.response, + send: async (_input, init) => { + const body = JSON.parse(String(init?.body)) as { + message: { id?: string | number }; + }; + // Simulate the SSE response for this id landing first... + sse.push({ + type: "message", + data: { jsonrpc: "2.0", id: body.message.id, result: {} }, + }); + // ...and let the independent SSE consumer settle it before the + // HTTP reply (an auth_challenge for the same id) comes back. + await tick(); + return jsonResponse({ + ok: false, + kind: "auth_challenge", + authChallenge: { reason: "token_expired" }, + }); + }, + }); + await t.start(); + await expect( + t.send({ jsonrpc: "2.0", id: 11, method: "tools/list" }), + ).rejects.toBeInstanceOf(AuthChallengeError); + await t.close(); + }); + }); + + describe("postSend catch skips cancel for requestId-less messages", () => { + it("does not attempt to cancel an SSE wait for a notification when requestSend throws", async () => { + const notif: JSONRPCMessage = { + jsonrpc: "2.0", + method: "notifications/cancelled", + }; + const t = makeTransport({ + events: () => openEventsResponse(), + send: () => { + throw new Error("network down"); + }, + }); + await t.start(); + await expect(t.send(notif)).rejects.toThrow(/network down/); + await t.close(); + }); + }); + + describe("send() generic transport_error and notification failure paths", () => { + it("throws the raw error message when send returns a non-auth_challenge failure kind", async () => { + const t = makeTransport({ + events: () => openEventsResponse(), + send: () => + jsonResponse({ + ok: false, + kind: "transport_error", + error: "upstream 503", + }), + }); + await t.start(); + await expect( + t.send({ jsonrpc: "2.0", id: 1, method: "ping" }), + ).rejects.toThrow(/upstream 503/); + await t.close(); + }); + + it("skips cancelSseResponseWait for a notification whose send fails", async () => { + const notif: JSONRPCMessage = { + jsonrpc: "2.0", + method: "notifications/cancelled", + }; + const t = makeTransport({ + events: () => openEventsResponse(), + send: () => + jsonResponse({ ok: false, kind: "transport_error", error: "boom" }), + }); + await t.start(); + await expect(t.send(notif)).rejects.toThrow(/boom/); + await t.close(); + }); + }); + + describe("send() auth_challenge without recovery configured", () => { + it("throws immediately when no authRecovery is configured", async () => { + const t = makeTransport({ + events: () => openEventsResponse(), + send: () => + jsonResponse({ + ok: false, + kind: "auth_challenge", + authChallenge: { reason: "token_expired" }, + }), + }); + await t.start(); + await expect( + t.send({ jsonrpc: "2.0", id: 1, method: "ping" }), + ).rejects.toBeInstanceOf(AuthChallengeError); + await t.close(); + }); + + it("throws immediately with the raw httpStatus when authRecovery has no pushAuthState handler", async () => { + const t = makeTransport( + { + events: () => openEventsResponse(), + send: () => + jsonResponse({ + ok: false, + kind: "auth_challenge", + authChallenge: { + reason: "token_expired", + raw: { httpStatus: 403 }, + }, + }), + }, + { authRecovery: { handleAuthChallenge: vi.fn() } }, + ); + await t.start(); + try { + await t.send({ jsonrpc: "2.0", id: 1, method: "ping" }); + throw new Error("expected send() to throw"); + } catch (e) { + expect((e as AuthChallengeError).status).toBe(403); + } + await t.close(); + }); + }); + + describe("pushAuthState guards", () => { + it("throws Transport not started when called before start()", async () => { + const t = makeTransport({}); + await expect( + t.pushAuthState({ + oauthTokens: { access_token: "a", token_type: "Bearer" }, + }), + ).rejects.toThrow(/Transport not started/); + }); + + it("throws Transport is closed after close()", async () => { + const t = makeTransport({}); + await t.start(); + await t.close(); + await expect( + t.pushAuthState({ + oauthTokens: { access_token: "a", token_type: "Bearer" }, + }), + ).rejects.toThrow(/Transport is closed/); + }); + + it("throws when the resolved auth state has neither oauthTokens nor oauthClient", async () => { + const t = makeTransport({ events: () => openEventsResponse() }); + await t.start(); + await expect(t.pushAuthState({})).rejects.toThrow( + /No auth state to push/, + ); + await t.close(); + }); + + it("throws No auth provider configured when called with no explicit state and no authProvider", async () => { + const t = makeTransport({ events: () => openEventsResponse() }); + await t.start(); + await expect(t.pushAuthState()).rejects.toThrow( + /No auth provider configured/, + ); + await t.close(); + }); + + it("throws No OAuth tokens available when the configured authProvider has no tokens", async () => { + const authProvider = { + tokens: async () => undefined, + } as unknown as NonNullable; + const t = makeTransport( + { events: () => openEventsResponse() }, + { authProvider }, + ); + await t.start(); + await expect(t.pushAuthState()).rejects.toThrow( + /No OAuth tokens available/, + ); + await t.close(); + }); + + it("throws a formatted error when the auth-state POST responds non-OK", async () => { + const t = makeTransport({ + events: () => openEventsResponse(), + authState: () => new Response("nope", { status: 500 }), + }); + await t.start(); + await expect( + t.pushAuthState({ + oauthTokens: { access_token: "a", token_type: "Bearer" }, + }), + ).rejects.toThrow(/Remote auth-state update failed \(500\)/); + await t.close(); + }); + + it("throws the server-provided error message when the auth-state POST reports ok:false", async () => { + const t = makeTransport({ + events: () => openEventsResponse(), + authState: () => jsonResponse({ ok: false, error: "session gone" }), + }); + await t.start(); + await expect( + t.pushAuthState({ + oauthTokens: { access_token: "a", token_type: "Bearer" }, + }), + ).rejects.toThrow(/session gone/); + await t.close(); + }); + + it("throws a default error message when the auth-state POST reports ok:false with no error field", async () => { + const t = makeTransport({ + events: () => openEventsResponse(), + authState: () => jsonResponse({ ok: false }), + }); + await t.start(); + await expect( + t.pushAuthState({ + oauthTokens: { access_token: "a", token_type: "Bearer" }, + }), + ).rejects.toThrow(/Remote auth-state update failed/); + await t.close(); + }); + + it("accepts an explicit authState argument without consulting authProvider", async () => { + const authProvider = { + tokens: vi.fn(), + } as unknown as NonNullable; + const authStateCalls: unknown[] = []; + const t = makeTransport( + { + events: () => openEventsResponse(), + authState: (_input, init) => { + authStateCalls.push(JSON.parse(String(init?.body))); + return jsonResponse({ ok: true }); + }, + }, + { authProvider }, + ); + await t.start(); + await t.pushAuthState({ + oauthTokens: { access_token: "explicit", token_type: "Bearer" }, + }); + expect(authStateCalls).toHaveLength(1); + await t.close(); + }); + }); + + describe("misc coverage", () => { + it("setOnAuthChallenge updates the ambient auth challenge handler", async () => { + const t = makeTransport({ + events: () => + sseResponse([ + sseFrame({ + type: "auth_challenge", + data: { reason: "token_expired" }, + }), + ]), + }); + const onAuthChallenge = vi.fn(); + t.setOnAuthChallenge(onAuthChallenge); + await t.start(); + await tick(); + expect(onAuthChallenge).toHaveBeenCalledWith({ reason: "token_expired" }); + }); + + it("is a no-op when start() is called again while already connected", async () => { + const connect = vi.fn(() => jsonResponse({ sessionId: "s" })); + const t = makeTransport({ connect, events: () => openEventsResponse() }); + await t.start(); + await t.start(); + expect(connect).toHaveBeenCalledTimes(1); + await t.close(); + }); + + it("rejects a pending SSE response wait when close() runs mid-send", async () => { + const t = makeTransport({ + events: () => openEventsResponse(), + send: () => jsonResponse({ ok: true }), + }); + await t.start(); + const sendPromise = t.send({ jsonrpc: "2.0", id: 1, method: "ping" }); + const rejection = expect(sendPromise).rejects.toThrow(/Transport closed/); + await t.close(); + await rejection; + }); + }); }); diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index b523a9133..ba862f20f 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -788,4 +788,113 @@ describe("server.ts supplemental coverage", () => { } }); }); + + describe("keychain-unavailable 503 on PUT/DELETE", () => { + class UnavailableOnWriteStore implements SecretStore { + async get(): Promise { + return null; + } + async set(): Promise { + throw new KeychainUnavailableError(new Error("libsecret missing")); + } + async delete(): Promise { + /* no-op */ + } + async deleteAllForServer(): Promise { + throw new KeychainUnavailableError(new Error("libsecret missing")); + } + } + + it("PUT returns 503 when the keychain write is unavailable", async () => { + const h = await start({ + secretStore: new UnavailableOnWriteStore(), + seedConfig: JSON.stringify({ + mcpServers: { + srv: { + type: "streamable-http", + url: "https://x.test/mcp", + oauth: { clientId: "cid", clientSecret: "shh" }, + }, + }, + }), + }); + try { + const res = await fetch(`${h.baseUrl}/api/servers/srv`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + config: { type: "streamable-http", url: "https://x.test/mcp" }, + settings: { + headers: [], + metadata: [], + connectionTimeout: 0, + requestTimeout: 0, + oauthClientSecret: "new-secret", + }, + }), + }); + expect(res.status).toBe(503); + expect((await res.json()).error).toMatch(/libsecret missing/); + } finally { + await stop(h); + } + }); + + it("DELETE returns 503 when the keychain sweep is unavailable", async () => { + const h = await start({ + secretStore: new UnavailableOnWriteStore(), + seedConfig: JSON.stringify({ + mcpServers: { srv: { type: "stdio", command: "node" } }, + }), + }); + try { + const res = await fetch(`${h.baseUrl}/api/servers/srv`, { + method: "DELETE", + }); + expect(res.status).toBe(503); + expect((await res.json()).error).toMatch(/libsecret missing/); + } finally { + await stop(h); + } + }); + }); + + describe("migratePlaintextSecrets rethrows a non-keychain error", () => { + class ThrowingOnGetStore implements SecretStore { + async get(): Promise { + throw new Error("disk full"); + } + async set(): Promise { + /* unreachable in this test */ + } + async delete(): Promise { + /* no-op */ + } + async deleteAllForServer(): Promise { + /* no-op */ + } + } + + it("GET /api/servers returns 500 when migration hits a non-KeychainUnavailableError", async () => { + const h = await start({ + secretStore: new ThrowingOnGetStore(), + seedConfig: JSON.stringify({ + mcpServers: { + srv: { + type: "streamable-http", + url: "https://x.test/mcp", + oauth: { clientId: "cid", clientSecret: "shh" }, + }, + }, + }), + }); + try { + const res = await fetch(`${h.baseUrl}/api/servers`); + expect(res.status).toBe(500); + expect((await res.json()).error).toMatch(/Failed to read server list/); + } finally { + await stop(h); + } + }); + }); }); diff --git a/clients/web/src/utils/browserTabVisibility.test.ts b/clients/web/src/utils/browserTabVisibility.test.ts index c4c9e98f4..d852327a1 100644 --- a/clients/web/src/utils/browserTabVisibility.test.ts +++ b/clients/web/src/utils/browserTabVisibility.test.ts @@ -41,4 +41,83 @@ describe("browserTabVisibility", () => { handler!(); expect(callback).toHaveBeenCalledOnce(); }); + + it("returned unsubscribe removes the visibilitychange listener", () => { + let handler: (() => void) | undefined; + vi.mocked(document.addEventListener).mockImplementation((_event, fn) => { + handler = fn as () => void; + }); + + const callback = vi.fn(); + const unsubscribe = onBrowserTabVisible(callback); + expect(handler).toBeDefined(); + + unsubscribe(); + expect(document.removeEventListener).toHaveBeenCalledWith( + "visibilitychange", + handler, + ); + + // After unsubscribing the stored handler must no longer fire the callback + // through the (now removed) listener path. + vi.mocked(document.addEventListener).mockClear(); + expect(document.addEventListener).not.toHaveBeenCalled(); + }); + + it("integrates with a real document: dispatch, hidden/visible, and unsubscribe", () => { + vi.unstubAllGlobals(); + const original = Object.getOwnPropertyDescriptor( + Document.prototype, + "visibilityState", + ); + const setVisibility = (value: string): void => { + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => value, + }); + }; + + try { + setVisibility("visible"); + expect(isBrowserTabVisible()).toBe(true); + + const callback = vi.fn(); + const unsubscribe = onBrowserTabVisible(callback); + + // Hidden branch: listener fires but callback is not invoked. + setVisibility("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + expect(callback).not.toHaveBeenCalled(); + expect(isBrowserTabVisible()).toBe(false); + + // Visible branch: callback invoked. + setVisibility("visible"); + document.dispatchEvent(new Event("visibilitychange")); + expect(callback).toHaveBeenCalledOnce(); + + // Unsubscribe: further events must not re-invoke the callback. + unsubscribe(); + document.dispatchEvent(new Event("visibilitychange")); + expect(callback).toHaveBeenCalledOnce(); + } finally { + if (original) { + Object.defineProperty(document, "visibilityState", original); + } else { + delete (document as { visibilityState?: string }).visibilityState; + } + } + }); + + it("no-ops safely when document is undefined (SSR guard)", () => { + vi.stubGlobal("document", undefined); + + expect(isBrowserTabVisible()).toBe(false); + + const callback = vi.fn(); + const unsubscribe = onBrowserTabVisible(callback); + expect(typeof unsubscribe).toBe("function"); + // The returned no-op must be callable without throwing. + expect(() => unsubscribe()).not.toThrow(); + expect(callback).not.toHaveBeenCalled(); + }); }); diff --git a/clients/web/src/utils/oauthResume.test.ts b/clients/web/src/utils/oauthResume.test.ts index 2ed83dc9d..439691794 100644 --- a/clients/web/src/utils/oauthResume.test.ts +++ b/clients/web/src/utils/oauthResume.test.ts @@ -7,6 +7,7 @@ import { oauthResumeInsufficientScopeMessage, oauthResumeToastMessage, OAUTH_PENDING_SERVER_KEY, + OAUTH_RESUME_KEY, readOAuthResumeSnapshot, restoreTabUiFromSnapshot, writeOAuthResumeSnapshot, @@ -222,4 +223,273 @@ describe("oauthResume", () => { expect(clearGetPromptState).toHaveBeenCalledOnce(); expect(clearReadResourceState).toHaveBeenCalledOnce(); }); + + it("readOAuthResumeSnapshot returns undefined for a non-JSON string", () => { + storage.set(OAUTH_RESUME_KEY, "not-json{"); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("readOAuthResumeSnapshot returns undefined when parsed JSON is null", () => { + storage.set(OAUTH_RESUME_KEY, "null"); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("readOAuthResumeSnapshot rejects a wrong version", () => { + storage.set( + OAUTH_RESUME_KEY, + JSON.stringify({ + version: 2, + serverId: "srv-1", + activeTab: "Tools", + authKind: "reauth", + tabUi: {}, + }), + ); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("readOAuthResumeSnapshot rejects a non-string serverId", () => { + storage.set( + OAUTH_RESUME_KEY, + JSON.stringify({ + version: 1, + serverId: 42, + activeTab: "Tools", + authKind: "reauth", + tabUi: {}, + }), + ); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("readOAuthResumeSnapshot rejects an unknown authKind", () => { + storage.set( + OAUTH_RESUME_KEY, + JSON.stringify({ + version: 1, + serverId: "srv-1", + activeTab: "Tools", + authKind: "bogus", + tabUi: {}, + }), + ); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("readOAuthResumeSnapshot rejects a non-string activeTab", () => { + storage.set( + OAUTH_RESUME_KEY, + JSON.stringify({ + version: 1, + serverId: "srv-1", + activeTab: 7, + authKind: "reauth", + tabUi: {}, + }), + ); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("readOAuthResumeSnapshot accepts a snapshot with tabUi absent", () => { + storage.set( + OAUTH_RESUME_KEY, + JSON.stringify({ + version: 1, + serverId: "srv-1", + activeTab: "Tools", + authKind: "reauth", + }), + ); + const snapshot = readOAuthResumeSnapshot(); + expect(snapshot?.serverId).toBe("srv-1"); + expect(snapshot?.tabUi).toBeUndefined(); + }); + + it("readOAuthResumeSnapshot rejects non-object tabUi (string, array, null)", () => { + for (const tabUi of ['"nope"', "[]", "null"]) { + storage.set( + OAUTH_RESUME_KEY, + `{"version":1,"serverId":"srv-1","activeTab":"Tools","authKind":"reauth","tabUi":${tabUi}}`, + ); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + } + }); + + it("writeOAuthResumeSnapshot swallows setItem failures", () => { + vi.stubGlobal("sessionStorage", { + getItem: () => null, + setItem: () => { + throw new Error("quota exceeded"); + }, + removeItem: () => {}, + }); + expect(() => + writeOAuthResumeSnapshot({ + version: 1, + serverId: "srv-1", + activeTab: "Tools", + authKind: "reauth", + tabUi: {}, + }), + ).not.toThrow(); + }); + + it("clearOAuthResumeSnapshot swallows removeItem failures", () => { + vi.stubGlobal("sessionStorage", { + getItem: () => null, + setItem: () => {}, + removeItem: () => { + throw new Error("blocked"); + }, + }); + expect(() => clearOAuthResumeSnapshot()).not.toThrow(); + }); + + it("legacy fallback returns undefined when the pending-key read throws", () => { + vi.stubGlobal("sessionStorage", { + getItem: (key: string) => { + if (key === OAUTH_PENDING_SERVER_KEY) { + throw new Error("blocked"); + } + return null; + }, + setItem: () => {}, + removeItem: () => {}, + }); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("legacy fallback returns undefined when no pending server is stored", () => { + expect(readOAuthResumeSnapshot()).toBeUndefined(); + }); + + describe("without a window global", () => { + beforeEach(() => { + vi.stubGlobal("window", undefined); + }); + + it("writeOAuthResumeSnapshot is a no-op", () => { + expect(() => + writeOAuthResumeSnapshot({ + version: 1, + serverId: "srv-1", + activeTab: "Tools", + authKind: "reauth", + tabUi: {}, + }), + ).not.toThrow(); + expect(storage.size).toBe(0); + }); + + it("readOAuthResumeSnapshot returns undefined", () => { + expect(readOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("clearOAuthResumeSnapshot is a no-op", () => { + expect(() => clearOAuthResumeSnapshot()).not.toThrow(); + }); + }); + + it("restoreTabUiFromSnapshot returns early when tabUi is undefined", () => { + const setToolsUi = vi.fn(); + restoreTabUiFromSnapshot(undefined, { + setToolsUi, + setPromptsUi: vi.fn(), + setResourcesUi: vi.fn(), + setAppsUi: vi.fn(), + setTasksUi: vi.fn(), + setLogsUi: vi.fn(), + setHistoryUi: vi.fn(), + setNetworkUi: vi.fn(), + }); + expect(setToolsUi).not.toHaveBeenCalled(); + }); + + it("restoreTabUiFromSnapshot skips keys that are not inspector tabs", () => { + const setters = { + setToolsUi: vi.fn(), + setPromptsUi: vi.fn(), + setResourcesUi: vi.fn(), + setAppsUi: vi.fn(), + setTasksUi: vi.fn(), + setLogsUi: vi.fn(), + setHistoryUi: vi.fn(), + setNetworkUi: vi.fn(), + }; + restoreTabUiFromSnapshot( + { NotATab: {} } as Record, + setters, + ); + for (const setter of Object.values(setters)) { + expect(setter).not.toHaveBeenCalled(); + } + }); + + it("restoreTabUiFromSnapshot restores every tab with a present value", () => { + const setters = { + setToolsUi: vi.fn(), + setPromptsUi: vi.fn(), + setResourcesUi: vi.fn(), + setAppsUi: vi.fn(), + setTasksUi: vi.fn(), + setLogsUi: vi.fn(), + setHistoryUi: vi.fn(), + setNetworkUi: vi.fn(), + }; + restoreTabUiFromSnapshot( + { + Tools: EMPTY_TOOLS_UI, + Prompts: EMPTY_PROMPTS_UI, + Resources: EMPTY_RESOURCES_UI, + Apps: EMPTY_APPS_UI, + Tasks: EMPTY_TASKS_UI, + Logs: EMPTY_LOGS_UI, + History: EMPTY_HISTORY_UI, + Network: EMPTY_NETWORK_UI, + }, + setters, + ); + expect(setters.setToolsUi).toHaveBeenCalledWith(EMPTY_TOOLS_UI); + expect(setters.setPromptsUi).toHaveBeenCalledWith(EMPTY_PROMPTS_UI); + expect(setters.setResourcesUi).toHaveBeenCalledWith(EMPTY_RESOURCES_UI); + expect(setters.setAppsUi).toHaveBeenCalledWith(EMPTY_APPS_UI); + expect(setters.setTasksUi).toHaveBeenCalledWith(EMPTY_TASKS_UI); + expect(setters.setLogsUi).toHaveBeenCalledWith(EMPTY_LOGS_UI); + expect(setters.setHistoryUi).toHaveBeenCalledWith(EMPTY_HISTORY_UI); + expect(setters.setNetworkUi).toHaveBeenCalledWith(EMPTY_NETWORK_UI); + }); + + it("restoreTabUiFromSnapshot falls back to EMPTY state for undefined tab values", () => { + const setters = { + setToolsUi: vi.fn(), + setPromptsUi: vi.fn(), + setResourcesUi: vi.fn(), + setAppsUi: vi.fn(), + setTasksUi: vi.fn(), + setLogsUi: vi.fn(), + setHistoryUi: vi.fn(), + setNetworkUi: vi.fn(), + }; + restoreTabUiFromSnapshot( + { + Tools: undefined, + Prompts: undefined, + Resources: undefined, + Apps: undefined, + Tasks: undefined, + Logs: undefined, + History: undefined, + Network: undefined, + }, + setters, + ); + expect(setters.setToolsUi).toHaveBeenCalledWith(EMPTY_TOOLS_UI); + expect(setters.setPromptsUi).toHaveBeenCalledWith(EMPTY_PROMPTS_UI); + expect(setters.setResourcesUi).toHaveBeenCalledWith(EMPTY_RESOURCES_UI); + expect(setters.setAppsUi).toHaveBeenCalledWith(EMPTY_APPS_UI); + expect(setters.setTasksUi).toHaveBeenCalledWith(EMPTY_TASKS_UI); + expect(setters.setLogsUi).toHaveBeenCalledWith(EMPTY_LOGS_UI); + expect(setters.setHistoryUi).toHaveBeenCalledWith(EMPTY_HISTORY_UI); + expect(setters.setNetworkUi).toHaveBeenCalledWith(EMPTY_NETWORK_UI); + }); }); diff --git a/core/auth/node/runner-interactive-oauth.ts b/core/auth/node/runner-interactive-oauth.ts index 77de2b17e..f68a32802 100644 --- a/core/auth/node/runner-interactive-oauth.ts +++ b/core/auth/node/runner-interactive-oauth.ts @@ -76,6 +76,7 @@ export async function runRunnerInteractiveOAuth( onError: (params) => { flowReject( new Error( + /* v8 ignore next -- params.error is a required non-null string, so the "OAuth error" fallback is unreachable */ params.error_description ?? params.error ?? "OAuth error", ), ); @@ -89,6 +90,7 @@ export async function runRunnerInteractiveOAuth( options.callbackTimeoutMs ?? DEFAULT_RUNNER_INTERACTIVE_OAUTH_TIMEOUT_MS; const waitForCallback = Promise.race([ flowDone.finally(() => { + /* v8 ignore next 3 -- timeoutId is assigned synchronously while the race is constructed, before flowDone can settle, so the undefined arm is unreachable */ if (timeoutId !== undefined) { clearTimeout(timeoutId); } diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index c029ccf0f..601fc35b3 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -2529,8 +2529,7 @@ export class InspectorClient extends InspectorClientEventTarget { ), { method: "completion/complete", - toolName: - ref.type === "ref/prompt" ? ref.name : ref.uri, + toolName: ref.type === "ref/prompt" ? ref.name : ref.uri, }, ); @@ -2941,6 +2940,7 @@ export class InspectorClient extends InspectorClientEventTarget { throw err; } const challenge = parseAuthChallengeFromError(err, context); + /* v8 ignore next 3 -- defensive: parseAuthChallengeFromError shares isAuthChallengeError's checks, so it always returns a truthy challenge once that guard passes */ if (!challenge) { throw err; } @@ -2994,7 +2994,10 @@ export class InspectorClient extends InspectorClientEventTarget { * Direct transports reconnect after token exchange so the live MCP session * picks up the new Bearer token (mirrors silent recovery reconnect). */ - async completeOAuthFlow(authorizationCode: string, iss?: string): Promise { + async completeOAuthFlow( + authorizationCode: string, + iss?: string, + ): Promise { await this.ensureOAuthManager().completeOAuthFlow(authorizationCode, iss); if (this.usesDirectAuthRecovery()) { await this.reconnectAfterAuthRecovery(); diff --git a/core/mcp/node/authChallengeFetch.ts b/core/mcp/node/authChallengeFetch.ts index e404b767a..00361a395 100644 --- a/core/mcp/node/authChallengeFetch.ts +++ b/core/mcp/node/authChallengeFetch.ts @@ -17,6 +17,7 @@ export function createAuthChallengeInterceptFetch( } const challenge = parseAuthChallengeFromResponse(response); + /* v8 ignore next 3 -- parseAuthChallengeFromResponse only returns undefined for non-401/403, which the status guard above already excludes */ if (!challenge) { return response; } diff --git a/core/mcp/oauthManager.ts b/core/mcp/oauthManager.ts index 880f8c440..63c8ca143 100644 --- a/core/mcp/oauthManager.ts +++ b/core/mcp/oauthManager.ts @@ -469,9 +469,10 @@ export class OAuthManager { challenge, tokens.scope, ); + const authorizationScopesJoined = enriched.authorizationScopes?.join(" "); + /* v8 ignore next -- authorizationScopes from enrichChallengeWithAuthorizationScopes is always a defined array here, so this fallback is unreachable */ const scopeForAuth = - enriched.authorizationScopes?.join(" ") ?? - enriched.requiredScopes?.join(" "); + authorizationScopesJoined ?? enriched.requiredScopes?.join(" "); if (!scopeForAuth?.trim()) { return false; } @@ -556,7 +557,8 @@ export class OAuthManager { storage?.getScope(serverUrl), grantedTokenScope, ); - const requiredFromChallenge = challenge.requiredScopes?.filter(Boolean) ?? []; + const requiredFromChallenge = + challenge.requiredScopes?.filter(Boolean) ?? []; const grantedSet = new Set(parseScopeString(previousScope)); const missingRequired = requiredFromChallenge.filter( (scope) => !grantedSet.has(scope), @@ -576,9 +578,12 @@ export class OAuthManager { }; } - private resolveEmaScopeForChallenge(challenge: AuthChallenge): string | undefined { + private resolveEmaScopeForChallenge( + challenge: AuthChallenge, + ): string | undefined { if (challenge.reason === "insufficient_scope") { const fromChallenge = challenge.requiredScopes?.join(" ").trim(); + /* v8 ignore next 3 -- authorizationScopes is empty only when requiredScopes is also empty, so this branch is unreachable */ if (fromChallenge) { return fromChallenge; } @@ -600,7 +605,8 @@ export class OAuthManager { challenge: AuthChallenge, options?: HandleAuthChallengeOptions, ): Promise { - const enriched = await this.enrichChallengeWithAuthorizationScopes(challenge); + const enriched = + await this.enrichChallengeWithAuthorizationScopes(challenge); if (enriched.reason === "insufficient_scope" && !options?.confirmedStepUp) { return { kind: "step_up_confirm", challenge: enriched }; @@ -644,8 +650,7 @@ export class OAuthManager { enriched.reason === "insufficient_scope" && enriched.authorizationScopes?.length ) { - this.pendingAuthorizationScope = - enriched.authorizationScopes.join(" "); + this.pendingAuthorizationScope = enriched.authorizationScopes.join(" "); } return { kind: "interactive", authorizationUrl, challenge: enriched }; } catch (error) { diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 3df7f60cd..77804a097 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -2042,10 +2042,8 @@ export function createRemoteApp( // deliberate side-effect of using `readMcpConfig` + full rewrite // here. const existing = current.mcpServers[originalId]; + /* v8 ignore next 5 -- the `in` check above guarantees this branch is unreachable; narrowing without the non-null assertion keeps TS happy and makes the contract explicit for future refactors. */ if (!existing) { - // The `in` check above guarantees this branch is unreachable; - // narrowing without the non-null assertion keeps TS happy and - // makes the contract explicit for future refactors. return c.json({ error: `Server '${originalId}' not found` }, 404); } // Split the existing entry into its SDK-only config (no Inspector- diff --git a/core/mcp/remote/remoteClientTransport.ts b/core/mcp/remote/remoteClientTransport.ts index b9fde2f85..82c86b4f0 100644 --- a/core/mcp/remote/remoteClientTransport.ts +++ b/core/mcp/remote/remoteClientTransport.ts @@ -103,7 +103,10 @@ function requestIdForMessage( function isConnectAuthChallenge( json: RemoteConnectResponse, -): json is Extract { +): json is Extract< + RemoteConnectResponse, + { ok: false; kind: "auth_challenge" } +> { return ( typeof json === "object" && json !== null && @@ -116,7 +119,10 @@ function isConnectAuthChallenge( function isConnectTransportError( json: RemoteConnectResponse, -): json is Extract { +): json is Extract< + RemoteConnectResponse, + { ok: false; kind: "transport_error" } +> { return ( typeof json === "object" && json !== null && @@ -212,7 +218,10 @@ export class RemoteClientTransport implements Transport { private eventStreamConsumeTask: Promise | null = null; private restartingEventStream = false; private closed = false; - private readonly sseResponseWaits = new Map(); + private readonly sseResponseWaits = new Map< + string | number, + SseResponseWait + >(); private readonly options: RemoteTransportOptions; private readonly config: import("../types.js").MCPServerConfig; @@ -287,10 +296,7 @@ export class RemoteClientTransport implements Transport { ): Promise { const recovery = this.options.authRecovery; if (!recovery) { - throw new AuthChallengeError( - challenge, - challenge.raw?.httpStatus ?? 401, - ); + throw new AuthChallengeError(challenge, challenge.raw?.httpStatus ?? 401); } const outcome = await recovery.handleAuthChallenge(challenge); @@ -536,7 +542,9 @@ export class RemoteClientTransport implements Transport { if (!res.ok) { const text = await res.text(); - throw new Error(`Remote auth-state update failed (${res.status}): ${text}`); + throw new Error( + `Remote auth-state update failed (${res.status}): ${text}`, + ); } const json = (await res.json()) as { ok?: boolean; error?: string }; From 74c6491b5212a16faf2b473fead2ffac416592e1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 3 Jul 2026 13:03:01 -0400 Subject: [PATCH 09/11] refactor(web): resolve Mantine/CSS rule violations in mid-session auth UI - App.tsx: replace the inline `style={{}}` on the re-auth banner wrapper with Mantine style props (pos/top/bg) plus a `.reauth-banner-bar` class for the z-index/box-shadow that have no Mantine shorthand. - App.tsx: extract the ~100-line inline `onAuthorize` and the inline `onCancel` step-up handlers into named `handleStepUpAuthorize`/`handleStepUpCancel`. - ReAuthBanner: move the flat `styles={{ root }}` (background/border) into a `reauth` Alert theme variant; replace the raw `` with ``. - StepUpAuthModal: extract the inline `onClick` wrapper into `handleAuthorize`. No behavior change; validate + integration green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DZyeWManYTvcXxjcrhxSQc --- clients/web/src/App.css | 10 + clients/web/src/App.tsx | 288 +++++++++--------- .../groups/ReAuthBanner/ReAuthBanner.tsx | 14 +- .../StepUpAuthModal/StepUpAuthModal.tsx | 3 +- clients/web/src/theme/Alert.ts | 13 + 5 files changed, 173 insertions(+), 155 deletions(-) diff --git a/clients/web/src/App.css b/clients/web/src/App.css index 111d40155..fc431065a 100644 --- a/clients/web/src/App.css +++ b/clients/web/src/App.css @@ -412,3 +412,13 @@ .server-drag-handle:active { cursor: grabbing; } + +/* + * Sticky re-auth banner bar. Positioning (pos/top) and background are set via + * Mantine props on the Box; z-index and box-shadow have no Mantine style-prop + * shorthand, so they live here. + */ +.reauth-banner-bar { + z-index: 200; + box-shadow: var(--mantine-shadow-sm); +} diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 96fac79ea..1999cd1cb 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -3434,20 +3434,154 @@ function App() { [pendingElicitations], ); + const handleStepUpAuthorize = async () => { + if (!pendingStepUp || stepUpAuthorizeInProgressRef.current) { + return; + } + const stepUp = pendingStepUp; + const client = inspectorClient; + if (!client) { + return; + } + + if (stepUp.enterpriseManaged) { + stepUpAuthorizeInProgressRef.current = true; + setPendingStepUp(null); + notifications.show({ + title: "Organization permissions", + message: emaStepUpInProgressMessage(), + color: "blue", + autoClose: 4000, + }); + try { + const outcome = await client.handleAuthChallenge(stepUp.challenge, { + confirmedStepUp: true, + }); + if (outcome.kind === "satisfied") { + await client.pushRemoteAuthState(); + notifications.show({ + title: "Permissions updated", + message: emaStepUpSuccessMessage({ + recoverySource: stepUp.source, + }), + color: "green", + autoClose: 5000, + }); + const retry = pendingStepUpRetryRef.current; + pendingStepUpRetryRef.current = null; + if (retry) { + await retry(); + } + return; + } + if (outcome.kind === "interactive") { + prepareOAuthRedirect({ + serverId: stepUp.serverId, + authKind: "step_up", + authorizationUrl: outcome.authorizationUrl, + authChallenge: outcome.challenge, + recoverySource: stepUp.source, + }); + return; + } + if (outcome.kind === "failed") { + const failureMessage = emaStepUpFailureMessage(outcome.error.message); + notifications.show({ + title: "Organization permissions", + message: failureMessage, + color: "red", + autoClose: 6000, + }); + switch (stepUp.source) { + case "tool": + setToolCallState({ + status: "error", + error: failureMessage, + }); + break; + case "prompt": + setGetPromptState((prev) => + prev + ? { ...prev, status: "error", error: failureMessage } + : prev, + ); + break; + case "resource": + setReadResourceState((prev) => + prev + ? { ...prev, status: "error", error: failureMessage } + : prev, + ); + break; + default: + break; + } + } + } finally { + stepUpAuthorizeInProgressRef.current = false; + } + return; + } + + stepUpAuthorizeInProgressRef.current = true; + prepareOAuthRedirect({ + serverId: stepUp.serverId, + authKind: "step_up", + authorizationUrl: stepUp.authorizationUrl, + authChallenge: stepUp.challenge, + recoverySource: stepUp.source, + }); + setPendingStepUp(null); + pendingStepUpRetryRef.current = null; + stepUpAuthorizeInProgressRef.current = false; + }; + + const handleStepUpCancel = () => { + const stepUp = pendingStepUpRef.current; + setPendingStepUp(null); + pendingStepUpRetryRef.current = null; + if (!stepUp) { + return; + } + const cancelled = "Authorization cancelled."; + switch (stepUp.source) { + case "tool": + setToolCallState({ status: "error", error: cancelled }); + break; + case "prompt": + setGetPromptState((prev) => + prev ? { ...prev, status: "error", error: cancelled } : prev, + ); + break; + case "resource": + setReadResourceState((prev) => + prev ? { ...prev, status: "error", error: cancelled } : prev, + ); + break; + case "app": + notifications.show({ + title: "Authorization cancelled", + message: cancelled, + color: "gray", + autoClose: 4000, + }); + break; + case "ambient": + break; + } + }; + return ( <> {reAuthBanner ? ( { - if (!pendingStepUp || stepUpAuthorizeInProgressRef.current) { - return; - } - const stepUp = pendingStepUp; - const client = inspectorClient; - if (!client) { - return; - } - - if (stepUp.enterpriseManaged) { - stepUpAuthorizeInProgressRef.current = true; - setPendingStepUp(null); - notifications.show({ - title: "Organization permissions", - message: emaStepUpInProgressMessage(), - color: "blue", - autoClose: 4000, - }); - try { - const outcome = await client.handleAuthChallenge( - stepUp.challenge, - { confirmedStepUp: true }, - ); - if (outcome.kind === "satisfied") { - await client.pushRemoteAuthState(); - notifications.show({ - title: "Permissions updated", - message: emaStepUpSuccessMessage({ - recoverySource: stepUp.source, - }), - color: "green", - autoClose: 5000, - }); - const retry = pendingStepUpRetryRef.current; - pendingStepUpRetryRef.current = null; - if (retry) { - await retry(); - } - return; - } - if (outcome.kind === "interactive") { - prepareOAuthRedirect({ - serverId: stepUp.serverId, - authKind: "step_up", - authorizationUrl: outcome.authorizationUrl, - authChallenge: outcome.challenge, - recoverySource: stepUp.source, - }); - return; - } - if (outcome.kind === "failed") { - const failureMessage = emaStepUpFailureMessage( - outcome.error.message, - ); - notifications.show({ - title: "Organization permissions", - message: failureMessage, - color: "red", - autoClose: 6000, - }); - switch (stepUp.source) { - case "tool": - setToolCallState({ - status: "error", - error: failureMessage, - }); - break; - case "prompt": - setGetPromptState((prev) => - prev - ? { ...prev, status: "error", error: failureMessage } - : prev, - ); - break; - case "resource": - setReadResourceState((prev) => - prev - ? { ...prev, status: "error", error: failureMessage } - : prev, - ); - break; - default: - break; - } - } - } finally { - stepUpAuthorizeInProgressRef.current = false; - } - return; - } - - stepUpAuthorizeInProgressRef.current = true; - prepareOAuthRedirect({ - serverId: stepUp.serverId, - authKind: "step_up", - authorizationUrl: stepUp.authorizationUrl, - authChallenge: stepUp.challenge, - recoverySource: stepUp.source, - }); - setPendingStepUp(null); - pendingStepUpRetryRef.current = null; - stepUpAuthorizeInProgressRef.current = false; - }} - onCancel={() => { - const stepUp = pendingStepUpRef.current; - setPendingStepUp(null); - pendingStepUpRetryRef.current = null; - if (!stepUp) { - return; - } - const cancelled = "Authorization cancelled."; - switch (stepUp.source) { - case "tool": - setToolCallState({ status: "error", error: cancelled }); - break; - case "prompt": - setGetPromptState((prev) => - prev ? { ...prev, status: "error", error: cancelled } : prev, - ); - break; - case "resource": - setReadResourceState((prev) => - prev ? { ...prev, status: "error", error: cancelled } : prev, - ); - break; - case "app": - notifications.show({ - title: "Authorization cancelled", - message: cancelled, - color: "gray", - autoClose: 4000, - }); - break; - case "ambient": - break; - } - }} + onAuthorize={handleStepUpAuthorize} + onCancel={handleStepUpCancel} /> ); diff --git a/clients/web/src/components/groups/ReAuthBanner/ReAuthBanner.tsx b/clients/web/src/components/groups/ReAuthBanner/ReAuthBanner.tsx index 99b8830ec..a314b3e3e 100644 --- a/clients/web/src/components/groups/ReAuthBanner/ReAuthBanner.tsx +++ b/clients/web/src/components/groups/ReAuthBanner/ReAuthBanner.tsx @@ -1,4 +1,4 @@ -import { Alert, Button, Group } from "@mantine/core"; +import { Alert, Button, Group, Text } from "@mantine/core"; export interface ReAuthBannerProps { message: string; @@ -14,19 +14,15 @@ export function ReAuthBanner({ return ( - {message} + + {message} + diff --git a/clients/web/src/components/groups/StepUpAuthModal/StepUpAuthModal.tsx b/clients/web/src/components/groups/StepUpAuthModal/StepUpAuthModal.tsx index c69c54236..e3e264e47 100644 --- a/clients/web/src/components/groups/StepUpAuthModal/StepUpAuthModal.tsx +++ b/clients/web/src/components/groups/StepUpAuthModal/StepUpAuthModal.tsx @@ -30,6 +30,7 @@ export function StepUpAuthModal({ }: StepUpAuthModalProps) { const additionalScopes = challenge ? stepUpAdditionalScopes(challenge) : []; const ema = enterpriseManaged === true; + const handleAuthorize = () => void onAuthorize(); return ( Cancel - + diff --git a/clients/web/src/theme/Alert.ts b/clients/web/src/theme/Alert.ts index 191486299..d2cad987d 100644 --- a/clients/web/src/theme/Alert.ts +++ b/clients/web/src/theme/Alert.ts @@ -4,4 +4,17 @@ export const ThemeAlert = Alert.extend({ defaultProps: { radius: "md", }, + styles: (_theme, props) => { + // Re-auth banner: body-colored surface with a red hairline border, used + // for the persistent mid-session re-authentication banner. + if (props.variant === "reauth") { + return { + root: { + backgroundColor: "var(--mantine-color-body)", + border: "1px solid var(--mantine-color-red-3)", + }, + }; + } + return { root: {} }; + }, }); From 14404bc405fb337d999bd62803a63b0bf1d085dc Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 3 Jul 2026 13:16:05 -0400 Subject: [PATCH 10/11] refactor: remove unsafe non-null assertions / double-cast in auth paths Address the TypeScript-safety review items on the mid-session auth work: - oauthManager: replace the unguarded `this.oauthConfig.storage!` assertions on the EMA re-mint/persist paths with a `requireStorage()` helper that throws a clear error; in `getOAuthState` reuse the already-narrowed local `storage`. - cli: `parseRunnerOAuthCallbackUrl` always returns a config, so make `callMethod`'s `callbackUrlConfig` (and the always-passed `cliAuthOverrides`) required instead of asserting them non-null at three call sites. - test-servers/resolve-config: drop the `as unknown as T` double cast; collect into a base-shaped array and do a single honest downcast at the return. No behavior change; validate + coverage green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DZyeWManYTvcXxjcrhxSQc --- clients/cli/src/cli.ts | 13 ++++++------- core/mcp/oauthManager.ts | 22 ++++++++++++++++++---- test-servers/src/resolve-config.ts | 8 +++++--- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index ddd92d10f..b5d8e42d7 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -71,8 +71,8 @@ async function callMethod( serverSettings: InspectorServerSettings | undefined, args: MethodArgs & { method: string }, clientConfig: ClientConfig, - cliAuthOverrides?: RunnerClientConfigOverrides, - callbackUrlConfig?: RunnerOAuthCallbackConfig, + cliAuthOverrides: RunnerClientConfigOverrides, + callbackUrlConfig: RunnerOAuthCallbackConfig, ): Promise { const __dirname = dirname(fileURLToPath(import.meta.url)); const packageJsonPath = join(__dirname, "../package.json"); @@ -93,9 +93,8 @@ async function callMethod( }; const redirectUrlProvider = new MutableRedirectUrlProvider(); if (isOAuthCapableServerConfig(serverConfig)) { - redirectUrlProvider.redirectUrl = formatRunnerOAuthRedirectUrl( - callbackUrlConfig!, - ); + redirectUrlProvider.redirectUrl = + formatRunnerOAuthRedirectUrl(callbackUrlConfig); environment.oauth = { storage: new NodeOAuthStorage(), navigation: new ConsoleNavigation(), @@ -258,14 +257,14 @@ async function callMethod( inspectorClient, serverConfig, redirectUrlProvider, - callbackUrlConfig!, + callbackUrlConfig, serverSettings, ); const result = await withCliAuthRecoveryRetry( inspectorClient, redirectUrlProvider, - callbackUrlConfig!, + callbackUrlConfig, serverSettings, runMethod, ); diff --git a/core/mcp/oauthManager.ts b/core/mcp/oauthManager.ts index 63c8ca143..878495fac 100644 --- a/core/mcp/oauthManager.ts +++ b/core/mcp/oauthManager.ts @@ -10,6 +10,7 @@ import { EMPTY_OAUTH_FLOW_STATE } from "../auth/types.js"; import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; import type { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js"; import { mcpAuth } from "../auth/mcpAuth.js"; +import type { OAuthStorage } from "../auth/storage.js"; import { parseOAuthState } from "../auth/utils.js"; import type { EnterpriseManagedAuthIdpConfig } from "../client/types.js"; import type { ClientConfig } from "../client/types.js"; @@ -104,6 +105,19 @@ export class OAuthManager { return this.params.getServerUrl(); } + /** + * Return the configured OAuth storage, throwing a clear error if it is + * absent. Used on the EMA re-mint/persist paths, which are only reached for + * a fully-configured OAuth-capable server (storage present). + */ + private requireStorage(): OAuthStorage { + const storage = this.oauthConfig.storage; + if (!storage) { + throw new Error("OAuth storage is required for this operation."); + } + return storage; + } + private async createOAuthProvider(): Promise { if ( !this.oauthConfig.storage || @@ -284,7 +298,7 @@ export class OAuthManager { requestedScope, ); if (scopeToPersist) { - await this.oauthConfig.storage!.saveScope( + await this.requireStorage().saveScope( this.getServerUrl(), scopeToPersist, ); @@ -422,7 +436,7 @@ export class OAuthManager { protocol: protocolFromOAuthConfig(this.oauthConfig), configuredScope: this.oauthConfig.scope, enterpriseManagedAuth: this.params.enterpriseManagedAuth, - storage: this.oauthConfig.storage!, + storage, flowState: this.oauthFlowState ?? undefined, }); } @@ -618,7 +632,7 @@ export class OAuthManager { const silent = await trySilentEmaAuth(config); if (silent.status === "success") { if (await this.checkAuthChallengeSatisfied(enriched)) { - const minted = await this.oauthConfig.storage!.getTokens( + const minted = await this.requireStorage().getTokens( this.getServerUrl(), ); const scopeToPersist = resolvePersistedScopeAfterGrant( @@ -626,7 +640,7 @@ export class OAuthManager { enriched.authorizationScopes?.join(" "), ); if (scopeToPersist) { - await this.oauthConfig.storage!.saveScope( + await this.requireStorage().saveScope( this.getServerUrl(), scopeToPersist, ); diff --git a/test-servers/src/resolve-config.ts b/test-servers/src/resolve-config.ts index 921e54e12..331bb549a 100644 --- a/test-servers/src/resolve-config.ts +++ b/test-servers/src/resolve-config.ts @@ -29,7 +29,7 @@ function resolvePresetRefs( type: "tool" | "resource" | "resourceTemplate" | "prompt", ): T[] { if (!refs || refs.length === 0) return []; - const result: T[] = []; + const result: Array<{ requiredScopes?: string[] }> = []; for (const entry of refs) { const items = Array.isArray(entry) ? entry : [entry]; for (const ref of items) { @@ -42,11 +42,13 @@ function resolvePresetRefs( const resolved = resolvePreset(type, presetName, ref.params); const arr = Array.isArray(resolved) ? resolved : [resolved]; for (const item of arr) { - result.push(mergeRequiredScopes(item as unknown as T, ref)); + result.push(mergeRequiredScopes(item, ref)); } } } - return result; + // The caller pairs `type` with the matching `T`, so each resolved preset is + // the requested definition kind; TS can't see that correspondence. + return result as T[]; } /** From 143e0d3f71b843f1705eff4c723a35757f48c0a0 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 3 Jul 2026 13:20:29 -0400 Subject: [PATCH 11/11] test+docs: add inspectorTabs test; document mid-session auth modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a dedicated `inspectorTabs.test.ts` (was relying only on incidental coverage from App.test.tsx) — covers the tab-id constants and both branches of the `isInspectorTabId` type guard; 100% per-file. - AGENTS.md: extend the `core/auth/` structure-tree entry to describe the new mid-session recovery modules (challenge parsing, scope union, shared OAuth UX copy, force-reauthorization) and the node loopback callback flow. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DZyeWManYTvcXxjcrhxSQc --- AGENTS.md | 8 +++-- clients/web/src/utils/inspectorTabs.test.ts | 38 +++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 clients/web/src/utils/inspectorTabs.test.ts diff --git a/AGENTS.md b/AGENTS.md index 1b84fa8fd..12500c434 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,9 +22,13 @@ inspector/ │ ├── tui/ # TUI client (Ink + React, tsup bundle) │ ├── launcher/ # Shared launcher (relative imports into sibling build/ outputs) ├── core/ # Shared core code (no package.json — consumed via the `@inspector/core` vite alias) -│ ├── auth/ # OAuth: state machine, providers, discovery, storage +│ ├── auth/ # OAuth: state machine, providers, discovery, storage; +│ │ # mid-session recovery (challenge.ts WWW-Authenticate +│ │ # parsing, scopes.ts SEP-2350 scope union, oauthUx.ts +│ │ # shared copy, mcpAuth.ts force-reauthorization) │ │ ├── browser/ # Browser-side OAuth (sessionStorage, BrowserNavigation) -│ │ ├── node/ # Node-side OAuth (NodeOAuthStorage, OAuthCallbackServer) +│ │ ├── node/ # Node-side OAuth (NodeOAuthStorage, OAuthCallbackServer, +│ │ │ # runner-interactive-oauth loopback callback flow) │ │ └── remote/ # Remote OAuth storage (delegates to the remote server) │ ├── json/ # JSON utilities and parameter/argument conversion │ ├── logging/ # Silent pino logger singleton diff --git a/clients/web/src/utils/inspectorTabs.test.ts b/clients/web/src/utils/inspectorTabs.test.ts new file mode 100644 index 000000000..ebd7b6961 --- /dev/null +++ b/clients/web/src/utils/inspectorTabs.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { + INSPECTOR_SERVERS_TAB, + INSPECTOR_TAB_IDS, + isInspectorTabId, +} from "./inspectorTabs"; + +describe("inspectorTabs", () => { + it("names the Servers tab, which is not a liftable inspector tab", () => { + expect(INSPECTOR_SERVERS_TAB).toBe("Servers"); + expect(INSPECTOR_TAB_IDS).not.toContain(INSPECTOR_SERVERS_TAB); + }); + + it("enumerates the liftable inspector tabs", () => { + expect(INSPECTOR_TAB_IDS).toEqual([ + "Apps", + "Tools", + "Prompts", + "Resources", + "Tasks", + "Logs", + "History", + "Network", + ]); + }); + + it("isInspectorTabId returns true for every enumerated tab", () => { + for (const tab of INSPECTOR_TAB_IDS) { + expect(isInspectorTabId(tab)).toBe(true); + } + }); + + it("isInspectorTabId returns false for non-inspector tab values", () => { + expect(isInspectorTabId(INSPECTOR_SERVERS_TAB)).toBe(false); + expect(isInspectorTabId("")).toBe(false); + expect(isInspectorTabId("Bogus")).toBe(false); + }); +});