Guided-auth removal, OAuth smoke testing, and TUI/CLI support (#1531)

* refactor(web): update OAuth E2E tests to use new authentication method

- Replaced `runGuidedAuth` with `authenticate` in the InspectorClient OAuth end-to-end tests to support quick authentication with CIMD pre-registration.
- Updated comments to reflect the change in authentication method and its support for HTTP test metadata URLs.
- Ensured that the tests continue to validate the expected authorization URL after the change.

Additionally, imported `ensureCimdClientRegistration` in the OAuthManager to streamline client registration during the authentication process.

* Guided-auth removal, OAuth smoke testing, and TUI/CLI support

* Added CLI OAuth runner flags tests

* Added clarifying comment on port 0 binding

* README notes on TUI oauth callback port, prettier fixes for CLI tests

* CIMD validation uses inline like EMA IdP

* Fixed TUI test failures from merge

* test: bring 5 OAuth files over the 90 per-file coverage gate

The guided-auth-removal work added/touched several files that fell below
the repo's >=90 per-file coverage gate (npm run coverage). CI doesn't run
the gate (it's local-only), so validate + test:integration passed while
these gaps slipped through. Bring them all back over the line:

- core/auth/node/runner-oauth-callback.ts: test bad-URL/non-http-scheme
  throws, no-port->80 default, and IPv6 hostname bracketing. Annotate the
  provably-dead guards (empty host, out-of-range port, String(err)
  fallback, pathname "/" fallback) -- all rejected by new URL() upstream --
  with justified v8 ignore comments instead of lowering the gate.
- core/auth/cimd.ts: test the AS-doesn't-support-CIMD early return.
- core/react/useClientSettingsDraft.ts: test the functional-updater path,
  the stale-onChange-after-close prev===null guard, and the
  timer-fires-after-close value===null guard.
- ClientSettingsForm.tsx: test the CIMD checkbox toggle and CIMD URL clear.
- clients/tui/src/utils/oauthDisplay.ts: test static/dcr registration
  kinds, the expired IdP session, and the configured-scope/empty-scope
  branches of formatScopes.

npm run coverage now passes (exit 0) across web, cli, tui, launcher.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: cliffhall <cliff@futurescale.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bob Dickinson 2026-06-29 12:35:51 -07:00 • committed by GitHub
commit 13dafe457b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 5702 additions and 4444 deletions

View file

@ -0,0 +1,856 @@
# Inspector V2 — Enterprise-Managed Authorization (EMA / XAA)
### [Brief](README.md) | [V2 Scope](v2_scope.md) | [Servers file](v2_servers_file.md) | [Auth hardening](v2_auth_hardening.md) | [Mid-session auth](v2_auth_mid_session.md) | [Smoke testing](v2_auth_smoke_testing.md)
Tracks [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509).
## Summary
Add support for [Enterprise-Managed Authorization](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization) (EMA, also referred to as XAA / ID-JAG in client implementations). EMA extends the existing OAuth flow so an enterprise IdP (OIDC) can authenticate the client once; any MCP resource authorization server (AS) configured to trust that IdP can then be accessed with minimal or no user prompting.
Inspector v2 already has OAuth infrastructure (`core/auth/`, `core/mcp/oauthManager.ts`, per-server OAuth fields in `~/.mcp-inspector/mcp.json` — see [Servers file](v2_servers_file.md)). Phases 1–2 (web connect via `authenticate()`) are **implemented** in `core/auth/ema/` and documented below. TUI and CLI load `client.json` and wire OAuth for HTTP/SSE servers; dedicated Client Settings UX and full interactive EMA on CLI remain follow-ups. VS Code reference material in the appendix informs remaining work.
## Normative references
- [EMA extension spec](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization)
- [MCP blog: Enterprise-managed auth](https://blog.modelcontextprotocol.io/posts/enterprise-managed-auth/) (announced stable June 18, 2026)
- GitHub issue: [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509)
## Goals
- Support EMA for HTTP MCP servers, aligned with the [MCP EMA extension spec](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization). EMA wire and orchestration live in `core/auth/ema/` — the v1 TypeScript SDK does not expose EMA as a named API.
- **Preserve existing standard OAuth behavior** for non-EMA servers — no regressions to current connect or configured-credential flows.
- Reuse existing inspector OAuth configuration and secret storage where possible.
- Work across web, CLI, and TUI clients via shared `core/` auth logic.
## Non-goals
- v1 or v1.5/main backport (v2 only; see issue label).
## EMA spec and SDK audit
_Audited June 2026 against the [EMA extension spec](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization), VS Code's shipped schema, and the TypeScript SDK docs._
### Protocol (EMA extension)
- Extension id: `io.modelcontextprotocol/enterprise-managed-authorization` (stable as of June 2026).
- **Problem solved:** per-server interactive OAuth consent is replaced by enterprise IdP policy — user authenticates to the corporate IdP once; the client obtains an identity assertion and exchanges it for MCP resource tokens without redirecting to each MCP authorization server.
- **Client flow (three legs):**
1. **IdP SSO** — obtain an OpenID ID Token for the user via OIDC at the enterprise IdP (the MCP protocol also allows SAML assertions; **out of scope for initial EMA implementation**).
2. **ID-JAG mint** — RFC 8693 token exchange at the IdP: present the ID Token + IdP client credentials → receive an Identity Assertion JWT Authorization Grant (ID-JAG).
3. **Resource token** — RFC 7523 JWT bearer grant at the MCP resource authorization server: present the ID-JAG + **resource AS** client credentials → receive the MCP access token.
- **No browser redirect to the MCP AS** on the happy path — the MCP AS trusts the IdP-issued assertion.
- Underlying standards: RFC 8693, RFC 7523, RFC 8707 (resource indicators), RFC 9728 (protected resource metadata).
### TypeScript SDK (implemented)
Inspector depends on **`@modelcontextprotocol/sdk` v1.x** only (`^1.29.0` in root `package.json`). Standard OAuth and EMA both build on that package — there is **no** `@modelcontextprotocol/client` v2 dependency in the tree today.
| Concern | Package / module |
| ------- | ---------------- |
| Standard OAuth | v1 `@modelcontextprotocol/sdk/client/auth.js` via `BaseOAuthClientProvider` (`core/auth/providers.ts`) |
| EMA leg 1 (IdP OIDC) | v1 SDK: `startAuthorization`, `exchangeAuthorization`, `discoverAuthorizationServerMetadata` — `core/auth/ema/idpOidc.ts` |
| EMA legs 2–3 (ID-JAG + resource token) | **Local wire** in `core/auth/ema/wire.ts` (RFC 8693 token exchange + RFC 7523 JWT bearer); reuses v1 SDK for AS discovery, `selectClientAuthMethod`, `OAuthTokensSchema` |
| EMA orchestration | `core/auth/ema/emaFlow.ts` — `mintEmaResourceTokens`, silent connect, connect/callback completion |
| EMA 401 / transport | `core/auth/ema/transportProvider.ts` — `EmaTransportOAuthProvider` wraps v1 `BaseOAuthClientProvider`; re-runs legs 2–3 on expiry, IdP redirect on missing ID Token |
| JWT expiry helpers | `core/auth/ema/jwt.ts` — `jwtExpiresAtMs`, `isJwtExpired` (exp claim only; no signature verification) |
| Leg 1 in-flight PKCE key | `core/auth/ema/storage.ts` — `idpOAuthStorageKey(issuer)` → `ema-idp:{issuer}` in per-server `servers` map (storage key only; not an OAuth `state` param prefix) |
| Wire constants | `core/auth/ema/constants.ts` — grant/type URNs + `IDP_OIDC_SCOPES` for leg 1 |
**EMA is not in the v1 SDK** as a named feature (`CrossAppAccess`, ID-JAG helpers, etc.). Inspector implements the protocol in `core/auth/ema/` instead.
#### v2 `@modelcontextprotocol/client` (not used; future option)
The v2 client package (`CrossAppAccessProvider`, `discoverAndRequestJwtAuthGrant`, `exchangeJwtAuthGrant`) implements the same legs 2–3 wire format internally. It is **not** imported anywhere today. Possible future paths:
| Option | When |
| ------ | ---- |
| **Swap legs 2–3 to v2 Layer-2 helpers** | Replace `wire.ts` calls with `discoverAndRequestJwtAuthGrant` / `exchangeJwtAuthGrant`; keep v1 transport + `EmaTransportOAuthProvider` |
| **Full v2 transport (Path B)** | v2 `StreamableHTTPClientTransport` + `CrossAppAccessProvider` for EMA servers only — requires v2 client stack; does **not** drop into v1 transport today |
**Why v1 + local wire for the first slice:** single SDK surface, custom `fetchFn` threading, inspector-owned scope/audience resolution (`resourceContext.ts`), and v1 `OAuthClientProvider` adapter for 401 re-auth — without pulling in alpha v2 client bundle surface.
**Watch items (unchanged):**
- **401 re-auth** — EMA connections re-run legs 2–3 (leg 1 only if ID Token missing/expired); do not fall back to standard resource OAuth redirect.
- **Future v2 adoption** — when migrating to v2 wholesale, prefer SDK Layer-2 helpers or `CrossAppAccessProvider` over maintaining duplicate wire in `wire.ts`.
## Configuration
Config shape matches VS Code (verified against [`mcpConfiguration.ts`](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts)). **Two credential planes** — do not merge them:
| Plane | Scope | What it identifies |
| ----- | ----- | ------------------ |
| **IdP (client / tenant)** | Global — one per inspector install or session | Inspector's OIDC client at the enterprise IdP (legs 1–2) |
| **Resource AS (per-server)** | Per MCP server entry | OAuth client trusted by the protected resource (leg 3) |
### 1. Client / tenant IdP credentials (global)
One IdP registration shared across all EMA-enabled servers. VS Code setting key: `mcp.enterpriseManagedAuth.idp`. Shape:
```ts
interface EnterpriseManagedAuthIdpConfig {
issuer: string;
clientId: string;
clientSecret: string;
}
```
All three fields are the **IdP OIDC client** credentials used in leg 2 (ID-JAG mint). `clientSecret` is sensitive — never persist it in `mcp.json` (server catalog).
#### Near-term persistence: `client.json` (+ `/api/storage/client`)
Until **client profiles** (see below) land, install-level client config — starting with `enterpriseManagedAuth` — is stored in **`~/.mcp-inspector/storage/client.json`** (same generic storage API as other install-level blobs):
| Store | API (web) | On disk | Purpose |
| ----- | --------- | ------- | ------- |
| **`client`** | `GET/POST/DELETE /api/storage/client` | `~/.mcp-inspector/storage/client.json` | **Config** — IdP credentials, later client identity/caps |
**Do not** put IdP credentials in the OAuth store or per-server `mcp.json`. **Do not** use environment variables for IdP config — all clients read/write the same `client.json` file.
**Runtime auth state** (standard OAuth tokens, PKCE, **and** EMA runtime state: cached IdP ID Token / refresh token, leg-1 in-flight PKCE under `ema-idp:{issuer}`, and per-server resource tokens tagged `enterpriseManaged: true` when minted via EMA legs 2–3) uses the existing **`OAuthStorage`** interface (`core/auth/storage.ts`) — whatever adapter each client already passes to `InspectorClient`. ID-JAG is **not** cached — legs 2–3 re-mint on each connect or 401 refresh. EMA does **not** mandate a specific backing store; it extends the serialized auth state that adapter already persists.
No EMA-specific migration of web OAuth persistence is required for the initial ship — sessionStorage-backed web sessions work for EMA today. **Follow-up:** shared file-backed OAuth via `RemoteOAuthStorage` → `/api/storage/oauth` so web matches CLI/TUI (see §Follow-up work).
| Client | Config (`client.json`) | Runtime auth state (`OAuthStorage`) |
| ------ | ---------------------- | ----------------------------------- |
| **Web (today)** | `RemoteStorage` adapter → `/api/storage/client` | `BrowserOAuthStorage` → sessionStorage |
| **Web (target)** | same | `RemoteOAuthStorage` → `/api/storage/oauth` → `~/.mcp-inspector/storage/oauth.json` |
| **CLI / TUI** | `NodeClientStorage` (file adapter) | `NodeOAuthStorage` → `oauth.json` |
On-disk shape (initial):
```json
{
"enterpriseManagedAuth": {
"idp": {
"issuer": "https://idp.example.com",
"clientId": "inspector-idp-client-id",
"clientSecret": "inspector-idp-client-secret"
}
}
}
```
When the user turns off **Enable** in Client Settings, credentials are retained but EMA is inactive install-wide:
```json
{
"enterpriseManagedAuth": {
"enabled": false,
"idp": {
"issuer": "https://idp.example.com",
"clientId": "inspector-idp-client-id",
"clientSecret": "inspector-idp-client-secret"
}
}
}
```
`isEnterpriseManagedAuthEnabled()` / `getActiveEnterpriseManagedAuthIdp()` in `core/client/types.ts` treat `enabled: false` as inactive while preserving the IdP fields for re-enable without re-entry.
Later fields (client identity, capability toggles) extend this document until client profiles replace it.
Core provides:
- Types: `ClientConfig`, `EnterpriseManagedAuthIdpConfig` in `core/client/types.ts` (imported by `core/mcp/types.ts` for server/oauth shapes).
- Load/save helpers with JSON parse/validate — no `process.env` reads inside `InspectorClient`.
- File path: `getStoreFilePath(getDefaultStorageDir(), "client")` (reuse `store-io.ts` primitives).
Tests write fixture `client.json` files directly or via `/api/storage/client` in integration tests (same as oauth test helpers).
#### Wiring into `InspectorClient`
Pass IdP config explicitly on the options surface — do **not** put it inside per-server `options.oauth` (that block already means resource OAuth client credentials).
Near-term shape on `InspectorClientOptions`:
```ts
/** Active IdP credentials — omitted when install-level EMA is disabled. */
enterpriseManagedAuth?: { idp: EnterpriseManagedAuthIdpConfig };
/**
* Full install-level EMA config from client.json (including when disabled).
* Used to produce friendly errors when a server expects EMA but IdP is inactive.
*/
installEnterpriseManagedAuth?: ClientConfig["enterpriseManagedAuth"];
```
Callers load `client.json` once at session startup, then pass into each `InspectorClient` construction:
```ts
const clientConfig = await loadClientConfig(); // client.json
new InspectorClient(serverConfig, {
...opts,
...(getActiveEnterpriseManagedAuthIdp(clientConfig) && {
enterpriseManagedAuth: { idp: getActiveEnterpriseManagedAuthIdp(clientConfig)! },
}),
...(clientConfig.enterpriseManagedAuth && {
installEnterpriseManagedAuth: clientConfig.enterpriseManagedAuth,
}),
});
```
**Issuer validation:** `parseHttpUrl()` in `core/auth/utils.ts` validates IdP issuer URLs (and other HTTP URLs in EMA paths) with clear, user-facing error labels — e.g. rejects `https;//idp.example.com` at connect time and during `client.json` parse.
Future: nest under **`InspectorClientProfile`** loaded from the profile store; `client.json` migrates into that mechanism.
**IdP session state** (cached ID Token from leg 1, optional refresh token, optional `idTokenExpiresAt`) lives in the **OAuth store** via `OAuthStorage`. Use a **store-root `idpSessions` map keyed by issuer** (global across servers — one IdP per install), separate from per-server `servers[url]` resource OAuth state. Leg-1 in-flight PKCE/metadata uses synthetic server key `ema-idp:{issuer}` in the `servers` map:
```ts
interface ServerOAuthState {
// ... existing fields ...
enterpriseManaged?: boolean; // true when resource tokens came from EMA legs 2–3
}
interface IdpSessionState {
idToken?: string;
refreshToken?: string;
idTokenExpiresAt?: number; // epoch ms
}
interface OAuthStoreState {
servers: Record<string, ServerOAuthState>; // per-server resource OAuth + leg-1 PKCE at ema-idp:{issuer}
idpSessions?: Record<string, IdpSessionState>; // issuer → IdP session cache
}
```
Extend `OAuthStorage` / store methods accordingly. IdP **credentials** stay in `client.json`; only runtime tokens belong in `idpSessions`. Per-server `ServerOAuthState` may include `enterpriseManaged: true` when EMA legs 2–3 persist resource tokens (`saveTokens(..., { enterpriseManaged: true })`). **Sign out** clears `idpSessions` for the configured issuer and removes tagged EMA resource entries from the shared OAuth blob — standard OAuth server entries are not cleared. All adapters (`BrowserOAuthStorage`, `NodeOAuthStorage`, `RemoteOAuthStorage`) serialize the same extended shape through their existing backends.
#### Later: client profiles
Inspector is designing a **client profile** mechanism for install-level client configuration (identity, capabilities, IdP/EMA settings, etc.). That work is not settled yet. When it ships:
- Migrate `client.json` content into the profile store (`clientSecret` in the OS keychain, not plaintext).
- `client.json` becomes legacy or an export format.
Web **Client Settings** (see §UX) is the near-term editor for IdP credentials; client profiles may subsume or extend that dialog later.
Near-term IdP config can be edited in the **web Client Settings** dialog (primary path), by hand in `client.json`, via **`POST /api/storage/client`** (tests/automation), or through the file adapters on CLI/TUI — no env-var path.
### 2. Per-server MCP config (`~/.mcp-inspector/mcp.json`)
Extend the existing per-server `oauth` block already used for standard OAuth ([Servers file](v2_servers_file.md)). When `enterpriseManaged` is true, the server-level OAuth fields mean **resource authorization server** credentials, not IdP credentials:
```json
{
"mcpServers": {
"my-enterprise-server": {
"url": "https://mcp.example.com/mcp",
"oauth": {
"enterpriseManaged": true,
"clientId": "resource-as-client-id",
"clientSecret": "resource-as-client-secret",
"scopes": "tools:read tools:execute"
}
}
}
}
```
| Field | When `enterpriseManaged: false` | When `enterpriseManaged: true` |
| ----- | ------------------------------- | ------------------------------ |
| `oauth.clientId` | OAuth client id (existing behavior) | **Resource AS** client id trusted by the protected resource |
| `oauth.clientSecret` | OAuth client secret (existing behavior) | **Resource AS** client secret (keychain-backed, same as today) |
| `oauth.scopes` | OAuth scopes (existing behavior) | Scopes for the resource token request |
| `oauth.enterpriseManaged` | omitted / `false` | `true` — route to EMA flow instead of standard authorization-code OAuth |
Types to extend in `core/mcp/types.ts`:
- `StoredMCPServer.oauth.enterpriseManaged?: boolean`
- `InspectorServerSettings` — lift `enterpriseManaged` alongside existing flat `oauthClientId` / `oauthClientSecret` / `oauthScopes` fields.
Normalizers, server list read/write, and API wire shape follow the same patterns as existing OAuth fields ([Servers file](v2_servers_file.md)).
### UX (near-term)
#### Per-server: `enterpriseManaged` (in scope for #1509)
Add **`enterpriseManaged`** to server settings data and surface it in the web UI:
- **Where:** `ServerSettingsForm` OAuth section (`clients/web/src/components/groups/ServerSettingsForm/`) — same panel as existing OAuth client id, client secret, and scopes.
- **Control:** checkbox **"Enterprise-managed authorization"**, with help text: connect via the configured enterprise IdP instead of interactive OAuth to the MCP authorization server; OAuth fields below are resource authorization server credentials.
- **Behavior:** toggling updates `InspectorServerSettings.enterpriseManaged` and persists to `mcp.json` under `oauth.enterpriseManaged` on save. When unchecked, standard OAuth behavior is unchanged.
- **Visibility:** OAuth and EMA apply to remote HTTP transports (SSE / streamable-http) only. **Implemented:** the entire **OAuth Settings** accordion section is hidden for stdio servers (`isOAuthCapableServerType` in `core/mcp/config.ts`; `ServerSettingsForm` receives `serverType` from `ServerSettingsModal` / `App.tsx`). Stdio entries may still carry legacy `oauth` fields on disk; they are not used at connect time.
CLI/TUI can read/write `enterpriseManaged` via catalog/config file without dedicated UX in the first slice; web form is the primary editor.
#### Client / IdP settings (web — implemented)
Install-level IdP credentials are edited in **Client Settings**, separate from per-server **Server Settings**. Same modal pattern as server settings: collapsible accordion sections, controlled draft, debounced persist on change, flush on close.
- **Entry point:** gear icon in the top bar (`ViewHeader`), immediately left of the theme toggle. Available connected and disconnected.
- **Components:** `ClientSettingsModal` + `ClientSettingsForm` (`clients/web/src/components/groups/ClientSettingsModal/`, `ClientSettingsForm/`). Storybook: `Groups/ClientSettingsModal`.
- **Section (initial):** **Enterprise-Managed Authorization**
- **Enable** checkbox — when off, persisted config keeps `enterpriseManagedAuth.idp` but sets `enabled: false` so credentials are not lost; when on (or `enabled` omitted), EMA is active install-wide.
- When enabled: **Issuer**, **Client ID**, **Client secret** (Inspector's registration with the enterprise IdP).
- Shows the OAuth **redirect URI** to register with the IdP (`{origin}/oauth/callback`), derived from `window.location.origin`.
- **Persistence:** `POST /api/storage/client` via `saveClientConfigRemote` / `parseClientConfig` (`core/client/remote.ts`, `core/client/config-parse.ts`). Same on-disk shape as hand-edited `client.json` (`enterpriseManagedAuth.idp`, optional `enabled`).
- **Runtime wiring:** `App.tsx` loads config at startup (`loadClientConfigRemote`) and passes **active** IdP via `getActiveEnterpriseManagedAuthIdp()` plus full install config via `installEnterpriseManagedAuth` into each `InspectorClient`. After a successful save, in-memory `clientConfig` updates so the next connect uses new IdP values without a full page reload.
- **Draft hook:** `useClientSettingsDraft` (`core/react/`, alongside `useSettingsDraft`) — mirrors server settings draft behavior; skips debounced persist while EMA is enabled but issuer or client id is still empty (avoids validation errors mid-edit).
- **IdP sign-in state (implemented):** when an issuer is configured, **Client Settings** shows install-level IdP session status derived from `OAuthStorage.idpSessions[issuer]`:
- **Signed in** — cached ID Token is present and not expired (`core/auth/ema/idpSession.ts` + `isJwtExpired`).
- **Session expired** — cached ID Token exists but is expired (next EMA connect will prompt for IdP login).
- **Not signed in** — no cached IdP session (next EMA connect will open IdP login).
- State is refreshed when the modal opens (`useEmaIdpLoginState` in `core/react/`).
- **Sign out (implemented):** **Sign out** button when a cached IdP session exists (signed in or expired). Calls `clearEmaIdpSession(storage, issuer)` in **`core/auth/ema/idpSession.ts`** — a single core entry point; clients do **not** pass a server catalog. It:
1. Clears `idpSessions[issuer]`
2. Clears leg-1 in-flight state at `servers["ema-idp:{issuer}"]`
3. Calls `OAuthStorage.clearEnterpriseManagedResourceServers()` — scans the shared OAuth blob and removes `servers[url]` entries where `enterpriseManaged === true` (set when EMA legs 2–3 saved tokens via `saveTokens(url, tokens, { enterpriseManaged: true })` in `emaFlow.ts` / `EmaTransportOAuthProvider`). Standard OAuth entries in the same blob are **not** cleared. Untagged legacy EMA tokens (saved before tagging landed) are not removed until the next EMA connect re-saves with the tag.
The next connect to a cleared EMA server has no cached access token, so a **401** triggers leg 1 (IdP login) via `authenticate()`.
- **Web OAuth store singleton:** web uses `getBrowserOAuthStorage()` (`core/auth/browser/storage.ts`) so Client Settings sign-out updates the same in-memory Zustand store as the active `InspectorClient` (not a separate `BrowserOAuthStorage` instance per consumer).
**Future (not implemented):** explicit **Sign out** may additionally invoke the IdP's OIDC **end-session** / logout endpoint (RP-initiated logout) so the IdP SSO cookie is cleared — not just inspector-local IdP/resource token state. Today sign-out is **local-only** (clear `idpSessions`, tagged EMA resource entries, and leg-1 PKCE); the IdP may still treat the browser as signed in and skip the login prompt on the next authorize redirect until the IdP session expires or the user signs out at the IdP.
**Copy / UX notes:** User-facing text explains enterprise-managed authorization in plain language (org IdP sign-in vs each server's OAuth login). It does **not** reference protocol jargon (`leg 1`, resource AS, etc.) or storage filenames in the form. Per-server EMA enablement and MCP-server OAuth credentials remain in **Server Settings** (see below).
Alternative paths (CLI/TUI, automation, hand-edit):
- Hand-edit `~/.mcp-inspector/storage/client.json`
- **`POST /api/storage/client`** from tests or scripts
Do not store IdP credentials in the OAuth store — only **runtime OAuth/EMA state** (tokens, PKCE, IdP session cache, etc.) belongs there, via whichever `OAuthStorage` adapter the client uses.
CLI/TUI do not have a dedicated Client Settings dialog. They **load** `client.json` at startup (`--client-config`, default `~/.mcp-inspector/storage/client.json`) and pass IdP/CIMD settings into `InspectorClient`. Edit install config via the web **Client Settings** dialog, hand-edit the file, or **`POST /api/storage/client`**. Default OAuth callback for TUI/CLI: `http://127.0.0.1:6276/oauth/callback` — see `clients/tui/README.md` and `clients/cli/README.md`.
#### Connect errors when IdP is missing or disabled (web — implemented)
When the user connects to a server with `oauth.enterpriseManaged: true` but install-level IdP is not active, `OAuthManager.getEmaFlowConfig()` throws `EmaClientNotConfiguredError` (`core/auth/ema/clientConfigError.ts`) with a reason-specific message:
| Reason | When | User-facing guidance |
| ------ | ---- | -------------------- |
| `not_configured` | No IdP block, or issuer/client id missing | Open Client Settings, enable Enterprise IdP, set issuer, client ID, and secret |
| `disabled` | `enterpriseManagedAuth.enabled === false` with IdP credentials retained | Enterprise IdP is turned off in Client Settings — re-enable and retry |
`installEnterpriseManagedAuth` on `InspectorClientOptions` supplies the full install config so the error can distinguish disabled from never configured. Web surfaces this in connect, OAuth auth, and post-callback reconnect paths (`App.tsx`): toast title `Cannot connect to "<server name>"`, message from the error, `autoClose: false` (stays until dismissed).
#### Connection Info — OAuth snapshot (web — implemented)
`InspectorClient.getOAuthState()` (`core/auth/connection-state.ts`) assembles `OAuthConnectionState` from storage and config only — no network. Used by **Connection Info** (`ConnectionInfoContent`, `oauthDetailsFromConnectionState.ts`) for the active server:
- **Protocol** — `standard` or `ema`
- **Authorized** — whether a usable access token exists in storage
- **Client ID** — static, DCR, or CIMD registration kind (see `clientRegistrationKind` on connection state)
- **IdP session** (EMA only) — `none` / `logged_in` / `expired` from `idpSessions[issuer]`
- **Auth URL** — cached from an in-flight or completed OAuth flow (`OAuthFlowState`), when present
- **Scopes** — configured vs granted
- **Access token** — `OAuthAccessTokenField`: copy (raw), decode JWT in place (`core/auth/ema/jwt.ts` helpers); multi-line wrap with segment-aware breaking for JWTs
In-flight OAuth flow state uses `OAuthFlowState` / `getOAuthFlowState()` / `getOAuthFlowStep()`. `getOAuthState()` is the persisted connection snapshot; flow state is separate and ephemeral.
### Leg 1 — IdP OIDC (settled)
Leg 1 is **OIDC authorization-code login** against the enterprise IdP using credentials from `client.json` (`issuer`, `clientId`, `clientSecret`). Discover IdP endpoints from the issuer (OpenID Provider Metadata). Exchange the authorization code at the IdP token endpoint; persist the resulting **ID Token** (and refresh token if issued) in `OAuthStorage`.
**Reuse existing OAuth client infrastructure** — leg 1 is not a new auth stack. It uses the same per-client mechanisms already wired for standard MCP resource OAuth (`OAuthManager`, redirect/callback, PKCE, token exchange). Only the target changes: IdP endpoints and `client.json` credentials instead of the resource authorization server and per-server `oauth.*` fields.
| Client | Leg 1 mechanism (same stack as standard OAuth in that client) |
| ------ | ------------------------------------------------------------- |
| **Web** | Browser redirect via `navigation`; callback at `/oauth/callback`; `InspectorClient.completeOAuthFlow(code)`; `BrowserOAuthStorage` |
| **TUI / CLI** | `OAuthCallbackServer` opens local callback; system browser for authorize URL; `completeOAuthFlow(code)`; `NodeOAuthStorage` |
**Silent connect:** if a valid cached ID Token exists for the configured issuer, skip the interactive redirect and proceed to leg 2.
**SAML:** defined in the MCP EMA protocol but **out of scope** for initial EMA implementation.
### Implementation notes (leg 1 + OAuthManager)
Leg 1 reuses the existing OAuth redirect/callback/PKCE machinery but **targets the IdP**, not the resource authorization server. `OAuthManager` today discovers resource AS metadata and builds authorize URLs against the resource AS — the EMA branch must parameterize the same flow with IdP endpoints (from issuer OpenID Provider Metadata) and IdP credentials from `client.json`.
**Web callback disambiguation:** web uses a single `/oauth/callback` path for both resource OAuth and IdP OIDC. Pending server id is stashed in sessionStorage before redirect; **protocol** (standard vs EMA) comes from `oauth.enterpriseManaged` on that server, not from the OAuth `state` query param. The `state` param is a 64-char hex CSRF token (`generateOAuthState()`); `parseOAuthState()` extracts `authId` for fetch-log restore on callback.
**Auth protocol:** `AuthProtocol` (`standard` | `ema`) from server/client config. EMA and standard OAuth both use `InspectorClient.authenticate()` / `completeOAuthFlow()`.
**Scope for leg 2:** `resourceContext.resolveEmaScopes()` — prefer configured per-server `oauth.scopes`; else join `resourceMetadata.scopes_supported` when present; else omit. Do **not** fall back to IdP `scopes_supported`.
**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 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
When connecting to an HTTP server with `oauth.enterpriseManaged: true`:
1. Validate protected resource metadata (`resource`, `authorization_servers`).
2. Branch from standard OAuth (VS Code: `mainThreadMcp` — see appendix §A.3).
3. Load tenant IdP config from `client.json` (via `InspectorClientOptions.enterpriseManagedAuth`, loaded at session startup).
4. Obtain or reuse IdP ID Token (leg 1 — `idpOidc.ts`, v1 SDK OIDC redirect/callback).
5. Run leg 2 — `wire.exchangeIdJag()` (RFC 8693 at IdP) → ID-JAG (scopes from `resourceContext.ts`; see §Implementation notes).
6. Run leg 3 — `wire.redeemIdJagForAccessToken()` (RFC 7523 at resource AS) with per-server `oauth.clientId` / `oauth.clientSecret` → MCP access token.
7. Connect with the resource access token via the existing v1 transport; persist tokens via `OAuthStorage.saveTokens(serverUrl, tokens, { enterpriseManaged: true })`.
When `enterpriseManaged` is absent or false, existing standard OAuth path is unchanged.
**Client capability:** declare EMA support in the MCP `initialize` request when connecting EMA-enabled servers:
```json
{
"capabilities": {
"extensions": {
"io.modelcontextprotocol/enterprise-managed-authorization": {}
}
}
}
```
Inspector touchpoints to extend:
- `core/client/types.ts` — `ClientConfig`, `EnterpriseManagedAuthIdpConfig`, `getActiveEnterpriseManagedAuthIdp`, `isEnterpriseManagedAuthEnabled`
- `core/mcp/types.ts` — `enterpriseManaged` on server oauth shape; `InspectorClientOptions.enterpriseManagedAuth`, `installEnterpriseManagedAuth`
- `core/client/` (or `core/storage/`) — `loadClientConfig` / `saveClientConfig`; `NodeClientStorage`; remote adapter for `/api/storage/client`
- `core/auth/utils.ts` — `parseHttpUrl` (issuer and EMA URL validation)
- `core/auth/connection-state.ts` — `buildOAuthConnectionState`, `OAuthConnectionState`
- `core/auth/storage.ts` + `core/auth/store.ts` — `OAuthStorage` / `OAuthStoreState`: store-root `idpSessions`; `ServerOAuthState.enterpriseManaged` tag; `SaveTokensOptions`; `clearEnterpriseManagedResourceServers()`; all adapters pick up the extended shape automatically
- `core/auth/ema/` — `idpOidc.ts` (leg 1), `wire.ts` (legs 2–3), `emaFlow.ts` (orchestration + tagged `saveTokens`), `transportProvider.ts` (401 re-auth + tagged `saveTokens`), `resourceContext.ts`, `idpSession.ts` (`getEmaIdpLoginState`, `clearEmaIdpSession`), `jwt.ts`, `storage.ts`, `constants.ts`, `clientConfigError.ts` (`EmaClientNotConfiguredError`)
- `core/mcp/oauthManager.ts` — branch on `enterpriseManaged`; EMA connect via `emaFlow`; `getOAuthState()`; `createOAuthProvider()` returns `EmaTransportOAuthProvider` for EMA servers (401 re-auth); standard OAuth unchanged
- `core/mcp/inspectorClient.ts` — declare EMA extension in `initialize` capabilities when `enterpriseManaged`; `getOAuthState()`
- `core/react/useEmaIdpLoginState.ts` — Client Settings IdP session status; calls `clearEmaIdpSession` on sign-out (no catalog wiring)
- `core/auth/browser/storage.ts` — `getBrowserOAuthStorage()` singleton (web)
- `clients/web` — **Client Settings** modal; **Connection Info** OAuth snapshot; friendly EMA connect toasts; load client config at startup; `ServerSettingsForm` OAuth section (HTTP/SSE only); web callback flow tagging for IdP vs resource OAuth
- `clients/cli`, `clients/tui` — load `client.json` at startup; pass into `InspectorClient`; leg 1 via same `OAuthCallbackServer` + `NodeOAuthStorage` stack as TUI standard OAuth today
### Clients
| Client | Notes |
| ------ | ----- |
| Web | **First** — implement and test EMA here; **Client Settings** for IdP + per-server EMA checkbox (HTTP/SSE only); connect via `authenticate()` on 401 or explicit auth; leg 1 via browser redirect + `/oauth/callback` (same path as standard OAuth; disambiguate pending flow) |
| TUI | Follow web; extend existing OAuth auth tab (`clients/tui/src/App.tsx`, `AuthTab`) for EMA legs 2–3; leg 1 via existing `OAuthCallbackServer` flow |
| CLI | Follow web/TUI; same Node OAuth stack as TUI when interactive IdP login is required |
### Implementation order (settled)
**Web first** for development and testing. Core types, `OAuthManager` EMA branch, and `OAuthStorage` extensions live in `core/` and are client-agnostic; the shipped #1509 path is web connect plus Client Settings / Server Settings UX. TUI/CLI load install config and support OAuth connect on HTTP/SSE; CLI interactive callback and terminal-native Client Settings remain follow-ups.
Design decisions for EMA are complete. Remaining work is the phased plan and checklist below (client profiles are a separate, later track — not a blocker for `#1509`).
### Phased implementation plan
#### Phase 1 — Foundation (core, no EMA connect yet)
1. Add types: `ClientConfig`, `EnterpriseManagedAuthIdpConfig`, `enterpriseManaged`, `InspectorClientOptions.enterpriseManagedAuth`.
2. Add `enterpriseManaged` to server types, normalizers, and `mcp.json` read/write.
3. Implement `client.json` load/save (`NodeClientStorage` + remote adapter for `/api/storage/client`).
4. Extend `OAuthStorage` / store schema: store-root `idpSessions`; `ServerOAuthState.enterpriseManaged` tag; `clearEnterpriseManagedResourceServers()`.
5. Implement EMA wire + orchestration in `core/auth/ema/` (v1 SDK for discovery/OIDC; local `wire.ts` for legs 2–3).
#### Phase 2 — Web EMA connect (first end-to-end path)
6. Wire web to load `client.json` at session startup → active IdP + `installEnterpriseManagedAuth`.
7. Web UX: **Client Settings** dialog for IdP (`ClientSettingsModal` / `/api/storage/client`); IdP sign-in state + sign-out; `enterpriseManaged` checkbox in `ServerSettingsForm` OAuth section; friendly connect errors; Connection Info OAuth snapshot.
8. `OAuthManager` EMA branch: leg 1 (`idpOidc.ts`) → legs 2–3 (`wire.ts` / `emaFlow.ts`) → tagged `saveTokens(..., { enterpriseManaged: true })` → connect.
9. `InspectorClient`: declare EMA extension in `initialize` when `enterpriseManaged`. EMA 401 re-auth via `EmaTransportOAuthProvider` in `OAuthManager.createOAuthProvider()` (re-run legs 2–3, not resource OAuth redirect).
10. Web callback: disambiguate IdP OIDC vs resource OAuth pending flows at `/oauth/callback`.
#### Phase 3 — Integration tests
11. Tests: EMA wire/orchestration unit tests and mock IdP/AS integration. **Implemented:** `parseClientConfig`, `clientSettingsValues`, `idpSessions` storage, `enterpriseManaged` server-list round-trip, OAuth `state` parsing, `clientConfigError`, `connection-state`, OAuthManager EMA not-configured paths, keychain secret migration on server-id rename (`servers-route.test.ts`), **Phase 3b automated tests** (§Phase 3b test plan — Layers 1–3). **Manual staging:** live xaa.dev EMA verified (§Staging validation). **Optional follow-up:** `RemoteOAuthStorage` EMA E2E variant, 401 re-auth stretch case.
#### 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.
## Implementation checklist
### Design (complete)
- [x] Audit EMA spec and TypeScript SDK surfaces
- [x] Finalize inspector config shape (server + tenant IdP)
- [x] Choose near-term implementation: v1 `@modelcontextprotocol/sdk` + local EMA wire (`core/auth/ema/`), v1 transport unchanged
- [x] Choose near-term persistence: `client.json` (IdP config) + extend existing `OAuthStorage` per client (OAuth/EMA runtime state)
- [x] Leg 1 mechanism: OIDC authorization-code flow via existing per-client OAuth stack (web redirect/callback; TUI/CLI `OAuthCallbackServer`)
- [x] Implementation order: web client first for EMA development and testing
### Phase 1 — Foundation
- [x] Implement EMA core: `core/auth/ema/` (leg 1 via v1 SDK OIDC; legs 2–3 via `wire.ts`; orchestration in `emaFlow.ts`)
- [x] Add types: `ClientConfig`, `EnterpriseManagedAuthIdpConfig`, `enterpriseManaged`, `InspectorClientOptions.enterpriseManagedAuth`
- [x] Add `enterpriseManaged` to server types, normalizers, and `mcp.json` read/write
- [x] Implement `client.json` load/save (`NodeClientStorage` + `/api/storage/client` remote adapter)
- [x] Extend `OAuthStorage` / OAuth store schema: `idpSessions`, `ServerOAuthState.enterpriseManaged`, `clearEnterpriseManagedResourceServers()`
### Phase 2 — Web EMA connect
- [x] Wire web to load client config at session startup → active IdP + `installEnterpriseManagedAuth`
- [x] Web UX: **Client Settings** dialog for IdP credentials (`ClientSettingsModal`, gear icon in header, `/api/storage/client`)
- [x] Web UX: IdP sign-in state + **Sign out** in Client Settings (`idpSession.ts`, `useEmaIdpLoginState`, `clearEmaIdpSession` + tagged resource cleanup)
- [x] Web UX: `enterpriseManaged` checkbox and OAuth settings section in `ServerSettingsForm` (hidden for stdio)
- [x] Web UX: friendly connect errors when EMA server but IdP missing/disabled (`EmaClientNotConfiguredError`, persistent toast)
- [x] Web UX: Connection Info OAuth snapshot (`getOAuthState`, access token copy/decode)
- [x] Integrate EMA routing in `OAuthManager` (branch on `enterpriseManaged`; leg 1 IdP OIDC; legs 2–3 via `wire.ts` / `emaFlow.ts`)
- [x] Declare EMA extension in `initialize` when connecting with `enterpriseManaged`
- [x] EMA 401 re-auth: re-run legs 2–3 (leg 1 only if ID Token expired/missing) via `EmaTransportOAuthProvider`
- [x] Web: disambiguate IdP vs resource OAuth at `/oauth/callback` (shared callback; `completeOAuthFlow` branches on `enterpriseManaged`; protocol from server config; OAuth `state` is 64-char hex CSRF token)
### Phase 3 — Integration tests
- [x] **Manual staging validation** — full EMA connect against live xaa.dev (see §Staging validation). Confirms legs 1–3 and web UX outside CI.
- [x] **Automated integration tests** — mock IdP + mock resource AS + composable protected-resource server (see §Phase 3b test plan). Live xaa.dev is **not** required for CI.
- [ ] *(optional)* `RemoteOAuthStorage` EMA E2E variant — same happy path via `/api/storage/oauth` (shared-storage follow-up).
- [ ] *(optional)* 401 re-auth stretch — invalidate resource token, assert legs 2–3 re-run.
### Phase 4 — Other clients
- [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
### Later
- [ ] **Remove Zustand from OAuth persistence** — direct read/write of OAuth blob; see §Follow-up work
- [ ] **Web shared OAuth store** — `RemoteOAuthStorage` / `oauth.json` for web + CLI + TUI parity; see §Follow-up work
- [ ] Client profile persistence (migrate from `client.json`; may extend or replace web Client Settings)
- [ ] Optional: adopt `@modelcontextprotocol/client` v2 Layer-2 helpers for legs 2–3 (replace `wire.ts`) or full v2 transport for EMA
- [ ] Optional: IdP **end-session** / RP-initiated logout on explicit **Sign out** (today sign-out clears inspector-local IdP session and tagged EMA resource tokens only; IdP browser SSO may remain active)
---
## Staging validation (manual — verified)
Full end-to-end EMA has been exercised manually against live **xaa.dev** (June 2026). This validates the #1509 web path outside CI.
### Topology
| Role | Service | Inspector config |
| ---- | ------- | ---------------- |
| **Enterprise IdP** (legs 1–2) | `https://idp.xaa.dev` | **Client Settings** — issuer, requesting-app client id/secret |
| **Resource authorization server** (leg 3) | `https://auth.resource.xaa.dev` (xaa.dev resource AS) | Per-server **Server Settings** — resource test client id/secret, EMA enabled, scopes |
| **Protected MCP resource** | Composable test server (`test-servers/configs/xaa-ema-http.json`) — `protected-resource` mode, local streamable-http | Server catalog entry URL must match registered resource identifier (`http://localhost:8080/` — use `localhost`, not `127.0.0.1`) |
Registration on xaa.dev: composable test server registered as a **resource server**; its AS points at xaa.dev (`authorizationServers` / JWKS in `xaa-ema-http.json`). IdP and resource AS are distinct hosts on xaa.dev.
### Flow exercised
1. Client Settings — Enterprise IdP configured and enabled.
2. Server Settings — `enterpriseManaged` on; resource AS client credentials from xaa.dev resource registration.
3. Connect — IdP login when needed → ID-JAG mint → resource access token → MCP `initialize` with EMA extension capability.
4. Reconnect / 401 — silent or re-auth paths; Connection Info shows EMA OAuth snapshot.
### Known-good fixture
`test-servers/configs/xaa-ema-http.json` — protected-resource OAuth pointing at `https://auth.resource.xaa.dev`. Use with the composable test server CLI; align Inspector server URL and xaa.dev resource identifier.
---
## Follow-up work (not blocking #1509 close-out)
These items came out of EMA staging and apply beyond EMA. They are **not** required to ship EMA on web but should be tracked.
### Remove Zustand from OAuth persistence
Today `OAuthStorage` is backed by a **Zustand** `persist` store (`core/auth/store.ts`) with adapters for sessionStorage (`BrowserOAuthStorage`), file (`NodeOAuthStorage` via `file-storage.ts`), and HTTP (`RemoteOAuthStorage` via `remote-storage.ts`). The Zustand layer wraps a simple `{ servers, idpSessions }` blob and adds:
- `{ state, version }` envelope on file/remote adapters (see [Servers file](v2_servers_file.md) — intentionally avoided for `mcp.json`)
- A second in-memory copy and async persist semantics that complicate sign-out, callback rebuild, and multi-consumer sharing (`getBrowserOAuthStorage()` singleton exists partly to paper over this)
**Direction:** refactor `OAuthStorage` implementations to read/write the serialized shape **directly** (file I/O, `/api/storage/oauth`, or in-memory for tests) without Zustand. Keep the `OAuthStorage` interface stable for `InspectorClient` / `OAuthManager`.
### Shared file-backed OAuth state (web + CLI + TUI)
Today:
| Client | OAuth runtime store |
| ------ | ------------------- |
| **Web** | `BrowserOAuthStorage` → **sessionStorage** (tab-local; lost on new tab / callback page load relies on same tab) |
| **CLI / TUI** | `NodeOAuthStorage` → `~/.mcp-inspector/storage/oauth.json` |
`RemoteOAuthStorage` already exists (`core/auth/remote/storage-remote.ts`) and talks to **`GET/POST/DELETE /api/storage/oauth`** on the local Hono backend — the same generic storage API that persists to disk under `~/.mcp-inspector/storage/`. Integration tests use it (`inspectorClient-oauth-remote-storage-e2e.test.ts`).
**Direction:** wire the **web app** through `RemoteOAuthStorage` (via `environmentFactory.ts` / `App.tsx`) instead of `BrowserOAuthStorage`, so web, CLI, and TUI share one **`oauth.json`** when using the default local backend. Benefits: IdP session and EMA resource tokens survive browser refresh; sign-out in Client Settings and connect use the same store without a sessionStorage singleton; EMA testing matches how CLI/TUI will behave in Phase 4.
**Migration:** one-time import or “start fresh” for existing sessionStorage OAuth blobs; document that enabling shared storage is the default for local dev.
---
## Phase 3b test plan (automated integration)
Goal: CI tests that prove EMA legs 1–3 through `InspectorClient` / `OAuthManager` without calling live xaa.dev. Manual staging (§Staging validation) already covers live IdP/AS; automation encodes regressions.
**Status (June 2026):** Layers 1–3 implemented and green in CI (`npm run test:integration`). Optional items (RemoteOAuthStorage variant, 401 re-auth) remain open.
### Principles
- **No live xaa.dev in default CI** — flaky, credential-bound, network-dependent. Optional `describe.skipIf` / manual job later.
- **Reuse existing patterns** — `TestServerHttp` + `createExternalResourceOAuthTestServerConfig`, `inspectorClient-oauth-e2e.test.ts`, `inspectorClient-oauth-remote-storage-e2e.test.ts`, `jose` for JWTs (`test-server-protected-resource.test.ts`).
- **Mock at HTTP boundary** — `fetchFn` injection on `InspectorClient` / `EmaFlowConfig` so `wire.ts` and `idpOidc.ts` run real code against local mock servers.
- **Single auth path** — `authenticate()` / `trySilentEmaAuth` / `completeOAuthFlow()` (same as standard OAuth).
### Test layers
#### Layer 1 — `wire.ts` + `emaFlow.ts` (unit / near-unit) — **done**
**Files:** `clients/web/src/test/core/auth/ema/wire.test.ts`, `emaFlow.test.ts`
| Case | Status |
| ---- | ------ |
| `exchangeIdJag` happy path | ✅ Mock IdP token endpoint; assert RFC 8693 request shape |
| `exchangeIdJag` IdP error | ✅ Asserts leg-2 error message |
| `redeemIdJagForAccessToken` happy path | ✅ Mock resource AS token endpoint |
| `mintEmaResourceTokens` end-to-end | ✅ In-memory `OAuthStorage` + mocked fetch; `resourceContext` bypass |
| Missing ID Token | ✅ |
| Missing resource secret | ✅ |
| `trySilentEmaAuth` saves tagged tokens | ✅ `enterpriseManaged: true` on `saveTokens` |
| `trySilentEmaAuth` returns false without IdP session | ✅ |
Uses `vi.fn()` fetch — no listening ports. Runs in `unit` project.
#### Layer 2 — Mock IdP + mock resource AS (local HTTP) — **done**
**File:** `clients/web/src/test/integration/mcp/ema-mock-servers.ts`
Ephemeral `node:http` servers (port `0` on `127.0.0.1`):
1. **Mock IdP** — `/.well-known/oauth-authorization-server` + `openid-configuration`, `/token` (RFC 8693 token exchange → ID-JAG). Fixture client id/secret: `EMA_MOCK_IDP_*` constants.
2. **Mock resource AS** — `/.well-known/oauth-authorization-server`, `/jwks`, `/token` (JWT bearer grant accepts ID-JAG, returns RS256 access token). Fixture client id/secret: `EMA_MOCK_RESOURCE_*` constants.
Shared helper `minimalOAuthAsMetadata()` satisfies SDK OAuth metadata schema. `createMockIdToken()` seeds leg-1 IdP session in storage. Topology mirrors xaa.dev staging — see comment block in helper + `test-servers/configs/xaa-ema-http.json`.
#### Layer 3 — `InspectorClient` EMA connect E2E (integration) — **done**
**File:** `clients/web/src/test/integration/mcp/inspectorClient-ema-e2e.test.ts`
| Step | Status |
| ---- | ------ |
| 1 | ✅ Protected-resource `TestServerHttp` + `createExternalResourceOAuthTestServerConfig` (mock AS JWKS/issuers) |
| 2 | ✅ Mock IdP + mock resource AS on ephemeral ports |
| 3 | ✅ `InspectorClient` with `enterpriseManaged: true`, fixture IdP + resource credentials |
| 4 | ✅ Cached ID Token seeded in `NodeOAuthStorage` (leg 1 shortcut) |
| 5 | ✅ `connect()` → `getOAuthState()` `protocol: "ema"`, `authorized: true`, `ema.idpSession: "logged_in"` |
| 6 | ✅ Second connect reuses silent path; persisted tokens tagged `enterpriseManaged: true` |
Runs under `integration` project; `test-servers:build` prerequisite (existing). Transport: `streamable-http` only (EMA is HTTP-only).
**Storage variant (optional — not implemented):** same flow with `RemoteOAuthStorage` + `createRemoteApp` tmp dir — proves EMA tokens persist via `/api/storage/oauth` (feeds shared-storage follow-up).
#### Layer 4 — Protected-resource discovery — **partial**
**File:** `clients/web/src/test/integration/mcp/test-server-protected-resource.test.ts`
Existing coverage: `xaa-ema-http.json` config load, `ExternalAccessTokenValidator`, protected-resource metadata advertises external `authorization_servers`. Layer 3 E2E exercises `discoverEmaResourceContext` implicitly on connect. Dedicated `discoverEmaResourceContext` unit test not added (optional).
### Out of scope for Phase 3b
- Live xaa.dev (covered by §Staging validation)
- Browser / Playwright UI tests
- TUI/CLI (Phase 4)
### Suggested implementation order
1. ~~Layer 1 (`wire` / `emaFlow` unit tests)~~ — **done**
2. ~~Layer 2 mock servers helper~~ — **done**
3. ~~Layer 3 single happy-path E2E (`streamable-http`)~~ — **done**
4. Layer 3 storage variant with `RemoteOAuthStorage` — optional, aligns with shared-storage refactor.
5. 401 re-auth case — connect, invalidate resource token, assert legs 2–3 re-run (stretch).
### Done criteria for Phase 3b checklist
- [x] Layer 1 tests merged
- [x] Layer 3 happy-path integration test green in CI
- [x] Document in test file how mock topology maps to xaa.dev staging (comment block + link to `xaa-ema-http.json`)
- [ ] *(optional)* `RemoteOAuthStorage` EMA E2E variant
- [ ] *(optional)* 401 re-auth stretch case
---
## Appendix A — VS Code implementation reference
The following is **reference material only**, adapted from a working document in `/Users/bob/Documents/Auth0/vscode-xaa-implementation-reference.md`. It describes Microsoft's VS Code TypeScript implementation of enterprise-managed MCP auth (XAA / ID-JAG). **Do not treat it as inspector design** — use it to locate patterns and source files when implementing [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509).
### A.1 Changelist that introduced this
- PR: https://github.com/microsoft/vscode/pull/318067
- Merge commit: https://github.com/microsoft/vscode/commit/2d95154af0f50db2a41ceee431acfc6d79638aab
- Release notes section (1.123): https://code.visualstudio.com/updates/v1_123#_enterprise-managed-mcp-authentication-preview
### A.2 Where the feature is configured
#### A.2.1 `mcp.json` server-level opt-in (`oauth.enterpriseManaged`)
- MCP schema (`enterpriseManaged` under HTTP oauth):
- https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts
- MCP types (`enterpriseManaged?: boolean`):
- https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/mcp/common/mcpTypes.ts
#### A.2.2 Tenant-level IdP config (`mcp.enterpriseManagedAuth.idp`)
- Setting key + TS interface (`issuer`, `clientId`, `clientSecret`):
- https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts
- Registration of setting in configuration registry (policy-backed, app-scoped, hidden):
- https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts
- Policy metadata for enterprise deployment:
- https://github.com/microsoft/vscode/blob/main/build/lib/policies/policyData.jsonc
### A.3 Routing logic: where VS Code chooses XAA vs normal OAuth
- MCP HTTP launch data sends enterprise-managed flag to main thread:
- https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/common/extHostMcp.ts
- Main routing decision (`if (authDetails.enterpriseManaged) ...`):
- https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/browser/mainThreadMcp.ts
In `mainThreadMcp`, enterprise-managed path does these implementation-specific checks:
1. Validate resource metadata has `resource`.
2. Read resource AS from `authorization_servers[0]`.
3. Compute XAA scopes from challenge/resource metadata (not IdP scopes_supported fallback).
4. Resolve issuer from `mcp.enterpriseManagedAuth.idp.issuer`.
5. Request shared provider via `createOrGetXaaProvider(issuer)`.
6. Resolve resource client secret from secret store using resource + resource client id key.
7. Call session acquisition with `audience` and `resource` in options.
All of this is implemented in:
- https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/browser/mainThreadMcp.ts
### A.4 Provider lifecycle and host plumbing
#### A.4.1 Authentication service API additions
- New `createOrGetXaaProvider(issuer)` API contract:
- https://github.com/microsoft/vscode/blob/main/src/vs/workbench/services/authentication/common/authentication.ts
- Browser implementation (one provider per issuer id):
- https://github.com/microsoft/vscode/blob/main/src/vs/workbench/services/authentication/browser/authenticationService.ts
#### A.4.2 Main thread registration path
- Creates XAA provider from issuer metadata; prefers configured IdP credentials over cached:
- https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/browser/mainThreadAuthentication.ts
#### A.4.3 Ext host registration path
- New RPC methods in protocol (`$registerXaaAuthProvider`, enterpriseManaged metadata shape, prompt for resource secret):
- https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/common/extHost.protocol.ts
- Ext host registration of XAA provider (`$registerXaaAuthProvider`):
- https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/common/extHostAuthentication.ts
- Node ext host binds XAA mixin to node provider implementation:
- https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/node/extHostAuthentication.ts
### A.5 Core XAA implementation class (the most important file)
- Main implementation:
- https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/common/extHostXaaAuthProvider.ts
Key implementation methods to mirror in your app:
1. `getSessions(...)`
- Silent path first.
- Reuses persisted IdP session.
- Re-mints resource token silently (no prompts).
2. `createSession(...)`
- Interactive path.
- Ensures IdP session, then mints resource token.
3. `_mintResourceToken(...)`
- Runs leg 2 + leg 3 logic.
- Resolves resource `client_id` from explicit config first, then from JAG claim fallback.
- Handles per-resource `client_secret` retrieval/prompt/caching semantics.
4. `_exchangeForIdJag(...)`
- IdP token exchange request construction and execution.
5. `_discoverResourceTokenEndpoint(...)`
- Fetches metadata for audience/resource AS.
6. `_redeemAtResource(...)`
- Resource-side JWT bearer redemption for access token.
7. Caching helpers
- `cacheKey(...)`, `isExpired(...)`, in-memory resource token cache keyed by `(resource, scopes)`.
### A.6 OAuth wire-format helpers (shared utilities)
- OAuth constants + body builders used by XAA flow:
- https://github.com/microsoft/vscode/blob/main/src/vs/base/common/oauth.ts
Functions to copy conceptually:
1. `buildIdJagExchangeBody(...)`
2. `buildResourceRedemptionBody(...)`
3. JWT claims parsing helpers used to infer `client_id` from assertion
Tests for body semantics:
- https://github.com/microsoft/vscode/blob/main/src/vs/base/test/common/oauth.test.ts
### A.7 Secret storage model in this implementation
#### A.7.1 Resource client secret prompt + persistence
- Prompt and persistence (`$promptForResourceClientSecret`):
- https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/browser/mainThreadAuthentication.ts
Behavior details implemented:
1. `undefined` means user canceled (do not cache as "no secret").
2. Empty string means explicit "no secret" (valid for public clients).
3. Non-empty values stored in OS secret storage.
#### A.7.2 Secret keying strategy
- Secret key utility is in MCP types (`mcpOAuthClientSecretStorageKey`):
- https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/mcp/common/mcpTypes.ts
The feature intentionally keys by **resource indicator + resource client id**, not only client id.
### A.8 Session option surface expansion (`audience`)
The implementation introduces `audience` as part of auth session option shapes so XAA provider can receive resource AS context via standard session APIs.
- Internal service types (`IAuthenticationCreateSessionOptions`, `IAuthenticationGetSessionsOptions`, provider session options):
- https://github.com/microsoft/vscode/blob/main/src/vs/workbench/services/authentication/common/authentication.ts
- Proposed VS Code API file:
- https://github.com/microsoft/vscode/blob/main/src/vscode-dts/vscode.proposed.authSessionAudience.d.ts
- Proposal registry inclusion:
- https://github.com/microsoft/vscode/blob/main/src/vs/platform/extensions/common/extensionsApiProposals.ts
### A.9 Tests added in this changelist
- XAA helper tests (`cacheKey`, `isExpired`, `IDP_SCOPES`):
- https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/test/browser/extHostXaaAuthProvider.test.ts
- OAuth wire-format tests for ID-JAG and redemption bodies:
- https://github.com/microsoft/vscode/blob/main/src/vs/base/test/common/oauth.test.ts
- Integration test shape update for new RPC method:
- https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/test/browser/mainThreadAuthentication.integrationTest.ts
### A.10 Third-party libraries used for this feature
No new third-party runtime dependency appears to be introduced by this changelist.
The implementation relies on:
1. Existing VS Code auth/mcp infrastructure.
2. Web/platform primitives (`fetch`, `URLSearchParams`).
3. Protocol standards (RFC 8693, RFC 7523, RFC 8707, ID-JAG draft semantics).
### A.11 Practical architecture notes reusable in inspector
If implementing this pattern in inspector, mirror these design choices:
1. Split credentials into two planes:
- IdP client credentials (tenant/global)
- Resource AS client credentials (per resource/per client id)
2. Cache provider by issuer:
- One XAA provider per issuer, shared across resources.
3. Keep silent and interactive paths separate:
- `getSessions` style silent path must not prompt.
- `createSession` style path can prompt.
4. Use `audience` explicitly:
- Do not overload `resource` for both meanings.
5. Treat cancel as cancel:
- Distinguish `undefined` (cancel) from `""` (explicit no secret).
6. Key secrets with resource context:
- Prevent cross-resource secret confusion.
### A.12 Commit-pinned references (stable snapshot)
If you need a frozen reference (won't drift as `main` changes), use commit-pinned paths under:
- https://github.com/microsoft/vscode/tree/2d95154af0f50db2a41ceee431acfc6d79638aab
For example:
- https://github.com/microsoft/vscode/blob/2d95154af0f50db2a41ceee431acfc6d79638aab/src/vs/workbench/api/common/extHostXaaAuthProvider.ts
- https://github.com/microsoft/vscode/blob/2d95154af0f50db2a41ceee431acfc6d79638aab/src/vs/workbench/api/browser/mainThreadMcp.ts
- https://github.com/microsoft/vscode/blob/2d95154af0f50db2a41ceee431acfc6d79638aab/src/vs/workbench/api/browser/mainThreadAuthentication.ts

View file

@ -0,0 +1,185 @@
# Inspector V2 — Authorization hardening (MCP 2026-07-28)
### [Brief](README.md) | [V2 Scope](v2_scope.md) | [Mid-session auth](v2_auth_mid_session.md) | [EMA / XAA](v2_auth_ema.md) | [Smoke testing](v2_auth_smoke_testing.md)
Align Inspector v2 with the **authorization hardening** in the upcoming MCP **`2026-07-28`** specification release — six SEPs that tighten OAuth/OIDC client behavior for MCP's single-client, many-server deployment pattern.
## Summary
The [2026-07-28 release candidate](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) adds six authorization SEPs. All are **OAuth client** requirements. Inspector implements OAuth through a mix of SDK delegation (connect-time standard OAuth via `auth()`) and local code (EMA wire, storage, web callback, mid-session recovery).
**Strategy:** Upgrade to the v2 TypeScript SDK (`@modelcontextprotocol/client`) when its auth-hardening PRs land. Do **not** reimplement SDK OAuth logic in Inspector — delegate connect-time standard OAuth to the SDK and wire the gaps (callback parameters, storage shape, client type). **Mid-session authorization** is a separate, near-term track ([Mid-session auth](v2_auth_mid_session.md)); this doc covers connect-time and storage hardening that mid-session builds on.
## Normative references
- [MCP authorization (draft — 2026-07-28 RC target)](https://modelcontextprotocol.io/specification/draft/basic/authorization)
- [MCP authorization (2025-11-25 — current stable)](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)
- [2026-07-28 RC announcement — Authorization Hardening](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/#authorization-hardening)
- [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207) — OAuth 2.0 Authorization Server Issuer Identification (`iss` parameter)
- [RFC 6750 §3.1](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1) — Bearer token `insufficient_scope`
- TypeScript SDK tracking: [modelcontextprotocol/typescript-sdk](https://github.com/modelcontextprotocol/typescript-sdk) issues `#2197`–`#2201`, `#2198`, `#2256`; PRs `#2265`, `#2271`, `#2272`
## Relationship to mid-session auth
[Mid-session auth](v2_auth_mid_session.md) ships in the **near term**. Overlap:
| 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-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 |
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.
## The six SEPs
| SEP | Requirement | Primary owner |
| --- | ------------- | ------------- |
| **[SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468)** | Validate `iss` on authorization responses per RFC 9207; reject mix-up across authorization servers | Client (+ SDK reference impl) |
| **[SEP-837](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837)** | Declare OIDC `application_type` during Dynamic Client Registration (`native` vs `web`) | Client (+ SDK DCR body) |
| **[SEP-2352](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352)** | Bind persisted DCR credentials to the AS `issuer`; re-register when resource migrates between ASes | Client storage (+ SDK AS-change detection) |
| **[SEP-2207](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207)** | Request refresh tokens from OIDC-style ASes (`offline_access` in scope when appropriate); do not assume refresh tokens are issued | Client (+ SDK `determineScope`) |
| **[SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350)** | On 403 step-up, re-authorize with **union** of previously requested scopes and challenge scopes; on 401 re-login, **replace** scope | Client (+ SDK transport retry for direct transport) |
| **[SEP-2351](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2351)** | Stable RFC 8414 `.well-known` discovery suffix for authorization and resource metadata | Spec / discovery (SDK helpers) |
Servers emit per-operation scopes in 403 challenges (SEP-2350 server posture). Inspector consumes them; server-side scope-challenge middleware is out of scope unless needed for tests.
## SDK vs Inspector responsibilities
Inspector today uses **`@modelcontextprotocol/sdk` ^1.29.0** (v1 monolith). Auth hardening lands on **v2 `main`** (`@modelcontextprotocol/client`). Upgrade is part of this work.
```text
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
```
### What the v2 SDK provides (after upgrade)
| 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-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 |
| SEP-2351 | Tracking [#2256](https://github.com/modelcontextprotocol/typescript-sdk/issues/2256) | Discovery URL suffix in SDK metadata helpers |
### What Inspector must still implement
| SEP | Inspector work | Why not SDK-only |
| --- | -------------- | ---------------- |
| SEP-2468 | Parse `iss` from OAuth callback URL; extend `completeOAuthFlow(code, iss?)`; pass through to SDK `auth()` | Host owns the redirect handler — SDK cannot read the callback URL |
| 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-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.
## Implementation strategy
### Principles
1. **Delegate** connect-time standard OAuth to the v2 SDK `auth()` flow.
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.
### 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) |
| 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 |
Land order in the SDK (maintainer plan): **SEP-2350 → SEP-2352 → SEP-2468**, with **SEP-837** in the same auth-release series.
### Per-SEP Inspector mapping
#### SEP-2468 — `iss` validation (RFC 9207)
- **Web:** `App.tsx` OAuth callback — read `iss` from query string alongside `code`; pass to `InspectorClient.completeOAuthFlow(code, iss?)`.
- **TUI / CLI:** Node callback server — same passthrough.
- **Core:** `OAuthManager.completeOAuthFlow` forwards `iss` to SDK `auth()`.
- **EMA:** Leg 1 OIDC callback is separate (`completeEmaIdpAuthorizationAndMint`); evaluate whether IdP responses require `iss` validation on the OIDC path (may reuse SDK OIDC helpers after upgrade).
#### SEP-837 — `application_type` in DCR
- **Web (`BrowserOAuthClientProvider`):** `application_type: "web"` (https redirect).
- **TUI / CLI:** `application_type: "native"` (localhost / custom scheme redirect).
- **Pre-registered clients:** N/A — no DCR.
- **CIMD:** Portable client IDs — no `application_type` on DCR.
#### SEP-2352 — credentials bound to AS issuer
- Extend `ServerOAuthState` / `oauth.json` with `authorizationServerIssuer` (or key storage by issuer + resource).
- Implement `OAuthClientProvider.invalidateCredentials(scope)` on `BaseOAuthClientProvider` — clear client info and/or tokens per SDK request.
- On AS migration (PRM `authorization_servers` change), SDK triggers invalidation; Inspector storage must honor it and allow re-DCR.
- **CIMD carve-out:** HTTPS `client_id` URLs remain portable across AS changes; tokens still invalidate per SDK behavior.
#### SEP-2207 — OIDC refresh token guidance
- **Standard OAuth:** Inherited from v2 SDK `determineScope()` after upgrade.
- **EMA leg 1:** Already requests `openid offline_access`; IdP refresh in `refreshIdpOidcSession`.
- **Mid-session:** Silent refresh and EMA re-mint per [Mid-session auth](v2_auth_mid_session.md); do not assume refresh tokens exist.
#### 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.
#### SEP-2351 — discovery suffix
- Rely on v2 SDK discovery helpers used by `auth()`, CIMD (`core/auth/cimd.ts`), and EMA (`core/auth/ema/resourceContext.ts`).
- No Inspector-specific discovery URL construction unless regression found in smoke tests.
## Non-goals
- Reimplementing MCP authorization-server or resource-server wire formats.
- v1 / v1.5 backport.
- **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.
## Testing
| SEP | Test |
| --- | ---- |
| SEP-2468 | Callback with matching `iss` succeeds; mismatched `iss` rejected; AS advertising support without `iss` rejected |
| SEP-837 | DCR succeeds for web (https redirect) and TUI/CLI (localhost redirect) against OIDC AS requiring `application_type` |
| SEP-2352 | Simulated AS migration in PRM → credentials invalidated → re-DCR → connect succeeds |
| SEP-2207 | Authorize request includes `offline_access` when AS advertises it; connect succeeds without refresh token when AS omits one |
| SEP-2350 | Connect-time and mid-session 403 step-up use union scopes; 401 re-login replaces scopes |
| SEP-2351 | Discovery resolves against draft suffix paths |
Document manual scenarios in [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) after implementation.
## File touch list (expected)
| Area | Files |
| ---- | ----- |
| Upgrade | Root and client `package.json`; import paths v1 → v2 |
| 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` |
| 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` |
| Smoke doc | `specification/v2_auth_smoke_testing.md` |
## Related specs
| Doc | Relationship |
| --- | ------------ |
| [v2_auth_mid_session.md](v2_auth_mid_session.md) | Mid-session auth (near term); owns runtime SEP-2350 behavior and most recovery UX |
| [v2_auth_ema.md](v2_auth_ema.md) | EMA legs 1–3; SEP-2207 leg 1 scopes; future v2 SDK Layer-2 helpers |
| [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) | Manual verification ladder |
| [v2_scope.md](v2_scope.md) | OAuth handling scope item |
| [v2_storage.md](v2_storage.md) | Shared `oauth.json` — issuer-keyed credentials affect storage schema |

View file

@ -0,0 +1,502 @@
# Inspector V2 — Mid-session authorization
### [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.
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).
## Summary
Inspector v2 already supports **connect-time** OAuth and EMA:
- `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))
**Gap:** Authorization can fail whenever the MCP server rejects the credentials in use — **during** `connect()` (including reconnect with a stored token snapshot), **after** a successful connect (expired or revoked tokens), or on **insufficient scope** for a specific request. The web client compounds this: MCP runs on the Hono backend with a **token snapshot** at connect time (`createTokenAuthProvider`), so the backend cannot complete interactive OAuth or reliable silent refresh on its own.
This spec defines:
1. A normalized **`AuthChallenge`** model (what went wrong + what is required).
2. A single core entry point **`handleAuthChallenge()`** (how Inspector responds).
3. **Remote event propagation** so the browser can run auth and reconnect.
4. **Phased delivery** (recovery → step-up → client parity → RPC replay) — see [Architecture](#architecture).
## Architecture
### Code layout
- Challenge types live in `core/auth/challenge.ts`.
- `OAuthManager.handleAuthChallenge()` implements the handler; `InspectorClient` exposes a delegating wrapper.
- Web orchestration starts in `App.tsx`; extract `clients/web/src/utils/authChallengeFlow.ts` if wiring exceeds ~50 lines.
### Web: detection and wire protocol
- **Backend detection:** 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.
- **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.
- **Deduplication:** in-memory per session, keyed by `reason` + sorted `requiredScopes`; suppress duplicates until satisfied or scopes change.
- **Multi-tab:** duplicate modals are acceptable until Phase 4 `RemoteOAuthStorage`; then `navigator.locks.request()` single-flight per server URL inside `handleAuthChallenge()`.
### TUI / CLI: detection
- Same `handleAuthChallenge()` entry via a transport fetch wrapper, before the SDK auth retry path.
- Intercept 401 and 403 on streamable HTTP; run union scopes in `handleAuthChallenge()` for step-up. Do not rely on the SDK built-in 403 retry (challenge-only scope, no SEP-2350 union).
- Legacy SSE transport: 401 only (no 403 step-up in SDK).
- Replace TUI `show401AuthHint` with the `authChallenge` event (Phase 4).
- Phase A: rely on SDK in-flight retry where applicable. Phase C adds explicit pending-RPC replay.
The SDK (`@modelcontextprotocol/sdk` 1.29.0) auto-retries 401/403 on streamable HTTP `send()` but does not union scopes for step-up — Inspector owns SEP-2350 union in `handleAuthChallenge()`.
### Scope and EMA
- **Previously requested scopes:** `OAuthStorage.scope` / `saveScope()` per server.
- **After successful authorize:** `saveScope(authorizationScopes)`; step-up uses union; 401 re-login replaces scope.
- **EMA mint scopes on 401 refresh:** challenge `scope` from `WWW-Authenticate` if present, else configured `oauth.scope`, else PRM `scopes_supported` (`resolveEmaScopes` order).
- **EMA step-up (valid IdP session):** silent legs 2–3 re-mint with `authorizationScopes` (union). Same toast as 401 refresh. No modal, no resource-AS redirect. Resource MCP scopes are on leg 2/3 token requests, not leg 1 (`openid offline_access` only).
### RPC retry
- After every successful recovery (401 refresh, EMA re-mint, step-up), **retry the failed MCP request**. Phases 1–3 may ship without auto-retry; Phase 5 queues `AuthChallenge.context.pendingRequest` and replays once after `satisfied` + reconnect (bounded).
### UX
| Situation | Behavior |
| --------- | -------- |
| Silent recovery (refresh / EMA re-mint / EMA step-up) | Brief toast: “Refreshing authorization…” |
| **401** — interactive re-auth required | Toast “Session expired, re-authenticating…” → auto-start redirect (same as connect-time). No confirm modal. |
| **403** step-up — standard OAuth | Blocking modal: scopes, tool context, **Authorize** / **Cancel** |
| **Cancel** on standard-OAuth step-up modal | Stay connected. Failed tool shows error. Other scoped operations may still work. Do not disconnect. |
| **401** — user aborts IdP redirect or callback fails | Stay connected (degraded). Persistent re-auth banner. Auth-gated calls fail until recovery. Do not auto-disconnect. |
| Hard failure (`kind: "failed"`) | Persistent error toast |
TUI: standard-OAuth step-up uses Auth tab message + Cancel semantics. EMA step-up is silent. CLI mirrors when OAuth is wired (Phase 4).
Connection Info showing effective vs pending scopes is out of scope for v1.
### When silent recovery fails
Silent path = refresh token grant (standard OAuth) or EMA legs 2–3 re-mint (valid IdP session). Falls through to interactive when silent cannot succeed:
| Protocol | Silent fails when |
| -------- | ----------------- |
| Standard OAuth | No `refresh_token`; refresh token expired/revoked; AS rejects refresh; no tokens in storage |
| EMA | No IdP session; legs 2–3 mint error (bad resource client creds, AS/network errors) |
| Step-up (403) | Standard OAuth: interactive consent (modal + resource-AS redirect). EMA (valid IdP session): silent legs 2–3 re-mint — same as 401 refresh |
| Web | Silent runs in the browser after SSE `auth_challenge`; the backend cannot refresh frozen tokens |
### Test infrastructure
Extend `test-server-oauth.ts` with scope-check middleware that returns **403** + `insufficient_scope` for scoped tool routes.
## Normative references
- [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)
## Goals
### Phase A — Mid-session token recovery (implement first)
- **One core path** for all clients: parse or receive a challenge → `handleAuthChallenge()` → updated tokens or interactive auth.
- **Web remote architecture:** backend detects and **emits** challenges; browser **handles** auth (never runs OAuth redirects on the server).
- Support **token refresh** (silent when possible) for **401 / invalid or expired tokens** at runtime.
- **EMA-aware:** re-run legs 2–3 when the resource token expires; leg 1 only when IdP session is missing or expired ([EMA 401 rules](v2_auth_ema.md)).
- Preserve existing connect-time OAuth behavior — no regressions.
### Phase B — MCP step-up authorization (after Phase A)
- 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)
- 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`.
## Non-goals
- 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.
## 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. |
## Current architecture (why mid-session fails on web)
```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
```
**TUI/CLI:** OAuth authority and MCP transport live in the same process. The SDK can call `tokens()`, refresh, or fire `oauthAuthorizationRequired`.
**Web:** OAuth authority is in the browser; MCP HTTP is on the backend. Only **connect-time** 401 is wired today when **no** token snapshot is sent (`App.tsx` → `authenticate()`). When the browser **does** send a snapshot (reconnect with stored tokens, or post-OAuth `connect()` where the server still rejects the token), failures on `/api/mcp/send` — **including `initialize` during `connect()`** — do not trigger browser re-auth. The MCP SDK invokes `auth()` on the frozen `createTokenAuthProvider` stub; recovery fails and often surfaces as **HTTP 500** (e.g. SDK error *"OAuth client information must be saveable for dynamic registration"*) instead of **401**, because the stub cannot persist DCR results or run browser redirects.
### Known failure: reconnect with stored tokens (pre–Phase 2)
This is the same root cause as mid-session tool-call failures; only the triggering RPC differs (`initialize` during `connect()` vs e.g. `tools/list` after connected).
| Situation | Today (web remote) | After Phase 2 |
| --------- | ------------------ | ------------- |
| No tokens in storage; user clicks **Connect** | Remote 401 → browser `authenticate()` → OAuth → connect | Unchanged (connect-time path) |
| Stored tokens present (expired, revoked, wrong registration, or server-invalidated); user clicks **Connect** | Token snapshot sent → MCP 401 → stub `auth()` → **500** / opaque SDK error; user sees **Failed to connect**, not re-auth | Fetch wrapper emits **`auth_challenge`** → browser `handleAuthChallenge()` → refresh or interactive re-auth → reconnect |
| Connected; token becomes invalid; user calls a tool | Same stub failure or opaque error on `/api/mcp/send` | Same **`auth_challenge`** → recovery → reconnect |
**Workaround until Phase 2:** **Clear stored OAuth state** for the server (Server Settings or Connection Info), then **Connect** again to take the no-snapshot connect-time path. Do **not** rely on proactive JWT `exp` checks at connect as a substitute for Phase 2 — they only cover clock-expired JWTs, not server-rejected tokens, opaque access tokens, or registration mismatches; challenge detection from the MCP HTTP response remains the source of truth (see [Parsing](#parsing-best-effort-extensible)).
## SEP-2350 — step-up authorization
[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**.
**Inspector implements SEP-2350 in Phase B** (after basic mid-session 401 / token-refresh handling). Until then, step-up challenges may surface as opaque tool-call failures.
### What the upcoming MCP spec requires
From the [draft authorization spec](https://modelcontextprotocol.io/specification/draft/basic/authorization) (incorporating SEP-2350):
| Situation | HTTP status | `WWW-Authenticate` | Client behavior |
| --------- | ----------- | ------------------ | --------------- |
| No token / invalid / expired token | **401** | `scope` may guide initial selection | Refresh if possible; else full (re-)authorization. **Replace** scope set on full re-login (down-scoping opportunity). |
| Valid token, insufficient scope (runtime) | **403** | `error="insufficient_scope"`, `scope="…"` per [RFC 6750 §3.1](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1) | **Step-up flow** — see below. |
**Server posture (SEP-2350):** servers emit scopes needed for the **current operation only**, not the union of everything the client was ever granted. Servers remain stateless regarding client scope history.
**Client posture (SEP-2350 — Step-Up Authorization Flow step 2):**
1. Parse `WWW-Authenticate` from the 403 (or AS error) response.
2. Compute **`requiredScopes = union(previouslyRequestedScopes, challengeScopes)`** — union of the client's previously **requested** scope set and the scopes from the current challenge. Do **not** replace the prior set with the challenge scopes alone (that would drop permissions needed for other tools).
3. Initiate (re-)authorization with the union scope set.
4. Retry the original MCP request with the new token (bounded retries).
Reference implementation discussion: [python-sdk PR #2676](https://github.com/modelcontextprotocol/python-sdk/pull/2676) (403 → union; 401 → replace).
### Inspector mapping for SEP-2350
- Persist **`previouslyRequestedScopes`** per server in `OAuthStorage.scope` via `saveScope()`.
- `parseAuthChallengeFromResponse()` maps **403 + `insufficient_scope`** → `AuthChallenge { reason: "insufficient_scope", requiredScopes }`.
- `handleAuthChallenge()` for **standard OAuth**: build authorize URL with **union scopes** (interactive). For **EMA** (valid IdP session): silent legs 2–3 re-mint with **`authorizationScopes`** — resource scopes are on the leg 2/3 token requests, not leg 1 OIDC scopes.
- UX: **standard OAuth** step-up — modal (“**Tool X** needs additional permissions…”). **EMA** step-up — silent toast only (valid IdP session); mint failure surfaces error toast.
## Auth challenge model
### Type shape (`core/auth/challenge.ts`)
```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
/** Normalized challenge for handleAuthChallenge(). */
export interface AuthChallenge {
reason: AuthChallengeReason;
/** Scopes from the current challenge (step-up). Per RFC 6750 §3.1 / MCP Runtime Insufficient Scope Errors — scopes needed for this operation. */
requiredScopes?: string[];
/**
* For step-up (SEP-2350): union of previously requested scopes and requiredScopes.
* Set by handleAuthChallenge before re-authorization; not sent on the wire.
*/
authorizationScopes?: string[];
/** Resource indicator / MCP resource URL when known (EMA RFC 8707). */
resource?: string;
/** Resource authorization server audience when known. */
audience?: string;
/** Optional human-readable detail from server or SDK (for UI, not parsing). */
message?: string;
/** MCP method / tool name that triggered the challenge (for UX: “authorizing for tool X”). */
context?: {
method?: string;
toolName?: string;
/** Phase C: JSON-RPC request to replay after successful recovery. */
pendingRequest?: import("@modelcontextprotocol/sdk/types.js").JSONRPCMessage;
};
/** Opaque raw hints for logging and forward-compatible parsers. */
raw?: {
httpStatus?: number;
wwwAuthenticate?: string;
};
}
```
### Parsing (best effort, extensible)
Challenge construction is **layered** — do not message-guess. Parse at the point of failure: when the MCP transport returns **401/403**, when `transport.send()` throws, or when `onerror` fires with an auth status code.
1. **SDK / transport error** — preserve HTTP status / `code` (existing pattern in `core/mcp/remote/node/server.ts`; web uses `isAuthChallengeError()` for mid-session detection). Treat **401** and **403** separately per MCP [Error Handling](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.
When parsing fails, use `reason: "unauthorized"` and still allow interactive re-auth.
### Challenge vs connect-time 401
| | Connect-time (no snapshot) | Runtime / reconnect (snapshot sent) |
| --- | --- | --- |
| **When** | First connect with no stored tokens; `initialize` gets 401 before any bearer token was sent to the backend | Reconnect with stored tokens, or any MCP request after a token snapshot was frozen on the backend — **including `initialize` during `connect()`** |
| **Detection** | `connect()` throws **401** to the browser | MCP HTTP **401/403** on backend transport → **`auth_challenge`** (Phase 2); today often **500** from stub `auth()` |
| **Handler** | `authenticate()` (today) | `handleAuthChallenge()` (this spec) |
| **Web follow-up** | Redirect or silent connect | Recover tokens in browser → **disconnect + connect** to re-snapshot → Phase C replays pending RPC |
Both paths may call the same underlying OAuth/EMA primitives (`authenticate()`, refresh, `completeOAuthFlow()`); only **detection** and **re-snapshot reconnect** differ. Phase 2 unifies recovery for the snapshot path; it does **not** replace the no-snapshot connect-time path.
## Core API — `handleAuthChallenge()`
**Location:** `OAuthManager.handleAuthChallenge()`; `InspectorClient` exposes a delegating wrapper.
```typescript
export type AuthChallengeOutcome =
| { kind: "satisfied" } // New tokens in OAuthStorage; caller may reconnect transport
| { kind: "interactive"; authorizationUrl: URL }
| { kind: "failed"; error: Error };
/** Satisfy an auth challenge when possible. */
async handleAuthChallenge(challenge: AuthChallenge): Promise<AuthChallengeOutcome>;
```
### Strategy by protocol and reason
#### Standard OAuth (`protocol: "standard"`)
| Reason | Silent path | Interactive path |
| ------ | ----------- | ---------------- |
| `token_expired`, `invalid_token`, `unauthorized` | SDK refresh via stored `refresh_token` when AS supports it | New authorization code flow (`authenticate()`) |
| `insufficient_scope` | Not applicable — need new consent | **[SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350):** authorization URL with **`authorizationScopes`** = union(previously requested, `requiredScopes`) — not replace |
Uses existing `BaseOAuthClientProvider`, storage, and `authenticate()` / `completeOAuthFlow()`.
#### EMA (`protocol: "ema"`)
Per [EMA 401 rules](v2_auth_ema.md): **do not** fall back to standard resource-OAuth redirect.
| Reason | Silent path | Interactive path |
| ------ | ----------- | ---------------- |
| `token_expired`, `unauthorized` (resource token) | `refreshEmaResourceTokens()` / legs 2–3; scopes: challenge `WWW-Authenticate` scope → configured `oauth.scope` → PRM `scopes_supported` | — |
| IdP session missing / expired | — | Leg 1 IdP OIDC redirect (`startEmaIdpAuthorization`) then legs 2–3 |
| `insufficient_scope` | **Silent:** re-mint legs 2–3 with **`authorizationScopes`** (union) | **Leg 1** IdP redirect when IdP session invalid — separate from step-up UX; then legs 2–3 with union scopes |
Uses `EmaTransportOAuthProvider`, `emaFlow.ts`, and `resourceContext.ts` (extend scope resolution to prefer challenge scopes when present).
### After `kind: "satisfied"`
| Client | Action |
| ------ | ------ |
| **TUI / CLI** | Live provider 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. |
Until Phase C, the failed tool call may still require a manual retry after recovery.
### After `kind: "interactive"`
Same as connect-time today:
- **Web (401):** toast → auto-redirect → `/oauth/callback` → `completeOAuthFlow()` → reconnect.
- **Web (403, standard OAuth):** modal → on Authorize, stash pending server id → redirect → `/oauth/callback` → `completeOAuthFlow()` → reconnect.
- **EMA (IdP session missing or expired):** leg 1 IdP redirect → callback → legs 2–3 → reconnect.
- **TUI / CLI:** `oauthAuthorizationRequired` → browser → callback → `completeOAuthFlow()` → reconnect if needed.
### After `kind: "failed"` or user **Cancel** (standard-OAuth step-up modal only)
Do **not** disconnect the MCP session for recoverable challenges.
| Reason | Cancel / failed outcome |
| ------ | ------------------------ |
| `insufficient_scope` (standard OAuth, user Cancelled) | Stay connected; failed tool shows error; other scoped operations may still work |
| `insufficient_scope` (EMA, silent path) | No Cancel — auto re-mint; on mint failure → `kind: "failed"` toast, stay connected (degraded) |
| `token_expired` / `unauthorized` | Stay connected (**degraded**); banner to re-authenticate; auth-gated calls fail until recovery |
## Remote wire protocol (web)
Backend **reports** challenges; browser **handles** them.
### 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()`.
```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()
```
#### 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).
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).
#### TUI / CLI — detection (direct transport)
Same **`handleAuthChallenge()`** entry via **transport fetch wrapper** (before SDK auth retry):
- Intercept **401** and **403** on streamable HTTP; run `handleAuthChallenge()` with SEP-2350 union scopes for step-up. Do **not** rely on SDK built-in 403 retry alone.
- Legacy **SSE** transport: **401** only (no 403 step-up in SDK).
- Dispatch **`authChallenge`** on `InspectorClient` (Phase 4 replaces TUI `show401AuthHint`).
- **`oauthAuthorizationRequired`** fires when `handleAuthChallenge()` returns `interactive`.
### SSE event
Extend `RemoteEvent` in `core/mcp/remote/types.ts`:
```typescript
export interface RemoteAuthChallengeEvent {
type: "auth_challenge";
data: AuthChallenge & {
/** Server catalog id — browser resolves InspectorClient instance. */
serverId?: string;
};
}
```
**Rules:**
- Emit **once per recoverable 401/403 auth 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).
### 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).
## 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 |
| 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 |
| 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). 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).
### 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 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()`
- [ ] 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 (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`)
### 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)
### 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 — Pending RPC replay (Phase C)
- [ ] 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
## 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 (Phase C replay) | 401 / EMA / 403 recovery → original tool call replays automatically |
| Integration (SEP-2350 step-up) | MCP server returns **403** `insufficient_scope` → union re-auth → retried tool call |
| 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` |
| Tests | `clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts`, new remote auth-challenge + Phase C replay tests |

View file

@ -0,0 +1,573 @@
# Inspector V2 — OAuth smoke testing (real servers)
### [Brief](README.md) | [V2 Scope](v2_scope.md) | [Servers file](v2_servers_file.md) | [Auth hardening](v2_auth_hardening.md) | [Mid-session auth](v2_auth_mid_session.md) | [EMA / XAA](v2_auth_ema.md)
Manual smoke procedures for exercising Inspector OAuth against **hosted** MCP servers. Complements automated coverage in `clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts`, which uses the in-repo `TestServerHttp` (`createOAuthTestServerConfig`).
This document does **not** replace CI. It records known-good real endpoints, which client-ID mechanism each server supports, and how to configure Inspector (web, TUI, or CLI).
## Install-level client config (TUI / CLI)
EMA and CIMD credentials live in **`~/.mcp-inspector/storage/client.json`** (same file the web **Client Settings** dialog writes). TUI and CLI load it automatically at startup:
| Flag / env | Default |
| ---------- | ------- |
| `--client-config <path>` | `~/.mcp-inspector/storage/client.json` |
| `MCP_CLIENT_CONFIG_PATH` | Same default when `--client-config` is omitted |
For repo smoke fixtures, point at the checked-in template:
```bash
--client-config configs/client.json
```
CLI flags (`--client-metadata-url`, `--client-id`, `--client-secret`) override values from `client.json` when present. Per-server OAuth fields in `mcp.json` still apply for static/DCR/EMA resource credentials.
OAuth callback URL (TUI/CLI only):
| Flag / env | Default |
| ---------- | ------- |
| `--callback-url <url>` | `http://127.0.0.1:6276/oauth/callback` |
| `MCP_OAUTH_CALLBACK_URL` | Same default when `--callback-url` is omitted |
Web uses `http://localhost:6274/oauth/callback` on the main app server — not these runner settings.
## Terminology
| Term | Meaning in this doc |
| ---- | ------------------- |
| **Static / preregistered client** | You supply `oauthClientId` and optionally `oauthClientSecret` in Server Settings. Inspector skips DCR and uses your credentials. |
| **DCR** | Dynamic Client Registration (RFC 7591). Inspector registers at the AS `registration_endpoint` on first connect; no client id in config. |
| **CIMD** | Client ID Metadata Document (SEP-991). Inspector uses an HTTPS metadata URL as `client_id` when the AS advertises `client_id_metadata_document_supported`. |
| **Client credentials grant** | OAuth 2.0 machine-to-machine `grant_type=client_credentials`. **Not** what we mean by “static client credentials” here. Inspector does not implement this grant yet ([#1225](https://github.com/modelcontextprotocol/inspector/issues/1225)). |
## Prerequisites
1. **Inspector web, TUI, or CLI** with OAuth environment wired (`environment.oauth` — web always; TUI/CLI for HTTP/SSE servers).
2. **Redirect URI** must match what the authorization server expects:
- **Web (dev):** `http://localhost:6274/oauth/callback` (default `CLIENT_PORT`; see `clients/web/server/web-server-config.ts`).
- **Web (prod launcher):** `{origin}/oauth/callback` (same host/port as the Hono server).
- **TUI / CLI:** loopback callback from `createOAuthCallbackServer()` (TUI) — default **`http://127.0.0.1:6276/oauth/callback`** (port 6276 ≈ T9 “MCPO”, MCP OAuth; separate from web `6274`). Override with `--callback-url` or `MCP_OAUTH_CALLBACK_URL`. Use `http://127.0.0.1:0/oauth/callback` for an OS-assigned ephemeral port when the AS registers redirect URIs dynamically (DCR).
3. **Catalog entry** in `~/.mcp-inspector/mcp.json` (or ad-hoc connect) with `type: "streamable-http"` and the server URL.
4. **Keychain-backed secrets (TUI/CLI):** when OAuth client secrets or stdio env values were saved via web **Server Settings**, they live in the OS keychain — not in `mcp.json`. TUI and CLI merge keychain values on catalog load (same effective config as web `GET /api/servers`; see [Servers file](v2_servers_file.md) §Secret storage). For static/EMA smokes you can also pass `--client-secret` (CLI/TUI), use a local hand-edited `mcp.json` with plaintext secrets (dev only — never commit), or save secrets once via web on the same machine before running the TUI/CLI.
### Example catalog shape (HTTP server)
```jsonc
{
"mcpServers": {
"mcp-example-everything": {
"type": "streamable-http",
"url": "https://example-server.modelcontextprotocol.io/mcp"
},
"github-mcp": {
"type": "streamable-http",
"url": "https://api.githubcopilot.com/mcp/"
},
"stytch-mcp-demo": {
"type": "streamable-http",
"url": "https://stytch-as-demo.val.run/mcp"
},
"stytch-mcp": {
"type": "streamable-http",
"url": "https://mcp.stytch.dev/mcp"
}
}
}
```
Per-server OAuth fields live on the same entry (lifted to `InspectorServerSettings` in memory). On disk (#1358 flat shape):
```jsonc
"oauth": {
"clientId": "<from GitHub OAuth App>",
"clientSecret": "<from GitHub OAuth App>",
"scopes": "repo read:user"
}
```
## Smoke matrix (real servers)
| Server | URL | Mechanism to smoke | Credentials in repo? |
| ------ | --- | ------------------ | -------------------- |
| **MCP Example “Everything”** (hosted) | `https://example-server.modelcontextprotocol.io/mcp` | **DCR** | No — register at connect time |
| **GitHub MCP** (remote) | `https://api.githubcopilot.com/mcp/` | **Static OAuth App** (or PAT header bypass) | No — you create a GitHub OAuth App |
| **Stytch MCP demo** (hosted) | `https://stytch-as-demo.val.run/mcp` | **CIMD** (also DCR) | No — use [MCPJam CIMD](#cimd-credentials-for-smoke-mcpjam) (default for smoke) |
| **Stytch MCP** (management API) | `https://mcp.stytch.dev/mcp` | **CIMD** (also DCR) | No — same MCPJam CIMD URL; real Stytch login at `stytch.com` |
| **Composable test server** (local) | `http://127.0.0.1:<port>/mcp` | Static, DCR, CIMD (all) | Fake ids in e2e tests only |
| **xaa.dev EMA** | Local resource + `auth.resource.xaa.dev` | EMA (not standard OAuth ladder) | Registered on xaa.dev — see [EMA staging](v2_auth_ema.md#staging-validation-manual--verified) |
---
## 1. MCP Example “Everything” server (DCR)
**Hosted reference server** implementing the full MCP feature surface (tools, resources, prompts, sampling, elicitation). Source: [modelcontextprotocol/example-remote-server](https://github.com/modelcontextprotocol/example-remote-server). Public deployment:
| Field | Value |
| ----- | ----- |
| MCP URL | `https://example-server.modelcontextprotocol.io/mcp` |
| Resource identifier | `https://example-server.modelcontextprotocol.io/` |
| Authorization server | Same host (combined resource + AS) |
| Protected resource metadata | `https://example-server.modelcontextprotocol.io/.well-known/oauth-protected-resource` |
| AS metadata | `https://example-server.modelcontextprotocol.io/.well-known/oauth-authorization-server` |
**Verified AS capabilities (June 2026):**
- `registration_endpoint`: present → **DCR supported**
- `client_id_metadata_document_supported`: **not advertised** → CIMD not available on this host
- `token_endpoint_auth_methods_supported`: `["none"]` → public client after DCR
- `code_challenge_methods_supported`: `["S256"]` → PKCE required
### Procedure (web)
1. Add catalog entry (no `oauth` block needed for DCR).
2. Start Inspector web (`mcp-inspector --web --dev` or launcher).
3. Connect → expect **401**, then OAuth redirect (or use Connection flow that calls `authenticate()` on 401).
4. Complete browser authorization on the example AS.
5. **Pass:** `tools/list` succeeds; Connection Info shows authorized + dynamic client id from DCR.
### Procedure (TUI)
1. `mcp-inspector --tui` with catalog containing the server.
2. Press **C** to connect — OAuth starts automatically when the server returns 401.
3. Complete browser flow against callback server (`http://127.0.0.1:6276/oauth/callback` by default).
4. **Pass:** connect succeeds without a second **C**; Auth tab shows authorized state.
### Notes
- First connect performs discovery + DCR + authorization code + PKCE. Tokens persist in `~/.mcp-inspector/storage/oauth.json` (TUI/CLI path) or browser session storage (web).
- Reconnect should reuse stored DCR `client_id` unless storage was cleared.
---
## 2. GitHub MCP server (static / preregistered OAuth App)
**Remote GitHub MCP** is the usual choice for **static client credential** smoke testing against a production authorization server that does **not** expose DCR to arbitrary MCP clients.
| Field | Value |
| ----- | ----- |
| MCP URL | `https://api.githubcopilot.com/mcp/` |
| Resource identifier | `https://api.githubcopilot.com/mcp` |
| Protected resource metadata | `https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp` |
| Authorization server | `https://github.com/login/oauth` |
| Upstream docs | [github/github-mcp-server](https://github.com/github/github-mcp-server) — [remote-server.md](https://github.com/github/github-mcp-server/blob/main/docs/remote-server.md) |
**Verified PRM (June 2026):** `authorization_servers: ["https://github.com/login/oauth"]`. Scopes advertised include `repo`, `read:org`, `read:user`, `user:email`, `gist`, `workflow`, etc.
### Why GitHub is the static-credentials smoke server
- GitHub’s OAuth platform expects a **pre-registered OAuth App** (or GitHub App) with a fixed callback URL. There is no open `registration_endpoint` for Inspector-style DCR.
- VS Code, Cursor, and other hosts register **their own** GitHub OAuth applications; Inspector does not ship shared production client id/secret pairs.
- This matches the “user knows static credentials are required” path: set `oauthClientId` / `oauthClientSecret` in Server Settings before or after the first 401.
### Credentials to use (you create these)
There are **no shared Inspector test credentials** in this repository. Create a **GitHub OAuth App** under your account or org:
1. Open [GitHub Developer settings → OAuth Apps](https://github.com/settings/developers) → **New OAuth App**.
2. Suggested values for local web smoke:
| Field | Value |
| ----- | ----- |
| Application name | `MCP Inspector (local dev)` |
| Homepage URL | `http://localhost:6274` |
| Authorization callback URL | `http://localhost:6274/oauth/callback` |
For TUI/CLI, register **`http://127.0.0.1:6276/oauth/callback`** (default) on the OAuth app, or override with `--callback-url` / `MCP_OAUTH_CALLBACK_URL`.
3. After creation, copy **Client ID** and generate a **Client secret**.
4. In Inspector **Server Settings → OAuth** for the GitHub MCP entry:
- **Client ID** → `oauth.clientId` on disk
- **Client secret** → `oauth.clientSecret` (stored via keychain on web backend when saved through UI)
- **Scopes** → space-separated subset of PRM scopes, e.g. `read:user repo` (match what you need for `tools/list`; tighter scopes reduce consent friction)
Example `mcp.json` fragment:
```jsonc
"github-mcp": {
"type": "streamable-http",
"url": "https://api.githubcopilot.com/mcp/",
"oauth": {
"clientId": "Ov23liXXXXXXXXXXXX",
"clientSecret": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"scopes": "read:user repo"
}
}
```
**Never commit real secrets.** Use local catalog only.
### Procedure (web)
Run both phases in order. **Clear OAuth state** between phases: **Server Settings → OAuth → Clear stored OAuth state**, or (while connected) **Connection Info → OAuth Details → Clear and disconnect**.
#### Phase 1 — static credentials off (expect failure)
1. Add the GitHub MCP catalog entry with **no** Server Settings OAuth fields (no `oauth` block on disk).
2. Connect → **401** → authenticate → complete or attempt GitHub login.
3. **Pass (negative control):** connect does **not** reach an authorized MCP session. Typical outcomes: token exchange or registration error, or 401 persists after callback — GitHub has no DCR `registration_endpoint`, so Inspector cannot obtain a client id without your OAuth App credentials.
#### Phase 2 — static credentials on (expect success)
1. Create the GitHub OAuth App and fill **Server Settings → OAuth** (Client ID, Client secret, scopes) as above.
2. Clear OAuth state for this server again (Server Settings or Connection Info as above).
3. Connect → **401** → authenticate → GitHub login/consent for **your** OAuth App.
4. **Pass:** connected; `tools/list` returns GitHub tools; Connection Info shows **Client registration = Static (preregistered)** and your OAuth App client id.
### Procedure (TUI)
Same two phases:
1. **Off:** catalog entry without `oauth` block → connect, **A** to authenticate → **expect failure** (no DCR on GitHub).
2. **On:** add `oauth` block or `--client-id` / `--client-secret` → clear stored OAuth tokens for the server → connect → **expect success** as in Phase 2 (web).
### Alternative: Personal Access Token (non-OAuth smoke)
GitHub documents PAT auth with a static header (bypasses OAuth flow entirely). Useful for MCP tool smoke, **not** for testing Inspector OAuth:
```jsonc
"github-mcp-pat": {
"type": "streamable-http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"Authorization": "Bearer <GITHUB_PAT>"
}
}
```
See [GitHub MCP README — Using a GitHub PAT](https://github.com/github/github-mcp-server#remote-github-mcp-server).
### GitHub org / enterprise
- Org may block OAuth Apps or MCP policies — see [policies-and-governance.md](https://github.com/github/github-mcp-server/blob/main/docs/policies-and-governance.md).
- **GitHub Enterprise Cloud (ghe.com):** use `https://copilot-api.octocorp.ghe.com/mcp` and enterprise OAuth settings ([README](https://github.com/github/github-mcp-server#github-enterprise)).
---
## 3. Stytch MCP server (CIMD)
Stytch advertises first-class [CIMD support for MCP](https://stytch.com/blog/oauth-client-id-metadata-mcp/). Inspector smoke testing uses **two** hosted Stytch MCP targets:
| Target | When to use |
| ------ | ----------- |
| **`stytch-mcp-demo`** — `https://stytch-as-demo.val.run/mcp` | **Default for local CIMD smoke.** Test Stytch project; authorize at the demo app (`/oauth/authorize`) with email OTP / test login — not the `stytch.com` dashboard. |
| **`stytch-mcp`** — `https://mcp.stytch.dev/mcp` | **Production-style smoke.** Stytch’s hosted Management API MCP; authorize at `https://stytch.com/oauth/authorize` with a real Stytch workspace account (often social SSO). |
### 3a. Stytch demo MCP (preferred for dev)
| Field | Value |
| ----- | ----- |
| MCP URL | `https://stytch-as-demo.val.run/mcp` |
| Resource identifier | `https://stytch-as-demo.val.run/mcp` |
| Protected resource metadata | `https://stytch-as-demo.val.run/.well-known/oauth-protected-resource/mcp` |
| Authorization server (issuer) | `https://industrious-dress-4239.customers.stytch.dev` *(resolve from PRM — may change)* |
| AS metadata | `https://industrious-dress-4239.customers.stytch.dev/.well-known/oauth-authorization-server` |
| Token endpoint | `https://industrious-dress-4239.customers.stytch.dev/v1/oauth2/token` |
| DCR registration endpoint | `https://industrious-dress-4239.customers.stytch.dev/v1/oauth2/register` |
| Authorization UI | `https://stytch-as-demo.val.run/oauth/authorize` (demo-hosted; test login — **not** `stytch.com`) |
**Verified discovery (June 2026):** PRM at the URL above currently returns:
```json
{
"resource": "https://stytch-as-demo.val.run/mcp",
"authorization_servers": ["https://industrious-dress-4239.customers.stytch.dev"],
"scopes_supported": ["openid", "email"]
}
```
AS metadata from that issuer currently includes:
- `issuer`: `https://industrious-dress-4239.customers.stytch.dev`
- `client_id_metadata_document_supported`: **true** → CIMD
- `registration_endpoint`: `…/v1/oauth2/register` → **DCR also works** on the same server
- `token_endpoint`: `…/v1/oauth2/token`
- `authorization_endpoint`: `https://stytch-as-demo.val.run/oauth/authorize` (not `stytch.com`)
- `code_challenge_methods_supported`: `["S256"]`
- `token_endpoint_auth_methods_supported`: `["client_secret_basic", "client_secret_post", "none"]`
Use this server when validating Inspector CIMD/DCR against a **real** Connected Apps AS without needing a Stytch Management workspace login.
**Typical network sequence (CIMD, web):**
1. `POST https://stytch-as-demo.val.run/mcp` → **401** (no token)
2. `GET …/.well-known/oauth-protected-resource/mcp` → PRM
3. `GET …/.well-known/oauth-authorization-server` → AS metadata (on the issuer host from PRM)
4. Browser redirect to `https://stytch-as-demo.val.run/oauth/authorize?client_id=…` (not logged as an Inspector fetch)
5. Callback to `http://localhost:6274/oauth/callback`
6. `POST …/v1/oauth2/token` → **200** (see [Verifying CIMD](#verifying-cimd-vs-dcr-network-and-tokens) for the request body)
7. `POST https://stytch-as-demo.val.run/mcp` → **200**
CIMD does **not** call `POST …/v1/oauth2/register` — Inspector stores the metadata URL as `client_id` locally before `auth()`. Stytch fetches the metadata document **server-side** during authorize; that fetch does not appear in Inspector’s network log.
### 3b. Stytch Management MCP (production-style)
| Field | Value |
| ----- | ----- |
| MCP URL | `https://mcp.stytch.dev/mcp` |
| Resource identifier | `https://mcp.stytch.dev` |
| Protected resource metadata | `https://mcp.stytch.dev/.well-known/oauth-protected-resource` |
| Product docs | [Stytch MCP Server](https://stytch.com/docs/resources/workspace-management/stytch-mcp-server), [mcp.stytch.dev](https://mcp.stytch.dev/) |
**Verified discovery (June 2026):** PRM at the URL above currently returns:
```json
{
"resource": "https://mcp.stytch.dev",
"authorization_servers": ["https://rustic-kilogram-6347.customers.stytch.com"],
"scopes_supported": [
"openid", "email", "profile",
"admin:projects", "manage:api_keys", "manage:api_keys:test",
"manage:project_settings", "manage:project_data"
]
}
```
AS metadata from that issuer includes:
- `client_id_metadata_document_supported`: **true** → CIMD
- `registration_endpoint`: present → **DCR also works** on the same server
- `authorization_endpoint`: `https://stytch.com/oauth/authorize` (Stytch workspace login)
- `code_challenge_methods_supported`: `["S256"]`
Resolve `token_endpoint` and `registration_endpoint` fresh from AS metadata on the issuer host (`rustic-kilogram-6347.customers.stytch.com` at time of writing). Issuer hostnames are project-specific and may change — always follow PRM → AS discovery rather than hardcoding.
Use this server when you need to smoke CIMD against Stytch’s **live** Management API MCP and can sign in with a real Stytch account.
### CIMD credentials for smoke (MCPJam)
Inspector Stytch CIMD smoke uses **[MCPJam](https://www.mcpjam.com/)’s public Client ID Metadata Document** — no hosting or OAuth App setup required.
CIMD does **not** use `oauth.clientId` / `oauth.clientSecret` in `mcp.json`. Configure install-wide in **Client Settings** (web), **`client.json`** (TUI/CLI default path or `--client-config`), or **`--client-metadata-url`** (CLI/TUI override).
#### Metadata URL (use this in Client Settings)
```
https://www.mcpjam.com/.well-known/oauth/client-metadata.json
```
Live document (June 2026; fetch fresh if debugging redirect mismatches):
```json
{
"client_id": "https://www.mcpjam.com/.well-known/oauth/client-metadata.json",
"client_name": "MCPJam",
"client_uri": "https://www.mcpjam.com",
"logo_uri": "https://www.mcpjam.com/mcp_jam_2row.png",
"redirect_uris": [
"mcpjam://oauth/callback",
"mcpjam://authkit/callback",
"http://127.0.0.1:6274/oauth/callback",
"http://localhost:6274/oauth/callback",
"…"
],
"grant_types": ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"application_type": "native"
}
```
The full `redirect_uris` list also includes other localhost ports and MCPJam app URLs — see the [live document](https://www.mcpjam.com/.well-known/oauth/client-metadata.json).
#### Inspector configuration
| Surface | Setting |
| ------- | ------- |
| **Web** | **Client Settings** → **Client ID Metadata Document** → paste the MCPJam URL above → enable **Use Client ID Metadata Document** |
| **TUI** | `--client-metadata-url https://www.mcpjam.com/.well-known/oauth/client-metadata.json` (or enable CIMD in `client.json` / `--client-config`) |
| **CLI** | Same flags; reuses tokens from `~/.mcp-inspector/storage/oauth.json` when already authorized via web/TUI |
#### Why MCPJam’s document works with Inspector
CIMD treats the metadata **HTTPS URL** as the OAuth `client_id`. On authorize, Stytch (or any CIMD-capable AS) fetches that JSON and checks:
1. The document’s `client_id` field matches the URL.
2. The `redirect_uri` on the request appears **exactly** in `redirect_uris`.
Inspector web dev uses `http://localhost:6274/oauth/callback` by default (`CLIENT_PORT=6274`). MCPJam’s published metadata **includes that URI** (and `127.0.0.1:6274`), so a smoke run succeeds without Inspector hosting its own CIMD file.
Implications for smoke (expected, not bugs):
- **Connection Info → Client ID** shows the MCPJam metadata URL, not “MCP Inspector”.
- **Consent UI** may show **“MCPJam”** name/logo from the document — you authorized MCPJam’s published OAuth identity, not a separate Inspector-specific registration.
- **No `POST …/v1/oauth2/register`** — Inspector stores the metadata URL locally; Stytch validates by fetching MCPJam’s HTTPS document server-side.
- **Access token JWT** still carries Stytch’s internal `connected-app-test-…` id; see [Verifying CIMD](#verifying-cimd-vs-dcr-network-and-tokens).
This is appropriate for **protocol smoke only**. MCPJam’s doc is a shared dev identity with permissive localhost redirects; production Inspector deployments should use an Inspector-owned metadata URL (future — see below).
**Reference only (won’t work for Inspector callback):** [client.dev/oauth/metadata.json](https://client.dev/oauth/metadata.json) is Stytch’s public CIMD demo document; its `redirect_uris` point at `client.dev`, not localhost.
Or use **DCR** against the same Stytch server with CIMD disabled (see [Alternative: DCR on Stytch](#alternative-dcr-on-stytch-no-metadata-hosting)).
#### Inspector-owned CIMD (future)
Hosting a dedicated `https://…/inspector/oauth-client.json` with Inspector branding and a minimal `redirect_uris` list (only Inspector callbacks) is the right model for non-smoke use. Not required for Stytch smoke today; document shape and Stytch requirements will be added here when we ship a hosted metadata URL.
### Verifying CIMD vs DCR (network and tokens)
Stytch **normalizes** both CIMD and DCR into internal Connected App records. The **access token JWT** looks the same either way — `client_id` in the decoded payload is Stytch’s internal id (e.g. `connected-app-test-0bb7b586-…`), **not** your metadata URL. **Do not use the JWT alone to distinguish CIMD from DCR.**
| Check | CIMD | DCR |
| ----- | ---- | --- |
| **Connection Info → Client ID** | MCPJam metadata URL | Opaque `connected-app-test-…` |
| **Connection Info → Client registration** | Client ID Metadata (CIMD) | Dynamic (DCR) |
| **`POST …/v1/oauth2/token` body → `client_id`** | Metadata URL | `connected-app-test-…` |
| **Authorize URL → `client_id` param** (browser) | Metadata URL | `connected-app-test-…` |
| **`POST …/v1/oauth2/register`** (after cleared OAuth state) | **Absent** | Present on first connect |
| **Decoded access token JWT → `client_id`** | Internal Stytch id *(same shape as DCR)* | Internal Stytch id |
**Definitive smoke signal:** expand the **`POST …/v1/oauth2/token`** row in the network log (auth category). The form body must include the MCPJam metadata URL as `client_id`. Verified example against **`stytch-mcp-demo`** (June 2026):
```
grant_type=authorization_code
code=<authorization_code>
code_verifier=<pkce_verifier>
redirect_uri=http://localhost:6274/oauth/callback
resource=https://stytch-as-demo.val.run/mcp
client_id=https://www.mcpjam.com/.well-known/oauth/client-metadata.json
```
Example decoded access token payload from the same flow (Stytch internal id — **expected**, not a CIMD failure):
```json
{
"aud": ["project-test-d06972e8-6af2-4952-bcb0-44d795ec5d6f"],
"client_id": "connected-app-test-0bb7b586-e16a-43f6-b2b4-6511a146cd1d",
"iss": "https://industrious-dress-4239.customers.stytch.dev",
"scope": "openid email",
"sub": "user-test-5ccf82bc-485d-4919-9e11-40084b02dc28"
}
```
### Procedure (web)
Use **`stytch-mcp-demo`** unless you explicitly need the Management API host. Configure the [MCPJam CIMD URL](#cimd-credentials-for-smoke-mcpjam), then run both phases. **Clear OAuth state** between phases: **Server Settings → OAuth → Clear stored OAuth state**, or (while connected) **Connection Info → OAuth Details → Clear and disconnect**.
**Stytch note:** Both hosts advertise DCR. With CIMD disabled, Inspector may still authorize via dynamic registration. Phase 1 therefore checks that CIMD was **not** used (client id ≠ MCPJam metadata URL), not necessarily that connect hard-fails. For a strict fail-without-CIMD smoke, use the [local composable server](#local-fallback-composable-test-server) with `supportCIMD: true` and `supportDCR: false`.
#### Phase 1 — CIMD off (expect failure or non-CIMD client id)
1. **Client Settings** → **Client ID Metadata Document**: paste the [MCPJam URL](#cimd-credentials-for-smoke-mcpjam) but leave **Use Client ID Metadata Document** **unchecked** (URL stays saved for Phase 2).
2. Add Stytch demo MCP (`https://stytch-as-demo.val.run/mcp`) with **no** per-server OAuth fields.
3. Connect → **401** → authenticate.
4. **Pass (negative control):** either connect **does not** reach an authorized session (e.g. you blocked DCR in your Stytch workspace), **or** Connection Info shows a **dynamic DCR client id** (not the MCPJam metadata URL). If you already see the MCPJam URL as client id, CIMD was still active — clear OAuth storage and confirm the checkbox is off.
#### Phase 2 — CIMD on (expect success)
1. Enable **Use Client ID Metadata Document** (MCPJam URL from [above](#cimd-credentials-for-smoke-mcpjam)).
2. Clear OAuth state for the Stytch server again.
3. Connect → **401** → authenticate → demo login/consent at `stytch-as-demo.val.run` (or `stytch.com` if using Management MCP).
4. **Pass:** connected; `tools/list` succeeds; Connection Info shows **client id = MCPJam metadata URL** and **Client registration = Client ID Metadata (CIMD)**; **`POST …/v1/oauth2/token` request body** shows the same URL as `client_id` (see [Verifying CIMD](#verifying-cimd-vs-dcr-network-and-tokens)).
### Procedure (TUI)
Same two phases (prefer **`stytch-mcp-demo`** URL):
1. **Off:** launch without CIMD enabled in `client.json` and without `--client-metadata-url` → connect (**C**) → complete browser auth if prompted → **expect failure or DCR client id** as in Phase 1 (web).
2. **On:**
```bash
mcp-inspector --tui --catalog configs/mcp.json \
--client-metadata-url https://www.mcpjam.com/.well-known/oauth/client-metadata.json
```
Or enable CIMD in `client.json` and pass `--client-config configs/client.json` (or rely on the default install path after saving via web Client Settings).
Clear OAuth tokens → connect (**C**) → **expect success** with client id = metadata URL.
### Procedure (CLI)
Reuse tokens from a prior web/TUI session, or run after interactive auth in TUI:
```bash
mcp-inspector --cli --catalog configs/mcp.json --server stytch-mcp-demo \
--client-metadata-url https://www.mcpjam.com/.well-known/oauth/client-metadata.json \
--method tools/list
```
Interactive OAuth on CLI prints the authorize URL to stdout (`ConsoleNavigation`); prefer TUI for first-time login smokes.
### Alternative: DCR on Stytch (no metadata hosting)
Same MCP URL (`stytch-as-demo.val.run/mcp` or `mcp.stytch.dev/mcp`), leave CIMD disabled and `clientMetadataUrl` unset — Inspector registers via `registration_endpoint` on the Stytch AS (demo: `POST https://industrious-dress-4239.customers.stytch.dev/v1/oauth2/register` at time of writing). First connect should show that register call in the network log before authorize. Useful when you only want to verify remote DCR + PKCE without hosting CIMD JSON. Token exchange will use the returned `connected-app-test-…` id as `client_id`, not an HTTPS metadata URL.
### Local fallback (composable test server)
For offline CIMD regression — or a **strict** fail-with-CIMD-off / succeed-with-CIMD-on pair without Stytch’s DCR fallback — use the in-repo `TestServerHttp` with `supportCIMD: true` and **`supportDCR: false`** (see `inspectorClient-oauth-e2e.test.ts`). `ensureCimdClientRegistration` allows `http://127.0.0.1` metadata URLs in tests only.
```bash
cd test-servers && npm run build
node build/server-composable.js --config path/to/oauth-cimd-only.json
```
Example composable OAuth flags: `"supportCIMD": true`, `"supportDCR": false`. With CIMD off in Client Settings, connect + authenticate should **fail**; with CIMD on and a valid local metadata URL, it should **succeed**.
---
## 4. EMA (xaa.dev) — cross-reference
Enterprise-managed auth uses a **different** credential model (IdP in Client Settings + resource AS client on server). Documented in [v2_auth_ema.md § Staging validation](v2_auth_ema.md#staging-validation-manual--verified):
- IdP: `https://idp.xaa.dev`
- Resource AS: `https://auth.resource.xaa.dev`
- Local MCP resource: `test-servers/configs/xaa-ema-http.json`
Do not use xaa.dev for standard DCR/static/CIMD ladder testing unless explicitly testing EMA.
**TUI EMA smoke** (IdP in `client.json`, resource server with `enterpriseManaged: true` in catalog):
```bash
mcp-inspector --tui --catalog configs/mcp.json \
--client-config configs/client.json
```
Register **`http://127.0.0.1:6276/oauth/callback`** on the xaa.dev IdP before leg 1 (default runner callback). IdP `clientSecret` belongs in `client.json` (web **Client Settings** or `--client-config` fixture). Per-server resource `oauth.clientSecret` belongs in the OS keychain (web **Server Settings** save path) or a local fixture — TUI/CLI rehydrate both IdP and resource secrets from keychain on catalog load (see prerequisite §4 above).
---
## What to verify (all smokes)
| Check | Where |
| ----- | ----- |
| **Negative control** (mechanism off → fail or wrong client id source) | Connection Info / error toast |
| **Clear OAuth state** between smoke phases | **Web:** Server Settings → OAuth → **Clear stored OAuth state**; or Connection Info → **Clear and disconnect** (standard) / **Clear OAuth state** (EMA). **TUI:** Auth tab → **S** (**Clear OAuth State**; disconnects when connected) |
| **Positive path** (mechanism on → authorized session) | Connect + `tools/list` |
| 401 on first connect without tokens | Network / Requests tab |
| OAuth discovery + correct mechanism (DCR vs static vs CIMD) | Network log (auth category) |
| **CIMD:** token exchange `client_id` = metadata URL | `POST …/v1/oauth2/token` request body |
| **CIMD:** no DCR register call (after cleared state) | Absence of `POST …/v1/oauth2/register` |
| **Stytch:** JWT `client_id` is internal id (not metadata URL) | Decode access token — informational only |
| Authorization redirect opens | Browser |
| Callback completes (`/oauth/callback`) | Web URL or TUI callback server |
| Second connect uses stored access token | Connect without re-login (until expiry) |
| `tools/list` JSON | Tools tab / CLI `--method tools/list` |
| Connection Info: protocol, authorized, client id, registration kind | Connection Info OAuth section |
## Automated parity
| Mode | Real server (this doc) | CI (in-repo) |
| ---- | ---------------------- | ------------ |
| DCR | example-server.modelcontextprotocol.io (or Stytch MCP) | `inspectorClient-oauth-e2e.test.ts` |
| 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 |
## 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).
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.
- **Client credentials grant:** not implemented ([#1225](https://github.com/modelcontextprotocol/inspector/issues/1225)).
## References
- [MCP authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)
- [example-remote-server](https://github.com/modelcontextprotocol/example-remote-server)
- [GitHub MCP remote server](https://github.com/github/github-mcp-server/blob/main/docs/remote-server.md)
- [Stytch MCP demo](https://stytch-as-demo.val.run/mcp) — local CIMD/DCR smoke (test login at demo `/oauth/authorize`)
- [Stytch MCP](https://mcp.stytch.dev/) — Management API MCP (production-style `stytch.com` login)
- [MCPJam CIMD metadata](https://www.mcpjam.com/.well-known/oauth/client-metadata.json) — shared dev metadata URL
- [CIMD for MCP](https://stytch.com/blog/oauth-client-id-metadata-mcp/), [Stytch client types](https://stytch.com/docs/connected-apps/oauth-learn-more/client-types)
- [client.dev](https://client.dev/) — CIMD format reference
- Inspector e2e: `clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts`
- Local OAuth AS: `test-servers/src/test-server-oauth.ts`, `createOAuthTestServerConfig()` in `test-server-fixtures.ts`

View file

@ -40,9 +40,12 @@
* Ping
## OAuth Handling
* Quick Flow
* Guided Flow
* Basic (non debugger) Flow
* `authenticate()` / `completeOAuthFlow()` (SDK-backed authorization-code flow)
* Connection Info for auth debugging
* **Enterprise-managed authorization (EMA / XAA)** — see [EMA / XAA](v2_auth_ema.md)
* **Mid-session authorization** (401 after connect: token refresh, step-up scopes, web remote reconnect) — see [Mid-session auth](v2_auth_mid_session.md)
* **Authorization hardening** (MCP 2026-07-28 SEPs: `iss`, DCR client type, issuer-bound credentials, step-up scope union) — see [Auth hardening](v2_auth_hardening.md)
* **OAuth smoke testing** (manual procedures against hosted servers) — see [Smoke testing](v2_auth_smoke_testing.md)
## Transport Types
* STDIO

View file

@ -243,7 +243,7 @@ Each server entry may carry these Inspector-extension fields at the top level:
- `connectionTimeout` → `Promise.race` wrapper around `InspectorClient.connect()` in the web client.
- `oauth.clientId` / `oauth.clientSecret` / `oauth.scopes` → pre-seeded OAuth client credentials via `InspectorClientOptions.oauth` (the disk-side `oauth` object is lifted into the flat `oauthClientId` / etc. fields on `InspectorServerSettings` for the form).
- **First-connect contract**: settings apply on the *first* outbound request after the entry loads from disk — no need to open the settings form. The browser sends `settings` to the backend in the `/api/mcp/connect` body; the backend reads it from `RemoteConnectRequest` and threads it into `createTransportNode`.
- **Secret storage (#1356)**: `oauth.clientSecret` and stdio `env` values are persisted in the OS keychain (macOS Keychain Services / Windows Credential Manager / Linux libsecret via `@napi-rs/keyring`), keyed by `(serverId, field)` under the service name `mcp-inspector`. Field names: `oauth-client-secret`, `env:<KEY>` (one per stdio env variable). The on-disk `mcp.json` is stripped of these values — `oauth.clientSecret` is omitted entirely, stdio env keys are preserved with empty-string placeholders (`"env": { "API_KEY": "" }`) so the file still documents the env interface the server expects. The wire shape returned by `GET /api/servers` is unchanged from before #1356: the handler rehydrates values from the keychain so browser code sees the same JSON it has always seen. The keychain interactions live in `core/auth/node/secret-store.ts` behind a `SecretStore` interface; `KeyringSecretStore` is the production impl and `InMemorySecretStore` is the test double the integration suite injects via `RemoteServerOptions.secretStore`.
- **Secret storage (#1356)**: `oauth.clientSecret` and stdio `env` values are persisted in the OS keychain (macOS Keychain Services / Windows Credential Manager / Linux libsecret via `@napi-rs/keyring`), keyed by `(serverId, field)` under the service name `mcp-inspector`. Field names: `oauth-client-secret`, `env:<KEY>` (one per stdio env variable). The on-disk `mcp.json` is stripped of these values — `oauth.clientSecret` is omitted entirely, stdio env keys are preserved with empty-string placeholders (`"env": { "API_KEY": "" }`) so the file still documents the env interface the server expects. The wire shape returned by `GET /api/servers` is unchanged from before #1356: the handler rehydrates values from the keychain so browser code sees the same JSON it has always seen. **TUI/CLI rehydration:** the shared `loadServerEntries()` path (`core/mcp/node/servers.ts`) calls `rehydrateMcpConfigFromKeychain()` (`core/mcp/node/server-secrets.ts`) after reading `mcp.json`, so runner clients see the same effective OAuth client secrets and stdio env values as the web catalog list. This path is read-only — it does not migrate plaintext secrets into the keychain (that remains the web `GET /api/servers` migration sweep). The keychain interactions live in `core/auth/node/secret-store.ts` behind a `SecretStore` interface; `KeyringSecretStore` is the production impl and `InMemorySecretStore` is the test double the integration suite injects via `RemoteServerOptions.secretStore`.
- **Migration**: on every `GET /api/servers`, the handler walks the freshly-read config and, for any entry that still carries plaintext secrets (older Inspector builds, hand-edited files, files imported from another tool), lifts each value into the keychain and rewrites the file with the stripped shape. The migration is idempotent — when the keychain already holds a value for `(serverId, field)`, the keychain wins and the disk plaintext is dropped unread. After the rewrite the disk file no longer contains the secret material.
- **Linux without libsecret**: `KeyringSecretStore` is *tolerant* — only the `set` operation throws `KeychainUnavailableError` (translated to a `503` by the handlers); `get` returns `null` and the destructive operations silently no-op. The result is that no-secret flows (creating a stdio server with no env values, deleting an entry, reading the list, the defensive sweep on POST) all work normally on a minimal Linux box without libsecret. Only the moments where a secret would actually be lost — saving an OAuth client secret, saving a stdio env value, or migrating a plaintext value into the keychain — surface a clear error. macOS and Windows always have a working keychain so this only matters on minimal Linux installs.
- **Migration tolerance**: when migration encounters `KeychainUnavailableError`, the GET handler logs a warning, leaves the on-disk plaintext untouched, and serves the (still-plaintext) response. Subsequent reads retry — installing libsecret later lifts the secrets on the next GET without any user action.