diff --git a/AGENTS.md b/AGENTS.md index fe07c080..a69546a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,9 +22,13 @@ inspector/ │ ├── tui/ # TUI client (Ink + React, tsup bundle) │ ├── launcher/ # Shared launcher (relative imports into sibling build/ outputs) ├── core/ # Shared core code (no package.json — consumed via the `@inspector/core` vite alias) -│ ├── auth/ # OAuth: state machine, providers, discovery, storage +│ ├── auth/ # OAuth: state machine, providers, discovery, storage; +│ │ # mid-session recovery (challenge.ts WWW-Authenticate +│ │ # parsing, scopes.ts SEP-2350 scope union, oauthUx.ts +│ │ # shared copy, mcpAuth.ts force-reauthorization) │ │ ├── browser/ # Browser-side OAuth (sessionStorage, BrowserNavigation) -│ │ ├── node/ # Node-side OAuth (NodeOAuthStorage, OAuthCallbackServer) +│ │ ├── node/ # Node-side OAuth (NodeOAuthStorage, OAuthCallbackServer, +│ │ │ # runner-interactive-oauth loopback callback flow) │ │ └── remote/ # Remote OAuth storage (delegates to the remote server) │ ├── json/ # JSON utilities and parameter/argument conversion │ ├── logging/ # Silent pino logger singleton @@ -189,14 +193,15 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i ### Lint-fixed, Formatted code - ALWAYS do `npm run format` before committing — it auto-fixes any Prettier issues. `validate` runs `format:check` (the non-fixing variant) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it. -- ALWAYS do `npm run validate` before pushing any changes — from the repo root it chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each delegates to that client's own `npm run validate` = `format:check` + `lint` + `build` + `test` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). +- **`npm run ci` before pushing** — runs the same steps as `.github/workflows/main.yml` (minus `npm install`): `validate` → web `test:integration` → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). Use this when you want CI parity; expect several minutes. **`npm run validate`** remains the fast loop during development (unit tests only — no web integration, smoke, or Storybook). +- ALWAYS do `npm run format` before committing, then **`npm run ci`** (or at minimum `npm run validate`) before pushing. From the repo root, `validate` chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each delegates to that client's own `npm run validate` = `format:check` + `lint` + `build` + `test` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && build && test`. - Before pushing, also run **`npm run coverage`** — `validate` is fast and does NOT enforce the per-file gate (or, for web, run the integration project); `coverage` does both. **CI runs `coverage`** too (#1550), so the gate blocks PRs that regress below 90 — running it locally first just catches failures before CI does. CI has no separate `test:integration` step: web's `test:coverage` already runs the integration project. - **`smoke` is a separate top-level target, NOT part of `validate`.** Run it (or the individual `smoke:*`) after a build/validate: `npm run validate && npm run smoke`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `smoke`. Storybook is the only CI step left out (see below). - `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. - `smoke:web` (`scripts/smoke-web.mjs`) starts `mcp-inspector --web` (prod, no `--dev`) against the built `clients/web/dist` and asserts `GET /` serves the SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. Prod `--web` serves from `clients/web/dist`, which ships in the published package but is absent in a fresh checkout — the runner builds it on demand (`build:client` = `vite build`) on first launch, or exits with an actionable error if that build can't run (see `clients/web/server/ensure-web-build.ts` and the launcher README). `--dev` runs Vite directly and never needs `dist`. - `smoke:cli` (`scripts/smoke-cli.mjs`) drives `mcp-inspector --cli` through the built launcher against the bundled stdio test server via a temp `--catalog`: it asserts `tools/list` returns the server's tools (real connect over stdio), the default writable catalog is seeded empty on first run, a missing read-only `--config` errors without seeding, and `--catalog` + `--config` is rejected. `smoke:tui` (`scripts/smoke-tui.mjs`) launches `mcp-inspector --tui --catalog ` and asserts the Ink app renders its first frame (the "MCP Servers" panel) within a timeout, then SIGTERMs it — a shallow boot/render check, not full interaction. **`smoke:tui` is local-only: it self-skips when `process.env.CI` is set**, because the Ink TUI needs a real TTY (raw mode) that headless CI lacks — so run it (via `npm run smoke`) on your own machine before pushing. Both build `test-servers/build` on demand if it's missing. -- Also run `npm run test:storybook` from `clients/web/` before pushing — it executes every story's `play` function in headless Chromium via `@vitest/browser-playwright` (~10s). CI runs this as a separate step (from `clients/web`) after `validate`; failures block merge. It is kept out of `validate` because it needs the Playwright browser binary and is much slower than the unit suite. (There is no root-level `test:storybook` aggregate — run it in the web client.) +- Storybook play-function tests (`clients/web` `test:storybook`) run in headless Chromium via `@vitest/browser-playwright` (~10s). They are part of `npm run ci` (which installs Playwright chromium first); kept out of `validate` because they need the browser binary and are slower than the unit suite. ### Typescript instructions - Use TypeScript for all new code diff --git a/clients/cli/README.md b/clients/cli/README.md index 36399b79..098fe620 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -108,9 +108,15 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en ### CLI-specific (OAuth for HTTP servers) -The CLI **reuses** OAuth tokens from `~/.mcp-inspector/storage/oauth.json` (same file as the TUI). Complete first-time authorization in the **web** or **TUI** client, then run one-shot CLI commands against HTTP/SSE servers without signing in again. +The CLI runs the same loopback callback server as the TUI (`http://127.0.0.1:6276/oauth/callback` by default). On connect **401** or mid-session interactive auth (re-login / step-up), it: -The CLI does **not** start a local callback server or retry connect on 401. If tokens are missing or expired, connect fails; `ConsoleNavigation` may print an authorize URL to stdout, but the CLI cannot finish the redirect flow. Use the TUI for interactive runner OAuth until Phase 4 adds a CLI callback server. +1. Starts the callback listener on `--callback-url` (or `MCP_OAUTH_CALLBACK_URL`) +2. Prints the authorization URL to the console (`ConsoleNavigation`) +3. Waits for the browser redirect, exchanges the code, and retries connect or the failed RPC + +**Step-up (standard OAuth):** when an RPC needs extra scopes, the CLI prompts on stderr: `Proceed with step-up authorization? [y/N]`. **y** continues; **N** exits with an error. EMA step-up re-mints silently (no prompt). + +**Shared OAuth storage:** the CLI **reuses** tokens from `~/.mcp-inspector/storage/oauth.json` when they already exist (same file as other Inspector clients). That is passive file sharing, not launching another app. **Shared with TUI** (config only, not interactive login): @@ -121,13 +127,13 @@ The CLI does **not** start a local callback server or retry connect on 401. If t #### OAuth callback URL -| Surface | Default callback | -| ------- | ----------------------------------------------------------------------------------------- | -| **Web** | `http://localhost:6274/oauth/callback` | -| **TUI** | `http://127.0.0.1:6276/oauth/callback` (interactive — callback server) | -| **CLI** | `http://127.0.0.1:6276/oauth/callback` (redirect URI in OAuth metadata only; no listener) | +| Surface | Default callback | +| ------- | ---------------- | +| **Web** | `http://localhost:6274/oauth/callback` | +| **TUI** | `http://127.0.0.1:6276/oauth/callback` (interactive — callback server) | +| **CLI** | `http://127.0.0.1:6276/oauth/callback` (interactive — same callback server as TUI) | -Register `http://127.0.0.1:6276/oauth/callback` on static or enterprise IdPs that require pre-registered redirect URIs before using the **TUI** (or when your OAuth app expects that URI). Override with `--callback-url` or `MCP_OAUTH_CALLBACK_URL`. The CLI passes this value as `redirect_uri` when an OAuth flow runs, but does not listen on the port. +Register `http://127.0.0.1:6276/oauth/callback` on static or enterprise IdPs that require pre-registered redirect URIs before using the **TUI** or **CLI**. Override with `--callback-url` or `MCP_OAUTH_CALLBACK_URL`. Only one process should bind the default port at a time. #### Flags @@ -147,7 +153,7 @@ npx @modelcontextprotocol/inspector --cli --catalog mcp.json --server my-http-se --method tools/list ``` -See [EMA / enterprise-managed auth](../../specification/v2_auth_ema.md) and [OAuth smoke testing](../../specification/v2_auth_smoke_testing.md) for configuration details and staging servers. +See [EMA / enterprise-managed auth](../../specification/v2_auth_ema.md) and [OAuth smoke testing](../../specification/v2_auth_smoke_testing.md) (§3 Stytch/CIMD; [§5 mid-session manual validation](v2_auth_smoke_testing.md#5-mid-session-auth--step-up--manual-validation) — CLI **C1–C2**). ## Exit codes & error envelopes diff --git a/clients/cli/__tests__/README.md b/clients/cli/__tests__/README.md index b1fcca9e..09aec386 100644 --- a/clients/cli/__tests__/README.md +++ b/clients/cli/__tests__/README.md @@ -17,6 +17,7 @@ npm run test:cli # cli.test.ts npm run test:cli-tools # tools.test.ts npm run test:cli-headers # headers.test.ts npm run test:cli-metadata # metadata.test.ts +npx vitest run oauth-interactive.test.ts cliOAuth.test.ts # OAuth interactive smoke parity ``` ## How the CLI is exercised @@ -42,7 +43,10 @@ root provides a further end-to-end check of the binary.) - `metadata.test.ts` - Metadata functionality: General metadata, tool-specific metadata, parsing, merging, validation - `methods.test.ts` - Method/option-validation paths not covered elsewhere (resource templates, missing/invalid options, the `--` target separator) - `error-handler.test.ts` - The binary's `handleError` error sink, exercised in-process with `process.exit` stubbed -- `e2e.test.ts` - Out-of-process spawn of the built binary (exit codes + boot) +- `oauth-runner.test.ts` - OAuth flag wiring (`--client-config`, `--callback-url`, CIMD overrides) +- `cliOAuth.test.ts` - Unit tests for `cliOAuth.ts` (step-up confirm, helper wiring, retry) +- `oauth-interactive.test.ts` - **Integration** smoke parity for CLI interactive OAuth: connect-time callback server + step-up **y/N** against composable `TestServerHttp` (auto-completes authorize URL programmatically; not a subprocess binary e2e) +- `e2e.test.ts` - Out-of-process spawn of the built binary (exit codes + boot; no OAuth) ## Helpers diff --git a/clients/cli/__tests__/cliOAuth.test.ts b/clients/cli/__tests__/cliOAuth.test.ts new file mode 100644 index 00000000..42178e29 --- /dev/null +++ b/clients/cli/__tests__/cliOAuth.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; +import { MutableRedirectUrlProvider } from "@inspector/core/auth/index.js"; +import * as runnerInteractive from "@inspector/core/auth/node/runner-interactive-oauth.js"; +import { + handleCliAuthRecoveryRequired, + isStandardOAuthStepUp, + runCliInteractiveOAuth, + withCliAuthRecoveryRetry, +} from "../src/cliOAuth.js"; + +describe("cliOAuth", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("isStandardOAuthStepUp", () => { + it("returns true for insufficient_scope on non-EMA servers", () => { + expect( + isStandardOAuthStepUp( + { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, + {}, + ), + ).toBe(true); + }); + + it("returns false for EMA servers", () => { + expect( + isStandardOAuthStepUp( + { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, + { enterpriseManaged: true }, + ), + ).toBe(false); + }); + }); + + it("runCliInteractiveOAuth writes success to stderr", async () => { + vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({ + kind: "success", + }); + const client = { + authenticate: vi.fn(), + beginInteractiveAuthorization: vi.fn(), + completeOAuthFlow: vi.fn(), + checkAuthChallengeSatisfied: vi.fn(), + }; + const redirectUrlProvider = new MutableRedirectUrlProvider(); + const stderrSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + + await runCliInteractiveOAuth(client, redirectUrlProvider, { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }); + + expect(stderrSpy).toHaveBeenCalledWith("Authorization complete.\n"); + }); + + it("runCliInteractiveOAuth throws when scopes remain insufficient", async () => { + const challenge = { + reason: "insufficient_scope" as const, + requiredScopes: ["weather:read"], + }; + vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({ + kind: "insufficient_scope", + challenge, + }); + const client = { + authenticate: vi.fn(), + beginInteractiveAuthorization: vi.fn(), + completeOAuthFlow: vi.fn(), + checkAuthChallengeSatisfied: vi.fn(), + }; + + await expect( + runCliInteractiveOAuth( + client, + new MutableRedirectUrlProvider(), + { hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" }, + { authChallenge: challenge }, + ), + ).rejects.toThrow(/required scopes were not granted/); + }); + + it("handleCliAuthRecoveryRequired declines standard step-up when user says no", async () => { + const runSpy = vi + .spyOn(runnerInteractive, "runRunnerInteractiveOAuth") + .mockResolvedValue({ kind: "success" }); + const client = { + authenticate: vi.fn(), + beginInteractiveAuthorization: vi.fn(), + completeOAuthFlow: vi.fn(), + checkAuthChallengeSatisfied: vi.fn(), + }; + const error = new AuthRecoveryRequiredError( + new URL("https://as.example/authorize"), + { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, + ); + + await expect( + handleCliAuthRecoveryRequired( + client, + error, + new MutableRedirectUrlProvider(), + { hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" }, + {}, + async () => false, + ), + ).rejects.toThrow("Step-up authorization declined."); + + expect(runSpy).not.toHaveBeenCalled(); + }); + + it("handleCliAuthRecoveryRequired runs OAuth after step-up confirm", async () => { + const runSpy = vi + .spyOn(runnerInteractive, "runRunnerInteractiveOAuth") + .mockResolvedValue({ kind: "success" }); + const client = { + authenticate: vi.fn(), + beginInteractiveAuthorization: vi.fn(), + completeOAuthFlow: vi.fn(), + checkAuthChallengeSatisfied: vi.fn(), + }; + const authorizationUrl = new URL("https://as.example/authorize"); + const challenge = { + reason: "insufficient_scope" as const, + requiredScopes: ["weather:read"], + }; + const error = new AuthRecoveryRequiredError(authorizationUrl, challenge); + + await handleCliAuthRecoveryRequired( + client, + error, + new MutableRedirectUrlProvider(), + { hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" }, + {}, + async () => true, + ); + + expect(runSpy).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationUrl, + authChallenge: challenge, + }), + ); + }); + + it("handleCliAuthRecoveryRequired skips step-up when storage already satisfies", async () => { + const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth"); + const client = { + authenticate: vi.fn(), + beginInteractiveAuthorization: vi.fn(), + completeOAuthFlow: vi.fn(), + checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true), + }; + const error = new AuthRecoveryRequiredError( + new URL("https://as.example/authorize"), + { + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }, + ); + + await handleCliAuthRecoveryRequired( + client, + error, + new MutableRedirectUrlProvider(), + { hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" }, + ); + + expect(runSpy).not.toHaveBeenCalled(); + }); + + it("handleCliAuthRecoveryRequired skips OAuth when storage already satisfies reauth", async () => { + const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth"); + const client = { + authenticate: vi.fn(), + beginInteractiveAuthorization: vi.fn(), + completeOAuthFlow: vi.fn(), + checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true), + }; + const error = new AuthRecoveryRequiredError( + new URL("https://as.example/authorize"), + { reason: "token_expired" }, + ); + + await handleCliAuthRecoveryRequired( + client, + error, + new MutableRedirectUrlProvider(), + { hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" }, + ); + + expect(runSpy).not.toHaveBeenCalled(); + }); + + it("withCliAuthRecoveryRetry reruns the operation after interactive recovery", async () => { + vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({ + kind: "success", + }); + const client = { + authenticate: vi.fn(), + beginInteractiveAuthorization: vi.fn(), + completeOAuthFlow: vi.fn(), + checkAuthChallengeSatisfied: vi.fn(), + }; + const fn = vi + .fn() + .mockRejectedValueOnce( + new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), { + reason: "unauthorized", + }), + ) + .mockResolvedValueOnce("ok"); + + const result = await withCliAuthRecoveryRetry( + client, + new MutableRedirectUrlProvider(), + { hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" }, + { enterpriseManaged: true }, + fn, + async () => true, + ); + + expect(result).toBe("ok"); + expect(fn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/clients/cli/__tests__/oauth-interactive.test.ts b/clients/cli/__tests__/oauth-interactive.test.ts new file mode 100644 index 00000000..358caaf7 --- /dev/null +++ b/clients/cli/__tests__/oauth-interactive.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + TestServerHttp, + waitForOAuthWellKnown, + getDefaultServerConfig, + createOAuthTestServerConfig, + clearOAuthTestData, +} from "@modelcontextprotocol/inspector-test-server"; +import { InspectorClient } from "@inspector/core/mcp/index.js"; +import { createTransportNode } from "@inspector/core/mcp/node/index.js"; +import { + CallbackNavigation, + MutableRedirectUrlProvider, +} from "@inspector/core/auth/index.js"; +import { NodeOAuthStorage } from "@inspector/core/auth/node/index.js"; +import { + connectInspectorWithOAuth, + withCliAuthRecoveryRetry, +} from "../src/cliOAuth.js"; +import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; + +const oauthTestStatePath = join( + tmpdir(), + `mcp-oauth-${process.pid}-cli-interactive.json`, +); + +async function completeOAuthAuthorization( + authorizationUrl: URL, +): Promise { + let response = await fetch(authorizationUrl.toString(), { + redirect: "manual", + }); + + // Local composable test AS shows an HTML consent page on GET; approve via POST. + if (response.status === 200) { + const body = new URLSearchParams(authorizationUrl.searchParams); + response = await fetch( + `${authorizationUrl.origin}${authorizationUrl.pathname}`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + redirect: "manual", + }, + ); + } + + if (response.status !== 302 && response.status !== 301) { + throw new Error( + `Expected redirect (302/301), got ${response.status}: ${await response.text()}`, + ); + } + const location = response.headers.get("location"); + if (!location) { + throw new Error("Missing Location header"); + } + const redirect = new URL(location, authorizationUrl.origin); + const code = redirect.searchParams.get("code"); + if (!code) { + throw new Error("Missing authorization code"); + } + return code; +} + +function createAutoCompleteNavigation( + redirectUrlProvider: MutableRedirectUrlProvider, +) { + return new CallbackNavigation(async (url) => { + const code = await completeOAuthAuthorization(url); + const redirect = redirectUrlProvider.redirectUrl; + if (!redirect) { + throw new Error("redirectUrl not set"); + } + await fetch(`${redirect}?code=${encodeURIComponent(code)}`); + }); +} + +const callbackUrlConfig = { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", +}; +const presetRedirectUrl = "http://127.0.0.1:6276/oauth/callback"; + +describe("CLI interactive OAuth (integration)", () => { + let mcpServer: TestServerHttp | null = null; + + afterEach(async () => { + if (mcpServer) { + await mcpServer.stop(); + mcpServer = null; + } + clearOAuthTestData(); + try { + rmSync(oauthTestStatePath, { force: true }); + } catch { + // ignore + } + }, 30_000); + + it("connects to an OAuth-protected server via the loopback callback server", async () => { + const serverConfig = { + ...getDefaultServerConfig(), + serverType: "streamable-http" as const, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportDCR: true, + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const redirectUrlProvider = new MutableRedirectUrlProvider(); + redirectUrlProvider.redirectUrl = presetRedirectUrl; + const navigation = createAutoCompleteNavigation(redirectUrlProvider); + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + { + environment: { + transport: createTransportNode, + oauth: { + storage: new NodeOAuthStorage(oauthTestStatePath), + navigation, + redirectUrlProvider, + }, + }, + directAuthRecovery: true, + oauth: {}, + }, + ); + + await connectInspectorWithOAuth( + client, + { type: "streamable-http", url: `${serverUrl}/mcp` }, + redirectUrlProvider, + callbackUrlConfig, + ); + + const tools = await client.listTools(); + expect(tools.tools.length).toBeGreaterThan(0); + await client.disconnect(); + }, 30_000); + + it("retries an RPC after step-up authorization when the user confirms", async () => { + const baseConfig = getDefaultServerConfig(); + + const serverConfig = { + ...baseConfig, + serverType: "streamable-http" as const, + tools: baseConfig.tools!.map((tool) => + tool.name === "get_temp" + ? { ...tool, requiredScopes: ["weather:read"] } + : tool, + ), + ...createOAuthTestServerConfig({ + requireAuth: true, + supportRefreshTokens: true, + supportDCR: true, + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const redirectUrlProvider = new MutableRedirectUrlProvider(); + redirectUrlProvider.redirectUrl = presetRedirectUrl; + const navigation = createAutoCompleteNavigation(redirectUrlProvider); + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + { + environment: { + transport: createTransportNode, + oauth: { + storage: new NodeOAuthStorage(oauthTestStatePath), + navigation, + redirectUrlProvider, + }, + }, + directAuthRecovery: true, + oauth: { + scope: "mcp tools:read", + }, + }, + ); + + await connectInspectorWithOAuth( + client, + { type: "streamable-http", url: `${serverUrl}/mcp` }, + redirectUrlProvider, + callbackUrlConfig, + ); + + const tools = await client.listTools(); + const getTempTool = tools.tools.find((tool) => tool.name === "get_temp"); + expect(getTempTool).toBeDefined(); + + let stepUpPrompted = false; + const originalWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk, ...rest) => { + const text = typeof chunk === "string" ? chunk : String(chunk); + if (text.includes("Proceed with step-up authorization?")) { + stepUpPrompted = true; + } + return originalWrite(chunk, ...rest); + }) as typeof process.stderr.write; + + try { + const result = await withCliAuthRecoveryRetry( + client, + redirectUrlProvider, + callbackUrlConfig, + {}, + () => + client.callTool(getTempTool!, { + city: "NYC", + units: "C", + }), + async () => true, + ); + + expect(stepUpPrompted).toBe(true); + expect(result.success).toBe(true); + } finally { + process.stderr.write = originalWrite; + await client.disconnect(); + } + }, 30_000); +}); diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index a8039cbe..b5d8e42d 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -28,6 +28,10 @@ import { MutableRedirectUrlProvider, } from "@inspector/core/auth/index.js"; import { NodeOAuthStorage } from "@inspector/core/auth/node/index.js"; +import { + connectInspectorWithOAuth, + withCliAuthRecoveryRetry, +} from "./cliOAuth.js"; import { DEFAULT_RUNNER_OAUTH_CALLBACK_URL, formatRunnerOAuthRedirectUrl, @@ -67,8 +71,8 @@ async function callMethod( serverSettings: InspectorServerSettings | undefined, args: MethodArgs & { method: string }, clientConfig: ClientConfig, - cliAuthOverrides?: RunnerClientConfigOverrides, - callbackUrlConfig?: RunnerOAuthCallbackConfig, + cliAuthOverrides: RunnerClientConfigOverrides, + callbackUrlConfig: RunnerOAuthCallbackConfig, ): Promise { const __dirname = dirname(fileURLToPath(import.meta.url)); const packageJsonPath = join(__dirname, "../package.json"); @@ -89,9 +93,8 @@ async function callMethod( }; const redirectUrlProvider = new MutableRedirectUrlProvider(); if (isOAuthCapableServerConfig(serverConfig)) { - redirectUrlProvider.redirectUrl = formatRunnerOAuthRedirectUrl( - callbackUrlConfig!, - ); + redirectUrlProvider.redirectUrl = + formatRunnerOAuthRedirectUrl(callbackUrlConfig); environment.oauth = { storage: new NodeOAuthStorage(), navigation: new ConsoleNavigation(), @@ -122,9 +125,7 @@ async function callMethod( null; let managedPromptsState: ManagedPromptsState | null = null; - try { - await inspectorClient.connect(); - + const runMethod = async (): Promise => { let result: McpResponse; if (args.method === "tools/list" || args.method === "tools/call") { @@ -248,6 +249,26 @@ async function callMethod( ); } + return result; + }; + + try { + await connectInspectorWithOAuth( + inspectorClient, + serverConfig, + redirectUrlProvider, + callbackUrlConfig, + serverSettings, + ); + + const result = await withCliAuthRecoveryRetry( + inspectorClient, + redirectUrlProvider, + callbackUrlConfig, + serverSettings, + runMethod, + ); + await awaitableLog(JSON.stringify(result, null, 2)); } finally { managedToolsState?.destroy(); diff --git a/clients/cli/src/cliOAuth.ts b/clients/cli/src/cliOAuth.ts new file mode 100644 index 00000000..24aa6058 --- /dev/null +++ b/clients/cli/src/cliOAuth.ts @@ -0,0 +1,184 @@ +import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; +import { + AuthRecoveryRequiredError, + isStandardOAuthStepUp as isCoreStandardOAuthStepUp, + isUnauthorizedError, + stepUpConfirmMessage, + stepUpInsufficientScopeMessage, + MutableRedirectUrlProvider, +} from "@inspector/core/auth/index.js"; +import { + createOAuthCallbackServer, + runRunnerInteractiveOAuth, +} from "@inspector/core/auth/node/index.js"; +import type { RunnerOAuthCallbackConfig } from "@inspector/core/auth/node/runner-oauth-callback.js"; +import type { InspectorClient } from "@inspector/core/mcp/index.js"; +import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; +import { isOAuthCapableServerConfig } from "@inspector/core/client/runner.js"; +import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; +import { createInterface } from "node:readline/promises"; + +/** Standard-OAuth step-up (not EMA silent re-mint). */ +export function isStandardOAuthStepUp( + challenge: AuthChallenge, + settings?: InspectorServerSettings, +): boolean { + return isCoreStandardOAuthStepUp(challenge, { + enterpriseManaged: settings?.enterpriseManaged, + }); +} + +async function confirmStepUpFromStdin(): Promise { + const rl = createInterface({ input: process.stdin, output: process.stderr }); + try { + const answer = await rl.question(""); + const normalized = answer.trim().toLowerCase(); + return normalized === "y" || normalized === "yes"; + } finally { + rl.close(); + } +} + +async function promptStepUpConfirm( + challenge: AuthChallenge, + confirmStepUp: () => Promise, +): Promise { + process.stderr.write(`${stepUpConfirmMessage(challenge)}\n`); + process.stderr.write("Proceed with step-up authorization? [y/N] "); + return confirmStepUp(); +} + +export async function runCliInteractiveOAuth( + client: InspectorClient, + redirectUrlProvider: MutableRedirectUrlProvider, + callbackUrlConfig: RunnerOAuthCallbackConfig, + options?: { + authorizationUrl?: URL; + authChallenge?: AuthChallenge; + }, +): Promise { + const result = await runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: callbackUrlConfig, + createCallbackServer: createOAuthCallbackServer, + authorizationUrl: options?.authorizationUrl, + authChallenge: options?.authChallenge, + }); + + if (result.kind === "insufficient_scope") { + throw new Error(stepUpInsufficientScopeMessage(result.challenge)); + } + if (result.kind === "success") { + process.stderr.write("Authorization complete.\n"); + } +} + +export async function handleCliAuthRecoveryRequired( + client: InspectorClient, + error: AuthRecoveryRequiredError, + redirectUrlProvider: MutableRedirectUrlProvider, + callbackUrlConfig: RunnerOAuthCallbackConfig, + serverSettings?: InspectorServerSettings, + confirmStepUp: () => Promise = confirmStepUpFromStdin, +): Promise { + if (isStandardOAuthStepUp(error.authChallenge, serverSettings)) { + if (await client.checkAuthChallengeSatisfied(error.authChallenge)) { + return; + } + const proceed = await promptStepUpConfirm( + error.authChallenge, + confirmStepUp, + ); + if (!proceed) { + throw new Error("Step-up authorization declined."); + } + } else if (await client.checkAuthChallengeSatisfied(error.authChallenge)) { + return; + } + + await runCliInteractiveOAuth(client, redirectUrlProvider, callbackUrlConfig, { + authorizationUrl: error.authorizationUrl, + ...(error.authChallenge.reason === "insufficient_scope" && { + authChallenge: error.authChallenge, + }), + }); +} + +export async function connectInspectorWithOAuth( + inspectorClient: InspectorClient, + serverConfig: MCPServerConfig, + redirectUrlProvider: MutableRedirectUrlProvider, + callbackUrlConfig: RunnerOAuthCallbackConfig, + serverSettings?: InspectorServerSettings, +): Promise { + try { + await inspectorClient.connect(); + } catch (err) { + if (!isOAuthCapableServerConfig(serverConfig)) { + throw err; + } + + if (err instanceof AuthRecoveryRequiredError) { + if ( + await inspectorClient.checkAuthChallengeSatisfied(err.authChallenge) + ) { + await inspectorClient.connect(); + return; + } + await handleCliAuthRecoveryRequired( + inspectorClient, + err, + redirectUrlProvider, + callbackUrlConfig, + serverSettings, + ); + await inspectorClient.connect(); + return; + } + + if (isUnauthorizedError(err)) { + await inspectorClient.disconnect().catch(() => {}); + await runCliInteractiveOAuth( + inspectorClient, + redirectUrlProvider, + callbackUrlConfig, + ); + await inspectorClient.connect(); + return; + } + + throw err; + } +} + +/** + * Run `fn` once; on {@link AuthRecoveryRequiredError}, complete interactive OAuth + * and retry `fn` a single time (no further recovery attempts). + */ +export async function withCliAuthRecoveryRetry( + inspectorClient: InspectorClient, + redirectUrlProvider: MutableRedirectUrlProvider, + callbackUrlConfig: RunnerOAuthCallbackConfig, + serverSettings: InspectorServerSettings | undefined, + fn: () => Promise, + confirmStepUp: () => Promise = confirmStepUpFromStdin, +): Promise { + try { + return await fn(); + } catch (err) { + if (!(err instanceof AuthRecoveryRequiredError)) { + throw err; + } + await handleCliAuthRecoveryRequired( + inspectorClient, + err, + redirectUrlProvider, + callbackUrlConfig, + serverSettings, + confirmStepUp, + ); + process.stderr.write("Authorization complete. Retrying…\n"); + return await fn(); + } +} diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index ddefbecc..7ea98657 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -55,6 +55,9 @@ const h = vi.hoisted(() => { clearOAuthTokens: vi.fn(), completeOAuthFlow: vi.fn(async (): Promise => {}), getOAuthState: vi.fn(async () => undefined), + callTool: vi.fn(), + checkAuthChallengeSatisfied: vi.fn(async () => false), + handleAuthChallenge: vi.fn(async () => ({ kind: "satisfied" as const })), }; // Captured options from the most recent callbackServer.start(), so a test can // drive the onCallback / onError handlers the OAuth flows register. @@ -95,6 +98,11 @@ const h = vi.hoisted(() => { completeOAuthFlow = (...a: unknown[]) => clientSpies.completeOAuthFlow(...a); getOAuthState = (...a: unknown[]) => clientSpies.getOAuthState(...a); + callTool = (...a: unknown[]) => clientSpies.callTool(...a); + checkAuthChallengeSatisfied = (...a: unknown[]) => + clientSpies.checkAuthChallengeSatisfied(...a); + handleAuthChallenge = (...a: unknown[]) => + clientSpies.handleAuthChallenge(...a); readResource = vi.fn(async () => ({ result: { contents: [{ uri: "file://x", text: "hello" }] }, })); @@ -185,16 +193,25 @@ vi.mock("@inspector/core/auth/index.js", async (importOriginal) => { }, }; }); -vi.mock("@inspector/core/auth/node/index.js", () => ({ - createOAuthCallbackServer: h.createOAuthCallbackServer, - NodeOAuthStorage: class {}, -})); +vi.mock("@inspector/core/auth/node/index.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + createOAuthCallbackServer: h.createOAuthCallbackServer, + NodeOAuthStorage: class {}, + }; +}); vi.mock("../src/utils/openUrl.js", () => ({ openUrl: h.openUrl, })); import App from "../src/App.js"; import type { TuiServer } from "../src/tui-servers.js"; +import { + AuthRecoveryRequiredError, + EMA_STEP_UP_PENDING_URL, +} from "@inspector/core/auth/challenge.js"; const tick = () => new Promise((r) => setTimeout(r, 25)); const callbackUrlConfig = { hostname: "127.0.0.1", port: 0, pathname: "/cb" }; @@ -234,6 +251,27 @@ function oneHttp(): Record { }; } +function oneEmaHttp(): Record { + return { + ema: { + config: { type: "streamable-http", url: "http://localhost:8080/mcp" }, + settings: { + requestTimeout: 0, + metadata: [], + headers: [], + env: [], + roots: [], + maxFetchRequests: 1000, + taskTtl: 0, + connectionTimeout: 0, + oauthClientId: "client_ema_test", + oauthScopes: "tools:read", + enterpriseManaged: true, + }, + } as never, + }; +} + // Mixed catalog: an OAuth-capable http server first (auto-selected) followed by // a stdio server � drives per-server tab gating + the tab-switch-away effects. function httpThenStdio(): Record { @@ -466,6 +504,11 @@ beforeEach(() => { h.clientSpies.completeOAuthFlow.mockResolvedValue(undefined); h.clientSpies.getOAuthState.mockReset(); h.clientSpies.getOAuthState.mockResolvedValue(undefined); + h.clientSpies.callTool.mockReset(); + h.clientSpies.checkAuthChallengeSatisfied.mockReset(); + h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); + h.clientSpies.handleAuthChallenge.mockReset(); + h.clientSpies.handleAuthChallenge.mockResolvedValue({ kind: "satisfied" }); }); afterEach(() => { @@ -936,4 +979,65 @@ describe("App (OAuth flows)", () => { await h.cb.opts!.onCallback({ code: "x" }); await expectFrame(r, "cb-string"); }); + + it("shows EMA step-up confirmation on tool auth recovery instead of auto OAuth", async () => { + const challenge = { + reason: "insufficient_scope" as const, + requiredScopes: ["env:read"], + authorizationScopes: ["tools:read", "env:read"], + context: { toolName: "get-env" }, + }; + h.clientSpies.callTool.mockRejectedValue( + new AuthRecoveryRequiredError(EMA_STEP_UP_PENDING_URL, challenge, { + emaStepUpConfirm: true, + }), + ); + h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); + h.ctrl.status = "connected"; + h.ctrl.tools = [sampleTool]; + const r = await mount(oneEmaHttp()); + await press(r, ["t", TAB, ENTER]); + await expectFrame(r, "MOCK_FORM"); + await press(r, [ENTER]); + await waitUntil(() => h.clientSpies.callTool.mock.calls.length > 0); + await expectFrame(r, "organization before it can continue"); + const frame = r.lastFrame() ?? ""; + expect(frame).toMatch(/organization|get-env/i); + expect(frame).not.toMatch(/opens browser/i); + expect(frame).not.toMatch(/OAuth: authenticating/i); + expect(h.callbackStart).not.toHaveBeenCalled(); + }); + + it("runs confirmed EMA step-up via handleAuthChallenge when user authorizes", async () => { + const challenge = { + reason: "insufficient_scope" as const, + requiredScopes: ["env:read"], + authorizationScopes: ["tools:read", "env:read"], + context: { toolName: "get-env" }, + }; + h.clientSpies.callTool.mockRejectedValue( + new AuthRecoveryRequiredError(EMA_STEP_UP_PENDING_URL, challenge, { + emaStepUpConfirm: true, + }), + ); + h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); + h.clientSpies.handleAuthChallenge.mockResolvedValue({ kind: "satisfied" }); + h.ctrl.status = "connected"; + h.ctrl.tools = [sampleTool]; + const r = await mount(oneEmaHttp()); + await press(r, ["t", TAB, ENTER]); + await expectFrame(r, "MOCK_FORM"); + await press(r, [ENTER]); + await waitUntil(() => h.clientSpies.callTool.mock.calls.length > 0); + await expectFrame(r, "organization before it can continue"); + await press(r, ["a"]); + await waitUntil( + () => h.clientSpies.handleAuthChallenge.mock.calls.length > 0, + ); + expect(h.clientSpies.handleAuthChallenge).toHaveBeenCalledWith(challenge, { + confirmedStepUp: true, + }); + expect(h.callbackStart).not.toHaveBeenCalled(); + await expectFrame(r, "Step-up authorization succeeded"); + }); }); diff --git a/clients/tui/__tests__/AuthTab.test.tsx b/clients/tui/__tests__/AuthTab.test.tsx index 431391d2..fbefcf2e 100644 --- a/clients/tui/__tests__/AuthTab.test.tsx +++ b/clients/tui/__tests__/AuthTab.test.tsx @@ -71,6 +71,15 @@ const baseProps = { connectionStatus: "disconnected" as const, }; +const pendingStepUp = { + challenge: { + reason: "insufficient_scope" as const, + requiredScopes: ["env:read"], + authorizationScopes: ["tools:read", "env:read"], + }, + enterpriseManaged: true, +}; + describe("AuthTab", () => { it("renders the placeholder when there is no server", () => { const { lastFrame } = render( @@ -220,6 +229,86 @@ describe("AuthTab", () => { expect(lastFrame() ?? "").toContain("OAuth Details"); }); + it("navigates step-up choices with arrows and activates with Enter", async () => { + const onAuthorizeStepUp = vi.fn(); + const onCancelStepUp = vi.fn(); + const { client } = makeClient(sampleOAuthState); + const { stdin } = render( + , + ); + await tick(); + stdin.write("\r"); + await tick(); + expect(onAuthorizeStepUp).toHaveBeenCalledTimes(1); + expect(onCancelStepUp).not.toHaveBeenCalled(); + + onAuthorizeStepUp.mockClear(); + stdin.write(DOWN); + await tick(); + stdin.write("\r"); + await tick(); + expect(onCancelStepUp).toHaveBeenCalledTimes(1); + expect(onAuthorizeStepUp).not.toHaveBeenCalled(); + }); + + it("shows step-up footer with selection hints when focused", async () => { + const { client } = makeClient(sampleOAuthState); + const { lastFrame } = render( + , + ); + await tick(); + expect(lastFrame() ?? "").toContain("↑/↓ select, Enter confirm"); + }); + + it("refreshes OAuth state when connection becomes connected", async () => { + const { client, getOAuthState } = makeClient(undefined); + const { lastFrame, rerender } = render( + , + ); + await tick(); + expect(getOAuthState).toHaveBeenCalledTimes(1); + expect(lastFrame() ?? "").toContain("No OAuth information yet"); + + getOAuthState.mockResolvedValue(sampleOAuthState); + rerender( + , + ); + await tick(); + expect(getOAuthState).toHaveBeenCalledTimes(2); + expect(lastFrame() ?? "").toContain("Authorized"); + expect(lastFrame() ?? "").toContain("OAuth Details"); + }); + it("refreshes OAuth state when oauthComplete fires", async () => { const { client, getOAuthState, fire, listeners } = makeClient(sampleOAuthState); diff --git a/clients/tui/__tests__/PromptTestModal.test.tsx b/clients/tui/__tests__/PromptTestModal.test.tsx index 1a1d0582..c5058257 100644 --- a/clients/tui/__tests__/PromptTestModal.test.tsx +++ b/clients/tui/__tests__/PromptTestModal.test.tsx @@ -2,6 +2,7 @@ import React from "react"; import { describe, it, expect, vi, afterEach } from "vitest"; import { render } from "ink-testing-library"; import type { InspectorClient } from "@inspector/core/mcp/index.js"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import type { Prompt } from "@modelcontextprotocol/sdk/types.js"; // ScrollView: passthrough so the results JSX actually mounts (and is counted @@ -341,4 +342,34 @@ describe("PromptTestModal", () => { await tick(); expect(true).toBe(true); }); + + it("delegates AuthRecoveryRequiredError to onAuthRecoveryRequired and closes", async () => { + const recovery = new AuthRecoveryRequiredError( + new URL("https://auth.example.com/authorize"), + { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, + ); + const getPrompt = vi.fn().mockRejectedValue(recovery); + const onClose = vi.fn(); + const onAuthRecoveryRequired = vi.fn(); + setFormSubmitValue({ topic: "x" }); + + const { stdin } = render( + , + ); + + await tick(); + stdin.write(ENTER); + await tick(); + await tick(); + + expect(onAuthRecoveryRequired).toHaveBeenCalledWith(recovery); + expect(onClose).toHaveBeenCalledTimes(1); + }); }); diff --git a/clients/tui/__tests__/ResourceTestModal.test.tsx b/clients/tui/__tests__/ResourceTestModal.test.tsx index 37103ba7..d9de004d 100644 --- a/clients/tui/__tests__/ResourceTestModal.test.tsx +++ b/clients/tui/__tests__/ResourceTestModal.test.tsx @@ -2,6 +2,7 @@ import React from "react"; import { describe, it, expect, vi, afterEach } from "vitest"; import { render } from "ink-testing-library"; import type { InspectorClient } from "@inspector/core/mcp/index.js"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; // ScrollView passthrough so the results JSX actually mounts (and is covered). vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js")); @@ -264,4 +265,32 @@ describe("ResourceTestModal", () => { await tick(); api.unmount(); }); + + it("delegates AuthRecoveryRequiredError to onAuthRecoveryRequired and closes", async () => { + const recovery = new AuthRecoveryRequiredError( + new URL("https://auth.example.com/authorize"), + { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, + ); + const read = vi.fn().mockRejectedValue(recovery); + const onClose = vi.fn(); + const onAuthRecoveryRequired = vi.fn(); + const api = render( + , + ); + await tick(); + setSubmitValue({ name: "world" }); + api.stdin.write("\r"); + await tick(); + await tick(); + expect(onAuthRecoveryRequired).toHaveBeenCalledWith(recovery); + expect(onClose).toHaveBeenCalledTimes(1); + api.unmount(); + }); }); diff --git a/clients/tui/__tests__/tuiOAuth.test.ts b/clients/tui/__tests__/tuiOAuth.test.ts new file mode 100644 index 00000000..125c57f4 --- /dev/null +++ b/clients/tui/__tests__/tuiOAuth.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { + isStandardOAuthStepUp, + isStepUpConfirmation, + stepUpConfirmMessage, + stepUpInsufficientScopeMessage, +} from "../src/utils/tuiOAuth.js"; + +describe("tuiOAuth", () => { + it("detects standard OAuth step-up", () => { + expect( + isStandardOAuthStepUp( + { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, + { enterpriseManaged: false }, + ), + ).toBe(true); + expect( + isStandardOAuthStepUp( + { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, + { enterpriseManaged: true }, + ), + ).toBe(false); + }); + + it("detects EMA step-up confirmation", () => { + expect( + isStepUpConfirmation( + { reason: "insufficient_scope", requiredScopes: ["env:read"] }, + { enterpriseManaged: true }, + ), + ).toBe(true); + expect( + isStepUpConfirmation( + { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, + { enterpriseManaged: false }, + ), + ).toBe(true); + }); + + it("formats step-up messages", () => { + const challenge = { + reason: "insufficient_scope" as const, + requiredScopes: ["weather:read"], + context: { toolName: "get_temp" }, + }; + expect(stepUpConfirmMessage(challenge)).toMatch(/get_temp/); + expect(stepUpInsufficientScopeMessage(challenge)).toMatch(/get_temp/); + }); +}); diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 763eaa01..01b05bc5 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -45,7 +45,10 @@ import { CallbackNavigation, MutableRedirectUrlProvider, isUnauthorizedError, + AuthRecoveryRequiredError, } from "@inspector/core/auth/index.js"; +import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; +import type { TypedEvent } from "@inspector/core/mcp/inspectorClientEventTarget.js"; import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js"; import type { ClientConfig } from "@inspector/core/client/types.js"; import { @@ -57,9 +60,15 @@ import { createOAuthCallbackServer, type OAuthCallbackServer, NodeOAuthStorage, + runRunnerInteractiveOAuth, } from "@inspector/core/auth/node/index.js"; import { getTuiLogger } from "./logger.js"; import { openUrl } from "./utils/openUrl.js"; +import { + isStepUpConfirmation, + stepUpInsufficientScopeMessage, +} from "./utils/tuiOAuth.js"; +import { emaStepUpFailureMessage } from "@inspector/core/auth/oauthUx.js"; import { Tabs } from "./components/Tabs.js"; import { type TabType, tabs as tabList } from "./components/tabsConfig.js"; import { InfoTab } from "./components/InfoTab.js"; @@ -141,14 +150,6 @@ function App({ callbackUrlConfig, }: AppProps) { const { exit } = useApp(); - const callbackServerBaseOptions = useMemo( - () => ({ - port: callbackUrlConfig.port, - hostname: callbackUrlConfig.hostname, - path: callbackUrlConfig.pathname, - }), - [callbackUrlConfig], - ); useEffect(() => { getTuiLogger().info( @@ -174,9 +175,22 @@ function App({ >("idle"); const [oauthMessage, setOauthMessage] = useState(null); const [oauthRevision, setOauthRevision] = useState(0); + const [pendingStepUp, setPendingStepUp] = useState<{ + serverName: string; + challenge: AuthChallenge; + authorizationUrl: URL; + enterpriseManaged?: boolean; + } | null>(null); + const pendingStepUpRef = useRef(pendingStepUp); + useEffect(() => { + pendingStepUpRef.current = pendingStepUp; + }, [pendingStepUp]); const [connectError, setConnectError] = useState(null); const oauthInProgressRef = useRef(false); const callbackServerRef = useRef(null); + const selectedServerRef = useRef(null); + const mcpServersRef = useRef(mcpServers); + const inspectorClientsRef = useRef>({}); // Tool test modal state const [toolTestModal, setToolTestModal] = useState<{ @@ -419,12 +433,22 @@ function App({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Clear OAuth status when switching servers + // Clear OAuth status when switching servers; drop step-up for other servers. useEffect(() => { setOauthStatus("idle"); setOauthMessage(null); + const stepUp = pendingStepUpRef.current; + if (stepUp && selectedServer && stepUp.serverName !== selectedServer) { + setPendingStepUp(null); + } }, [selectedServer]); + useEffect(() => { + selectedServerRef.current = selectedServer; + mcpServersRef.current = mcpServers; + inspectorClientsRef.current = inspectorClients; + }, [selectedServer, mcpServers, inspectorClients]); + // Switch away from Auth tab when server is not OAuth-capable useEffect(() => { if ( @@ -534,79 +558,203 @@ function App({ selectedManagedPromptsState, ); - // Shared ref for OAuth callback server; stop before starting new (avoids EADDRINUSE when prior auth failed without redirect) + // Connect — on 401 or mid-session auth recovery, run OAuth then retry. + type TuiOAuthRunResult = + | "success" + | "already_authorized" + | "insufficient_scope" + | "skipped" + | "unsupported"; - // Connect — on 401, run OAuth then retry (same pattern as web App.tsx). - const runOAuthAuthentication = useCallback(async () => { - if ( - !selectedServer || - !selectedInspectorClient || - !selectedServerConfig || - !isOAuthCapableServerConfig(selectedServerConfig) - ) { - return; - } - if (oauthInProgressRef.current) return; - oauthInProgressRef.current = true; - getTuiLogger().info( - { server: selectedServer }, - "OAuth authentication started", - ); - const existing = callbackServerRef.current; - if (existing) { - await existing.stop(); - callbackServerRef.current = null; - } - const callbackServer = createOAuthCallbackServer(); - callbackServerRef.current = callbackServer; - let flowResolve: () => void; - let flowReject: (err: Error) => void; - const flowDone = new Promise((resolve, reject) => { - flowResolve = resolve; - flowReject = reject; - }); - try { - const { redirectUrl } = await callbackServer.start({ - ...callbackServerBaseOptions, - onCallback: async (params) => { - try { - await selectedInspectorClient!.completeOAuthFlow(params.code); - flowResolve!(); - } catch (err) { - flowReject!(err instanceof Error ? err : new Error(String(err))); - } finally { - callbackServerRef.current = null; - } - }, - onError: (params) => { - flowReject!( - new Error( - params.error_description ?? params.error ?? "OAuth error", - ), - ); - void callbackServer.stop(); - callbackServerRef.current = null; - }, + const runOAuthAuthentication = useCallback( + async (options?: { + challenge?: AuthChallenge; + authorizationUrl?: URL; + /** When set, run OAuth for this server (may differ from the selected server). */ + serverName?: string; + }): Promise => { + const serverName = options?.serverName ?? selectedServer; + if (!serverName) { + return "unsupported"; + } + const client = inspectorClientsRef.current[serverName]; + const serverEntry = mcpServersRef.current[serverName]; + const serverConfig = serverEntry?.config; + if ( + !client || + !serverConfig || + !isOAuthCapableServerConfig(serverConfig) + ) { + return "unsupported"; + } + if (oauthInProgressRef.current) { + return "skipped"; + } + oauthInProgressRef.current = true; + getTuiLogger().info( + { server: serverName }, + "OAuth authentication started", + ); + const existing = callbackServerRef.current; + if (existing) { + await existing.stop(); + callbackServerRef.current = null; + } + const redirectUrlProvider = redirectUrlProvidersRef.current[serverName]; + if (!redirectUrlProvider) { + oauthInProgressRef.current = false; + return "unsupported"; + } + try { + const result = await runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: callbackUrlConfig, + createCallbackServer: createOAuthCallbackServer, + onCallbackServer: (server) => { + callbackServerRef.current = server; + }, + authorizationUrl: options?.authorizationUrl, + authChallenge: options?.challenge, + }); + + if (result.kind === "insufficient_scope") { + setOauthStatus("error"); + setOauthMessage(stepUpInsufficientScopeMessage(result.challenge)); + return "insufficient_scope"; + } + if (result.kind === "success" || result.kind === "already_authorized") { + setOauthRevision((n) => n + 1); + return result.kind; + } + return "unsupported"; + } finally { + oauthInProgressRef.current = false; + callbackServerRef.current = null; + } + }, + [selectedServer, callbackUrlConfig], + ); + + const presentStepUpForServer = useCallback( + ( + serverName: string, + challenge: AuthChallenge, + authorizationUrl: URL, + enterpriseManaged?: boolean, + ) => { + const pending = pendingStepUpRef.current; + if (pending && pending.serverName !== serverName) { + setOauthMessage( + "A step-up prompt is already open. Complete or decline it before continuing.", + ); + return; + } + if (pending?.serverName === serverName) { + setOauthMessage( + "Updated step-up request — review the scopes on the Auth tab.", + ); + } else { + setOauthMessage(null); + } + setSelectedServer(serverName); + setPendingStepUp({ + serverName, + challenge, + authorizationUrl, + enterpriseManaged, }); - const redirectUrlProvider = - redirectUrlProvidersRef.current[selectedServer]; - if (redirectUrlProvider) { - redirectUrlProvider.redirectUrl = redirectUrl; + setActiveTab("auth"); + setOauthStatus("idle"); + setFocus("tabContentList"); + }, + [], + ); + + const handleAuthRecoveryRequired = useCallback( + (serverName: string, error: AuthRecoveryRequiredError) => { + const serverEntry = mcpServersRef.current[serverName]; + const settings = serverEntry?.settings; + const client = inspectorClientsRef.current[serverName]; + const needsStepUpConfirm = + error.emaStepUpConfirm || + isStepUpConfirmation(error.authChallenge, settings); + if (needsStepUpConfirm) { + void (async () => { + if ( + client && + (await client.checkAuthChallengeSatisfied(error.authChallenge)) + ) { + setOauthStatus("idle"); + setOauthMessage("Authorization updated. Retry your action."); + setOauthRevision((n) => n + 1); + return; + } + presentStepUpForServer( + serverName, + error.authChallenge, + error.authorizationUrl, + settings?.enterpriseManaged, + ); + })(); + return; } - const authUrl = await selectedInspectorClient.authenticate(); - if (authUrl !== undefined) { - await flowDone; + void (async () => { + if ( + client && + (await client.checkAuthChallengeSatisfied(error.authChallenge)) + ) { + setOauthStatus("idle"); + setOauthMessage("Authorization updated. Retry your action."); + setOauthRevision((n) => n + 1); + return; + } + const needsSwitch = selectedServerRef.current !== serverName; + if (needsSwitch) { + setSelectedServer(serverName); + setActiveTab("auth"); + setOauthMessage( + `Authentication required for "${serverName}". Re-authenticating…`, + ); + } else { + setOauthMessage(null); + } + setOauthStatus("authenticating"); + try { + const oauthResult = await runOAuthAuthentication({ + challenge: error.authChallenge, + authorizationUrl: error.authorizationUrl, + serverName, + }); + if ( + oauthResult === "success" || + oauthResult === "already_authorized" + ) { + setOauthStatus("idle"); + setOauthMessage("Authorization updated. Retry your action."); + } else if (oauthResult === "skipped") { + setOauthStatus("idle"); + setOauthMessage("OAuth already in progress."); + } + } catch (authErr) { + const authMsg = + authErr instanceof Error ? authErr.message : String(authErr); + setOauthStatus("error"); + setOauthMessage(authMsg); + } + })(); + }, + [presentStepUpForServer, runOAuthAuthentication], + ); + + const onAuthRecoveryRequired = useCallback( + (error: AuthRecoveryRequiredError) => { + if (selectedServer) { + handleAuthRecoveryRequired(selectedServer, error); } - setOauthRevision((n) => n + 1); - } finally { - oauthInProgressRef.current = false; - } - }, [ - selectedServer, - selectedInspectorClient, - selectedServerConfig, - callbackServerBaseOptions, - ]); + }, + [selectedServer, handleAuthRecoveryRequired], + ); const handleConnect = useCallback(async () => { if (!selectedServer || !selectedInspectorClient || !selectedServerConfig) { @@ -618,6 +766,7 @@ function App({ setConnectError(null); setOauthStatus("idle"); setOauthMessage(null); + setOauthRevision((n) => n + 1); }; try { @@ -639,13 +788,22 @@ function App({ try { setOauthStatus("authenticating"); setOauthMessage(null); - // Tear down the failed handshake transport so the post-OAuth connect - // creates a fresh transport with tokens from storage (same as web, - // which reloads the client after redirect). await disconnectInspector(); - await runOAuthAuthentication(); - await finishConnect(); + const oauthResult = await runOAuthAuthentication(); + if ( + oauthResult === "success" || + oauthResult === "already_authorized" + ) { + await finishConnect(); + } else if (oauthResult === "skipped") { + setOauthStatus("idle"); + setOauthMessage("OAuth already in progress."); + } } catch (authErr) { + if (authErr instanceof AuthRecoveryRequiredError) { + handleAuthRecoveryRequired(selectedServer, authErr); + return; + } const authMsg = authErr instanceof Error ? authErr.message : String(authErr); setConnectError(authMsg); @@ -659,6 +817,11 @@ function App({ } return; } + + if (err instanceof AuthRecoveryRequiredError && selectedServer) { + handleAuthRecoveryRequired(selectedServer, err); + return; + } } }, [ selectedServer, @@ -667,8 +830,63 @@ function App({ connectInspector, disconnectInspector, runOAuthAuthentication, + handleAuthRecoveryRequired, ]); + useEffect(() => { + const cleanups: Array<() => void> = []; + + for (const [serverName, client] of Object.entries(inspectorClients)) { + const onAmbient = (): void => { + if (selectedServerRef.current !== serverName) return; + setOauthStatus("idle"); + setOauthMessage("Refreshing authorization…"); + }; + const onRecovered = (): void => { + if (selectedServerRef.current !== serverName) return; + setOauthMessage(null); + setOauthRevision((n) => n + 1); + }; + const onInteractive = ( + event: TypedEvent<"authChallengeInteractive">, + ): void => { + handleAuthRecoveryRequired( + serverName, + new AuthRecoveryRequiredError( + event.detail.authorizationUrl, + event.detail.challenge, + ), + ); + }; + const onOAuthError = (event: TypedEvent<"oauthError">): void => { + if (selectedServerRef.current !== serverName) return; + const message = + event.detail.error instanceof Error + ? event.detail.error.message + : String(event.detail.error); + setOauthStatus("error"); + setOauthMessage(message); + }; + + client.addEventListener("authChallengeAmbient", onAmbient); + client.addEventListener("authChallengeRecovered", onRecovered); + client.addEventListener("authChallengeInteractive", onInteractive); + client.addEventListener("oauthError", onOAuthError); + cleanups.push(() => { + client.removeEventListener("authChallengeAmbient", onAmbient); + client.removeEventListener("authChallengeRecovered", onRecovered); + client.removeEventListener("authChallengeInteractive", onInteractive); + client.removeEventListener("oauthError", onOAuthError); + }); + } + + return () => { + for (const cleanup of cleanups) { + cleanup(); + } + }; + }, [inspectorClients, handleAuthRecoveryRequired]); + // Disconnect handler const handleDisconnect = useCallback(async () => { if (!selectedServer) return; @@ -1108,15 +1326,15 @@ function App({ ); if (tabAccelerators[input.toLowerCase()]) { const nextTab = tabAccelerators[input.toLowerCase()]!; - setActiveTab(nextTab); - setFocus(nextTab === "auth" ? "tabContentList" : "tabs"); - } else if ( - activeTab === "auth" && - showAuthTab && - input.toLowerCase() === "s" - ) { - void handleClearOAuth(); - setFocus("tabContentList"); + const authStepUpAccelerator = + input.toLowerCase() === "a" && + nextTab === "auth" && + activeTab === "auth" && + pendingStepUp?.serverName === selectedServer; + if (!authStepUpAccelerator) { + setActiveTab(nextTab); + setFocus(nextTab === "auth" ? "tabContentList" : "tabs"); + } } else if (key.tab && !key.shift) { // Flat focus order: servers -> tabs -> list -> details -> wrap to servers const focusOrder: FocusArea[] = @@ -1470,6 +1688,102 @@ function App({ oauthStatus={oauthStatus} oauthMessage={oauthMessage} oauthRevision={oauthRevision} + pendingStepUp={ + pendingStepUp?.serverName === selectedServer + ? { + challenge: pendingStepUp.challenge, + authorizationScopes: + pendingStepUp.challenge.authorizationScopes, + enterpriseManaged: pendingStepUp.enterpriseManaged, + } + : null + } + onAuthorizeStepUp={() => { + if (!pendingStepUp || !selectedInspectorClient) return; + const { challenge, authorizationUrl, enterpriseManaged } = + pendingStepUp; + setPendingStepUp(null); + setOauthStatus("authenticating"); + void (async () => { + try { + if (enterpriseManaged) { + const outcome = + await selectedInspectorClient.handleAuthChallenge( + challenge, + { confirmedStepUp: true }, + ); + if (outcome.kind === "satisfied") { + await disconnectInspector().catch(() => {}); + await connectInspector(); + setOauthStatus("idle"); + setOauthMessage( + "Step-up authorization succeeded. Retry your action.", + ); + setOauthRevision((n) => n + 1); + return; + } + if (outcome.kind === "interactive") { + const oauthResult = await runOAuthAuthentication({ + challenge: outcome.challenge, + authorizationUrl: outcome.authorizationUrl, + }); + if ( + oauthResult === "success" || + oauthResult === "already_authorized" + ) { + setOauthStatus("idle"); + setOauthMessage( + "Step-up authorization succeeded. Retry your action.", + ); + } else if (oauthResult === "skipped") { + setOauthStatus("idle"); + setOauthMessage("OAuth already in progress."); + } + return; + } + if (outcome.kind === "failed") { + setOauthStatus("error"); + setOauthMessage( + emaStepUpFailureMessage(outcome.error.message), + ); + return; + } + setOauthStatus("idle"); + setOauthMessage( + "Step-up authorization succeeded. Retry your action.", + ); + return; + } + const oauthResult = await runOAuthAuthentication({ + challenge, + authorizationUrl, + }); + if ( + oauthResult === "success" || + oauthResult === "already_authorized" + ) { + setOauthStatus("idle"); + setOauthMessage( + "Step-up authorization succeeded. Retry your action.", + ); + } else if (oauthResult === "skipped") { + setOauthStatus("idle"); + setOauthMessage("OAuth already in progress."); + } + } catch (authErr) { + const authMsg = + authErr instanceof Error + ? authErr.message + : String(authErr); + setOauthStatus("error"); + setOauthMessage(authMsg); + } + })(); + }} + onCancelStepUp={() => { + setPendingStepUp(null); + setOauthMessage("Authorization cancelled."); + }} width={contentWidth} height={contentHeight} focused={ @@ -1517,8 +1831,14 @@ function App({ inspectorClient: selectedInspectorClient, }); }} + onAuthRecoveryRequired={onAuthRecoveryRequired} modalOpen={ - !!(toolTestModal || resourceTestModal || detailsModal) + !!( + toolTestModal || + resourceTestModal || + promptTestModal || + detailsModal + ) } /> ) : activeTab === "prompts" && @@ -1552,6 +1872,7 @@ function App({ inspectorClient: selectedInspectorClient, }); }} + onAuthRecoveryRequired={onAuthRecoveryRequired} modalOpen={ !!( toolTestModal || @@ -1680,6 +2001,10 @@ function App({ width={dimensions.width} height={dimensions.height} onClose={() => setToolTestModal(null)} + onAuthRecoveryRequired={(error) => { + onAuthRecoveryRequired(error); + setToolTestModal(null); + }} /> )} @@ -1691,6 +2016,10 @@ function App({ width={dimensions.width} height={dimensions.height} onClose={() => setResourceTestModal(null)} + onAuthRecoveryRequired={(error) => { + onAuthRecoveryRequired(error); + setResourceTestModal(null); + }} /> )} @@ -1701,6 +2030,10 @@ function App({ width={dimensions.width} height={dimensions.height} onClose={() => setPromptTestModal(null)} + onAuthRecoveryRequired={(error) => { + onAuthRecoveryRequired(error); + setPromptTestModal(null); + }} /> )} diff --git a/clients/tui/src/components/AuthTab.tsx b/clients/tui/src/components/AuthTab.tsx index a3247194..c495a4e5 100644 --- a/clients/tui/src/components/AuthTab.tsx +++ b/clients/tui/src/components/AuthTab.tsx @@ -8,12 +8,19 @@ import type { ConnectionStatus, } from "@inspector/core/mcp/index.js"; import type { OAuthConnectionState } from "@inspector/core/auth/types.js"; +import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; import { formatAuthProtocol, formatClientRegistrationKind, formatIdpSession, formatScopes, } from "../utils/oauthDisplay.js"; +import { + stepUpAuthorizeActionLabel, + stepUpConfirmMessage, + stepUpFollowUpMessage, + stepUpModalTitle, +} from "../utils/tuiOAuth.js"; interface AuthTabProps { serverName: string | null; @@ -22,6 +29,13 @@ interface AuthTabProps { oauthStatus: "idle" | "authenticating" | "error"; oauthMessage: string | null; oauthRevision: number; + pendingStepUp?: { + challenge: AuthChallenge; + authorizationScopes?: string[]; + enterpriseManaged?: boolean; + } | null; + onAuthorizeStepUp?: () => void; + onCancelStepUp?: () => void; width: number; height: number; focused?: boolean; @@ -44,6 +58,9 @@ export function AuthTab({ oauthStatus, oauthMessage, oauthRevision, + pendingStepUp, + onAuthorizeStepUp, + onCancelStepUp, width, height, focused = false, @@ -58,6 +75,7 @@ export function AuthTab({ >(undefined); const [clearedConfirmation, setClearedConfirmation] = useState(false); const [lastClearDisconnected, setLastClearDisconnected] = useState(false); + const [stepUpChoiceIndex, setStepUpChoiceIndex] = useState(0); const refreshOAuthState = useCallback(async () => { if (!inspectorClient) { @@ -70,13 +88,17 @@ export function AuthTab({ useEffect(() => { void refreshOAuthState(); - }, [refreshOAuthState, oauthRevision]); + }, [refreshOAuthState, oauthRevision, connectionStatus]); useEffect(() => { setClearedConfirmation(false); setLastClearDisconnected(false); }, [oauthRevision]); + useEffect(() => { + setStepUpChoiceIndex(0); + }, [pendingStepUp]); + useEffect(() => { if (!inspectorClient) return; @@ -93,6 +115,34 @@ export function AuthTab({ (input: string, key: Key) => { if (!focused) return; + if (pendingStepUp) { + if (key.upArrow) { + setStepUpChoiceIndex((i) => Math.max(0, i - 1)); + return; + } + if (key.downArrow) { + setStepUpChoiceIndex((i) => Math.min(1, i + 1)); + return; + } + if (key.return) { + if (stepUpChoiceIndex === 0) { + onAuthorizeStepUp?.(); + } else { + onCancelStepUp?.(); + } + return; + } + if (input.toLowerCase() === "a") { + onAuthorizeStepUp?.(); + return; + } + if (input.toLowerCase() === "c") { + onCancelStepUp?.(); + return; + } + return; + } + if (key.upArrow && scrollViewRef.current) { scrollViewRef.current.scrollBy(-1); } else if (key.downArrow && scrollViewRef.current) { @@ -139,6 +189,66 @@ export function AuthTab({ {oauthStatus === "error" && oauthMessage && ( {oauthMessage} )} + {oauthStatus === "idle" && oauthMessage && ( + {oauthMessage} + )} + + {pendingStepUp ? ( + + + {stepUpModalTitle({ + enterpriseManaged: pendingStepUp.enterpriseManaged, + })} + + + {stepUpConfirmMessage(pendingStepUp.challenge, { + enterpriseManaged: pendingStepUp.enterpriseManaged, + })}{" "} + {stepUpFollowUpMessage({ + enterpriseManaged: pendingStepUp.enterpriseManaged, + })} + + {(() => { + const scopes = + pendingStepUp.authorizationScopes ?? + pendingStepUp.challenge.requiredScopes; + if (!scopes?.length) return null; + return ( + + + {pendingStepUp.enterpriseManaged + ? "Permissions requested:" + : "Scopes requested:"} + + {scopes.map((scope) => ( + + {" "} + • {scope} + + ))} + + ); + })()} + + + {(() => { + const label = stepUpAuthorizeActionLabel({ + enterpriseManaged: pendingStepUp.enterpriseManaged, + }); + return ( + <> + {label[0]} + {label.slice(1)} + + ); + })()} + + + Cancel + + + + ) : null} {oauthState ? ( @@ -229,7 +339,9 @@ export function AuthTab({ backgroundColor="gray" > - S {isLiveConnection ? "clear+disconnect" : "clear"}, ↑/↓ scroll + {pendingStepUp + ? "↑/↓ select, Enter confirm, A authorize, C cancel" + : `S ${isLiveConnection ? "clear+disconnect" : "clear"}, ↑/↓ scroll`} )} diff --git a/clients/tui/src/components/PromptTestModal.tsx b/clients/tui/src/components/PromptTestModal.tsx index c10d0345..dfd284b3 100644 --- a/clients/tui/src/components/PromptTestModal.tsx +++ b/clients/tui/src/components/PromptTestModal.tsx @@ -2,6 +2,7 @@ import React, { useState } from "react"; import { Box, Text, useInput, type Key } from "ink"; import { Form } from "ink-form"; import { InspectorClient } from "@inspector/core/mcp/index.js"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import type { Prompt, GetPromptResult, @@ -29,6 +30,7 @@ interface PromptTestModalProps { width: number; height: number; onClose: () => void; + onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void; } type ModalState = "form" | "loading" | "results"; @@ -47,6 +49,7 @@ export function PromptTestModal({ width, height, onClose, + onAuthRecoveryRequired, }: PromptTestModalProps) { const [state, setState] = useState("form"); const [result, setResult] = useState(null); @@ -144,6 +147,11 @@ export function PromptTestModal({ }); setState("results"); } catch (error) { + if (error instanceof AuthRecoveryRequiredError) { + onAuthRecoveryRequired?.(error); + onClose(); + return; + } const duration = Date.now() - startTime; const errorMessage = getErrorMessage(error); diff --git a/clients/tui/src/components/PromptsTab.tsx b/clients/tui/src/components/PromptsTab.tsx index c41291a1..f2a79d05 100644 --- a/clients/tui/src/components/PromptsTab.tsx +++ b/clients/tui/src/components/PromptsTab.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from "react"; import { Box, Text, useInput, type Key } from "ink"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; import type { InspectorClient } from "@inspector/core/mcp/index.js"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import type { Prompt, PromptArgument, @@ -18,6 +19,7 @@ interface PromptsTabProps { focusedPane?: "list" | "details" | null; onViewDetails?: (prompt: Prompt & { result?: GetPromptResult }) => void; onFetchPrompt?: (prompt: Prompt) => void; + onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void; modalOpen?: boolean; } @@ -29,6 +31,7 @@ export function PromptsTab({ focusedPane = null, onViewDetails, onFetchPrompt, + onAuthRecoveryRequired, modalOpen = false, }: PromptsTabProps) { const visibleCount = Math.max(1, height - 7); @@ -64,6 +67,10 @@ export function PromptsTab({ }); } } catch (error) { + if (error instanceof AuthRecoveryRequiredError) { + onAuthRecoveryRequired?.(error); + return; + } setError( error instanceof Error ? error.message : "Failed to get prompt", ); diff --git a/clients/tui/src/components/ResourceTestModal.tsx b/clients/tui/src/components/ResourceTestModal.tsx index fe6dade7..8d52ee12 100644 --- a/clients/tui/src/components/ResourceTestModal.tsx +++ b/clients/tui/src/components/ResourceTestModal.tsx @@ -2,6 +2,7 @@ import React, { useState } from "react"; import { Box, Text, useInput, type Key } from "ink"; import { Form } from "ink-form"; import { InspectorClient } from "@inspector/core/mcp/index.js"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js"; import { uriTemplateToForm } from "../utils/uriTemplateToForm.js"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; @@ -30,6 +31,7 @@ interface ResourceTestModalProps { width: number; height: number; onClose: () => void; + onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void; } type ModalState = "form" | "loading" | "results"; @@ -49,6 +51,7 @@ export function ResourceTestModal({ width, height, onClose, + onAuthRecoveryRequired, }: ResourceTestModalProps) { const [state, setState] = useState("form"); const [result, setResult] = useState(null); @@ -150,6 +153,11 @@ export function ResourceTestModal({ }); setState("results"); } catch (error) { + if (error instanceof AuthRecoveryRequiredError) { + onAuthRecoveryRequired?.(error); + onClose(); + return; + } const duration = Date.now() - startTime; const errorMessage = getErrorMessage(error); diff --git a/clients/tui/src/components/ResourcesTab.tsx b/clients/tui/src/components/ResourcesTab.tsx index abcc6eae..df976708 100644 --- a/clients/tui/src/components/ResourcesTab.tsx +++ b/clients/tui/src/components/ResourcesTab.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useMemo } from "react"; import { Box, Text, useInput, type Key } from "ink"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; import type { InspectorClient } from "@inspector/core/mcp/index.js"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import type { Resource, ReadResourceResult, @@ -27,6 +28,7 @@ interface ResourcesTabProps { ) => void; onFetchResource?: (resource: Resource) => void; onFetchTemplate?: (template: ResourceTemplate) => void; + onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void; modalOpen?: boolean; } @@ -41,6 +43,7 @@ export function ResourcesTab({ onViewDetails, onFetchResource, onFetchTemplate, + onAuthRecoveryRequired, modalOpen = false, }: ResourcesTabProps) { const [error, setError] = useState(null); @@ -169,6 +172,10 @@ export function ResourcesTab({ await inspectorClient.readResource(shouldFetchResource); setResourceContent(invocation.result); } catch (err) { + if (err instanceof AuthRecoveryRequiredError) { + onAuthRecoveryRequired?.(err); + return; + } setError( err instanceof Error ? err.message : "Failed to read resource", ); @@ -180,7 +187,7 @@ export function ResourcesTab({ }; fetchContent(); - }, [shouldFetchResource, inspectorClient]); + }, [shouldFetchResource, inspectorClient, onAuthRecoveryRequired]); const listWidth = Math.floor(width * 0.4); const detailWidth = width - listWidth; diff --git a/clients/tui/src/components/ToolTestModal.tsx b/clients/tui/src/components/ToolTestModal.tsx index dca992e4..a556c9a6 100644 --- a/clients/tui/src/components/ToolTestModal.tsx +++ b/clients/tui/src/components/ToolTestModal.tsx @@ -2,6 +2,7 @@ import React, { useState } from "react"; import { Box, Text, useInput, type Key } from "ink"; import { Form } from "ink-form"; import { InspectorClient } from "@inspector/core/mcp/index.js"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import type { Tool, CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { JsonValue } from "@inspector/core/mcp/index.js"; import { schemaToForm } from "../utils/schemaToForm.js"; @@ -13,6 +14,7 @@ interface ToolTestModalProps { width: number; height: number; onClose: () => void; + onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void; } type ModalState = "form" | "loading" | "results"; @@ -31,6 +33,7 @@ export function ToolTestModal({ width, height, onClose, + onAuthRecoveryRequired, }: ToolTestModalProps) { const [state, setState] = useState("form"); const [result, setResult] = useState(null); @@ -150,6 +153,11 @@ export function ToolTestModal({ } setState("results"); } catch (error) { + if (error instanceof AuthRecoveryRequiredError) { + onAuthRecoveryRequired?.(error); + onClose(); + return; + } const duration = Date.now() - startTime; const errorObj = error instanceof Error diff --git a/clients/tui/src/utils/tuiOAuth.ts b/clients/tui/src/utils/tuiOAuth.ts new file mode 100644 index 00000000..668151f7 --- /dev/null +++ b/clients/tui/src/utils/tuiOAuth.ts @@ -0,0 +1,36 @@ +import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; +import { + isStandardOAuthStepUp as isCoreStandardOAuthStepUp, + isStepUpConfirmation as isCoreStepUpConfirmation, + stepUpConfirmMessage, + stepUpInsufficientScopeMessage, +} from "@inspector/core/auth/oauthUx.js"; +import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; + +export { stepUpConfirmMessage, stepUpInsufficientScopeMessage }; + +export { + stepUpAuthorizeActionLabel, + stepUpFollowUpMessage, + stepUpModalTitle, +} from "@inspector/core/auth/oauthUx.js"; + +/** Standard-OAuth step-up (not EMA silent re-mint). */ +export function isStandardOAuthStepUp( + challenge: AuthChallenge, + settings?: InspectorServerSettings, +): boolean { + return isCoreStandardOAuthStepUp(challenge, { + enterpriseManaged: settings?.enterpriseManaged, + }); +} + +/** Standard or EMA step-up that requires in-app confirmation before OAuth. */ +export function isStepUpConfirmation( + challenge: AuthChallenge, + settings?: InspectorServerSettings, +): boolean { + return isCoreStepUpConfirmation(challenge, { + enterpriseManaged: settings?.enterpriseManaged, + }); +} diff --git a/clients/web/package.json b/clients/web/package.json index 14f3c1cc..b51cb69b 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -17,6 +17,7 @@ "build:runner": "tsup --config tsup.runner.config.ts", "build:storybook": "storybook build", "validate": "npm run format:check && npm run lint && npm run build && npm run test", + "pretest": "npm run test-servers:build", "test": "vitest run --project=unit", "test:watch": "vitest --project=unit", "test:coverage": "npm run test-servers:build && vitest run --project=unit --project=integration --coverage", diff --git a/clients/web/src/App.css b/clients/web/src/App.css index 111d4015..fc431065 100644 --- a/clients/web/src/App.css +++ b/clients/web/src/App.css @@ -412,3 +412,13 @@ .server-drag-handle:active { cursor: grabbing; } + +/* + * Sticky re-auth banner bar. Positioning (pos/top) and background are set via + * Mantine props on the Box; z-index and box-shadow have no Mantine style-prop + * shorthand, so they live here. + */ +.reauth-banner-bar { + z-index: 200; + box-shadow: var(--mantine-shadow-sm); +} diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index 1d1c42f9..7d7e2aba 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -75,6 +75,8 @@ vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => { getRoots = vi.fn().mockReturnValue([]); setRoots = vi.fn().mockResolvedValue(undefined); setServerSettings = vi.fn(); + resumeAfterOAuth = vi.fn().mockResolvedValue(undefined); + checkAuthChallengeSatisfied = vi.fn().mockResolvedValue(true); } const instances: FakeInspectorClient[] = []; return { @@ -257,6 +259,8 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({ getPromptState?: { status?: string }; readResourceState?: { status?: string }; currentLogLevel?: string; + activeTab?: string; + onActiveTabChange: (tab: string) => void; onToggleConnection: (id: string) => void; onToolsUiChange: (next: { selectedToolName?: string; @@ -314,6 +318,10 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({ {props.readResourceState?.status ?? "none"} {props.currentLogLevel} + {props.activeTab ?? "none"} + + + + ); +} diff --git a/clients/web/src/components/groups/StepUpAuthModal/StepUpAuthModal.test.tsx b/clients/web/src/components/groups/StepUpAuthModal/StepUpAuthModal.test.tsx new file mode 100644 index 00000000..1fb86efa --- /dev/null +++ b/clients/web/src/components/groups/StepUpAuthModal/StepUpAuthModal.test.tsx @@ -0,0 +1,120 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { StepUpAuthModal } from "./StepUpAuthModal"; + +const stepUpChallenge: AuthChallenge = { + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + authorizationScopes: ["mcp", "tools:read", "weather:read"], + context: { toolName: "get_temp" }, +}; + +describe("StepUpAuthModal", () => { + it("does not render when closed", () => { + renderWithMantine( + , + ); + expect( + screen.queryByText(/Additional permissions required/i), + ).not.toBeInTheDocument(); + }); + + it("lists additional scopes from the challenge, not the SEP-2350 union", () => { + renderWithMantine( + , + ); + expect( + screen.getByText(/Additional permissions required/i), + ).toBeInTheDocument(); + expect(screen.getByText(/get_temp/)).toBeInTheDocument(); + expect(screen.getByText("weather:read")).toBeInTheDocument(); + expect(screen.queryByText("tools:read")).not.toBeInTheDocument(); + expect(screen.queryByText("mcp")).not.toBeInTheDocument(); + expect( + screen.getByText( + /redirected to authorize, then returned to the inspector/i, + ), + ).toBeInTheDocument(); + }); + + it("uses EMA copy when enterpriseManaged is true", () => { + renderWithMantine( + , + ); + expect( + screen.getByText(/Additional organization permissions required/i), + ).toBeInTheDocument(); + expect( + screen.getByText(/enterprise identity provider/i), + ).toBeInTheDocument(); + expect( + screen.queryByText( + /redirected to authorize, then returned to the inspector/i, + ), + ).not.toBeInTheDocument(); + }); + + it("falls back to requiredScopes when authorizationScopes is empty", () => { + renderWithMantine( + , + ); + expect(screen.getByText("admin:write")).toBeInTheDocument(); + }); + + it("calls onAuthorize when Authorize is clicked", async () => { + const user = userEvent.setup(); + const onAuthorize = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: /^Authorize$/ })); + expect(onAuthorize).toHaveBeenCalledOnce(); + }); + + it("calls onCancel when Cancel is clicked", async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: /^Cancel$/ })); + expect(onCancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/clients/web/src/components/groups/StepUpAuthModal/StepUpAuthModal.tsx b/clients/web/src/components/groups/StepUpAuthModal/StepUpAuthModal.tsx new file mode 100644 index 00000000..e3e264e4 --- /dev/null +++ b/clients/web/src/components/groups/StepUpAuthModal/StepUpAuthModal.tsx @@ -0,0 +1,88 @@ +import { Button, Group, List, Modal, Stack, Text } from "@mantine/core"; +import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; +import { + stepUpAdditionalScopes, + stepUpConfirmMessage, + stepUpFollowUpMessage, + stepUpModalTitle, +} from "@inspector/core/auth/oauthUx.js"; + +export interface StepUpAuthModalProps { + opened: boolean; + challenge: AuthChallenge | null; + /** Effective consent scope set (SEP-2350 union). */ + authorizationScopes?: string[]; + /** Enterprise-managed (EMA) server — organization/IdP copy instead of resource AS. */ + enterpriseManaged?: boolean; + onAuthorize: () => void | Promise; + onCancel: () => void; +} + +const Actions = Group.withProps({ justify: "flex-end", gap: "sm", mt: "md" }); + +export function StepUpAuthModal({ + opened, + challenge, + authorizationScopes, + enterpriseManaged, + onAuthorize, + onCancel, +}: StepUpAuthModalProps) { + const additionalScopes = challenge ? stepUpAdditionalScopes(challenge) : []; + const ema = enterpriseManaged === true; + const handleAuthorize = () => void onAuthorize(); + + return ( + + + + {challenge + ? stepUpConfirmMessage(challenge, { enterpriseManaged: ema }) + : stepUpConfirmMessage( + { reason: "insufficient_scope" }, + { enterpriseManaged: ema }, + )}{" "} + {stepUpFollowUpMessage({ enterpriseManaged: ema })} + + {additionalScopes.length > 0 ? ( + + + {ema + ? "Additional permissions needed" + : "Additional scopes needed"} + + + {additionalScopes.map((scope) => ( + + + {scope} + + + ))} + + {!ema && + authorizationScopes && + authorizationScopes.length > additionalScopes.length ? ( + + The authorization server may also show scopes you already + granted during sign-in. + + ) : null} + + ) : null} + + + + + + + ); +} diff --git a/clients/web/src/components/screens/screenUiState.ts b/clients/web/src/components/screens/screenUiState.ts index f35d6fa7..a0a85f23 100644 --- a/clients/web/src/components/screens/screenUiState.ts +++ b/clients/web/src/components/screens/screenUiState.ts @@ -62,3 +62,15 @@ export const EMPTY_NETWORK_UI: NetworkUiState = { filterText: "", visibleCategories: ALL_CATEGORIES_VISIBLE, }; + +/** Registry for OAuth resume snapshot restore (data-only; setters live in App). */ +export const TAB_UI_REGISTRY = { + Apps: { empty: EMPTY_APPS_UI }, + Tools: { empty: EMPTY_TOOLS_UI }, + Prompts: { empty: EMPTY_PROMPTS_UI }, + Resources: { empty: EMPTY_RESOURCES_UI }, + Tasks: { empty: EMPTY_TASKS_UI }, + Logs: { empty: EMPTY_LOGS_UI }, + History: { empty: EMPTY_HISTORY_UI }, + Network: { empty: EMPTY_NETWORK_UI }, +} as const; diff --git a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx index 13e4fb81..779c3ccb 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx @@ -14,6 +14,7 @@ import type { ServerEntry, } from "@inspector/core/mcp/types.js"; import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; import { expect, fn } from "storybook/test"; import { InspectorView } from "./InspectorView"; import { @@ -400,6 +401,18 @@ const meta: Meta = { onCloseApp: fn(), onAppError: fn(), onRefreshApps: fn(), + activeTab: "Servers", + onActiveTabChange: fn(), + }, + render: (args) => { + const [activeTab, setActiveTab] = useState(args.activeTab ?? "Servers"); + return ( + + ); }, }; diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index 175f3ca7..08d8b26f 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -18,7 +18,6 @@ import { } from "../../../test/renderWithMantine"; import { InspectorView, type InspectorViewProps } from "./InspectorView"; import type { BridgeFactory } from "../../elements/AppRenderer/AppRenderer"; -import type { AppsUiState } from "../../screens/AppsScreen/AppsScreen"; import { EMPTY_TOOLS_UI, EMPTY_APPS_UI, @@ -130,21 +129,23 @@ function makeProps( onCloseApp: vi.fn(), onAppError: vi.fn(), onRefreshApps: vi.fn(), + activeTab: "Servers", + onActiveTabChange: vi.fn(), ...overrides, }; } -// Most tests render the view fully prop-driven (every callback is a spy). A few -// interactions — selecting an App and watching it auto-launch — depend on the -// parent-owned selection state actually updating, since the view is controlled -// (#1417). This host holds the App-tab selection/form state and threads it back -// in as controlled props, mirroring how App.tsx owns it in the real wiring. -function StatefulInspectorView({ props }: { props: InspectorViewProps }) { - const [appsUi, setAppsUi] = useState( - props.appsUi ?? EMPTY_APPS_UI, - ); +function StatefulInspectorViewHost(props: InspectorViewProps) { + const [activeTab, setActiveTab] = useState(props.activeTab ?? "Servers"); + const [appsUi, setAppsUi] = useState(props.appsUi ?? EMPTY_APPS_UI); return ( - + ); } @@ -210,7 +211,7 @@ const sampleTask: Task = { describe("InspectorView", () => { it("renders the empty-server-list placeholder when no servers are configured", () => { - renderWithMantine(); + renderWithMantine(); expect( screen.getByText("No servers configured. Add a server to get started."), ).toBeInTheDocument(); @@ -218,7 +219,7 @@ describe("InspectorView", () => { it("renders the server card from the input list", () => { renderWithMantine( - , + , ); expect(screen.getByText("Alpha")).toBeInTheDocument(); }); @@ -227,7 +228,7 @@ describe("InspectorView", () => { const onToggleConnection = vi.fn(); const user = userEvent.setup({ delay: null }); renderWithMantine( - , ); @@ -237,7 +238,7 @@ describe("InspectorView", () => { it("renders the connected header when connectionStatus + initializeResult are set", () => { renderWithMantine( - { it("surfaces the negotiated protocol version on the active connected card", () => { renderWithMantine( - { it("does not show a protocol version on the card while disconnected", () => { renderWithMantine( - { // version is somehow absent — the connected header/modal must still render // (gated on serverInfo, not the version), and the card label stays hidden. renderWithMantine( - { it("snaps activeTab back to Servers when connection drops", async () => { const { rerender } = renderWithMantine( - { ); rerender( - { }); it("disables non-Servers tabs while disconnected", () => { - renderWithMantine(); + renderWithMantine(); // The disconnected ViewHeader doesn't render the tab Select at all — // only the connected branch does. Asserting on the empty-state copy is // enough; a follow-up could deepen this once the disconnected header @@ -359,7 +360,7 @@ describe("InspectorView", () => { it("hides the Network tab when the active server is stdio", async () => { renderWithMantine( - { serverInfo: { name: "Beta", version: "1.0.0" }, }; renderWithMantine( - { it("hides the Tools tab when the server does not advertise the tools capability", async () => { renderWithMantine( - { it("shows the Tools tab when the server advertises tools even with an empty list", async () => { renderWithMantine( - { it("hides the Logs tab when the server does not advertise the logging capability", async () => { renderWithMantine( - { it("shows the Logs tab when the server advertises the logging capability", async () => { renderWithMantine( - { it("keeps History available regardless of advertised server capabilities", async () => { // History is a local client-side log — never gated on server capabilities. renderWithMantine( - { it("hides the Apps tab when app tools exist but the server omits the tools capability", async () => { renderWithMantine( - { _meta: { ui: { resourceUri: "not-a-ui-uri" } }, }; renderWithMantine( - { inputSchema: { type: "object" }, }; renderWithMantine( - { it("shows the Apps tab when the server exposes one or more MCP App tools", async () => { renderWithMantine( - { inputSchema: { type: "object" }, }; const { rerender } = renderWithMantine( - { // A tools/list_changed refresh adds an app tool — the tab appears reactively. rerender( - { it("snaps activeTab back to Servers when the Apps tab disappears after a refresh", async () => { const user = userEvent.setup({ delay: null }); const { rerender } = renderWithMantine( - { // The app tool goes away (server switch / list-changed) — the Apps tab is // pulled from availableTabs and the activeTab fallback lands on Servers. rerender( - { it("hides the Prompts tab when the server does not advertise the prompts capability", async () => { renderWithMantine( - { it("shows the Prompts tab when the server advertises prompts even with an empty list", async () => { renderWithMantine( - { it("hides the Resources tab when the server does not advertise the resources capability", async () => { renderWithMantine( - { it("shows the Resources tab when the server advertises resources even with empty lists", async () => { renderWithMantine( - { it("hides the Tasks tab when the server does not advertise the tasks capability", async () => { renderWithMantine( - { it("shows the Tasks tab when the server advertises tasks even with no tasks yet", async () => { renderWithMantine( - { it("recomputes tabs from the new capability set when reconnecting to a different server", async () => { // First server advertises tasks but not logging. const { rerender } = renderWithMantine( - { // Reconnect to a server that advertises logging but not tasks — the tabs // recompute purely from the new capability set. rerender( - { const onSetLogLevel = vi.fn(); const user = userEvent.setup({ delay: null }); renderWithMantine( - { it("persists Logs sort direction to localStorage and restores it on remount", async () => { const user = userEvent.setup({ delay: null }); const { unmount } = renderWithMantine( - { unmount(); renderWithMantine( - { const user = userEvent.setup({ delay: null }); window.localStorage.setItem("inspector.sortDirection.history", "garbage"); renderWithMantine( - { }, }; const { unmount } = renderWithMantine( - { unmount(); renderWithMantine( - { }, }; renderWithMantine( - { connection: { status: "disconnected" }, }; renderWithMantine( - { }; // Live session on alpha → beta starts out dimmed/inert. const { rerender } = renderWithMantine( - { // id still points at alpha. The other cards must re-enable anyway; only a // *live* session should dim them. rerender( - { it("toggles the Servers list compact state from the list toggle", async () => { const user = userEvent.setup({ delay: null }); renderWithMantine( - , + , ); // Servers default to expanded (compact=false), so the toggle reads // "Collapse all"; clicking it flips serversCompact via the inline callback. @@ -1106,7 +1107,7 @@ describe("InspectorView", () => { connection: { status: "connected" }, }; renderWithMantine( - { connection: { status: "connected" }, }; renderWithMantine( - { it("routes toolsListChanged to the Tools screen indicator", async () => { renderWithMantine( - { it("shares the tools flag with the Apps screen (apps are filtered tools)", async () => { renderWithMantine( - { it("routes promptsListChanged to the Prompts screen indicator", async () => { renderWithMantine( - { it("routes resourcesListChanged to the Resources screen indicator", async () => { renderWithMantine( - { it("does not show the indicator on a screen whose flag is false (no cross-wiring)", async () => { renderWithMantine( - void; + // Logging level. The MCP `logging/setLevel` request has no echo // notification, so the parent keeps the optimistic current value. currentLogLevel: LoggingLevel; @@ -455,11 +460,12 @@ export function InspectorView({ onCloseApp, onAppError, onRefreshApps, + activeTab: activeTabProp, + onActiveTabChange, }: InspectorViewProps) { // UI-only state. Connection state, primitive lists, and all action - // dispatching live in the parent; this component only owns navigation - // (which tab is visible) and a couple of view-local toggles. - const [selectedTab, setSelectedTab] = useState(SERVERS_TAB); + // dispatching live in the parent; this component only owns view-local + // toggles (sort direction, list compact). Tab selection is lifted (#1417). const [logsSort, setLogsSort] = useSortDirection("logs"); const [historySort, setHistorySort] = useSortDirection("history"); @@ -557,9 +563,9 @@ export function InspectorView({ // `[Servers]` and the view renders Servers without us having to imperatively // reset the state (and trip the `set-state-in-effect` lint). When the // connection comes back, the previous selection pops in again because - // `selectedTab` is preserved. - const activeTab = availableTabs.includes(selectedTab) - ? selectedTab + // the parent's `activeTab` is preserved. + const activeTab = availableTabs.includes(activeTabProp) + ? activeTabProp : SERVERS_TAB; // Merge the parent's `serversInput` (static config) with the runtime @@ -627,7 +633,7 @@ export function InspectorView({ latencyMs={latencyMs} activeTab={activeTab} availableTabs={availableTabs} - onTabChange={setSelectedTab} + onTabChange={onActiveTabChange} onDisconnect={onDisconnect} onToggleTheme={onToggleTheme} onOpenClientSettings={onOpenClientSettings} diff --git a/clients/web/src/test/core/auth/challenge.test.ts b/clients/web/src/test/core/auth/challenge.test.ts new file mode 100644 index 00000000..bafacc01 --- /dev/null +++ b/clients/web/src/test/core/auth/challenge.test.ts @@ -0,0 +1,393 @@ +import { describe, it, expect } from "vitest"; +import { + AuthChallengeError, + AuthRecoveryRequiredError, + isAuthChallengeError, + isConnectAuthRecoveryError, + parseAuthChallengeFromError, + parseAuthChallengeFromResponse, + parseScopeString, + parseWwwAuthenticateBearer, + unionAuthorizationScopes, +} from "@inspector/core/auth/challenge.js"; + +describe("parseWwwAuthenticateBearer", () => { + it("parses insufficient_scope and scope parameters", () => { + expect( + parseWwwAuthenticateBearer( + 'Bearer error="insufficient_scope", scope="weather:read admin:write"', + ), + ).toEqual({ + error: "insufficient_scope", + scope: "weather:read admin:write", + resourceMetadata: undefined, + errorDescription: undefined, + }); + }); + + it("parses invalid_token and error_description", () => { + expect( + parseWwwAuthenticateBearer( + 'Bearer error="invalid_token", error_description="Token expired"', + ), + ).toEqual({ + error: "invalid_token", + scope: undefined, + resourceMetadata: undefined, + errorDescription: "Token expired", + }); + }); + + it("parses unquoted RFC 6750 parameters", () => { + expect( + parseWwwAuthenticateBearer( + "Bearer error=insufficient_scope, scope=weather:read", + ), + ).toEqual({ + error: "insufficient_scope", + scope: "weather:read", + resourceMetadata: undefined, + errorDescription: undefined, + }); + }); + + it("returns empty object for non-Bearer challenges", () => { + expect(parseWwwAuthenticateBearer('Basic realm="test"')).toEqual({}); + }); +}); + +describe("parseScopeString", () => { + it("splits space-separated scopes", () => { + expect(parseScopeString("mcp tools:read")).toEqual(["mcp", "tools:read"]); + }); + + it("returns empty array for blank input", () => { + expect(parseScopeString(undefined)).toEqual([]); + expect(parseScopeString(" ")).toEqual([]); + }); +}); + +describe("unionAuthorizationScopes", () => { + it("unions previous and required scopes without duplicates", () => { + expect( + unionAuthorizationScopes("mcp tools:read", [ + "tools:read", + "weather:read", + ]), + ).toEqual(["mcp", "tools:read", "weather:read"]); + }); + + it("returns required scopes when no previous scope exists", () => { + expect(unionAuthorizationScopes(undefined, ["weather:read"])).toEqual([ + "weather:read", + ]); + }); +}); + +describe("parseAuthChallengeFromResponse", () => { + it("maps 401 invalid_token to invalid_token reason", () => { + const response = new Response(null, { + status: 401, + headers: { + "WWW-Authenticate": 'Bearer error="invalid_token"', + }, + }); + + expect(parseAuthChallengeFromResponse(response)).toEqual({ + reason: "invalid_token", + raw: { + httpStatus: 401, + wwwAuthenticate: 'Bearer error="invalid_token"', + }, + }); + }); + + it("maps 401 without error to token_expired", () => { + const response = new Response(null, { status: 401 }); + expect(parseAuthChallengeFromResponse(response)?.reason).toBe( + "token_expired", + ); + }); + + it("maps 401 insufficient_scope to insufficient_scope", () => { + const response = new Response(null, { + status: 401, + headers: { + "WWW-Authenticate": + 'Bearer error="insufficient_scope", scope="weather:read"', + }, + }); + + expect(parseAuthChallengeFromResponse(response)).toMatchObject({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }); + }); + + it("maps 403 insufficient_scope with required scopes", () => { + const response = new Response(null, { + status: 403, + headers: { + "WWW-Authenticate": + 'Bearer error="insufficient_scope", scope="weather:read"', + }, + }); + + expect( + parseAuthChallengeFromResponse(response, { toolName: "get_temp" }), + ).toMatchObject({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + context: { toolName: "get_temp" }, + }); + }); + + it("returns undefined for non-auth statuses", () => { + const response = new Response(null, { status: 500 }); + expect(parseAuthChallengeFromResponse(response)).toBeUndefined(); + }); + + it("carries error_description into the challenge message", () => { + const response = new Response(null, { + status: 401, + headers: { + "WWW-Authenticate": + 'Bearer error="invalid_token", error_description="Token expired"', + }, + }); + + expect(parseAuthChallengeFromResponse(response)).toEqual({ + reason: "invalid_token", + message: "Token expired", + raw: { + httpStatus: 401, + wwwAuthenticate: + 'Bearer error="invalid_token", error_description="Token expired"', + }, + }); + }); + + it("maps 403 with a non-scope error to unauthorized", () => { + const response = new Response(null, { + status: 403, + headers: { + "WWW-Authenticate": 'Bearer error="invalid_token"', + }, + }); + + expect(parseAuthChallengeFromResponse(response)?.reason).toBe( + "unauthorized", + ); + }); +}); + +describe("parseAuthChallengeFromError", () => { + it("extracts embedded authChallenge objects", () => { + const challenge = { + reason: "token_expired" as const, + }; + expect(parseAuthChallengeFromError({ authChallenge: challenge })).toEqual( + challenge, + ); + }); + + it("builds a challenge from status and WWW-Authenticate on errors", () => { + expect( + parseAuthChallengeFromError({ + status: 403, + wwwAuthenticate: + 'Bearer error="insufficient_scope", scope="admin:write"', + }), + ).toMatchObject({ + reason: "insufficient_scope", + requiredScopes: ["admin:write"], + }); + }); + + it("returns undefined for bare 401 without auth markers", () => { + expect(parseAuthChallengeFromError({ status: 401 })).toBeUndefined(); + }); + + it("returns the challenge directly for AuthChallengeError instances", () => { + const err = new AuthChallengeError({ reason: "invalid_token" }, 401); + expect(parseAuthChallengeFromError(err)).toEqual({ + reason: "invalid_token", + }); + }); + + it("returns undefined for non-object and null errors", () => { + expect(parseAuthChallengeFromError("boom")).toBeUndefined(); + expect(parseAuthChallengeFromError(null)).toBeUndefined(); + }); + + it("merges context into an embedded authChallenge", () => { + expect( + parseAuthChallengeFromError( + { + authChallenge: { reason: "token_expired", context: { method: "x" } }, + }, + { toolName: "get_temp" }, + ), + ).toEqual({ + reason: "token_expired", + context: { method: "x", toolName: "get_temp" }, + }); + }); + + it("falls back to the numeric code when status is absent", () => { + expect( + parseAuthChallengeFromError({ + code: 403, + wwwAuthenticate: 'Bearer error="insufficient_scope", scope="admin"', + }), + ).toMatchObject({ + reason: "insufficient_scope", + requiredScopes: ["admin"], + raw: { httpStatus: 403 }, + }); + }); + + it("returns undefined when the status is neither 401 nor 403", () => { + expect(parseAuthChallengeFromError({ status: 500 })).toBeUndefined(); + }); + + it("reads WWW-Authenticate from a headers.get accessor", () => { + expect( + parseAuthChallengeFromError({ + status: 401, + headers: { + get: (name: string) => + name === "WWW-Authenticate" ? 'Bearer error="invalid_token"' : null, + }, + }), + ).toMatchObject({ + reason: "invalid_token", + raw: { httpStatus: 401, wwwAuthenticate: 'Bearer error="invalid_token"' }, + }); + }); + + it("reads WWW-Authenticate from an embedded raw challenge", () => { + expect( + parseAuthChallengeFromError({ + status: 401, + authChallenge: { raw: { wwwAuthenticate: "Bearer realm=mcp" } }, + }), + ).toMatchObject({ + reason: "token_expired", + raw: { httpStatus: 401, wwwAuthenticate: "Bearer realm=mcp" }, + }); + }); + + it("returns undefined for an empty WWW-Authenticate header", () => { + expect( + parseAuthChallengeFromError({ status: 401, wwwAuthenticate: "" }), + ).toBeUndefined(); + }); +}); + +describe("isAuthChallengeError", () => { + it("detects AuthChallengeError instances", () => { + const err = new AuthChallengeError({ reason: "token_expired" }, 401); + expect(isAuthChallengeError(err)).toBe(true); + }); + + it("detects 401 and 403 with WWW-Authenticate as auth challenges", () => { + expect( + isAuthChallengeError({ + status: 401, + wwwAuthenticate: 'Bearer error="invalid_token"', + }), + ).toBe(true); + expect( + isAuthChallengeError({ + status: 403, + wwwAuthenticate: 'Bearer error="insufficient_scope"', + }), + ).toBe(true); + expect(isAuthChallengeError({ status: 500 })).toBe(false); + }); + + it("does not treat bare 401/403 status without auth markers as auth challenge", () => { + expect(isAuthChallengeError({ status: 401 })).toBe(false); + expect(isAuthChallengeError({ status: 403 })).toBe(false); + }); + + it("does not treat connect-time unauthorized wording as auth challenge", () => { + expect(isAuthChallengeError(new Error("network failed"))).toBe(false); + }); + + it("returns false for non-object and null errors", () => { + expect(isAuthChallengeError("boom")).toBe(false); + expect(isAuthChallengeError(null)).toBe(false); + }); + + it("detects an embedded authChallenge with a reason", () => { + expect( + isAuthChallengeError({ authChallenge: { reason: "token_expired" } }), + ).toBe(true); + }); + + it("uses the numeric code when status is absent", () => { + expect( + isAuthChallengeError({ + code: 403, + wwwAuthenticate: 'Bearer error="insufficient_scope"', + }), + ).toBe(true); + }); + + it("reads WWW-Authenticate from a headers.get accessor", () => { + expect( + isAuthChallengeError({ + status: 401, + headers: { + get: (name: string) => + name === "WWW-Authenticate" ? "Bearer realm=mcp" : null, + }, + }), + ).toBe(true); + }); + + it("reads WWW-Authenticate from an embedded raw challenge", () => { + expect( + isAuthChallengeError({ + status: 401, + authChallenge: { raw: { wwwAuthenticate: "Bearer realm=mcp" } }, + }), + ).toBe(true); + }); + + it("returns false for an empty WWW-Authenticate header", () => { + expect(isAuthChallengeError({ status: 401, wwwAuthenticate: "" })).toBe( + false, + ); + }); +}); + +describe("isConnectAuthRecoveryError", () => { + it("treats AuthRecoveryRequiredError and 401 connect failures as recoverable", () => { + expect( + isConnectAuthRecoveryError( + new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), { + reason: "unauthorized", + }), + ), + ).toBe(true); + const unauthorized = new Error("Unauthorized") as Error & { + status?: number; + }; + unauthorized.status = 401; + expect(isConnectAuthRecoveryError(unauthorized)).toBe(true); + }); + + it("does not treat other handshake failures as recoverable", () => { + expect(isConnectAuthRecoveryError(new Error("Connection timed out"))).toBe( + false, + ); + expect( + isConnectAuthRecoveryError( + new AuthChallengeError({ reason: "token_expired" }, 403), + ), + ).toBe(false); + }); +}); diff --git a/clients/web/src/test/core/auth/mcpAuth.test.ts b/clients/web/src/test/core/auth/mcpAuth.test.ts new file mode 100644 index 00000000..79af5d79 --- /dev/null +++ b/clients/web/src/test/core/auth/mcpAuth.test.ts @@ -0,0 +1,315 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; + +const { + sdkAuth, + discoverOAuthServerInfo, + startAuthorization, + selectResourceURL, + registerClient, + isHttpsUrl, +} = vi.hoisted(() => ({ + sdkAuth: vi.fn(), + discoverOAuthServerInfo: vi.fn(), + startAuthorization: vi.fn(), + selectResourceURL: vi.fn(), + registerClient: vi.fn(), + isHttpsUrl: vi.fn(), +})); + +vi.mock("@modelcontextprotocol/sdk/client/auth.js", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@modelcontextprotocol/sdk/client/auth.js") + >(); + return { + ...actual, + auth: sdkAuth, + discoverOAuthServerInfo, + selectResourceURL, + startAuthorization, + registerClient, + isHttpsUrl, + }; +}); + +import { mcpAuth } from "@inspector/core/auth/mcpAuth.js"; +import { InvalidClientMetadataError } from "@modelcontextprotocol/sdk/server/auth/errors.js"; + +function makeProvider( + overrides: Partial = {}, +): OAuthClientProvider { + return { + redirectUrl: "http://localhost/callback", + clientMetadata: { + redirect_uris: ["http://localhost/callback"], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + client_name: "test", + client_uri: "https://example.com", + scope: "", + }, + clientInformation: vi.fn().mockResolvedValue({ client_id: "cid" }), + tokens: vi.fn(), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockReturnValue("verifier"), + ...overrides, + }; +} + +/** + * Arrange the discovery + authorization mocks used by the + * `forceReauthorization` (authorize-without-refresh) path. + */ +function stubForcePath( + discovery: Partial<{ + authorizationServerUrl: string; + authorizationServerMetadata: Record; + resourceMetadata: unknown; + }> = {}, +): void { + discoverOAuthServerInfo.mockResolvedValue({ + authorizationServerUrl: "https://as.example.com", + authorizationServerMetadata: { issuer: "https://as.example.com" }, + resourceMetadata: undefined, + ...discovery, + }); + selectResourceURL.mockResolvedValue(undefined); + startAuthorization.mockResolvedValue({ + authorizationUrl: new URL("https://as.example.com/authorize"), + codeVerifier: "cv", + }); +} + +describe("mcpAuth", () => { + beforeEach(() => { + vi.clearAllMocks(); + isHttpsUrl.mockReturnValue(true); + }); + + it("delegates to SDK auth() when forceReauthorization is not set", async () => { + sdkAuth.mockResolvedValue("AUTHORIZED"); + const provider = makeProvider(); + + const result = await mcpAuth(provider, { + serverUrl: "https://mcp.example.com", + authorizationCode: "code", + fetchFn: fetch, + }); + + expect(result).toBe("AUTHORIZED"); + expect(sdkAuth).toHaveBeenCalledWith(provider, { + serverUrl: "https://mcp.example.com", + authorizationCode: "code", + scope: undefined, + resourceMetadataUrl: undefined, + fetchFn: fetch, + }); + expect(discoverOAuthServerInfo).not.toHaveBeenCalled(); + }); + + it("rejects forceReauthorization combined with authorizationCode", async () => { + const provider = makeProvider(); + + await expect( + mcpAuth(provider, { + serverUrl: "https://mcp.example.com", + authorizationCode: "code", + forceReauthorization: true, + }), + ).rejects.toThrow( + "forceReauthorization cannot be combined with authorizationCode", + ); + expect(sdkAuth).not.toHaveBeenCalled(); + expect(discoverOAuthServerInfo).not.toHaveBeenCalled(); + }); + + it("uses discovery + startAuthorization when forceReauthorization is true", async () => { + stubForcePath(); + const provider = makeProvider(); + + const result = await mcpAuth(provider, { + serverUrl: "https://mcp.example.com", + scope: "mcp weather:read", + forceReauthorization: true, + fetchFn: fetch, + }); + + expect(result).toBe("REDIRECT"); + expect(sdkAuth).not.toHaveBeenCalled(); + expect(startAuthorization).toHaveBeenCalled(); + expect(provider.saveCodeVerifier).toHaveBeenCalledWith("cv"); + expect(provider.redirectToAuthorization).toHaveBeenCalled(); + }); + + it("persists discovery state and forwards the selected resource + state", async () => { + stubForcePath({ + resourceMetadata: { resource: "https://mcp.example.com" }, + }); + selectResourceURL.mockResolvedValue(new URL("https://mcp.example.com/r")); + const saveDiscoveryState = vi.fn().mockResolvedValue(undefined); + const state = vi.fn().mockResolvedValue("state-123"); + const provider = makeProvider({ saveDiscoveryState, state }); + + await mcpAuth(provider, { + serverUrl: "https://mcp.example.com", + resourceMetadataUrl: new URL("https://mcp.example.com/.well-known/x"), + forceReauthorization: true, + }); + + expect(saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: "https://as.example.com", + resourceMetadataUrl: "https://mcp.example.com/.well-known/x", + }), + ); + expect(state).toHaveBeenCalled(); + expect(startAuthorization).toHaveBeenCalledWith( + "https://as.example.com", + expect.objectContaining({ + state: "state-123", + resource: new URL("https://mcp.example.com/r"), + }), + ); + }); + + it("derives scope from resourceMetadata.scopes_supported when no scope given", async () => { + stubForcePath({ + resourceMetadata: { scopes_supported: ["mcp", "weather:read"] }, + }); + const provider = makeProvider(); + + await mcpAuth(provider, { + serverUrl: "https://mcp.example.com", + forceReauthorization: true, + }); + + expect(startAuthorization).toHaveBeenCalledWith( + "https://as.example.com", + expect.objectContaining({ scope: "mcp weather:read" }), + ); + }); + + it("falls back to clientMetadata.scope when nothing else provides scope", async () => { + stubForcePath(); + const provider = makeProvider({ + clientMetadata: { + ...makeProvider().clientMetadata, + scope: "fallback:scope", + }, + }); + + await mcpAuth(provider, { + serverUrl: "https://mcp.example.com", + forceReauthorization: true, + }); + + expect(startAuthorization).toHaveBeenCalledWith( + "https://as.example.com", + expect.objectContaining({ scope: "fallback:scope" }), + ); + }); + + it("dynamically registers a client when no client information exists", async () => { + stubForcePath(); + registerClient.mockResolvedValue({ client_id: "registered" }); + const saveClientInformation = vi.fn().mockResolvedValue(undefined); + const provider = makeProvider({ + clientInformation: vi.fn().mockResolvedValue(undefined), + saveClientInformation, + }); + + const result = await mcpAuth(provider, { + serverUrl: "https://mcp.example.com", + scope: "mcp", + forceReauthorization: true, + }); + + expect(result).toBe("REDIRECT"); + expect(registerClient).toHaveBeenCalledWith( + "https://as.example.com", + expect.objectContaining({ scope: "mcp" }), + ); + expect(saveClientInformation).toHaveBeenCalledWith({ + client_id: "registered", + }); + }); + + it("uses a URL-based client id when the AS supports it", async () => { + stubForcePath({ + authorizationServerMetadata: { + issuer: "https://as.example.com", + client_id_metadata_document_supported: true, + }, + }); + isHttpsUrl.mockReturnValue(true); + const saveClientInformation = vi.fn().mockResolvedValue(undefined); + const provider = makeProvider({ + clientInformation: vi.fn().mockResolvedValue(undefined), + clientMetadataUrl: "https://app.example.com/client.json", + saveClientInformation, + }); + + await mcpAuth(provider, { + serverUrl: "https://mcp.example.com", + forceReauthorization: true, + }); + + expect(registerClient).not.toHaveBeenCalled(); + expect(saveClientInformation).toHaveBeenCalledWith({ + client_id: "https://app.example.com/client.json", + }); + }); + + it("rejects a non-HTTPS clientMetadataUrl", async () => { + stubForcePath(); + isHttpsUrl.mockReturnValue(false); + const provider = makeProvider({ + clientInformation: vi.fn().mockResolvedValue(undefined), + clientMetadataUrl: "http://insecure.example.com/client.json", + saveClientInformation: vi.fn(), + }); + + await expect( + mcpAuth(provider, { + serverUrl: "https://mcp.example.com", + forceReauthorization: true, + }), + ).rejects.toBeInstanceOf(InvalidClientMetadataError); + expect(registerClient).not.toHaveBeenCalled(); + }); + + it("requires saveClientInformation for dynamic registration", async () => { + stubForcePath(); + const provider = makeProvider({ + clientInformation: vi.fn().mockResolvedValue(undefined), + saveClientInformation: undefined, + }); + + await expect( + mcpAuth(provider, { + serverUrl: "https://mcp.example.com", + forceReauthorization: true, + }), + ).rejects.toThrow( + "OAuth client information must be saveable for dynamic registration", + ); + expect(registerClient).not.toHaveBeenCalled(); + }); + + it("requires a redirectUrl for the authorization_code flow", async () => { + stubForcePath(); + const provider = makeProvider({ redirectUrl: undefined }); + + await expect( + mcpAuth(provider, { + serverUrl: "https://mcp.example.com", + forceReauthorization: true, + }), + ).rejects.toThrow("redirectUrl is required for authorization_code flow"); + expect(startAuthorization).not.toHaveBeenCalled(); + }); +}); diff --git a/clients/web/src/test/core/auth/oauthUx.test.ts b/clients/web/src/test/core/auth/oauthUx.test.ts new file mode 100644 index 00000000..f209c9ab --- /dev/null +++ b/clients/web/src/test/core/auth/oauthUx.test.ts @@ -0,0 +1,336 @@ +import { describe, it, expect } from "vitest"; +import { + authRecoveryRestoredMessage, + emaStepUpFailureMessage, + emaStepUpInProgressMessage, + emaStepUpSuccessMessage, + isActionTriggeredOAuthRecovery, + isEmaStepUp, + isReAuthBannerReason, + isStandardOAuthStepUp, + isStepUpConfirmation, + oauthPreRedirectToastCopy, + oauthResumeAbandonedMessage, + oauthResumeSuccessMessage, + reAuthBannerMessage, + stepUpAdditionalScopes, + stepUpConfirmMessage, + stepUpFollowUpMessage, + stepUpInsufficientScopeMessage, + stepUpModalTitle, + stepUpAuthorizeActionLabel, +} from "@inspector/core/auth/oauthUx.js"; +import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; + +describe("oauthUx step-up copy", () => { + const challenge: AuthChallenge = { + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + authorizationScopes: ["mcp", "tools:read", "weather:read"], + context: { toolName: "get_temp" }, + }; + + it("stepUpConfirmMessage prefers tool context over scope union", () => { + expect(stepUpConfirmMessage(challenge)).toMatch(/get_temp/); + expect(stepUpConfirmMessage(challenge)).not.toMatch(/tools:read/); + }); + + it("stepUpConfirmMessage lists only requiredScopes when no tool context", () => { + expect( + stepUpConfirmMessage({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + authorizationScopes: ["mcp", "tools:read", "weather:read"], + }), + ).toBe("This operation needs additional scope: weather:read."); + }); + + it("stepUpConfirmMessage uses plural label and organization language for multiple EMA scopes", () => { + expect( + stepUpConfirmMessage( + { + reason: "insufficient_scope", + requiredScopes: ["weather:read", "weather:write"], + }, + { enterpriseManaged: true }, + ), + ).toBe( + "This operation needs additional organization scopes: weather:read, weather:write.", + ); + }); + + it("stepUpConfirmMessage uses plural label for multiple standard scopes", () => { + expect( + stepUpConfirmMessage({ + reason: "insufficient_scope", + requiredScopes: ["weather:read", "weather:write"], + }), + ).toBe( + "This operation needs additional scopes: weather:read, weather:write.", + ); + }); + + it("stepUpConfirmMessage falls back to generic standard copy with no tool or scopes", () => { + expect(stepUpConfirmMessage({ reason: "insufficient_scope" })).toBe( + "This operation needs additional OAuth scopes before it can continue.", + ); + }); + + it("stepUpConfirmMessage falls back to generic EMA copy with no tool or scopes", () => { + expect( + stepUpConfirmMessage( + { reason: "insufficient_scope", requiredScopes: ["", ""] }, + { enterpriseManaged: true }, + ), + ).toBe( + "This operation needs additional permissions from your organization before it can continue.", + ); + }); + + it("stepUpConfirmMessage uses organization language for EMA", () => { + expect( + stepUpConfirmMessage(challenge, { enterpriseManaged: true }), + ).toMatch(/organization/i); + expect(stepUpFollowUpMessage({ enterpriseManaged: true })).toMatch( + /identity provider/i, + ); + expect(stepUpFollowUpMessage()).toMatch(/redirected to authorize/i); + expect(stepUpModalTitle({ enterpriseManaged: true })).toMatch( + /organization/i, + ); + expect(stepUpModalTitle()).toBe("Additional permissions required"); + expect(stepUpAuthorizeActionLabel({ enterpriseManaged: true })).toBe( + "Authorize", + ); + expect(stepUpAuthorizeActionLabel()).toBe("Authorize (opens browser)"); + }); + + it("isStandardOAuthStepUp is true only for non-EMA insufficient_scope", () => { + expect(isStandardOAuthStepUp(challenge)).toBe(true); + expect(isStandardOAuthStepUp(challenge, { enterpriseManaged: true })).toBe( + false, + ); + expect(isStandardOAuthStepUp({ reason: "token_expired" })).toBe(false); + }); + + it("isStepUpConfirmation covers standard OAuth and EMA insufficient_scope", () => { + expect(isStepUpConfirmation(challenge)).toBe(true); + expect(isStepUpConfirmation(challenge, { enterpriseManaged: true })).toBe( + true, + ); + expect(isEmaStepUp(challenge, { enterpriseManaged: true })).toBe(true); + expect(isEmaStepUp(challenge)).toBe(false); + expect( + isStepUpConfirmation( + { reason: "token_expired" }, + { + enterpriseManaged: true, + }, + ), + ).toBe(false); + }); + + it("emaStepUpInProgressMessage describes requesting organization permissions", () => { + expect(emaStepUpInProgressMessage()).toMatch(/organization/i); + }); + + it("emaStepUpSuccessMessage suggests retry only for command-scoped recovery", () => { + expect(emaStepUpSuccessMessage()).toBe( + "Organization permissions were updated.", + ); + expect(emaStepUpSuccessMessage({ recoverySource: "tool" })).toMatch( + /Retry your action/, + ); + }); + + it("emaStepUpFailureMessage returns detail when present, else generic copy", () => { + expect(emaStepUpFailureMessage("boom")).toBe("boom"); + expect(emaStepUpFailureMessage(" ")).toBe( + "Could not obtain the additional permissions from your organization.", + ); + expect(emaStepUpFailureMessage()).toBe( + "Could not obtain the additional permissions from your organization.", + ); + }); + + it("stepUpAdditionalScopes returns requiredScopes only", () => { + expect(stepUpAdditionalScopes(challenge)).toEqual(["weather:read"]); + }); + + it("stepUpAdditionalScopes returns empty array when requiredScopes undefined", () => { + expect(stepUpAdditionalScopes({ reason: "insufficient_scope" })).toEqual( + [], + ); + }); +}); + +describe("oauthUx recovery-source predicates", () => { + it("isActionTriggeredOAuthRecovery is true for action sources, false otherwise", () => { + for (const source of ["tool", "prompt", "resource", "app"] as const) { + expect(isActionTriggeredOAuthRecovery(source)).toBe(true); + } + expect(isActionTriggeredOAuthRecovery("ambient")).toBe(false); + expect(isActionTriggeredOAuthRecovery(undefined)).toBe(false); + }); +}); + +describe("oauthUx resume/restore copy", () => { + it("oauthResumeSuccessMessage step_up varies with retry", () => { + expect( + oauthResumeSuccessMessage("step_up", { recoverySource: "tool" }), + ).toBe("Step-up authorization succeeded. Retry your action."); + expect(oauthResumeSuccessMessage("step_up")).toBe( + "Step-up authorization succeeded.", + ); + }); + + it("oauthResumeSuccessMessage reauth varies with retry", () => { + expect( + oauthResumeSuccessMessage("reauth", { recoverySource: "prompt" }), + ).toBe("Authentication succeeded. Retry your action."); + expect(oauthResumeSuccessMessage("reauth")).toBe( + "Authentication succeeded.", + ); + }); + + it("authRecoveryRestoredMessage varies with retry", () => { + expect(authRecoveryRestoredMessage({ recoverySource: "resource" })).toBe( + "Session credentials were updated. Retry your action.", + ); + expect(authRecoveryRestoredMessage()).toBe( + "Session credentials were updated.", + ); + }); + + it("oauthResumeAbandonedMessage reauth is retry-agnostic", () => { + expect( + oauthResumeAbandonedMessage("reauth", { recoverySource: "tool" }), + ).toBe("Sign-in was not completed. Re-authenticate to restore access."); + expect(oauthResumeAbandonedMessage("reauth")).toBe( + "Sign-in was not completed. Re-authenticate to restore access.", + ); + }); + + it("oauthResumeAbandonedMessage step_up varies with retry", () => { + expect( + oauthResumeAbandonedMessage("step_up", { recoverySource: "app" }), + ).toBe("Step-up authorization was not completed. Retry your action."); + expect(oauthResumeAbandonedMessage("step_up")).toBe( + "Step-up authorization was not completed.", + ); + }); +}); + +describe("oauthUx insufficient-scope resolution copy", () => { + it("prefers tool context", () => { + expect( + stepUpInsufficientScopeMessage({ + reason: "insufficient_scope", + context: { toolName: "get_temp" }, + }), + ).toMatch(/tool "get_temp"/); + }); + + it("uses authorizationScopes when present and no tool", () => { + expect( + stepUpInsufficientScopeMessage({ + reason: "insufficient_scope", + authorizationScopes: ["a", "b"], + requiredScopes: ["c"], + }), + ).toBe( + "Authorization completed, but required scopes were not granted (a, b). Grant the requested permissions on the authorization server, then retry your action.", + ); + }); + + it("falls back to requiredScopes when authorizationScopes absent", () => { + expect( + stepUpInsufficientScopeMessage({ + reason: "insufficient_scope", + requiredScopes: ["c"], + }), + ).toMatch(/\(c\)/); + }); + + it("uses generic copy when no tool or scopes", () => { + expect( + stepUpInsufficientScopeMessage({ reason: "insufficient_scope" }), + ).toBe( + "Authorization completed, but the required permissions were not granted. Grant the requested scopes on the authorization server, then retry your action.", + ); + }); +}); + +describe("oauthUx pre-redirect toast copy", () => { + it("returns undefined for a fresh connect handshake", () => { + expect( + oauthPreRedirectToastCopy("reauth", { context: "connect" }), + ).toBeUndefined(); + }); + + it("step_up toast includes server name when provided", () => { + expect(oauthPreRedirectToastCopy("step_up", { serverName: "svc" })).toEqual( + { + title: 'Step-up authorization for "svc"', + message: "Redirecting to authorize additional permissions…", + }, + ); + expect(oauthPreRedirectToastCopy("step_up", {})).toEqual({ + title: "Step-up authorization", + message: "Redirecting to authorize additional permissions…", + }); + }); + + it("enterprise-managed reauth toast re-authenticates", () => { + expect( + oauthPreRedirectToastCopy("reauth", { + serverName: "svc", + enterpriseManaged: true, + }), + ).toEqual({ + title: 'Re-authenticating "svc"', + message: "Re-authenticating…", + }); + expect( + oauthPreRedirectToastCopy("reauth", { enterpriseManaged: true }), + ).toEqual({ title: "Re-authenticating", message: "Re-authenticating…" }); + }); + + it("default reauth toast signals an expired session", () => { + expect(oauthPreRedirectToastCopy("reauth", { serverName: "svc" })).toEqual({ + title: 'Session expired for "svc"', + message: "Session expired, re-authenticating…", + }); + expect(oauthPreRedirectToastCopy("reauth", {})).toEqual({ + title: "Session expired", + message: "Session expired, re-authenticating…", + }); + }); +}); + +describe("oauthUx re-auth banner", () => { + it("isReAuthBannerReason is true for degraded-session reasons", () => { + for (const reason of [ + "token_expired", + "unauthorized", + "invalid_token", + ] as const) { + expect(isReAuthBannerReason(reason)).toBe(true); + } + expect(isReAuthBannerReason("insufficient_scope")).toBe(false); + expect(isReAuthBannerReason(undefined)).toBe(false); + }); + + it("reAuthBannerMessage varies with server name and detail", () => { + expect( + reAuthBannerMessage({ serverName: "svc", detail: "Token expired." }), + ).toBe('Authentication for "svc" needs attention. Token expired.'); + expect(reAuthBannerMessage({ serverName: "svc" })).toBe( + 'Authentication for "svc" needs attention.', + ); + expect(reAuthBannerMessage({ detail: "Token expired." })).toBe( + "Authentication needs attention. Token expired.", + ); + expect(reAuthBannerMessage({})).toBe("Authentication needs attention."); + }); +}); diff --git a/clients/web/src/test/core/auth/providers.test.ts b/clients/web/src/test/core/auth/providers.test.ts index f533f7fe..0bf8b849 100644 --- a/clients/web/src/test/core/auth/providers.test.ts +++ b/clients/web/src/test/core/auth/providers.test.ts @@ -327,6 +327,23 @@ describe("OAuthNavigation", () => { expect(navCallback).toHaveBeenCalledWith(url); }); + it("redirectToAuthorization captures URL but skips navigation when suppressed", () => { + const storage = makeStorage(); + const navCallback = vi.fn(); + const provider = makeProvider(storage, navCallback); + const url = new URL("https://mcp.example.com/authorize"); + + provider.setSuppressAuthorizationNavigation(true); + provider.redirectToAuthorization(url); + + expect(provider.getCapturedAuthUrl()).toBe(url); + expect(navCallback).not.toHaveBeenCalled(); + + provider.setSuppressAuthorizationNavigation(false); + provider.redirectToAuthorization(url); + expect(navCallback).toHaveBeenCalledWith(url); + }); + it("delegates token and scope persistence to storage", async () => { const storage = makeStorage(); const provider = makeProvider(storage); diff --git a/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts b/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts new file mode 100644 index 00000000..1dbf740f --- /dev/null +++ b/clients/web/src/test/core/auth/runner-interactive-oauth.test.ts @@ -0,0 +1,442 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + runRunnerInteractiveOAuth, + type RunnerInteractiveOAuthClient, +} from "@inspector/core/auth/node/runner-interactive-oauth.js"; +import { + createOAuthCallbackServer, + type OAuthCallbackServer, + type OAuthCallbackServerStartOptions, +} from "@inspector/core/auth/node/oauth-callback-server.js"; +import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; + +vi.mock( + "@inspector/core/auth/node/oauth-callback-server.js", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@inspector/core/auth/node/oauth-callback-server.js") + >(); + return { ...actual, createOAuthCallbackServer: vi.fn() }; + }, +); + +function mockClient( + overrides: Partial = {}, +): RunnerInteractiveOAuthClient { + return { + authenticate: vi.fn(async () => undefined), + beginInteractiveAuthorization: vi.fn(async () => {}), + completeOAuthFlow: vi.fn(async () => {}), + checkAuthChallengeSatisfied: vi.fn(async () => true), + ...overrides, + }; +} + +interface MockCallbackHandlers { + onCallback?: OAuthCallbackServerStartOptions["onCallback"]; + onError?: OAuthCallbackServerStartOptions["onError"]; +} + +function createMockCallbackServer(handlers: { + current: MockCallbackHandlers; +}): OAuthCallbackServer { + return { + start: vi.fn(async (opts: OAuthCallbackServerStartOptions) => { + handlers.current = { + onCallback: opts.onCallback, + onError: opts.onError, + }; + return { + port: 6276, + redirectUrl: "http://127.0.0.1:6276/oauth/callback", + }; + }), + stop: vi.fn(async () => {}), + } as unknown as OAuthCallbackServer; +} + +/** Drive the loopback callback the way a browser redirect would. */ +async function simulateCallback( + handlers: MockCallbackHandlers, + code = "auth-code-123", +): Promise { + if (!handlers.onCallback) { + throw new Error("onCallback not registered"); + } + await handlers.onCallback({ code }); +} + +describe("runRunnerInteractiveOAuth", () => { + const handlers: { current: MockCallbackHandlers } = { current: {} }; + + afterEach(() => { + handlers.current = {}; + vi.restoreAllMocks(); + }); + + it("returns already_authorized when authenticate yields no URL", async () => { + const client = mockClient({ + authenticate: vi.fn(async () => undefined), + }); + const redirectUrlProvider = { redirectUrl: "" }; + + const result = await runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => createMockCallbackServer(handlers), + }); + + expect(result).toEqual({ kind: "already_authorized" }); + expect(client.completeOAuthFlow).not.toHaveBeenCalled(); + }); + + it("clears the callback timeout on already_authorized", async () => { + vi.useFakeTimers(); + try { + const client = mockClient({ + authenticate: vi.fn(async () => undefined), + }); + const redirectUrlProvider = { redirectUrl: "" }; + + const result = await runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + callbackTimeoutMs: 60_000, + createCallbackServer: () => createMockCallbackServer(handlers), + }); + + expect(result).toEqual({ kind: "already_authorized" }); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(60_000); + } finally { + vi.useRealTimers(); + } + }); + + it("completes connect-time OAuth via authenticate and callback", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const client = mockClient({ + authenticate: vi.fn(async () => { + await simulateCallback(handlers.current); + return new URL("https://as.example/authorize"); + }), + }); + + const result = await runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => createMockCallbackServer(handlers), + }); + + expect(result).toEqual({ kind: "success" }); + expect(client.completeOAuthFlow).toHaveBeenCalledWith("auth-code-123"); + expect(redirectUrlProvider.redirectUrl).toBe( + "http://127.0.0.1:6276/oauth/callback", + ); + }); + + it("uses beginInteractiveAuthorization when authorizationUrl is provided", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const authorizationUrl = new URL( + "https://as.example/authorize?state=step-up", + ); + const client = mockClient({ + beginInteractiveAuthorization: vi.fn(async () => { + await simulateCallback(handlers.current, "step-up-code"); + }), + }); + + const result = await runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + authorizationUrl, + createCallbackServer: () => createMockCallbackServer(handlers), + }); + + expect(result).toEqual({ kind: "success" }); + expect(client.beginInteractiveAuthorization).toHaveBeenCalledWith( + authorizationUrl, + ); + expect(client.authenticate).not.toHaveBeenCalled(); + expect(client.completeOAuthFlow).toHaveBeenCalledWith("step-up-code"); + }); + + it("returns insufficient_scope when post-step-up check fails", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const challenge: AuthChallenge = { + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }; + const client = mockClient({ + beginInteractiveAuthorization: vi.fn(async () => { + await simulateCallback(handlers.current); + }), + checkAuthChallengeSatisfied: vi.fn(async () => false), + }); + + const result = await runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + authorizationUrl: new URL("https://as.example/authorize"), + authChallenge: challenge, + createCallbackServer: () => createMockCallbackServer(handlers), + }); + + expect(result).toEqual({ kind: "insufficient_scope", challenge }); + }); + + it("propagates completeOAuthFlow failures", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const client = mockClient({ + authenticate: vi.fn(async () => { + await simulateCallback(handlers.current); + return new URL("https://as.example/authorize"); + }), + completeOAuthFlow: vi.fn(async () => { + throw new Error("token exchange failed"); + }), + }); + + await expect( + runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => createMockCallbackServer(handlers), + }), + ).rejects.toThrow("token exchange failed"); + }); + + it("propagates OAuth callback errors from the authorization server", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const client = mockClient({ + authenticate: vi.fn(async () => { + handlers.current.onError?.({ + error: "access_denied", + error_description: "user cancelled", + }); + return new URL("https://as.example/authorize"); + }), + }); + + await expect( + runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => createMockCallbackServer(handlers), + }), + ).rejects.toThrow("user cancelled"); + }); + + it("times out when the browser callback never arrives", async () => { + vi.useFakeTimers(); + try { + const redirectUrlProvider = { redirectUrl: "" }; + const client = mockClient({ + authenticate: vi.fn( + async () => new URL("https://as.example/authorize"), + ), + }); + + const promise = runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + callbackTimeoutMs: 1000, + createCallbackServer: () => createMockCallbackServer(handlers), + }); + + const assertRejects = + expect(promise).rejects.toThrow(/timed out after 1s/); + await vi.advanceTimersByTimeAsync(1000); + await assertRejects; + } finally { + vi.useRealTimers(); + } + }); + + it("invokes onCallbackServer with the live listener", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const onCallbackServer = vi.fn(); + const mockServer = createMockCallbackServer(handlers); + const client = mockClient({ + authenticate: vi.fn(async () => { + await simulateCallback(handlers.current); + return new URL("https://as.example/authorize"); + }), + }); + + await runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + onCallbackServer, + createCallbackServer: () => mockServer, + }); + + expect(onCallbackServer).toHaveBeenCalledTimes(1); + expect(onCallbackServer).toHaveBeenCalledWith(mockServer); + }); + + it("wraps a non-Error completeOAuthFlow rejection", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const client = mockClient({ + authenticate: vi.fn(async () => { + await simulateCallback(handlers.current).catch(() => {}); + return new URL("https://as.example/authorize"); + }), + completeOAuthFlow: vi.fn(async () => { + throw "string failure"; + }), + }); + + await expect( + runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => createMockCallbackServer(handlers), + }), + ).rejects.toThrow("string failure"); + }); + + it("falls back to params.error when no error_description is present", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const client = mockClient({ + authenticate: vi.fn(async () => { + handlers.current.onError?.({ error: "access_denied" }); + return new URL("https://as.example/authorize"); + }), + }); + + await expect( + runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => createMockCallbackServer(handlers), + }), + ).rejects.toThrow("access_denied"); + }); + + it("defaults to createOAuthCallbackServer when none is provided", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + vi.mocked(createOAuthCallbackServer).mockReturnValue( + createMockCallbackServer(handlers), + ); + const client = mockClient({ + authenticate: vi.fn(async () => undefined), + }); + + const result = await runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + }); + + expect(result).toEqual({ kind: "already_authorized" }); + expect(createOAuthCallbackServer).toHaveBeenCalled(); + }); + + it("swallows a server.stop() rejection during cleanup", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const mockServer = createMockCallbackServer(handlers); + vi.mocked(mockServer.stop).mockRejectedValue(new Error("stop failed")); + const client = mockClient({ + authenticate: vi.fn(async () => { + await simulateCallback(handlers.current); + return new URL("https://as.example/authorize"); + }), + }); + + const result = await runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => mockServer, + }); + + expect(result).toEqual({ kind: "success" }); + expect(mockServer.stop).toHaveBeenCalled(); + }); + + it("cleans up when the callback server fails to start", async () => { + const redirectUrlProvider = { redirectUrl: "" }; + const mockServer = createMockCallbackServer(handlers); + vi.mocked(mockServer.start).mockRejectedValue(new Error("bind failed")); + const client = mockClient(); + + await expect( + runRunnerInteractiveOAuth({ + client, + redirectUrlProvider, + callbackListen: { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", + }, + createCallbackServer: () => mockServer, + }), + ).rejects.toThrow("bind failed"); + expect(mockServer.stop).toHaveBeenCalled(); + }); +}); diff --git a/clients/web/src/test/core/auth/scopes.test.ts b/clients/web/src/test/core/auth/scopes.test.ts new file mode 100644 index 00000000..bc906661 --- /dev/null +++ b/clients/web/src/test/core/auth/scopes.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { + computeScopeUnion, + isStrictScopeSuperset, + resolveEffectiveGrantedScope, + resolvePersistedScopeAfterGrant, +} from "@inspector/core/auth/scopes.js"; + +describe("scopes", () => { + describe("computeScopeUnion", () => { + it("unions and dedupes scope tokens", () => { + expect( + computeScopeUnion("mcp tools:read", "tools:read weather:read"), + ).toBe("mcp tools:read weather:read"); + }); + + it("returns undefined when all inputs are empty", () => { + expect(computeScopeUnion(undefined, "", undefined)).toBeUndefined(); + }); + }); + + describe("isStrictScopeSuperset", () => { + it("returns true when union adds a new scope", () => { + expect( + isStrictScopeSuperset("mcp tools:read weather:read", "mcp tools:read"), + ).toBe(true); + }); + + it("returns false when union is covered by current grant", () => { + expect(isStrictScopeSuperset("mcp tools:read", "mcp tools:read")).toBe( + false, + ); + }); + + it("treats missing token scope as empty (forces re-auth)", () => { + expect(isStrictScopeSuperset("mcp weather:read", undefined)).toBe(true); + }); + }); + + describe("resolvePersistedScopeAfterGrant", () => { + it("prefers explicit granted scope over requested", () => { + expect(resolvePersistedScopeAfterGrant("mcp", "mcp weather:read")).toBe( + "mcp", + ); + }); + + it("falls back to requested scope when grant omits scope", () => { + expect( + resolvePersistedScopeAfterGrant(undefined, "mcp weather:read"), + ).toBe("mcp weather:read"); + }); + }); + + describe("resolveEffectiveGrantedScope", () => { + it("uses token scope when present even if storage overstates grant", () => { + expect(resolveEffectiveGrantedScope("mcp weather:read", "mcp")).toBe( + "mcp", + ); + }); + + it("falls back to stored scope when token omits scope", () => { + expect(resolveEffectiveGrantedScope("mcp tools:read", undefined)).toBe( + "mcp tools:read", + ); + }); + }); +}); diff --git a/clients/web/src/test/core/auth/utils.test.ts b/clients/web/src/test/core/auth/utils.test.ts index 08189dca..8852a1a5 100644 --- a/clients/web/src/test/core/auth/utils.test.ts +++ b/clients/web/src/test/core/auth/utils.test.ts @@ -5,7 +5,10 @@ import { generateOAuthState, parseOAuthState, generateOAuthErrorDescription, + formatOAuthFailureDetail, + isUnauthorizedError, } from "@inspector/core/auth/utils.js"; +import { z, ZodError } from "zod"; describe("parseHttpUrl", () => { it("parses valid URLs", () => { @@ -17,6 +20,12 @@ describe("parseHttpUrl", () => { it("throws on invalid URLs", () => { expect(() => parseHttpUrl("not-a-url", "test")).toThrow(/Invalid test/); }); + + it("includes the trimmed offending value in the thrown message", () => { + expect(() => parseHttpUrl(" bad url ", "Server URL")).toThrow( + /Invalid Server URL: "bad url"/, + ); + }); }); describe("parseOAuthCallbackParams", () => { @@ -129,3 +138,152 @@ describe("generateOAuthErrorDescription", () => { ); }); }); + +describe("formatOAuthFailureDetail", () => { + const zodTokenJson = `[ { "expected": "string", "code": "invalid_type", "path": [ "access_token" ], "message": "Invalid input" }, { "expected": "string", "code": "invalid_type", "path": [ "token_type" ], "message": "Invalid input" } ]`; + + it("replaces serialized Zod token-response issues with readable copy", () => { + expect(formatOAuthFailureDetail(zodTokenJson)).toBe( + "The authorization server did not return valid tokens. Check your OAuth client ID and secret, then try again.", + ); + }); + + it("formats ZodError instances", () => { + const err = new ZodError([ + { + code: "invalid_type", + expected: "string", + path: ["access_token"], + message: "Invalid input", + }, + ]); + expect(formatOAuthFailureDetail(err)).toMatch(/valid tokens/i); + }); + + it("passes through ordinary error messages", () => { + expect(formatOAuthFailureDetail(new Error("Network timeout"))).toBe( + "Network timeout", + ); + }); + + it("passes through plain strings that are not bracketed JSON", () => { + expect(formatOAuthFailureDetail("something went wrong")).toBe( + "something went wrong", + ); + }); + + it("stringifies non-string/non-error values", () => { + expect(formatOAuthFailureDetail(42)).toBe("42"); + expect(formatOAuthFailureDetail(null)).toBe("null"); + }); + + it("formats a real ZodError instance (non-token path)", () => { + const err = z.string().safeParse(123).error!; + const result = formatOAuthFailureDetail(err); + expect(result).not.toMatch(/valid tokens/i); + // Root-level failure has an empty path → "input" label + expect(result).toMatch(/^input: /); + }); + + it("joins non-token issues as `path: message`", () => { + const err = new ZodError([ + { + code: "invalid_type", + expected: "string", + path: ["client", "id"], + message: "Required", + }, + ]); + expect(formatOAuthFailureDetail(err)).toBe("client.id: Required"); + }); + + it("labels an issue with an empty path as `input` and defaults a missing message to `invalid`", () => { + const err = new ZodError([ + { + code: "custom", + path: [], + } as unknown as z.core.$ZodIssue, + ]); + expect(formatOAuthFailureDetail(err)).toBe("input: invalid"); + }); + + it("formats a bracketed JSON string of non-token zod issues", () => { + const json = `[ { "code": "invalid_type", "path": [ "scope" ], "message": "Required" } ]`; + expect(formatOAuthFailureDetail(json)).toBe("scope: Required"); + }); + + it("returns the raw string when bracketed JSON is invalid", () => { + const bad = "[ not valid json"; + expect(formatOAuthFailureDetail(bad)).toBe(bad); + }); + + it("returns the raw string when bracketed JSON is not a zod-issue array", () => { + // Parses fine, but empty array / first element lacks `code` + expect(formatOAuthFailureDetail("[]")).toBe("[]"); + const notIssues = `[ { "foo": "bar" } ]`; + expect(formatOAuthFailureDetail(notIssues)).toBe(notIssues); + }); + + it("returns the raw string when bracketed JSON is a non-array value", () => { + const obj = `[1, 2, 3]`; + // Array of numbers: first element is not an object → not a zod-issue array + expect(formatOAuthFailureDetail(obj)).toBe(obj); + }); +}); + +describe("isUnauthorizedError", () => { + it("returns true for an object with status 401", () => { + expect(isUnauthorizedError({ status: 401 })).toBe(true); + }); + + it("returns true for an object with code 401", () => { + expect(isUnauthorizedError({ code: 401 })).toBe(true); + }); + + it("returns true when an Error message matches the transport wording", () => { + expect( + isUnauthorizedError(new Error("Connection failed for server (401)")), + ).toBe(true); + }); + + it("returns true when a non-error value stringifies to matching wording", () => { + expect(isUnauthorizedError("request failed with status (401)")).toBe(true); + }); + + it("returns false for unrelated (401) mentions without `failed`", () => { + expect(isUnauthorizedError(new Error("error code (401) noted"))).toBe( + false, + ); + }); + + it("returns false for a non-401 object", () => { + expect(isUnauthorizedError({ status: 500 })).toBe(false); + }); + + it("returns false for null", () => { + expect(isUnauthorizedError(null)).toBe(false); + }); +}); + +describe("generateOAuthErrorDescription without description/uri", () => { + it("omits the details and more-info lines when absent", () => { + const message = generateOAuthErrorDescription({ + successful: false, + error: "server_error", + error_description: null, + error_uri: null, + }); + expect(message).toBe("Error: server_error."); + }); +}); + +describe("parseOAuthCallbackParams error without description", () => { + it("returns null description/uri when only error is present", () => { + expect(parseOAuthCallbackParams("?error=access_denied")).toEqual({ + successful: false, + error: "access_denied", + error_description: null, + error_uri: null, + }); + }); +}); diff --git a/clients/web/src/test/core/mcp/authResyncPropagation.test.ts b/clients/web/src/test/core/mcp/authResyncPropagation.test.ts new file mode 100644 index 00000000..0b4da48c --- /dev/null +++ b/clients/web/src/test/core/mcp/authResyncPropagation.test.ts @@ -0,0 +1,288 @@ +/** + * Remote transport contract: HTTP ack + SSE payload, auth retry on send. + */ + +import { describe, it, expect, vi } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js"; +import { MessageTrackingTransport } from "@inspector/core/mcp/messageTrackingTransport.js"; +import { RemoteClientTransport } from "@inspector/core/mcp/remote/remoteClientTransport.js"; + +const config = { + type: "streamable-http" as const, + url: "http://localhost/mcp", +}; + +/** SSE stream that can push MCP message events after /api/mcp/send accepts a request. */ +function createPushableSseStream() { + const encoder = new TextEncoder(); + let controller: ReadableStreamDefaultController | null = null; + const stream = new ReadableStream({ + start(c) { + controller = c; + c.enqueue(encoder.encode(": keepalive\n\n")); + }, + }); + const pushMessage = (message: unknown) => { + const payload = JSON.stringify({ type: "message", data: message }); + controller?.enqueue(encoder.encode(`data: ${payload}\n\n`)); + }; + return { + response: new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + pushMessage, + }; +} + +describe("RemoteClientTransport send contract", () => { + it("waits for SSE response after HTTP ok:true and completes within 1s", async () => { + const sse = createPushableSseStream(); + const fetchFn = vi + .fn() + .mockImplementation(async (input, init) => { + const url = String(input); + if (url.endsWith("/api/mcp/connect") && init?.method === "POST") { + return new Response(JSON.stringify({ sessionId: "s1" }), { + status: 200, + }); + } + if (url.includes("/api/mcp/events")) { + return sse.response; + } + if (url.endsWith("/api/mcp/send") && init?.method === "POST") { + const body = JSON.parse(String(init.body)) as { + message: { id?: string | number; method?: string }; + }; + sse.pushMessage({ + jsonrpc: "2.0", + id: body.message.id, + result: { tools: [] }, + }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }); + + const transport = new RemoteClientTransport( + { + baseUrl: "http://remote.test/", + fetchFn: fetchFn as unknown as typeof fetch, + sseResponseTimeoutMs: 2000, + }, + config, + ); + + await transport.start(); + + const started = Date.now(); + await transport.send({ jsonrpc: "2.0", id: 7, method: "tools/list" }); + expect(Date.now() - started).toBeLessThan(1000); + + await transport.close(); + }); + + it("times out within 5s when HTTP ok:true but no SSE response arrives", async () => { + const sse = createPushableSseStream(); + const fetchFn = vi + .fn() + .mockImplementation(async (input, init) => { + const url = String(input); + if (url.endsWith("/api/mcp/connect") && init?.method === "POST") { + return new Response(JSON.stringify({ sessionId: "s1" }), { + status: 200, + }); + } + if (url.includes("/api/mcp/events")) { + return sse.response; + } + if (url.endsWith("/api/mcp/send") && init?.method === "POST") { + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }); + + const transport = new RemoteClientTransport( + { + baseUrl: "http://remote.test/", + fetchFn: fetchFn as unknown as typeof fetch, + sseResponseTimeoutMs: 500, + }, + config, + ); + + await transport.start(); + + const started = Date.now(); + await expect( + transport.send({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + ).rejects.toThrow(/Timed out waiting for MCP response on SSE/i); + expect(Date.now() - started).toBeLessThan(5000); + + await transport.close(); + }); + + it("retries send once after satisfied auth challenge and pushAuthState", async () => { + const sse = createPushableSseStream(); + let sendCalls = 0; + let connectCalls = 0; + const fetchFn = vi + .fn() + .mockImplementation(async (input, init) => { + const url = String(input); + if (url.endsWith("/api/mcp/connect") && init?.method === "POST") { + connectCalls += 1; + return new Response( + JSON.stringify({ sessionId: `s${connectCalls}` }), + { status: 200 }, + ); + } + if (url.includes("/api/mcp/events")) { + return sse.response; + } + if (url.endsWith("/api/mcp/send") && init?.method === "POST") { + sendCalls += 1; + const body = JSON.parse(String(init.body)) as { + message: { id?: string | number }; + }; + if (sendCalls === 1) { + return new Response( + JSON.stringify({ + ok: false, + kind: "auth_challenge", + authChallenge: { reason: "token_expired" }, + }), + { status: 200 }, + ); + } + sse.pushMessage({ + jsonrpc: "2.0", + id: body.message.id, + result: { tools: [{ name: "echo" }] }, + }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }); + + const pushAuthState = vi.fn().mockResolvedValue(undefined); + + const transport = new RemoteClientTransport( + { + baseUrl: "http://remote.test/", + fetchFn: fetchFn as unknown as typeof fetch, + sseResponseTimeoutMs: 2000, + authRecovery: { + handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "satisfied" }), + pushAuthState, + }, + }, + config, + ); + + await transport.start(); + + const started = Date.now(); + await transport.send({ jsonrpc: "2.0", id: 1, method: "tools/list" }); + expect(Date.now() - started).toBeLessThan(5000); + expect(sendCalls).toBe(2); + expect(connectCalls).toBe(1); + expect(pushAuthState).toHaveBeenCalledTimes(1); + + await transport.close(); + }); + + it("SDK listTools succeeds through full stack after auth retry", async () => { + let sse = createPushableSseStream(); + let sendCalls = 0; + const fetchFn = vi + .fn() + .mockImplementation(async (input, init) => { + const url = String(input); + if (url.endsWith("/api/mcp/connect") && init?.method === "POST") { + return new Response(JSON.stringify({ sessionId: "s1" }), { + status: 200, + }); + } + if (url.includes("/api/mcp/events")) { + sse = createPushableSseStream(); + return sse.response; + } + if (url.endsWith("/api/mcp/auth-state") && init?.method === "POST") { + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + if (url.endsWith("/api/mcp/send") && init?.method === "POST") { + sendCalls += 1; + const body = JSON.parse(String(init.body)) as { + message: { method?: string; id?: string | number }; + }; + if (body.message.method === "initialize") { + sse.pushMessage({ + jsonrpc: "2.0", + id: body.message.id, + result: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: "test", version: "1.0.0" }, + }, + }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + if (body.message.method === "tools/list") { + if (sendCalls === 2) { + return new Response( + JSON.stringify({ + ok: false, + kind: "auth_challenge", + authChallenge: { reason: "token_expired" }, + }), + { status: 200 }, + ); + } + sse.pushMessage({ + jsonrpc: "2.0", + id: body.message.id, + result: { + tools: [{ name: "echo", inputSchema: { type: "object" } }], + }, + }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }); + + const authProvider = { + tokens: vi.fn().mockResolvedValue({ + access_token: "refreshed", + token_type: "Bearer", + }), + }; + + const transport = new RemoteClientTransport( + { + baseUrl: "http://remote.test/", + fetchFn: fetchFn as unknown as typeof fetch, + sseResponseTimeoutMs: 2000, + authProvider: authProvider as never, + }, + config, + ); + transport.setAuthRecovery({ + handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "satisfied" }), + pushAuthState: () => transport.pushAuthState(), + }); + const wrapped = new MessageTrackingTransport(transport, {}); + const client = new Client({ name: "test", version: "1.0.0" }); + await client.connect(wrapped); + + const started = Date.now(); + const result = await client.listTools(); + expect(Date.now() - started).toBeLessThan(5000); + expect(result.tools).toHaveLength(1); + + await transport.close(); + }); +}); diff --git a/clients/web/src/test/core/mcp/inspectorClient-ambient-auth.test.ts b/clients/web/src/test/core/mcp/inspectorClient-ambient-auth.test.ts new file mode 100644 index 00000000..d3804f34 --- /dev/null +++ b/clients/web/src/test/core/mcp/inspectorClient-ambient-auth.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { RemoteClientTransport } from "@inspector/core/mcp/remote/remoteClientTransport.js"; + +describe("InspectorClient ambient auth dedup", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("joins concurrent handleAmbientAuthChallenge calls for the same challenge", async () => { + const client = Object.create(InspectorClient.prototype) as InspectorClient; + const handleAuthChallenge = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve({ kind: "satisfied" as const }), 20); + }), + ); + (client as unknown as { oauthManager: unknown }).oauthManager = { + handleAuthChallenge, + }; + ( + client as unknown as { + ambientAuthChallengeInFlight: Map>; + } + ).ambientAuthChallengeInFlight = new Map(); + (client as unknown as { baseTransport: unknown }).baseTransport = + Object.create(RemoteClientTransport.prototype); + vi.spyOn(client, "pushRemoteAuthState").mockResolvedValue(undefined); + vi.spyOn(client, "dispatchTypedEvent").mockImplementation(() => {}); + + const challenge = { + reason: "token_expired" as const, + }; + + await Promise.all([ + client.handleAmbientAuthChallenge(challenge), + client.handleAmbientAuthChallenge(challenge), + ]); + + expect(handleAuthChallenge).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clients/web/src/test/core/mcp/inspectorClient-authRecovery-branches.test.ts b/clients/web/src/test/core/mcp/inspectorClient-authRecovery-branches.test.ts new file mode 100644 index 00000000..2052a35c --- /dev/null +++ b/clients/web/src/test/core/mcp/inspectorClient-authRecovery-branches.test.ts @@ -0,0 +1,297 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { AuthChallengeError } from "@inspector/core/auth/challenge.js"; +import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { RemoteClientTransport } from "@inspector/core/mcp/remote/remoteClientTransport.js"; + +/** + * Additional branch coverage for the mid-session auth recovery paths + * (`handleAmbientAuthChallenge` / `runAmbientAuthChallenge`, `withDirectAuthRecovery`, + * `pushRemoteAuthState`, `resumeAfterOAuth`). Complements + * inspectorClient-ambient-auth.test.ts and inspectorClient-direct-auth-recovery.test.ts, + * which each cover a single "happy path" through these methods. + * + * Uses the same `Object.create(InspectorClient.prototype)` technique as the + * sibling test files so each test can wire up only the private fields its + * branch needs, without a live transport or a real OAuthManager. + */ + +type FakeOAuthManager = { + handleAuthChallenge: ReturnType; + completeOAuthFlow?: ReturnType; +}; + +type Internals = { + oauthManager: FakeOAuthManager | null; + baseTransport: unknown; + ambientAuthChallengeInFlight: Map>; + directAuthRecovery: boolean; + directAuthRecoveryActive: boolean | null; + activeToolCallAbortController?: AbortController; + status: string; + reconnectAfterAuthRecovery: () => Promise; + withDirectAuthRecovery: ( + operation: () => Promise, + context?: { method?: string; toolName?: string }, + attempt?: number, + ) => Promise; +}; + +function makeClient(): InspectorClient { + const client = Object.create(InspectorClient.prototype) as InspectorClient; + (client as unknown as Internals).ambientAuthChallengeInFlight = new Map(); + return client; +} + +function internalsOf(client: InspectorClient): Internals { + return client as unknown as Internals; +} + +describe("InspectorClient pushRemoteAuthState", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("is a no-op when the base transport is not a RemoteClientTransport", async () => { + const client = makeClient(); + internalsOf(client).baseTransport = null; + + await expect(client.pushRemoteAuthState()).resolves.toBeUndefined(); + }); +}); + +describe("InspectorClient runAmbientAuthChallenge branches", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + const challenge: AuthChallenge = { reason: "token_expired" }; + + it("returns without dispatching a recovery outcome when no oauthManager is configured", async () => { + const client = makeClient(); + internalsOf(client).oauthManager = null; + const dispatch = vi + .spyOn(client, "dispatchTypedEvent") + .mockImplementation(() => {}); + + await client.handleAmbientAuthChallenge(challenge); + + // Only the initial "ambient" announcement fires; the early return means no + // outcome-specific event (recovered/interactive/oauthError) is dispatched. + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith("authChallengeAmbient", { + challenge, + }); + }); + + it("reconnects (rather than pushing remote auth state) for a non-remote transport on a satisfied outcome", async () => { + const client = makeClient(); + const handleAuthChallenge = vi + .fn() + .mockResolvedValue({ kind: "satisfied" }); + internalsOf(client).oauthManager = { handleAuthChallenge }; + internalsOf(client).baseTransport = {}; // not a RemoteClientTransport instance + const reconnect = vi.fn().mockResolvedValue(undefined); + internalsOf(client).reconnectAfterAuthRecovery = reconnect; + const pushRemoteAuthState = vi + .spyOn(client, "pushRemoteAuthState") + .mockResolvedValue(undefined); + const dispatch = vi + .spyOn(client, "dispatchTypedEvent") + .mockImplementation(() => {}); + + await client.handleAmbientAuthChallenge(challenge); + + expect(reconnect).toHaveBeenCalledTimes(1); + expect(pushRemoteAuthState).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledWith("authChallengeRecovered", { + challenge, + }); + }); + + it("dispatches an interactive event with the EMA pending URL for a step_up_confirm outcome", async () => { + const client = makeClient(); + const outcomeChallenge: AuthChallenge = { + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }; + const handleAuthChallenge = vi.fn().mockResolvedValue({ + kind: "step_up_confirm", + challenge: outcomeChallenge, + }); + internalsOf(client).oauthManager = { handleAuthChallenge }; + const dispatch = vi + .spyOn(client, "dispatchTypedEvent") + .mockImplementation(() => {}); + + await client.handleAmbientAuthChallenge(challenge); + + expect(dispatch).toHaveBeenCalledWith( + "authChallengeInteractive", + expect.objectContaining({ challenge: outcomeChallenge }), + ); + }); + + it("dispatches oauthError for a failed outcome", async () => { + const client = makeClient(); + const error = new Error("recovery failed"); + const handleAuthChallenge = vi + .fn() + .mockResolvedValue({ kind: "failed", error }); + internalsOf(client).oauthManager = { handleAuthChallenge }; + const dispatch = vi + .spyOn(client, "dispatchTypedEvent") + .mockImplementation(() => {}); + + await client.handleAmbientAuthChallenge(challenge); + + expect(dispatch).toHaveBeenCalledWith("oauthError", { error }); + }); + + it("wraps a non-Error thrown from handleAuthChallenge before dispatching oauthError", async () => { + const client = makeClient(); + const handleAuthChallenge = vi.fn().mockRejectedValue("boom-string"); + internalsOf(client).oauthManager = { handleAuthChallenge }; + const dispatch = vi + .spyOn(client, "dispatchTypedEvent") + .mockImplementation(() => {}); + + await client.handleAmbientAuthChallenge(challenge); + + const call = dispatch.mock.calls.find(([name]) => name === "oauthError"); + expect(call).toBeDefined(); + const detail = call?.[1] as { error: Error }; + expect(detail.error).toBeInstanceOf(Error); + expect(detail.error.message).toBe("boom-string"); + }); + + it("passes through a real Error thrown from handleAuthChallenge unwrapped", async () => { + const client = makeClient(); + const thrown = new Error("direct failure"); + const handleAuthChallenge = vi.fn().mockRejectedValue(thrown); + internalsOf(client).oauthManager = { handleAuthChallenge }; + const dispatch = vi + .spyOn(client, "dispatchTypedEvent") + .mockImplementation(() => {}); + + await client.handleAmbientAuthChallenge(challenge); + + const call = dispatch.mock.calls.find(([name]) => name === "oauthError"); + expect(call?.[1]).toEqual({ error: thrown }); + }); +}); + +describe("InspectorClient withDirectAuthRecovery branches", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("rethrows immediately without consulting handleAuthChallenge for a non-auth-challenge error", async () => { + const client = makeClient(); + internalsOf(client).directAuthRecovery = true; + internalsOf(client).directAuthRecoveryActive = true; + const operation = vi.fn().mockRejectedValue(new Error("network down")); + const handleAuthChallenge = vi.spyOn(client, "handleAuthChallenge"); + + await expect( + internalsOf(client).withDirectAuthRecovery.call(client, operation), + ).rejects.toThrow("network down"); + + expect(operation).toHaveBeenCalledTimes(1); + expect(handleAuthChallenge).not.toHaveBeenCalled(); + }); + + it("clears an active tool-call abort controller and retries after a satisfied recovery", async () => { + const client = makeClient(); + internalsOf(client).directAuthRecovery = true; + internalsOf(client).directAuthRecoveryActive = true; + const abortController = new AbortController(); + internalsOf(client).activeToolCallAbortController = abortController; + const reconnect = vi.fn().mockResolvedValue(undefined); + internalsOf(client).reconnectAfterAuthRecovery = reconnect; + + const challenge = { reason: "token_expired" as const }; + const operation = vi + .fn() + .mockRejectedValueOnce(new AuthChallengeError(challenge, 401)) + .mockResolvedValueOnce("ok"); + vi.spyOn(client, "handleAuthChallenge").mockResolvedValue({ + kind: "satisfied", + }); + vi.spyOn(client, "dispatchTypedEvent").mockImplementation(() => {}); + + const result = await internalsOf(client).withDirectAuthRecovery.call( + client, + operation, + ); + + expect(result).toBe("ok"); + expect(internalsOf(client).activeToolCallAbortController).toBeUndefined(); + expect(reconnect).toHaveBeenCalledTimes(1); + expect(operation).toHaveBeenCalledTimes(2); + }); + + it("throws AuthRecoveryRequiredError with emaStepUpConfirm for a step_up_confirm outcome", async () => { + const client = makeClient(); + internalsOf(client).directAuthRecovery = true; + internalsOf(client).directAuthRecoveryActive = true; + const challenge = { + reason: "insufficient_scope" as const, + requiredScopes: ["weather:read"], + }; + const operation = vi + .fn() + .mockRejectedValue(new AuthChallengeError(challenge, 403)); + vi.spyOn(client, "handleAuthChallenge").mockResolvedValue({ + kind: "step_up_confirm", + challenge, + }); + vi.spyOn(client, "dispatchTypedEvent").mockImplementation(() => {}); + + await expect( + internalsOf(client).withDirectAuthRecovery.call(client, operation), + ).rejects.toMatchObject({ + name: "AuthRecoveryRequiredError", + emaStepUpConfirm: true, + }); + expect(operation).toHaveBeenCalledTimes(1); + }); +}); + +describe("InspectorClient resumeAfterOAuth branches", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("reattaches a remote session, pushes auth state, and reconnects when not connected", async () => { + const client = makeClient(); + internalsOf(client).oauthManager = { + handleAuthChallenge: vi.fn(), + completeOAuthFlow: vi.fn().mockResolvedValue(undefined), + }; + internalsOf(client).directAuthRecovery = false; + internalsOf(client).directAuthRecoveryActive = null; + internalsOf(client).status = "disconnected"; + + const transport = Object.create( + RemoteClientTransport.prototype, + ) as RemoteClientTransport; + const attachToSession = vi.fn().mockResolvedValue(undefined); + const pushAuthState = vi.fn().mockResolvedValue(undefined); + (transport as unknown as { attachToSession: unknown }).attachToSession = + attachToSession; + (transport as unknown as { pushAuthState: unknown }).pushAuthState = + pushAuthState; + internalsOf(client).baseTransport = transport; + + const connectSpy = vi.spyOn(client, "connect").mockResolvedValue(undefined); + + await client.resumeAfterOAuth("auth-code", { + remoteSessionId: "session-1", + }); + + expect(attachToSession).toHaveBeenCalledWith("session-1"); + expect(pushAuthState).toHaveBeenCalledTimes(1); + expect(connectSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clients/web/src/test/core/mcp/inspectorClient-direct-auth-recovery.test.ts b/clients/web/src/test/core/mcp/inspectorClient-direct-auth-recovery.test.ts new file mode 100644 index 00000000..95d36663 --- /dev/null +++ b/clients/web/src/test/core/mcp/inspectorClient-direct-auth-recovery.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { AuthChallengeError } from "@inspector/core/auth/challenge.js"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; + +describe("InspectorClient direct auth recovery retry bound", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("retries an operation once after satisfied recovery, then gives up", async () => { + const client = Object.create(InspectorClient.prototype) as InspectorClient; + const internals = client as unknown as { + directAuthRecovery: boolean; + directAuthRecoveryActive: boolean; + reconnectAfterAuthRecovery: ReturnType; + withDirectAuthRecovery: ( + operation: () => Promise, + context?: { method?: string; toolName?: string }, + attempt?: number, + ) => Promise; + }; + internals.directAuthRecovery = true; + internals.directAuthRecoveryActive = true; + internals.reconnectAfterAuthRecovery = vi.fn().mockResolvedValue(undefined); + + const challenge = { + reason: "insufficient_scope" as const, + requiredScopes: ["weather:read"], + }; + const operation = vi + .fn() + .mockRejectedValue(new AuthChallengeError(challenge, 403)); + + vi.spyOn(client, "handleAuthChallenge").mockResolvedValue({ + kind: "satisfied", + }); + vi.spyOn(client, "dispatchTypedEvent").mockImplementation(() => {}); + + await expect( + internals.withDirectAuthRecovery.call(client, operation, { + method: "tools/call", + toolName: "get_temp", + }), + ).rejects.toBeInstanceOf(AuthChallengeError); + + expect(operation).toHaveBeenCalledTimes(2); + expect(client.handleAuthChallenge).toHaveBeenCalledTimes(1); + expect(internals.reconnectAfterAuthRecovery).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clients/web/src/test/core/mcp/node/authChallengeFetch.test.ts b/clients/web/src/test/core/mcp/node/authChallengeFetch.test.ts new file mode 100644 index 00000000..f94b7241 --- /dev/null +++ b/clients/web/src/test/core/mcp/node/authChallengeFetch.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi } from "vitest"; +import { createAuthChallengeInterceptFetch } from "@inspector/core/mcp/node/authChallengeFetch.js"; +import { AuthChallengeError } from "@inspector/core/auth/challenge.js"; + +describe("createAuthChallengeInterceptFetch", () => { + it("passes through successful responses", async () => { + const baseFetch = vi.fn(async () => new Response("ok", { status: 200 })); + const fetchFn = createAuthChallengeInterceptFetch(baseFetch); + const res = await fetchFn("https://example.com/mcp"); + expect(res.status).toBe(200); + expect(await res.text()).toBe("ok"); + }); + + it("throws AuthChallengeError on 401", async () => { + const baseFetch = vi.fn( + async () => + new Response(null, { + status: 401, + headers: { + "WWW-Authenticate": 'Bearer error="invalid_token"', + }, + }), + ); + const fetchFn = createAuthChallengeInterceptFetch(baseFetch); + await expect(fetchFn("https://example.com/mcp")).rejects.toBeInstanceOf( + AuthChallengeError, + ); + }); + + it("throws AuthChallengeError on 403 insufficient_scope", async () => { + const baseFetch = vi.fn( + async () => + new Response(null, { + status: 403, + headers: { + "WWW-Authenticate": + 'Bearer error="insufficient_scope", scope="weather:read"', + }, + }), + ); + const fetchFn = createAuthChallengeInterceptFetch(baseFetch); + try { + await fetchFn("https://example.com/mcp"); + throw new Error("expected throw"); + } catch (err) { + expect(err).toBeInstanceOf(AuthChallengeError); + expect((err as AuthChallengeError).authChallenge.reason).toBe( + "insufficient_scope", + ); + } + }); + + it("cancels a present response body before throwing", async () => { + const response = new Response("challenge body", { + status: 401, + headers: { "WWW-Authenticate": 'Bearer error="invalid_token"' }, + }); + const cancelSpy = vi.spyOn(response.body!, "cancel"); + const baseFetch = vi.fn(async () => response); + const fetchFn = createAuthChallengeInterceptFetch(baseFetch); + + await expect(fetchFn("https://example.com/mcp")).rejects.toBeInstanceOf( + AuthChallengeError, + ); + expect(cancelSpy).toHaveBeenCalled(); + }); + + it("swallows a body.cancel() rejection and still throws the challenge", async () => { + const response = new Response("challenge body", { + status: 403, + headers: { + "WWW-Authenticate": + 'Bearer error="insufficient_scope", scope="weather:read"', + }, + }); + vi.spyOn(response.body!, "cancel").mockRejectedValue( + new Error("cancel failed"), + ); + const baseFetch = vi.fn(async () => response); + const fetchFn = createAuthChallengeInterceptFetch(baseFetch); + + await expect(fetchFn("https://example.com/mcp")).rejects.toBeInstanceOf( + AuthChallengeError, + ); + }); +}); diff --git a/clients/web/src/test/core/mcp/oauthManager.test.ts b/clients/web/src/test/core/mcp/oauthManager.test.ts index be436ac7..a49cb400 100644 --- a/clients/web/src/test/core/mcp/oauthManager.test.ts +++ b/clients/web/src/test/core/mcp/oauthManager.test.ts @@ -14,19 +14,16 @@ import { emaClientNotConfiguredMessage, } from "@inspector/core/auth/ema/clientConfigError.js"; import * as emaFlow from "@inspector/core/auth/ema/emaFlow.js"; -import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; +import { mcpAuth } from "@inspector/core/auth/mcpAuth.js"; -// Mock the SDK auth() entry point so quick-flow paths don't hit the network. -// Other named exports of the module are left intact for code that uses them. -vi.mock("@modelcontextprotocol/sdk/client/auth.js", async (importOriginal) => { +// Mock mcpAuth so OAuthManager tests do not hit the network. +vi.mock("@inspector/core/auth/mcpAuth.js", async (importOriginal) => { const actual = - await importOriginal< - typeof import("@modelcontextprotocol/sdk/client/auth.js") - >(); - return { ...actual, auth: vi.fn() }; + await importOriginal(); + return { ...actual, mcpAuth: vi.fn() }; }); -const mockedAuth = vi.mocked(auth); +const mockedMcpAuth = vi.mocked(mcpAuth); const SERVER_URL = "https://example.com/mcp"; @@ -101,7 +98,7 @@ function storageOf(params: OAuthManagerParams): MockStorage { describe("OAuthManager", () => { beforeEach(() => { - mockedAuth.mockReset(); + mockedMcpAuth.mockReset(); }); describe("setOAuthConfig", () => { @@ -248,6 +245,24 @@ describe("OAuthManager", () => { const manager = new OAuthManager(params); expect(await manager.getOAuthTokens()).toBeUndefined(); }); + + it("returns tokens from in-memory flow state without querying storage", async () => { + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); + const params = createMockParams(); + const tokens = { access_token: "cached", token_type: "Bearer" }; + storageOf(params).getTokens.mockResolvedValue(tokens); + storageOf(params).getClientInformation.mockResolvedValue({ + client_id: "cid", + }); + const manager = new OAuthManager(params); + await manager.completeOAuthFlow("code"); + storageOf(params).getTokens.mockClear(); + + const result = await manager.getOAuthTokens(); + + expect(result).toEqual(tokens); + expect(storageOf(params).getTokens).not.toHaveBeenCalled(); + }); }); describe("isOAuthAuthorized", () => { @@ -399,7 +414,7 @@ describe("OAuthManager", () => { const capturedUrl = new URL( "https://auth.example.com/authorize?state=abc", ); - mockedAuth.mockResolvedValue("REDIRECT"); + mockedMcpAuth.mockResolvedValue("REDIRECT"); const parseSpy = vi .spyOn(await import("@inspector/core/auth/utils.js"), "parseOAuthState") .mockReturnValue({ @@ -413,6 +428,7 @@ describe("OAuthManager", () => { const params = createMockParams({ onBeforeOAuthRedirect }); // A configured scope exercises the saveScope branch in createOAuthProvider. params.initialConfig.scope = "read write"; + storageOf(params).getScope.mockReturnValue(undefined); storageOf(params).getClientInformation.mockResolvedValue({ client_id: "cid", }); @@ -443,8 +459,40 @@ describe("OAuthManager", () => { captureSpy.mockRestore(); }); + it("preserves stored scope instead of resetting to config scope", async () => { + const capturedUrl = new URL( + "https://auth.example.com/authorize?state=abc", + ); + mockedMcpAuth.mockResolvedValue("REDIRECT"); + const params = createMockParams(); + params.initialConfig.scope = "mcp tools:read"; + storageOf(params).getScope.mockReturnValue("mcp tools:read weather:read"); + storageOf(params).getClientInformation.mockResolvedValue({ + client_id: "cid", + }); + const manager = new OAuthManager(params); + const captureSpy = vi + .spyOn( + (await import("@inspector/core/auth/providers.js")) + .BaseOAuthClientProvider.prototype, + "getCapturedAuthUrl", + ) + .mockReturnValue(capturedUrl); + + await manager.authenticate(); + + expect(storageOf(params).saveScope).not.toHaveBeenCalled(); + expect(mockedMcpAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + scope: "mcp tools:read weather:read", + }), + ); + captureSpy.mockRestore(); + }); + it("throws when auth() unexpectedly returns AUTHORIZED", async () => { - mockedAuth.mockResolvedValue("AUTHORIZED"); + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); const manager = new OAuthManager(createMockParams()); await expect(manager.authenticate()).rejects.toThrow( "Unexpected: auth() returned AUTHORIZED without authorization code", @@ -452,7 +500,7 @@ describe("OAuthManager", () => { }); it("throws when no authorization URL is captured", async () => { - mockedAuth.mockResolvedValue("REDIRECT"); + mockedMcpAuth.mockResolvedValue("REDIRECT"); const manager = new OAuthManager(createMockParams()); // Default provider captures nothing (auth() is mocked and never redirects). await expect(manager.authenticate()).rejects.toThrow( @@ -464,7 +512,7 @@ describe("OAuthManager", () => { const capturedUrl = new URL( "https://auth.example.com/authorize?state=zzz", ); - mockedAuth.mockResolvedValue("REDIRECT"); + mockedMcpAuth.mockResolvedValue("REDIRECT"); const parseSpy = vi .spyOn(await import("@inspector/core/auth/utils.js"), "parseOAuthState") .mockReturnValue(null); @@ -490,7 +538,7 @@ describe("OAuthManager", () => { describe("completeOAuthFlow (quick, standard)", () => { it("completes via the quick path and dispatches complete", async () => { const tokens = { access_token: "QT", token_type: "Bearer" }; - mockedAuth.mockResolvedValue("AUTHORIZED"); + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); const params = createMockParams(); storageOf(params).getTokens.mockResolvedValue(tokens); storageOf(params).getClientInformation.mockResolvedValue({ @@ -506,7 +554,7 @@ describe("OAuthManager", () => { it("throws and dispatches error when auth() is not AUTHORIZED", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - mockedAuth.mockResolvedValue("REDIRECT"); + mockedMcpAuth.mockResolvedValue("REDIRECT"); const params = createMockParams(); const manager = new OAuthManager(params); @@ -518,7 +566,7 @@ describe("OAuthManager", () => { }); it("throws when tokens cannot be retrieved after authorization", async () => { - mockedAuth.mockResolvedValue("AUTHORIZED"); + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); const params = createMockParams(); storageOf(params).getTokens.mockResolvedValue(undefined); const manager = new OAuthManager(params); @@ -528,6 +576,103 @@ describe("OAuthManager", () => { ); expect(params.dispatchOAuthError).toHaveBeenCalled(); }); + + it("clears pending step-up scope when completeOAuthFlow fails", async () => { + const capturedUrl = new URL( + "https://auth.example.com/authorize?state=step-up", + ); + mockedMcpAuth + .mockResolvedValueOnce("REDIRECT") + .mockResolvedValueOnce("REDIRECT") + .mockResolvedValueOnce("AUTHORIZED"); + const params = createMockParams(); + storageOf(params).getScope.mockReturnValue("mcp"); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "access", + refresh_token: "refresh", + token_type: "Bearer", + scope: "mcp", + }); + storageOf(params).getClientInformation.mockResolvedValue({ + client_id: "cid", + }); + const manager = new OAuthManager(params); + const captureSpy = vi + .spyOn( + (await import("@inspector/core/auth/providers.js")) + .BaseOAuthClientProvider.prototype, + "getCapturedAuthUrl", + ) + .mockReturnValue(capturedUrl); + + await manager.handleAuthChallenge({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }); + + await expect(manager.completeOAuthFlow("bad-code")).rejects.toThrow(); + + storageOf(params).getTokens.mockResolvedValue({ + access_token: "access", + token_type: "Bearer", + scope: "mcp", + }); + await manager.completeOAuthFlow("good-code"); + + expect(storageOf(params).saveScope).toHaveBeenLastCalledWith( + SERVER_URL, + "mcp", + ); + captureSpy.mockRestore(); + }); + + it("persists granted scope when AS down-scopes the token response", async () => { + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); + const params = createMockParams(); + storageOf(params).getScope.mockReturnValue("mcp"); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "access", + token_type: "Bearer", + scope: "mcp", + }); + storageOf(params).getClientInformation.mockResolvedValue({ + client_id: "cid", + }); + const manager = new OAuthManager(params); + ( + manager as unknown as { pendingAuthorizationScope: string | undefined } + ).pendingAuthorizationScope = "mcp weather:read"; + + await manager.completeOAuthFlow("code"); + + expect(storageOf(params).saveScope).toHaveBeenCalledWith( + SERVER_URL, + "mcp", + ); + }); + + it("persists requested scope when the token response omits scope", async () => { + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "access", + token_type: "Bearer", + }); + storageOf(params).getClientInformation.mockResolvedValue({ + client_id: "cid", + }); + const manager = new OAuthManager(params); + ( + manager as unknown as { pendingAuthorizationScope: string | undefined } + ).pendingAuthorizationScope = "mcp weather:read"; + + await manager.completeOAuthFlow("code"); + + expect(storageOf(params).saveScope).toHaveBeenCalledWith( + SERVER_URL, + "mcp weather:read", + ); + }); }); describe("completeOAuthFlow (EMA)", () => { @@ -678,6 +823,65 @@ describe("OAuthManager", () => { startSpy.mockRestore(); parseSpy.mockRestore(); }); + + it("skips onBeforeOAuthRedirect when none is configured", async () => { + const silentSpy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "no_idp_session" }); + const authUrl = new URL( + "https://idp.example.com/authorize?state=no-callback", + ); + const startSpy = vi + .spyOn(emaFlow, "startEmaIdpAuthorization") + .mockResolvedValue(authUrl); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const result = await manager.authenticate(); + + expect(result).toEqual(authUrl); + expect( + params.initialConfig.navigation!.navigateToAuthorization, + ).toHaveBeenCalledWith(authUrl); + + silentSpy.mockRestore(); + startSpy.mockRestore(); + }); + + it("skips onBeforeOAuthRedirect when the authorization state has no authId", async () => { + const silentSpy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "no_idp_session" }); + const authUrl = new URL( + "https://idp.example.com/authorize?state=no-authid", + ); + const startSpy = vi + .spyOn(emaFlow, "startEmaIdpAuthorization") + .mockResolvedValue(authUrl); + const parseSpy = vi + .spyOn(await import("@inspector/core/auth/utils.js"), "parseOAuthState") + .mockReturnValue(null); + const params = emaParams(); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + await manager.authenticate(); + + expect(params.onBeforeOAuthRedirect).not.toHaveBeenCalled(); + + silentSpy.mockRestore(); + startSpy.mockRestore(); + parseSpy.mockRestore(); + }); }); describe("refreshEnterpriseManagedTokens", () => { @@ -718,6 +922,620 @@ describe("OAuthManager", () => { }); }); + describe("checkAuthChallengeSatisfied", () => { + it("returns false when no tokens in storage", async () => { + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue(undefined); + const manager = new OAuthManager(params); + + expect( + await manager.checkAuthChallengeSatisfied({ + reason: "insufficient_scope", + requiredScopes: ["tools:write"], + }), + ).toBe(false); + }); + + it("returns true for token_expired when a usable access token exists", async () => { + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + }); + const manager = new OAuthManager(params); + + expect( + await manager.checkAuthChallengeSatisfied({ reason: "token_expired" }), + ).toBe(true); + }); + + it("returns false for invalid_token even when a locally valid token exists", async () => { + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + expires_in: 3600, + }); + const manager = new OAuthManager(params); + + expect( + await manager.checkAuthChallengeSatisfied({ reason: "invalid_token" }), + ).toBe(false); + }); + + it("returns false for unauthorized even when a locally valid token exists", async () => { + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + expires_in: 3600, + }); + const manager = new OAuthManager(params); + + expect( + await manager.checkAuthChallengeSatisfied({ reason: "unauthorized" }), + ).toBe(false); + }); + + it("returns true when stored scope covers step-up union", async () => { + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + scope: "mcp tools:read tools:write", + }); + storageOf(params).getScope.mockReturnValue("mcp tools:read tools:write"); + const manager = new OAuthManager(params); + + expect( + await manager.checkAuthChallengeSatisfied({ + reason: "insufficient_scope", + requiredScopes: ["tools:write"], + }), + ).toBe(true); + }); + + it("returns false when step-up union exceeds granted scope", async () => { + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + scope: "mcp tools:read", + }); + storageOf(params).getScope.mockReturnValue("mcp tools:read"); + const manager = new OAuthManager(params); + + expect( + await manager.checkAuthChallengeSatisfied({ + reason: "insufficient_scope", + requiredScopes: ["tools:write"], + }), + ).toBe(false); + }); + + it("ignores inflated stored scope when token scope is explicit", async () => { + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + scope: "mcp", + }); + storageOf(params).getScope.mockReturnValue("mcp weather:read"); + const manager = new OAuthManager(params); + + expect( + await manager.checkAuthChallengeSatisfied({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }), + ).toBe(false); + }); + + it("returns false for insufficient_scope with no scopes in the challenge", async () => { + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + }); + storageOf(params).getScope.mockReturnValue(undefined); + const manager = new OAuthManager(params); + + expect( + await manager.checkAuthChallengeSatisfied({ + reason: "insufficient_scope", + }), + ).toBe(false); + }); + + it("short-circuits handleAuthChallenge when scope already satisfied", async () => { + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + scope: "mcp tools:read tools:write", + }); + storageOf(params).getScope.mockReturnValue("mcp tools:read tools:write"); + const manager = new OAuthManager(params); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + requiredScopes: ["tools:write"], + }); + + expect(outcome).toEqual({ kind: "satisfied" }); + expect(mockedMcpAuth).not.toHaveBeenCalled(); + }); + + it("does not short-circuit token_expired at handleAuthChallenge entry", async () => { + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + }); + const manager = new OAuthManager(params); + + const outcome = await manager.handleAuthChallenge({ + reason: "token_expired", + }); + + expect(outcome).toEqual({ kind: "satisfied" }); + expect(mockedMcpAuth).toHaveBeenCalled(); + }); + }); + + describe("handleAuthChallenge", () => { + it("returns satisfied when silent refresh succeeds", async () => { + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); + const manager = new OAuthManager(createMockParams()); + + const outcome = await manager.handleAuthChallenge({ + reason: "token_expired", + }); + + expect(outcome).toEqual({ kind: "satisfied" }); + }); + + it("returns interactive when refresh requires redirect without navigating", async () => { + const capturedUrl = new URL( + "https://auth.example.com/authorize?state=abc", + ); + mockedMcpAuth.mockResolvedValue("REDIRECT"); + const params = createMockParams(); + const manager = new OAuthManager(params); + const captureSpy = vi + .spyOn( + (await import("@inspector/core/auth/providers.js")) + .BaseOAuthClientProvider.prototype, + "getCapturedAuthUrl", + ) + .mockReturnValue(capturedUrl); + + const outcome = await manager.handleAuthChallenge({ + reason: "token_expired", + }); + + expect(outcome).toEqual( + expect.objectContaining({ + kind: "interactive", + authorizationUrl: capturedUrl, + }), + ); + expect( + params.initialConfig.navigation!.navigateToAuthorization, + ).not.toHaveBeenCalled(); + expect(manager.getOAuthFlowStep()).toBe("authorization_code"); + captureSpy.mockRestore(); + }); + + it("uses catalog scope for reauth interactive flows", async () => { + const capturedUrl = new URL( + "https://auth.example.com/authorize?state=reauth", + ); + mockedMcpAuth.mockResolvedValue("REDIRECT"); + const params = createMockParams(); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ scope: "catalog:scope" }); + storageOf(params).getScope.mockReturnValue("stored union scope"); + const captureSpy = vi + .spyOn( + (await import("@inspector/core/auth/providers.js")) + .BaseOAuthClientProvider.prototype, + "getCapturedAuthUrl", + ) + .mockReturnValue(capturedUrl); + + await manager.handleAuthChallenge({ reason: "token_expired" }); + + expect(mockedMcpAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ scope: "catalog:scope" }), + ); + captureSpy.mockRestore(); + }); + + it("returns failed with step-up message when silent refresh grants insufficient scope", async () => { + mockedMcpAuth + .mockResolvedValueOnce("AUTHORIZED") + .mockResolvedValueOnce("AUTHORIZED"); + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "access", + token_type: "Bearer", + scope: "mcp tools:read", + }); + storageOf(params).getScope.mockReturnValue("mcp tools:read"); + const manager = new OAuthManager(params); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + context: { toolName: "get_weather" }, + }); + + expect(outcome.kind).toBe("failed"); + if (outcome.kind === "failed") { + expect(outcome.error.message).toMatch(/get_weather/); + } + }); + + it("returns failed when no authorization URL is captured", async () => { + mockedMcpAuth.mockResolvedValue("REDIRECT"); + const manager = new OAuthManager(createMockParams()); + + const outcome = await manager.handleAuthChallenge({ + reason: "unauthorized", + }); + + expect(outcome.kind).toBe("failed"); + if (outcome.kind === "failed") { + expect(outcome.error.message).toMatch( + /Failed to capture authorization URL/, + ); + } + }); + + it("returns interactive for insufficient_scope without navigating", async () => { + const capturedUrl = new URL( + "https://auth.example.com/authorize?state=step-up", + ); + mockedMcpAuth.mockResolvedValue("REDIRECT"); + const params = createMockParams(); + storageOf(params).getScope.mockReturnValue("mcp tools:read"); + const manager = new OAuthManager(params); + const captureSpy = vi + .spyOn( + (await import("@inspector/core/auth/providers.js")) + .BaseOAuthClientProvider.prototype, + "getCapturedAuthUrl", + ) + .mockReturnValue(capturedUrl); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }); + + expect(outcome).toEqual( + expect.objectContaining({ + kind: "interactive", + authorizationUrl: capturedUrl, + }), + ); + expect( + params.initialConfig.navigation!.navigateToAuthorization, + ).not.toHaveBeenCalled(); + captureSpy.mockRestore(); + }); + + it("unions scopes and starts interactive step-up for insufficient_scope", async () => { + const capturedUrl = new URL( + "https://auth.example.com/authorize?state=step-up", + ); + mockedMcpAuth.mockResolvedValue("REDIRECT"); + const params = createMockParams(); + storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "access", + refresh_token: "refresh", + token_type: "Bearer", + scope: "mcp tools:read", + }); + const manager = new OAuthManager(params); + const captureSpy = vi + .spyOn( + (await import("@inspector/core/auth/providers.js")) + .BaseOAuthClientProvider.prototype, + "getCapturedAuthUrl", + ) + .mockReturnValue(capturedUrl); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }); + + expect(outcome).toEqual( + expect.objectContaining({ + kind: "interactive", + authorizationUrl: capturedUrl, + }), + ); + expect(storageOf(params).saveScope).not.toHaveBeenCalled(); + expect(mockedMcpAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + scope: "mcp tools:read weather:read", + forceReauthorization: true, + }), + ); + captureSpy.mockRestore(); + }); + + it("returns satisfied for EMA silent refresh", async () => { + const refreshSpy = vi + .spyOn(emaFlow, "refreshEmaResourceTokens") + .mockResolvedValue(undefined); + const authUrl = new URL("https://idp.example.com/authorize?state=ema"); + const startSpy = vi + .spyOn(emaFlow, "startEmaIdpAuthorization") + .mockResolvedValue(authUrl); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const outcome = await manager.handleAuthChallenge({ + reason: "token_expired", + }); + + expect(outcome).toEqual( + expect.objectContaining({ + kind: "interactive", + authorizationUrl: authUrl, + }), + ); + expect( + params.initialConfig.navigation!.navigateToAuthorization, + ).not.toHaveBeenCalled(); + refreshSpy.mockRestore(); + startSpy.mockRestore(); + }); + + it("returns step_up_confirm for EMA insufficient_scope until user confirms", async () => { + const silentSpy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "success" }); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + storageOf(params).getScope.mockReturnValue("mcp"); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + scope: "mcp", + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }); + + expect(outcome.kind).toBe("step_up_confirm"); + if (outcome.kind === "step_up_confirm") { + expect(outcome.challenge.authorizationScopes).toEqual([ + "mcp", + "weather:read", + ]); + } + expect(silentSpy).not.toHaveBeenCalled(); + silentSpy.mockRestore(); + }); + + it("step_up_confirm lists only scopes not already granted when AS sends full tool requirements", async () => { + const silentSpy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "success" }); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + scope: "mcp tools:read", + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + requiredScopes: ["tools:read", "env:read"], + }); + + expect(outcome.kind).toBe("step_up_confirm"); + if (outcome.kind === "step_up_confirm") { + expect(outcome.challenge.requiredScopes).toEqual(["env:read"]); + expect(outcome.challenge.authorizationScopes).toEqual([ + "mcp", + "tools:read", + "env:read", + ]); + } + silentSpy.mockRestore(); + }); + + it("returns satisfied for EMA insufficient_scope after user confirms", async () => { + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + let storedScope = "mcp"; + storageOf(params).getScope.mockImplementation(() => storedScope); + storageOf(params).saveScope.mockImplementation(async (_url, scope) => { + storedScope = scope; + }); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + scope: "mcp", + }); + const silentSpy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockImplementation(async () => { + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + scope: "mcp weather:read", + }); + return { status: "success" }; + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const outcome = await manager.handleAuthChallenge( + { + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }, + { confirmedStepUp: true }, + ); + + expect(outcome).toEqual({ kind: "satisfied" }); + expect(storageOf(params).saveScope).toHaveBeenCalledWith( + SERVER_URL, + "mcp weather:read", + ); + expect(silentSpy).toHaveBeenCalled(); + silentSpy.mockRestore(); + }); + + it("does not persist union scope or return satisfied when silent EMA mint is down-scoped", async () => { + const silentSpy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "success" }); + const authUrl = new URL("https://idp.example.com/authorize?state=ema"); + const startSpy = vi + .spyOn(emaFlow, "startEmaIdpAuthorization") + .mockResolvedValue(authUrl); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + storageOf(params).getScope.mockReturnValue("mcp"); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + scope: "mcp", + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const outcome = await manager.handleAuthChallenge( + { + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }, + { confirmedStepUp: true }, + ); + + expect(outcome).toEqual( + expect.objectContaining({ + kind: "interactive", + authorizationUrl: authUrl, + }), + ); + expect(storageOf(params).saveScope).not.toHaveBeenCalled(); + silentSpy.mockRestore(); + startSpy.mockRestore(); + }); + + it("completeOAuthFlow mints EMA tokens with pending step-up union scope", async () => { + const silentSpy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "no_idp_session" }); + const authUrl = new URL("https://idp.example.com/authorize?state=ema"); + const startSpy = vi + .spyOn(emaFlow, "startEmaIdpAuthorization") + .mockResolvedValue(authUrl); + const mintSpy = vi + .spyOn(emaFlow, "completeEmaIdpAuthorizationAndMint") + .mockResolvedValue({ access_token: "tok", token_type: "Bearer" }); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "old", + token_type: "Bearer", + scope: "mcp tools:read", + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true, scope: "mcp" }); + + const outcome = await manager.handleAuthChallenge( + { + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }, + { confirmedStepUp: true }, + ); + expect(outcome.kind).toBe("interactive"); + + await manager.completeOAuthFlow("auth-code"); + + expect(mintSpy).toHaveBeenCalledWith( + expect.objectContaining({ scope: "mcp tools:read weather:read" }), + "auth-code", + ); + expect(storageOf(params).saveScope).toHaveBeenCalledWith( + SERVER_URL, + "mcp tools:read weather:read", + ); + + silentSpy.mockRestore(); + startSpy.mockRestore(); + mintSpy.mockRestore(); + }); + }); + describe("createOAuthProviderForTransport", () => { it("returns a plain provider for standard OAuth", async () => { const manager = new OAuthManager(createMockParams()); @@ -765,4 +1583,643 @@ describe("OAuthManager", () => { expect(state?.serverUrl).toBe(SERVER_URL); }); }); + + describe("getOAuthState (storage not configured)", () => { + it("returns undefined when storage is not configured", async () => { + const params = createMockParams({ + initialConfig: { + redirectUrlProvider: { + getRedirectUrl: vi + .fn() + .mockReturnValue("http://localhost/callback"), + }, + navigation: { navigateToAuthorization: vi.fn() }, + } as OAuthManagerConfig, + }); + const manager = new OAuthManager(params); + await expect(manager.getOAuthState()).resolves.toBeUndefined(); + }); + }); + + describe("checkAuthChallengeSatisfied (storage not configured)", () => { + it("returns false when storage is not configured", async () => { + const params = createMockParams({ + initialConfig: { + redirectUrlProvider: { + getRedirectUrl: vi + .fn() + .mockReturnValue("http://localhost/callback"), + }, + navigation: { navigateToAuthorization: vi.fn() }, + } as OAuthManagerConfig, + }); + const manager = new OAuthManager(params); + expect( + await manager.checkAuthChallengeSatisfied({ + reason: "insufficient_scope", + }), + ).toBe(false); + }); + }); + + describe("handleAuthChallenge (additional branch coverage)", () => { + it("resolves via the second satisfaction check inside the mutex", async () => { + const params = createMockParams(); + const insufficientTokens = { + access_token: "a1", + token_type: "Bearer", + scope: "mcp tools:read", + }; + const sufficientTokens = { + access_token: "a2", + token_type: "Bearer", + scope: "mcp tools:read weather:read", + }; + storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params) + .getTokens.mockResolvedValueOnce(insufficientTokens) + .mockResolvedValue(sufficientTokens); + const manager = new OAuthManager(params); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }); + + expect(outcome).toEqual({ kind: "satisfied" }); + expect(mockedMcpAuth).not.toHaveBeenCalled(); + }); + + it("persists broadened scope when silent refresh already satisfies the step-up scope", async () => { + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); + const params = createMockParams(); + const insufficientTokens = { + access_token: "a1", + refresh_token: "r1", + token_type: "Bearer", + scope: "mcp tools:read", + }; + const sufficientTokens = { + access_token: "a2", + refresh_token: "r2", + token_type: "Bearer", + scope: "mcp tools:read weather:read", + }; + storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params) + .getTokens.mockResolvedValueOnce(insufficientTokens) + .mockResolvedValueOnce(insufficientTokens) + .mockResolvedValueOnce(insufficientTokens) + .mockResolvedValue(sufficientTokens); + const manager = new OAuthManager(params); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }); + + expect(outcome).toEqual({ kind: "satisfied" }); + expect(storageOf(params).saveScope).toHaveBeenCalledWith( + SERVER_URL, + "mcp tools:read weather:read", + ); + }); + + it("returns satisfied without persisting scope when the fresh grant has no scope to record", async () => { + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); + const params = createMockParams(); + // scope: "" (not omitted) avoids enrichChallengeWithAuthorizationScopes' + // internal extra getTokens() re-fetch, which only triggers when the + // passed grantedTokenScope is `undefined`. + const noScopeTokens = { + access_token: "a", + token_type: "Bearer", + scope: "", + }; + const midScopeTokens = { + access_token: "b", + token_type: "Bearer", + scope: "newscope", + }; + const finalNoScopeTokens = { + access_token: "c", + token_type: "Bearer", + scope: "", + }; + storageOf(params) + .getTokens.mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(noScopeTokens) + .mockResolvedValueOnce(midScopeTokens) + .mockResolvedValueOnce(finalNoScopeTokens); + const manager = new OAuthManager(params); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + }); + + expect(outcome).toEqual({ kind: "satisfied" }); + expect(storageOf(params).saveScope).not.toHaveBeenCalled(); + }); + + it("forces reauthorization and persists the granted scope when the retry succeeds", async () => { + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); + const params = createMockParams(); + const noScopeTokens = { + access_token: "a", + token_type: "Bearer", + scope: "", + }; + const midScopeTokens = { + access_token: "b", + token_type: "Bearer", + scope: "newscope", + }; + const grantedTokens = { + access_token: "d", + token_type: "Bearer", + scope: "granted:scope", + }; + storageOf(params) + .getTokens.mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(noScopeTokens) + .mockResolvedValueOnce(noScopeTokens) + .mockResolvedValueOnce(midScopeTokens) + .mockResolvedValueOnce(grantedTokens); + const manager = new OAuthManager(params); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + }); + + expect(outcome).toEqual({ kind: "satisfied" }); + expect(mockedMcpAuth).toHaveBeenCalledTimes(2); + expect(mockedMcpAuth).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.objectContaining({ forceReauthorization: true }), + ); + expect(storageOf(params).saveScope).toHaveBeenCalledWith( + SERVER_URL, + "granted:scope", + ); + }); + + it("forces reauthorization without persisting scope when the retry grants no explicit scope", async () => { + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); + const params = createMockParams(); + const noScopeTokens = { + access_token: "a", + token_type: "Bearer", + scope: "", + }; + const midScopeTokens = { + access_token: "b", + token_type: "Bearer", + scope: "newscope", + }; + storageOf(params) + .getTokens.mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(noScopeTokens) + .mockResolvedValueOnce(noScopeTokens) + .mockResolvedValueOnce(midScopeTokens) + .mockResolvedValueOnce(noScopeTokens); + const manager = new OAuthManager(params); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + }); + + expect(outcome).toEqual({ kind: "satisfied" }); + expect(storageOf(params).saveScope).not.toHaveBeenCalled(); + }); + + it("returns failed when the forced reauthorization retry does not complete", async () => { + mockedMcpAuth + .mockResolvedValueOnce("AUTHORIZED") + .mockResolvedValueOnce("REDIRECT"); + const params = createMockParams(); + storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "access", + token_type: "Bearer", + scope: "mcp tools:read", + }); + const manager = new OAuthManager(params); + + const outcome = await manager.handleAuthChallenge({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }); + + expect(outcome.kind).toBe("failed"); + if (outcome.kind === "failed") { + expect(outcome.error.message).toMatch(/weather:read/); + } + }); + + it("falls back to challenge requiredScopes when no catalog scope is configured", async () => { + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); + const params = createMockParams(); + const manager = new OAuthManager(params); + + const outcome = await manager.handleAuthChallenge({ + reason: "unauthorized", + requiredScopes: ["fallback:scope"], + }); + + expect(outcome).toEqual({ kind: "satisfied" }); + expect(mockedMcpAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ scope: "fallback:scope" }), + ); + }); + }); + + describe("completeOAuthFlow (non-Error rejection)", () => { + it("wraps non-Error throw values in the dispatched error", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + mockedMcpAuth.mockRejectedValue("plain-string-failure"); + const params = createMockParams(); + const manager = new OAuthManager(params); + + await expect(manager.completeOAuthFlow("code")).rejects.toBe( + "plain-string-failure", + ); + expect(params.dispatchOAuthError).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.any(Error) }), + ); + const dispatchedError = ( + params.dispatchOAuthError as ReturnType + ).mock.calls[0][0].error as Error; + expect(dispatchedError.message).toBe("plain-string-failure"); + errorSpy.mockRestore(); + }); + }); + + describe("handleEnterpriseManagedAuthChallenge (additional branch coverage)", () => { + it("returns failed when EMA silent re-mint fails during confirmed step-up", async () => { + const mintError = new Error("mint failed during step-up"); + const silentSpy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "mint_failed", error: mintError }); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const outcome = await manager.handleAuthChallenge( + { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, + { confirmedStepUp: true }, + ); + + expect(outcome).toEqual({ kind: "failed", error: mintError }); + silentSpy.mockRestore(); + }); + + it("returns satisfied for EMA token_expired when refreshEmaResourceTokens succeeds, using the configured fallback scope", async () => { + const refreshSpy = vi + .spyOn(emaFlow, "refreshEmaResourceTokens") + .mockResolvedValue({ access_token: "R", token_type: "Bearer" }); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ + enterpriseManaged: true, + scope: "fallback:scope", + }); + + const outcome = await manager.handleAuthChallenge({ + reason: "token_expired", + }); + + expect(outcome).toEqual({ kind: "satisfied" }); + expect(refreshSpy).toHaveBeenCalledWith( + expect.objectContaining({ scope: "fallback:scope" }), + ); + refreshSpy.mockRestore(); + }); + + it("returns failed when starting the EMA IdP authorization throws an Error", async () => { + const refreshSpy = vi + .spyOn(emaFlow, "refreshEmaResourceTokens") + .mockResolvedValue(undefined); + const startSpy = vi + .spyOn(emaFlow, "startEmaIdpAuthorization") + .mockRejectedValue(new Error("idp unreachable")); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const outcome = await manager.handleAuthChallenge({ + reason: "token_expired", + }); + + expect(outcome.kind).toBe("failed"); + if (outcome.kind === "failed") { + expect(outcome.error.message).toBe("idp unreachable"); + } + refreshSpy.mockRestore(); + startSpy.mockRestore(); + }); + + it("wraps non-Error throw values when starting EMA IdP authorization fails", async () => { + const refreshSpy = vi + .spyOn(emaFlow, "refreshEmaResourceTokens") + .mockResolvedValue(undefined); + const startSpy = vi + .spyOn(emaFlow, "startEmaIdpAuthorization") + .mockRejectedValue("idp offline"); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const outcome = await manager.handleAuthChallenge({ + reason: "token_expired", + }); + + expect(outcome.kind).toBe("failed"); + if (outcome.kind === "failed") { + expect(outcome.error.message).toBe("idp offline"); + } + refreshSpy.mockRestore(); + startSpy.mockRestore(); + }); + + it("returns interactive for EMA insufficient_scope with no prior scope and no configured fallback", async () => { + const silentSpy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "no_idp_session" }); + const authUrl = new URL( + "https://idp.example.com/authorize?state=ema-empty", + ); + const startSpy = vi + .spyOn(emaFlow, "startEmaIdpAuthorization") + .mockResolvedValue(authUrl); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const outcome = await manager.handleAuthChallenge( + { reason: "insufficient_scope" }, + { confirmedStepUp: true }, + ); + + expect(outcome).toEqual( + expect.objectContaining({ + kind: "interactive", + authorizationUrl: authUrl, + }), + ); + silentSpy.mockRestore(); + startSpy.mockRestore(); + }); + }); + + describe("beginInteractiveAuthorization", () => { + it("records flow state, navigates, and dispatches when state carries an authId", async () => { + const authorizationUrl = new URL( + "https://auth.example.com/authorize?state=begin-1", + ); + const onBeforeOAuthRedirect = vi.fn().mockResolvedValue(undefined); + const parseSpy = vi + .spyOn(await import("@inspector/core/auth/utils.js"), "parseOAuthState") + .mockReturnValue({ + execution: "quick", + authId: "begin-auth-id", + } as ReturnType< + typeof import("@inspector/core/auth/utils.js").parseOAuthState + >); + const params = createMockParams({ onBeforeOAuthRedirect }); + storageOf(params).getClientInformation.mockResolvedValue({ + client_id: "cid", + }); + const manager = new OAuthManager(params); + + await manager.beginInteractiveAuthorization(authorizationUrl); + + expect(onBeforeOAuthRedirect).toHaveBeenCalledWith("begin-auth-id"); + expect( + params.initialConfig.navigation!.navigateToAuthorization, + ).toHaveBeenCalledWith(authorizationUrl); + expect(manager.getOAuthFlowStep()).toBe("authorization_code"); + expect(manager.getOAuthFlowState()?.oauthClientInfo).toEqual({ + client_id: "cid", + }); + expect(params.dispatchOAuthAuthorizationRequired).toHaveBeenCalledWith({ + url: authorizationUrl, + }); + + parseSpy.mockRestore(); + }); + + it("skips onBeforeOAuthRedirect when there is no state param", async () => { + const authorizationUrl = new URL("https://auth.example.com/authorize"); + const onBeforeOAuthRedirect = vi.fn(); + const params = createMockParams({ onBeforeOAuthRedirect }); + const manager = new OAuthManager(params); + + await manager.beginInteractiveAuthorization(authorizationUrl); + + expect(onBeforeOAuthRedirect).not.toHaveBeenCalled(); + expect( + params.initialConfig.navigation!.navigateToAuthorization, + ).toHaveBeenCalledWith(authorizationUrl); + expect(params.dispatchOAuthAuthorizationRequired).toHaveBeenCalledWith({ + url: authorizationUrl, + }); + }); + + it("skips onBeforeOAuthRedirect when state param has no authId", async () => { + const authorizationUrl = new URL( + "https://auth.example.com/authorize?state=zzz", + ); + const parseSpy = vi + .spyOn(await import("@inspector/core/auth/utils.js"), "parseOAuthState") + .mockReturnValue(null); + const onBeforeOAuthRedirect = vi.fn(); + const params = createMockParams({ onBeforeOAuthRedirect }); + const manager = new OAuthManager(params); + + await manager.beginInteractiveAuthorization(authorizationUrl); + + expect(onBeforeOAuthRedirect).not.toHaveBeenCalled(); + + parseSpy.mockRestore(); + }); + + it("throws when navigation is not configured", async () => { + const params = createMockParams({ + initialConfig: { + storage: createMockParams().initialConfig.storage, + redirectUrlProvider: { + getRedirectUrl: vi + .fn() + .mockReturnValue("http://localhost/callback"), + }, + } as OAuthManagerConfig, + }); + const manager = new OAuthManager(params); + + await expect( + manager.beginInteractiveAuthorization( + new URL("https://auth.example.com/authorize"), + ), + ).rejects.toThrow("OAuth navigation is required."); + }); + }); + + describe("createOAuthProvider (clientId not configured)", () => { + it("skips savePreregisteredClientInformation when clientId is not configured", async () => { + mockedMcpAuth.mockResolvedValue("REDIRECT"); + const capturedUrl = new URL( + "https://auth.example.com/authorize?state=no-client-id", + ); + const params = createMockParams(); + params.initialConfig.clientId = undefined; + const manager = new OAuthManager(params); + const captureSpy = vi + .spyOn( + (await import("@inspector/core/auth/providers.js")) + .BaseOAuthClientProvider.prototype, + "getCapturedAuthUrl", + ) + .mockReturnValue(capturedUrl); + + await manager.authenticate(); + + expect( + storageOf(params).savePreregisteredClientInformation, + ).not.toHaveBeenCalled(); + captureSpy.mockRestore(); + }); + }); + + describe("completeOAuthFlow (oauthClientInfo null fallback)", () => { + it("stores null clientInfo when none is available and no flow state pre-exists", async () => { + mockedMcpAuth.mockResolvedValue("AUTHORIZED"); + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + }); + const manager = new OAuthManager(params); + + await manager.completeOAuthFlow("code"); + + expect(manager.getOAuthFlowState()?.oauthClientInfo).toBeNull(); + }); + + it("stores null clientInfo when none is available and flow state already exists", async () => { + const capturedUrl = new URL( + "https://auth.example.com/authorize?state=existing-flow", + ); + mockedMcpAuth + .mockResolvedValueOnce("REDIRECT") + .mockResolvedValueOnce("AUTHORIZED"); + const params = createMockParams(); + storageOf(params).getTokens.mockResolvedValue({ + access_token: "access", + token_type: "Bearer", + scope: "mcp", + }); + const manager = new OAuthManager(params); + const captureSpy = vi + .spyOn( + (await import("@inspector/core/auth/providers.js")) + .BaseOAuthClientProvider.prototype, + "getCapturedAuthUrl", + ) + .mockReturnValue(capturedUrl); + + await manager.handleAuthChallenge({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }); + expect(manager.getOAuthFlowState()).toBeDefined(); + + await manager.completeOAuthFlow("code"); + + expect(manager.getOAuthFlowState()?.oauthClientInfo).toBeNull(); + captureSpy.mockRestore(); + }); + }); + + describe("handleEnterpriseManagedAuthChallenge (scopeToPersist false arm)", () => { + it("returns satisfied without persisting scope when the EMA mint has no scope to record", async () => { + const silentSpy = vi + .spyOn(emaFlow, "trySilentEmaAuth") + .mockResolvedValue({ status: "success" }); + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + storageOf(params) + .getTokens.mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ + access_token: "g", + token_type: "Bearer", + scope: "granted", + }) + .mockResolvedValueOnce({ access_token: "g2", token_type: "Bearer" }); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const outcome = await manager.handleAuthChallenge( + { reason: "insufficient_scope" }, + { confirmedStepUp: true }, + ); + + expect(outcome).toEqual({ kind: "satisfied" }); + expect(storageOf(params).saveScope).not.toHaveBeenCalled(); + silentSpy.mockRestore(); + }); + }); }); diff --git a/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts b/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts index cb43f7ed..64a1328d 100644 --- a/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts +++ b/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts @@ -41,6 +41,28 @@ function sseStreamResponse(): Response { }); } +function createPushableSseStream() { + const encoder = new TextEncoder(); + let controller: ReadableStreamDefaultController | null = null; + const stream = new ReadableStream({ + start(c) { + controller = c; + c.enqueue(encoder.encode(": keepalive\n\n")); + }, + }); + const pushMessage = (message: unknown) => { + const payload = JSON.stringify({ type: "message", data: message }); + controller?.enqueue(encoder.encode(`data: ${payload}\n\n`)); + }; + return { + response: new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + pushMessage, + }; +} + describe("RemoteClientTransport", () => { it("send() throws when called before start()", async () => { const transport = new RemoteClientTransport( @@ -384,4 +406,113 @@ describe("RemoteClientTransport", () => { const parsed = JSON.parse(seenBodies[0]!) as Record; expect("settings" in parsed).toBe(false); }); + + it("retries send once after satisfied auth challenge and pushAuthState", async () => { + let sessionCounter = 0; + let sendAttempts = 0; + let authStateUpdates = 0; + let sse = createPushableSseStream(); + const fetchFn = vi + .fn() + .mockImplementation(async (input, init) => { + const url = String(input); + if (url.endsWith("/api/mcp/connect") && init?.method === "POST") { + sessionCounter += 1; + return new Response( + JSON.stringify({ sessionId: `session-${sessionCounter}` }), + { status: 200 }, + ); + } + if (url.includes("/api/mcp/events")) { + sse = createPushableSseStream(); + return sse.response; + } + if (url.endsWith("/api/mcp/auth-state") && init?.method === "POST") { + authStateUpdates += 1; + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + if (url.endsWith("/api/mcp/send") && init?.method === "POST") { + sendAttempts += 1; + const body = JSON.parse(String(init.body)) as { + message: { id?: string | number }; + }; + if (sendAttempts === 1) { + return new Response( + JSON.stringify({ + ok: false, + kind: "auth_challenge", + authChallenge: { reason: "token_expired" }, + }), + { status: 200 }, + ); + } + sse.pushMessage({ + jsonrpc: "2.0", + id: body.message.id, + result: { tools: [] }, + }); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }); + + const authProvider = { + tokens: vi.fn().mockResolvedValue({ + access_token: "refreshed", + token_type: "Bearer", + }), + }; + + const transport = new RemoteClientTransport( + { + baseUrl, + fetchFn: fetchFn as unknown as typeof fetch, + sseResponseTimeoutMs: 2000, + authProvider: authProvider as never, + }, + config, + ); + transport.setAuthRecovery({ + handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "satisfied" }), + pushAuthState: () => transport.pushAuthState(), + }); + + await transport.start(); + await transport.send({ jsonrpc: "2.0", id: 1, method: "tools/list" }); + + expect(sendAttempts).toBe(2); + expect(sessionCounter).toBe(1); + expect(authStateUpdates).toBe(1); + await transport.close(); + }); + + it("forwards ambient auth_challenge SSE events to onAuthChallenge", async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ sessionId: "abc" }), { status: 200 }), + ) + .mockResolvedValueOnce( + sseResponse( + 'data: {"type":"auth_challenge","data":{"reason":"token_expired"}}\n\n', + ), + ); + const onAuthChallenge = vi.fn(); + const transport = new RemoteClientTransport( + { + baseUrl, + fetchFn: fetchFn as unknown as typeof fetch, + onAuthChallenge, + }, + config, + ); + await transport.start(); + await vi.waitFor(() => expect(onAuthChallenge).toHaveBeenCalled(), { + timeout: 1000, + interval: 10, + }); + expect(onAuthChallenge.mock.calls[0]?.[0]).toEqual({ + reason: "token_expired", + }); + }); }); diff --git a/clients/web/src/test/core/mcp/test-server-scope.test.ts b/clients/web/src/test/core/mcp/test-server-scope.test.ts new file mode 100644 index 00000000..903ab5f2 --- /dev/null +++ b/clients/web/src/test/core/mcp/test-server-scope.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import type { Request, Response, NextFunction } from "express"; +import { + buildScopeRequirementRegistry, + clearOAuthTestData, + createScopeCheckMiddleware, + mintTestAccessToken, + scopeRequirementRegistryHasEntries, +} from "@modelcontextprotocol/inspector-test-server"; +import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server"; + +function createMinimalConfig( + overrides: Partial = {}, +): ServerConfig { + return { + serverInfo: { name: "scope-test", version: "1.0.0" }, + tools: [ + { + name: "get_temp", + description: "mock", + requiredScopes: ["weather:read"], + handler: async () => ({ + content: [{ type: "text" as const, text: "ok" }], + }), + }, + ], + ...overrides, + }; +} + +function invokeScopeMiddleware( + registry: ReturnType, + body: unknown, + token: string, + oauthTokenScopes?: string[], +): { + status: number; + headers: Record; + body?: unknown; + next: boolean; +} { + const middleware = createScopeCheckMiddleware(registry); + const headers: Record = {}; + let status = 200; + let responseBody: unknown; + let nextCalled = false; + + const req = { + body, + oauthToken: token, + ...(oauthTokenScopes !== undefined ? { oauthTokenScopes } : {}), + } as Request & { oauthToken: string; oauthTokenScopes?: string[] }; + + const res = { + status(code: number) { + status = code; + return this; + }, + setHeader(name: string, value: string) { + headers[name.toLowerCase()] = value; + }, + json(payload: unknown) { + responseBody = payload; + }, + } as unknown as Response; + + middleware(req, res, (() => { + nextCalled = true; + }) as NextFunction); + + return { status, headers, body: responseBody, next: nextCalled }; +} + +describe("test server scope requirements", () => { + beforeEach(() => { + clearOAuthTestData(); + }); + + it("builds registry from capability requiredScopes", () => { + const registry = buildScopeRequirementRegistry(createMinimalConfig()); + expect(scopeRequirementRegistryHasEntries(registry)).toBe(true); + expect(registry.tools.get("get_temp")).toEqual(["weather:read"]); + }); + + it("returns 403 insufficient_scope when token lacks required scope", () => { + const token = mintTestAccessToken("mcp tools:read"); + const registry = buildScopeRequirementRegistry(createMinimalConfig()); + const result = invokeScopeMiddleware( + registry, + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "get_temp", arguments: { city: "NYC", units: "C" } }, + }, + token, + ); + + expect(result.next).toBe(false); + expect(result.status).toBe(403); + expect(result.headers["www-authenticate"]).toContain("insufficient_scope"); + expect(result.headers["www-authenticate"]).toContain("weather:read"); + expect(result.headers["www-authenticate"]).not.toContain("tools:read"); + }); + + it("allows request when token includes required scopes", () => { + const token = mintTestAccessToken("mcp tools:read weather:read"); + const registry = buildScopeRequirementRegistry(createMinimalConfig()); + const result = invokeScopeMiddleware( + registry, + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "get_temp", arguments: { city: "NYC", units: "C" } }, + }, + token, + ); + + expect(result.next).toBe(true); + expect(result.status).toBe(200); + }); + + it("enforces requiredScopes on resource templates for resources/read", () => { + const registry = buildScopeRequirementRegistry({ + ...createMinimalConfig({ tools: [] }), + resourceTemplates: [ + { + name: "files", + uriTemplate: "file:///{path}", + requiredScopes: ["files:read"], + handler: async () => ({ + contents: [{ uri: "file:///tmp/x", text: "ok" }], + }), + }, + ], + }); + expect(registry.resourceTemplates.get("file:///{path}")).toEqual([ + "files:read", + ]); + + const denied = invokeScopeMiddleware( + registry, + { + jsonrpc: "2.0", + id: 2, + method: "resources/read", + params: { uri: "file:///tmp/example.txt" }, + }, + mintTestAccessToken("mcp"), + ); + expect(denied.next).toBe(false); + expect(denied.status).toBe(403); + + const allowed = invokeScopeMiddleware( + registry, + { + jsonrpc: "2.0", + id: 3, + method: "resources/read", + params: { uri: "file:///tmp/example.txt" }, + }, + mintTestAccessToken("mcp files:read"), + ); + expect(allowed.next).toBe(true); + }); + + it("uses oauthTokenScopes attached by bearer middleware (external JWT path)", () => { + const registry = buildScopeRequirementRegistry({ + ...createMinimalConfig(), + tools: [ + { + name: "echo", + description: "mock", + requiredScopes: ["tools:read"], + handler: async () => ({ + content: [{ type: "text" as const, text: "ok" }], + }), + }, + ], + }); + const result = invokeScopeMiddleware( + registry, + { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "echo", arguments: { message: "hi" } }, + }, + "external.jwt.not.in.internal.map", + ["tools:read"], + ); + + expect(result.next).toBe(true); + expect(result.status).toBe(200); + }); +}); diff --git a/clients/web/src/test/core/remote-tokenAuthProvider.test.ts b/clients/web/src/test/core/remote-tokenAuthProvider.test.ts index cfd12dc7..b2323463 100644 --- a/clients/web/src/test/core/remote-tokenAuthProvider.test.ts +++ b/clients/web/src/test/core/remote-tokenAuthProvider.test.ts @@ -1,28 +1,53 @@ import { describe, it, expect } from "vitest"; -import { createTokenAuthProvider } from "@inspector/core/mcp/remote/node/tokenAuthProvider.js"; +import { createRemoteAuthProvider } from "@inspector/core/mcp/remote/node/tokenAuthProvider.js"; -describe("createTokenAuthProvider", () => { - it("returns undefined when tokens are not provided", () => { - expect(createTokenAuthProvider(undefined)).toBeUndefined(); +describe("createRemoteAuthProvider", () => { + it("returns undefined when no auth state is provided", () => { + expect(createRemoteAuthProvider(undefined)).toBeUndefined(); + expect(createRemoteAuthProvider({})).toBeUndefined(); }); it("returns a provider whose tokens() resolves with the supplied tokens", async () => { const tokens = { access_token: "abc", token_type: "Bearer" }; - const provider = createTokenAuthProvider(tokens); - expect(provider).toBeDefined(); - await expect(provider!.tokens()).resolves.toEqual(tokens); + const handle = createRemoteAuthProvider({ oauthTokens: tokens }); + expect(handle).toBeDefined(); + await expect(handle!.provider.tokens()).resolves.toEqual(tokens); }); - it("exposes no-op stubs for the auxiliary OAuthClientProvider methods", async () => { - const provider = createTokenAuthProvider({ - access_token: "abc", - token_type: "Bearer", + it("updates tokens via setAuthState without replacing the provider", async () => { + const handle = createRemoteAuthProvider({ + oauthTokens: { access_token: "old", token_type: "Bearer" }, }); - expect(provider).toBeDefined(); - // The aux methods are no-op stubs that satisfy the OAuthClientProvider - // surface; the underlying object type widens via the `as unknown as` - // cast in the source, so we narrow here for the test assertions. - const p = provider! as unknown as { + handle!.setAuthState({ + oauthTokens: { + access_token: "new", + token_type: "Bearer", + refresh_token: "rt", + }, + }); + await expect(handle!.provider.tokens()).resolves.toEqual({ + access_token: "new", + token_type: "Bearer", + refresh_token: "rt", + }); + expect(handle!.getAuthState().oauthTokens?.access_token).toBe("new"); + }); + + it("exposes clientInformation when oauthClient is set", async () => { + const handle = createRemoteAuthProvider({ + oauthClient: { client_id: "cid", client_secret: "sec" }, + }); + await expect(handle!.provider.clientInformation()).resolves.toEqual({ + client_id: "cid", + client_secret: "sec", + }); + }); + + it("exposes no-op stubs for auxiliary OAuthClientProvider methods", async () => { + const handle = createRemoteAuthProvider({ + oauthTokens: { access_token: "abc", token_type: "Bearer" }, + }); + const p = handle!.provider as unknown as { clientInformation: () => Promise; saveTokens: (t: { access_token: string; @@ -37,8 +62,12 @@ describe("createTokenAuthProvider", () => { await expect(p.clientInformation()).resolves.toBeUndefined(); await expect( - p.saveTokens({ access_token: "noop", token_type: "Bearer" }), + p.saveTokens({ access_token: "saved", token_type: "Bearer" }), ).resolves.toBeUndefined(); + await expect(handle!.provider.tokens()).resolves.toEqual({ + access_token: "saved", + token_type: "Bearer", + }); expect(p.codeVerifier()).toBeUndefined(); await expect(p.saveCodeVerifier("v")).resolves.toBeUndefined(); expect(() => p.clear()).not.toThrow(); @@ -49,10 +78,9 @@ describe("createTokenAuthProvider", () => { }); it("exposes clientMetadata so SDK auth() does not throw on 401 retry", () => { - const provider = createTokenAuthProvider({ - access_token: "abc", - token_type: "Bearer", + const handle = createRemoteAuthProvider({ + oauthTokens: { access_token: "abc", token_type: "Bearer" }, }); - expect(provider!.clientMetadata.scope).toBe(""); + expect(handle!.provider.clientMetadata.scope).toBe(""); }); }); diff --git a/clients/web/src/test/integration/helpers/oauth-client-fixtures.ts b/clients/web/src/test/integration/helpers/oauth-client-fixtures.ts index eb1b23ac..f46e4780 100644 --- a/clients/web/src/test/integration/helpers/oauth-client-fixtures.ts +++ b/clients/web/src/test/integration/helpers/oauth-client-fixtures.ts @@ -137,9 +137,8 @@ export async function createClientMetadataServer( } /** - * Helper function to programmatically complete OAuth authorization - * Makes HTTP GET request to authorization URL and extracts authorization code - * The test server's authorization endpoint auto-approves and redirects with code + * Programmatically complete OAuth against the local composable test AS. + * GET shows an HTML consent page; approve via POST (same as CLI test helper). * * @param authorizationUrl - The authorization URL from oauthAuthorizationRequired event * @returns Authorization code extracted from redirect URL @@ -147,10 +146,23 @@ export async function createClientMetadataServer( export async function completeOAuthAuthorization( authorizationUrl: URL, ): Promise { - const response = await fetch(authorizationUrl.toString(), { + let response = await fetch(authorizationUrl.toString(), { redirect: "manual", }); + if (response.status === 200) { + const body = new URLSearchParams(authorizationUrl.searchParams); + response = await fetch( + `${authorizationUrl.origin}${authorizationUrl.pathname}`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + redirect: "manual", + }, + ); + } + if (response.status !== 302 && response.status !== 301) { throw new Error( `Expected redirect (302/301), got ${response.status}: ${await response.text()}`, @@ -162,7 +174,7 @@ export async function completeOAuthAuthorization( throw new Error("No Location header in redirect response"); } - const redirectUrlObj = new URL(redirectUrl); + const redirectUrlObj = new URL(redirectUrl, authorizationUrl.origin); const code = redirectUrlObj.searchParams.get("code"); if (!code) { throw new Error(`No authorization code in redirect URL: ${redirectUrl}`); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth-direct-mid-session-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth-direct-mid-session-e2e.test.ts new file mode 100644 index 00000000..605d5558 --- /dev/null +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth-direct-mid-session-e2e.test.ts @@ -0,0 +1,243 @@ +/** + * Mid-session OAuth recovery over direct (TUI/CLI) transport. + * Uses createAuthChallengeInterceptFetch + handleAuthChallenge on InspectorClient. + */ + +import { describe, it, expect, beforeEach, afterEach, afterAll } from "vitest"; +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; +import { NodeOAuthStorage } from "@inspector/core/auth/node/storage-node.js"; +import { + TestServerHttp, + waitForOAuthWellKnown, + getDefaultServerConfig, + createOAuthTestServerConfig, + clearOAuthTestData, + invalidateAccessToken, +} from "@modelcontextprotocol/inspector-test-server"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; +import { + createOAuthClientConfig, + completeOAuthAuthorization, +} from "../helpers/oauth-client-fixtures.js"; +import { ConsoleNavigation } from "@inspector/core/auth/providers.js"; +import type { InspectorClientOptions } from "@inspector/core/mcp/inspectorClient.js"; +import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; + +const oauthTestStatePath = join( + tmpdir(), + `mcp-oauth-${process.pid}-direct-mid-session-e2e.json`, +); + +function createTestOAuthConfig( + options: Parameters[0], +) { + return { + ...createOAuthClientConfig(options), + storage: new NodeOAuthStorage(oauthTestStatePath), + }; +} + +describe("InspectorClient direct mid-session OAuth", () => { + let mcpServer: TestServerHttp | null = null; + const testRedirectUrl = "http://localhost:3000/oauth/callback"; + + beforeEach(() => { + clearOAuthTestData(); + }); + + afterEach(async () => { + if (mcpServer) { + await mcpServer.stop(); + mcpServer = null; + } + }, 30_000); + + afterAll(() => { + try { + rmSync(oauthTestStatePath, { force: true }); + } catch { + // ignore + } + }); + + it("recovers from invalidated access token after connect via silent refresh without disconnect", async () => { + const staticClientId = "test-direct-mid-session"; + const staticClientSecret = "test-secret-direct-mid-session"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: "streamable-http" as const, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportRefreshTokens: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: new ConsoleNavigation(), + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + directAuthRecovery: true, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); + await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl)); + await client.connect(); + + const tokens = await client.getOAuthTokens(); + expect(tokens?.refresh_token).toBeDefined(); + invalidateAccessToken(tokens!.access_token); + + const toolsResult = await client.listTools(); + expect(toolsResult.tools.length).toBeGreaterThan(0); + expect(client.getStatus()).toBe("connected"); + + await client.disconnect(); + }, 30_000); + + it("step-up after insufficient_scope throws AuthRecoveryRequiredError then succeeds after OAuth", async () => { + const staticClientId = "test-direct-step-up"; + const staticClientSecret = "test-secret-direct-step-up"; + + const baseConfig = getDefaultServerConfig(); + const serverConfig = { + ...baseConfig, + serverType: "streamable-http" as const, + tools: baseConfig.tools!.map((tool) => + tool.name === "get_temp" + ? { ...tool, requiredScopes: ["weather:read"] } + : tool, + ), + ...createOAuthTestServerConfig({ + requireAuth: true, + supportRefreshTokens: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + scope: "mcp tools:read", + }); + + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage: oauthConfig.storage, + navigation: new ConsoleNavigation(), + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + directAuthRecovery: true, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); + await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl)); + await client.connect(); + + const toolsResult = await client.listTools(); + const getTempTool = toolsResult.tools.find((t) => t.name === "get_temp"); + expect(getTempTool).toBeDefined(); + + let recovery: AuthRecoveryRequiredError | undefined; + try { + await client.callTool(getTempTool!, { city: "NYC", units: "C" }); + } catch (error) { + if (error instanceof AuthRecoveryRequiredError) { + recovery = error; + } else { + throw error; + } + } + expect(recovery?.authChallenge.reason).toBe("insufficient_scope"); + expect(recovery!.authorizationUrl.searchParams.get("scope")).toContain( + "weather:read", + ); + + const stepUpCode = await completeOAuthAuthorization( + recovery!.authorizationUrl, + ); + await client.completeOAuthFlow(stepUpCode); + + const result = await client.callTool(getTempTool!, { + city: "NYC", + units: "C", + }); + expect(result.success).toBe(true); + + await client.disconnect(); + }, 30_000); +}); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts new file mode 100644 index 00000000..f2e0bb66 --- /dev/null +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts @@ -0,0 +1,715 @@ +/** + * Mid-session OAuth recovery over the web remote transport. + * Verifies inline auth_challenge on /api/mcp/send, silent refresh, auth-state push, and retry. + */ + +import { + describe, + it, + expect, + beforeEach, + afterEach, + afterAll, + vi, +} from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { serve } from "@hono/node-server"; +import type { ServerType } from "@hono/node-server"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { createRemoteTransport } from "@inspector/core/mcp/remote/createRemoteTransport.js"; +import { createRemoteFetch } from "@inspector/core/mcp/remote/createRemoteFetch.js"; +import { NodeOAuthStorage } from "@inspector/core/auth/node/storage-node.js"; +import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js"; +import { + TestServerHttp, + waitForOAuthWellKnown, + getDefaultServerConfig, + createOAuthTestServerConfig, + clearOAuthTestData, + invalidateAccessToken, +} from "@modelcontextprotocol/inspector-test-server"; +import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; +import { + createOAuthClientConfig, + completeOAuthAuthorization, +} from "../helpers/oauth-client-fixtures.js"; +import { ConsoleNavigation } from "@inspector/core/auth/providers.js"; +import type { InspectorClientOptions } from "@inspector/core/mcp/inspectorClient.js"; +import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; + +const oauthTestStatePath = join( + tmpdir(), + `mcp-oauth-${process.pid}-remote-mid-session-e2e.json`, +); + +function createTestOAuthConfig( + options: Parameters[0], +) { + return { + ...createOAuthClientConfig(options), + storage: new NodeOAuthStorage(oauthTestStatePath), + }; +} + +async function startRemoteServer(port: number): Promise<{ + baseUrl: string; + server: ServerType; + authToken: string; +}> { + const { app, authToken } = createRemoteApp({ + initialConfig: { defaultEnvironment: {} }, + }); + return new Promise((resolve, reject) => { + const server = serve( + { fetch: app.fetch, port, hostname: "127.0.0.1" }, + (info) => { + const actualPort = + info && typeof info === "object" && "port" in info + ? (info as { port: number }).port + : port; + resolve({ + baseUrl: `http://127.0.0.1:${actualPort}`, + server, + authToken, + }); + }, + ); + server.on("error", reject); + }); +} + +describe("InspectorClient remote mid-session OAuth", () => { + let remoteServer: ServerType | null = null; + let mcpServer: TestServerHttp | null = null; + let remoteBaseUrl: string | undefined; + let remoteAuthToken: string | undefined; + const testRedirectUrl = "http://localhost:3000/oauth/callback"; + + beforeEach(() => { + clearOAuthTestData(); + }); + + afterEach(async () => { + if (mcpServer) { + await mcpServer.stop(); + mcpServer = null; + } + if (remoteServer) { + await new Promise((resolve) => { + remoteServer!.close(() => resolve()); + }); + remoteServer = null; + } + remoteBaseUrl = undefined; + remoteAuthToken = undefined; + }, 30_000); + + afterAll(() => { + try { + rmSync(oauthTestStatePath, { force: true }); + } catch { + // ignore + } + }); + + async function setupRemoteServer(): Promise { + const tmp = mkdtempSync(join(tmpdir(), "inspector-remote-mid-session-")); + const { baseUrl, server, authToken } = await startRemoteServer(0); + remoteBaseUrl = baseUrl; + remoteAuthToken = authToken; + remoteServer = server; + rmSync(tmp, { recursive: true, force: true }); + } + + it("recovers from invalidated access token after connect via silent refresh and auth-state push", async () => { + await setupRemoteServer(); + + const staticClientId = "test-remote-mid-session"; + const staticClientSecret = "test-secret-mid-session"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: "streamable-http" as const, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportRefreshTokens: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + + const clientConfig: InspectorClientOptions = { + environment: { + transport: createRemoteTransport({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + fetch: createRemoteFetch({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + oauth: { + storage: oauthConfig.storage, + navigation: new ConsoleNavigation(), + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); + await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl)); + await client.connect(); + + const tokens = await client.getOAuthTokens(); + expect(tokens?.refresh_token).toBeDefined(); + invalidateAccessToken(tokens!.access_token); + + const toolsResult = await client.listTools(); + expect(toolsResult.tools.length).toBeGreaterThan(0); + expect(client.getStatus()).toBe("connected"); + + await client.disconnect(); + }, 15_000); + + it("recovers on reconnect when stored access token was invalidated before connect", async () => { + await setupRemoteServer(); + + const staticClientId = "test-remote-reconnect-mid-session"; + const staticClientSecret = "test-secret-reconnect-mid-session"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: "streamable-http" as const, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportRefreshTokens: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + + const clientConfig: InspectorClientOptions = { + environment: { + transport: createRemoteTransport({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + fetch: createRemoteFetch({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + oauth: { + storage: oauthConfig.storage, + navigation: new ConsoleNavigation(), + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); + await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl)); + await client.connect(); + + const tokens = await client.getOAuthTokens(); + invalidateAccessToken(tokens!.access_token); + + await client.connect(); + + expect(client.getStatus()).toBe("connected"); + await client.disconnect(); + }); + + it("step-up re-auth after insufficient_scope lets scoped tool succeed", async () => { + await setupRemoteServer(); + + const staticClientId = "test-remote-step-up"; + const staticClientSecret = "test-secret-step-up"; + + const baseConfig = getDefaultServerConfig(); + const serverConfig = { + ...baseConfig, + tools: baseConfig.tools!.map((tool) => + tool.name === "get_temp" + ? { ...tool, requiredScopes: ["weather:read"] } + : tool, + ), + serverType: "streamable-http" as const, + ...createOAuthTestServerConfig({ + requireAuth: true, + scopesSupported: ["mcp", "tools:read", "weather:read"], + supportRefreshTokens: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + scope: "mcp tools:read", + }); + + const clientConfig: InspectorClientOptions = { + environment: { + transport: createRemoteTransport({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + fetch: createRemoteFetch({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + oauth: { + storage: oauthConfig.storage, + navigation: new ConsoleNavigation(), + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); + await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl)); + await client.connect(); + + const toolsResult = await client.listTools(); + const echoTool = toolsResult.tools.find((t) => t.name === "echo"); + const getTempTool = toolsResult.tools.find((t) => t.name === "get_temp"); + expect(echoTool).toBeDefined(); + expect(getTempTool).toBeDefined(); + + await client.callTool(echoTool!, { message: "hello" }); + + const remoteSessionId = client.getRemoteBackendSessionId(); + expect(remoteSessionId).toBeDefined(); + + let recovery: AuthRecoveryRequiredError | undefined; + try { + await client.callTool(getTempTool!, { city: "NYC", units: "C" }); + } catch (error) { + if (error instanceof AuthRecoveryRequiredError) { + recovery = error; + } else { + throw error; + } + } + expect(recovery?.authChallenge.reason).toBe("insufficient_scope"); + + const mcpServerUrl = `${serverUrl}/mcp`; + const scopeBeforeStepUp = oauthConfig.storage.getScope(mcpServerUrl); + expect(scopeBeforeStepUp).toContain("mcp"); + expect(scopeBeforeStepUp).not.toContain("weather:read"); + + expect(recovery!.authorizationUrl.searchParams.get("scope")).toContain( + "weather:read", + ); + + const stepUpCode = await completeOAuthAuthorization( + recovery!.authorizationUrl, + ); + + // Mirror `/oauth/callback`: new InspectorClient instance, same persisted OAuth + // storage, reattach to the live remote backend session. + const callbackClient = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + clientConfig, + ); + await callbackClient.resumeAfterOAuth(stepUpCode, { remoteSessionId }); + + expect(callbackClient.getStatus()).toBe("connected"); + + const scopeAfterStepUp = oauthConfig.storage.getScope(mcpServerUrl); + expect(scopeAfterStepUp).toContain("weather:read"); + expect(scopeAfterStepUp).toContain("mcp"); + + const callbackTools = await callbackClient.listTools(); + const callbackGetTemp = callbackTools.tools.find( + (t) => t.name === "get_temp", + ); + expect(callbackGetTemp).toBeDefined(); + + const result = await callbackClient.callTool(callbackGetTemp!, { + city: "NYC", + units: "C", + }); + expect(result.success).toBe(true); + const content = result.result?.content; + expect(Array.isArray(content)).toBe(true); + expect(content?.[0]).toHaveProperty("type", "text"); + expect("text" in content![0] && content![0].text).toContain("25"); + + await callbackClient.disconnect(); + await client.disconnect(); + }, 30_000); + + it("resumeAfterOAuth falls back to connect when remote session is dead", async () => { + await setupRemoteServer(); + + const staticClientId = "test-remote-resume-fallback"; + const staticClientSecret = "test-secret-resume-fallback"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: "streamable-http" as const, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportRefreshTokens: false, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + + const clientConfig: InspectorClientOptions = { + environment: { + transport: createRemoteTransport({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + fetch: createRemoteFetch({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + oauth: { + storage: oauthConfig.storage, + navigation: new ConsoleNavigation(), + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); + const initialCode = await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(initialCode); + await client.connect(); + + const mcpServerUrl = `${serverUrl}/mcp`; + await oauthConfig.storage.clearTokens(mcpServerUrl); + const reauthUrl = await client.authenticate(); + if (!reauthUrl) throw new Error("Expected reauth URL"); + const code = await completeOAuthAuthorization(reauthUrl); + + const callbackClient = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + clientConfig, + ); + const connectSpy = vi.spyOn(callbackClient, "connect"); + await callbackClient.resumeAfterOAuth(code, { + remoteSessionId: "dead-remote-session-id", + }); + expect(connectSpy).toHaveBeenCalled(); + connectSpy.mockRestore(); + + expect(callbackClient.getStatus()).toBe("connected"); + const toolsResult = await callbackClient.listTools(); + expect(toolsResult.tools.length).toBeGreaterThan(0); + + await callbackClient.disconnect(); + await client.disconnect(); + }, 30_000); + + it("dispatches authChallengeInteractive for ambient interactive recovery", async () => { + await setupRemoteServer(); + + const staticClientId = "test-remote-ambient-interactive"; + const staticClientSecret = "test-secret-ambient-interactive"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: "streamable-http" as const, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportRefreshTokens: false, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + { + environment: { + transport: createRemoteTransport({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + fetch: createRemoteFetch({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + oauth: { + storage: oauthConfig.storage, + navigation: new ConsoleNavigation(), + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }, + ); + + const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); + await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl)); + await client.connect(); + + const tokens = await client.getOAuthTokens(); + invalidateAccessToken(tokens!.access_token); + + const interactive = vi.fn(); + client.addEventListener("authChallengeInteractive", interactive); + + await client.handleAmbientAuthChallenge({ reason: "token_expired" }); + + expect(interactive).toHaveBeenCalledOnce(); + expect(interactive.mock.calls[0]![0].detail.challenge.reason).toBe( + "token_expired", + ); + expect( + interactive.mock.calls[0]![0].detail.authorizationUrl, + ).toBeInstanceOf(URL); + + await client.disconnect(); + }, 15_000); + + it("recovers from ambient auth notification after idle token invalidation", async () => { + await setupRemoteServer(); + + const staticClientId = "test-remote-ambient-auth"; + const staticClientSecret = "test-secret-ambient-auth"; + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: "streamable-http" as const, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportRefreshTokens: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + const port = await mcpServer.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); + + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + + const clientConfig: InspectorClientOptions = { + environment: { + transport: createRemoteTransport({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + fetch: createRemoteFetch({ + baseUrl: remoteBaseUrl!, + authToken: remoteAuthToken!, + }), + oauth: { + storage: oauthConfig.storage, + navigation: new ConsoleNavigation(), + redirectUrlProvider: oauthConfig.redirectUrlProvider, + }, + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; + + const client = new InspectorClient( + { + type: "streamable-http", + url: `${serverUrl}/mcp`, + } as MCPServerConfig, + clientConfig, + ); + + const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); + await client.completeOAuthFlow(await completeOAuthAuthorization(authUrl)); + await client.connect(); + + const tokens = await client.getOAuthTokens(); + invalidateAccessToken(tokens!.access_token); + + const recovered = vi.fn(); + client.addEventListener("authChallengeRecovered", recovered); + + await client.handleAmbientAuthChallenge({ reason: "token_expired" }); + + expect(recovered).toHaveBeenCalled(); + + const toolsResult = await client.listTools(); + expect(toolsResult.tools.length).toBeGreaterThan(0); + expect(client.getStatus()).toBe("connected"); + + await client.disconnect(); + }, 15_000); +}); diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 5cb82ff5..f79ab992 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -341,6 +341,33 @@ describe("InspectorClient", () => { // (the same end state any other handshake failure would produce). expect(client.getStatus()).toBe("error"); }); + + it("holds status at connecting when connect fails with a recoverable 401", async () => { + const unauthorizedTransport = { + start: async () => { + const err = new Error("Unauthorized") as Error & { status?: number }; + err.status = 401; + throw err; + }, + send: async () => {}, + close: async () => {}, + onclose: undefined, + onerror: undefined, + onmessage: undefined, + sessionId: undefined, + }; + const fakeFactory = () => ({ + transport: + unauthorizedTransport as unknown as import("@modelcontextprotocol/sdk/shared/transport.js").Transport, + }); + client = new InspectorClient( + { type: "streamable-http", url: "http://localhost:8081/mcp" }, + { environment: { transport: fakeFactory } }, + ); + + await expect(client.connect()).rejects.toMatchObject({ status: 401 }); + expect(client.getStatus()).toBe("connecting"); + }); }); describe("Message Tracking", () => { diff --git a/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts b/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts new file mode 100644 index 00000000..3a2100e5 --- /dev/null +++ b/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts @@ -0,0 +1,269 @@ +/** + * Supplemental coverage for createRemoteApp's /api/mcp/* routes + * (core/mcp/remote/node/server.ts), targeting branches the broader e2e + * suites (transport.test.ts, connect-crash.test.ts) don't reach: + * + * - POST /api/mcp/connect: connect-time AuthChallengeError (401 upstream) + * - POST /api/mcp/send: transport-dead short-circuit + * - POST /api/mcp/disconnect: unknown sessionId (no-op, still 200) + * - POST /api/mcp/auth-state: every guard branch (missing fields, unknown + * session, dead transport, no OAuth provider on the session, success) + */ + +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { createServer } from "node:http"; +import type { Server } from "node:http"; +import { serve } from "@hono/node-server"; +import type { ServerType } from "@hono/node-server"; +import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js"; +import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; +import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; + +interface Harness { + baseUrl: string; + server: ServerType; +} + +async function start(): Promise { + const { app } = createRemoteApp({ + dangerouslyOmitAuth: true, + initialConfig: { defaultEnvironment: {} }, + }); + return new Promise((resolve, reject) => { + const server = serve( + { fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, + (info) => { + const port = + info && typeof info === "object" && "port" in info + ? (info as { port: number }).port + : 0; + resolve({ baseUrl: `http://127.0.0.1:${port}`, server }); + }, + ); + server.on("error", reject); + }); +} + +async function stop(h: Harness): Promise { + await new Promise((resolve) => h.server.close(() => resolve())); +} + +async function connect( + h: Harness, + config: MCPServerConfig, + authState?: { oauthTokens: { access_token: string; token_type: string } }, +): Promise { + return fetch(`${h.baseUrl}/api/mcp/connect`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ config, ...(authState && { authState }) }), + }); +} + +/** A raw HTTP server that returns 401 for every request (no MCP semantics). */ +async function startUnauthorizedUpstream(): Promise<{ + url: string; + server: Server; +}> { + const server = createServer((_req, res) => { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "unauthorized" })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + return { url: `http://127.0.0.1:${port}/mcp`, server }; +} + +/** Connect a stdio session whose process crashes almost immediately, then + * give the onclose handler time to mark the session's transport dead. */ +async function connectDeadSession(h: Harness): Promise { + const config: MCPServerConfig = { + type: "stdio", + command: process.execPath, + args: ["-e", "process.stderr.write('dying\\n'); process.exit(1);"], + }; + const res = await connect(h, config); + expect(res.status).toBe(200); + const { sessionId } = (await res.json()) as { sessionId: string }; + // Give the subprocess time to exit and the transport's onclose handler + // to mark the session dead (mirrors connect-crash.test.ts's technique). + await new Promise((resolve) => setTimeout(resolve, 300)); + return sessionId; +} + +describe("server.ts /api/mcp/* branch coverage", () => { + let h: Harness; + beforeEach(async () => { + h = await start(); + }); + afterEach(async () => { + await stop(h); + }); + + describe("POST /api/mcp/connect", () => { + it("returns 500 (not 401) when the upstream SSE connection is rejected for a non-auth reason", async () => { + // The SDK's SSEClientTransport wraps every connection failure — including + // a 401 — in its own SseError, so `err instanceof AuthChallengeError` at + // the connect catch site is unreachable via the real SSE/streamable-http + // SDK transports today (streamable-http's start() makes no network call + // at all; SSE's start() discards the thrown error's subclass). This test + // instead pins the *reachable* generic-failure branch: a non-401 refusal + // still surfaces as a 500 with the wrapped message, not a misleading 401. + const upstream = await startUnauthorizedUpstream(); + try { + const res = await connect(h, { + type: "sse", + url: upstream.url, + }); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/Failed to start transport/); + } finally { + await new Promise((r) => upstream.server.close(() => r())); + } + }); + }); + + describe("POST /api/mcp/send", () => { + it("short-circuits with a transport_error when the session's transport is already dead", async () => { + const sessionId = await connectDeadSession(h); + const res = await fetch(`${h.baseUrl}/api/mcp/send`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId, + message: { jsonrpc: "2.0", id: 1, method: "tools/list" }, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + ok: boolean; + kind?: string; + error?: string; + }; + expect(body.ok).toBe(false); + expect(body.kind).toBe("transport_error"); + expect(typeof body.error).toBe("string"); + }); + }); + + describe("POST /api/mcp/disconnect", () => { + it("returns ok:true as a no-op when the sessionId is unknown", async () => { + const res = await fetch(`${h.baseUrl}/api/mcp/disconnect`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId: "does-not-exist" }), + }); + expect(res.status).toBe(200); + expect((await res.json()).ok).toBe(true); + }); + }); + + describe("POST /api/mcp/auth-state", () => { + async function postAuthState(body: unknown): Promise { + return fetch(`${h.baseUrl}/api/mcp/auth-state`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } + + it("returns 400 when sessionId is missing", async () => { + const res = await postAuthState({ + authState: { oauthTokens: { access_token: "a", token_type: "Bearer" } }, + }); + expect(res.status).toBe(400); + expect((await res.json()).error).toMatch( + /Missing sessionId or authState/, + ); + }); + + it("returns 400 when authState is missing", async () => { + const res = await postAuthState({ sessionId: "some-session" }); + expect(res.status).toBe(400); + expect((await res.json()).error).toMatch( + /Missing sessionId or authState/, + ); + }); + + it("returns 400 on invalid JSON body", async () => { + const res = await fetch(`${h.baseUrl}/api/mcp/auth-state`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{not json", + }); + expect(res.status).toBe(400); + expect((await res.json()).error).toMatch(/Invalid JSON body/); + }); + + it("returns 404 when the session is not found", async () => { + const res = await postAuthState({ + sessionId: "unknown-session-id", + authState: { oauthTokens: { access_token: "a", token_type: "Bearer" } }, + }); + expect(res.status).toBe(404); + expect((await res.json()).error).toMatch(/Session not found/); + }); + + it("returns a transport_error when the session's transport is already dead", async () => { + const sessionId = await connectDeadSession(h); + const res = await postAuthState({ + sessionId, + authState: { oauthTokens: { access_token: "a", token_type: "Bearer" } }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean; kind?: string }; + expect(body.ok).toBe(false); + expect(body.kind).toBe("transport_error"); + }); + + it("returns 400 when the session has no OAuth auth provider (connected without authState)", async () => { + const { command, args } = getTestMcpServerCommand(); + const res = await connect(h, { type: "stdio", command, args }); + expect(res.status).toBe(200); + const { sessionId } = (await res.json()) as { sessionId: string }; + + const authRes = await postAuthState({ + sessionId, + authState: { oauthTokens: { access_token: "a", token_type: "Bearer" } }, + }); + expect(authRes.status).toBe(400); + expect((await authRes.json()).error).toMatch( + /Session has no OAuth auth provider/, + ); + + await fetch(`${h.baseUrl}/api/mcp/disconnect`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId }), + }); + }); + + it("returns ok:true and hot-swaps the token when the session has an OAuth auth provider", async () => { + const { command, args } = getTestMcpServerCommand(); + const res = await connect( + h, + { type: "stdio", command, args }, + { oauthTokens: { access_token: "initial", token_type: "Bearer" } }, + ); + expect(res.status).toBe(200); + const { sessionId } = (await res.json()) as { sessionId: string }; + + const authRes = await postAuthState({ + sessionId, + authState: { + oauthTokens: { access_token: "rotated", token_type: "Bearer" }, + }, + }); + expect(authRes.status).toBe(200); + expect((await authRes.json()).ok).toBe(true); + + await fetch(`${h.baseUrl}/api/mcp/disconnect`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId }), + }); + }); + }); +}); diff --git a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts index cf95564f..bb52a33b 100644 --- a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts @@ -10,6 +10,7 @@ import { describe, it, expect, vi } from "vitest"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { RemoteSession } from "@inspector/core/mcp/remote/node/remote-session.js"; import type { FetchRequestEntryBase } from "@inspector/core/mcp/types.js"; +import { AuthChallengeError } from "@inspector/core/auth/challenge.js"; function makeFetchEntry( overrides: Partial = {}, @@ -173,4 +174,170 @@ describe("RemoteSession", () => { "2026-01-01T00:00:00.000Z", ); }); + + it("waitForRequestResponse resolves when a matching JSON-RPC response arrives", async () => { + const session = new RemoteSession("s-wait"); + const wait = session.waitForRequestResponse(42); + session.onMessage({ jsonrpc: "2.0", id: 42, result: { tools: [] } }); + await expect(wait).resolves.toBeUndefined(); + }); + + it("endSend is a no-op when no send is active", () => { + const session = new RemoteSession("s-endsend-noop"); + expect(session.hasActiveSend()).toBe(false); + // No matching beginSend() — activeSendCount is already 0. + session.endSend(); + expect(session.hasActiveSend()).toBe(false); + }); + + it("waitForRequestResponse with timeoutMs=0 never schedules a timer and still resolves", async () => { + const session = new RemoteSession("s-no-timeout"); + const wait = session.waitForRequestResponse(7, 0); + session.onMessage({ jsonrpc: "2.0", id: 7, result: {} }); + await expect(wait).resolves.toBeUndefined(); + }); + + it("cancelRequestWait rejects a timeoutMs=0 wait without a timer to clear", async () => { + const session = new RemoteSession("s-no-timeout-cancel"); + const wait = session.waitForRequestResponse(8, 0); + const rejection = expect(wait).rejects.toThrow(/cancelled/); + session.cancelRequestWait(8); + await rejection; + }); + + it("handleTransportAuthError rejects active request waits during send", async () => { + const session = new RemoteSession("s-auth"); + session.beginSend(); + const wait = session.waitForRequestResponse(1); + const err = new AuthChallengeError({ reason: "token_expired" }, 401); + expect(session.handleTransportAuthError(err)).toBe(true); + await expect(wait).rejects.toBe(err); + session.endSend(); + }); + + it("handleTransportAuthError pushes ambient auth when no send is active", () => { + const session = new RemoteSession("s-ambient"); + const received: unknown[] = []; + session.setEventConsumer((event) => { + if (event.type === "auth_challenge") received.push(event.data); + }); + const err = new AuthChallengeError({ reason: "token_expired" }, 401); + expect(session.handleTransportAuthError(err)).toBe(true); + expect(received).toHaveLength(1); + }); + + it("does not push SSE auth while a send is active (command path owns delivery)", () => { + const session = new RemoteSession("s-active"); + const received: unknown[] = []; + session.setEventConsumer((event) => { + if (event.type === "auth_challenge") received.push(event.data); + }); + const err = new AuthChallengeError({ reason: "token_expired" }, 401); + session.beginSend(); + expect(session.handleTransportAuthError(err)).toBe(true); + expect(received).toHaveLength(0); + session.endSend(); + }); + + it("does not duplicate on SSE until the HTTP echo suppress window expires", () => { + vi.useFakeTimers(); + const session = new RemoteSession("s-echo"); + const received: unknown[] = []; + session.setEventConsumer((event) => { + if (event.type === "auth_challenge") received.push(event.data); + }); + const err = new AuthChallengeError({ reason: "token_expired" }, 401); + session.beginSend(); + session.noteAuthChallengeDeliveredViaHttp(); + session.endSend(); + expect(session.handleTransportAuthError(err)).toBe(true); + expect(received).toHaveLength(0); + expect(session.handleTransportAuthError(err)).toBe(true); + expect(received).toHaveLength(0); + session.beginSend(); + session.endSend(); + expect(session.handleTransportAuthError(err)).toBe(true); + expect(received).toHaveLength(0); + vi.advanceTimersByTime(RemoteSession.AUTH_HTTP_ECHO_SUPPRESS_MS + 1); + expect(session.handleTransportAuthError(err)).toBe(true); + expect(received).toHaveLength(1); + vi.useRealTimers(); + }); + + it("does not clear HTTP auth suppression when a concurrent send starts", () => { + const session = new RemoteSession("s-concurrent"); + const received: unknown[] = []; + session.setEventConsumer((event) => { + if (event.type === "auth_challenge") received.push(event.data); + }); + const err = new AuthChallengeError({ reason: "token_expired" }, 401); + session.beginSend(); + session.noteAuthChallengeDeliveredViaHttp(); + session.endSend(); + session.beginSend(); + expect(session.handleTransportAuthError(err)).toBe(true); + expect(received).toHaveLength(0); + session.endSend(); + }); + + it("waitForRequestResponse rejects after timeout", async () => { + vi.useFakeTimers(); + const session = new RemoteSession("s-timeout"); + const wait = session.waitForRequestResponse(99, 1000); + const rejection = expect(wait).rejects.toThrow(/timed out/); + await vi.advanceTimersByTimeAsync(1000); + await rejection; + vi.useRealTimers(); + }); + + it("cancelRequestWait clears the timeout for a pending wait", async () => { + vi.useFakeTimers(); + const session = new RemoteSession("s-cancel"); + const wait = session.waitForRequestResponse(42, 5000); + const rejection = expect(wait).rejects.toThrow(/cancelled/); + session.cancelRequestWait(42); + await rejection; + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(5000); + vi.useRealTimers(); + }); + + it("setAuthState throws when no auth provider handle is set", () => { + const session = new RemoteSession("no-auth-provider"); + expect(() => + session.setAuthState({ + oauthTokens: { access_token: "x", token_type: "Bearer" }, + }), + ).toThrow(/Session has no OAuth auth provider/); + }); + + it("cancelRequestWait is a no-op when the requestId has no pending wait", () => { + const session = new RemoteSession("s-cancel-noop"); + // No wait was ever registered for this id — should not throw. + expect(() => session.cancelRequestWait(123)).not.toThrow(); + }); + + it("handleTransportAuthError returns false for a non-AuthChallengeError", () => { + const session = new RemoteSession("s-not-auth-error"); + expect(session.handleTransportAuthError(new Error("plain error"))).toBe( + false, + ); + }); + + it("setAuthState updates the session auth provider", async () => { + const { createRemoteAuthProvider } = + await import("@inspector/core/mcp/remote/node/tokenAuthProvider.js"); + const handle = createRemoteAuthProvider({ + oauthTokens: { access_token: "old", token_type: "Bearer" }, + })!; + const session = new RemoteSession("auth-state"); + session.setAuthProviderHandle(handle); + session.setAuthState({ + oauthTokens: { access_token: "new", token_type: "Bearer" }, + }); + await expect(handle.provider.tokens()).resolves.toEqual({ + access_token: "new", + token_type: "Bearer", + }); + }); }); diff --git a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts index 4e57718e..4e07b3aa 100644 --- a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts @@ -16,6 +16,11 @@ import type { RemoteTransportOptions } from "@inspector/core/mcp/remote/remoteCl import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; import type { RemoteEvent } from "@inspector/core/mcp/remote/types.js"; import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; +import { + AuthChallengeError, + AuthRecoveryRequiredError, + EMA_STEP_UP_PENDING_URL, +} from "@inspector/core/auth/challenge.js"; const CONFIG: MCPServerConfig = { type: "sse", @@ -53,10 +58,26 @@ function sseFrame(event: RemoteEvent): string { } interface MockFetchPlan { - connect?: () => Response | Promise; - events?: () => Response | Promise; - send?: () => Response | Promise; - disconnect?: () => Response | Promise; + connect?: ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Response | Promise; + events?: ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Response | Promise; + send?: ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Response | Promise; + disconnect?: ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Response | Promise; + authState?: ( + input: RequestInfo | URL, + init?: RequestInit, + ) => Response | Promise; } /** @@ -64,27 +85,58 @@ interface MockFetchPlan { * handlers, defaulting to sensible success responses when a handler is omitted. */ function mockFetch(plan: MockFetchPlan): typeof fetch { - const fn = vi.fn(async (input: RequestInfo | URL): Promise => { - const url = typeof input === "string" ? input : input.toString(); - if (url.includes("/api/mcp/connect")) { - return plan.connect - ? plan.connect() - : jsonResponse({ sessionId: "sess-1" }); - } - if (url.includes("/api/mcp/events")) { - return plan.events ? plan.events() : sseResponse([]); - } - if (url.includes("/api/mcp/send")) { - return plan.send ? plan.send() : jsonResponse({ ok: true }); - } - if (url.includes("/api/mcp/disconnect")) { - return plan.disconnect ? plan.disconnect() : jsonResponse({ ok: true }); - } - throw new Error(`unexpected fetch to ${url}`); - }); + const fn = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/mcp/connect")) { + return plan.connect + ? plan.connect(input, init) + : jsonResponse({ sessionId: "sess-1" }); + } + if (url.includes("/api/mcp/events")) { + return plan.events ? plan.events(input, init) : sseResponse([]); + } + if (url.includes("/api/mcp/auth-state")) { + return plan.authState + ? plan.authState(input, init) + : jsonResponse({ ok: true }); + } + if (url.includes("/api/mcp/send")) { + return plan.send ? plan.send(input, init) : jsonResponse({ ok: true }); + } + if (url.includes("/api/mcp/disconnect")) { + return plan.disconnect + ? plan.disconnect(input, init) + : jsonResponse({ ok: true }); + } + throw new Error(`unexpected fetch to ${url}`); + }, + ); return fn as unknown as typeof fetch; } +/** Push arbitrary RemoteEvent frames onto a controllable SSE stream. */ +function createPushableEventStream() { + const encoder = new TextEncoder(); + let controller: ReadableStreamDefaultController | null = null; + const stream = new ReadableStream({ + start(c) { + controller = c; + c.enqueue(encoder.encode(": keepalive\n\n")); + }, + }); + const push = (event: RemoteEvent) => { + controller?.enqueue(encoder.encode(sseFrame(event))); + }; + return { + response: new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + push, + }; +} + function makeTransport( plan: MockFetchPlan, extra: Partial = {}, @@ -103,6 +155,19 @@ function makeTransport( /** Wait a tick so the detached consumeEventStream loop can advance. */ const tick = () => new Promise((r) => setTimeout(r, 10)); +/** + * An SSE response whose stream stays open (never closes on its own) until the + * transport cancels it. Use for tests that need the transport to remain + * started/open past the initial start() call — the plan's default `events()` + * handler returns an already-closed empty stream, which auto-closes the + * transport shortly after start() resolves. + */ +function openEventsResponse(): Response { + return new Response(new ReadableStream({ start() {} }), { + status: 200, + }); +} + describe("RemoteClientTransport (focused branch coverage)", () => { describe("start()", () => { it("throws Remote connect failed with status on non-OK connect", async () => { @@ -172,7 +237,9 @@ describe("RemoteClientTransport (focused branch coverage)", () => { ); await t.start(); expect(connectBody).toMatchObject({ - oauthTokens: { access_token: "AT", refresh_token: "RT" }, + authState: { + oauthTokens: { access_token: "AT", refresh_token: "RT" }, + }, }); await t.close(); }); @@ -497,17 +564,38 @@ describe("RemoteClientTransport (focused branch coverage)", () => { it("posts a message including relatedRequestId and succeeds", async () => { let sentBody: { relatedRequestId?: unknown } | undefined; + const encoder = new TextEncoder(); + let sseController: ReadableStreamDefaultController | null = + null; + const pushSseMessage = (message: JSONRPCMessage) => { + const payload = JSON.stringify({ type: "message", data: message }); + sseController?.enqueue(encoder.encode(`data: ${payload}\n\n`)); + }; const fetchFn = vi.fn( async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === "string" ? input : input.toString(); if (url.includes("/connect")) return jsonResponse({ sessionId: "s" }); - if (url.includes("/events")) + if (url.includes("/events")) { return new Response( - new ReadableStream({ start() {} }), + new ReadableStream({ + start(controller) { + sseController = controller; + controller.enqueue(encoder.encode(": keepalive\n\n")); + }, + }), { status: 200 }, ); + } if (url.includes("/send")) { sentBody = JSON.parse(init!.body as string); + const requestId = ( + sentBody as { message: { id?: string | number } } + ).message.id; + pushSseMessage({ + jsonrpc: "2.0", + id: requestId!, + result: {}, + }); return jsonResponse({ ok: true }); } return jsonResponse({ ok: true }); @@ -517,6 +605,7 @@ describe("RemoteClientTransport (focused branch coverage)", () => { { baseUrl: "http://remote.test", fetchFn: fetchFn as unknown as typeof fetch, + sseResponseTimeoutMs: 2000, }, CONFIG, ); @@ -577,6 +666,22 @@ describe("RemoteClientTransport (focused branch coverage)", () => { }); }); + describe("attachToSession", () => { + it("opens SSE for an existing session without POST /connect", async () => { + const connect = vi.fn(() => jsonResponse({ sessionId: "new" })); + const events = vi.fn(() => sseResponse([])); + const t = makeTransport({ connect, events }); + await t.attachToSession("existing-sess"); + expect(connect).not.toHaveBeenCalled(); + expect(events).toHaveBeenCalledWith( + expect.stringContaining("sessionId=existing-sess"), + expect.anything(), + ); + expect(t.getRemoteBackendSessionId()).toBe("existing-sess"); + await t.close(); + }); + }); + describe("sessionId getter", () => { it("always returns undefined (intentional)", () => { const t = makeTransport({}); @@ -608,4 +713,638 @@ describe("RemoteClientTransport (focused branch coverage)", () => { expect(seen[0]!["x-mcp-remote-auth"]).toBeUndefined(); }); }); + + describe("start() connect-time auth challenge and error branches", () => { + it("accepts the ok:true connect response shape (ok-wrapped sessionId)", async () => { + const t = makeTransport({ + connect: () => jsonResponse({ ok: true, sessionId: "sess-ok" }), + }); + await t.start(); + expect(t.getRemoteBackendSessionId()).toBe("sess-ok"); + await t.close(); + }); + + it("throws a formatted error when connect returns a transport_error", async () => { + const t = makeTransport({ + connect: () => + jsonResponse({ + ok: false, + kind: "transport_error", + error: "upstream unreachable", + }), + }); + await expect(t.start()).rejects.toThrow( + /Remote connect failed: upstream unreachable/, + ); + }); + + it("throws AuthChallengeError immediately when connect returns an auth_challenge and no authRecovery is configured", async () => { + const t = makeTransport({ + connect: () => + jsonResponse({ + ok: false, + kind: "auth_challenge", + authChallenge: { reason: "token_expired" }, + }), + }); + try { + await t.start(); + throw new Error("expected start() to throw"); + } catch (e) { + expect(e).toBeInstanceOf(AuthChallengeError); + expect((e as AuthChallengeError).status).toBe(401); + } + }); + + it("preserves the raw httpStatus on the AuthChallengeError when no authRecovery is configured", async () => { + const t = makeTransport({ + connect: () => + jsonResponse({ + ok: false, + kind: "auth_challenge", + authChallenge: { + reason: "invalid_token", + raw: { httpStatus: 403 }, + }, + }), + }); + try { + await t.start(); + throw new Error("expected start() to throw"); + } catch (e) { + expect((e as AuthChallengeError).status).toBe(403); + } + }); + + it("throws AuthRecoveryRequiredError with the EMA pending URL for a step_up_confirm outcome", async () => { + const t = makeTransport( + { + connect: () => + jsonResponse({ + ok: false, + kind: "auth_challenge", + authChallenge: { reason: "insufficient_scope" }, + }), + }, + { + authRecovery: { + handleAuthChallenge: vi.fn().mockResolvedValue({ + kind: "step_up_confirm", + challenge: { reason: "insufficient_scope" }, + }), + }, + }, + ); + try { + await t.start(); + throw new Error("expected start() to throw"); + } catch (e) { + expect(e).toBeInstanceOf(AuthRecoveryRequiredError); + const err = e as AuthRecoveryRequiredError; + expect(err.authorizationUrl).toBe(EMA_STEP_UP_PENDING_URL); + expect(err.emaStepUpConfirm).toBe(true); + } + }); + + it("throws AuthRecoveryRequiredError with the authorization URL for an interactive outcome", async () => { + const authorizationUrl = new URL("https://idp.example/authorize"); + const t = makeTransport( + { + connect: () => + jsonResponse({ + ok: false, + kind: "auth_challenge", + authChallenge: { reason: "unauthorized" }, + }), + }, + { + authRecovery: { + handleAuthChallenge: vi.fn().mockResolvedValue({ + kind: "interactive", + authorizationUrl, + challenge: { reason: "unauthorized" }, + }), + }, + }, + ); + try { + await t.start(); + throw new Error("expected start() to throw"); + } catch (e) { + const err = e as AuthRecoveryRequiredError; + expect(err.authorizationUrl).toBe(authorizationUrl); + expect(err.emaStepUpConfirm).toBeUndefined(); + } + }); + + it("rethrows the recovery error for a failed outcome", async () => { + const recoveryError = new Error("recovery failed"); + const t = makeTransport( + { + connect: () => + jsonResponse({ + ok: false, + kind: "auth_challenge", + authChallenge: { reason: "unauthorized" }, + }), + }, + { + authRecovery: { + handleAuthChallenge: vi + .fn() + .mockResolvedValue({ kind: "failed", error: recoveryError }), + }, + }, + ); + await expect(t.start()).rejects.toBe(recoveryError); + }); + + it("succeeds after a satisfied outcome retries connect and receives a session", async () => { + let connectAttempt = 0; + const t = makeTransport( + { + connect: () => { + connectAttempt += 1; + if (connectAttempt === 1) { + return jsonResponse({ + ok: false, + kind: "auth_challenge", + authChallenge: { reason: "token_expired" }, + }); + } + return jsonResponse({ sessionId: "recovered-session" }); + }, + events: () => openEventsResponse(), + }, + { + authRecovery: { + handleAuthChallenge: vi + .fn() + .mockResolvedValue({ kind: "satisfied" }), + }, + }, + ); + await t.start(); + expect(connectAttempt).toBe(2); + expect(t.getRemoteBackendSessionId()).toBe("recovered-session"); + await t.close(); + }); + + it("throws AuthChallengeError when a retried connect still returns an auth_challenge", async () => { + const t = makeTransport( + { + connect: () => + jsonResponse({ + ok: false, + kind: "auth_challenge", + authChallenge: { reason: "token_expired" }, + }), + }, + { + authRecovery: { + handleAuthChallenge: vi + .fn() + .mockResolvedValue({ kind: "satisfied" }), + }, + }, + ); + await expect(t.start()).rejects.toBeInstanceOf(AuthChallengeError); + }); + }); + + describe("attachToSession while closed", () => { + it("un-closes a previously closed transport before reattaching", async () => { + const events = vi.fn(() => openEventsResponse()); + const t = makeTransport({ events }); + await t.close(); // never started; closed=true, no sessionId so disconnect is skipped + expect((t as unknown as { closed: boolean }).closed).toBe(true); + await t.attachToSession("resumed-session"); + expect((t as unknown as { closed: boolean }).closed).toBe(false); + expect(t.getRemoteBackendSessionId()).toBe("resumed-session"); + await t.close(); + }); + }); + + describe("fetchFn default", () => { + it("falls back to globalThis.fetch when no fetchFn option is configured", () => { + const t = new RemoteClientTransport( + { baseUrl: "http://remote.test" }, + CONFIG, + ); + expect((t as unknown as { fetchFn: typeof fetch }).fetchFn).toBe( + globalThis.fetch, + ); + }); + }); + + describe("parseSSE trailing buffer", () => { + it("ignores a trailing buffer line that is neither an event: nor a data: field", async () => { + const onmessage = vi.fn(); + const t = makeTransport({ + events: () => sseResponse([": trailing comment with no newline"]), + }); + t.onmessage = onmessage; + await t.start(); + await tick(); + expect(onmessage).not.toHaveBeenCalled(); + }); + }); + + describe("consumeEventStream stops after transport_error closes it", () => { + it("does not process a message frame that follows a transport_error frame in the same batch", async () => { + const m: JSONRPCMessage = { jsonrpc: "2.0", id: 99, result: {} }; + const onmessage = vi.fn(); + const onerror = vi.fn(); + const onclose = vi.fn(); + const t = makeTransport({ + events: () => + sseResponse([ + sseFrame({ type: "transport_error", data: { error: "died" } }), + sseFrame({ type: "message", data: m }), + ]), + }); + t.onmessage = onmessage; + t.onerror = onerror; + t.onclose = onclose; + await t.start(); + await tick(); + expect(onerror).toHaveBeenCalledTimes(1); + expect(onclose).toHaveBeenCalledTimes(1); + expect(onmessage).not.toHaveBeenCalled(); + }); + }); + + describe("consumeEventStream restart / re-entrancy suppression", () => { + it("suppresses transport_error handling entirely while a reconnect is in flight", async () => { + const sse = createPushableEventStream(); + const onerror = vi.fn(); + const onclose = vi.fn(); + const t = makeTransport({ events: () => sse.response }); + t.onerror = onerror; + t.onclose = onclose; + await t.start(); + await tick(); + // Simulate a reconnect (attachToSession/close) already in flight on this + // transport when a stale transport_error frame from the old stream lands. + ( + t as unknown as { restartingEventStream: boolean } + ).restartingEventStream = true; + sse.push({ type: "transport_error", data: { error: "stale" } }); + await tick(); + expect(onerror).not.toHaveBeenCalled(); + expect(onclose).not.toHaveBeenCalled(); + ( + t as unknown as { restartingEventStream: boolean } + ).restartingEventStream = false; + await t.close(); + }); + + it("does not double-fire onclose when the onerror handler synchronously closes the transport", async () => { + const t = makeTransport({ + events: () => + sseResponse([ + sseFrame({ type: "transport_error", data: { error: "boom" } }), + ]), + }); + const onclose = vi.fn(); + let closePromise: Promise | undefined; + t.onerror = () => { + closePromise = t.close(); + }; + t.onclose = onclose; + await t.start(); + await tick(); + await closePromise; + expect(onclose).toHaveBeenCalledTimes(1); + }); + }); + + describe("settleSseResponseWait guard", () => { + it("ignores a message-type SSE event with no id (notification)", async () => { + const onmessage = vi.fn(); + const t = makeTransport({ + events: () => + sseResponse([ + sseFrame({ + type: "message", + data: { + jsonrpc: "2.0", + method: "notifications/progress", + params: {}, + }, + }), + ]), + }); + t.onmessage = onmessage; + await t.start(); + await tick(); + expect(onmessage).toHaveBeenCalledTimes(1); + }); + + it("ignores a message-type SSE event that has an id but neither result nor error", async () => { + const onmessage = vi.fn(); + const t = makeTransport({ + events: () => + sseResponse([ + sseFrame({ + type: "message", + data: { jsonrpc: "2.0", id: 3, method: "sampling/createMessage" }, + }), + ]), + }); + t.onmessage = onmessage; + await t.start(); + await tick(); + expect(onmessage).toHaveBeenCalledTimes(1); + }); + }); + + describe("cancelSseResponseWait no-op when already settled", () => { + it("no-ops when the SSE response for the id already settled before the auth_challenge HTTP reply arrives", async () => { + const sse = createPushableEventStream(); + const t = makeTransport({ + events: () => sse.response, + send: async (_input, init) => { + const body = JSON.parse(String(init?.body)) as { + message: { id?: string | number }; + }; + // Simulate the SSE response for this id landing first... + sse.push({ + type: "message", + data: { jsonrpc: "2.0", id: body.message.id, result: {} }, + }); + // ...and let the independent SSE consumer settle it before the + // HTTP reply (an auth_challenge for the same id) comes back. + await tick(); + return jsonResponse({ + ok: false, + kind: "auth_challenge", + authChallenge: { reason: "token_expired" }, + }); + }, + }); + await t.start(); + await expect( + t.send({ jsonrpc: "2.0", id: 11, method: "tools/list" }), + ).rejects.toBeInstanceOf(AuthChallengeError); + await t.close(); + }); + }); + + describe("postSend catch skips cancel for requestId-less messages", () => { + it("does not attempt to cancel an SSE wait for a notification when requestSend throws", async () => { + const notif: JSONRPCMessage = { + jsonrpc: "2.0", + method: "notifications/cancelled", + }; + const t = makeTransport({ + events: () => openEventsResponse(), + send: () => { + throw new Error("network down"); + }, + }); + await t.start(); + await expect(t.send(notif)).rejects.toThrow(/network down/); + await t.close(); + }); + }); + + describe("send() generic transport_error and notification failure paths", () => { + it("throws the raw error message when send returns a non-auth_challenge failure kind", async () => { + const t = makeTransport({ + events: () => openEventsResponse(), + send: () => + jsonResponse({ + ok: false, + kind: "transport_error", + error: "upstream 503", + }), + }); + await t.start(); + await expect( + t.send({ jsonrpc: "2.0", id: 1, method: "ping" }), + ).rejects.toThrow(/upstream 503/); + await t.close(); + }); + + it("skips cancelSseResponseWait for a notification whose send fails", async () => { + const notif: JSONRPCMessage = { + jsonrpc: "2.0", + method: "notifications/cancelled", + }; + const t = makeTransport({ + events: () => openEventsResponse(), + send: () => + jsonResponse({ ok: false, kind: "transport_error", error: "boom" }), + }); + await t.start(); + await expect(t.send(notif)).rejects.toThrow(/boom/); + await t.close(); + }); + }); + + describe("send() auth_challenge without recovery configured", () => { + it("throws immediately when no authRecovery is configured", async () => { + const t = makeTransport({ + events: () => openEventsResponse(), + send: () => + jsonResponse({ + ok: false, + kind: "auth_challenge", + authChallenge: { reason: "token_expired" }, + }), + }); + await t.start(); + await expect( + t.send({ jsonrpc: "2.0", id: 1, method: "ping" }), + ).rejects.toBeInstanceOf(AuthChallengeError); + await t.close(); + }); + + it("throws immediately with the raw httpStatus when authRecovery has no pushAuthState handler", async () => { + const t = makeTransport( + { + events: () => openEventsResponse(), + send: () => + jsonResponse({ + ok: false, + kind: "auth_challenge", + authChallenge: { + reason: "token_expired", + raw: { httpStatus: 403 }, + }, + }), + }, + { authRecovery: { handleAuthChallenge: vi.fn() } }, + ); + await t.start(); + try { + await t.send({ jsonrpc: "2.0", id: 1, method: "ping" }); + throw new Error("expected send() to throw"); + } catch (e) { + expect((e as AuthChallengeError).status).toBe(403); + } + await t.close(); + }); + }); + + describe("pushAuthState guards", () => { + it("throws Transport not started when called before start()", async () => { + const t = makeTransport({}); + await expect( + t.pushAuthState({ + oauthTokens: { access_token: "a", token_type: "Bearer" }, + }), + ).rejects.toThrow(/Transport not started/); + }); + + it("throws Transport is closed after close()", async () => { + const t = makeTransport({}); + await t.start(); + await t.close(); + await expect( + t.pushAuthState({ + oauthTokens: { access_token: "a", token_type: "Bearer" }, + }), + ).rejects.toThrow(/Transport is closed/); + }); + + it("throws when the resolved auth state has neither oauthTokens nor oauthClient", async () => { + const t = makeTransport({ events: () => openEventsResponse() }); + await t.start(); + await expect(t.pushAuthState({})).rejects.toThrow( + /No auth state to push/, + ); + await t.close(); + }); + + it("throws No auth provider configured when called with no explicit state and no authProvider", async () => { + const t = makeTransport({ events: () => openEventsResponse() }); + await t.start(); + await expect(t.pushAuthState()).rejects.toThrow( + /No auth provider configured/, + ); + await t.close(); + }); + + it("throws No OAuth tokens available when the configured authProvider has no tokens", async () => { + const authProvider = { + tokens: async () => undefined, + } as unknown as NonNullable; + const t = makeTransport( + { events: () => openEventsResponse() }, + { authProvider }, + ); + await t.start(); + await expect(t.pushAuthState()).rejects.toThrow( + /No OAuth tokens available/, + ); + await t.close(); + }); + + it("throws a formatted error when the auth-state POST responds non-OK", async () => { + const t = makeTransport({ + events: () => openEventsResponse(), + authState: () => new Response("nope", { status: 500 }), + }); + await t.start(); + await expect( + t.pushAuthState({ + oauthTokens: { access_token: "a", token_type: "Bearer" }, + }), + ).rejects.toThrow(/Remote auth-state update failed \(500\)/); + await t.close(); + }); + + it("throws the server-provided error message when the auth-state POST reports ok:false", async () => { + const t = makeTransport({ + events: () => openEventsResponse(), + authState: () => jsonResponse({ ok: false, error: "session gone" }), + }); + await t.start(); + await expect( + t.pushAuthState({ + oauthTokens: { access_token: "a", token_type: "Bearer" }, + }), + ).rejects.toThrow(/session gone/); + await t.close(); + }); + + it("throws a default error message when the auth-state POST reports ok:false with no error field", async () => { + const t = makeTransport({ + events: () => openEventsResponse(), + authState: () => jsonResponse({ ok: false }), + }); + await t.start(); + await expect( + t.pushAuthState({ + oauthTokens: { access_token: "a", token_type: "Bearer" }, + }), + ).rejects.toThrow(/Remote auth-state update failed/); + await t.close(); + }); + + it("accepts an explicit authState argument without consulting authProvider", async () => { + const authProvider = { + tokens: vi.fn(), + } as unknown as NonNullable; + const authStateCalls: unknown[] = []; + const t = makeTransport( + { + events: () => openEventsResponse(), + authState: (_input, init) => { + authStateCalls.push(JSON.parse(String(init?.body))); + return jsonResponse({ ok: true }); + }, + }, + { authProvider }, + ); + await t.start(); + await t.pushAuthState({ + oauthTokens: { access_token: "explicit", token_type: "Bearer" }, + }); + expect(authStateCalls).toHaveLength(1); + await t.close(); + }); + }); + + describe("misc coverage", () => { + it("setOnAuthChallenge updates the ambient auth challenge handler", async () => { + const t = makeTransport({ + events: () => + sseResponse([ + sseFrame({ + type: "auth_challenge", + data: { reason: "token_expired" }, + }), + ]), + }); + const onAuthChallenge = vi.fn(); + t.setOnAuthChallenge(onAuthChallenge); + await t.start(); + await tick(); + expect(onAuthChallenge).toHaveBeenCalledWith({ reason: "token_expired" }); + }); + + it("is a no-op when start() is called again while already connected", async () => { + const connect = vi.fn(() => jsonResponse({ sessionId: "s" })); + const t = makeTransport({ connect, events: () => openEventsResponse() }); + await t.start(); + await t.start(); + expect(connect).toHaveBeenCalledTimes(1); + await t.close(); + }); + + it("rejects a pending SSE response wait when close() runs mid-send", async () => { + const t = makeTransport({ + events: () => openEventsResponse(), + send: () => jsonResponse({ ok: true }), + }); + await t.start(); + const sendPromise = t.send({ jsonrpc: "2.0", id: 1, method: "ping" }); + const rejection = expect(sendPromise).rejects.toThrow(/Transport closed/); + await t.close(); + await rejection; + }); + }); }); diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index b523a913..ba862f20 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -788,4 +788,113 @@ describe("server.ts supplemental coverage", () => { } }); }); + + describe("keychain-unavailable 503 on PUT/DELETE", () => { + class UnavailableOnWriteStore implements SecretStore { + async get(): Promise { + return null; + } + async set(): Promise { + throw new KeychainUnavailableError(new Error("libsecret missing")); + } + async delete(): Promise { + /* no-op */ + } + async deleteAllForServer(): Promise { + throw new KeychainUnavailableError(new Error("libsecret missing")); + } + } + + it("PUT returns 503 when the keychain write is unavailable", async () => { + const h = await start({ + secretStore: new UnavailableOnWriteStore(), + seedConfig: JSON.stringify({ + mcpServers: { + srv: { + type: "streamable-http", + url: "https://x.test/mcp", + oauth: { clientId: "cid", clientSecret: "shh" }, + }, + }, + }), + }); + try { + const res = await fetch(`${h.baseUrl}/api/servers/srv`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + config: { type: "streamable-http", url: "https://x.test/mcp" }, + settings: { + headers: [], + metadata: [], + connectionTimeout: 0, + requestTimeout: 0, + oauthClientSecret: "new-secret", + }, + }), + }); + expect(res.status).toBe(503); + expect((await res.json()).error).toMatch(/libsecret missing/); + } finally { + await stop(h); + } + }); + + it("DELETE returns 503 when the keychain sweep is unavailable", async () => { + const h = await start({ + secretStore: new UnavailableOnWriteStore(), + seedConfig: JSON.stringify({ + mcpServers: { srv: { type: "stdio", command: "node" } }, + }), + }); + try { + const res = await fetch(`${h.baseUrl}/api/servers/srv`, { + method: "DELETE", + }); + expect(res.status).toBe(503); + expect((await res.json()).error).toMatch(/libsecret missing/); + } finally { + await stop(h); + } + }); + }); + + describe("migratePlaintextSecrets rethrows a non-keychain error", () => { + class ThrowingOnGetStore implements SecretStore { + async get(): Promise { + throw new Error("disk full"); + } + async set(): Promise { + /* unreachable in this test */ + } + async delete(): Promise { + /* no-op */ + } + async deleteAllForServer(): Promise { + /* no-op */ + } + } + + it("GET /api/servers returns 500 when migration hits a non-KeychainUnavailableError", async () => { + const h = await start({ + secretStore: new ThrowingOnGetStore(), + seedConfig: JSON.stringify({ + mcpServers: { + srv: { + type: "streamable-http", + url: "https://x.test/mcp", + oauth: { clientId: "cid", clientSecret: "shh" }, + }, + }, + }), + }); + try { + const res = await fetch(`${h.baseUrl}/api/servers`); + expect(res.status).toBe(500); + expect((await res.json()).error).toMatch(/Failed to read server list/); + } finally { + await stop(h); + } + }); + }); }); diff --git a/clients/web/src/test/integration/mcp/remote/transport.test.ts b/clients/web/src/test/integration/mcp/remote/transport.test.ts index 1f8c2d44..35f9ec70 100644 --- a/clients/web/src/test/integration/mcp/remote/transport.test.ts +++ b/clients/web/src/test/integration/mcp/remote/transport.test.ts @@ -1340,7 +1340,7 @@ describe("Remote transport e2e", () => { expect((await res.json()).error).toMatch(/Failed to create transport:/); }); - it("/api/mcp/send returns 500 when transport is dead", async () => { + it("/api/mcp/send returns transport_error when transport is dead", async () => { mcpHttpServer = createTestServerHttp({ serverInfo: createTestServerInfo(), tools: [createEchoTool()], @@ -1387,9 +1387,11 @@ describe("Remote transport e2e", () => { message: { jsonrpc: "2.0", id: 99, method: "ping" }, }), }); - // Either the dead-transport short-circuit (500) or a downstream send - // failure (500) — either way, the path is covered. - expect(sendRes.status).toBeGreaterThanOrEqual(500); + expect(sendRes.status).toBe(200); + expect(await sendRes.json()).toMatchObject({ + ok: false, + kind: "transport_error", + }); }); it("/api/log accepts non-JSON body silently via the catch fallback", async () => { diff --git a/clients/web/src/test/integration/mcp/test-server-protected-resource.test.ts b/clients/web/src/test/integration/mcp/test-server-protected-resource.test.ts index f9dd93c3..487ae29d 100644 --- a/clients/web/src/test/integration/mcp/test-server-protected-resource.test.ts +++ b/clients/web/src/test/integration/mcp/test-server-protected-resource.test.ts @@ -7,6 +7,7 @@ import { loadConfig, resolveConfig, ExternalAccessTokenValidator, + extractScopesFromJwtPayload, TestServerHttp, createExternalResourceOAuthTestServerConfig, } from "@modelcontextprotocol/inspector-test-server"; @@ -59,6 +60,19 @@ describe("protected-resource OAuth config", () => { }); describe("ExternalAccessTokenValidator", () => { + it("extracts scopes from JWT payload claims", () => { + expect(extractScopesFromJwtPayload({ scope: "mcp tools:read" })).toEqual([ + "mcp", + "tools:read", + ]); + expect( + extractScopesFromJwtPayload({ scp: ["tools:read", "env:read"] }), + ).toEqual(["tools:read", "env:read"]); + expect(extractScopesFromJwtPayload({ scopes: ["tools:read"] })).toEqual([ + "tools:read", + ]); + }); + it("accepts JWTs signed by the AS and rejects wrong issuer", async () => { const issuer = "https://as.example"; const jwksUri = "https://as.example/jwks"; diff --git a/clients/web/src/theme/Alert.ts b/clients/web/src/theme/Alert.ts index 19148629..d2cad987 100644 --- a/clients/web/src/theme/Alert.ts +++ b/clients/web/src/theme/Alert.ts @@ -4,4 +4,17 @@ export const ThemeAlert = Alert.extend({ defaultProps: { radius: "md", }, + styles: (_theme, props) => { + // Re-auth banner: body-colored surface with a red hairline border, used + // for the persistent mid-session re-authentication banner. + if (props.variant === "reauth") { + return { + root: { + backgroundColor: "var(--mantine-color-body)", + border: "1px solid var(--mantine-color-red-3)", + }, + }; + } + return { root: {} }; + }, }); diff --git a/clients/web/src/utils/browserTabVisibility.test.ts b/clients/web/src/utils/browserTabVisibility.test.ts new file mode 100644 index 00000000..d852327a --- /dev/null +++ b/clients/web/src/utils/browserTabVisibility.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + isBrowserTabVisible, + onBrowserTabVisible, +} from "./browserTabVisibility.js"; + +describe("browserTabVisibility", () => { + beforeEach(() => { + vi.stubGlobal("document", { + visibilityState: "visible", + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("isBrowserTabVisible reflects document.visibilityState", () => { + expect(isBrowserTabVisible()).toBe(true); + (document as { visibilityState: string }).visibilityState = "hidden"; + expect(isBrowserTabVisible()).toBe(false); + }); + + it("onBrowserTabVisible invokes callback when becoming visible", () => { + let handler: (() => void) | undefined; + vi.mocked(document.addEventListener).mockImplementation((_event, fn) => { + handler = fn as () => void; + }); + + const callback = vi.fn(); + onBrowserTabVisible(callback); + expect(handler).toBeDefined(); + + (document as { visibilityState: string }).visibilityState = "hidden"; + handler!(); + expect(callback).not.toHaveBeenCalled(); + + (document as { visibilityState: string }).visibilityState = "visible"; + handler!(); + expect(callback).toHaveBeenCalledOnce(); + }); + + it("returned unsubscribe removes the visibilitychange listener", () => { + let handler: (() => void) | undefined; + vi.mocked(document.addEventListener).mockImplementation((_event, fn) => { + handler = fn as () => void; + }); + + const callback = vi.fn(); + const unsubscribe = onBrowserTabVisible(callback); + expect(handler).toBeDefined(); + + unsubscribe(); + expect(document.removeEventListener).toHaveBeenCalledWith( + "visibilitychange", + handler, + ); + + // After unsubscribing the stored handler must no longer fire the callback + // through the (now removed) listener path. + vi.mocked(document.addEventListener).mockClear(); + expect(document.addEventListener).not.toHaveBeenCalled(); + }); + + it("integrates with a real document: dispatch, hidden/visible, and unsubscribe", () => { + vi.unstubAllGlobals(); + const original = Object.getOwnPropertyDescriptor( + Document.prototype, + "visibilityState", + ); + const setVisibility = (value: string): void => { + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => value, + }); + }; + + try { + setVisibility("visible"); + expect(isBrowserTabVisible()).toBe(true); + + const callback = vi.fn(); + const unsubscribe = onBrowserTabVisible(callback); + + // Hidden branch: listener fires but callback is not invoked. + setVisibility("hidden"); + document.dispatchEvent(new Event("visibilitychange")); + expect(callback).not.toHaveBeenCalled(); + expect(isBrowserTabVisible()).toBe(false); + + // Visible branch: callback invoked. + setVisibility("visible"); + document.dispatchEvent(new Event("visibilitychange")); + expect(callback).toHaveBeenCalledOnce(); + + // Unsubscribe: further events must not re-invoke the callback. + unsubscribe(); + document.dispatchEvent(new Event("visibilitychange")); + expect(callback).toHaveBeenCalledOnce(); + } finally { + if (original) { + Object.defineProperty(document, "visibilityState", original); + } else { + delete (document as { visibilityState?: string }).visibilityState; + } + } + }); + + it("no-ops safely when document is undefined (SSR guard)", () => { + vi.stubGlobal("document", undefined); + + expect(isBrowserTabVisible()).toBe(false); + + const callback = vi.fn(); + const unsubscribe = onBrowserTabVisible(callback); + expect(typeof unsubscribe).toBe("function"); + // The returned no-op must be callable without throwing. + expect(() => unsubscribe()).not.toThrow(); + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/clients/web/src/utils/browserTabVisibility.ts b/clients/web/src/utils/browserTabVisibility.ts new file mode 100644 index 00000000..4e5fae1d --- /dev/null +++ b/clients/web/src/utils/browserTabVisibility.ts @@ -0,0 +1,20 @@ +/** True when this browser tab is the foreground tab (Page Visibility API). */ +export function isBrowserTabVisible(): boolean { + return ( + typeof document !== "undefined" && document.visibilityState === "visible" + ); +} + +/** Subscribe to this browser tab becoming visible. Returns an unsubscribe function. */ +export function onBrowserTabVisible(callback: () => void): () => void { + if (typeof document === "undefined") { + return () => {}; + } + const handler = (): void => { + if (document.visibilityState === "visible") { + callback(); + } + }; + document.addEventListener("visibilitychange", handler); + return () => document.removeEventListener("visibilitychange", handler); +} diff --git a/clients/web/src/utils/inspectorTabs.test.ts b/clients/web/src/utils/inspectorTabs.test.ts new file mode 100644 index 00000000..ebd7b696 --- /dev/null +++ b/clients/web/src/utils/inspectorTabs.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { + INSPECTOR_SERVERS_TAB, + INSPECTOR_TAB_IDS, + isInspectorTabId, +} from "./inspectorTabs"; + +describe("inspectorTabs", () => { + it("names the Servers tab, which is not a liftable inspector tab", () => { + expect(INSPECTOR_SERVERS_TAB).toBe("Servers"); + expect(INSPECTOR_TAB_IDS).not.toContain(INSPECTOR_SERVERS_TAB); + }); + + it("enumerates the liftable inspector tabs", () => { + expect(INSPECTOR_TAB_IDS).toEqual([ + "Apps", + "Tools", + "Prompts", + "Resources", + "Tasks", + "Logs", + "History", + "Network", + ]); + }); + + it("isInspectorTabId returns true for every enumerated tab", () => { + for (const tab of INSPECTOR_TAB_IDS) { + expect(isInspectorTabId(tab)).toBe(true); + } + }); + + it("isInspectorTabId returns false for non-inspector tab values", () => { + expect(isInspectorTabId(INSPECTOR_SERVERS_TAB)).toBe(false); + expect(isInspectorTabId("")).toBe(false); + expect(isInspectorTabId("Bogus")).toBe(false); + }); +}); diff --git a/clients/web/src/utils/inspectorTabs.ts b/clients/web/src/utils/inspectorTabs.ts new file mode 100644 index 00000000..0cc65de5 --- /dev/null +++ b/clients/web/src/utils/inspectorTabs.ts @@ -0,0 +1,24 @@ +/** + * Inspector main-view tab identifiers. Match labels used in ViewHeader / + * InspectorView (`"Tools"`, `"Resources"`, …). + */ + +export const INSPECTOR_SERVERS_TAB = "Servers"; + +/** Tabs with liftable `*UiState` in App.tsx (Servers has no ui snapshot). */ +export const INSPECTOR_TAB_IDS = [ + "Apps", + "Tools", + "Prompts", + "Resources", + "Tasks", + "Logs", + "History", + "Network", +] as const; + +export type InspectorTabId = (typeof INSPECTOR_TAB_IDS)[number]; + +export function isInspectorTabId(value: string): value is InspectorTabId { + return (INSPECTOR_TAB_IDS as readonly string[]).includes(value); +} diff --git a/clients/web/src/utils/oauthResume.test.ts b/clients/web/src/utils/oauthResume.test.ts new file mode 100644 index 00000000..43969179 --- /dev/null +++ b/clients/web/src/utils/oauthResume.test.ts @@ -0,0 +1,495 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + applyOAuthResumeUi, + buildTabUiSnapshot, + clearOAuthResumeSnapshot, + consumeOAuthResumeSnapshot, + oauthResumeInsufficientScopeMessage, + oauthResumeToastMessage, + OAUTH_PENDING_SERVER_KEY, + OAUTH_RESUME_KEY, + readOAuthResumeSnapshot, + restoreTabUiFromSnapshot, + writeOAuthResumeSnapshot, + type OAuthResumeSnapshot, +} from "./oauthResume.js"; +import { + EMPTY_TOOLS_UI, + EMPTY_PROMPTS_UI, + EMPTY_RESOURCES_UI, + EMPTY_APPS_UI, + EMPTY_TASKS_UI, + EMPTY_LOGS_UI, + EMPTY_HISTORY_UI, + EMPTY_NETWORK_UI, +} from "../components/screens/screenUiState.js"; + +describe("oauthResume", () => { + const storage = new Map(); + + beforeEach(() => { + storage.clear(); + vi.stubGlobal("sessionStorage", { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + removeItem: (key: string) => { + storage.delete(key); + }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("consumeOAuthResumeSnapshot reads once then clears storage", () => { + const snapshot: OAuthResumeSnapshot = { + version: 1, + serverId: "srv-1", + activeTab: "Tools", + authKind: "reauth", + tabUi: {}, + }; + writeOAuthResumeSnapshot(snapshot); + expect(consumeOAuthResumeSnapshot()).toEqual(snapshot); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + expect(consumeOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("clearOAuthResumeSnapshot removes pending redirect state (explicit disconnect)", () => { + writeOAuthResumeSnapshot({ + version: 1, + serverId: "srv-1", + activeTab: "Tools", + authKind: "reauth", + tabUi: {}, + }); + clearOAuthResumeSnapshot(); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + expect(consumeOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("round-trips OAuthResumeSnapshot", () => { + const snapshot: OAuthResumeSnapshot = { + version: 1, + serverId: "srv-1", + activeTab: "Tools", + authKind: "step_up", + tabUi: { + Tools: { + ...EMPTY_TOOLS_UI, + selectedToolName: "echo", + formValues: { message: "hi" }, + }, + }, + remoteSessionId: "remote-abc", + }; + writeOAuthResumeSnapshot(snapshot); + expect(readOAuthResumeSnapshot()).toEqual(snapshot); + expect(storage.get(OAUTH_PENDING_SERVER_KEY)).toBeUndefined(); + clearOAuthResumeSnapshot(); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("builds and restores tab ui snapshots", () => { + const toolsUi = { + ...EMPTY_TOOLS_UI, + selectedToolName: "get_temp", + formValues: { city: "NYC" }, + }; + const tabUi = buildTabUiSnapshot({ + toolsUi, + promptsUi: EMPTY_PROMPTS_UI, + resourcesUi: EMPTY_RESOURCES_UI, + appsUi: EMPTY_APPS_UI, + tasksUi: EMPTY_TASKS_UI, + logsUi: EMPTY_LOGS_UI, + historyUi: EMPTY_HISTORY_UI, + networkUi: EMPTY_NETWORK_UI, + }); + const setToolsUi = vi.fn(); + restoreTabUiFromSnapshot(tabUi, { + setToolsUi, + setPromptsUi: vi.fn(), + setResourcesUi: vi.fn(), + setAppsUi: vi.fn(), + setTasksUi: vi.fn(), + setLogsUi: vi.fn(), + setHistoryUi: vi.fn(), + setNetworkUi: vi.fn(), + }); + expect(setToolsUi).toHaveBeenCalledWith(toolsUi); + }); + + it("falls back to legacy pending server key", () => { + storage.set(OAUTH_PENDING_SERVER_KEY, "legacy-srv"); + const snapshot = readOAuthResumeSnapshot(); + expect(snapshot?.serverId).toBe("legacy-srv"); + expect(snapshot?.activeTab).toBe("Servers"); + expect(snapshot?.authKind).toBe("reauth"); + }); + + it("returns toast copy by auth kind", () => { + expect(oauthResumeToastMessage("step_up", { recoverySource: "tool" })).toBe( + "Step-up authorization succeeded. Retry your action.", + ); + expect(oauthResumeToastMessage("step_up")).toBe( + "Step-up authorization succeeded.", + ); + expect(oauthResumeToastMessage("reauth", { recoverySource: "tool" })).toBe( + "Authentication succeeded. Retry your action.", + ); + expect(oauthResumeToastMessage("reauth")).toBe("Authentication succeeded."); + }); + + it("returns insufficient-scope message with tool context", () => { + expect( + oauthResumeInsufficientScopeMessage({ + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + context: { toolName: "get_temp" }, + }), + ).toMatch(/get_temp/); + }); + + it("round-trips authChallenge on step-up snapshot", () => { + const challenge = { + reason: "insufficient_scope" as const, + requiredScopes: ["weather:read"], + context: { toolName: "get_temp" }, + }; + const snapshot: OAuthResumeSnapshot = { + version: 1, + serverId: "srv-1", + activeTab: "Tools", + authKind: "step_up", + tabUi: {}, + authChallenge: challenge, + }; + writeOAuthResumeSnapshot(snapshot); + expect(readOAuthResumeSnapshot()?.authChallenge).toEqual(challenge); + }); + + it("rejects snapshots with invalid tabUi keys", () => { + writeOAuthResumeSnapshot({ + version: 1, + serverId: "srv-1", + activeTab: "Tools", + authKind: "reauth", + tabUi: { NotATab: {} }, + } as OAuthResumeSnapshot); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("applyOAuthResumeUi restores tab ui, active tab, and clears in-flight panels", () => { + const toolsUi = { + ...EMPTY_TOOLS_UI, + selectedToolName: "get_temp", + formValues: { city: "NYC" }, + }; + const snapshot: OAuthResumeSnapshot = { + version: 1, + serverId: "srv-1", + activeTab: "Tools", + authKind: "step_up", + tabUi: { Tools: toolsUi }, + }; + const setToolsUi = vi.fn(); + const setActiveTab = vi.fn(); + const clearToolCallState = vi.fn(); + const clearGetPromptState = vi.fn(); + const clearReadResourceState = vi.fn(); + + applyOAuthResumeUi(snapshot, { + setToolsUi, + setPromptsUi: vi.fn(), + setResourcesUi: vi.fn(), + setAppsUi: vi.fn(), + setTasksUi: vi.fn(), + setLogsUi: vi.fn(), + setHistoryUi: vi.fn(), + setNetworkUi: vi.fn(), + setActiveTab, + clearToolCallState, + clearGetPromptState, + clearReadResourceState, + }); + + expect(setToolsUi).toHaveBeenCalledWith(toolsUi); + expect(setActiveTab).toHaveBeenCalledWith("Tools"); + expect(clearToolCallState).toHaveBeenCalledOnce(); + expect(clearGetPromptState).toHaveBeenCalledOnce(); + expect(clearReadResourceState).toHaveBeenCalledOnce(); + }); + + it("readOAuthResumeSnapshot returns undefined for a non-JSON string", () => { + storage.set(OAUTH_RESUME_KEY, "not-json{"); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("readOAuthResumeSnapshot returns undefined when parsed JSON is null", () => { + storage.set(OAUTH_RESUME_KEY, "null"); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("readOAuthResumeSnapshot rejects a wrong version", () => { + storage.set( + OAUTH_RESUME_KEY, + JSON.stringify({ + version: 2, + serverId: "srv-1", + activeTab: "Tools", + authKind: "reauth", + tabUi: {}, + }), + ); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("readOAuthResumeSnapshot rejects a non-string serverId", () => { + storage.set( + OAUTH_RESUME_KEY, + JSON.stringify({ + version: 1, + serverId: 42, + activeTab: "Tools", + authKind: "reauth", + tabUi: {}, + }), + ); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("readOAuthResumeSnapshot rejects an unknown authKind", () => { + storage.set( + OAUTH_RESUME_KEY, + JSON.stringify({ + version: 1, + serverId: "srv-1", + activeTab: "Tools", + authKind: "bogus", + tabUi: {}, + }), + ); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("readOAuthResumeSnapshot rejects a non-string activeTab", () => { + storage.set( + OAUTH_RESUME_KEY, + JSON.stringify({ + version: 1, + serverId: "srv-1", + activeTab: 7, + authKind: "reauth", + tabUi: {}, + }), + ); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("readOAuthResumeSnapshot accepts a snapshot with tabUi absent", () => { + storage.set( + OAUTH_RESUME_KEY, + JSON.stringify({ + version: 1, + serverId: "srv-1", + activeTab: "Tools", + authKind: "reauth", + }), + ); + const snapshot = readOAuthResumeSnapshot(); + expect(snapshot?.serverId).toBe("srv-1"); + expect(snapshot?.tabUi).toBeUndefined(); + }); + + it("readOAuthResumeSnapshot rejects non-object tabUi (string, array, null)", () => { + for (const tabUi of ['"nope"', "[]", "null"]) { + storage.set( + OAUTH_RESUME_KEY, + `{"version":1,"serverId":"srv-1","activeTab":"Tools","authKind":"reauth","tabUi":${tabUi}}`, + ); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + } + }); + + it("writeOAuthResumeSnapshot swallows setItem failures", () => { + vi.stubGlobal("sessionStorage", { + getItem: () => null, + setItem: () => { + throw new Error("quota exceeded"); + }, + removeItem: () => {}, + }); + expect(() => + writeOAuthResumeSnapshot({ + version: 1, + serverId: "srv-1", + activeTab: "Tools", + authKind: "reauth", + tabUi: {}, + }), + ).not.toThrow(); + }); + + it("clearOAuthResumeSnapshot swallows removeItem failures", () => { + vi.stubGlobal("sessionStorage", { + getItem: () => null, + setItem: () => {}, + removeItem: () => { + throw new Error("blocked"); + }, + }); + expect(() => clearOAuthResumeSnapshot()).not.toThrow(); + }); + + it("legacy fallback returns undefined when the pending-key read throws", () => { + vi.stubGlobal("sessionStorage", { + getItem: (key: string) => { + if (key === OAUTH_PENDING_SERVER_KEY) { + throw new Error("blocked"); + } + return null; + }, + setItem: () => {}, + removeItem: () => {}, + }); + expect(readOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("legacy fallback returns undefined when no pending server is stored", () => { + expect(readOAuthResumeSnapshot()).toBeUndefined(); + }); + + describe("without a window global", () => { + beforeEach(() => { + vi.stubGlobal("window", undefined); + }); + + it("writeOAuthResumeSnapshot is a no-op", () => { + expect(() => + writeOAuthResumeSnapshot({ + version: 1, + serverId: "srv-1", + activeTab: "Tools", + authKind: "reauth", + tabUi: {}, + }), + ).not.toThrow(); + expect(storage.size).toBe(0); + }); + + it("readOAuthResumeSnapshot returns undefined", () => { + expect(readOAuthResumeSnapshot()).toBeUndefined(); + }); + + it("clearOAuthResumeSnapshot is a no-op", () => { + expect(() => clearOAuthResumeSnapshot()).not.toThrow(); + }); + }); + + it("restoreTabUiFromSnapshot returns early when tabUi is undefined", () => { + const setToolsUi = vi.fn(); + restoreTabUiFromSnapshot(undefined, { + setToolsUi, + setPromptsUi: vi.fn(), + setResourcesUi: vi.fn(), + setAppsUi: vi.fn(), + setTasksUi: vi.fn(), + setLogsUi: vi.fn(), + setHistoryUi: vi.fn(), + setNetworkUi: vi.fn(), + }); + expect(setToolsUi).not.toHaveBeenCalled(); + }); + + it("restoreTabUiFromSnapshot skips keys that are not inspector tabs", () => { + const setters = { + setToolsUi: vi.fn(), + setPromptsUi: vi.fn(), + setResourcesUi: vi.fn(), + setAppsUi: vi.fn(), + setTasksUi: vi.fn(), + setLogsUi: vi.fn(), + setHistoryUi: vi.fn(), + setNetworkUi: vi.fn(), + }; + restoreTabUiFromSnapshot( + { NotATab: {} } as Record, + setters, + ); + for (const setter of Object.values(setters)) { + expect(setter).not.toHaveBeenCalled(); + } + }); + + it("restoreTabUiFromSnapshot restores every tab with a present value", () => { + const setters = { + setToolsUi: vi.fn(), + setPromptsUi: vi.fn(), + setResourcesUi: vi.fn(), + setAppsUi: vi.fn(), + setTasksUi: vi.fn(), + setLogsUi: vi.fn(), + setHistoryUi: vi.fn(), + setNetworkUi: vi.fn(), + }; + restoreTabUiFromSnapshot( + { + Tools: EMPTY_TOOLS_UI, + Prompts: EMPTY_PROMPTS_UI, + Resources: EMPTY_RESOURCES_UI, + Apps: EMPTY_APPS_UI, + Tasks: EMPTY_TASKS_UI, + Logs: EMPTY_LOGS_UI, + History: EMPTY_HISTORY_UI, + Network: EMPTY_NETWORK_UI, + }, + setters, + ); + expect(setters.setToolsUi).toHaveBeenCalledWith(EMPTY_TOOLS_UI); + expect(setters.setPromptsUi).toHaveBeenCalledWith(EMPTY_PROMPTS_UI); + expect(setters.setResourcesUi).toHaveBeenCalledWith(EMPTY_RESOURCES_UI); + expect(setters.setAppsUi).toHaveBeenCalledWith(EMPTY_APPS_UI); + expect(setters.setTasksUi).toHaveBeenCalledWith(EMPTY_TASKS_UI); + expect(setters.setLogsUi).toHaveBeenCalledWith(EMPTY_LOGS_UI); + expect(setters.setHistoryUi).toHaveBeenCalledWith(EMPTY_HISTORY_UI); + expect(setters.setNetworkUi).toHaveBeenCalledWith(EMPTY_NETWORK_UI); + }); + + it("restoreTabUiFromSnapshot falls back to EMPTY state for undefined tab values", () => { + const setters = { + setToolsUi: vi.fn(), + setPromptsUi: vi.fn(), + setResourcesUi: vi.fn(), + setAppsUi: vi.fn(), + setTasksUi: vi.fn(), + setLogsUi: vi.fn(), + setHistoryUi: vi.fn(), + setNetworkUi: vi.fn(), + }; + restoreTabUiFromSnapshot( + { + Tools: undefined, + Prompts: undefined, + Resources: undefined, + Apps: undefined, + Tasks: undefined, + Logs: undefined, + History: undefined, + Network: undefined, + }, + setters, + ); + expect(setters.setToolsUi).toHaveBeenCalledWith(EMPTY_TOOLS_UI); + expect(setters.setPromptsUi).toHaveBeenCalledWith(EMPTY_PROMPTS_UI); + expect(setters.setResourcesUi).toHaveBeenCalledWith(EMPTY_RESOURCES_UI); + expect(setters.setAppsUi).toHaveBeenCalledWith(EMPTY_APPS_UI); + expect(setters.setTasksUi).toHaveBeenCalledWith(EMPTY_TASKS_UI); + expect(setters.setLogsUi).toHaveBeenCalledWith(EMPTY_LOGS_UI); + expect(setters.setHistoryUi).toHaveBeenCalledWith(EMPTY_HISTORY_UI); + expect(setters.setNetworkUi).toHaveBeenCalledWith(EMPTY_NETWORK_UI); + }); +}); diff --git a/clients/web/src/utils/oauthResume.ts b/clients/web/src/utils/oauthResume.ts new file mode 100644 index 00000000..81167ffa --- /dev/null +++ b/clients/web/src/utils/oauthResume.ts @@ -0,0 +1,278 @@ +/** + * Persist inspector shell state across full-page OAuth redirects. + * Serializes only liftable `*UiState` shells — not message logs, fetch bodies, + * tool results, or managed primitive lists. + */ + +import { + EMPTY_APPS_UI, + EMPTY_HISTORY_UI, + EMPTY_LOGS_UI, + EMPTY_NETWORK_UI, + EMPTY_PROMPTS_UI, + EMPTY_RESOURCES_UI, + EMPTY_TASKS_UI, + EMPTY_TOOLS_UI, +} from "../components/screens/screenUiState.js"; +import type { AppsUiState } from "../components/screens/AppsScreen/AppsScreen.js"; +import type { HistoryUiState } from "../components/screens/HistoryScreen/HistoryScreen.js"; +import type { LogsUiState } from "../components/screens/LoggingScreen/LoggingScreen.js"; +import type { NetworkUiState } from "../components/screens/NetworkScreen/NetworkScreen.js"; +import type { PromptsUiState } from "../components/screens/PromptsScreen/PromptsScreen.js"; +import type { ResourcesUiState } from "../components/screens/ResourcesScreen/ResourcesScreen.js"; +import type { TasksUiState } from "../components/screens/TasksScreen/TasksScreen.js"; +import type { ToolsUiState } from "../components/screens/ToolsScreen/ToolsScreen.js"; +import { + INSPECTOR_SERVERS_TAB, + type InspectorTabId, + isInspectorTabId, +} from "./inspectorTabs.js"; +import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; +import { + oauthResumeSuccessMessage, + stepUpInsufficientScopeMessage, + type OAuthRecoverySource, +} from "@inspector/core/auth/oauthUx.js"; +import { OAUTH_PENDING_SERVER_KEY } from "./oauthFlow.js"; + +export const OAUTH_RESUME_KEY = "mcp-inspector:oauth-resume"; + +export { OAUTH_PENDING_SERVER_KEY }; + +export type OAuthResumeAuthKind = "step_up" | "reauth"; + +export interface OAuthResumeSnapshot { + version: 1; + serverId: string; + activeTab: string; + authKind: OAuthResumeAuthKind; + /** + * Per-tab lifted UI state (`*UiState` only). Keys are {@link InspectorTabId}. + */ + tabUi: Partial>; + /** Hono remote session id for auth-state push after callback. */ + remoteSessionId?: string; + /** Step-up challenge at redirect time; used to verify scope satisfaction after callback. */ + authChallenge?: AuthChallenge; + /** Command-scoped recovery source when redirect was triggered by a user action. */ + recoverySource?: OAuthRecoverySource; +} + +export interface LiftedTabUiState { + toolsUi: ToolsUiState; + promptsUi: PromptsUiState; + resourcesUi: ResourcesUiState; + appsUi: AppsUiState; + tasksUi: TasksUiState; + logsUi: LogsUiState; + historyUi: HistoryUiState; + networkUi: NetworkUiState; +} + +export interface TabUiSetters { + setToolsUi: (next: ToolsUiState) => void; + setPromptsUi: (next: PromptsUiState) => void; + setResourcesUi: (next: ResourcesUiState) => void; + setAppsUi: (next: AppsUiState) => void; + setTasksUi: (next: TasksUiState) => void; + setLogsUi: (next: LogsUiState) => void; + setHistoryUi: (next: HistoryUiState) => void; + setNetworkUi: (next: NetworkUiState) => void; +} + +export function buildTabUiSnapshot( + state: LiftedTabUiState, +): Partial> { + return { + Apps: state.appsUi, + Tools: state.toolsUi, + Prompts: state.promptsUi, + Resources: state.resourcesUi, + Tasks: state.tasksUi, + Logs: state.logsUi, + History: state.historyUi, + Network: state.networkUi, + }; +} + +export function restoreTabUiFromSnapshot( + tabUi: Partial> | undefined, + setters: TabUiSetters, +): void { + if (!tabUi) { + return; + } + for (const tabId of Object.keys(tabUi) as InspectorTabId[]) { + if (!isInspectorTabId(tabId)) { + continue; + } + const value = tabUi[tabId]; + switch (tabId) { + case "Tools": + setters.setToolsUi( + (value as ToolsUiState | undefined) ?? EMPTY_TOOLS_UI, + ); + break; + case "Prompts": + setters.setPromptsUi( + (value as PromptsUiState | undefined) ?? EMPTY_PROMPTS_UI, + ); + break; + case "Resources": + setters.setResourcesUi( + (value as ResourcesUiState | undefined) ?? EMPTY_RESOURCES_UI, + ); + break; + case "Apps": + setters.setAppsUi((value as AppsUiState | undefined) ?? EMPTY_APPS_UI); + break; + case "Tasks": + setters.setTasksUi( + (value as TasksUiState | undefined) ?? EMPTY_TASKS_UI, + ); + break; + case "Logs": + setters.setLogsUi((value as LogsUiState | undefined) ?? EMPTY_LOGS_UI); + break; + case "History": + setters.setHistoryUi( + (value as HistoryUiState | undefined) ?? EMPTY_HISTORY_UI, + ); + break; + case "Network": + setters.setNetworkUi( + (value as NetworkUiState | undefined) ?? EMPTY_NETWORK_UI, + ); + break; + default: { + const _exhaustive: never = tabId; + void _exhaustive; + } + } + } +} + +export function writeOAuthResumeSnapshot(snapshot: OAuthResumeSnapshot): void { + if (typeof window === "undefined") { + return; + } + try { + window.sessionStorage.setItem(OAUTH_RESUME_KEY, JSON.stringify(snapshot)); + } catch { + // Best-effort — privacy mode / quota. + } +} + +export function readOAuthResumeSnapshot(): OAuthResumeSnapshot | undefined { + if (typeof window === "undefined") { + return undefined; + } + try { + const raw = window.sessionStorage.getItem(OAUTH_RESUME_KEY); + if (!raw) { + return readLegacyPendingServerSnapshot(); + } + const parsed = JSON.parse(raw) as OAuthResumeSnapshot; + if ( + parsed?.version !== 1 || + typeof parsed.serverId !== "string" || + !isOAuthResumeAuthKind(parsed.authKind) || + typeof parsed.activeTab !== "string" || + !isValidTabUiSnapshot(parsed.tabUi) + ) { + return undefined; + } + return parsed; + } catch { + return undefined; + } +} + +/** Read the pending snapshot and remove it from storage (one-shot). */ +export function consumeOAuthResumeSnapshot(): OAuthResumeSnapshot | undefined { + const snapshot = readOAuthResumeSnapshot(); + if (snapshot) { + clearOAuthResumeSnapshot(); + } + return snapshot; +} + +function readLegacyPendingServerSnapshot(): OAuthResumeSnapshot | undefined { + try { + const serverId = window.sessionStorage.getItem(OAUTH_PENDING_SERVER_KEY); + if (!serverId) { + return undefined; + } + return { + version: 1, + serverId, + activeTab: INSPECTOR_SERVERS_TAB, + authKind: "reauth", + tabUi: {}, + }; + } catch { + return undefined; + } +} + +export function clearOAuthResumeSnapshot(): void { + if (typeof window === "undefined") { + return; + } + try { + window.sessionStorage.removeItem(OAUTH_RESUME_KEY); + window.sessionStorage.removeItem(OAUTH_PENDING_SERVER_KEY); + } catch { + // ignore + } +} + +export function oauthResumeToastMessage( + authKind: OAuthResumeAuthKind, + options?: { recoverySource?: OAuthRecoverySource }, +): string { + return oauthResumeSuccessMessage(authKind, options); +} + +/** Post-callback copy when step-up OAuth completed but scopes still do not satisfy the challenge. */ +export function oauthResumeInsufficientScopeMessage( + challenge: AuthChallenge, +): string { + return stepUpInsufficientScopeMessage(challenge); +} + +function isOAuthResumeAuthKind(value: unknown): value is OAuthResumeAuthKind { + return value === "step_up" || value === "reauth"; +} + +function isValidTabUiSnapshot( + tabUi: unknown, +): tabUi is Partial> { + if (tabUi === undefined) { + return true; + } + if (typeof tabUi !== "object" || tabUi === null || Array.isArray(tabUi)) { + return false; + } + return Object.keys(tabUi).every((key) => isInspectorTabId(key)); +} + +/** Setters used when restoring App shell state after `/oauth/callback`. */ +export interface OAuthResumeUiSetters extends TabUiSetters { + setActiveTab: (tab: string) => void; + clearToolCallState: () => void; + clearGetPromptState: () => void; + clearReadResourceState: () => void; +} + +/** Restore tab selection, per-tab UI, and clear in-flight result panels. One-shot: callers must not invoke twice for the same redirect. */ +export function applyOAuthResumeUi( + snapshot: OAuthResumeSnapshot, + setters: OAuthResumeUiSetters, +): void { + restoreTabUiFromSnapshot(snapshot.tabUi, setters); + setters.setActiveTab(snapshot.activeTab); + setters.clearToolCallState(); + setters.clearGetPromptState(); + setters.clearReadResourceState(); +} diff --git a/clients/web/src/utils/oauthUx.test.ts b/clients/web/src/utils/oauthUx.test.ts new file mode 100644 index 00000000..e6e6f136 --- /dev/null +++ b/clients/web/src/utils/oauthUx.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { + authRecoveryRestoredMessage, + isReAuthBannerReason, + oauthPreRedirectToastCopy, + oauthResumeSuccessMessage, + reAuthBannerMessage, +} from "./oauthUx.js"; + +describe("oauthUx", () => { + it("returns pre-redirect toast for standard OAuth reauth", () => { + expect(oauthPreRedirectToastCopy("reauth", { serverName: "srv" })).toEqual({ + title: 'Session expired for "srv"', + message: "Session expired, re-authenticating…", + }); + }); + + it("returns no pre-redirect toast for connect-time OAuth", () => { + expect( + oauthPreRedirectToastCopy("reauth", { + serverName: "srv", + context: "connect", + }), + ).toBeUndefined(); + expect( + oauthPreRedirectToastCopy("reauth", { + serverName: "srv", + enterpriseManaged: true, + context: "connect", + }), + ).toBeUndefined(); + }); + + it("returns pre-redirect toast for EMA reauth", () => { + expect( + oauthPreRedirectToastCopy("reauth", { + serverName: "srv", + enterpriseManaged: true, + }), + ).toEqual({ + title: 'Re-authenticating "srv"', + message: "Re-authenticating…", + }); + }); + + it("returns pre-redirect toast for step-up", () => { + expect(oauthPreRedirectToastCopy("step_up", { serverName: "srv" })).toEqual( + { + title: 'Step-up authorization for "srv"', + message: "Redirecting to authorize additional permissions…", + }, + ); + }); + + it("identifies re-auth banner challenge reasons", () => { + expect(isReAuthBannerReason("unauthorized")).toBe(true); + expect(isReAuthBannerReason("token_expired")).toBe(true); + expect(isReAuthBannerReason("invalid_token")).toBe(true); + expect(isReAuthBannerReason("insufficient_scope")).toBe(false); + }); + + it("builds re-auth banner message", () => { + expect( + reAuthBannerMessage({ serverName: "demo", detail: "Sign in again." }), + ).toBe('Authentication for "demo" needs attention. Sign in again.'); + }); + + it("returns success toast copy only for action-triggered recovery", () => { + expect( + oauthResumeSuccessMessage("reauth", { recoverySource: "tool" }), + ).toMatch(/Retry your action/); + expect(oauthResumeSuccessMessage("reauth")).not.toMatch( + /Retry your action/, + ); + expect(authRecoveryRestoredMessage({ recoverySource: "prompt" })).toMatch( + /Retry your action/, + ); + expect(authRecoveryRestoredMessage()).not.toMatch(/Retry your action/); + }); +}); diff --git a/clients/web/src/utils/oauthUx.ts b/clients/web/src/utils/oauthUx.ts new file mode 100644 index 00000000..4485c48a --- /dev/null +++ b/clients/web/src/utils/oauthUx.ts @@ -0,0 +1,12 @@ +export { + authRecoveryRestoredMessage, + isActionTriggeredOAuthRecovery, + oauthPreRedirectToastCopy, + oauthResumeAbandonedMessage, + oauthResumeSuccessMessage, + isReAuthBannerReason, + reAuthBannerMessage, + type OAuthInteractiveAuthKind, + type OAuthPreRedirectContext, + type OAuthRecoverySource, +} from "@inspector/core/auth/oauthUx.js"; diff --git a/clients/web/src/utils/pendingReauth.test.ts b/clients/web/src/utils/pendingReauth.test.ts new file mode 100644 index 00000000..94eadcb5 --- /dev/null +++ b/clients/web/src/utils/pendingReauth.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from "vitest"; +import type { PendingReauth } from "./pendingReauth.js"; + +describe("PendingReauth", () => { + it("carries step-up source through deferred resume", () => { + const pending: PendingReauth = { + serverId: "srv-1", + challenge: { + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }, + authorizationUrl: new URL("https://as.example/authorize"), + authKind: "step_up", + source: "tool", + }; + expect(pending.source).toBe("tool"); + }); +}); diff --git a/clients/web/src/utils/pendingReauth.ts b/clients/web/src/utils/pendingReauth.ts new file mode 100644 index 00000000..8e839c2f --- /dev/null +++ b/clients/web/src/utils/pendingReauth.ts @@ -0,0 +1,20 @@ +import type { AuthChallenge } from "@inspector/core/auth/challenge.js"; +import type { OAuthRecoverySource } from "@inspector/core/auth/oauthUx.js"; +import type { OAuthResumeAuthKind } from "./oauthResume.js"; + +/** Origin of a deferred or resumed auth recovery flow (matches web `StepUpSource`). */ +export type PendingReauthSource = OAuthRecoverySource; + +/** Deferred ambient interactive recovery for a background browser tab. */ +export interface PendingReauth { + serverId: string; + challenge: AuthChallenge; + authorizationUrl: URL; + authKind: OAuthResumeAuthKind; + source: PendingReauthSource; +} + +/** + * In-memory only — survives tab visibility changes but not a full page reload. + * OAuth resume snapshot is written only once interactive redirect starts. + */ diff --git a/core/auth/challenge.ts b/core/auth/challenge.ts new file mode 100644 index 00000000..631e6160 --- /dev/null +++ b/core/auth/challenge.ts @@ -0,0 +1,331 @@ +import { isUnauthorizedError } from "./utils.js"; + +/** Why authorization failed for this MCP interaction. */ +export type AuthChallengeReason = + | "unauthorized" + | "token_expired" + | "insufficient_scope" + | "invalid_token"; + +/** Normalized challenge for handleAuthChallenge(). */ +export interface AuthChallenge { + reason: AuthChallengeReason; + + /** Scopes from the current challenge (step-up). */ + requiredScopes?: string[]; + + /** + * For step-up (SEP-2350): union of previously requested scopes and requiredScopes. + * Set by handleAuthChallenge before re-authorization; not sent on the wire. + */ + authorizationScopes?: string[]; + + /** Resource indicator / MCP resource URL when known (EMA RFC 8707). */ + resource?: string; + + /** Resource authorization server audience when known. */ + audience?: string; + + /** Optional human-readable detail from server or SDK (for UI, not parsing). */ + message?: string; + + /** Optional UX hints when known (not used for ambient RPC replay). */ + context?: { + method?: string; + toolName?: string; + }; + + /** Opaque raw hints for logging and forward-compatible parsers. */ + raw?: { + httpStatus?: number; + wwwAuthenticate?: string; + }; +} + +export type AuthChallengeOutcome = + | { kind: "satisfied" } + | { kind: "interactive"; authorizationUrl: URL; challenge: AuthChallenge } + | { kind: "step_up_confirm"; challenge: AuthChallenge } + | { kind: "failed"; error: Error }; + +/** Placeholder URL when EMA step-up awaits in-app confirmation (no redirect yet). */ +export const EMA_STEP_UP_PENDING_URL = new URL("mcp-inspector:ema-step-up"); + +export interface HandleAuthChallengeOptions { + /** User confirmed step-up in Inspector UI — run silent EMA re-mint / IdP redirect. */ + confirmedStepUp?: boolean; +} + +export interface ParseAuthChallengeContext { + method?: string; + toolName?: string; +} + +export class AuthChallengeError extends Error { + readonly authChallenge: AuthChallenge; + readonly status: number; + + constructor( + authChallenge: AuthChallenge, + status: number, + message?: string, + ) { + super(message ?? `Auth challenge: ${authChallenge.reason}`); + this.name = "AuthChallengeError"; + this.authChallenge = authChallenge; + this.status = status; + } +} + +/** Thrown when interactive auth recovery was started and the caller should wait for callback. */ +export class AuthRecoveryRequiredError extends Error { + readonly authorizationUrl: URL; + readonly authChallenge: AuthChallenge; + /** EMA insufficient_scope awaiting user confirmation in Inspector (no redirect yet). */ + readonly emaStepUpConfirm?: boolean; + + constructor( + authorizationUrl: URL, + authChallenge: AuthChallenge, + options?: { emaStepUpConfirm?: boolean }, + ) { + super("Interactive auth recovery required"); + this.name = "AuthRecoveryRequiredError"; + this.authorizationUrl = authorizationUrl; + this.authChallenge = authChallenge; + this.emaStepUpConfirm = options?.emaStepUpConfirm; + } +} + +/** + * Connect-time failures the app can recover via OAuth redirect. These are not + * terminal connection errors — the UI should stay on "connecting" until the + * redirect or an explicit recovery failure. + */ +export function isConnectAuthRecoveryError(err: unknown): boolean { + if (err instanceof AuthRecoveryRequiredError) return true; + return isUnauthorizedError(err); +} + +export interface WwwAuthenticateBearerParams { + error?: string; + scope?: string; + resourceMetadata?: string; + errorDescription?: string; +} + +/** Parse the first `WWW-Authenticate: Bearer …` challenge (RFC 6750). */ +export function parseWwwAuthenticateBearer( + header: string, +): WwwAuthenticateBearerParams { + const match = header.match(/Bearer\s+(.+)/i); + if (!match) { + return {}; + } + + const params: Record = {}; + const paramRegex = /([\w-]+)(?:="([^"]*)"|=([^\s,]+))?/g; + let m: RegExpExecArray | null; + while ((m = paramRegex.exec(match[1])) !== null) { + const value = m[2] ?? m[3]; + if (value !== undefined) { + params[m[1].toLowerCase()] = value; + } + } + + return { + error: params.error, + scope: params.scope, + resourceMetadata: params.resource_metadata, + errorDescription: params.error_description, + }; +} + +/** Split an OAuth scope string into individual scopes (space-separated). */ +export function parseScopeString(scope: string | undefined): string[] { + if (!scope?.trim()) { + return []; + } + return scope.trim().split(/\s+/).filter(Boolean); +} + +/** + * SEP-2350: union of previously requested scopes and scopes from the current challenge. + * Preserves order: previous scopes first, then any new required scopes. + */ +export function unionAuthorizationScopes( + previousScope: string | undefined, + requiredScopes: string[], +): string[] { + const seen = new Set(); + const result: string[] = []; + + for (const scope of [ + ...parseScopeString(previousScope), + ...requiredScopes.filter(Boolean), + ]) { + if (!seen.has(scope)) { + seen.add(scope); + result.push(scope); + } + } + + return result; +} + +function reasonFromHttpResponse( + status: number, + bearer: WwwAuthenticateBearerParams, +): AuthChallengeReason { + if (status === 403) { + if (bearer.error === "insufficient_scope") { + return "insufficient_scope"; + } + return "unauthorized"; + } + + if (bearer.error === "insufficient_scope") { + return "insufficient_scope"; + } + + if (bearer.error === "invalid_token") { + return "invalid_token"; + } + + // Bare 401 without a Bearer error code — treat as expired token for silent + // refresh / reauth UX (connect-time 401 uses isUnauthorizedError separately). + if (status === 401) { + return "token_expired"; + } + + return "unauthorized"; +} + +/** + * Build an AuthChallenge from an MCP HTTP response (401 / 403). + * Returns undefined when the response is not an auth challenge. + */ +export function parseAuthChallengeFromResponse( + response: Response, + context?: ParseAuthChallengeContext, +): AuthChallenge | undefined { + const status = response.status; + if (status !== 401 && status !== 403) { + return undefined; + } + + const wwwAuthenticate = response.headers.get("WWW-Authenticate") ?? undefined; + const bearer = wwwAuthenticate + ? parseWwwAuthenticateBearer(wwwAuthenticate) + : {}; + const requiredScopes = parseScopeString(bearer.scope); + + return { + reason: reasonFromHttpResponse(status, bearer), + ...(requiredScopes.length > 0 ? { requiredScopes } : {}), + ...(bearer.errorDescription ? { message: bearer.errorDescription } : {}), + ...(context ? { context } : {}), + raw: { + httpStatus: status, + ...(wwwAuthenticate ? { wwwAuthenticate } : {}), + }, + }; +} + +/** Best-effort challenge extraction from SDK / transport errors. */ +export function parseAuthChallengeFromError( + err: unknown, + context?: ParseAuthChallengeContext, +): AuthChallenge | undefined { + if (err instanceof AuthChallengeError) { + return err.authChallenge; + } + + if (typeof err !== "object" || err === null) { + return undefined; + } + + const authChallenge = (err as { authChallenge?: AuthChallenge }) + .authChallenge; + if (authChallenge?.reason) { + return { + ...authChallenge, + ...(context + ? { + context: { + ...authChallenge.context, + ...context, + }, + } + : {}), + }; + } + + const status = + (err as { status?: number }).status ?? + (err as { code?: number }).code; + if (status !== 401 && status !== 403) { + return undefined; + } + + const wwwAuthenticate = + authChallenge?.raw?.wwwAuthenticate ?? + (err as { wwwAuthenticate?: string }).wwwAuthenticate ?? + (err as { headers?: { get?: (name: string) => string | null } }).headers + ?.get?.("WWW-Authenticate") ?? + undefined; + + if (!wwwAuthenticate?.length) { + return undefined; + } + + const bearer = parseWwwAuthenticateBearer(wwwAuthenticate); + const requiredScopes = parseScopeString(bearer.scope); + + return { + reason: reasonFromHttpResponse(status, bearer), + ...(requiredScopes.length > 0 ? { requiredScopes } : {}), + ...(context ? { context } : {}), + raw: { + httpStatus: status, + wwwAuthenticate, + }, + }; +} + +/** + * True for mid-session auth failures (HTTP 401 or 403 on MCP traffic). + * Connect-time 401 detection remains {@link isUnauthorizedError} in utils.ts. + * + * Bare HTTP status codes alone are not treated as auth challenges — require + * {@link AuthChallengeError}, an embedded `authChallenge`, or `WWW-Authenticate`. + */ +export function isAuthChallengeError(err: unknown): boolean { + if (err instanceof AuthChallengeError) { + return true; + } + + if (typeof err !== "object" || err === null) { + return false; + } + + const authChallenge = (err as { authChallenge?: AuthChallenge }).authChallenge; + if (authChallenge?.reason) { + return true; + } + + const status = + (err as { status?: number }).status ?? (err as { code?: number }).code; + if (status !== 401 && status !== 403) { + return false; + } + + const wwwAuthenticate = + authChallenge?.raw?.wwwAuthenticate ?? + (err as { wwwAuthenticate?: string }).wwwAuthenticate ?? + (err as { headers?: { get?: (name: string) => string | null } }).headers + ?.get?.("WWW-Authenticate") ?? + undefined; + + return wwwAuthenticate !== undefined && wwwAuthenticate.length > 0; +} diff --git a/core/auth/connection-state.ts b/core/auth/connection-state.ts index 263d5da8..b31c3299 100644 --- a/core/auth/connection-state.ts +++ b/core/auth/connection-state.ts @@ -19,7 +19,10 @@ export interface BuildOAuthConnectionStateParams { flowState?: OAuthFlowState; } -function isAccessTokenUsable(tokens: OAuthTokens | undefined): boolean { +/** True when persisted tokens include a non-expired access token (JWT exp when parseable). */ +export function isAccessTokenUsable( + tokens: OAuthTokens | undefined, +): boolean { if (!tokens?.access_token) return false; return !isJwtExpired(tokens.access_token); } diff --git a/core/auth/index.ts b/core/auth/index.ts index 7d159c9b..e9e2e80d 100644 --- a/core/auth/index.ts +++ b/core/auth/index.ts @@ -24,6 +24,12 @@ export type { BuildOAuthConnectionStateParams } from "./connection-state.js"; export { ensureCimdClientRegistration } from "./cimd.js"; +export { mcpAuth, type McpAuthOptions, type McpAuthResult } from "./mcpAuth.js"; +export { + computeScopeUnion, + isStrictScopeSuperset, +} from "./scopes.js"; + // Storage export type { OAuthStorage, IdpSessionState, SaveClientInformationOptions } from "./storage.js"; export { getServerSpecificKey, OAUTH_STORAGE_KEYS } from "./storage.js"; @@ -49,9 +55,50 @@ export { generateOAuthState, parseOAuthState, generateOAuthErrorDescription, + formatOAuthFailureDetail, isUnauthorizedError, } from "./utils.js"; +export type { + AuthChallenge, + AuthChallengeReason, + AuthChallengeOutcome, + HandleAuthChallengeOptions, + ParseAuthChallengeContext, + WwwAuthenticateBearerParams, +} from "./challenge.js"; +export { + AuthChallengeError, + AuthRecoveryRequiredError, + parseAuthChallengeFromError, + parseAuthChallengeFromResponse, + parseScopeString, + parseWwwAuthenticateBearer, + unionAuthorizationScopes, + isAuthChallengeError, + isConnectAuthRecoveryError, + EMA_STEP_UP_PENDING_URL, +} from "./challenge.js"; + +export { + isStandardOAuthStepUp, + isEmaStepUp, + isStepUpConfirmation, + stepUpConfirmMessage, + stepUpFollowUpMessage, + stepUpModalTitle, + stepUpAuthorizeActionLabel, + emaStepUpInProgressMessage, + emaStepUpSuccessMessage, + emaStepUpFailureMessage, + stepUpAdditionalScopes, + stepUpInsufficientScopeMessage, + oauthPreRedirectToastCopy, + isReAuthBannerReason, + reAuthBannerMessage, + type OAuthInteractiveAuthKind, +} from "./oauthUx.js"; + // Discovery export { discoverScopes } from "./discovery.js"; diff --git a/core/auth/mcpAuth.ts b/core/auth/mcpAuth.ts new file mode 100644 index 00000000..9e52a769 --- /dev/null +++ b/core/auth/mcpAuth.ts @@ -0,0 +1,156 @@ +/** + * v2-shaped OAuth orchestrator for standard MCP resource authorization. + * + * **Upgrade path:** On `@modelcontextprotocol/client` v2, replace this module + * with `export { auth as mcpAuth, type AuthOptions as McpAuthOptions } from + * "@modelcontextprotocol/client"` and delete `authorizeWithoutRefresh`. + * + * Until then, `mcpAuth` delegates to SDK v1 `auth()` except when + * `forceReauthorization: true` — then it runs discovery + `startAuthorization` + * (the same path v2 `auth()` takes when refresh cannot widen scope). + */ + +import { + auth as sdkAuth, + discoverOAuthServerInfo, + isHttpsUrl, + registerClient, + selectResourceURL, + startAuthorization, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { InvalidClientMetadataError } from "@modelcontextprotocol/sdk/server/auth/errors.js"; + +export type McpAuthResult = "AUTHORIZED" | "REDIRECT"; + +/** + * Options aligned with v2 SDK `AuthOptions`. v2-only fields are accepted now + * and forwarded on upgrade; v1 passthrough ignores unsupported fields. + */ +export interface McpAuthOptions { + serverUrl: string | URL; + authorizationCode?: string; + /** RFC 9207 callback `iss` — forwarded on v2 upgrade; ignored by v1 SDK `auth()`. */ + iss?: string; + scope?: string; + resourceMetadataUrl?: URL; + fetchFn?: FetchLike; + /** v2 SEP-2468 — ignored until SDK v2 upgrade. */ + skipIssuerMetadataValidation?: boolean; + /** + * Skip refresh and start an authorization-code flow. Required for step-up + * when the union scope exceeds the current token grant (RFC 6749 §6). + */ + forceReauthorization?: boolean; +} + +export async function mcpAuth( + provider: OAuthClientProvider, + options: McpAuthOptions, +): Promise { + if (options.forceReauthorization) { + if (options.authorizationCode !== undefined) { + throw new Error( + "forceReauthorization cannot be combined with authorizationCode", + ); + } + return authorizeWithoutRefresh(provider, options); + } + + return sdkAuth(provider, { + serverUrl: options.serverUrl, + authorizationCode: options.authorizationCode, + scope: options.scope, + resourceMetadataUrl: options.resourceMetadataUrl, + fetchFn: options.fetchFn, + }); +} + +async function authorizeWithoutRefresh( + provider: OAuthClientProvider, + options: McpAuthOptions, +): Promise<"REDIRECT"> { + const { serverUrl, scope, resourceMetadataUrl, fetchFn } = options; + + const serverInfo = await discoverOAuthServerInfo(serverUrl, { + resourceMetadataUrl, + fetchFn, + }); + const { + authorizationServerUrl, + authorizationServerMetadata: metadata, + resourceMetadata, + } = serverInfo; + + await provider.saveDiscoveryState?.({ + authorizationServerUrl: String(authorizationServerUrl), + resourceMetadataUrl: resourceMetadataUrl?.toString(), + resourceMetadata, + authorizationServerMetadata: metadata, + }); + + const resource = await selectResourceURL( + serverUrl, + provider, + resourceMetadata, + ); + + const resolvedScope = + scope || + resourceMetadata?.scopes_supported?.join(" ") || + provider.clientMetadata.scope; + + let clientInformation = await Promise.resolve(provider.clientInformation()); + if (!clientInformation) { + const supportsUrlBasedClientId = + metadata?.client_id_metadata_document_supported === true; + const clientMetadataUrl = provider.clientMetadataUrl; + if (clientMetadataUrl && !isHttpsUrl(clientMetadataUrl)) { + throw new InvalidClientMetadataError( + `clientMetadataUrl must be a valid HTTPS URL with a non-root pathname, got: ${clientMetadataUrl}`, + ); + } + const shouldUseUrlBasedClientId = + supportsUrlBasedClientId && clientMetadataUrl; + if (shouldUseUrlBasedClientId) { + clientInformation = { client_id: clientMetadataUrl }; + await provider.saveClientInformation?.(clientInformation); + } else { + if (!provider.saveClientInformation) { + throw new Error( + "OAuth client information must be saveable for dynamic registration", + ); + } + const fullInformation = await registerClient(authorizationServerUrl, { + metadata, + clientMetadata: provider.clientMetadata, + scope: resolvedScope, + fetchFn, + }); + await provider.saveClientInformation(fullInformation); + clientInformation = fullInformation; + } + } + + const state = provider.state ? await provider.state() : undefined; + const redirectUrl = provider.redirectUrl; + if (!redirectUrl) { + throw new Error("redirectUrl is required for authorization_code flow"); + } + const { authorizationUrl, codeVerifier } = await startAuthorization( + authorizationServerUrl, + { + metadata, + clientInformation, + state, + redirectUrl, + scope: resolvedScope, + resource, + }, + ); + + await provider.saveCodeVerifier(codeVerifier); + await provider.redirectToAuthorization(authorizationUrl); + return "REDIRECT"; +} diff --git a/core/auth/node/index.ts b/core/auth/node/index.ts index 1075a3a3..b4fdab89 100644 --- a/core/auth/node/index.ts +++ b/core/auth/node/index.ts @@ -23,3 +23,12 @@ export { parseRunnerOAuthCallbackUrl, } from "./runner-oauth-callback.js"; export type { RunnerOAuthCallbackConfig } from "./runner-oauth-callback.js"; +export { + runRunnerInteractiveOAuth, +} from "./runner-interactive-oauth.js"; +export type { + RunRunnerInteractiveOAuthOptions, + RunnerInteractiveOAuthClient, + RunnerInteractiveOAuthRedirectProvider, + RunnerInteractiveOAuthResult, +} from "./runner-interactive-oauth.js"; diff --git a/core/auth/node/runner-interactive-oauth.ts b/core/auth/node/runner-interactive-oauth.ts new file mode 100644 index 00000000..f68a3280 --- /dev/null +++ b/core/auth/node/runner-interactive-oauth.ts @@ -0,0 +1,142 @@ +import type { AuthChallenge } from "../challenge.js"; +import { + createOAuthCallbackServer, + type OAuthCallbackServer, +} from "./oauth-callback-server.js"; +import type { RunnerOAuthCallbackConfig } from "./runner-oauth-callback.js"; + +/** Minimal InspectorClient surface for runner interactive OAuth. */ +export interface RunnerInteractiveOAuthClient { + authenticate(): Promise; + beginInteractiveAuthorization(authorizationUrl: URL): Promise; + completeOAuthFlow(authorizationCode: string): Promise; + checkAuthChallengeSatisfied(challenge: AuthChallenge): Promise; +} + +export interface RunnerInteractiveOAuthRedirectProvider { + redirectUrl: string; +} + +export type RunnerInteractiveOAuthResult = + | { kind: "success" } + | { kind: "already_authorized" } + | { kind: "insufficient_scope"; challenge: AuthChallenge }; + +/** Default wait for loopback OAuth callback (15 minutes). */ +export const DEFAULT_RUNNER_INTERACTIVE_OAUTH_TIMEOUT_MS = 15 * 60 * 1000; + +export interface RunRunnerInteractiveOAuthOptions { + client: RunnerInteractiveOAuthClient; + redirectUrlProvider: RunnerInteractiveOAuthRedirectProvider; + callbackListen: RunnerOAuthCallbackConfig; + /** When set, use deferred interactive authorization (mid-session step-up / re-login). */ + authorizationUrl?: URL; + /** When set, verify scopes after a successful token exchange (SEP-2350 step-up). */ + authChallenge?: AuthChallenge; + createCallbackServer?: () => OAuthCallbackServer; + /** Invoked after the listener binds; hosts may keep a ref for unmount cleanup. */ + onCallbackServer?: (server: OAuthCallbackServer) => void; + /** Max wait for browser callback; defaults to {@link DEFAULT_RUNNER_INTERACTIVE_OAUTH_TIMEOUT_MS}. */ + callbackTimeoutMs?: number; +} + +/** + * Run interactive OAuth for Node runners (TUI / CLI): loopback callback server, + * browser redirect, authorization-code exchange via {@link completeOAuthFlow}. + */ +export async function runRunnerInteractiveOAuth( + options: RunRunnerInteractiveOAuthOptions, +): Promise { + const createServer = + options.createCallbackServer ?? createOAuthCallbackServer; + const server = createServer(); + + let flowResolve!: () => void; + let flowReject!: (err: Error) => void; + const flowDone = new Promise((resolve, reject) => { + flowResolve = resolve; + flowReject = reject; + }); + + let timeoutId: ReturnType | undefined; + + try { + const { redirectUrl } = await server.start({ + hostname: options.callbackListen.hostname, + port: options.callbackListen.port, + path: options.callbackListen.pathname, + onCallback: async (params) => { + try { + await options.client.completeOAuthFlow(params.code); + flowResolve(); + } catch (err) { + flowReject(err instanceof Error ? err : new Error(String(err))); + } + }, + onError: (params) => { + flowReject( + new Error( + /* v8 ignore next -- params.error is a required non-null string, so the "OAuth error" fallback is unreachable */ + params.error_description ?? params.error ?? "OAuth error", + ), + ); + }, + }); + + options.onCallbackServer?.(server); + options.redirectUrlProvider.redirectUrl = redirectUrl; + + const timeoutMs = + options.callbackTimeoutMs ?? DEFAULT_RUNNER_INTERACTIVE_OAUTH_TIMEOUT_MS; + const waitForCallback = Promise.race([ + flowDone.finally(() => { + /* v8 ignore next 3 -- timeoutId is assigned synchronously while the race is constructed, before flowDone can settle, so the undefined arm is unreachable */ + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + }), + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject( + new Error( + `OAuth callback timed out after ${Math.round(timeoutMs / 1000)}s`, + ), + ); + }, timeoutMs); + }), + ]); + + if (options.authorizationUrl) { + await options.client.beginInteractiveAuthorization( + options.authorizationUrl, + ); + await waitForCallback; + } else { + const authUrl = await options.client.authenticate(); + if (authUrl !== undefined) { + await waitForCallback; + } else { + return { kind: "already_authorized" }; + } + } + + if (options.authChallenge) { + const satisfied = await options.client.checkAuthChallengeSatisfied( + options.authChallenge, + ); + if (!satisfied) { + return { + kind: "insufficient_scope", + challenge: options.authChallenge, + }; + } + } + + return { kind: "success" }; + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + await server.stop().catch(() => {}); + } +} diff --git a/core/auth/oauthUx.ts b/core/auth/oauthUx.ts new file mode 100644 index 00000000..df7fdc26 --- /dev/null +++ b/core/auth/oauthUx.ts @@ -0,0 +1,236 @@ +import type { AuthChallenge } from "./challenge.js"; + +export type OAuthInteractiveAuthKind = "step_up" | "reauth"; + +/** Origin of an interactive OAuth recovery flow (command, ambient, connect, etc.). */ +export type OAuthRecoverySource = + | "tool" + | "prompt" + | "resource" + | "ambient" + | "app"; + +export function isActionTriggeredOAuthRecovery( + source: OAuthRecoverySource | undefined, +): boolean { + return ( + source === "tool" || + source === "prompt" || + source === "resource" || + source === "app" + ); +} + +export function oauthResumeSuccessMessage( + authKind: OAuthInteractiveAuthKind, + options?: { recoverySource?: OAuthRecoverySource }, +): string { + const retry = isActionTriggeredOAuthRecovery(options?.recoverySource); + if (authKind === "step_up") { + return retry + ? "Step-up authorization succeeded. Retry your action." + : "Step-up authorization succeeded."; + } + return retry + ? "Authentication succeeded. Retry your action." + : "Authentication succeeded."; +} + +export function authRecoveryRestoredMessage(options?: { + recoverySource?: OAuthRecoverySource; +}): string { + const retry = isActionTriggeredOAuthRecovery(options?.recoverySource); + return retry + ? "Session credentials were updated. Retry your action." + : "Session credentials were updated."; +} + +export function oauthResumeAbandonedMessage( + authKind: OAuthInteractiveAuthKind, + options?: { recoverySource?: OAuthRecoverySource }, +): string { + if (authKind === "reauth") { + return "Sign-in was not completed. Re-authenticate to restore access."; + } + return isActionTriggeredOAuthRecovery(options?.recoverySource) + ? "Step-up authorization was not completed. Retry your action." + : "Step-up authorization was not completed."; +} + +/** Standard-OAuth step-up (not EMA silent re-mint). */ +export function isStandardOAuthStepUp( + challenge: AuthChallenge, + options?: { enterpriseManaged?: boolean }, +): boolean { + return ( + challenge.reason === "insufficient_scope" && !options?.enterpriseManaged + ); +} + +/** EMA step-up (`insufficient_scope` on an enterprise-managed server). */ +export function isEmaStepUp( + challenge: AuthChallenge, + options?: { enterpriseManaged?: boolean }, +): boolean { + return ( + challenge.reason === "insufficient_scope" && + options?.enterpriseManaged === true + ); +} + +/** Any step-up that should show the Inspector confirmation modal before proceeding. */ +export function isStepUpConfirmation( + challenge: AuthChallenge, + options?: { enterpriseManaged?: boolean }, +): boolean { + return ( + isStandardOAuthStepUp(challenge, options) || + isEmaStepUp(challenge, options) + ); +} + +export function stepUpConfirmMessage( + challenge: AuthChallenge, + options?: { enterpriseManaged?: boolean }, +): string { + const toolName = challenge.context?.toolName?.trim(); + if (toolName) { + return options?.enterpriseManaged + ? `Tool "${toolName}" needs additional permissions from your organization before it can continue.` + : `Tool "${toolName}" needs additional OAuth scopes before it can continue.`; + } + const additional = challenge.requiredScopes?.filter(Boolean); + if (additional?.length) { + const label = additional.length === 1 ? "scope" : "scopes"; + return options?.enterpriseManaged + ? `This operation needs additional organization ${label}: ${additional.join(", ")}.` + : `This operation needs additional ${label}: ${additional.join(", ")}.`; + } + return options?.enterpriseManaged + ? "This operation needs additional permissions from your organization before it can continue." + : "This operation needs additional OAuth scopes before it can continue."; +} + +/** Body copy below the step-up summary (what happens on Authorize). */ +export function stepUpFollowUpMessage(options?: { + enterpriseManaged?: boolean; +}): string { + return options?.enterpriseManaged + ? "Inspector will request the additional permissions from your enterprise identity provider. You may be asked to sign in if your organization session expired." + : "You will be redirected to authorize, then returned to the inspector."; +} + +/** Step-up confirm action label (e.g. TUI menu item). EMA re-mints in-process when possible. */ +export function stepUpAuthorizeActionLabel(options?: { + enterpriseManaged?: boolean; +}): string { + return options?.enterpriseManaged + ? "Authorize" + : "Authorize (opens browser)"; +} + +export function stepUpModalTitle(options?: { + enterpriseManaged?: boolean; +}): string { + return options?.enterpriseManaged + ? "Additional organization permissions required" + : "Additional permissions required"; +} + +/** Toast while EMA step-up is in progress after user confirms. */ +export function emaStepUpInProgressMessage(): string { + return "Requesting additional permissions from your organization…"; +} + +/** Toast when EMA step-up completes successfully. */ +export function emaStepUpSuccessMessage(options?: { + recoverySource?: OAuthRecoverySource; +}): string { + const retry = isActionTriggeredOAuthRecovery(options?.recoverySource); + return retry + ? "Organization permissions were updated. Retry your action." + : "Organization permissions were updated."; +} + +/** Toast when EMA step-up fails after user confirmation. */ +export function emaStepUpFailureMessage(detail?: string): string { + return detail?.trim() + ? detail + : "Could not obtain the additional permissions from your organization."; +} + +/** Scopes the current operation still lacks (from the resource-server challenge). */ +export function stepUpAdditionalScopes(challenge: AuthChallenge): string[] { + return challenge.requiredScopes?.filter(Boolean) ?? []; +} + +export function stepUpInsufficientScopeMessage( + challenge: AuthChallenge, +): string { + const toolName = challenge.context?.toolName?.trim(); + if (toolName) { + return `Authorization completed, but required permissions for tool "${toolName}" were not granted. Grant the requested scopes on the authorization server, then retry.`; + } + const missing = + challenge.authorizationScopes?.filter(Boolean) ?? + challenge.requiredScopes?.filter(Boolean); + if (missing?.length) { + return `Authorization completed, but required scopes were not granted (${missing.join(", ")}). Grant the requested permissions on the authorization server, then retry your action.`; + } + return "Authorization completed, but the required permissions were not granted. Grant the requested scopes on the authorization server, then retry your action."; +} + +export type OAuthPreRedirectContext = "connect" | "session_recovery"; + +/** Pre-redirect toast copy for interactive OAuth. */ +export function oauthPreRedirectToastCopy( + authKind: OAuthInteractiveAuthKind, + options: { + serverName?: string; + enterpriseManaged?: boolean; + /** Fresh connect handshake — no existing session to recover. */ + context?: OAuthPreRedirectContext; + }, +): { title: string; message: string } | undefined { + if (options.context === "connect") { + return undefined; + } + const name = options.serverName; + if (authKind === "step_up") { + return { + title: name ? `Step-up authorization for "${name}"` : "Step-up authorization", + message: "Redirecting to authorize additional permissions…", + }; + } + if (options.enterpriseManaged) { + return { + title: name ? `Re-authenticating "${name}"` : "Re-authenticating", + message: "Re-authenticating…", + }; + } + return { + title: name ? `Session expired for "${name}"` : "Session expired", + message: "Session expired, re-authenticating…", + }; +} + +/** Challenge reasons that warrant a persistent re-auth banner (degraded session). */ +export function isReAuthBannerReason( + reason: AuthChallenge["reason"] | undefined, +): boolean { + return ( + reason === "token_expired" || + reason === "unauthorized" || + reason === "invalid_token" + ); +} + +export function reAuthBannerMessage(options: { + serverName?: string; + detail?: string; +}): string { + const prefix = options.serverName + ? `Authentication for "${options.serverName}" needs attention.` + : "Authentication needs attention."; + return options.detail ? `${prefix} ${options.detail}` : prefix; +} diff --git a/core/auth/providers.ts b/core/auth/providers.ts index f4b9faf0..5e351179 100644 --- a/core/auth/providers.ts +++ b/core/auth/providers.ts @@ -102,6 +102,7 @@ export type OAuthProviderConfig = { export class BaseOAuthClientProvider implements OAuthClientProvider { private capturedAuthUrl: URL | null = null; private eventTarget: EventTarget | null = null; + private suppressAuthorizationNavigation = false; protected serverUrl: string; protected storage: OAuthStorage; @@ -138,6 +139,11 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { this.capturedAuthUrl = null; } + /** Capture authorize URL without navigating (step-up confirmation modal). */ + setSuppressAuthorizationNavigation(suppress: boolean): void { + this.suppressAuthorizationNavigation = suppress; + } + get scope(): string | undefined { return this.storage.getScope(this.serverUrl); } @@ -217,17 +223,16 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { // Capture URL for return value this.capturedAuthUrl = authorizationUrl; - // Dispatch event if event target is set - if (this.eventTarget) { - this.eventTarget.dispatchEvent( - new CustomEvent("oauthAuthorizationRequired", { - detail: { url: authorizationUrl }, - }), - ); + if (!this.suppressAuthorizationNavigation) { + if (this.eventTarget) { + this.eventTarget.dispatchEvent( + new CustomEvent("oauthAuthorizationRequired", { + detail: { url: authorizationUrl }, + }), + ); + } + this.navigation.navigateToAuthorization(authorizationUrl); } - - // Original navigation behavior - this.navigation.navigateToAuthorization(authorizationUrl); } async saveCodeVerifier(codeVerifier: string): Promise { diff --git a/core/auth/scopes.ts b/core/auth/scopes.ts new file mode 100644 index 00000000..d858bd0f --- /dev/null +++ b/core/auth/scopes.ts @@ -0,0 +1,75 @@ +/** + * SEP-2350 scope helpers aligned with v2 `@modelcontextprotocol/client` exports. + * On SDK v2 upgrade, delete this module and import `computeScopeUnion` / + * `isStrictScopeSuperset` from the client package instead. + */ + +import { + parseScopeString, + unionAuthorizationScopes, +} from "./challenge.js"; + +/** Union space-delimited scope strings (order-preserving, deduped). */ +export function computeScopeUnion( + ...scopes: ReadonlyArray +): string | undefined { + let merged: string | undefined; + for (const scope of scopes) { + merged = unionAuthorizationScopes(merged, parseScopeString(scope)).join( + " ", + ); + if (!merged) { + merged = undefined; + } + } + return merged; +} + +/** + * Whether `union` contains a scope token not present in `current`. + * When the AS omits `scope` on the token response, `current` is empty and any + * non-empty union is a strict superset — step-up must re-authorize, not refresh. + */ +export function isStrictScopeSuperset( + union: string | undefined, + current: string | undefined, +): boolean { + if (!union) return false; + const currentSet = new Set((current ?? "").split(/\s+/).filter(Boolean)); + for (const token of union.split(/\s+/)) { + if (token && !currentSet.has(token)) return true; + } + return false; +} + +/** + * Scope to persist after a successful token grant (RFC 6749 §5.1). + * When the AS returns `scope`, it is the authoritative full grant. + * When `scope` is omitted on success, granted equals what was requested. + */ +export function resolvePersistedScopeAfterGrant( + grantedScope: string | undefined, + requestedScope: string | undefined, +): string | undefined { + const granted = grantedScope?.trim(); + if (granted) { + return granted; + } + const requested = requestedScope?.trim(); + return requested || undefined; +} + +/** + * Scope coverage for satisfaction checks: prefer the token's explicit grant; + * when omitted, fall back to stored scope (RFC implied grant on prior success). + */ +export function resolveEffectiveGrantedScope( + storedScope: string | undefined, + tokenScope: string | undefined, +): string | undefined { + const granted = tokenScope?.trim(); + if (granted) { + return granted; + } + return computeScopeUnion(storedScope, tokenScope); +} diff --git a/core/auth/utils.ts b/core/auth/utils.ts index 123274f8..c15bb974 100644 --- a/core/auth/utils.ts +++ b/core/auth/utils.ts @@ -1,4 +1,69 @@ import type { CallbackParams } from "./types.js"; +import { ZodError } from "zod"; + +type ZodIssueLike = { + path?: unknown[]; + message?: string; + code?: string; +}; + +function isZodIssueArray(value: unknown): value is ZodIssueLike[] { + return ( + Array.isArray(value) && + value.length > 0 && + typeof value[0] === "object" && + value[0] !== null && + "code" in value[0] + ); +} + +function formatZodIssues(issues: ZodIssueLike[]): string { + const tokenResponseIssue = issues.some( + (issue) => + Array.isArray(issue.path) && + (issue.path.includes("access_token") || + issue.path.includes("token_type")), + ); + if (tokenResponseIssue) { + return "The authorization server did not return valid tokens. Check your OAuth client ID and secret, then try again."; + } + return issues + .map((issue) => { + const path = + Array.isArray(issue.path) && issue.path.length + ? issue.path.join(".") + : "input"; + return `${path}: ${issue.message ?? "invalid"}`; + }) + .join(" "); +} + +/** + * Human-readable detail for OAuth failure toasts/banners (never raw Zod JSON). + */ +export function formatOAuthFailureDetail(detail: unknown): string { + if (detail instanceof ZodError) { + return formatZodIssues(detail.issues); + } + const raw = + detail instanceof Error + ? detail.message + : typeof detail === "string" + ? detail + : String(detail); + const trimmed = raw.trim(); + if (trimmed.startsWith("[")) { + try { + const parsed: unknown = JSON.parse(trimmed); + if (isZodIssueArray(parsed)) { + return formatZodIssues(parsed); + } + } catch { + // fall through + } + } + return raw; +} /** * Parse a string as an absolute URL. On failure, throws with `label` and the diff --git a/core/client/runner.ts b/core/client/runner.ts index 5a9f38d7..41bc1236 100644 --- a/core/client/runner.ts +++ b/core/client/runner.ts @@ -53,7 +53,7 @@ export function buildRunnerClientAuthOptions( cliOverrides?: RunnerClientConfigOverrides, ): Pick< InspectorClientOptions, - "oauth" | "enterpriseManagedAuth" | "installEnterpriseManagedAuth" + "oauth" | "enterpriseManagedAuth" | "installEnterpriseManagedAuth" | "directAuthRecovery" > { const activeIdp = getActiveEnterpriseManagedAuthIdp(clientConfig); const activeCimdUrl = getActiveCimdClientMetadataUrl(clientConfig); @@ -112,5 +112,6 @@ export function buildRunnerClientAuthOptions( ...(clientConfig.enterpriseManagedAuth && { installEnterpriseManagedAuth: clientConfig.enterpriseManagedAuth, }), + ...(oauth && { directAuthRecovery: true }), }; } diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 7567d725..601fc35b 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -118,11 +118,26 @@ import { UrlElicitationLoopError, } from "./urlElicitation.js"; import { ToolCallCancelledError } from "./toolCallCancelledError.js"; -import type { OAuthConnectionState, OAuthFlowState, OAuthStep } from "../auth/types.js"; +import type { + OAuthConnectionState, + OAuthFlowState, + OAuthStep, +} from "../auth/types.js"; +import { + AuthRecoveryRequiredError, + EMA_STEP_UP_PENDING_URL, + isAuthChallengeError, + isConnectAuthRecoveryError, + parseAuthChallengeFromError, + type AuthChallenge, + type AuthChallengeOutcome, + type HandleAuthChallengeOptions, +} from "../auth/challenge.js"; import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; import { silentLogger, type InspectorLogger } from "../logging/logger.js"; import { createFetchTracker } from "./fetchTracking.js"; import { OAuthManager, type OAuthManagerConfig } from "./oauthManager.js"; +import { RemoteClientTransport } from "./remote/remoteClientTransport.js"; /** Internal record for a receiver task (server polls us for status/result). */ interface ReceiverTaskRecord { @@ -181,6 +196,8 @@ export class InspectorClient extends InspectorClientEventTarget { private baseTransport: Transport | null = null; /** True when the cached transport was built with an OAuth authProvider attached. */ private transportHasAuthProvider = false; + /** Dedupes concurrent ambient auth challenges (reason + scopes). */ + private ambientAuthChallengeInFlight = new Map>(); private pipeStderr: boolean; private initialLoggingLevel?: LoggingLevel; private sample: boolean; @@ -249,6 +266,14 @@ export class InspectorClient extends InspectorClientEventTarget { // Session ID (for OAuth state and saveSession event; persistence is in FetchRequestLogState) private sessionId?: string; private transportConfig: MCPServerConfig; + /** null until first transport is built; then true for in-process OAuth runners. */ + private directAuthRecoveryActive: boolean | null = null; + /** + * Opt-in from {@link InspectorClientOptions.directAuthRecovery}: when true and + * the live transport is direct (not {@link RemoteClientTransport}), RPCs use + * fetch intercept + {@link withDirectAuthRecovery}. + */ + private readonly directAuthRecovery: boolean; constructor( transportConfig: MCPServerConfig, @@ -276,6 +301,7 @@ export class InspectorClient extends InspectorClientEventTarget { ? options.defaultMetadata : undefined; this.serverSettings = options.serverSettings; + this.directAuthRecovery = options.directAuthRecovery ?? false; // Only set roots if explicitly provided (even if empty array) - this enables roots capability this.roots = options.roots; // Initialize listChangedNotifications config (default: all enabled) @@ -426,7 +452,10 @@ export class InspectorClient extends InspectorClientEventTarget { }; this.dispatchTypedEvent("message", entry); }, - trackNotification: (message: JSONRPCNotification, origin: MessageOrigin) => { + trackNotification: ( + message: JSONRPCNotification, + origin: MessageOrigin, + ) => { const entry: MessageEntry = { id: crypto.randomUUID(), timestamp: new Date(), @@ -761,12 +790,40 @@ export class InspectorClient extends InspectorClientEventTarget { await oauthManager.createOAuthProviderForTransport(); } } + if ( + this.directAuthRecovery && + this.directAuthRecoveryActive !== false && + this.isHttpOAuthConfig() && + oauthManager && + transportOptions.authProvider + ) { + transportOptions.interceptAuthChallenges = true; + } this.transportHasAuthProvider = !!transportOptions.authProvider; const { transport: baseTransport } = this.transportClientFactory( this.transportConfig, transportOptions, ); this.baseTransport = baseTransport; + if (this.directAuthRecovery) { + this.directAuthRecoveryActive = !( + baseTransport instanceof RemoteClientTransport + ); + } + if ( + baseTransport instanceof RemoteClientTransport && + oauthManager && + this.isHttpOAuthConfig() + ) { + baseTransport.setAuthRecovery({ + handleAuthChallenge: (challenge, options) => + oauthManager.handleAuthChallenge(challenge, options), + pushAuthState: () => this.pushRemoteAuthState(), + }); + baseTransport.setOnAuthChallenge((challenge) => { + void this.handleAmbientAuthChallenge(challenge); + }); + } const messageTracking = this.createMessageTrackingCallbacks(); this.transport = new MessageTrackingTransport( baseTransport, @@ -789,38 +846,38 @@ export class InspectorClient extends InspectorClientEventTarget { // connect() starts clean and the upstream socket isn't left hanging. const connectTimeoutMs = this.serverSettings?.connectionTimeout ?? 0; const connectPromise = this.client.connect(this.transport); - if (connectTimeoutMs > 0) { - // Absorb any late rejection from `connectPromise` — when the timeout - // wins the race and `disconnect()` tears the transport down, real - // transports (SSE / streamable-http) reject the in-flight handshake - // *after* Promise.race has already settled. Without a handler here - // Node emits an unhandledRejection warning (and in test runs vitest - // can surface it as a suite failure). Only needed on the race path; - // the await-only branch below propagates rejection cleanly through - // the outer try/catch. - connectPromise.catch(() => {}); - let timer: ReturnType | undefined; - const timeoutPromise = new Promise((_, reject) => { - timer = setTimeout( - () => - reject( - new Error( - `Connection timed out after ${connectTimeoutMs} ms`, + const runConnect = async (): Promise => { + if (connectTimeoutMs > 0) { + connectPromise.catch(() => {}); + let timer: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new Error( + `Connection timed out after ${connectTimeoutMs} ms`, + ), ), - ), - connectTimeoutMs, - ); - }); - try { - await Promise.race([connectPromise, timeoutPromise]); - } catch (err) { - await this.disconnect().catch(() => {}); - throw err; - } finally { - if (timer) clearTimeout(timer); + connectTimeoutMs, + ); + }); + try { + await Promise.race([connectPromise, timeoutPromise]); + } finally { + if (timer) clearTimeout(timer); + } + } else { + await connectPromise; } - } else { - await connectPromise; + }; + + try { + await this.invokeMcpClient(runConnect); + } catch (err) { + if (connectTimeoutMs > 0) { + await this.disconnect().catch(() => {}); + } + throw err; } this.status = "connected"; this.dispatchTypedEvent("statusChange", this.status); @@ -1116,8 +1173,10 @@ export class InspectorClient extends InspectorClientEventTarget { // and routing work, and we inject the caller's progressToken into dispatched events. } } catch (error) { - this.status = "error"; - this.dispatchTypedEvent("statusChange", this.status); + if (!isConnectAuthRecoveryError(error)) { + this.status = "error"; + this.dispatchTypedEvent("statusChange", this.status); + } if (this.baseTransport && !this.transportHasAuthProvider) { await this.dropCachedTransport(); } @@ -1221,7 +1280,10 @@ export class InspectorClient extends InspectorClientEventTarget { this.instructions = undefined; this.protocolVersion = undefined; this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples); - this.dispatchTypedEvent("pendingElicitationsChange", this.pendingElicitations); + this.dispatchTypedEvent( + "pendingElicitationsChange", + this.pendingElicitations, + ); this.dispatchTypedEvent("capabilitiesChange", this.capabilities); this.dispatchTypedEvent("serverInfoChange", this.serverInfo); this.dispatchTypedEvent("instructionsChange", this.instructions); @@ -1558,9 +1620,11 @@ export class InspectorClient extends InspectorClientEventTarget { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), ...(cursor ? { cursor } : {}), }; - const response = await this.client.listTools( - params, - this.getRequestOptions(metadata?.progressToken), + const response = await this.invokeMcpClient(() => + this.client!.listTools( + params, + this.getRequestOptions(metadata?.progressToken), + ), ); const tools = [...(response.tools || [])]; return { tools, nextCursor: response.nextCursor }; @@ -1803,13 +1867,17 @@ export class InspectorClient extends InspectorClientEventTarget { // Both branches yield a CallToolResult: request() parsed it with // CallToolResultSchema above, callTool() returns the same shape — so the // `as CallToolResult` casts below are safe. - const result = options?.skipOutputValidation - ? await client.request( - { method: "tools/call", params: callParams }, - CallToolResultSchema, - requestOptions, - ) - : await client.callTool(callParams, undefined, requestOptions); + const result = await this.invokeMcpClient( + () => + options?.skipOutputValidation + ? client.request( + { method: "tools/call", params: callParams }, + CallToolResultSchema, + requestOptions, + ) + : client.callTool(callParams, undefined, requestOptions), + { method: "tools/call", toolName: tool.name }, + ); // On the bypass path the result was delivered without the SDK's strict // output validation. Run that check ourselves, non-fatally, so callers can @@ -2197,9 +2265,11 @@ export class InspectorClient extends InspectorClientEventTarget { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), ...(cursor ? { cursor } : {}), }; - const response = await this.client.listResources( - params, - this.getRequestOptions(metadata?.progressToken), + const response = await this.invokeMcpClient(() => + this.client!.listResources( + params, + this.getRequestOptions(metadata?.progressToken), + ), ); return { resources: response.resources || [], @@ -2225,9 +2295,13 @@ export class InspectorClient extends InspectorClientEventTarget { uri, ...(effectiveMeta ? { _meta: effectiveMeta } : {}), }; - const result = await this.client.readResource( - params, - this.getRequestOptions(metadata?.progressToken), + const result = await this.invokeMcpClient( + () => + this.client!.readResource( + params, + this.getRequestOptions(metadata?.progressToken), + ), + { method: "resources/read" }, ); const invocation: ResourceReadInvocation = { result, @@ -2317,9 +2391,13 @@ export class InspectorClient extends InspectorClientEventTarget { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), ...(cursor ? { cursor } : {}), }; - const response = await this.client.listResourceTemplates( - params, - this.getRequestOptions(metadata?.progressToken), + const response = await this.invokeMcpClient( + () => + this.client!.listResourceTemplates( + params, + this.getRequestOptions(metadata?.progressToken), + ), + { method: "resources/templates/list" }, ); return { resourceTemplates: response.resourceTemplates || [], @@ -2345,9 +2423,11 @@ export class InspectorClient extends InspectorClientEventTarget { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), ...(cursor ? { cursor } : {}), }; - const response = await this.client.listPrompts( - params, - this.getRequestOptions(metadata?.progressToken), + const response = await this.invokeMcpClient(() => + this.client!.listPrompts( + params, + this.getRequestOptions(metadata?.progressToken), + ), ); return { prompts: response.prompts || [], @@ -2380,9 +2460,13 @@ export class InspectorClient extends InspectorClientEventTarget { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), }; - const result = await this.client.getPrompt( - params, - this.getRequestOptions(metadata?.progressToken), + const result = await this.invokeMcpClient( + () => + this.client!.getPrompt( + params, + this.getRequestOptions(metadata?.progressToken), + ), + { method: "prompts/get", toolName: name }, ); const invocation: PromptGetInvocation = { @@ -2437,9 +2521,16 @@ export class InspectorClient extends InspectorClientEventTarget { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), }; - const response = await this.client.complete( - params, - this.getRequestOptions(metadata?.progressToken), + const response = await this.invokeMcpClient( + () => + this.client!.complete( + params, + this.getRequestOptions(metadata?.progressToken), + ), + { + method: "completion/complete", + toolName: ref.type === "ref/prompt" ? ref.name : ref.uri, + }, ); return { @@ -2721,10 +2812,243 @@ export class InspectorClient extends InspectorClientEventTarget { } /** - * Completes OAuth flow with authorization code from the redirect callback. + * Satisfy a mid-session auth challenge (token refresh, step-up, or interactive re-auth). */ - async completeOAuthFlow(authorizationCode: string): Promise { - return this.ensureOAuthManager().completeOAuthFlow(authorizationCode); + async handleAuthChallenge( + challenge: AuthChallenge, + options?: HandleAuthChallengeOptions, + ): Promise { + return this.ensureOAuthManager().handleAuthChallenge(challenge, options); + } + + /** + * Re-read OAuth storage and test whether a challenge is already satisfied. + * See {@link OAuthManager.checkAuthChallengeSatisfied}. + */ + async checkAuthChallengeSatisfied( + challenge: AuthChallenge, + ): Promise { + return this.ensureOAuthManager().checkAuthChallengeSatisfied(challenge); + } + + /** + * Push recovered OAuth auth state to the remote backend (same MCP session). + */ + async pushRemoteAuthState(): Promise { + if (!(this.baseTransport instanceof RemoteClientTransport)) { + return; + } + await this.baseTransport.pushAuthState(); + } + + /** + * Handle an ambient (SSE) auth challenge when no command-scoped send is active. + * Recovers session tokens on the remote backend; does not retry RPCs. + */ + async handleAmbientAuthChallenge(challenge: AuthChallenge): Promise { + const key = this.ambientAuthChallengeKey(challenge); + const existing = this.ambientAuthChallengeInFlight.get(key); + if (existing) { + return existing; + } + + const promise = this.runAmbientAuthChallenge(challenge); + this.ambientAuthChallengeInFlight.set(key, promise); + try { + await promise; + } finally { + if (this.ambientAuthChallengeInFlight.get(key) === promise) { + this.ambientAuthChallengeInFlight.delete(key); + } + } + } + + private async runAmbientAuthChallenge( + challenge: AuthChallenge, + ): Promise { + try { + this.dispatchTypedEvent("authChallengeAmbient", { challenge }); + const oauthManager = this.oauthManager; + if (!oauthManager) { + return; + } + + const outcome = await oauthManager.handleAuthChallenge(challenge); + if (outcome.kind === "satisfied") { + if (this.baseTransport instanceof RemoteClientTransport) { + await this.pushRemoteAuthState(); + } else { + await this.reconnectAfterAuthRecovery(); + } + this.dispatchTypedEvent("authChallengeRecovered", { challenge }); + } else if (outcome.kind === "step_up_confirm") { + this.dispatchTypedEvent("authChallengeInteractive", { + challenge: outcome.challenge, + authorizationUrl: EMA_STEP_UP_PENDING_URL, + }); + } else if (outcome.kind === "interactive") { + this.dispatchTypedEvent("authChallengeInteractive", { + challenge: outcome.challenge, + authorizationUrl: outcome.authorizationUrl, + }); + } else { + this.dispatchTypedEvent("oauthError", { error: outcome.error }); + } + } catch (error) { + this.dispatchTypedEvent("oauthError", { + error: error instanceof Error ? error : new Error(String(error)), + }); + } + } + + private ambientAuthChallengeKey(challenge: AuthChallenge): string { + const requiredScopes = [...(challenge.requiredScopes ?? [])] + .sort() + .join(" "); + const authorizationScopes = [...(challenge.authorizationScopes ?? [])] + .sort() + .join(" "); + return `${challenge.reason}:${requiredScopes}:${authorizationScopes}`; + } + + /** + * Full disconnect + reconnect after ambient auth recovery on direct transports. + */ + private async reconnectAfterAuthRecovery(): Promise { + await this.disconnect().catch(() => {}); + await this.dropCachedTransport(); + await this.connect(); + } + + /** Direct (non-remote) OAuth transports recover via fetch intercept + handleAuthChallenge. */ + private usesDirectAuthRecovery(): boolean { + return this.directAuthRecovery && this.directAuthRecoveryActive === true; + } + + private async withDirectAuthRecovery( + operation: () => Promise, + context?: { method?: string; toolName?: string }, + attempt = 0, + ): Promise { + try { + return await operation(); + } catch (err) { + if (attempt >= 1 || !this.usesDirectAuthRecovery()) { + throw err; + } + if (!isAuthChallengeError(err)) { + throw err; + } + const challenge = parseAuthChallengeFromError(err, context); + /* v8 ignore next 3 -- defensive: parseAuthChallengeFromError shares isAuthChallengeError's checks, so it always returns a truthy challenge once that guard passes */ + if (!challenge) { + throw err; + } + + if (context?.method || context?.toolName) { + this.dispatchTypedEvent("authChallengeCommand", { challenge }); + } else { + this.dispatchTypedEvent("authChallengeAmbient", { challenge }); + } + const outcome = await this.handleAuthChallenge(challenge); + if (outcome.kind === "satisfied") { + // Reconnect aborts activeToolCallAbortController; clear it so callTool + // retries are not immediately rejected with "Disconnected". + if (this.activeToolCallAbortController) { + this.activeToolCallAbortController = undefined; + } + await this.reconnectAfterAuthRecovery(); + this.dispatchTypedEvent("authChallengeRecovered", { challenge }); + return this.withDirectAuthRecovery(operation, context, attempt + 1); + } + if (outcome.kind === "step_up_confirm") { + throw new AuthRecoveryRequiredError( + EMA_STEP_UP_PENDING_URL, + outcome.challenge, + { emaStepUpConfirm: true }, + ); + } + if (outcome.kind === "interactive") { + throw new AuthRecoveryRequiredError( + outcome.authorizationUrl, + outcome.challenge, + ); + } + this.dispatchTypedEvent("oauthError", { error: outcome.error }); + throw outcome.error; + } + } + + private async invokeMcpClient( + operation: () => Promise, + context?: { method?: string; toolName?: string }, + ): Promise { + if (!this.usesDirectAuthRecovery()) { + return operation(); + } + return this.withDirectAuthRecovery(operation, context); + } + + /** + * Completes OAuth flow with authorization code from the redirect callback. + * Direct transports reconnect after token exchange so the live MCP session + * picks up the new Bearer token (mirrors silent recovery reconnect). + */ + async completeOAuthFlow( + authorizationCode: string, + iss?: string, + ): Promise { + await this.ensureOAuthManager().completeOAuthFlow(authorizationCode, iss); + if (this.usesDirectAuthRecovery()) { + await this.reconnectAfterAuthRecovery(); + } + } + + /** + * Navigate to the authorization server for interactive recovery. + */ + async beginInteractiveAuthorization(authorizationUrl: URL): Promise { + return this.ensureOAuthManager().beginInteractiveAuthorization( + authorizationUrl, + ); + } + + /** Remote Hono session id when using {@link RemoteClientTransport}. */ + getRemoteBackendSessionId(): string | undefined { + if (this.baseTransport instanceof RemoteClientTransport) { + return this.baseTransport.getRemoteBackendSessionId(); + } + return undefined; + } + + /** + * Finish OAuth after a full-page redirect and reconnect (or reattach) the MCP session. + */ + async resumeAfterOAuth( + authorizationCode: string, + options?: { remoteSessionId?: string }, + ): Promise { + await this.completeOAuthFlow(authorizationCode); + + const remoteSessionId = options?.remoteSessionId; + const transport = this.baseTransport; + + if (remoteSessionId && transport instanceof RemoteClientTransport) { + try { + await transport.attachToSession(remoteSessionId); + await transport.pushAuthState(); + if (this.status !== "connected") { + await this.connect(); + } + return; + } catch { + // Session expired during OAuth round trip — fall back to fresh connect. + } + } + + if (this.status !== "connected") { + await this.connect(); + } } /** diff --git a/core/mcp/inspectorClientEventTarget.ts b/core/mcp/inspectorClientEventTarget.ts index a5f9d882..bc9ffa1a 100644 --- a/core/mcp/inspectorClientEventTarget.ts +++ b/core/mcp/inspectorClientEventTarget.ts @@ -39,6 +39,7 @@ import type { SamplingCreateMessage } from "./samplingCreateMessage.js"; import type { ElicitationCreateMessage } from "./elicitationCreateMessage.js"; import type { JsonValue } from "../json/jsonUtils.js"; import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; +import type { AuthChallenge } from "../auth/challenge.js"; /** Task with createdAt optional so we can emit synthetic tasks (e.g. on result/error) that omit it. */ export type TaskWithOptionalCreatedAt = Omit & { @@ -148,6 +149,17 @@ export interface InspectorClientEventMap { oauthComplete: { tokens: OAuthTokens }; oauthAuthorizationRequired: { url: URL }; oauthError: { error: Error }; + /** Ambient (SSE) auth challenge while no command-scoped send is active. */ + authChallengeAmbient: { challenge: AuthChallenge }; + /** Command-scoped direct-transport auth challenge (no ambient toast). */ + authChallengeCommand: { challenge: AuthChallenge }; + /** Ambient auth recovery completed (remote auth state pushed). */ + authChallengeRecovered: { challenge: AuthChallenge }; + /** Interactive auth required; App orchestrates redirect (step-up modal or reauth). */ + authChallengeInteractive: { + challenge: AuthChallenge; + authorizationUrl: URL; + }; } /** diff --git a/core/mcp/node/authChallengeFetch.ts b/core/mcp/node/authChallengeFetch.ts new file mode 100644 index 00000000..00361a39 --- /dev/null +++ b/core/mcp/node/authChallengeFetch.ts @@ -0,0 +1,35 @@ +import { + AuthChallengeError, + parseAuthChallengeFromResponse, +} from "../../auth/challenge.js"; + +/** + * Wrap fetch so MCP HTTP 401/403 responses become {@link AuthChallengeError} + * before the SDK invokes `auth()` on a frozen remote token provider. + */ +export function createAuthChallengeInterceptFetch( + baseFetch: typeof fetch, +): typeof fetch { + return async (input, init) => { + const response = await baseFetch(input, init); + if (response.status !== 401 && response.status !== 403) { + return response; + } + + const challenge = parseAuthChallengeFromResponse(response); + /* v8 ignore next 3 -- parseAuthChallengeFromResponse only returns undefined for non-401/403, which the status guard above already excludes */ + if (!challenge) { + return response; + } + + // Release the connection before throwing so the SDK transport is not left + // with a half-read 401/403 body on streamable HTTP. + await response.body?.cancel().catch(() => {}); + + throw new AuthChallengeError( + challenge, + response.status, + `MCP auth challenge (${response.status})`, + ); + }; +} \ No newline at end of file diff --git a/core/mcp/node/transport.ts b/core/mcp/node/transport.ts index 2942fb67..ee04c855 100644 --- a/core/mcp/node/transport.ts +++ b/core/mcp/node/transport.ts @@ -12,6 +12,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { createFetchTracker } from "../fetchTracking.js"; +import { createAuthChallengeInterceptFetch } from "./authChallengeFetch.js"; import type { Dispatcher } from "undici"; /** Node's `RequestInit` plus the undici-specific `dispatcher` option. */ @@ -108,9 +109,15 @@ export function createTransportNode( onFetchResponseBody, authProvider, settings, + interceptAuthChallenges = false, } = options; + // Proxy-wrap first so the auth-challenge interceptor observes responses from + // the (optionally proxied) network call. const baseFetch = withProxyDispatcher(optionsFetchFn ?? globalThis.fetch); + const fetchWithOptionalAuthIntercept = interceptAuthChallenges + ? createAuthChallengeInterceptFetch(baseFetch) + : baseFetch; if (serverType === "stdio") { const stdioConfig = config as StdioServerConfig; @@ -143,7 +150,8 @@ export function createTransportNode( // A caller-supplied eventSourceInit.fetch wins as-is (explicit fetch is not // re-wrapped for proxying); the default path uses the proxy-aware baseFetch. const sseFetch = - (sseConfig.eventSourceInit?.fetch as typeof fetch) || baseFetch; + (sseConfig.eventSourceInit?.fetch as typeof fetch) || + fetchWithOptionalAuthIntercept; const trackedFetch = onFetchRequest ? createFetchTracker(sseFetch, { trackRequest: onFetchRequest, @@ -165,11 +173,11 @@ export function createTransportNode( }; const postFetch = onFetchRequest - ? createFetchTracker(baseFetch, { + ? createFetchTracker(fetchWithOptionalAuthIntercept, { trackRequest: onFetchRequest, updateResponseBody: onFetchResponseBody, }) - : baseFetch; + : fetchWithOptionalAuthIntercept; const transport = new SSEClientTransport(url, { authProvider, @@ -192,11 +200,11 @@ export function createTransportNode( }; const transportFetch = onFetchRequest - ? createFetchTracker(baseFetch, { + ? createFetchTracker(fetchWithOptionalAuthIntercept, { trackRequest: onFetchRequest, updateResponseBody: onFetchResponseBody, }) - : baseFetch; + : fetchWithOptionalAuthIntercept; const transport = new StreamableHTTPClientTransport(url, { authProvider, diff --git a/core/mcp/oauthManager.ts b/core/mcp/oauthManager.ts index 298d39ad..878495fa 100644 --- a/core/mcp/oauthManager.ts +++ b/core/mcp/oauthManager.ts @@ -8,8 +8,9 @@ import { BaseOAuthClientProvider } from "../auth/providers.js"; import type { OAuthFlowState, OAuthStep } from "../auth/types.js"; import { EMPTY_OAUTH_FLOW_STATE } from "../auth/types.js"; import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; -import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; import type { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { mcpAuth } from "../auth/mcpAuth.js"; +import type { OAuthStorage } from "../auth/storage.js"; import { parseOAuthState } from "../auth/utils.js"; import type { EnterpriseManagedAuthIdpConfig } from "../client/types.js"; import type { ClientConfig } from "../client/types.js"; @@ -24,12 +25,29 @@ import { import { buildOAuthConnectionState, hasPersistedOAuthServerState, + isAccessTokenUsable, isServerOAuthConfigured, protocolFromOAuthConfig, } from "../auth/connection-state.js"; import { ensureCimdClientRegistration } from "../auth/cimd.js"; import type { OAuthConnectionState } from "../auth/types.js"; import { EmaTransportOAuthProvider } from "../auth/ema/transportProvider.js"; +import type { + AuthChallenge, + AuthChallengeOutcome, + HandleAuthChallengeOptions, +} from "../auth/challenge.js"; +import { + parseScopeString, + unionAuthorizationScopes, +} from "../auth/challenge.js"; +import { + computeScopeUnion, + isStrictScopeSuperset, + resolveEffectiveGrantedScope, + resolvePersistedScopeAfterGrant, +} from "../auth/scopes.js"; +import { stepUpInsufficientScopeMessage } from "../auth/oauthUx.js"; import type { InspectorClientEnvironment, InspectorClientOptions, @@ -60,10 +78,14 @@ export class OAuthManager { private params: OAuthManagerParams; private oauthConfig: OAuthManagerConfig; private oauthFlowState: OAuthFlowState | null = null; + /** SEP-2350 union scope pending until interactive step-up completes. */ + private pendingAuthorizationScope: string | undefined; + private authChallengeMutex: Promise = Promise.resolve(); constructor(params: OAuthManagerParams) { this.params = params; this.oauthConfig = { ...params.initialConfig }; + this.pendingAuthorizationScope = undefined; } setOAuthConfig(config: { @@ -83,6 +105,19 @@ export class OAuthManager { return this.params.getServerUrl(); } + /** + * Return the configured OAuth storage, throwing a clear error if it is + * absent. Used on the EMA re-mint/persist paths, which are only reached for + * a fully-configured OAuth-capable server (storage present). + */ + private requireStorage(): OAuthStorage { + const storage = this.oauthConfig.storage; + if (!storage) { + throw new Error("OAuth storage is required for this operation."); + } + return storage; + } + private async createOAuthProvider(): Promise { if ( !this.oauthConfig.storage || @@ -104,7 +139,8 @@ export class OAuthManager { provider.setEventTarget(this.params.getEventTarget()); - if (this.oauthConfig.scope) { + const storedScope = this.oauthConfig.storage.getScope(serverUrl); + if (storedScope === undefined && this.oauthConfig.scope) { await provider.saveScope(this.oauthConfig.scope); } @@ -146,7 +182,11 @@ export class OAuthManager { idp, resourceClientId: this.oauthConfig.clientId, resourceClientSecret: this.oauthConfig.clientSecret, - scope: this.oauthConfig.scope, + scope: + computeScopeUnion( + this.oauthConfig.scope, + this.oauthConfig.storage.getScope(this.getServerUrl()), + ) || this.oauthConfig.scope, redirectUrl: this.oauthConfig.redirectUrlProvider.getRedirectUrl(), storage: this.oauthConfig.storage, fetchFn: this.params.effectiveAuthFetch, @@ -181,12 +221,8 @@ export class OAuthManager { } } - this.oauthConfig.navigation!.navigateToAuthorization(authorizationUrl); - this.oauthFlowState = { - ...EMPTY_OAUTH_FLOW_STATE, - oauthStep: "authorization_code", - authorizationUrl, - }; + this.requireNavigation().navigateToAuthorization(authorizationUrl); + await this.recordAuthorizationCodeFlowState(authorizationUrl); return authorizationUrl; } @@ -206,7 +242,7 @@ export class OAuthManager { fetchFn: this.params.effectiveAuthFetch, }); - const result = await auth(provider, { + const result = await mcpAuth(provider, { serverUrl, scope: provider.scope, fetchFn: this.params.effectiveAuthFetch, @@ -241,14 +277,33 @@ export class OAuthManager { return capturedUrl; } - async completeOAuthFlow(authorizationCode: string): Promise { + async completeOAuthFlow( + authorizationCode: string, + iss?: string, + ): Promise { try { if (this.isEnterpriseManaged()) { - const config = this.getEmaFlowConfig(); + const scopeForMint = + this.pendingAuthorizationScope ?? this.getEmaFlowConfig().scope; + const config = scopeForMint + ? { ...this.getEmaFlowConfig(), scope: scopeForMint } + : this.getEmaFlowConfig(); const tokens = await completeEmaIdpAuthorizationAndMint( config, authorizationCode, ); + const requestedScope = this.pendingAuthorizationScope; + const scopeToPersist = resolvePersistedScopeAfterGrant( + tokens.scope, + requestedScope, + ); + if (scopeToPersist) { + await this.requireStorage().saveScope( + this.getServerUrl(), + scopeToPersist, + ); + } + this.pendingAuthorizationScope = undefined; const completedAt = Date.now(); this.oauthFlowState = { ...EMPTY_OAUTH_FLOW_STATE, @@ -263,9 +318,10 @@ export class OAuthManager { const provider = await this.createOAuthProvider(); const serverUrl = this.getServerUrl(); - const result = await auth(provider, { + const result = await mcpAuth(provider, { serverUrl, authorizationCode, + iss, fetchFn: this.params.effectiveAuthFetch, }); @@ -280,6 +336,16 @@ export class OAuthManager { throw new Error("Failed to retrieve tokens after authorization"); } + const requestedScope = this.pendingAuthorizationScope; + const scopeToPersist = resolvePersistedScopeAfterGrant( + tokens.scope, + requestedScope, + ); + if (scopeToPersist) { + await provider.saveScope(scopeToPersist); + } + this.pendingAuthorizationScope = undefined; + const clientInfo = await provider.clientInformation(); const completedAt = Date.now(); this.oauthFlowState = this.oauthFlowState @@ -300,6 +366,7 @@ export class OAuthManager { this.params.dispatchOAuthComplete({ tokens }); } catch (error) { + this.pendingAuthorizationScope = undefined; this.params.dispatchOAuthError({ error: error instanceof Error ? error : new Error(String(error)), }); @@ -329,6 +396,7 @@ export class OAuthManager { this.oauthConfig.storage.clear(serverUrl); this.oauthFlowState = null; + this.pendingAuthorizationScope = undefined; } async isOAuthAuthorized(): Promise { @@ -368,7 +436,7 @@ export class OAuthManager { protocol: protocolFromOAuthConfig(this.oauthConfig), configuredScope: this.oauthConfig.scope, enterpriseManagedAuth: this.params.enterpriseManagedAuth, - storage: this.oauthConfig.storage!, + storage, flowState: this.oauthFlowState ?? undefined, }); } @@ -382,6 +450,408 @@ export class OAuthManager { return tokens !== undefined; } + /** + * Re-read persisted OAuth state and determine whether `challenge` is already + * satisfied without an authorization-server round-trip. + * + * Returns `true` for `insufficient_scope` when stored + token scope cover the + * SEP-2350 union. For `token_expired`, returns `true` when a usable access + * token is already in storage. `invalid_token` and `unauthorized` always + * return `false` — the resource server explicitly rejected the credential. + */ + async checkAuthChallengeSatisfied( + challenge: AuthChallenge, + ): Promise { + const storage = this.oauthConfig.storage; + if (!storage) { + return false; + } + + const serverUrl = this.getServerUrl(); + const tokens = await storage.getTokens(serverUrl); + if (!tokens?.access_token) { + return false; + } + + if (challenge.reason !== "insufficient_scope") { + return ( + challenge.reason === "token_expired" && isAccessTokenUsable(tokens) + ); + } + + const enriched = await this.enrichChallengeWithAuthorizationScopes( + challenge, + tokens.scope, + ); + const authorizationScopesJoined = enriched.authorizationScopes?.join(" "); + /* v8 ignore next -- authorizationScopes from enrichChallengeWithAuthorizationScopes is always a defined array here, so this fallback is unreachable */ + const scopeForAuth = + authorizationScopesJoined ?? enriched.requiredScopes?.join(" "); + if (!scopeForAuth?.trim()) { + return false; + } + + const effectiveScope = resolveEffectiveGrantedScope( + storage.getScope(serverUrl), + tokens.scope, + ); + return !isStrictScopeSuperset(scopeForAuth, effectiveScope); + } + + /** + * Satisfy a mid-session auth challenge when possible (silent refresh/re-mint or + * interactive redirect). + * + * Only `insufficient_scope` short-circuits on {@link checkAuthChallengeSatisfied} + * here — `token_expired` / `unauthorized` still attempt silent refresh even when + * storage holds a locally-valid token (the resource server may have invalidated it). + * Callers use {@link checkAuthChallengeSatisfied} directly before visible OAuth. + * + * Recovery is serialized per server so parallel challenges cannot race on + * `pendingAuthorizationScope` or OAuth provider state. + */ + async handleAuthChallenge( + challenge: AuthChallenge, + options?: HandleAuthChallengeOptions, + ): Promise { + if ( + challenge.reason === "insufficient_scope" && + (await this.checkAuthChallengeSatisfied(challenge)) + ) { + return { kind: "satisfied" }; + } + + const prior = this.authChallengeMutex; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + this.authChallengeMutex = gate; + await prior; + try { + if ( + challenge.reason === "insufficient_scope" && + (await this.checkAuthChallengeSatisfied(challenge)) + ) { + return { kind: "satisfied" }; + } + return await this.runHandleAuthChallenge(challenge, options); + } finally { + release(); + } + } + + private async runHandleAuthChallenge( + challenge: AuthChallenge, + options?: HandleAuthChallengeOptions, + ): Promise { + if (this.isEnterpriseManaged()) { + return this.handleEnterpriseManagedAuthChallenge(challenge, options); + } + return this.handleStandardAuthChallenge(challenge); + } + + private async enrichChallengeWithAuthorizationScopes( + challenge: AuthChallenge, + grantedTokenScope?: string, + ): Promise { + if (challenge.reason !== "insufficient_scope") { + return challenge; + } + + const storage = this.oauthConfig.storage; + const serverUrl = this.getServerUrl(); + + if (grantedTokenScope === undefined && storage) { + const tokens = await storage.getTokens(serverUrl); + grantedTokenScope = tokens?.scope; + } + + const previousScope = computeScopeUnion( + storage?.getScope(serverUrl), + grantedTokenScope, + ); + const requiredFromChallenge = + challenge.requiredScopes?.filter(Boolean) ?? []; + const grantedSet = new Set(parseScopeString(previousScope)); + const missingRequired = requiredFromChallenge.filter( + (scope) => !grantedSet.has(scope), + ); + const requiredScopes = + missingRequired.length > 0 ? missingRequired : requiredFromChallenge; + + const authorizationScopes = unionAuthorizationScopes( + previousScope, + requiredFromChallenge, + ); + + return { + ...challenge, + requiredScopes, + authorizationScopes, + }; + } + + private resolveEmaScopeForChallenge( + challenge: AuthChallenge, + ): string | undefined { + if (challenge.reason === "insufficient_scope") { + const fromChallenge = challenge.requiredScopes?.join(" ").trim(); + /* v8 ignore next 3 -- authorizationScopes is empty only when requiredScopes is also empty, so this branch is unreachable */ + if (fromChallenge) { + return fromChallenge; + } + } + return this.oauthConfig.scope?.trim() || undefined; + } + + private emaFlowConfigForChallenge(challenge: AuthChallenge): EmaFlowConfig { + const base = this.getEmaFlowConfig(); + const enriched = challenge.authorizationScopes; + if (enriched && enriched.length > 0) { + return { ...base, scope: enriched.join(" ") }; + } + const scope = this.resolveEmaScopeForChallenge(challenge); + return scope ? { ...base, scope } : base; + } + + private async handleEnterpriseManagedAuthChallenge( + challenge: AuthChallenge, + options?: HandleAuthChallengeOptions, + ): Promise { + const enriched = + await this.enrichChallengeWithAuthorizationScopes(challenge); + + if (enriched.reason === "insufficient_scope" && !options?.confirmedStepUp) { + return { kind: "step_up_confirm", challenge: enriched }; + } + + const config = this.emaFlowConfigForChallenge(enriched); + + if (enriched.reason === "insufficient_scope") { + const silent = await trySilentEmaAuth(config); + if (silent.status === "success") { + if (await this.checkAuthChallengeSatisfied(enriched)) { + const minted = await this.requireStorage().getTokens( + this.getServerUrl(), + ); + const scopeToPersist = resolvePersistedScopeAfterGrant( + minted?.scope, + enriched.authorizationScopes?.join(" "), + ); + if (scopeToPersist) { + await this.requireStorage().saveScope( + this.getServerUrl(), + scopeToPersist, + ); + } + return { kind: "satisfied" }; + } + } + if (silent.status === "mint_failed") { + return { kind: "failed", error: silent.error }; + } + } else { + const tokens = await refreshEmaResourceTokens(config); + if (tokens) { + return { kind: "satisfied" }; + } + } + + try { + const authorizationUrl = await startEmaIdpAuthorization(config); + if ( + enriched.reason === "insufficient_scope" && + enriched.authorizationScopes?.length + ) { + this.pendingAuthorizationScope = enriched.authorizationScopes.join(" "); + } + return { kind: "interactive", authorizationUrl, challenge: enriched }; + } catch (error) { + return { + kind: "failed", + error: error instanceof Error ? error : new Error(String(error)), + }; + } + } + + private async handleStandardAuthChallenge( + challenge: AuthChallenge, + ): Promise { + const provider = await this.createOAuthProvider(); + const serverUrl = this.getServerUrl(); + const tokens = await provider.tokens(); + const enriched = await this.enrichChallengeWithAuthorizationScopes( + challenge, + tokens?.scope, + ); + + provider.clearCapturedAuthUrl(); + + await ensureCimdClientRegistration({ + serverUrl, + provider, + fetchFn: this.params.effectiveAuthFetch, + }); + + const scopeForAuth = + enriched.reason === "insufficient_scope" + ? enriched.authorizationScopes?.join(" ") + : this.oauthConfig.scope?.trim() || + (enriched.requiredScopes?.length + ? enriched.requiredScopes.join(" ") + : undefined); + + provider.setSuppressAuthorizationNavigation(true); + let result: Awaited>; + try { + result = await mcpAuth(provider, { + serverUrl, + scope: scopeForAuth, + fetchFn: this.params.effectiveAuthFetch, + ...(enriched.reason === "insufficient_scope" && { + forceReauthorization: isStrictScopeSuperset( + scopeForAuth, + tokens?.scope, + ), + }), + }); + } finally { + provider.setSuppressAuthorizationNavigation(false); + } + + if (result === "AUTHORIZED") { + if (enriched.reason === "insufficient_scope") { + if (await this.checkAuthChallengeSatisfied(enriched)) { + const freshTokens = await provider.tokens(); + const scopeToPersist = resolvePersistedScopeAfterGrant( + freshTokens?.scope, + scopeForAuth, + ); + if (scopeToPersist) { + await provider.saveScope(scopeToPersist); + } + return { kind: "satisfied" }; + } + const forced = await this.tryForceReauthorizationForStepUp( + provider, + serverUrl, + scopeForAuth, + enriched, + ); + if (forced) { + return forced; + } + return { + kind: "failed", + error: new Error(stepUpInsufficientScopeMessage(enriched)), + }; + } else { + return { kind: "satisfied" }; + } + } + + const capturedUrl = provider.getCapturedAuthUrl(); + if (!capturedUrl) { + return { + kind: "failed", + error: new Error("Failed to capture authorization URL"), + }; + } + + if (enriched.reason === "insufficient_scope" && scopeForAuth) { + this.pendingAuthorizationScope = scopeForAuth; + } + + const clientInfo = await provider.clientInformation(); + await this.recordAuthorizationCodeFlowState(capturedUrl, clientInfo); + + return { + kind: "interactive", + authorizationUrl: capturedUrl, + challenge: enriched, + }; + } + + /** Start interactive OAuth after handleAuthChallenge returns `interactive`. */ + async beginInteractiveAuthorization(authorizationUrl: URL): Promise { + const stateParam = authorizationUrl.searchParams.get("state"); + if (stateParam && this.params.onBeforeOAuthRedirect) { + const parsedState = parseOAuthState(stateParam); + if (parsedState?.authId) { + await this.params.onBeforeOAuthRedirect(parsedState.authId); + } + } + + this.requireNavigation().navigateToAuthorization(authorizationUrl); + + const provider = await this.createOAuthProvider(); + const clientInfo = await provider.clientInformation(); + await this.recordAuthorizationCodeFlowState(authorizationUrl, clientInfo); + + this.params.dispatchOAuthAuthorizationRequired({ url: authorizationUrl }); + } + + private requireNavigation(): NonNullable { + const navigation = this.oauthConfig.navigation; + if (!navigation) { + throw new Error("OAuth navigation is required."); + } + return navigation; + } + + private async recordAuthorizationCodeFlowState( + authorizationUrl: URL, + oauthClientInfo?: OAuthClientInformation | null, + ): Promise { + this.oauthFlowState = { + ...EMPTY_OAUTH_FLOW_STATE, + oauthStep: "authorization_code", + authorizationUrl, + oauthClientInfo: oauthClientInfo ?? null, + }; + } + + /** + * Silent refresh returned AUTHORIZED but token scope still lacks the step-up + * union — force a fresh authorization redirect. + */ + private async tryForceReauthorizationForStepUp( + provider: BaseOAuthClientProvider, + serverUrl: string, + scopeForAuth: string | undefined, + enriched: AuthChallenge, + ): Promise { + provider.clearCapturedAuthUrl(); + provider.setSuppressAuthorizationNavigation(true); + let result: Awaited>; + try { + result = await mcpAuth(provider, { + serverUrl, + scope: scopeForAuth, + fetchFn: this.params.effectiveAuthFetch, + forceReauthorization: true, + }); + } finally { + provider.setSuppressAuthorizationNavigation(false); + } + if (result !== "AUTHORIZED") { + return null; + } + if (await this.checkAuthChallengeSatisfied(enriched)) { + const freshTokens = await provider.tokens(); + const scopeToPersist = resolvePersistedScopeAfterGrant( + freshTokens?.scope, + scopeForAuth, + ); + if (scopeToPersist) { + await provider.saveScope(scopeToPersist); + } + return { kind: "satisfied" }; + } + return null; + } + /** * Create an OAuth provider for transport auth (connect()). * Used only when isHttpOAuthConfig() is true. diff --git a/core/mcp/remote/index.ts b/core/mcp/remote/index.ts index 959acece..0ac361b6 100644 --- a/core/mcp/remote/index.ts +++ b/core/mcp/remote/index.ts @@ -6,6 +6,7 @@ export { RemoteClientTransport, type RemoteTransportOptions, + type AuthRecoveryHandlers, } from "./remoteClientTransport.js"; export { createRemoteTransport, @@ -26,6 +27,7 @@ export { export type { RemoteConnectRequest, RemoteConnectResponse, + RemoteSendResponse, RemoteEvent, } from "./types.js"; export { API_SERVER_ENV_VARS, LEGACY_AUTH_TOKEN_ENV } from "./constants.js"; diff --git a/core/mcp/remote/node/remote-session.ts b/core/mcp/remote/node/remote-session.ts index af4c81dd..83abd5dd 100644 --- a/core/mcp/remote/node/remote-session.ts +++ b/core/mcp/remote/node/remote-session.ts @@ -6,12 +6,21 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; import type { FetchRequestEntryBase } from "../../types.js"; import type { RemoteEvent } from "../types.js"; +import type { AuthChallenge } from "../../../auth/challenge.js"; +import { AuthChallengeError } from "../../../auth/challenge.js"; +import type { RemoteAuthProviderHandle } from "./tokenAuthProvider.js"; +import type { RemoteAuthState } from "../types.js"; export interface SessionEvent { type: RemoteEvent["type"]; data: unknown; } +type RequestWait = { + resolve: () => void; + reject: (error: Error) => void; +}; + export class RemoteSession { public readonly sessionId: string; public transport!: Transport; @@ -19,11 +28,31 @@ export class RemoteSession { private eventConsumer: ((event: SessionEvent) => void) | null = null; private transportDead: boolean = false; private transportError: string | null = null; + private activeSendCount = 0; + /** + * Suppress duplicate ambient SSE auth echoes after HTTP command-scoped delivery. + * Stale transport `onerror` may arrive after the send completes. + */ + private authHttpEchoSuppressUntilMs = 0; + static readonly AUTH_HTTP_ECHO_SUPPRESS_MS = 30_000; + private readonly requestWaits = new Map(); + private authProviderHandle: RemoteAuthProviderHandle | null = null; constructor(sessionId: string) { this.sessionId = sessionId; } + setAuthProviderHandle(handle: RemoteAuthProviderHandle | null): void { + this.authProviderHandle = handle; + } + + setAuthState(authState: RemoteAuthState): void { + if (!this.authProviderHandle) { + throw new Error("Session has no OAuth auth provider"); + } + this.authProviderHandle.setAuthState(authState); + } + setTransport(transport: Transport): void { this.transport = transport; } @@ -72,6 +101,124 @@ export class RemoteSession { return this.eventConsumer !== null; } + beginSend(): void { + this.activeSendCount++; + } + + endSend(): void { + if (this.activeSendCount > 0) { + this.activeSendCount--; + } + } + + /** Command-scoped auth was returned on HTTP; suppress async onerror echo on SSE. */ + noteAuthChallengeDeliveredViaHttp(): void { + this.extendAuthHttpEchoSuppression(); + } + + private extendAuthHttpEchoSuppression(): void { + this.authHttpEchoSuppressUntilMs = + Date.now() + RemoteSession.AUTH_HTTP_ECHO_SUPPRESS_MS; + } + + private shouldSuppressAuthEcho(): boolean { + return Date.now() < this.authHttpEchoSuppressUntilMs; + } + + hasActiveSend(): boolean { + return this.activeSendCount > 0; + } + + pushAuthChallenge(challenge: AuthChallenge): void { + this.pushEvent({ type: "auth_challenge", data: challenge }); + } + + /** + * Streamable HTTP `transport.send()` can return before the JSON-RPC response + * arrives. The remote send handler awaits this so `/api/mcp/send` returns + * `auth_challenge` or `ok: true` only after the MCP round-trip (or auth error). + */ + waitForRequestResponse( + requestId: string | number, + timeoutMs = 60_000, + ): Promise { + return new Promise((resolve, reject) => { + const timer = + timeoutMs > 0 + ? setTimeout(() => { + this.requestWaits.delete(requestId); + reject( + new Error( + `MCP request ${String(requestId)} timed out after ${timeoutMs}ms`, + ), + ); + }, timeoutMs) + : undefined; + + this.requestWaits.set(requestId, { + resolve: () => { + if (timer !== undefined) { + clearTimeout(timer); + } + resolve(); + }, + reject: (error) => { + if (timer !== undefined) { + clearTimeout(timer); + } + reject(error); + }, + }); + }); + } + + cancelRequestWait(requestId: string | number): void { + const wait = this.requestWaits.get(requestId); + if (!wait) { + return; + } + this.requestWaits.delete(requestId); + wait.reject(new Error("MCP request wait cancelled")); + } + + rejectActiveRequestWaits(error: Error): void { + for (const wait of this.requestWaits.values()) { + wait.reject(error); + } + this.requestWaits.clear(); + } + + /** + * Auth errors from the MCP transport. + * - During an active send: command path owns delivery (HTTP); never SSE. + * - After HTTP already returned auth_challenge: swallow async onerror echo. + * - Otherwise: ambient SSE (idle session). + */ + handleTransportAuthError(error: unknown): error is AuthChallengeError { + if (!(error instanceof AuthChallengeError)) { + return false; + } + this.rejectActiveRequestWaits(error); + if (this.hasActiveSend()) { + this.extendAuthHttpEchoSuppression(); + return true; + } + if (this.shouldSuppressAuthEcho()) { + return true; + } + this.pushAuthChallenge(error.authChallenge); + return true; + } + + private settleRequestWait(requestId: string | number): void { + const wait = this.requestWaits.get(requestId); + if (!wait) { + return; + } + this.requestWaits.delete(requestId); + wait.resolve(); + } + pushEvent(event: SessionEvent): void { if (this.eventConsumer) { this.eventConsumer(event); @@ -81,6 +228,14 @@ export class RemoteSession { } onMessage(message: JSONRPCMessage): void { + if ( + "id" in message && + message.id !== null && + message.id !== undefined && + ("result" in message || "error" in message) + ) { + this.settleRequestWait(message.id); + } this.pushEvent({ type: "message", data: message }); } diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 5ec0687a..77804a09 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -26,7 +26,13 @@ import type { Context, Env, Next } from "hono"; import { streamSSE } from "hono/streaming"; import { watch as chokidarWatch, type FSWatcher } from "chokidar"; import { createTransportNode } from "../../node/transport.js"; -import type { RemoteConnectRequest, RemoteSendRequest } from "../types.js"; +import type { + RemoteConnectRequest, + RemoteSendRequest, + RemoteSetAuthStateRequest, +} from "../types.js"; +import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; +import { AuthChallengeError } from "../../../auth/challenge.js"; import { DEFAULT_MAX_FETCH_REQUESTS, DEFAULT_TASK_TTL_MS, @@ -52,7 +58,7 @@ import { } from "../../serverList.js"; import { resolveImportSource } from "../../import/resolveSource.js"; import { RemoteSession } from "./remote-session.js"; -import { createTokenAuthProvider } from "./tokenAuthProvider.js"; +import { createRemoteAuthProvider } from "./tokenAuthProvider.js"; import { API_SERVER_ENV_VARS } from "../constants.js"; import { KeychainUnavailableError, @@ -321,6 +327,20 @@ function forwardLogEvent( } } +function requestIdForSendWait( + message: JSONRPCMessage, +): string | number | undefined { + if ( + "method" in message && + "id" in message && + message.id !== null && + message.id !== undefined + ) { + return message.id; + } + return undefined; +} + export function createRemoteApp( options: RemoteServerOptions, ): CreateRemoteAppResult { @@ -525,9 +545,12 @@ export function createRemoteApp( const session = new RemoteSession(sessionId); let transport: Awaited>["transport"]; + let authHandle: ReturnType; try { - // Create authProvider from tokens if provided - const authProvider = createTokenAuthProvider(body.oauthTokens); + const initialAuthState = + body.authState ?? + (body.oauthTokens ? { oauthTokens: body.oauthTokens } : undefined); + authHandle = createRemoteAuthProvider(initialAuthState); const result = createTransportNode(config, { pipeStderr: true, @@ -535,8 +558,12 @@ export function createRemoteApp( onFetchRequest: (entry) => session.onFetchRequest(entry), onFetchResponseBody: (id, body) => session.onFetchResponseBody(id, body), - authProvider, + authProvider: authHandle?.provider, settings: body.settings, + // Always intercept 401/403 on the node MCP transport. Without this, the + // SDK's built-in auth() retry on a frozen token snapshot can hang or + // succeed locally while /api/mcp/send still awaits a JSON-RPC response. + interceptAuthChallenges: true, }); transport = result.transport; } catch (err) { @@ -544,6 +571,7 @@ export function createRemoteApp( return c.json({ error: `Failed to create transport: ${msg}` }, 500); } + session.setAuthProviderHandle(authHandle ?? null); session.setTransport(transport); transport.onmessage = (msg) => session.onMessage(msg); @@ -558,6 +586,10 @@ export function createRemoteApp( // Set up error handlers BEFORE calling start() so we catch failures during start transport.onerror = (err) => { + if (session.handleTransportAuthError(err)) { + originalOnerror?.(err); + return; + } transportFailed = true; transportError = err instanceof Error ? err.message : String(err); originalOnerror?.(err); @@ -609,6 +641,19 @@ export function createRemoteApp( } } catch (err) { // transport.start() threw - this is the expected failure path + if (err instanceof AuthChallengeError) { + try { + await transport.close(); + } catch { + // Best-effort cleanup when connect fails with auth challenge. + } + session.noteAuthChallengeDeliveredViaHttp(); + return c.json({ + ok: false, + kind: "auth_challenge", + authChallenge: err.authChallenge, + }); + } const msg = err instanceof Error ? err.message : String(err); // Preserve 401 only when the transport/SDK reports it (no message guessing) const status = @@ -648,22 +693,41 @@ export function createRemoteApp( // Check if transport is dead - return error immediately (matches local behavior) if (session.isTransportDead()) { const errorMsg = session.getTransportError() || "Transport closed"; - return c.json({ error: errorMsg }, 500); + return c.json({ ok: false, kind: "transport_error", error: errorMsg }); } + session.beginSend(); + const requestId = requestIdForSendWait(message); + let responseWait: Promise | undefined; + if (requestId !== undefined) { + responseWait = session.waitForRequestResponse(requestId); + // Auth errors may reject the wait via onerror while send() also throws. + void responseWait.catch(() => {}); + } try { await session.transport.send(message, { relatedRequestId: relatedRequestId as string | number | undefined, }); + if (responseWait) { + await responseWait; + } return c.json({ ok: true }); } catch (err) { + if (requestId !== undefined) { + session.cancelRequestWait(requestId); + } + if (err instanceof AuthChallengeError) { + session.noteAuthChallengeDeliveredViaHttp(); + return c.json({ + ok: false, + kind: "auth_challenge", + authChallenge: err.authChallenge, + }); + } const msg = err instanceof Error ? err.message : String(err); - // Preserve 401 only when the transport/SDK reports it (no message guessing) - const status = - (err as { code?: number; status?: number }).code ?? - (err as { code?: number; status?: number }).status; - const is401 = status === 401; - return c.json({ error: msg }, is401 ? 401 : 500); + return c.json({ ok: false, kind: "transport_error", error: msg }); + } finally { + session.endSend(); } }); @@ -746,6 +810,39 @@ export function createRemoteApp( return c.json({ ok: true }); }); + app.post("/api/mcp/auth-state", async (c) => { + let body: RemoteSetAuthStateRequest; + try { + body = (await c.req.json()) as RemoteSetAuthStateRequest; + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + + const { sessionId, authState } = body; + if (!sessionId || !authState) { + return c.json({ error: "Missing sessionId or authState" }, 400); + } + + const session = sessions.get(sessionId); + if (!session) { + return c.json({ error: "Session not found" }, 404); + } + + if (session.isTransportDead()) { + const errorMsg = session.getTransportError() || "Transport closed"; + return c.json({ ok: false, kind: "transport_error", error: errorMsg }); + } + + try { + session.setAuthState(authState); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return c.json({ error: msg }, 400); + } + + return c.json({ ok: true }); + }); + app.post("/api/fetch", async (c) => { let body: { url: string; @@ -1945,10 +2042,8 @@ export function createRemoteApp( // deliberate side-effect of using `readMcpConfig` + full rewrite // here. const existing = current.mcpServers[originalId]; + /* v8 ignore next 5 -- the `in` check above guarantees this branch is unreachable; narrowing without the non-null assertion keeps TS happy and makes the contract explicit for future refactors. */ if (!existing) { - // The `in` check above guarantees this branch is unreachable; - // narrowing without the non-null assertion keeps TS happy and - // makes the contract explicit for future refactors. return c.json({ error: `Server '${originalId}' not found` }, 404); } // Split the existing entry into its SDK-only config (no Inspector- diff --git a/core/mcp/remote/node/tokenAuthProvider.ts b/core/mcp/remote/node/tokenAuthProvider.ts index ea51c4b6..3793eb72 100644 --- a/core/mcp/remote/node/tokenAuthProvider.ts +++ b/core/mcp/remote/node/tokenAuthProvider.ts @@ -1,29 +1,48 @@ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; import type { + OAuthClientInformation, OAuthClientMetadata, OAuthTokens, } from "@modelcontextprotocol/sdk/shared/auth.js"; -import type { RemoteConnectRequest } from "../types.js"; +import type { RemoteAuthState } from "../types.js"; const REMOTE_OAUTH_STUB_METADATA: OAuthClientMetadata = { redirect_uris: [], scope: "", }; -/** - * Simple OAuth client provider that just returns tokens. - * Used by the remote server to inject Bearer tokens into transport requests. - * - * The SDK may invoke {@link auth} on 401; stubs must satisfy the full provider - * surface so that path fails with a clear error instead of throwing on - * undefined `clientMetadata.scope`. - */ -export function createTokenAuthProvider( - tokens: RemoteConnectRequest["oauthTokens"], -): OAuthClientProvider | undefined { - if (!tokens) return undefined; +export interface RemoteAuthProviderHandle { + provider: OAuthClientProvider; + setAuthState: (authState: RemoteAuthState) => void; + getAuthState: () => RemoteAuthState; +} +function cloneAuthState(state: RemoteAuthState): RemoteAuthState { return { + ...(state.oauthTokens && { + oauthTokens: { ...state.oauthTokens }, + }), + ...(state.oauthClient && { + oauthClient: { ...state.oauthClient }, + }), + }; +} + +/** + * Mutable OAuth provider for the remote backend MCP transport. + * Browser (or CLI) owns interactive OAuth; this injects Bearer tokens and can + * be hot-updated via {@link RemoteAuthProviderHandle.setAuthState}. + */ +export function createRemoteAuthProvider( + initialAuthState?: RemoteAuthState, +): RemoteAuthProviderHandle | undefined { + if (!initialAuthState?.oauthTokens && !initialAuthState?.oauthClient) { + return undefined; + } + + let authState = cloneAuthState(initialAuthState); + + const provider = { get clientMetadata(): OAuthClientMetadata { return REMOTE_OAUTH_STUB_METADATA; }, @@ -31,13 +50,31 @@ export function createTokenAuthProvider( return ""; }, async tokens(): Promise { - return tokens as OAuthTokens; + return authState.oauthTokens as OAuthTokens | undefined; }, - async clientInformation() { - return undefined; + async clientInformation(): Promise { + if (!authState.oauthClient?.client_id) { + return undefined; + } + return { + client_id: authState.oauthClient.client_id, + ...(authState.oauthClient.client_secret && { + client_secret: authState.oauthClient.client_secret, + }), + }; }, - async saveTokens() { - // No-op + async saveTokens(tokens: OAuthTokens) { + authState = { + ...authState, + oauthTokens: { + access_token: tokens.access_token, + token_type: tokens.token_type, + expires_in: tokens.expires_in, + refresh_token: tokens.refresh_token, + scope: tokens.scope, + id_token: tokens.id_token, + }, + }; }, codeVerifier() { return undefined; @@ -46,7 +83,7 @@ export function createTokenAuthProvider( // No-op }, clear() { - // No-op + authState = {}; }, async redirectToAuthorization() { throw new Error( @@ -57,4 +94,14 @@ export function createTokenAuthProvider( return ""; }, } as unknown as OAuthClientProvider; + + return { + provider, + setAuthState(next: RemoteAuthState) { + authState = cloneAuthState(next); + }, + getAuthState() { + return cloneAuthState(authState); + }, + }; } diff --git a/core/mcp/remote/remoteClientTransport.ts b/core/mcp/remote/remoteClientTransport.ts index b145c7aa..82c86b4f 100644 --- a/core/mcp/remote/remoteClientTransport.ts +++ b/core/mcp/remote/remoteClientTransport.ts @@ -13,11 +13,33 @@ import type { } from "@modelcontextprotocol/sdk/types.js"; import type { InspectorServerSettings, StderrLogEntry } from "../types.js"; import type { FetchRequestEntryBase } from "../types.js"; +import type { + AuthChallenge, + AuthChallengeOutcome, + HandleAuthChallengeOptions, +} from "../../auth/challenge.js"; +import { + AuthChallengeError, + AuthRecoveryRequiredError, + EMA_STEP_UP_PENDING_URL, +} from "../../auth/challenge.js"; import type { RemoteConnectRequest, RemoteConnectResponse, + RemoteAuthState, RemoteEvent, + RemoteSendResponse, } from "./types.js"; +import { oauthTokensToRemoteAuthState } from "./types.js"; + +export interface AuthRecoveryHandlers { + handleAuthChallenge( + challenge: AuthChallenge, + options?: HandleAuthChallengeOptions, + ): Promise; + /** Push recovered auth state to the remote backend (same session). */ + pushAuthState?: (authState?: RemoteAuthState) => Promise; +} export interface RemoteTransportOptions { /** Base URL of the remote server (e.g. http://localhost:3000) */ @@ -47,6 +69,90 @@ export interface RemoteTransportOptions { * custom headers (SSE / streamable-http). */ settings?: InspectorServerSettings; + + /** Mid-session auth recovery (handle challenge on command-scoped send). */ + authRecovery?: AuthRecoveryHandlers; + + /** Ambient auth challenges delivered via SSE (no active send). */ + onAuthChallenge?: (challenge: AuthChallenge) => void; + + /** Max wait for a JSON-RPC response on SSE after HTTP `{ ok: true }`. Default 60s. */ + sseResponseTimeoutMs?: number; +} + +const DEFAULT_SSE_RESPONSE_TIMEOUT_MS = 60_000; + +type SseResponseWait = { + resolve: () => void; + reject: (error: Error) => void; +}; + +function requestIdForMessage( + message: JSONRPCMessage, +): string | number | undefined { + if ( + "method" in message && + "id" in message && + message.id !== null && + message.id !== undefined + ) { + return message.id; + } + return undefined; +} + +function isConnectAuthChallenge( + json: RemoteConnectResponse, +): json is Extract< + RemoteConnectResponse, + { ok: false; kind: "auth_challenge" } +> { + return ( + typeof json === "object" && + json !== null && + "ok" in json && + json.ok === false && + "kind" in json && + json.kind === "auth_challenge" + ); +} + +function isConnectTransportError( + json: RemoteConnectResponse, +): json is Extract< + RemoteConnectResponse, + { ok: false; kind: "transport_error" } +> { + return ( + typeof json === "object" && + json !== null && + "ok" in json && + json.ok === false && + "kind" in json && + json.kind === "transport_error" + ); +} + +function legacySessionId(json: RemoteConnectResponse): string | undefined { + if ( + typeof json === "object" && + json !== null && + "sessionId" in json && + typeof json.sessionId === "string" && + !("ok" in json) + ) { + return json.sessionId; + } + if ( + typeof json === "object" && + json !== null && + "ok" in json && + json.ok === true && + "sessionId" in json + ) { + return json.sessionId; + } + return undefined; } /** @@ -109,7 +215,13 @@ export class RemoteClientTransport implements Transport { private eventStreamReader: ReadableStreamDefaultReader | null = null; private eventStreamAbort: AbortController | null = null; + private eventStreamConsumeTask: Promise | null = null; + private restartingEventStream = false; private closed = false; + private readonly sseResponseWaits = new Map< + string | number, + SseResponseWait + >(); private readonly options: RemoteTransportOptions; private readonly config: import("../types.js").MCPServerConfig; @@ -124,6 +236,24 @@ export class RemoteClientTransport implements Transport { return undefined; } + /** Remote Hono session id (distinct from MCP protocol session). */ + getRemoteBackendSessionId(): string | undefined { + return this._sessionId; + } + + /** + * Reattach to an existing remote backend session after a full-page OAuth + * redirect. Opens the SSE event stream without POST /connect. + */ + async attachToSession(sessionId: string): Promise { + if (this.closed) { + this.closed = false; + } + await this.stopEventStream(); + this._sessionId = sessionId; + await this.openEventStream(); + } + constructor( options: RemoteTransportOptions, config: import("../types.js").MCPServerConfig, @@ -132,6 +262,16 @@ export class RemoteClientTransport implements Transport { this.config = config; } + setAuthRecovery(handlers: AuthRecoveryHandlers | undefined): void { + this.options.authRecovery = handlers; + } + + setOnAuthChallenge( + handler: ((challenge: AuthChallenge) => void) | undefined, + ): void { + this.options.onAuthChallenge = handler; + } + private get fetchFn(): typeof fetch { return this.options.fetchFn ?? globalThis.fetch; } @@ -150,28 +290,59 @@ export class RemoteClientTransport implements Transport { return h; } + private async recoverFromAuthChallenge( + challenge: AuthChallenge, + retry: () => Promise, + ): Promise { + const recovery = this.options.authRecovery; + if (!recovery) { + throw new AuthChallengeError(challenge, challenge.raw?.httpStatus ?? 401); + } + + const outcome = await recovery.handleAuthChallenge(challenge); + if (outcome.kind === "satisfied") { + await retry(); + return; + } + if (outcome.kind === "step_up_confirm") { + throw new AuthRecoveryRequiredError( + EMA_STEP_UP_PENDING_URL, + outcome.challenge, + { emaStepUpConfirm: true }, + ); + } + if (outcome.kind === "interactive") { + throw new AuthRecoveryRequiredError( + outcome.authorizationUrl, + outcome.challenge, + ); + } + throw outcome.error; + } + async start(): Promise { + if (this._sessionId && !this.closed) { + return; + } + return this.startWithRecovery(0); + } + + private async startWithRecovery(retryCount: number): Promise { /* v8 ignore next -- the sessionId getter is hardcoded to return undefined (see its doc comment), so this guard is never taken; it exists to satisfy the Transport contract. */ if (this.sessionId) return; if (this.closed) throw new Error("Transport is closed"); - // Extract OAuth tokens from authProvider if available - let oauthTokens: RemoteConnectRequest["oauthTokens"] | undefined; + let authState: RemoteAuthState | undefined; if (this.options.authProvider) { const tokens = await this.options.authProvider.tokens(); if (tokens) { - oauthTokens = { - access_token: tokens.access_token, - token_type: tokens.token_type, - expires_in: tokens.expires_in, - refresh_token: tokens.refresh_token, - }; + authState = oauthTokensToRemoteAuthState(tokens); } } const body: RemoteConnectRequest = { config: this.config, - oauthTokens, + ...(authState && { authState }), ...(this.options.settings && { settings: this.options.settings }), }; @@ -183,20 +354,40 @@ export class RemoteClientTransport implements Transport { if (!res.ok) { const text = await res.text(); - // Preserve the status code in the error so callers can detect 401 const error = new Error(`Remote connect failed (${res.status}): ${text}`); (error as { status?: number }).status = res.status; throw error; } const json = (await res.json()) as RemoteConnectResponse; - this._sessionId = json.sessionId; - if (!this._sessionId) { + if (isConnectAuthChallenge(json)) { + if (retryCount >= 1) { + throw new AuthChallengeError( + json.authChallenge, + json.authChallenge.raw?.httpStatus ?? 401, + ); + } + await this.recoverFromAuthChallenge(json.authChallenge, () => + this.startWithRecovery(retryCount + 1), + ); + return; + } + + if (isConnectTransportError(json)) { + throw new Error(`Remote connect failed: ${json.error}`); + } + + const sessionId = legacySessionId(json); + if (!sessionId) { throw new Error("Remote did not return sessionId"); } - // Open SSE event stream + this._sessionId = sessionId; + await this.openEventStream(); + } + + private async openEventStream(): Promise { this.eventStreamAbort = new AbortController(); const eventRes = await this.fetchFn( `${this.baseUrl}/api/mcp/events?sessionId=${encodeURIComponent(this._sessionId!)}`, @@ -221,7 +412,30 @@ export class RemoteClientTransport implements Transport { } this.eventStreamReader = bodyStream.getReader(); - this.consumeEventStream(); + this.eventStreamConsumeTask = this.consumeEventStream(); + } + + /** Stop the SSE consumer and release the reader before opening a new stream. */ + private async stopEventStream(): Promise { + this.restartingEventStream = true; + try { + this.eventStreamAbort?.abort(); + const reader = this.eventStreamReader; + if (reader) { + try { + await reader.cancel(); + } catch { + // Ignore cancel errors during close + } + } + this.eventStreamReader = null; + if (this.eventStreamConsumeTask) { + await this.eventStreamConsumeTask; + this.eventStreamConsumeTask = null; + } + } finally { + this.restartingEventStream = false; + } } private async consumeEventStream(): Promise { @@ -236,7 +450,9 @@ export class RemoteClientTransport implements Transport { const parsed = JSON.parse(data) as RemoteEvent; if (parsed.type === "message") { - this.onmessage?.(parsed.data as JSONRPCMessage, undefined); + const msg = parsed.data as JSONRPCMessage; + this.settleSseResponseWait(msg); + this.onmessage?.(msg, undefined); } else if ( parsed.type === "fetch_request" && this.options.onFetchRequest @@ -262,32 +478,32 @@ export class RemoteClientTransport implements Transport { timestamp: new Date(parsed.data.timestamp), message: parsed.data.message, }); + } else if (parsed.type === "auth_challenge") { + this.options.onAuthChallenge?.(parsed.data); } else if (parsed.type === "transport_error") { - // Transport died - notify client and close (matches local behavior) const error = new Error(parsed.data.error); if (parsed.data.code !== undefined) { (error as { code?: number | string }).code = parsed.data.code; } - this.onerror?.(error); - // Also trigger onclose to match local transport behavior - if (!this.closed) { - this.closed = true; - this.onclose?.(); + if (!this.restartingEventStream) { + this.onerror?.(error); + if (!this.closed) { + this.closed = true; + this.onclose?.(); + } } } } catch (err) { - // JSON parse error or other processing error - report but continue this.onerror?.(err instanceof Error ? err : new Error(String(err))); } } } catch (err) { - // Stream reading error (network issue, abort, etc.) if (!this.closed && err instanceof Error && err.name !== "AbortError") { this.onerror?.(err); } } finally { this.eventStreamReader = null; - if (!this.closed) { + if (!this.closed && !this.restartingEventStream) { this.closed = true; this.onclose?.(); } @@ -298,6 +514,174 @@ export class RemoteClientTransport implements Transport { message: JSONRPCMessage, options?: TransportSendOptions, ): Promise { + return this.postSend(message, options, 0); + } + + /** + * Push auth state to the remote backend without tearing down the session. + * Used after mid-session OAuth recovery in the web client. + */ + async pushAuthState(authState?: RemoteAuthState): Promise { + if (!this._sessionId) { + throw new Error("Transport not started"); + } + if (this.closed) { + throw new Error("Transport is closed"); + } + + const state = authState ?? (await this.buildAuthStateFromProvider()); + if (!state.oauthTokens && !state.oauthClient) { + throw new Error("No auth state to push"); + } + + const res = await this.fetchFn(`${this.baseUrl}/api/mcp/auth-state`, { + method: "POST", + headers: this.headers, + body: JSON.stringify({ sessionId: this._sessionId, authState: state }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error( + `Remote auth-state update failed (${res.status}): ${text}`, + ); + } + + const json = (await res.json()) as { ok?: boolean; error?: string }; + if (!json.ok) { + throw new Error(json.error ?? "Remote auth-state update failed"); + } + } + + private async buildAuthStateFromProvider(): Promise { + if (!this.options.authProvider) { + throw new Error("No auth provider configured"); + } + const tokens = await this.options.authProvider.tokens(); + if (!tokens) { + throw new Error("No OAuth tokens available"); + } + return oauthTokensToRemoteAuthState(tokens); + } + + private get sseResponseTimeoutMs(): number { + return this.options.sseResponseTimeoutMs ?? DEFAULT_SSE_RESPONSE_TIMEOUT_MS; + } + + private settleSseResponseWait(message: JSONRPCMessage): void { + if ( + !("id" in message) || + message.id === null || + message.id === undefined || + (!("result" in message) && !("error" in message)) + ) { + return; + } + const wait = this.sseResponseWaits.get(message.id); + if (!wait) { + return; + } + this.sseResponseWaits.delete(message.id); + wait.resolve(); + } + + private cancelSseResponseWait(requestId: string | number): void { + const wait = this.sseResponseWaits.get(requestId); + if (!wait) { + return; + } + this.sseResponseWaits.delete(requestId); + wait.reject(new Error("SSE response wait cancelled")); + } + + private cancelAllSseWaits(error: Error): void { + for (const wait of this.sseResponseWaits.values()) { + wait.reject(error); + } + this.sseResponseWaits.clear(); + } + + private waitForSseResponse(requestId: string | number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.sseResponseWaits.delete(requestId); + reject( + new Error( + `Timed out waiting for MCP response on SSE (${this.sseResponseTimeoutMs}ms)`, + ), + ); + }, this.sseResponseTimeoutMs); + this.sseResponseWaits.set(requestId, { + resolve: () => { + clearTimeout(timer); + resolve(); + }, + reject: (error) => { + clearTimeout(timer); + reject(error); + }, + }); + }); + } + + private async postSend( + message: JSONRPCMessage, + options: TransportSendOptions | undefined, + retryCount: number, + ): Promise { + const requestId = requestIdForMessage(message); + let sseWait: Promise | undefined; + if (requestId !== undefined) { + sseWait = this.waitForSseResponse(requestId); + void sseWait.catch(() => {}); + } + + let json: RemoteSendResponse; + try { + json = await this.requestSend(message, options); + } catch (error) { + if (requestId !== undefined) { + this.cancelSseResponseWait(requestId); + } + throw error; + } + + if (json.ok) { + if (sseWait) { + await sseWait; + } + return; + } + + if (requestId !== undefined) { + this.cancelSseResponseWait(requestId); + } + + if (json.kind === "auth_challenge") { + // Send-time recovery requires pushAuthState on the existing remote session. + // Connect-time recovery (startWithRecovery) retries with fresh authState in + // the connect body instead — see startWithRecovery(). + if (retryCount >= 1 || !this.options.authRecovery?.pushAuthState) { + throw new AuthChallengeError( + json.authChallenge, + json.authChallenge.raw?.httpStatus ?? 401, + ); + } + + await this.recoverFromAuthChallenge(json.authChallenge, async () => { + await this.options.authRecovery!.pushAuthState!(); + return this.postSend(message, options, retryCount + 1); + }); + return; + } + + throw new Error(json.error); + } + + private async requestSend( + message: JSONRPCMessage, + options?: TransportSendOptions, + ): Promise { if (!this._sessionId) { throw new Error("Transport not started"); } @@ -325,14 +709,16 @@ export class RemoteClientTransport implements Transport { (error as { status?: number }).status = res.status; throw error; } + + return (await res.json()) as RemoteSendResponse; } async close(): Promise { if (this.closed) return; this.closed = true; - this.eventStreamAbort?.abort(); - this.eventStreamReader = null; + this.cancelAllSseWaits(new Error("Transport closed")); + await this.stopEventStream(); if (this._sessionId) { try { diff --git a/core/mcp/remote/types.ts b/core/mcp/remote/types.ts index c2cc1728..7c3ffea5 100644 --- a/core/mcp/remote/types.ts +++ b/core/mcp/remote/types.ts @@ -8,6 +8,50 @@ import type { InspectorServerSettings, } from "../types.js"; import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; +import type { AuthChallenge } from "../../auth/challenge.js"; +import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; + +/** OAuth token set pushed to the remote backend for upstream MCP Bearer auth. */ +export interface RemoteMcpOAuthTokens { + access_token: string; + token_type: string; + expires_in?: number; + refresh_token?: string; + scope?: string; + id_token?: string; +} + +/** + * Auth credentials for the upstream MCP transport on the remote backend. + * Extensible so server-side token refresh can be added without a new API shape. + */ +export interface RemoteAuthState { + /** Bearer tokens for HTTP MCP transports (streamable-http / SSE). */ + oauthTokens?: RemoteMcpOAuthTokens; + /** + * OAuth client credentials for server-side refresh at the token endpoint. + * Optional today while the browser owns interactive OAuth and refresh. + */ + oauthClient?: { + client_id: string; + client_secret?: string; + }; +} + +export function oauthTokensToRemoteAuthState( + tokens: OAuthTokens | RemoteMcpOAuthTokens, +): RemoteAuthState { + return { + oauthTokens: { + access_token: tokens.access_token, + token_type: tokens.token_type, + expires_in: tokens.expires_in, + refresh_token: tokens.refresh_token, + scope: tokens.scope, + id_token: tokens.id_token, + }, + }; +} export interface RemoteConnectRequest { /** MCP server config (stdio, sse, or streamable-http) */ @@ -18,19 +62,60 @@ export interface RemoteConnectRequest { * from the legacy `config.headers` field (which has been removed). */ settings?: InspectorServerSettings; - /** Optional OAuth tokens for Bearer authentication (for HTTP transports) */ - oauthTokens?: { - access_token: string; - token_type: string; - expires_in?: number; - refresh_token?: string; - }; + /** + * Initial auth for upstream MCP HTTP transports. + * Prefer {@link authState}; {@link oauthTokens} is accepted for compatibility. + */ + authState?: RemoteAuthState; + /** @deprecated Prefer {@link authState}. */ + oauthTokens?: RemoteMcpOAuthTokens; } -export interface RemoteConnectResponse { +export interface RemoteSetAuthStateRequest { + sessionId: string; + authState: RemoteAuthState; +} + +export interface RemoteSetAuthStateResponse { + ok: true; +} + +export interface RemoteConnectResponseSuccess { + ok: true; sessionId: string; } +export interface RemoteConnectResponseAuthChallenge { + ok: false; + kind: "auth_challenge"; + authChallenge: AuthChallenge; +} + +export interface RemoteConnectResponseTransportError { + ok: false; + kind: "transport_error"; + error: string; +} + +export type RemoteConnectResponse = + | RemoteConnectResponseSuccess + | RemoteConnectResponseAuthChallenge + | RemoteConnectResponseTransportError + | { sessionId: string }; + +export type RemoteSendResponse = + | { ok: true } + | { + ok: false; + kind: "auth_challenge"; + authChallenge: AuthChallenge; + } + | { + ok: false; + kind: "transport_error"; + error: string; + }; + export interface RemoteSendRequest { message: JSONRPCMessage; /** Optional, for associating response with request (e.g. streamable-http) */ @@ -42,7 +127,8 @@ export type RemoteEventType = | "fetch_request" | "fetch_request_body_update" | "stdio_log" - | "transport_error"; + | "transport_error" + | "auth_challenge"; export interface RemoteEventMessage { type: "message"; @@ -72,9 +158,15 @@ export interface RemoteEventTransportError { }; } +export interface RemoteEventAuthChallenge { + type: "auth_challenge"; + data: AuthChallenge; +} + export type RemoteEvent = | RemoteEventMessage | RemoteEventFetchRequest | RemoteEventFetchRequestBodyUpdate | RemoteEventStdioLog - | RemoteEventTransportError; + | RemoteEventTransportError + | RemoteEventAuthChallenge; diff --git a/core/mcp/types.ts b/core/mcp/types.ts index d9c8c480..424556b1 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -545,6 +545,12 @@ export interface CreateTransportOptions { * Stdio ignores this — headers are not applicable. */ settings?: InspectorServerSettings; + + /** + * When true, wrap HTTP transport fetch with auth-challenge detection so 401/403 + * become {@link AuthChallengeError} before the SDK calls `auth()` on a frozen provider. + */ + interceptAuthChallenges?: boolean; } export interface CreateTransportResult { @@ -746,6 +752,13 @@ export interface InspectorClientOptions { */ installEnterpriseManagedAuth?: ClientConfig["enterpriseManagedAuth"]; + /** + * When true, direct transports (TUI/CLI) route MCP 401/403 through + * `handleAuthChallenge()` via fetch intercept instead of the SDK auth() path. + * Web remote clients should leave this false (default). + */ + directAuthRecovery?: boolean; + /** * Optional session ID. If not provided, will be extracted from OAuth state * when OAuth flow starts. Passed in saveSession event for FetchRequestLogState. diff --git a/package.json b/package.json index 1b2c2659..ad759d51 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,9 @@ "build:tui": "cd clients/tui && npm run build", "build:web": "cd clients/web && npm run build", "build:launcher": "cd clients/launcher && npm run build", + "ci": "npm run validate && npm run ci:integration && npm run smoke && npm run ci:storybook", + "ci:integration": "cd clients/web && npm run test:integration", + "ci:storybook": "cd clients/web && npx playwright install chromium && npm run test:storybook", "validate": "npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher", "validate:cli": "cd clients/cli && npm run validate", "validate:tui": "cd clients/tui && npm run validate", diff --git a/specification/README.md b/specification/README.md index ce3ffeb5..307f4291 100644 --- a/specification/README.md +++ b/specification/README.md @@ -1,6 +1,6 @@ # Inspector V2 Brief -### Brief | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) +### Brief | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) ## Table of Contents * [Motivation and Context](#motivation-and-context) diff --git a/specification/v1_problems.md b/specification/v1_problems.md index 0bf84ff8..0a504105 100644 --- a/specification/v1_problems.md +++ b/specification/v1_problems.md @@ -1,6 +1,6 @@ # Inspector V1 Problems -### [Brief](README.md) | V1 Problems | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) +### [Brief](README.md) | V1 Problems | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) ## Table of Contents diff --git a/specification/v2_auth.md b/specification/v2_auth.md new file mode 100644 index 00000000..2562e861 --- /dev/null +++ b/specification/v2_auth.md @@ -0,0 +1,26 @@ +# Inspector V2 Authorization - Specification + +### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) | V2 Auth | [V2 New Spec Impact](v2_new_spec_impact.md) +#### Overview | [EMA / XAA](v2_auth_ema.md) | [Hardening](v2_auth_hardening.md) | [Mid-session](v2_auth_mid_session.md) | [Smoke testing](v2_auth_smoke_testing.md) + +Inspector v2's authorization support spans OAuth 2.1 connect-time flows, Enterprise-Managed Authorization (EMA / XAA), mid-session credential recovery, and the hardening required by the MCP `2026-07-28` specification. This page is the landing point for that work; each area has its own spec below. + +Inspector already ships connect-time OAuth and EMA infrastructure (`core/auth/`, `core/mcp/oauthManager.ts`, per-server OAuth fields in `~/.mcp-inspector/mcp.json` — see [Servers file](v2_servers_file.md)). The specs here cover extending that foundation across web, TUI, and CLI. + +## Authorization specs + +### [EMA / XAA](v2_auth_ema.md) + +Support for [Enterprise-Managed Authorization](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization) (EMA, also referred to as XAA / ID-JAG). EMA extends the OAuth flow so an enterprise IdP (OIDC) can authenticate the client once; any MCP resource authorization server configured to trust that IdP is then accessible with minimal or no user prompting. Covers the web connect flow (implemented in `core/auth/ema/`), install-level `client.json` persistence, and wiring into `InspectorClient`. + +### [Hardening](v2_auth_hardening.md) + +Aligns Inspector with the six authorization SEPs in the MCP `2026-07-28` release candidate — all OAuth client requirements (`iss` validation per RFC 9207, `application_type` in DCR, credentials bound to AS issuer, and more). Strategy: upgrade to the v2 TypeScript SDK (`@modelcontextprotocol/client`) and wire the gaps rather than reimplementing SDK OAuth logic. Covers connect-time and storage hardening that mid-session builds on. + +### [Mid-session](v2_auth_mid_session.md) + +Detecting when in-flight MCP traffic needs new or elevated credentials, responding with the correct OAuth or EMA flow, and restoring the connection — across web, TUI, and CLI. Generalizes beyond expired access tokens to **step-up authorization** (a `403` with `insufficient_scope` and per-operation scopes, per SEP-2350). Addresses the web client's token-snapshot constraint, where MCP runs on the Hono backend and cannot complete interactive OAuth on its own. + +### [Smoke testing](v2_auth_smoke_testing.md) + +Manual smoke procedures for exercising Inspector OAuth against **hosted** MCP servers, complementing the automated integration coverage. Records known-good real endpoints, which client-ID mechanism each server supports, and how to configure Inspector (web, TUI, or CLI) via install-level `client.json`. diff --git a/specification/v2_auth_ema.md b/specification/v2_auth_ema.md index 1ac35880..2c38693f 100644 --- a/specification/v2_auth_ema.md +++ b/specification/v2_auth_ema.md @@ -1,6 +1,7 @@ -# Inspector V2 — Enterprise-Managed Authorization (EMA / XAA) +# Inspector V2 Auth — 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) +### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) | V2 Auth | [V2 New Spec Impact](v2_new_spec_impact.md) +#### [Overview](v2_auth.md) | EMA / XAA | [Hardening](v2_auth_hardening.md) | [Mid-session](v2_auth_mid_session.md) | [Smoke testing](v2_auth_smoke_testing.md) Tracks [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509). @@ -365,6 +366,22 @@ Leg 1 reuses the existing OAuth redirect/callback/PKCE machinery but **targets t **401 re-auth:** on EMA connections, re-run legs 2–3 (and leg 1 only if the cached ID Token is missing or expired). Do not fall back to standard resource authorization-code OAuth. Mid-session detection, step-up scopes, and web remote propagation are specified in **[Mid-session auth](v2_auth_mid_session.md)**. +### EMA step-up (web confirmation) + +When an EMA server returns **403** + `insufficient_scope`, `OAuthManager.handleAuthChallenge()` returns **`step_up_confirm`** until the web user clicks **Authorize** in `StepUpAuthModal`. Only then does Inspector call `trySilentEmaAuth()` (legs 2–3 re-mint with SEP-2350 union scopes) or start IdP leg 1 if the organization session is missing. + +**Why confirm on web?** EMA can often upgrade scopes silently when the IdP session is still valid. Inspector deliberately shows the additional scopes first — same pattern as standard OAuth step-up — because it is a **testing and exploration** client: operators should see permission elevation while validating MCP servers, not discover it only after the fact. + +**Web UX after Authorize:** + +| Outcome | User sees | +| ------- | --------- | +| `satisfied` (silent re-mint) | Blue in-progress toast → green “Organization permissions were updated” (+ “Retry your action” when step-up was triggered by a tool/prompt/resource/app) | +| `interactive` (IdP redirect) | Pre-redirect toast → full-page IdP callback → same resume snapshot behavior as other EMA flows | +| `failed` | Red toast with error detail; triggering panel shows failure | + +TUI/CLI still use their existing confirm prompts (Auth tab **A**/**C**, CLI **y/N**) before silent re-mint — no web modal. + **EMA resource token tagging + sign-out:** per-server `oauth.enterpriseManaged` in `mcp.json` is **config** (routing); the OAuth store does not read the catalog on sign-out. Instead, when EMA legs 2–3 persist a resource access token, the store tags that entry (`ServerOAuthState.enterpriseManaged: true` via `SaveTokensOptions`). Sign-out uses that tag to find and clear EMA resource state inside the same `OAuthStorage` blob without the client enumerating MCP servers. This avoids clearing standard OAuth servers and avoids clearing EMA catalog entries that were never connected. ## Inspector mapping @@ -448,7 +465,7 @@ Design decisions for EMA are complete. Remaining work is the phased plan and che #### Phase 4 — Other clients (after web works) 13. ~~Wire TUI/CLI to load `client.json` and pass `enterpriseManagedAuth` into `InspectorClient`.~~ **Done** — `loadRunnerClientConfig` + `buildRunnerClientAuthOptions` in TUI/CLI entrypoints. EMA sign-out (`clearEmaIdpSession`) is already client-agnostic in `core/` — Phase 4 clients can call it without catalog enumeration once Client Settings / logout UX lands. -14. ~~TUI interactive EMA + standard OAuth connect~~ **Done (TUI)** — 401 → `authenticate()` → `OAuthCallbackServer` on `6276` → reconnect; Auth tab OAuth snapshot + **S** clear (disconnects when connected). **Still open:** dedicated Client Settings UX in terminal, CLI local callback server for interactive login, EMA-specific terminal UX refinements. +14. ~~TUI interactive EMA + standard OAuth connect~~ **Done (TUI)** — 401 → `authenticate()` → `OAuthCallbackServer` on `6276` → reconnect; Auth tab OAuth snapshot + **S** clear (disconnects when connected). **Still open:** dedicated Client Settings UX in terminal, [CLI interactive OAuth](v2_auth_mid_session.md#tui-and-cli-implementation), EMA-specific terminal UX refinements. ## Implementation checklist @@ -493,7 +510,7 @@ Design decisions for EMA are complete. Remaining work is the phased plan and che - [x] Wire TUI/CLI to load client config → `InspectorClientOptions.enterpriseManagedAuth` (+ CIMD / per-server OAuth via `buildRunnerClientAuthOptions`) - [x] TUI interactive EMA + standard OAuth connect (401 → authenticate → callback → reconnect; Auth tab; keychain secret rehydration via `loadServerEntries`) -- [ ] TUI/CLI polish: Client Settings dialog, CLI interactive callback server, terminal EMA UX refinements +- [ ] TUI/CLI polish: Client Settings dialog, [CLI interactive OAuth](v2_auth_mid_session.md#tui-and-cli-implementation), terminal EMA UX refinements ### Later diff --git a/specification/v2_auth_hardening.md b/specification/v2_auth_hardening.md index 5f9a42b0..95e02793 100644 --- a/specification/v2_auth_hardening.md +++ b/specification/v2_auth_hardening.md @@ -1,6 +1,7 @@ -# Inspector V2 — Authorization hardening (MCP 2026-07-28) +# Inspector V2 Auth — 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) +### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) | V2 Auth | [V2 New Spec Impact](v2_new_spec_impact.md) +#### [Overview](v2_auth.md) | [EMA / XAA](v2_auth_ema.md) | Hardening | [Mid-session](v2_auth_mid_session.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. @@ -25,10 +26,11 @@ The [2026-07-28 release candidate](https://blog.modelcontextprotocol.io/posts/20 | Concern | Mid-session spec | This doc | | ------- | ---------------- | -------- | -| **SEP-2350** scope union on 403 step-up | `handleAuthChallenge()` owns union + UX for runtime challenges | Connect-time step-up inherits SDK union after v2 upgrade; mid-session still uses `saveScope(authorizationScopes)` in the challenge handler | +| **SEP-2350** scope union on 403 step-up | `handleAuthChallenge()` + `mcpAuth({ forceReauthorization })` for **web remote**, EMA, and **interactive** defer; union persisted on `completeOAuthFlow` success | **Direct streamable HTTP silent step-up:** v2 SDK transport ([#2265](https://github.com/modelcontextprotocol/typescript-sdk/pull/2265)). **Do not** duplicate with client-side fetch intercept or `AuthRecoveryTransport` on TUI/CLI | | **SEP-2207** refresh / `offline_access` | EMA legs 2–3 re-mint; standard OAuth silent refresh | Connect-time scope selection and DCR metadata | | **SEP-2468** `iss` validation | Applies to any interactive re-auth redirect (401 mid-session) | Connect-time callback wiring is the first place to land `iss` passthrough | | **SEP-2352** issuer-bound credentials | Re-register after AS migration may surface as mid-session failure | Storage and `invalidateCredentials` must be correct before mid-session recovery can succeed | +| **TUI/CLI mid-session** | [TUI and CLI implementation](v2_auth_mid_session.md#tui-and-cli-implementation): provider + UX; silent retry via v2 SDK | Provider v2 hooks (`invalidateCredentials`, issuer-keyed storage, `application_type`) | Implement mid-session auth now. Land auth hardening on the v2 SDK upgrade path without blocking mid-session work — use compatible storage and callback shapes so both tracks merge cleanly. @@ -53,8 +55,17 @@ Inspector today uses **`@modelcontextprotocol/sdk` ^1.29.0** (v1 monolith). Auth Connect-time standard OAuth EMA Web remote backend ─────────────────────────── ─── ────────────────── SDK auth() + BaseOAuthClientProvider Local wire (ema/) Frozen token stub -OAuthStorage keyed by serverUrl IdP OIDC in idpOidc.ts No interactive OAuth -completeOAuthFlow(code) only No SDK CrossAppAccess Mid-session → browser +OAuthStorage keyed by serverUrl IdP OIDC in idpOidc.ts No interactive OAuth on node +completeOAuthFlow(code, iss?) v2 CrossAppAccess optional Mid-session → browser + (evaluate vs ema/) fetch intercept (permanent) + RemoteClientTransport (permanent) + +TUI / CLI direct (not web remote): +────────────────────────────────── +StreamableHTTPClientTransport + live OAuthClientProvider in-process +v2 SDK: silent 401/403 retry + unionScopes on streamable HTTP (#2265) +Inspector: handleAuthChallenge for EMA + interactive defer; TUI/CLI UX only +Do NOT: fetch intercept or AuthRecoveryTransport on client SDK transport ``` ### What the v2 SDK provides (after upgrade) @@ -62,7 +73,7 @@ completeOAuthFlow(code) only No SDK CrossAppAccess Mid-session | SEP | SDK status (Jun 2026) | Inspector gets | | --- | --------------------- | -------------- | | SEP-2207 | Implemented on v2 `main` ([#2199](https://github.com/modelcontextprotocol/typescript-sdk/issues/2199)) | `offline_access` augmentation in authorize scope for standard OAuth via `auth()` | -| SEP-2350 | Open PR [#2265](https://github.com/modelcontextprotocol/typescript-sdk/pull/2265) | `unionScopes()` + 403 retry uses union on streamable HTTP direct transport | +| SEP-2350 | Open PR [#2265](https://github.com/modelcontextprotocol/typescript-sdk/pull/2265) | `unionScopes()` + 403 retry uses union on **streamable HTTP direct transport**; SSE unchanged (401 only) | | SEP-2352 | Open PR [#2271](https://github.com/modelcontextprotocol/typescript-sdk/pull/2271) | AS migration detection → `invalidateCredentials` → re-DCR | | SEP-2468 | Open PR [#2272](https://github.com/modelcontextprotocol/typescript-sdk/pull/2272) | `iss` validation in code-exchange path when host passes `iss` | | SEP-837 | Open [#2198](https://github.com/modelcontextprotocol/typescript-sdk/issues/2198) | `application_type` in DCR request body | @@ -76,7 +87,7 @@ completeOAuthFlow(code) only No SDK CrossAppAccess Mid-session | SEP-837 | Ensure web vs TUI/CLI set correct client type (`web` / `native`) in provider metadata or environment | SDK may infer from redirect URI; Inspector must not mis-declare cross-environment | | SEP-2352 | Key `OAuthStorage` credentials by AS `issuer` (not only `serverUrl`); implement `invalidateCredentials` on `BaseOAuthClientProvider`; migrate shared `oauth.json` | Storage is Inspector-owned; SDK calls provider hooks | | SEP-2207 | EMA leg 1 already requests `openid offline_access` (`IDP_OIDC_SCOPES`); verify standard OAuth path after upgrade | EMA bypasses SDK authorize for leg 1 | -| SEP-2350 | Mid-session union in `handleAuthChallenge()` + `saveScope(authorizationScopes)`; web/EMA paths bypass SDK transport retry | See [Mid-session auth](v2_auth_mid_session.md) | +| SEP-2350 | **Web remote:** mid-session union in `handleAuthChallenge()` (bypasses SDK — proxy architecture). **TUI/CLI direct silent step-up:** v2 SDK transport after #2265. **Interactive defer** (step-up modal / Auth tab confirm): Inspector `setSuppressAuthorizationNavigation` + `mcpAuth({ forceReauthorization })` | See [Mid-session auth § Core API](v2_auth_mid_session.md#core-api--handleauthchallenge) and [TUI and CLI implementation](v2_auth_mid_session.md#tui-and-cli-implementation) | | SEP-2351 | Verify discovery after upgrade; no duplicate discovery logic in Inspector | Uses SDK `discoverOAuthProtectedResourceMetadata` today | **Do not duplicate** SDK `auth.ts` logic (RFC 9207 decision table, AS migration edge cases, scope union helpers). Upgrade and wire. @@ -89,15 +100,29 @@ completeOAuthFlow(code) only No SDK CrossAppAccess Mid-session 2. **Wire** callback parameters, storage, and client-type metadata in Inspector. 3. **Extend** EMA and mid-session paths locally where the SDK has no EMA/XAA API. 4. **Upgrade** from `@modelcontextprotocol/sdk` v1 to v2 packages as a gated step — not a silent dependency bump. +5. **Do not unify transport layers** between web remote and TUI/CLI — web keeps `RemoteClientTransport` + node fetch intercept; TUI/CLI rely on v2 SDK transport for silent direct retry. + +### Transport ownership (v2 upgrade) + +| Path | Silent 401/403 + RPC retry | Interactive OAuth | Inspector integration | +| ---- | -------------------------- | ----------------- | --------------------- | +| **Web remote** | `RemoteClientTransport.postSend()` + `pushAuthState` (unchanged) | Browser redirect + resume snapshot | Node fetch intercept only (frozen stub) | +| **TUI/CLI direct (streamable HTTP)** | **v2 SDK transport** (`token()` + retry; #2265 union on 403) | Provider `redirectToAuthorization` + host callback | `handleAuthChallenge()` for EMA + defer-navigate; catch `UnauthorizedError` / `SdkHttpError` | +| **TUI/CLI direct (SSE)** | v2 SDK 401 retry only | Same as streamable | No 403 step-up on SSE | + +**v2 SDK hooks (direct):** `AuthProvider` with `token()` + optional `onUnauthorized()`; full `OAuthClientProvider` adapted internally. Inspector extends `BaseOAuthClientProvider` toward the v2 contract — **not** a parallel fetch intercept on the client transport. + +**Delete on upgrade:** `core/auth/mcpAuth.ts` body → re-export SDK `auth`; delete `core/auth/scopes.ts` → import `unionScopes` from SDK. **Keep:** `handleAuthChallenge()`, web `RemoteClientTransport`, node `createAuthChallengeInterceptFetch`. ### Order of work | Step | When | Work | | ---- | ---- | ---- | -| 1 | **Now (parallel with mid-session auth)** | Compatible prep: callback type includes optional `iss`; storage schema reserves `authorizationServerIssuer`; mid-session `saveScope(union)` per [Mid-session auth](v2_auth_mid_session.md) | +| 1 | **Now (parallel with [TUI/CLI mid-session](v2_auth_mid_session.md#tui-and-cli-implementation))** | Compatible prep: callback `iss`; storage schema reserves `authorizationServerIssuer`; provider v2-shaped hooks; direct mid-session e2e baseline; **no** client-side fetch intercept / `AuthRecoveryTransport` | | 2 | **When v2 SDK auth PRs merge** | Upgrade to `@modelcontextprotocol/client`; run existing OAuth integration tests | -| 3 | **Immediately after upgrade** | SEP-2468 callback passthrough; SEP-2352 storage + `invalidateCredentials`; SEP-837 client type per environment | -| 4 | **Verify** | Smoke scenarios in [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md); add hardening-specific cases | +| 3 | **Immediately after upgrade** | SEP-2468 callback passthrough; SEP-2352 storage + `invalidateCredentials`; SEP-837 `application_type` per environment; wire provider `onUnauthorized` → `handleAuthChallenge` where interactive defer needed | +| 4 | **TUI/CLI Phase C** | Interactive mid-session UX (Auth tab / callback server) — can overlap step 3 | +| 5 | **Verify** | Smoke scenarios in [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md); direct mid-session e2e green on v2 | Land order in the SDK (maintainer plan): **SEP-2350 → SEP-2352 → SEP-2468**, with **SEP-837** in the same auth-release series. @@ -132,9 +157,12 @@ Land order in the SDK (maintainer plan): **SEP-2350 → SEP-2352 → SEP-2468**, #### SEP-2350 — step-up scope accumulation -- **Connect-time (direct transport):** v2 SDK transport 403 retry after [#2265](https://github.com/modelcontextprotocol/typescript-sdk/pull/2265). -- **Mid-session:** `handleAuthChallenge()` computes `authorizationScopes = union(previous, required)`; `saveScope` after success. Standard OAuth step-up = interactive; EMA step-up = silent legs 2–3 when IdP session valid. -- **401 re-login:** Replace scope set (not union) — mid-session and connect-time. +- **Web remote (mid-session):** `handleAuthChallenge()` union + `pushAuthState` — SDK transport not involved (proxy). +- **TUI/CLI direct (silent, streamable HTTP):** v2 SDK transport 403 retry with `unionScopes()` after [#2265](https://github.com/modelcontextprotocol/typescript-sdk/pull/2265). +- **Interactive step-up (all clients):** `handleAuthChallenge()` + `mcpAuth({ forceReauthorization })`; `setSuppressAuthorizationNavigation` until user confirms (web modal / TUI Auth tab); `saveScope` on `completeOAuthFlow` success. +- **EMA step-up:** silent legs 2–3 when IdP session valid — Inspector `handleAuthChallenge()`, not SDK transport alone. +- **401 re-login:** Replace scope set (not union) — all clients. +- **SSE:** 401 only; no 403 step-up in v1 or v2 SDK. #### SEP-2351 — discovery suffix @@ -148,6 +176,8 @@ Land order in the SDK (maintainer plan): **SEP-2350 → SEP-2352 → SEP-2468**, - **Client credentials grant** ([#1225](https://github.com/modelcontextprotocol/inspector/issues/1225)) — separate track. - Full MCP **2026-07-28 stateless transport** migration (separate from auth hardening). - Server-side scope-challenge middleware in Inspector test servers beyond what [Mid-session auth](v2_auth_mid_session.md) requires for 403 fixtures. +- **`AuthRecoveryTransport`** or client-side fetch intercept on TUI/CLI SDK transport (defer silent retry to v2 SDK; web node intercept stays). +- Unifying web remote and TUI/CLI at the transport wrapper layer. ## Testing @@ -166,12 +196,15 @@ Document manual scenarios in [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md | Area | Files | | ---- | ----- | -| Upgrade | Root and client `package.json`; import paths v1 → v2 | +| Upgrade | Root and client `package.json`; import paths v1 → `@modelcontextprotocol/client` | | Callback | `clients/web/src/App.tsx`, `core/auth/node/oauth-callback-server.ts`, `core/mcp/oauthManager.ts`, `core/mcp/inspectorClient.ts` | -| Provider | `core/auth/providers.ts`, `core/auth/browser/providers.ts` | +| Provider | `core/auth/providers.ts`, `core/auth/browser/providers.ts` — v2 hooks: `invalidateCredentials`, issuer-keyed storage, `application_type` | | Storage | `core/auth/store.ts`, `core/auth/oauth-storage.ts`, `core/mcp/remote/node/remoteOAuthStorage.ts` (if landed) | -| Mid-session overlap | `core/auth/challenge.ts`, `core/mcp/oauthManager.ts` (`handleAuthChallenge`, `saveScope`) — see [Mid-session auth](v2_auth_mid_session.md) | -| Tests | `clients/web/src/test/core/auth/`, `clients/web/src/test/integration/mcp/inspectorClient-oauth*.test.ts` | +| Web remote (unchanged on v2) | `core/mcp/remote/remoteClientTransport.ts`, `core/mcp/node/authChallengeFetch.ts`, `core/mcp/remote/node/server.ts` | +| TUI/CLI | [Mid-session § TUI and CLI](v2_auth_mid_session.md#tui-and-cli-implementation): `clients/tui/src/App.tsx`; `clients/cli/src/cliOAuth.ts`; provider wiring in `core/mcp/inspectorClient.ts` | +| Mid-session overlap | `core/auth/challenge.ts`, `core/mcp/oauthManager.ts` (`handleAuthChallenge`, `saveScope`) | +| Delete on upgrade | `core/auth/mcpAuth.ts` (re-export SDK), `core/auth/scopes.ts` (use SDK `unionScopes`) | +| Tests | `inspectorClient-oauth*.test.ts`, `inspectorClient-oauth-direct-mid-session-e2e.test.ts` (planned) | | Smoke doc | `specification/v2_auth_smoke_testing.md` | ## Related specs diff --git a/specification/v2_auth_mid_session.md b/specification/v2_auth_mid_session.md index 0f35851b..7b1cf3a9 100644 --- a/specification/v2_auth_mid_session.md +++ b/specification/v2_auth_mid_session.md @@ -1,502 +1,560 @@ -# Inspector V2 — Mid-session authorization +# Inspector V2 Auth — 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) +### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) | V2 Auth | [V2 New Spec Impact](v2_new_spec_impact.md) +#### [Overview](v2_auth.md) | [EMA / XAA](v2_auth_ema.md) | [Hardening](v2_auth_hardening.md) | Mid-session | [Smoke testing](v2_auth_smoke_testing.md) -Design for **mid-session authorization** in Inspector: detecting when MCP traffic needs new or elevated credentials, responding with the correct OAuth or EMA flow, and restoring the connection — across web, TUI, and CLI. +Design and implementation reference for **mid-session authorization** in Inspector: detecting when MCP traffic needs new or elevated credentials, running the correct OAuth or EMA recovery flow, and restoring the session — across **web**, **TUI**, and **CLI**. -This spec generalizes beyond **expired access tokens** to include **step-up authorization** (e.g. a tool call returns **403** with `error="insufficient_scope"` and the scopes required for that operation — see [SEP-2350](#sep-2350-step-up-authorization) below). +For hands-on verification, see [v2_auth_smoke_testing.md §5](v2_auth_smoke_testing.md#5-mid-session-auth--step-up--manual-validation). + +--- ## Summary -Inspector v2 already supports **connect-time** OAuth and EMA: +Inspector already supports **connect-time** OAuth and EMA: the first `connect()` that hits **401** triggers `authenticate()` / browser redirect / loopback callback, and tokens land in per-client storage. -- `InspectorClient.authenticate()` / `completeOAuthFlow()` ([V2 Scope](v2_scope.md)) -- Connect-time 401 handling in web `App.tsx` and TUI Auth tab hints -- EMA legs 2–3 refresh via `EmaTransportOAuthProvider` when a **live** `OAuthClientProvider` is on the transport ([EMA spec](v2_auth_ema.md)) +**Mid-session authorization** covers everything **after** that: a token expires, is revoked, or lacks scopes for a specific operation while the user is working. The server signals this with HTTP **401** (bad/missing token) or **403** + `insufficient_scope` (valid token, not enough scope — **step-up**). Inspector normalizes those signals into an **`AuthChallenge`**, runs **`handleAuthChallenge()`**, and either refreshes silently or starts interactive OAuth. -**Gap:** Authorization can fail whenever the MCP server rejects the credentials in use — **during** `connect()` (including reconnect with a stored token snapshot), **after** a successful connect (expired or revoked tokens), or on **insufficient scope** for a specific request. The web client compounds this: MCP runs on the Hono backend with a **token snapshot** at connect time (`createTokenAuthProvider`), so the backend cannot complete interactive OAuth or reliable silent refresh on its own. +| Client | Transport model | Auth runs where | Recovery highlights | +| ------ | --------------- | --------------- | ------------------- | +| **Web** | Browser → Hono **remote** backend → MCP server | Browser (`OAuthManager`) | `POST /api/mcp/auth-state` hot-swap; full-page OAuth + **resume snapshot** | +| **TUI / CLI** | **Direct** SDK transport in-process | Node (`OAuthManager`) | Loopback callback on `127.0.0.1:6276`; user retries action after interactive auth | -This spec defines: +All three clients share **`core/auth/challenge.ts`** and **`OAuthManager.handleAuthChallenge()`**. UX and wire protocol differ by client. -1. A normalized **`AuthChallenge`** model (what went wrong + what is required). -2. A single core entry point **`handleAuthChallenge()`** (how Inspector responds). -3. **Remote event propagation** so the browser can run auth and reconnect. -4. **Phased delivery** (recovery → step-up → client parity → RPC replay) — see [Architecture](#architecture). +--- -## Architecture +## Background -### Code layout +### Connect-time vs mid-session -- Challenge types live in `core/auth/challenge.ts`. -- `OAuthManager.handleAuthChallenge()` implements the handler; `InspectorClient` exposes a delegating wrapper. -- Web orchestration starts in `App.tsx`; extract `clients/web/src/utils/authChallengeFlow.ts` if wiring exceeds ~50 lines. +| | Connect-time | Mid-session | +| --- | --- | --- | +| **When** | First connect with no valid tokens, or reconnect before a remote session exists | Any MCP RPC **after** credentials were supplied | +| **Typical signal** | `connect()` throws **401** | MCP HTTP **401/403** on an in-flight request | +| **Web handler** | `App.tsx` → `authenticate()` → redirect | `handleAuthChallenge()` → `auth-state` push and/or redirect | +| **Core handler** | Same OAuth primitives | `OAuthManager.handleAuthChallenge()` | -### Web: detection and wire protocol +Both paths use the same token storage, refresh, and authorization-code exchange; only **detection** and **session update** mechanics differ. -- **Backend detection:** 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()`. +### Step-up authorization (SEP-2350) -### TUI / CLI: detection +**Step-up** is OAuth for “I have a token, but not permission for *this* operation.” MCP servers usually express it as: -- Same `handleAuthChallenge()` entry via a transport fetch wrapper, before the SDK auth retry path. -- Intercept 401 and 403 on streamable HTTP; run union scopes in `handleAuthChallenge()` for step-up. Do not rely on the SDK built-in 403 retry (challenge-only scope, no SEP-2350 union). -- Legacy SSE transport: 401 only (no 403 step-up in SDK). -- Replace TUI `show401AuthHint` with the `authChallenge` event (Phase 4). -- Phase A: rely on SDK in-flight retry where applicable. Phase C adds explicit pending-RPC replay. +```http +HTTP/1.1 403 Forbidden +WWW-Authenticate: Bearer error="insufficient_scope", scope="weather:read" +``` -The SDK (`@modelcontextprotocol/sdk` 1.29.0) auto-retries 401/403 on streamable HTTP `send()` but does not union scopes for step-up — Inspector owns SEP-2350 union in `handleAuthChallenge()`. +By contrast, **401** means the token is missing, invalid, or expired — fix by refresh or full re-login. -### Scope and EMA +**[SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350)** (in the MCP 2026-07-28 authorization hardening) requires clients to **accumulate scopes** on step-up: -- **Previously requested scopes:** `OAuthStorage.scope` / `saveScope()` per server. -- **After successful authorize:** `saveScope(authorizationScopes)`; step-up uses union; 401 re-login replaces scope. -- **EMA mint scopes on 401 refresh:** challenge `scope` from `WWW-Authenticate` if present, else configured `oauth.scope`, else PRM `scopes_supported` (`resolveEmaScopes` order). -- **EMA step-up (valid IdP session):** silent legs 2–3 re-mint with `authorizationScopes` (union). Same toast as 401 refresh. No modal, no resource-AS redirect. Resource MCP scopes are on leg 2/3 token requests, not leg 1 (`openid offline_access` only). +| Situation | Client scope behavior | +| --------- | --------------------- | +| **403** step-up | **Union** previously requested scopes with scopes from the challenge — do not drop scopes needed for other tools | +| **401** re-login | **Replace** scope set (user may down-scope at the AS) | -### RPC retry +Inspector persists granted scopes in `OAuthStorage.scope` (`saveScope()`): when the authorization server returns a `scope` parameter on the token response, that value is **authoritative** (RFC 6749 §5.1 — including when the grant is a subset of what was requested). When `scope` is **omitted** on a successful response, granted scope is assumed to equal what was requested for that exchange. Storage must never claim scopes the access token was not granted. -- 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). +For step-up, `handleAuthChallenge()` computes `authorizationScopes` as the union of stored grant + challenge scopes for the **authorization request**; `saveScope()` runs only after a successful grant, using the rules above — not the pre-redirect requested union when the AS returned an explicit smaller `scope`. -### UX +**UX consequence:** standard-OAuth and **web EMA** step-up need **user-visible consent** before proceeding (web modal, TUI Auth tab confirm, CLI **y/N**). On the web client, EMA `insufficient_scope` shows the same **`StepUpAuthModal`** pattern as standard OAuth, with organization/IdP copy; only after **Authorize** does Inspector run silent re-mint or start an IdP redirect. TUI/CLI may still re-mint silently after their own confirm prompt — see [EMA step-up (web)](v2_auth_ema.md#ema-step-up-web-confirmation). -| 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 | +**Rationale (Inspector as a testing tool):** EMA can often satisfy scope upgrades without a visible IdP prompt when the organization session is still valid. That is convenient in production clients, but Inspector deliberately surfaces the requested scopes first so operators can see *what* permission elevation is happening while exploring MCP servers — the same visibility standard OAuth step-up already provides on web. -TUI: standard-OAuth step-up uses Auth tab message + Cancel semantics. EMA step-up is silent. CLI mirrors when OAuth is wired (Phase 4). +Normative background: [MCP authorization — Step-Up Authorization Flow](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#step-up-authorization-flow), [Runtime Insufficient Scope Errors](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#runtime-insufficient-scope-errors), [RFC 6750 §3.1](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1). -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. | +| **App tab** | An Inspector **view** inside one browser window: Tools, Resources, Prompts, etc. (`activeTab` in `App.tsx`). Not a browser tab. | +| **Browser tab** | A top-level browsing context (window/tab) running the Inspector SPA. Each has its own `InspectorClient`, remote session, and OAuth context. | +| **Auth challenge** | Normalized `{ reason, requiredScopes?, … }` — not raw HTTP. Built by `parseAuthChallengeFromResponse()` or received on the remote wire. | +| **`handleAuthChallenge()`** | Core orchestrator: silent refresh / EMA re-mint, or start interactive OAuth. Returns `satisfied`, `step_up_confirm` (EMA/web deferral), `interactive`, or `failed`. | +| **`RemoteAuthState`** | Payload for `POST /api/mcp/auth-state`: fresh `oauthTokens` (+ optional `oauthClient`) pushed to the node backend without tearing down the MCP transport. | +| **Command-scoped recovery** | Failure during an active user action (tool call, connect button). May **retry the same JSON-RPC** once after silent recovery. | +| **Ambient recovery** | Failure with no correlated in-flight command (idle SSE). Prepares the session for the **next** user action — no auto-retry of a specific RPC. | +| **OAuth resume snapshot** | `sessionStorage` blob written before a **full-page** OAuth redirect so the web app can restore server, app tab, and form state after callback. | -## Current architecture (why mid-session fails on web) +--- + +## Architecture + +### Two transport models + +Inspector implements mid-session auth twice, by design — not as a shared transport wrapper. ```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 +Web remote (browser proxy): + Browser RemoteClientTransport + → POST /api/mcp/send + → Hono RemoteSession + fetch intercept (frozen stub OAuth provider on node) + → upstream MCP server + Recovery: handleAuthChallenge() in browser → POST /api/mcp/auth-state → optional send retry + +TUI / CLI direct: + InspectorClient + live OAuthClientProvider on SDK StreamableHTTPClientTransport + Recovery: handleAuthChallenge() in-process → token swap / reconnect + Interactive: runRunnerInteractiveOAuth() + loopback callback (127.0.0.1:6276) ``` -**TUI/CLI:** OAuth authority and MCP transport live in the same process. The SDK can call `tokens()`, refresh, or fire `oauthAuthorizationRequired`. +**Why not unify?** The web node backend uses a **stub** provider (no redirects on the server). TUI/CLI hold a **live** provider. The v2 TypeScript SDK will own **silent** 401/403 retry on direct streamable HTTP ([#2265](https://github.com/modelcontextprotocol/typescript-sdk/pull/2265)); Inspector keeps **`handleAuthChallenge()`**, EMA paths, and **interactive deferral** (modal / confirm / snapshot). See [v2_auth_hardening.md](v2_auth_hardening.md). -**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. +**Explicit non-goals for TUI/CLI:** client-side fetch intercept on the SDK transport, `AuthRecoveryTransport`, or mirroring `RemoteClientTransport` on direct paths. -### Known failure: reconnect with stored tokens (pre–Phase 2) +### Recovery shapes -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). +| Shape | When | MCP retry | Page unload | +| ----- | ---- | --------- | ----------- | +| **Silent in-process** | Refresh, EMA re-mint after user confirmed step-up (web modal **Authorize**), TUI/CLI confirm | **Yes** (command-scoped, once) | No | +| **In-app step-up confirm** | Web EMA `insufficient_scope` before re-mint / IdP | **No** — modal only; retry after success toast | No | +| **Interactive full-page** | Standard-OAuth step-up, 401 re-login, EMA IdP leg 1 (after web confirm when applicable) | **No** — user retries action after callback | Yes (web) | -| 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 | +```text +Browser InspectorClient Hono RemoteSession MCP server +───────────────────────── ────────────────── ────────── +RemoteClientTransport StreamableHTTPClientTransport + OAuthClientProvider (live) createRemoteAuthProvider (mutable) + │ │ + POST /api/mcp/send ───────────────────────►│ same transport ───────────────►│ + │◄─ { ok: false, auth_challenge }──────│ │ + handleAuthChallenge() │ + POST /api/mcp/auth-state { authState } ───►│ setAuthState() → new Bearer │ + POST /api/mcp/send (retry) ─────────────────►│ same mcp-session-id ────────►│ +``` -**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)). +**Why auth-state push?** An earlier approach disconnected and reconnected the remote session on every recovery, which broke upstream MCP session continuity. Hot-swapping credentials on the existing transport matches direct streamable-http behavior. -## SEP-2350 — step-up authorization +### Code map -[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**. +| Area | Primary files | +| ---- | ------------- | +| Challenge types & parsing | `core/auth/challenge.ts`, `core/auth/oauthUx.ts` | +| Handler & storage | `core/mcp/oauthManager.ts`, `core/auth/mcpAuth.ts`, `core/auth/scopes.ts` | +| EMA | `core/auth/ema/emaFlow.ts`, `core/auth/ema/resourceContext.ts` — see [v2_auth_ema.md](v2_auth_ema.md) | +| Web remote backend | `core/mcp/remote/node/server.ts`, `remote-session.ts`, `core/mcp/node/authChallengeFetch.ts` | +| Web remote client | `core/mcp/remote/remoteClientTransport.ts`, `core/mcp/inspectorClient.ts` | +| Web app | `clients/web/src/App.tsx`, `utils/oauthResume.ts`, `utils/pendingReauth.ts`, `utils/browserTabVisibility.ts`, `components/groups/StepUpAuthModal/` | +| TUI | `clients/tui/src/App.tsx`, `utils/tuiOAuth.ts` | +| CLI | `clients/cli/src/cliOAuth.ts` | +| Runner OAuth (TUI/CLI) | `core/auth/node/runner-interactive-oauth.ts`, `oauth-callback-server.ts` | +| Step-up test fixture | `test-servers/configs/oauth-step-up-demo.json`, `test-servers/src/test-server-oauth.ts` | -**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`) +### `AuthChallenge` (`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 + | "unauthorized" + | "token_expired" + | "insufficient_scope" + | "invalid_token"; -/** 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). */ + requiredScopes?: string[]; // From WWW-Authenticate scope= (this operation) + authorizationScopes?: string[]; // SEP-2350 union — set before re-auth, not on wire 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; - }; + context?: { method?: string; toolName?: string }; + raw?: { httpStatus?: number; wwwAuthenticate?: string }; } ``` -### Parsing (best effort, extensible) +### Parsing -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. +Parsing is **layered** at the point of failure — not by guessing from error messages: -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. +1. HTTP **401/403** on the MCP response (fetch intercept or transport error). +2. **`WWW-Authenticate: Bearer`** — `error`, `scope`, `error_description` (quoted and unquoted RFC 6750 forms). +3. Embedded `authChallenge` on SDK/transport errors. -When parsing fails, use `reason: "unauthorized"` and still allow interactive re-auth. +Mapping highlights: -### Challenge vs connect-time 401 +- **403** + `insufficient_scope` → `reason: "insufficient_scope"`. +- **401** + `invalid_token` → `invalid_token` or `token_expired` as appropriate. +- **401** without Bearer error → `token_expired` (coarse default for silent refresh / reauth UX). +- Parse failure → `unauthorized` (still allows interactive re-auth). -| | 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 | +`isAuthChallengeError()` treats mid-session failures only when auth markers are present (challenge object, `WWW-Authenticate`, or `AuthChallengeError`) — not bare HTTP status alone. -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. +Connect-time **401** before tokens exist still uses `isUnauthorizedError()` and `authenticate()` — separate from mid-session detection. + +--- ## Core API — `handleAuthChallenge()` -**Location:** `OAuthManager.handleAuthChallenge()`; `InspectorClient` exposes a delegating wrapper. +**Location:** `OAuthManager.handleAuthChallenge()`; `InspectorClient.handleAuthChallenge()` delegates. ```typescript export type AuthChallengeOutcome = - | { kind: "satisfied" } // New tokens in OAuthStorage; caller may reconnect transport - | { kind: "interactive"; authorizationUrl: URL } + | { kind: "satisfied" } + | { kind: "interactive"; authorizationUrl: URL; challenge: AuthChallenge } | { kind: "failed"; error: Error }; - -/** Satisfy an auth challenge when possible. */ -async handleAuthChallenge(challenge: AuthChallenge): Promise; ``` -### Strategy by protocol and reason +### `checkAuthChallengeSatisfied(challenge)` -#### Standard OAuth (`protocol: "standard"`) +Read-only check against **current storage** (and token expiry helpers). Used before starting visible OAuth — especially when a background browser tab regains focus and another tab may have already re-authenticated. Does **not** call the authorization server. -| 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 | +- **`token_expired` / `unauthorized`:** valid non-expired access token in storage. +- **`insufficient_scope`:** stored/token scope is a superset of required / union scopes. Empty scope on an `insufficient_scope` challenge returns **false** (do not short-circuit). -Uses existing `BaseOAuthClientProvider`, storage, and `authenticate()` / `completeOAuthFlow()`. +### Strategy by protocol -#### EMA (`protocol: "ema"`) +#### Standard OAuth -Per [EMA 401 rules](v2_auth_ema.md): **do not** fall back to standard resource-OAuth redirect. +| Reason | Silent | Interactive | +| ------ | ------ | ----------- | +| `token_expired`, `invalid_token`, `unauthorized` | Refresh via `refresh_token` when supported | Authorization code flow (`authenticate()`) | +| `insufficient_scope` | N/A | Authorize with **`authorizationScopes`** = union(previous, challenge) via `mcpAuth({ forceReauthorization: true })`; navigation **deferred** until UI confirms (web modal / TUI Auth / CLI prompt) | -| 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 | +Union scope is held in `pendingAuthorizationScope` until `completeOAuthFlow()` succeeds; cleared on failure. On success, `saveScope()` stores the AS-granted scope (explicit `scope` on the token response, or the requested union when `scope` is omitted per RFC 6749 §5.1). -Uses `EmaTransportOAuthProvider`, `emaFlow.ts`, and `resourceContext.ts` (extend scope resolution to prefer challenge scopes when present). +#### EMA -### After `kind: "satisfied"` +Per [v2_auth_ema.md](v2_auth_ema.md): **no** fallback to standard resource-OAuth redirect. -| 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. | +| Reason | Silent | Interactive | +| ------ | ------ | ----------- | +| Resource token expired / unauthorized | Legs 2–3 re-mint | — | +| IdP session missing | — | Leg 1 IdP OIDC redirect, then legs 2–3 | +| `insufficient_scope` | After user confirms (web modal / TUI / CLI), re-mint legs 2–3 with union scopes when IdP session valid | IdP redirect if IdP session invalid | -Until Phase C, the failed tool call may still require a manual retry after recovery. +On **web**, `handleAuthChallenge()` returns **`step_up_confirm`** for EMA `insufficient_scope` until the user clicks **Authorize** in `StepUpAuthModal`; it does **not** call `trySilentEmaAuth()` before that. After confirm, silent re-mint runs in-process when possible; otherwise IdP redirect + callback (same as before). -### After `kind: "interactive"` +### Outcomes — what callers do -Same as connect-time today: +| Outcome | Web remote | TUI / CLI direct | +| ------- | ---------- | ---------------- | +| **`satisfied`** | `pushAuthState()`; command-scoped: **retry send once** | Reconnect or SDK retry (v2); command wrapper may retry RPC | +| **`step_up_confirm`** | Throw `AuthRecoveryRequiredError` (`emaStepUpConfirm`) → `StepUpAuthModal` (EMA copy) | N/A (web-only deferral) | +| **`interactive`** | Throw `AuthRecoveryRequiredError` (enriched challenge) → App shows modal or redirect + snapshot | `AuthRecoveryRequiredError` or `authChallengeInteractive` → callback server flow | -- **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. +**InspectorClient events (direct transport):** `authChallengeAmbient` (idle SSE / ambient recovery), `authChallengeCommand` (command-scoped direct recovery — no ambient toast), `authChallengeInteractive`, `authChallengeRecovered`. +| **`failed`** | Toast / banner; stay connected (degraded) | Error message; stay connected (TUI) or exit non-zero (CLI one-shot) | -### After `kind: "failed"` or user **Cancel** (standard-OAuth step-up modal only) +--- -Do **not** disconnect the MCP session for recoverable challenges. +## Web implementation -| 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 | +### Detection and delivery -## Remote wire protocol (web) +**Backend** (`core/mcp/remote/node/`): -Backend **reports** challenges; browser **handles** them. +- **Auth-challenge intercept fetch** on the MCP HTTP transport: on **401/403**, parse headers, throw `AuthChallengeError` **before** the stub provider invokes SDK `auth()`. +- **`createRemoteAuthProvider`**: mutable credentials; `RemoteSession.setAuthState()` updates the upstream Bearer without new `connect()`. -### Detection +**Dual delivery — one channel per incident:** -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()`. +| Path | Trigger | Wire | Client behavior | +| ---- | ------- | ---- | --------------- | +| **Command-scoped** | Active `POST /api/mcp/send` | HTTP **200** `{ ok: false, kind: "auth_challenge", authChallenge }` | `handleAuthChallenge()` → `pushAuthState()` → **retry same JSON-RPC once** | +| **Ambient** | Transport error while idle | SSE `auth_challenge` | `handleAmbientAuthChallenge()` → push auth state; **no RPC retry** | -```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() -``` +`authReturnedViaHttp` prevents duplicating a command-scoped challenge on SSE. -#### Web — detection (`core/mcp/remote/node/`) +Inspector API **4xx** are reserved for malformed requests / missing session — not for upstream MCP token expiry. -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`: +### `POST /api/mcp/auth-state` ```typescript -export interface RemoteAuthChallengeEvent { - type: "auth_challenge"; - data: AuthChallenge & { - /** Server catalog id — browser resolves InspectorClient instance. */ - serverId?: string; - }; +interface RemoteSetAuthStateRequest { + sessionId: string; + authState: RemoteAuthState; +} + +interface RemoteAuthState { + oauthTokens?: { access_token; token_type; refresh_token?; scope?; … }; + oauthClient?: { client_id; client_secret? }; // reserved for future server-side refresh } ``` -**Rules:** +Called by `RemoteClientTransport.pushAuthState()` after browser-side recovery. Seeding uses the same shape on `POST /api/mcp/connect`. -- 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). +### App orchestration (`clients/web/src/App.tsx`) -### Browser handling +**Command-scoped paths** (tool, prompt, resource, app) share `handleCommandScopedAuthRecovery()`: -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). +- Standard-OAuth **or EMA** step-up → `StepUpAuthModal` (defer redirect / re-mint until **Authorize**). +- 401 / EMA IdP (non-step-up) → `prepareOAuthRedirect()` (auto-redirect + snapshot). +- Background browser tab hidden → defer to `pendingReauth`; resume on `visibilitychange` with `checkAuthChallengeSatisfied` first. + +**Ambient path:** listens for `authChallengeInteractive` on `InspectorClient` (from SSE when silent recovery cannot complete). + +**Disconnect** clears `pendingStepUp`, `pendingReauth`, and `reAuthBanner`. + +### Step-up confirmation modal + +Shown for **`insufficient_scope`** on **standard OAuth** and **EMA (web)** when recovery will **redirect** to an AS or **re-mint** organization permissions. + +- Copy from `core/auth/oauthUx.ts` (`stepUpConfirmMessage`, `stepUpFollowUpMessage`, `stepUpModalTitle`). EMA uses organization / IdP language; standard OAuth uses resource-AS redirect language. +- Lists **`requiredScopes`** (additional scopes only — not the full SEP-2350 union). +- **Authorize (standard OAuth):** write [OAuth resume snapshot](#oauth-resume-snapshot), pre-redirect toast, `beginInteractiveAuthorization()`. +- **Authorize (EMA):** in-progress toast → `handleAuthChallenge(..., { confirmedStepUp: true })` → on `satisfied`, push auth state + success toast (retry hint when command-scoped); on `interactive`, same snapshot + IdP redirect as other EMA flows; on `failed`, error toast. +- **Cancel:** scoped by `StepUpSource` (tool / prompt / resource / app / ambient) — only the triggering panel shows error; session stays connected. + +Not shown for: token refresh, 401 re-login, connect-time OAuth. + +**Rationale:** Inspector is primarily a **testing and exploration** client. Surfacing step-up scopes before silent EMA re-mint makes permission elevation visible during manual validation (same UX bar as standard OAuth step-up on web). Production MCP clients may skip this confirm when silent re-mint is acceptable. + +### OAuth resume snapshot + +Full-page redirect (`window.location.href`) destroys in-memory React state. Before navigate, `prepareOAuthRedirect()` writes: + +```typescript +interface OAuthResumeSnapshot { + version: 1; + serverId: string; + activeTab: string; // App tab id ("Tools", …) + authKind: "step_up" | "reauth"; + tabUi: Partial>; // lifted *UiState shells + remoteSessionId?: string; + authChallenge?: AuthChallenge; // step-up: verify scopes after callback +} +``` + +Key: `mcp-inspector:oauth-resume` in `sessionStorage` (`clients/web/src/utils/oauthResume.ts`). + +**Callback flow** (`InspectorClient.resumeAfterOAuth()`): + +1. `completeOAuthFlow(code)`. +2. **Consume** snapshot from `sessionStorage` (read + clear — one-shot). +3. `setupClientForServer(serverId)`. +4. If `remoteSessionId` still valid: `attachToSession()` + `pushAuthState()`; else `connect()` (skipped if already connected). +5. Restore `tabUi` and `activeTab` **immediately after consume** (before async token work finishes); clear in-flight result panels. Never re-applied on later reconnect. +6. Step-up: `checkAuthChallengeSatisfied(authChallenge)` — warning toast if scopes still insufficient. +7. Success toast: step-up vs reauth copy; **user manually retries** the action (no auto-replay). + +Snapshots **per app tab UI**, not message logs, tool results, or network bodies. Each snapshot is **one-shot**: written immediately before a full-page OAuth redirect, **consumed** (read + cleared from `sessionStorage`) when the `/oauth/callback` handler runs, and UI restored once at that moment. **Explicit user disconnect** clears any pending snapshot and resets `activeTab` to Servers so a later manual reconnect does not pop back to the OAuth-restored tab; transport/client teardown during connect setup does **not** clear the snapshot (so connect-time OAuth can still match the server on callback). A later manual reconnect does not read or apply a consumed snapshot. + +### Multiple browser tabs + +Each browser tab has its own `RemoteSession` and (today) `BrowserOAuthStorage` in `sessionStorage`. SSE `auth_challenge` is scoped to that session. + +When a tab is **hidden**, **interactive** OAuth must not steal focus: + +1. Set **`pendingReauth`** (in-memory) instead of modal/redirect. +2. On **`visibilitychange` → visible`:** `checkAuthChallengeSatisfied()` first; if still needed, run visible flow. + +Command-scoped recovery (user clicked Run in the foreground tab) is **not** deferred. + +Future: shared `RemoteOAuthStorage` → `oauth.json` may use optional `navigator.locks` around silent refresh only — see [v2_auth_ema.md §Shared storage](v2_auth_ema.md). + +### Web UX reference + +| Situation | Behavior | +| --------- | -------- | +| Silent refresh / EMA re-mint (after confirm) | Toast “Refreshing authorization…” or EMA in-progress toast — no modal | +| **401** interactive | Toast “Session expired…” → auto-redirect; no confirm modal | +| **403** standard-OAuth step-up | Modal → optional pre-redirect toast → redirect → callback restore | +| **403** EMA step-up (web) | Modal (organization copy) → in-progress toast → silent re-mint or IdP redirect → success toast | +| EMA IdP leg 1 (401 / expired IdP) | Toast “Re-authenticating…” → auto-redirect | +| Step-up **Cancel** | Connected; failed action shows error | +| OAuth abort / callback failure | **ReAuthBanner**; Re-authenticate uses in-session `authenticate()` when already connected (no disconnect cycle) | +| Step-up callback, scopes still insufficient | Warning toast — not green success | +| `insufficient_scope` recovery failure (non-banner reasons) | Yellow toast — not ReAuthBanner | +| Concurrent step-up while modal open | Yellow toast — complete or cancel current step-up first | + +--- + +## TUI and CLI implementation + +### Principles + +- **Live provider** on the SDK transport — same process as MCP. +- **`InspectorClient.withDirectAuthRecovery()`** wraps RPCs: silent `handleAuthChallenge()` + reconnect; **`AuthRecoveryRequiredError`** for interactive. +- **`directAuthRecovery: true`** enables `interceptAuthChallenges` on `createTransportNode` until v2 SDK transport owns silent retry. +- **Interactive OAuth** uses shared **`runRunnerInteractiveOAuth()`** (`core/auth/node/runner-interactive-oauth.ts`): loopback server, browser redirect, `completeOAuthFlow()`, optional post-step-up scope check, **15-minute callback timeout** (configurable). + +Default callback: `http://127.0.0.1:6276/oauth/callback` (`--callback-url` / `MCP_OAUTH_CALLBACK_URL`). Only one TUI/CLI listener on that port at a time. + +CLI never spawns TUI/web for auth — completes locally or fails. + +### TUI UX (`clients/tui/src/App.tsx`) + +| Situation | Behavior | +| --------- | -------- | +| Silent recovery | Auth tab: “Refreshing authorization…” | +| **401** | Auto `runOAuthAuthentication()` (same as connect-time) | +| **403** standard-OAuth step-up | Switch to Auth tab; **A** authorize / **C** cancel; browser → callback | +| **403** EMA | Silent re-mint | +| Step-up on connected server | `presentStepUpForServer()` selects server + opens Auth tab | +| Reauth for affected server | `handleAuthRecoveryRequired()` switches server when needed | +| Clear OAuth | Auth tab **S** | + +### CLI UX (`clients/cli/src/cliOAuth.ts`) + +| Situation | Behavior | +| --------- | -------- | +| Connect-time 401 | `connectInspectorWithOAuth()` → authorize URL on stdout → callback | +| Mid-session interactive | `handleCliAuthRecoveryRequired()` / `withCliAuthRecoveryRetry()` (**one retry**) | +| **403** standard-OAuth step-up | stderr: scope message + `Proceed with step-up authorization? [y/N]` | +| Decline step-up | Exit non-zero | +| Insufficient scope after OAuth | stderr message from `stepUpInsufficientScopeMessage()` | + +### SSE limitation + +Legacy **SSE** transport: **401 only** on mid-session `send()` (no 403 step-up). Step-up testing targets **streamable HTTP**. + +--- ## Client matrix | Concern | Web | TUI | CLI | | ------- | --- | --- | --- | -| Challenge detection | 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) | +| Challenge detection | Inline send + SSE ambient | SDK transport + intercept | Same as TUI | +| Auth execution | Browser `OAuthManager` | Node `OAuthManager` | Node `OAuthManager` | +| OAuth storage | `BrowserOAuthStorage` (sessionStorage) | `NodeOAuthStorage` (file) | Same file as TUI | +| Silent recovery | `auth-state` push + send retry | Reconnect / SDK retry (v2) | One-shot RPC retry | +| Interactive recovery | Modal + full-page redirect + snapshot | Auth tab + callback | stderr **y/N** + callback | +| Step-up confirm | `StepUpAuthModal` | Auth tab **A** / **C** | **y/N** | +| EMA step-up | Web modal + confirm (then silent or IdP) | Confirm on Auth tab, then silent | **y/N**, then silent | +| Multiple browser tabs | Independent sessions; background defer | N/A | N/A | -## 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 | +## Test infrastructure -## Phased implementation +Step-up integration tests and manual smokes use the **composable OAuth server** (`test-servers/build/server-composable.js`) with per-capability scope requirements enforced in HTTP middleware (`test-server-oauth.ts`). -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). +### How scopes work at two levels -### Phase 1 — Foundation (core + types) +| Level | Config field | Role | +| ----- | ------------ | ---- | +| **Authorization server** | `oauth.scopesSupported` | Advertises every scope the AS **may grant** (PRM / AS metadata). Inspector uses this for connect-time consent and step-up union. Must include all scopes referenced by `requiredScopes` below. | +| **Per capability** | `requiredScopes` on a tool, resource, or prompt preset ref | Scopes the **access token must already include** before that RPC succeeds. Missing scope → **403** + `WWW-Authenticate: Bearer error="insufficient_scope", scope="…"`. Omitted → only global bearer validity (401 if no/invalid token). | -- [ ] 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 +**Connect-time vs step-up:** Inspector catalog OAuth scopes control what the user grants on **first connect**. The composable server may accept that token for some operations but reject others until step-up adds more scopes. -### Phase 2 — Web remote propagation (401 / token recovery) +Example flow with the sample below: -- [ ] 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`) +1. Catalog entry: `"oauth": { "scopes": "mcp tools:read" }` — user connects successfully. +2. `tools/call echo` — no `requiredScopes` → succeeds. +3. `tools/call get_temp` — needs `weather:read` → **403 insufficient_scope** → Inspector step-up with SEP-2350 union (`mcp`, `tools:read`, `weather:read`). +4. After step-up, `get_temp` succeeds; `resources/read` on `file:///secret.txt` still needs another step-up for `secrets:read`. -### Phase 3 — SEP-2350 step-up + EMA scope challenges (Phase B) +Step-up is enforced on **use** (`tools/call`, `resources/read`, `prompts/get`), not on discovery (`tools/list`, `resources/list`, `prompts/list`). -- [ ] 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) +### Sample composable config -### Phase 4 — Client parity + storage +Illustrates server-level `scopesSupported` plus tool-, resource-, and prompt-level `requiredScopes`. The checked-in smoke fixture [`test-servers/configs/oauth-step-up-demo.json`](../test-servers/configs/oauth-step-up-demo.json) is a **minimal** subset (tools only: `echo` + `get_temp`). -- [ ] 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 +```json +{ + "serverInfo": { "name": "step-up-demo", "version": "1.0.0" }, + "transport": { "type": "streamable-http", "port": 8081 }, + "oauth": { + "enabled": true, + "mode": "combined", + "requireAuth": true, + "scopesSupported": ["mcp", "tools:read", "weather:read", "secrets:read", "admin:write"], + "supportDCR": true, + "supportRefreshTokens": true + }, + "tools": [ + { "preset": "echo" }, + { "preset": "get_temp", "requiredScopes": ["weather:read"] }, + { "preset": "add", "requiredScopes": ["admin:write"] } + ], + "resources": [ + { + "preset": "static_text", + "params": { "uri": "file:///secret.txt", "name": "secret" }, + "requiredScopes": ["secrets:read"] + } + ], + "prompts": [ + { "preset": "simple_prompt", "requiredScopes": ["weather:read"] } + ] +} +``` -### Phase 5 — Pending RPC replay (Phase C) +**Matching Inspector catalog entry** (connect with subset scopes so step-up is exercisable): -- [ ] 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 +```json +"oauth-step-up-demo": { + "type": "streamable-http", + "url": "http://127.0.0.1:8081/mcp", + "oauth": { "scopes": "mcp tools:read" } +} +``` -## Testing +**Run the server:** -| 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 | +```bash +cd clients/web && npm run test-servers:build +node ../../test-servers/build/server-composable.js \ + --config ../../test-servers/configs/oauth-step-up-demo.json +``` -## File touch list (expected) +### Enforcement (HTTP middleware) -| 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 | +On each MCP request, after bearer validation: +1. Parse JSON-RPC `method` and `params`. +2. Look up `requiredScopes` from the registry built at startup: + +| MCP method | Registry key | +| ---------- | ------------ | +| `tools/call` | tool `name` (`params.name`) | +| `resources/read` | resource `uri` (`params.uri`) | +| `prompts/get` | prompt `name` (`params.name`) | +| `resources/templates/read` | template name or URI from `params` | + +3. Compare granted scopes on the access token (stored at token issue time in combined mode) against `requiredScopes`. +4. If any required scope is missing, respond **403** with: + + ```http + WWW-Authenticate: Bearer error="insufficient_scope", scope="weather:read" + ``` + + (`scope=` lists the missing scope(s); body is a JSON-RPC error envelope.) + +`requiredScopes` on preset refs is merged in `resolve-config.ts`; no application-code routes — config drives behavior. + +### Verification + +**Automated (high level):** challenge parsing, scope union, `checkAuthChallengeSatisfied`, `oauthResume`, `runRunnerInteractiveOAuth`; integration suites `inspectorClient-oauth-remote-mid-session-e2e.test.ts`, `inspectorClient-oauth-direct-mid-session-e2e.test.ts`, CLI `oauth-interactive.test.ts` / `cliOAuth.test.ts`. + +**Manual:** [v2_auth_smoke_testing.md §5](v2_auth_smoke_testing.md#5-mid-session-auth--step-up--manual-validation) — required gate **W1 + W5–W7**, **T1–T2 + T4**, **C1–C2**. + +--- + +## Related specifications + +| Document | Relationship | +| -------- | ------------ | +| [v2_auth_hardening.md](v2_auth_hardening.md) | Connect-time SEPs; v2 SDK upgrade; direct transport silent retry delegation | +| [v2_auth_ema.md](v2_auth_ema.md) | EMA legs 2–3 re-mint; scope resolution; no resource-OAuth fallback | +| [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) | Manual OAuth and mid-session validation procedures | +| [v2_storage.md](v2_storage.md) | Target: shared `oauth.json` via `RemoteOAuthStorage` on web | + +--- + +## Future work + +- **Web default `RemoteOAuthStorage`** — shared `oauth.json` with TUI/CLI; optional `navigator.locks` for silent refresh single-flight across browser tabs. +- **v2 SDK transport upgrade** — delegate direct streamable HTTP silent 401/403 + SEP-2350 union to SDK; remove `mcpAuth` / client intercept shims where redundant. +- **Server-side token refresh** on the node using `RemoteAuthState.oauthClient` + `refresh_token` (browser owns refresh today). +- **Connection Info** — display effective vs pending scopes. +- **Composable `oauth.operations`** map for method-wide scope defaults in test fixtures. +- **Popup OAuth window** as an alternative to full-page resume (not implemented). + +--- + +## Normative references + +- [MCP authorization (2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) +- [MCP authorization (draft — 2026-07-28 RC)](https://modelcontextprotocol.io/specification/draft/basic/authorization) +- [SEP-2350 — client-side scope accumulation in step-up](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350) +- [EMA extension](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization) +- [RFC 6750 §3.1](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1) — Bearer `insufficient_scope` diff --git a/specification/v2_auth_smoke_testing.md b/specification/v2_auth_smoke_testing.md index 23df077d..d9fbe474 100644 --- a/specification/v2_auth_smoke_testing.md +++ b/specification/v2_auth_smoke_testing.md @@ -1,6 +1,7 @@ -# Inspector V2 — OAuth smoke testing (real servers) +# Inspector V2 Auth — 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) +### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) | V2 Auth | [V2 New Spec Impact](v2_new_spec_impact.md) +#### [Overview](v2_auth.md) | [EMA / XAA](v2_auth_ema.md) | [Hardening](v2_auth_hardening.md) | [Mid-session](v2_auth_mid_session.md) | Smoke testing 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`). @@ -475,7 +476,7 @@ Same two phases (prefer **`stytch-mcp-demo`** URL): ### Procedure (CLI) -Reuse tokens from a prior web/TUI session, or run after interactive auth in TUI: +Interactive OAuth uses the loopback callback server (same as TUI). First-time connect or mid-session step-up: ```bash mcp-inspector --cli --catalog configs/mcp.json --server stytch-mcp-demo \ @@ -483,7 +484,11 @@ mcp-inspector --cli --catalog configs/mcp.json --server stytch-mcp-demo \ --method tools/list ``` -Interactive OAuth on CLI prints the authorize URL to stdout (`ConsoleNavigation`); prefer TUI for first-time login smokes. +**Expect:** stdout prints `Please navigate to: …` → open in browser → redirect to `http://127.0.0.1:6276/oauth/callback` → stderr `Authorization complete.` → JSON on stdout. + +Reuse tokens from a prior web/TUI/CLI session when `~/.mcp-inspector/storage/oauth.json` already has valid tokens for that server (passive file sharing). + +Mid-session / step-up manual validation: [§5](v2_auth_smoke_testing.md#5-mid-session-auth--step-up--manual-validation) (web **W1 + W5–W7**, TUI **T1–T2 + T4**, CLI **C1–C2** required; **W8–W11** recommended after auth recovery changes). ### Alternative: DCR on Stytch (no metadata hosting) @@ -523,6 +528,293 @@ Register **`http://127.0.0.1:6276/oauth/callback`** on the xaa.dev IdP before le --- +## 5. Mid-session auth + step-up — manual validation + +**Purpose:** Manual checklist for mid-session OAuth recovery across **web**, **TUI**, and **CLI** after changes to auth / step-up UX. Design: [v2_auth_mid_session.md](v2_auth_mid_session.md). + +**Fixture:** local composable server `test-servers/configs/oauth-step-up-demo.json` (`echo` unscoped, `get_temp` requires `weather:read`). Catalog scope **`mcp tools:read` only** (omit `weather:read`) so `get_temp` triggers step-up. + +### Required vs optional + +Run **required** smokes before release or after any auth UX change. **Optional** smokes extend coverage; run when time allows or when touching the listed area. + +| ID | Client | When | Required? | +| -- | ------ | ---- | --------- | +| **W1** | Web | Step-up modal + OAuth resume | **Yes** | +| W2–W4 | Web | Connect-time, silent refresh, multi-tab | Optional | +| **W5–W11** | Web | P0/P1/P2 code-review UX (below) | **Recommended** after auth recovery changes | +| **T1, T2, T4** | TUI | Connect, step-up, clear OAuth | **Yes** | +| T3, T5–T6 | TUI | Modal path, tab/server selection | Optional | +| **C1, C2** | CLI | Connect + step-up **y/N** | **Yes** | +| C3 | CLI | Built launcher subprocess | Optional | + +**Minimum release gate:** **W1** + **W5–W7** (web), **T1–T2** + **T4** (TUI), **C1–C2** (CLI). + +### What CI covers vs what you verify manually + +| Capability | Web | TUI | CLI | Automated in CI? | +| ---------- | --- | --- | --- | ---------------- | +| Core `handleAuthChallenge()` / token exchange | Remote transport | Direct transport | Direct (`cliOAuth.ts`) | Yes — `inspectorClient-oauth-*-mid-session-e2e.test.ts`, `oauth-interactive.test.ts` | +| Silent mid-session refresh (valid refresh token) | Yes | Yes (reconnect) | N/A (one-shot) | Yes — remote e2e | +| Connect-time 401 → interactive OAuth | Yes | Yes | Yes | Partial — core/CLI integration; **not** full UI/binary | +| Step-up confirm before second OAuth | Modal | Auth tab **A** / **C** | stderr **y/N** | Partial — unit/modal tests; **not** real browser + Ink/terminal | +| OAuth resume after full-page redirect | Yes (`6274`) | N/A (loopback) | N/A (loopback) | Partial — `oauthResume` unit tests; **not** browser tab restore | +| Tab + form restore after step-up | Yes | N/A | N/A | **Manual only** | +| Network log survives redirect | Yes | N/A | N/A | **Manual only** | +| Multi–browser-tab defer (background tab) | Yes | N/A | N/A | **Manual only** | +| Tool / prompt / resource modal → auth recovery | Yes | Yes | CLI one-shot RPC only | Partial — component unit tests | +| Loopback callback server (`6276`) | N/A | Yes | Yes | Partial — integration tests auto-complete authorize URL | +| Step-up **decline** (stay connected / exit cleanly) | Modal Cancel | Auth **C** | **N** | **Manual only** | +| ReAuthBanner reconnect without disconnect | Yes | N/A | N/A | **Manual only** (W7) | +| Abandoned step-up / reauth snapshot → banner | Yes | N/A | N/A | **Manual only** (W6) | +| Step-up pre-redirect toast | Yes | N/A | N/A | **Manual only** (W5) | +| Background-tab defer + resume on visibility | Yes | N/A | N/A | **Manual only** (W8) | +| Concurrent step-up → warning toast | Yes | TUI overwrite message | N/A | **Manual only** (W9 / T5) | +| Partial consent after step-up → warning toast | Yes | Yes | Yes (stderr message) | Partial — unit copy only (W10) | +| Prompt / resource / app command-scoped recovery | Yes | Yes | N/A (tools only in CLI) | Partial — no full UI e2e (W11) | +| Built launcher → CLI binary subprocess | N/A | N/A | Yes | `scripts/smoke-cli.mjs` — **no OAuth** | + +Run this section when touching mid-session auth UX. CI proves protocol/core paths; **you** prove each client’s interactive UX with a real browser (and terminal for CLI **y/N**). + +### Shared setup + +1. **Composable OAuth server** (terminal A): + ```bash + cd clients/web && npm run test-servers:build + node ../../test-servers/build/server-composable.js \ + --config ../../test-servers/configs/oauth-step-up-demo.json + ``` + Confirm stderr: `Composable server listening at http://127.0.0.1:8081/mcp`. + +2. **Catalog entry** — add to `~/.mcp-inspector/mcp.json` (all three clients): + ```json + "oauth-step-up-demo": { + "type": "streamable-http", + "url": "http://127.0.0.1:8081/mcp", + "oauth": { "scopes": "mcp tools:read" } + } + ``` + +3. **Clean OAuth state** before each client’s run: + - **Web:** Server Settings → OAuth → **Clear stored OAuth state** (or Connection Info → **Clear and disconnect**) + - **TUI / CLI:** Auth tab → **S** (TUI), or remove the server key from `~/.mcp-inspector/storage/oauth.json` / use a fresh `HOME` (CLI) + +4. **Ports:** composable server **8081**; web callback **6274**; TUI/CLI callback **6276** (only one TUI/CLI listener at a time). + +--- + +### Web — manual validation + +**Start web** (terminal B): `npm run web:dev -- --catalog configs/mcp.json` (or your `mcp.json` with the entry above). + +#### W1 — Step-up modal + OAuth resume (**required**) + +Primary path; CI does **not** exercise modal UX or post-redirect tab restore. + +1. Connect to **oauth-step-up-demo** → browser OAuth → **connected**. +2. **Tools** → **echo** → **Run** → success. +3. **Tools** → **get_temp** (city `NYC`, units `C`) → **Run**. +4. **Expect:** **“Additional permissions required”** modal (scopes include `weather:read`); **no** immediate redirect. +5. **Cancel** → modal closes, error shown, still **connected** → **echo** still works. +6. **Run get_temp** again → **Authorize** → redirect to AS on `:8081` → approve → `http://localhost:6274/oauth/callback` → return to app. +7. **Expect:** **Tools** tab, **get_temp** form restored, toast **“Step-up authorization succeeded. Retry your action.”**, network log from pre-redirect still visible. +8. **Run get_temp** → temperature result. + +#### W2 — Connect-time OAuth with no prior session (optional) + +1. Clear OAuth state, disconnect. +2. Connect → full-page OAuth → callback → toast **“Authentication succeeded. Retry your action.”** + +#### W3 — Silent mid-session refresh (optional) + +1. Connect, run **echo** successfully. +2. Invalidate access token only (devtools → `sessionStorage` → `mcp-inspector-oauth`), keep refresh token. +3. **Run echo** again → success **without** leaving the page. + +> CI: `inspectorClient-oauth-remote-mid-session-e2e.test.ts`. Skip W3 if W1 passed and time is short. + +#### W4 — Multi–browser-tab defer (optional, manual only) + +1. Tab A: connect, start step-up on **get_temp**, **do not** click Authorize yet. +2. Tab B: same server, run **echo** → should **not** redirect Tab B to OAuth while Tab A’s modal is open. +3. Tab A: **Authorize** → complete flow. + +#### W5 — Step-up pre-redirect toast (**recommended**, MR-219) + +1. Complete W1 steps 1–3 to open the step-up modal. +2. Click **Authorize**. +3. **Expect:** blue toast **“Step-up authorization for …”** / **“Redirecting to authorize additional permissions…”** appears **before** the browser leaves the page. +4. Complete OAuth → W1 steps 7–8. + +#### W6 — Abandoned step-up → re-auth banner (**recommended**, MR-111) + +1. Complete W1 steps 1–3; click **Authorize** and reach the AS consent page on `:8081`. +2. **Do not** approve — navigate back to `http://localhost:6274/` (or close the OAuth tab and open the inspector URL without callback query params). +3. **Expect:** persistent red **Re-authentication required** banner mentioning step-up was not completed. +4. Dismiss or use **Re-authenticate** (see W7). + +#### W7 — ReAuthBanner reconnect without disconnect (**recommended**, MR-218) + +**Prerequisite:** connected session + re-auth banner visible (from W6, OAuth callback error, or invalidate tokens and trigger a `token_expired` / `unauthorized` recovery that shows the banner — not an `insufficient_scope` toast). + +1. Confirm Connection toggle still shows **connected** (session not torn down). +2. Click banner **Re-authenticate**. +3. **Expect:** **no** disconnect/reconnect cycle; browser OAuth opens (or silent restore if tokens still valid). +4. After successful OAuth, banner clears; **echo** still works without manual reconnect. + +#### W8 — Background-tab defer + resume (**recommended**, MR-104) + +1. Connect; open step-up modal on **get_temp** (W1 steps 1–3) but **do not** click Authorize yet. +2. Switch away from the inspector tab (another browser tab or app) so the page is **hidden** (`document.visibilityState === "hidden"` — DevTools → **Rendering** → *Emulate page visibility hidden* also works). +3. Return to the inspector tab (or disable emulation). +4. **Expect:** deferred recovery runs — step-up modal or OAuth flow resumes without losing the connected session. +5. Complete step-up; run **get_temp** successfully. + +#### W9 — Concurrent step-up blocked (**recommended**, MR-110) + +1. Connect; open step-up modal on **get_temp**; leave it open. +2. Trigger a second step-up need (e.g. open step-up again via a second **Run** on **get_temp**, or a second scoped action if the fixture supports it). +3. **Expect:** yellow toast **“Step-up authorization in progress…”**; first modal remains; no second redirect. + +#### W10 — Partial consent after step-up (**optional**) + +1. Complete step-up **Authorize** flow but on the AS consent screen **deny** or omit `weather:read` if the AS offers granular scopes. +2. **Expect:** return to app with **warning** toast about permissions not granted — **not** the green **“Step-up authorization succeeded…”** toast. +3. **Run get_temp** again → step-up modal reappears. + +#### W11 — Prompt / resource / app recovery (**optional**, MR-003) + +The default `oauth-step-up-demo.json` fixture is **tools-only**. To smoke command-scoped recovery on other surfaces, temporarily add a scoped prompt or resource to the composable config (see [v2_auth_mid_session.md § Test infrastructure](v2_auth_mid_session.md#test-infrastructure)) and restart the server. + +1. Connect with `mcp tools:read` only. +2. **Prompts** → run a scoped **Get** (or **Resources** → **Read**, or **Apps** → open) that returns `403 insufficient_scope`. +3. **Expect:** same step-up modal / redirect / resume behavior as W1 — not an unhandled error. Cancel should only affect that panel’s in-flight state (MR-106). + +--- + +### TUI — manual validation + +**Start TUI:** `mcp-inspector --tui --catalog ~/.mcp-inspector/mcp.json` (or `--config`). No web dev required. + +#### T1 — Connect-time OAuth on 401 (**required**) + +CI does **not** run Ink + real browser callback. + +1. Clear OAuth state (**Auth** tab → **S**, or fresh `oauth.json`). +2. Select **oauth-step-up-demo** → **Connect** (`c`). +3. **Expect:** Auth tab shows authenticating; terminal/OS opens authorize URL (or log shows URL); browser completes redirect to `http://127.0.0.1:6276/oauth/callback`. +4. **Expect:** connected; **Tools** tab lists **echo** and **get_temp**. + +#### T2 — Step-up confirm on Auth tab (**required**) + +Requires connected session with **`mcp tools:read` only**. If connect already granted `weather:read`, clear state and retry, or skip to T3 only if `get_temp` succeeds without step-up. + +1. **Tools** tab → **get_temp** → run with city/units. +2. **Expect:** focus moves to **Auth** tab; message that extra scopes are needed; prompts **A** (authorize) / **C** (cancel). +3. Press **C** → message “Authorization cancelled.” → still connected → **echo** still works. +4. Run **get_temp** again → press **A** → browser OAuth → callback on **6276**. +5. **Expect:** Auth message **“Step-up authorization succeeded. Retry your action.”** +6. Run **get_temp** again → success. + +#### T3 — Tool test modal auth recovery (optional) + +1. From **Tools**, open test modal for **get_temp**, trigger step-up as in T2. +2. **Expect:** same Auth tab step-up flow (not a separate modal). + +#### T4 — Clear OAuth state (**required**, quick) + +1. While connected, **Auth** tab → **S**. +2. **Expect:** tokens cleared, disconnected if was connected. + +#### T5 — Step-up while on another tab (**optional**, MR-004) + +1. Connect (T1); stay on **Tools** tab (not Auth). +2. Run **get_temp** → step-up needed. +3. **Expect:** focus moves to **Auth** tab; **oauth-step-up-demo** remains selected; step-up prompt visible (T2 step 2). + +#### T6 — Concurrent step-up overwrite message (**optional**, MR-110) + +1. Connect; open step-up prompt on Auth tab (T2 step 2); leave it open. +2. Trigger another step-up (run **get_temp** again). +3. **Expect:** Auth tab message about an existing step-up prompt; no silent loss of the first prompt. + +> CI: `inspectorClient-oauth-direct-mid-session-e2e.test.ts` (core client, no Ink). TUI component tests: `App.test.tsx`, `tuiOAuth.test.ts`. + +--- + +### CLI — manual validation + +Uses loopback callback **`http://127.0.0.1:6276/oauth/callback`** (override with `--callback-url`). **Do not** run TUI and CLI OAuth smokes simultaneously on 6276. + +#### C1 — Connect-time OAuth + `tools/list` (**required**) + +CI auto-completes authorize URL; **you** use a real browser. + +1. Shared setup steps 1–3 (web dev **not** required). +2. ```bash + mcp-inspector --cli --catalog ~/.mcp-inspector/mcp.json --server oauth-step-up-demo \ + --method tools/list + ``` +3. **Expect stdout:** `Please navigate to: http://127.0.0.1:8081/oauth/authorize?...` +4. Open URL → approve → redirect to **6276** → “OAuth complete”. +5. **Expect stderr:** `Authorization complete.` +6. **Expect stdout:** JSON with `echo` and `get_temp`. + +#### C2 — Step-up **y/N** + `tools/call get_temp` (**required**) + +Session from C1 must have **`mcp tools:read` only**. If step-up does not prompt, clear OAuth state and retry C1. + +1. ```bash + mcp-inspector --cli --catalog ~/.mcp-inspector/mcp.json --server oauth-step-up-demo \ + --method tools/call --tool-name get_temp --tool-arg city=NYC --tool-arg 'units="C"' + ``` +2. **Expect stderr:** scope message + `Proceed with step-up authorization? [y/N]`. +3. Type **`n`** → exit ≠ 0, “Step-up authorization declined.” +4. Re-run; type **`y`** → authorize URL on stdout → browser → **6276** callback. +5. **Expect stderr:** `Authorization complete. Retrying…` then stdout JSON with temperature. + +#### C3 — Subprocess binary path (optional) + +`scripts/smoke-cli.mjs` (`npm run smoke:cli`) exercises the **built launcher + binary** for catalog/config/`tools/list` — **not** OAuth. Optional sanity check after CLI auth changes; C1/C2 remain the OAuth manual gate. + +> CI: `clients/cli/__tests__/oauth-interactive.test.ts`, `cliOAuth.test.ts` (in-process `cliOAuth.ts`, not subprocess). + +--- + +### Manual sign-off checklist + +| # | Check | Web | TUI | CLI | +| - | ----- | --- | --- | --- | +| 1 | Connect-time OAuth with empty storage | W1 prep / W2 | T1 | C1 | +| 2 | Unscoped tool works after connect | W1 step 2 | T1 | C1 output | +| 3 | Step-up prompt before second OAuth | W1 step 4 | T2 step 2 | C2 step 2 | +| 4 | Decline step-up, stay usable | W1 step 5 | T2 step 3 | C2 step 3 | +| 5 | Accept step-up + complete browser OAuth | W1 step 6–8 | T2 step 4–6 | C2 step 4–5 | +| 6 | Clear OAuth state | Settings | T4 | delete `oauth.json` / fresh HOME | +| 7 | Step-up pre-redirect toast | W5 | — | — | +| 8 | Abandoned step-up → banner | W6 | — | — | +| 9 | ReAuthBanner reconnect (no disconnect) | W7 | — | — | +| 10 | Background-tab defer + resume | W8 | — | — | +| 11 | Concurrent step-up warning | W9 | T6 | — | +| 12 | Partial consent warning toast | W10 | T2 (if AS allows) | C2 (if AS allows) | +| 13 | Prompt / resource / app recovery | W11 | T3 | — | + +### Troubleshooting + +| Symptom | Likely cause | +| ------- | ------------- | +| Redirect with no modal / no step-up prompt | Scopes already include `weather:read`; clear OAuth state and ensure catalog has `"scopes": "mcp tools:read"` only | +| Web callback “could not be matched” | Missing `mcp-inspector:oauth-resume` in `sessionStorage` — use modal **Authorize**, not manual URL | +| TUI/CLI `EADDRINUSE` on 6276 | Another TUI/CLI (or stale process) holds callback port | +| CLI step-up never prompts | Connect-time recovery unioned scopes — clear tokens and retry C1 | +| `get_temp` fails after web step-up without re-run | Expected — click **Run** again (no auto-replay after full-page OAuth) | +| ReAuthBanner **Re-authenticate** disconnects session | Should **not** happen when already connected — see W7 | +| Step-up shows toast instead of modal | `insufficient_scope` recovery failures use a toast, not ReAuthBanner (MR-206) | +| No pre-redirect toast on step-up Authorize | See W5 — blue toast expected before redirect (MR-219) | +| Port conflict on 8081 | Change `transport.port` in config and catalog URL | + +--- + ## What to verify (all smokes) | Check | Where | @@ -549,14 +841,17 @@ Register **`http://127.0.0.1:6276/oauth/callback`** on the xaa.dev IdP before le | Static | GitHub MCP + your OAuth App | `test-static-client` / `test-static-secret` on TestServerHttp | | CIMD | **Stytch demo MCP** (`stytch-as-demo.val.run`) + [MCPJam metadata URL](#cimd-credentials-for-smoke-mcpjam) (or local composable) | `createClientMetadataServer()` in e2e | | EMA | xaa.dev staging | `inspectorClient-ema-e2e.test.ts` + mocks | +| Mid-session / step-up (web remote) | §5 **W1** + **W5–W7** (required gate); W2–W4, W8–W11 optional | `inspectorClient-oauth-remote-mid-session-e2e.test.ts`; web unit (`oauthResume`, `StepUpAuthModal`) | +| Mid-session / step-up (TUI direct) | §5 **T1–T2, T4** (required); T3, T5–T6 optional | `inspectorClient-oauth-direct-mid-session-e2e.test.ts` (core); `App.test.tsx`, `tuiOAuth.test.ts` (no Ink+browser) | +| Mid-session / step-up (CLI direct) | §5 **C1–C2** (required) | `clients/cli/__tests__/oauth-interactive.test.ts`, `cliOAuth.test.ts` (in-process; not subprocess) | ## Known gaps (Inspector) -See **[Mid-session authorization](v2_auth_mid_session.md)** for the design to address mid-session 401, token refresh, and step-up scope challenges (including web remote reconnect). +**Mid-session auth** is implemented for web (remote transport), TUI, and CLI — see [Mid-session authorization](v2_auth_mid_session.md). **Remaining:** shared `RemoteOAuthStorage` on web, optional idle SSE E2E, v2 SDK transport upgrade for direct silent retry. See **[Auth hardening (MCP 2026-07-28)](v2_auth_hardening.md)** for connect-time OAuth hardening (SEP-2468, SEP-837, SEP-2352, SEP-2207, SEP-2350, SEP-2351) and the v2 SDK upgrade strategy. -- **CLI interactive OAuth:** no local callback server yet — reuse tokens from web/TUI or complete auth in TUI first; CLI prints authorize URLs to stdout when a new login is required. +- **Mid-session auth:** see [§5 manual validation](v2_auth_smoke_testing.md#5-mid-session-auth--step-up--manual-validation) — CI covers core protocol; **W1 + W5–W7 / T1–T2 + T4 / C1–C2** are the required manual gate per client; **W8–W11, T3, T5–T6, C3** extend P0/P1/P2 UX coverage. - **Client credentials grant:** not implemented ([#1225](https://github.com/modelcontextprotocol/inspector/issues/1225)). ## References diff --git a/specification/v2_catalog_launch_config.md b/specification/v2_catalog_launch_config.md index ad43cc6d..6fa0b9a7 100644 --- a/specification/v2_catalog_launch_config.md +++ b/specification/v2_catalog_launch_config.md @@ -1,6 +1,6 @@ # Inspector V2 Tech Stack - Storage - Catalog and Launch Configuration -### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | V2 Tech Stack | [V2 UX](v2_ux.md) +### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | V2 Tech Stack | [V2 UX](v2_ux.md) | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) #### [Web Client](v2_web_client.md) | [CLI, TUI, Launcher](v2_cli_tui_launcher.md) | [Server](v2_server.md) | [Storage](v2_storage.md) diff --git a/specification/v2_cli_tui_launcher.md b/specification/v2_cli_tui_launcher.md index 96c0ea3d..10a812ee 100644 --- a/specification/v2_cli_tui_launcher.md +++ b/specification/v2_cli_tui_launcher.md @@ -1,6 +1,6 @@ # Inspector V2 Tech Stack - CLI, TUI, and Launcher -### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | V2 Tech Stack | [V2 UX](v2_ux.md) +### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | V2 Tech Stack | [V2 UX](v2_ux.md) | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) #### [Web Client](v2_web_client.md) | CLI, TUI, Launcher | [Server](v2_server.md) | [Storage](v2_storage.md) diff --git a/specification/v2_new_spec_impact.md b/specification/v2_new_spec_impact.md new file mode 100644 index 00000000..26b2f4c4 --- /dev/null +++ b/specification/v2_new_spec_impact.md @@ -0,0 +1,375 @@ +# Inspector V2 - MCP Spec 2026-07-28: SEP Review and Inspector V2 Impact + +### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) | [V2 Auth](v2_auth.md) | V2 New Spec Impact + + + +* [1. Executive summary](#1-executive-summary) +* [2. Scope note: milestone vs. release](#2-scope-note-milestone-vs-release) +* [3. High-level overview of the milestone SEPs](#3-high-level-overview-of-the-milestone-seps) +* [4. Functional area: transport, HTTP, and observability](#4-functional-area-transport-http-and-observability) + * [4.1 SEP-2243 — HTTP Header Standardization (final title: "HTTP Header Standardization for Streamable HTTP Transport")](#41-sep-2243--http-header-standardization-final-title-http-header-standardization-for-streamable-http-transport) + * [4.2 SEP-2260 — Server requests must be associated with a client request](#42-sep-2260--server-requests-must-be-associated-with-a-client-request) + * [4.3 SEP-414 — OpenTelemetry trace context in `_meta`](#43-sep-414--opentelemetry-trace-context-in-_meta) +* [5. Functional area: authorization](#5-functional-area-authorization) + * [5.1 SEP-837 — `application_type` in Dynamic Client Registration](#51-sep-837--application_type-in-dynamic-client-registration) + * [5.2 SEP-2350 — Client-side scope accumulation in step-up authorization](#52-sep-2350--client-side-scope-accumulation-in-step-up-authorization) + * [5.3 SEP-2351 — RFC 8414 well-known suffix declaration](#53-sep-2351--rfc-8414-well-known-suffix-declaration) + * [5.4 SEP-2352 — Authorization server binding and migration](#54-sep-2352--authorization-server-binding-and-migration) +* [6. Functional area: extensibility, UI, and governance](#6-functional-area-extensibility-ui-and-governance) + * [6.1 SEP-2133 — Extensions framework](#61-sep-2133--extensions-framework) + * [6.2 SEP-1865 — MCP Apps (`io.modelcontextprotocol/ui`)](#62-sep-1865--mcp-apps-iomodelcontextprotocolui) + * [6.3 SEP-1730 — SDK tiers](#63-sep-1730--sdk-tiers) +* [7. How the over-the-wire conversation changes](#7-how-the-over-the-wire-conversation-changes) + * [7.1 Connection establishment](#71-connection-establishment) + * [7.2 Responses and request-scoped streaming](#72-responses-and-request-scoped-streaming) + * [7.3 Server→client interactions: MRTR replaces server-initiated requests](#73-serverclient-interactions-mrtr-replaces-server-initiated-requests) + * [7.4 Push notifications: `subscriptions/listen` replaces the GET stream](#74-push-notifications-subscriptionslisten-replaces-the-get-stream) + * [7.5 Cancellation, resumability, keepalive](#75-cancellation-resumability-keepalive) + * [7.6 Logging](#76-logging) + * [7.7 Errors and version negotiation](#77-errors-and-version-negotiation) + * [7.8 Backward compatibility: the "era" model](#78-backward-compatibility-the-era-model) +* [8. TypeScript SDK v2: what it handles vs. what bubbles up](#8-typescript-sdk-v2-what-it-handles-vs-what-bubbles-up) + * [8.1 Structural/API migration (independent of the new spec)](#81-structuralapi-migration-independent-of-the-new-spec) + * [8.2 Handled automatically by SDK v2](#82-handled-automatically-by-sdk-v2) + * [8.3 Bubbles up to the application](#83-bubbles-up-to-the-application) +* [9. Impact on MCP Inspector V2 (`v2/main`)](#9-impact-on-mcp-inspector-v2-v2main) + * [9.1 Connection model and state management](#91-connection-model-and-state-management) + * [9.2 History and Network tabs (the Inspector's core value)](#92-history-and-network-tabs-the-inspectors-core-value) + * [9.3 Feature tabs](#93-feature-tabs) + * [9.4 Auth and the OAuth store](#94-auth-and-the-oauth-store) + * [9.5 Suggested sequencing](#95-suggested-sequencing) +* [10. Reference: new error codes](#10-reference-new-error-codes) +* [11. Sources](#11-sources) + +--- + +## 1. Executive summary + +The upcoming release (protocol version **`2026-07-28`**, successor to `2025-11-25`) is the largest revision in MCP's history. The milestone's closed SEPs fall into four functional areas — transport/HTTP, authorization, extensibility/UI, and ecosystem governance — but the milestone list alone understates the change: the final draft spec also incorporates several other merged SEPs (**SEP-2567 sessionless**, **SEP-2575 stateless**, **SEP-2322 MRTR**, **SEP-2663 tasks-as-extension**) that dominate the over-the-wire differences and interact directly with the milestone items. Both are covered here, clearly attributed. + +The headline for the Inspector: + +1. **The HTTP transport is redesigned.** POST-only, no `initialize` handshake, no sessions, no GET stream, no SSE resumability. Every request is self-describing via `_meta`; server→client requests (sampling/elicitation/roots) are replaced by an in-band `input_required` retry pattern (MRTR); push notifications move to a `subscriptions/listen` stream; new `Mcp-Method`/`Mcp-Name` headers are mandatory (SEP-2243). +2. **SDK v2 absorbs most of the mechanics** — era negotiation, `_meta` envelopes, header mirroring, MRTR auto-fulfilment, listen streams — behind the existing handler APIs. But it defaults to **legacy** behavior, and its docs explicitly warn that *debugging tools should not default to auto-negotiation*. Era selection, per-request log levels, response caching, pagination, tasks, and a new error taxonomy all bubble up to Inspector UX and state. +3. **Auth changes are storage-schema changes.** Credentials must be keyed by `(server, issuer)`, DCR must send `application_type`, scope step-up accumulation is a client responsibility. SDK v2 implements the flows but the Inspector's persisted OAuth store and Network-tab visualizations must follow. +4. **Extensions become a first-class concept** (`capabilities.extensions` on both sides), MCP Apps is the flagship extension (already implemented in Inspector v2 via `ext-apps`), and **tasks moves out of core into an extension with a redesigned polling model** — and SDK v2 removed its built-in tasks support entirely. + +--- + +## 2. Scope note: milestone vs. release + +The milestone was 41% complete at review time (10 closed, 14 open). The 10 closed items are covered in §3–§6. However, the published **draft spec changelog** already lists as *major changes* several SEPs merged outside this closed list — SEP-2567 (remove sessions), SEP-2575 (stateless + `server/discover` + `subscriptions/listen`), SEP-2322 (MRTR + `resultType`), and SEP-2663 (tasks extension). Since the Inspector must implement the *composite* protocol, §7 (wire changes) and §9 (Inspector impact) describe the full 2026-07-28 picture. Caveat: items could still shift before the July 28 release; the RC window is the time to re-verify. + +A reading caveat that applies throughout: **the merged SEP documents themselves contain stale examples** (e.g., SEP-2243's doc still shows `Mcp-Session-Id` and `initialize`, and error code `-32001` before renumbering to `-32020`). The authoritative integration is the draft spec pages at `modelcontextprotocol.io/specification/draft`, not the SEP files. + +--- + +## 3. High-level overview of the milestone SEPs + +| SEP | Title | Area | One-liner | +|---|---|---|---| +| [2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243) | HTTP Header Standardization | Transport | Mandatory `Mcp-Method`/`Mcp-Name` headers mirroring the JSON-RPC body; opt-in `x-mcp-header` param mirroring; `-32020 HeaderMismatch` | +| [2260](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2260) | Server requests tied to client requests | Transport | Sampling/elicitation/roots MUST be associated with an in-flight client request; no standalone server→client requests. Doctrinal precursor to MRTR | +| [414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414) | OTel trace context conventions | Observability | `traceparent`/`tracestate`/`baggage` reserved as bare `_meta` keys (W3C formats) | +| [837](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837) | Client type in DCR | Auth | Clients MUST send `application_type` (`"native"`/`"web"`) during Dynamic Client Registration | +| [2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350) | Scope accumulation in step-up auth | Auth | Servers challenge per-operation; **clients** union previously requested scopes with challenged scopes before re-authorizing | +| [2351](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2351) | RFC 8414 well-known suffix | Auth | MCP formally declares the default `oauth-authorization-server` suffix; no MCP-specific suffix. Confirmatory, no wire change | +| [2352](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352) | AS binding and migration | Auth | Credentials MUST be keyed by AS `issuer`; on AS change, re-register (DCR) or error (pre-registered); CIMD IDs are portable | +| [2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133) | Extensions framework | Extensibility | `capabilities.extensions` map on both sides; governance for official (`ext-*`) / experimental / third-party extensions | +| [1865](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865) | MCP Apps | Extensibility/UI | Extension `io.modelcontextprotocol/ui`: predeclared `ui://` HTML resources, sandboxed-iframe rendering, JSON-RPC-over-postMessage host bridge | +| [1730](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1777) | SDK tiers | Governance | Three-tier SDK classification driven by conformance testing and issue-triage SLAs. No wire impact | + +Also landed in the draft (not in the closed milestone list, but essential context): **SEP-2567** (sessionless), **SEP-2575** (stateless, `server/discover`, `subscriptions/listen`, removal of `ping`/`logging/setLevel`/`notifications/roots/list_changed`), **SEP-2322** (MRTR, required `resultType`), **SEP-2663** (tasks redesigned as extension `io.modelcontextprotocol/tasks`), **SEP-2549** (list-response caching hints), **SEP-2577** (deprecation annotations for sampling/roots/logging), **SEP-991** (CIMD; DCR deprecated in its favor), **SEP-2596** (feature lifecycle; HTTP+SSE formally Deprecated). + +--- + +## 4. Functional area: transport, HTTP, and observability + +### 4.1 SEP-2243 — HTTP Header Standardization (final title: "HTTP Header Standardization for Streamable HTTP Transport") + +The problem: intermediaries (gateways, load balancers, WAFs, rate limiters) could not route or police MCP traffic without parsing JSON bodies. The fix: mirror key body fields into HTTP headers on every Streamable HTTP POST. + +Standard headers, **required for compliance**: + +- `MCP-Protocol-Version` — must match `_meta["io.modelcontextprotocol/protocolVersion"]` in the body. +- `Mcp-Method` — mirrors the JSON-RPC `method`, on every request. +- `Mcp-Name` — mirrors `params.name`/`params.uri` on `tools/call`, `resources/read`, `prompts/get`. + +Opt-in custom headers: a server may annotate tool `inputSchema` properties with `x-mcp-header: "{Name}"`, and conforming clients **MUST** then mirror those argument values into `Mcp-Param-{Name}` headers. Constraints on annotatable properties are strict (primitive types only — string/integer/boolean, no `number`; statically reachable via plain `properties` chains — no `$ref`, `oneOf`, `items`, conditionals). **A violating annotation invalidates the whole tool definition, and clients on Streamable HTTP MUST exclude that tool from `tools/list` results** (a new client-side conformance duty). Values that aren't header-safe ASCII must be encoded with a Base64 sentinel format: `=?base64?{b64}?=`. + +Validation: any server processing the body MUST verify header/body consistency and reject mismatches with HTTP `400` + JSON-RPC error **`-32020 HeaderMismatch`** (renumbered from the SEP's draft `-32001`; `-32020`–`-32099` is now reserved for spec-defined errors). Intermediaries forward unknown `Mcp-Param-*` headers untouched. + +Client duties beyond sending headers: build `Mcp-Param-*` from the most recently fetched `inputSchema`; on rejection without a schema, re-fetch `tools/list` and retry; handle `413`/`431` gracefully; treat header names case-insensitively but values case-sensitively. + +### 4.2 SEP-2260 — Server requests must be associated with a client request + +Upgrades the 2025-era "SHOULD relate to the originating request" to **MUST**: `sampling/createMessage`, `elicitation/create`, and `roots/list` may only be sent in association with an in-flight client request, never standalone on an independent stream (`ping` excepted). Clients receiving an orphaned server request SHOULD respond `-32602`. + +Strategically this SEP is the bridge that made MRTR (SEP-2322) and GET-stream removal possible. In the final 2026-07-28 protocol its constraint is fully absorbed: **servers cannot send any JSON-RPC requests to clients at all.** Server→client interactions arrive as data inside the client's own response (`InputRequiredResult`), and the client answers by retrying the original request. Against a modern server, SEP-2260 requires no dedicated code; against 2025-era servers it lets a client drop out-of-band request handling from the standalone GET stream. + +One operational consequence survives into both eras: transports must tolerate **unbounded human-in-the-loop delays** on the originating request (servers keep streams alive with SSE keepalive comments; clients must not apply short timeouts to requests that may elicit). + +### 4.3 SEP-414 — OpenTelemetry trace context in `_meta` + +A small, documentation-only standardization: the bare keys `traceparent`, `tracestate`, and `baggage` are **reserved** in `_meta` (an explicit exception to the reverse-DNS prefix rule) and, when present, MUST follow W3C Trace Context / Baggage formats. This codifies what instrumented SDKs already emit and keeps interop with the OTel semantic conventions for MCP. There is no requirement for clients to send or servers to process them. + +```json +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{ + "name":"get_weather","arguments":{"location":"NYC"}, + "_meta":{"traceparent":"00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"}}} +``` + +The carrier is the JSON-RPC body (transport-independent — survives stdio). Note for a tool that displays `_meta` verbatim: `baggage` can carry arbitrary correlation key-values; treat as potentially sensitive. + +--- + +## 5. Functional area: authorization + +All four auth SEPs are clarifications/hardening of the OAuth 2.1 framework — no new endpoints or protocol machinery — but two of them (837, 2352) impose hard requirements on client behavior and persisted state. Note also that the draft restructures the authorization spec into four pages and **deprecates DCR in favor of CIMD** (SEP-991, separate PR), so the DCR requirements below live under a deprecated-but-supported mechanism. + +### 5.1 SEP-837 — `application_type` in Dynamic Client Registration + +OIDC-flavored authorization servers default omitted `application_type` to `"web"`, which forbids loopback redirect URIs — breaking DCR for desktop/CLI/localhost clients. Now: clients **MUST** send an appropriate `application_type` in the DCR request. Native apps (desktop, mobile, CLI, *locally hosted web apps accessed via localhost* — i.e., the Inspector) SHOULD use `"native"`; remote browser-based apps SHOULD use `"web"`. Clients MUST handle registration rejections caused by redirect-URI constraints, SHOULD surface a meaningful error, and MAY retry with adjusted parameters. + +### 5.2 SEP-2350 — Client-side scope accumulation in step-up authorization + +Resolves an ambiguity: the old spec's `insufficient_scope` example implied servers echo accumulated scopes; RFC 6750 says the `scope` attribute describes the *current* resource. The resolution: **servers stay stateless and challenge per-operation; clients own accumulation.** + +- The 403 challenge now carries only the scopes for the failing operation (spec example changed from `scope="files:read files:write user:profile"` to `scope="files:write"`). +- On step-up, the client **computes the union** of its previously requested scope set and the challenged scopes, and re-authorizes with that union. A naive client that requests only the challenged scope will receive a token that silently *lost* its earlier grants. +- Clients MUST NOT assume any set relationship between challenged scopes and `scopes_supported`, and need not deduplicate hierarchical scopes. + +Practical requirement: persist the requested scope set per `(server, AS)` pair and union on every challenge. + +### 5.3 SEP-2351 — RFC 8414 well-known suffix declaration + +Purely declaratory: MCP uses the default `oauth-authorization-server` suffix and **does not define an application-specific suffix**. The existing priority-ordered discovery probing (path-inserted `oauth-authorization-server`, then `openid-configuration` variants) is unchanged. A client that already implements the probing correctly is compliant; the only rule is don't invent an MCP-specific suffix. + +### 5.4 SEP-2352 — Authorization server binding and migration + +Closes a credential-confusion hole when a server's Protected Resource Metadata changes or lists multiple ASes: + +- Persisted client credentials (pre-registered or DCR-obtained) **MUST be keyed by the issuing AS's `issuer` identifier**. +- Multiple ASes in PRM are fully independent; separate registration state per AS, and credentials MUST NOT be reused across ASes. +- On AS change (detected via updated PRM): DCR clients **MUST re-register** with the new AS; pre-registered clients SHOULD surface an error rather than send mismatched credentials; **CIMD client IDs are explicitly portable** (no re-registration). + +This is a storage-schema change for any client that caches OAuth state: `(MCP server, AS issuer) → {client_id, tokens, scopes}` instead of `MCP server → client_id`. + +The four interact: 2352's re-registration goes through DCR where 837's `application_type` applies; 2350's accumulated scope set is part of the per-issuer state 2352 mandates; 2351's declared suffix governs the AS-metadata fetch used in 2352's issuer comparison. + +--- + +## 6. Functional area: extensibility, UI, and governance + +### 6.1 SEP-2133 — Extensions framework + +Exactly one core schema change: an optional `extensions` map on both `ClientCapabilities` and `ServerCapabilities`, keyed by `{vendor-prefix}/{extension-name}` (e.g. `io.modelcontextprotocol/ui`), each value being an extension-defined settings object (`{}` = supported, no settings). + +```json +"capabilities": { + "extensions": { + "io.modelcontextprotocol/ui": { "mimeTypes": ["text/html;profile=mcp-app"] } + } +} +``` + +The rest is governance: official extensions live in `ext-*` repos with delegated maintainers and independent versioning, created via a new Extensions Track SEP type (reference implementation required before review); experimental extensions incubate in `experimental-ext-*` repos; breaking changes require a new identifier. Graceful degradation is normative, SDK extension support must be opt-in/disabled by default, and extension support is **not** required for conformance or SDK tiering. Official extensions now include Apps (`io.modelcontextprotocol/ui`), Tasks (`io.modelcontextprotocol/tasks`), and the `ext-auth` pair (OAuth client credentials, Enterprise-Managed Authorization). + +### 6.2 SEP-1865 — MCP Apps (`io.modelcontextprotocol/ui`) + +The flagship extension: servers deliver interactive HTML UIs that hosts render inline. Mechanics: + +- **Declaration:** UI templates are *predeclared resources* under a `ui://` URI scheme with MIME type `text/html;profile=mcp-app`, fetched via ordinary `resources/read`. Tools link to them via `_meta.ui.resourceUri`; resources can carry `_meta.ui.csp` (origin allowlist over a deny-by-default CSP) and `_meta.ui.permissions` (camera/mic etc.). Predeclaration enables prefetching at `tools/list` time and security review. +- **Negotiation:** via the SEP-2133 `extensions` map, with the client declaring supported `mimeTypes`. Servers SHOULD fall back to text-only tools for non-supporting clients. +- **Runtime:** mandatory sandboxed iframe; iframe↔host communication is a JSON-RPC dialect of MCP over `postMessage`: `ui/initialize` handshake, host→app notifications (`ui/notifications/tool-input`, `tool-input-partial`, `tool-result`, `tool-cancelled`, `host-context-changed`, `size-changed`, `resource-teardown`), and app-initiated `tools/call` proxied — and policed/consent-gated — by the host, plus `ui/message`, `ui/open-link`, `ui/request-display-mode`. +- **No new methods on the client↔server channel.** All new methods live on the postMessage bridge. The normative spec versions independently in `modelcontextprotocol/ext-apps` (`2026-01-26`); `@modelcontextprotocol/ext-apps` ships the host-side `AppBridge`. + +### 6.3 SEP-1730 — SDK tiers + +Governance only, zero wire impact. Three tiers: Tier 1 (100% conformance via `modelcontextprotocol/conformance`, new spec features within each release's RC window, 2-business-day triage, 7-day P0 fixes), Tier 2 (≥80%, slower SLAs), Tier 3 (experimental). Standardized issue-label taxonomy feeds automated tier metrics; automatic relegation triggers exist. Extensions and experimental features are excluded from conformance scoring. Relevance to the Inspector: it's a signal on SDK dependency choice (the TypeScript SDK targets Tier 1, meaning day-one 2026-07-28 support), and the conformance suite itself is useful tooling for an inspector-class project. + +--- + +## 7. How the over-the-wire conversation changes + +This section describes the composite 2026-07-28 Streamable HTTP transport (SEP-2243 + 2567 + 2575 + 2322 + 2260), before vs. after. Verified against the draft spec pages and changelog. + +### 7.1 Connection establishment + +**Before (2025-11-25):** POST `initialize` → server responds with capabilities + optional `Mcp-Session-Id` header → POST `notifications/initialized` → all subsequent requests carry `Mcp-Session-Id` and `MCP-Protocol-Version` headers. Optional GET opens a standalone SSE stream for server-initiated messages. DELETE terminates the session. + +**After (2026-07-28):** there is no handshake and no session. The client either calls `server/discover` (which servers MUST implement) to learn versions/capabilities/identity up front, or just sends its first real request. Every request and notification is its own POST and is self-describing: + +```http +POST /mcp HTTP/1.1 +Content-Type: application/json +Accept: application/json, text/event-stream +MCP-Protocol-Version: 2026-07-28 +Mcp-Method: tools/call +Mcp-Name: get_weather +Mcp-Param-Region: us-west1 + +{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{ + "name":"get_weather", + "arguments":{"region":"us-west1","location":"NYC"}, + "_meta":{ + "io.modelcontextprotocol/protocolVersion":"2026-07-28", + "io.modelcontextprotocol/clientInfo":{"name":"mcp-inspector","version":"2.0.0"}, + "io.modelcontextprotocol/clientCapabilities":{"elicitation":{"form":{},"url":{}}} + }}} +``` + +GET and DELETE are gone (modern servers return 405). `Mcp-Session-Id` is gone (modern servers ignore it). One JSON-RPC message per POST; clients never POST JSON-RPC responses; no batching. + +### 7.2 Responses and request-scoped streaming + +Unchanged in shape: the server answers each request with either `application/json` (single object) or a `text/event-stream` **scoped to that request** — but the stream may now carry only *notifications related to that request* (`notifications/progress`, `notifications/message`) followed by the final response. Servers MUST NOT send JSON-RPC requests on any stream. + +### 7.3 Server→client interactions: MRTR replaces server-initiated requests + +**Before:** the server sent `elicitation/create` / `sampling/createMessage` / `roots/list` as JSON-RPC *requests* (on the POST's SSE stream, or pre-SEP-2260 even on the GET stream); the client POSTed the response back, correlated by JSON-RPC id. + +**After:** all results carry a required **`resultType`** (`"complete"` | `"input_required"`; results from older servers lacking it are treated as complete). When a server needs input, it *returns*: + +```json +{"jsonrpc":"2.0","id":1,"result":{ + "resultType":"input_required", + "inputRequests":{"1":{"method":"elicitation/create","params":{...}}}, + "requestState":"opaque-server-token"}} +``` + +The client gathers the answers locally and **retries the original request with a new id**, attaching `inputResponses` and echoing `requestState`. There is no server→client JSON-RPC request framing left on the wire. + +### 7.4 Push notifications: `subscriptions/listen` replaces the GET stream + +The client POSTs `subscriptions/listen` with a filter (`toolsListChanged`, `promptsListChanged`, `resourcesListChanged`, `resourceSubscriptions: [uris]`); the response is a long-lived SSE stream that begins with `notifications/subscriptions/acknowledged` and thereafter carries only opted-in notification types, each tagged `_meta["io.modelcontextprotocol/subscriptionId"]`. This also replaces `resources/subscribe`/`unsubscribe`. Request-scoped notifications never appear on this stream. + +### 7.5 Cancellation, resumability, keepalive + +Closing a request's SSE stream **is** cancellation (`notifications/cancelled` is stdio-only now). SSE resumability is removed — no `Last-Event-ID`, no event IDs; a broken stream loses the in-flight request and the client MUST re-issue it with a new id. Servers keep long streams alive with SSE comment lines; `ping` is removed from the modern protocol. + +### 7.6 Logging + +`logging/setLevel` and its session-scoped level are gone. The client opts into logs **per request** via `_meta["io.modelcontextprotocol/logLevel"]`; servers MUST NOT emit `notifications/message` for requests that omitted it. + +### 7.7 Errors and version negotiation + +New spec-defined JSON-RPC error codes, all surfaced as HTTP 400 with a JSON-RPC body: **`-32020 HeaderMismatch`**, **`-32021 MissingRequiredClientCapability`**, **`-32022 UnsupportedProtocolVersion`** (the error body lists `supported` versions so the client can retry). Unknown methods return HTTP **404** with JSON-RPC `-32601` — the JSON-RPC body is what distinguishes a modern server from a legacy HTTP+SSE server's bare 404. + +### 7.8 Backward compatibility: the "era" model + +The spec defines two eras: **modern** (≥2026-07-28, per-request metadata) and **legacy** (≤2025-11-25, initialize handshake). A dual-era client sends a modern request first; on 400 it inspects the body — a recognized modern JSON-RPC error means "modern server, fix and retry"; an empty/unrecognized body means legacy → fall back to `initialize`, and possibly further to the now-formally-Deprecated 2024-11-05 HTTP+SSE transport (GET, look for the `endpoint` event). Era determination is per-origin and SHOULD be cached. + +--- + +## 8. TypeScript SDK v2: what it handles vs. what bubbles up + +The Inspector v2 currently depends on `@modelcontextprotocol/sdk@1.29.0`. SDK v2 (`main`, `2.0.0-beta.2`) targets the 2026-07-28 spec, with stable release planned alongside the spec on July 28; v1.x remains supported ≥6 months after. There is also a `v1.x-2026-07-28` backport branch worth watching as a possible lower-effort path. + +### 8.1 Structural/API migration (independent of the new spec) + +- **Package split:** `@modelcontextprotocol/sdk` → `@modelcontextprotocol/client` (Client, transports, OAuth), `@modelcontextprotocol/core` (public Zod schemas), plus server packages. stdio transport moves to subpath `@modelcontextprotocol/client/stdio` (root barrel is browser-safe — convenient for the Inspector's web/Node split). `WebSocketClientTransport` removed. A codemod exists: `npx @modelcontextprotocol/codemod@beta v1-to-v2 .` +- **Handlers take method strings, not Zod schemas:** `client.setRequestHandler('elicitation/create', ...)`. Result schemas are dropped from `request()`/`callTool()` for spec methods (SDK enforces them); custom/raw passthrough requests need an explicit schema — an Inspector-style "send raw request" feature should pass `ResultSchema` from core. +- **Zod 4 required** (`^4.2.0`); zod 3 unsupported. +- **Error taxonomy rename:** `McpError` → `ProtocolError`; new `SdkError` with string codes (`RequestTimeout`, `ConnectionClosed`, `EraNegotiationFailed`, `MethodNotSupportedByProtocolVersion`, `InputRequiredRoundsExceeded`, ...); `StreamableHTTPError` → `SdkHttpError` (status on `.status`). Unknown-tool calls now *reject* with `-32602` instead of resolving `isError: true`. +- **Elicitation `mode` discriminant:** branch on `params.mode === 'url'` vs. everything-else-is-form; `ElicitResult.content` values strictly `string | number | boolean | string[]`. +- `Protocol` base class no longer exported; `client.fallbackRequestHandler` is the hook for catching arbitrary inbound requests (relevant for a debugging UI). + +### 8.2 Handled automatically by SDK v2 + +- **Era/version negotiation:** `versionNegotiation: { mode: 'auto' }` probes `server/discover` and falls back to `initialize`; `{ pin: '2026-07-28' }` forces modern. **Default is `'legacy'`** — nothing 2026 goes on the wire unless opted in. State accessors: `getProtocolEra()`, `getNegotiatedProtocolVersion()`, `getDiscoverResult()` (persistable; feed back as `connect(transport, { prior })` for a zero-round-trip reconnect). +- **`_meta` envelope** attached to every modern request; `resultType` consumed internally. +- **SEP-2243 headers:** `Mcp-Method`/`Mcp-Name`/`MCP-Protocol-Version` emitted; `x-mcp-header` args auto-mirrored into `Mcp-Param-*` (skipped in browsers due to CORS — note for the Inspector's browser-side transport: header mirroring only happens on the Node/proxy path); `-32020` recovery retry built in. +- **MRTR auto-fulfilment:** on modern connections, `input_required` results are fulfilled through the *same* `setRequestHandler` handlers for elicitation/sampling/roots, and the original request is retried automatically (default 10 rounds; opt out with `inputRequired: { autoFulfill: false }` or drive manually with `withInputRequired()`). Existing sampling/elicitation UI handlers work on both eras unchanged. +- **Notifications:** `listChanged` options transparently open/manage a `subscriptions/listen` stream on modern; explicit `client.listen(filter)` returns an `McpSubscription`. +- **Cancellation** switches to stream-abort on modern; calling code unchanged. +- **Auth:** RFC 9207 `iss` validation in `finishAuth(URLSearchParams)`, issuer stamping (SEP-2352), HTTPS token-endpoint enforcement, scope step-up union (SEP-2350, `onInsufficientScope: 'reauthorize' | 'throw'`), DCR `application_type` defaults (SEP-837), CIMD support (DCR marked deprecated). + +### 8.3 Bubbles up to the application + +- **Negotiation mode is a product decision.** The SDK docs explicitly warn that **debugging tools should not default to `'auto'`** (the probe stalls on silent stdio legacy servers and pollutes recorded transcripts with an extra round trip). The Inspector should expose era/version selection as per-server configuration. +- **List verbs auto-aggregate all pages** (up to `listMaxPages`, default 64) and return `nextCursor: undefined`. **A pagination-debugging UI must drop to raw `client.request({method:'tools/list', ...})` to observe individual pages.** +- **List verbs no longer throw on missing capability** (return empty instead) unless `enforceStrictCapabilities: true` — an Inspector probably wants strictness on, or should annotate the difference. +- **Response caching (SEP-2549):** on 2026 connections, list verbs honor server `ttlMs`/`cacheScope` and may answer *without a round trip*. A debugging tool should default to `cacheMode: 'bypass'`/`'refresh'` or display cache provenance. +- **Per-request log level:** the SDK does **not** auto-attach `logLevel`; server logs are silently absent until the client stamps the `_meta` key per request. An Inspector that displays server logs must manage this itself on modern connections. +- **Tasks: removed from the SDK.** The experimental tasks layer (polling helpers, `callToolStream`, `TaskStore`) is deleted. Task wire types remain importable as deprecated for 2025-11-25 interop, but `tasks/*` calls require the explicit-schema raw-request form. On a 2026 connection, tasks live in the redesigned `io.modelcontextprotocol/tasks` extension (SEP-2663: polling via `tasks/get`, new `tasks/update`, `tasks/list` removed, unsolicited task handles allowed) — with **no SDK support**; the Inspector implements it or waits for an ext package. +- **Extensions:** capability plumbing only — declare via constructor capabilities, read via `getServerCapabilities()?.extensions`; extension methods are custom methods with your own schemas. **MCP Apps has no support in this SDK** (it stays in `@modelcontextprotocol/ext-apps`, which Inspector v2 already uses). +- **OAuth UX details:** under probing negotiation, a connect-time 401 surfaces wrapped as `SdkError(EraNegotiationFailed)` with the `UnauthorizedError` at `error.data.cause` — the Inspector's 401→authorize→`finishAuth`→reconnect flow must unwrap it. `finishAuth` now takes the full callback `URLSearchParams`. +- **Transcript-classification caveat:** the modern probe itself carries the `_meta` envelope *before* era is known — "saw an envelope" ≠ "modern negotiated" when labeling captured traffic in History. + +--- + +## 9. Impact on MCP Inspector V2 (`v2/main`) + +Grounded in the current branch architecture: `InspectorClient` (core/mcp/inspectorClient.ts) wrapping SDK 1.29 with typed events → state stores → React hooks; browser transport remoted through the Hono backend (`core/mcp/remote/`); zustand-persisted OAuth store; capability-gated tabs. + +### 9.1 Connection model and state management + +This is the deepest change. Today `InspectorClient` and the UI assume: connect ⇒ `initialize` exchange ⇒ negotiated capabilities + optional session id ⇒ session-scoped everything. On modern servers none of that exists. + +- **Era becomes first-class connection state.** Add per-server config (auto / legacy / pin-2026, defaulting *not* to auto per SDK guidance) and expose `getProtocolEra()` / `getNegotiatedProtocolVersion()` through the event target and stores. Most downstream UI (which tabs show, which affordances render, how messages are interpreted) should gate on era, not just capabilities. +- **`ConnectionInfoModal` needs an era-aware redesign:** for modern servers there is no initialize result or session id to display — show the `server/discover` result, the pinned/negotiated version, and "sessionless" explicitly. Consider persisting `getDiscoverResult()` in the server catalog and passing it as `prior` for instant reconnects. +- **`getServerType` (`core/mcp/config.ts`) hard-codes `stdio | sse | streamable-http`.** Transport type and protocol era are now orthogonal dimensions; the config model and the remote-transport protocol (`/api/mcp/connect`) must carry both. The backend's `RemoteSession` also currently assumes a persistent transport per session — still true mechanically, but "connected" no longer implies any server-side session, which affects reconnect/disconnect semantics and what `/api/mcp/disconnect` means for a modern server (nothing to DELETE). +- **Capability gating changes source:** tabs are currently gated on the `initialize` result. On modern connections capabilities come from `server/discover` (or lazily from error responses). Also new gates: `-32021 MissingRequiredClientCapability` handling, and the `extensions` maps on both sides. + +### 9.2 History and Network tabs (the Inspector's core value) + +- **New message vocabulary to render and correlate:** `_meta` envelopes on every request, `resultType` on every result, `input_required` results, retried requests with **new ids** linked by `requestState`, `server/discover`, `subscriptions/listen` + `notifications/subscriptions/acknowledged` + `subscriptionId` tagging. The History view's request/response pairing logic must learn that one logical operation can span multiple JSON-RPC ids (MRTR retries) — probably the single most valuable new visualization: group an MRTR conversation (original call → input requests → user answers → retry → final result) as one expandable unit. +- **If MRTR auto-fulfilment is left on**, the SDK hides the retry loop; the Inspector should either drive MRTR manually (`autoFulfill: false` + `withInputRequired()`) to keep its pending-request UX and full visibility, or capture the auto-fulfilled rounds via the transport-level message log. The existing `PendingClientRequests`/inline panels survive either way since handlers are era-agnostic, but the *semantics* shown to the user ("server sent a request" vs. "server returned input_required; response goes back as a retry") should be accurate per era. +- **Network tab:** display and validate the new headers (`Mcp-Method`, `Mcp-Name`, `Mcp-Param-*`, sentinel-encoded values), show `-32020/-32021/-32022` failures distinctly, and reflect that browser-path requests won't carry `Mcp-Param-*` (mirroring happens on the Node side — since Inspector's web client proxies through the backend, mirroring should work, but verify in the remote-transport layer). Cancellation now appears as connection abort, not a `notifications/cancelled` frame. +- **Raw-request passthrough** must supply explicit result schemas (`ResultSchema`) under SDK v2, and is also the only way to exercise page-by-page pagination and cache-bypass behaviors worth surfacing as Inspector features ("fetch single page", "bypass cache", "refresh"). + +### 9.3 Feature tabs + +- **Tools:** parse/validate `x-mcp-header` annotations and *visibly flag tools excluded for invalid annotations* (the spec makes exclusion mandatory — a debugging tool should show *why* a tool vanished). Show which args will be mirrored to headers. Unknown-tool calls now reject with `-32602` rather than returning `isError` results — adjust result rendering. +- **Tasks tab: substantial rework.** The current implementation targets the 2025-11-25 experimental core tasks (including receiver tasks and `tasks/list`). Under SEP-2663 tasks are an extension (`io.modelcontextprotocol/tasks`) with polling `tasks/get`, new `tasks/update`, no `tasks/list`, no blocking `tasks/result`, and unsolicited task handles. SDK v2 gives no help. Plan: keep the current tab for legacy-era servers, build an extension-aware implementation (raw requests + explicit schemas) for modern ones, and gate on the extension being negotiated. +- **Resources:** `resources/subscribe`/`unsubscribe` and `ResourceSubscriptionsState` become legacy-only; on modern, subscriptions are entries in the `subscriptions/listen` filter — the state store should model "one listen stream + filter set + acknowledged state + reconnect-by-re-listen" (no resumability). +- **Logs:** `logging/setLevel` and the `LoggingScreen`'s level selector are legacy-only. On modern, add a per-request (or global stamp-every-request) log-level control and make clear that logs arrive on the originating request's stream. `ping` UI, if any, is legacy-only. +- **Apps:** already ahead of the curve via `ext-apps`. Work is alignment: advertise `io.modelcontextprotocol/ui` with `mimeTypes` through the now-formalized `capabilities.extensions` (the EMA advertisement in `inspectorClient.ts` is the existing precedent to generalize), and track the ext-apps `2026-01-26` spec/`AppBridge` version. +- **Extensions UI (new, small but high-leverage):** display the server's `extensions` map in Connection Info, and let users toggle which extensions the Inspector *advertises* — servers legitimately change tool registration based on client-declared extensions, so this is a real debugging knob. + +### 9.4 Auth and the OAuth store + +- **Re-key the zustand OAuth store** (`core/auth/store.ts`) from per-server to per-`(server, issuer)`: stamp `issuer` on stored `clientInformation`/`tokens`, detect issuer changes against freshly fetched PRM/AS metadata on every flow, invalidate + re-register (DCR) / error (static) / continue (CIMD) accordingly. SDK v2 handles the flow logic but expects providers to round-trip issuer-stamped objects and optionally implement `discoveryState()`/`saveDiscoveryState()`. +- **Persist the requested-scope set** per (server, issuer) and show the step-up union computation in the auth visualizations — this is exactly the kind of thing Inspector users will want to see when debugging 403 loops. Surface the `onInsufficientScope` policy as a setting. +- **DCR panel:** show the `application_type` sent (Inspector = `"native"`), render registration rejections meaningfully, and reflect DCR's deprecated-in-favor-of-CIMD status (Inspector v2 already supports CIMD registration kind — good position). +- **401 handling under negotiation:** unwrap `EraNegotiationFailed` → `UnauthorizedError` in `oauthManager.ts`; switch to `finishAuth(callbackParams)` whole-params form (RFC 9207 iss validation happens inside; mismatches throw `IssuerMismatchError` — a new error state to display). +- **Network tab auth capture** continues to work (discovery/DCR/token traffic through `/api/fetch`), but the masked-secret-field list and the flow-step model (`OAuthStep`) should gain the new steps/errors (issuer comparison, step-up union, CIMD fetch). + +### 9.5 Suggested sequencing + +1. **SDK v2 migration mechanics** (packages, codemod, Zod 4, handler signatures, error taxonomy) with `versionNegotiation: 'legacy'` — behavior-neutral, unblocks everything else. Evaluate the `v1.x-2026-07-28` branch as a hedge if v2-stable slips past your release window. +2. **Auth store re-keying + SEP-837/2350/2352 UX** — independent of transport work, applies to both eras, and the SDK betas already enforce the flows. +3. **Era-aware connection model** (config, remote-transport protocol, Connection Info, capability gating) with explicit era selection UI. +4. **History/Network upgrades** for the new vocabulary (MRTR grouping, listen streams, Mcp-* headers, new error codes). +5. **Tab-by-tab era forks:** logging, resources subscriptions, tasks-as-extension, x-mcp-header tooling, extensions toggle UI. + +--- + +## 10. Reference: new error codes + +| Code | Name | HTTP | Source | +|---|---|---|---| +| -32020 | HeaderMismatch | 400 | SEP-2243 (renumbered from draft -32001) | +| -32021 | MissingRequiredClientCapability | 400 | SEP-2575 | +| -32022 | UnsupportedProtocolVersion (body lists `supported`) | 400 | SEP-2575 | +| -32601 | Method not found (era/method mismatch) | **404** | draft transport spec | +| -32602 | Invalid params — incl. orphaned server request (legacy), unknown tool (SDK v2) | — | SEP-2260 / SDK v2 | + +`-32020`–`-32099` is now reserved for spec-defined errors; `-32000`–`-32019` remains implementation-defined. + +--- + +## 11. Sources + +- Milestone: https://github.com/modelcontextprotocol/modelcontextprotocol/milestone/4?closed=1 +- Draft spec + changelog: https://modelcontextprotocol.io/specification/draft (esp. `/basic/transports/streamable-http`, `/basic/authorization/*`, `/basic/patterns/mrtr`, `/basic/patterns/subscriptions`, `/changelog`) +- SEP documents (final): https://modelcontextprotocol.io/seps/2243-http-standardization, `/seps/2575-stateless-mcp`, `/seps/1865-mcp-apps-interactive-user-interfaces-for-mcp`, `/seps/2133-extensions`, `/seps/1730-sdks-tiering-system` +- Extensions & Apps: https://modelcontextprotocol.io/extensions/overview, https://github.com/modelcontextprotocol/ext-apps (spec `2026-01-26`) +- SDK tiers / conformance: https://modelcontextprotocol.io/community/sdk-tiers, https://github.com/modelcontextprotocol/conformance +- TypeScript SDK v2 (`main`, cloned at commit `7635115`, packages `2.0.0-beta.2`): `docs/migration/upgrade-to-v2.md`, `docs/migration/support-2026-07-28.md`, `docs/protocol-versions.md`, `docs/clients/*` +- Inspector `v2/main` (cloned at commit `066676a`): `AGENTS.md`, `core/mcp/inspectorClient.ts`, `core/mcp/remote/`, `core/auth/`, `clients/web/src/` +- Release blog posts: https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/, `/posts/sdk-betas-2026-07-28/` + +*Verification note: transport and protocol claims were cross-checked three ways — the draft spec pages, the draft changelog, and the SDK v2 source/docs (independently cloned). SEP-level normative wording was diffed against the 2025-11-25 spec where relevant. GitHub API access was intermittently unavailable during research, so PR-level diffs for the four auth SEPs were reconstructed from the published draft vs. 2025-11-25 text; attribution of individual sentences to specific PRs is high-confidence but not diff-verified. Re-verify against the final spec when it ships July 28.* diff --git a/specification/v2_scope.md b/specification/v2_scope.md index 09c312a2..3d012d72 100644 --- a/specification/v2_scope.md +++ b/specification/v2_scope.md @@ -1,6 +1,6 @@ # Inspector V2 Scope -### [Brief](README.md) | [V1 Problems](v1_problems.md) | V2 Scope | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) +### [Brief](README.md) | [V1 Problems](v1_problems.md) | V2 Scope | [V2 Tech Stack](v2_web_client.md) | [V2 UX](v2_ux.md) | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) ## Table of Contents diff --git a/specification/v2_screenshots.md b/specification/v2_screenshots.md index 3277baf1..78c82bce 100644 --- a/specification/v2_screenshots.md +++ b/specification/v2_screenshots.md @@ -1,7 +1,7 @@ # Inspector V2 UX - Visual Reference -### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | V2 UX -#### [Overview](v2_ux.md) | [Features](v2_ux_features.md) | [Handlers](v2_ux_handlers.md) | Screenshots | [Components](v2_ux_components.md) +### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | V2 UX | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) +#### [Overview](v2_ux.md) | [Features](v2_ux_features.md) | [Handlers](v2_ux_handlers.md) | Screenshots | [Components](v2_ux_components.md) | [Interfaces](v2_ux_interfaces.md) --- diff --git a/specification/v2_server.md b/specification/v2_server.md index 5d291dcb..0b0bfd19 100644 --- a/specification/v2_server.md +++ b/specification/v2_server.md @@ -1,6 +1,6 @@ # Inspector V2 Tech Stack - Server -### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | V2 Tech Stack | [V2 UX](v2_ux.md) +### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | V2 Tech Stack | [V2 UX](v2_ux.md) | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) #### [Web Client](v2_web_client.md) | [CLI, TUI, Launcher](v2_cli_tui_launcher.md) | Server | [Storage](v2_storage.md) diff --git a/specification/v2_servers_file.md b/specification/v2_servers_file.md index 8c559ac7..2a895502 100644 --- a/specification/v2_servers_file.md +++ b/specification/v2_servers_file.md @@ -1,6 +1,6 @@ # Inspector V2 Tech Stack - Storage - Server List File -### Brief | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | V2 Tech Stack | [V2 UX](v2_ux.md) +### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | V2 Tech Stack | [V2 UX](v2_ux.md) | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) #### [Web Client](v2_web_client.md) | [CLI, TUI, Launcher](v2_cli_tui_launcher.md) | [Server](v2_server.md) | Storage ##### [Overview](v2_storage.md) | Server List File diff --git a/specification/v2_storage.md b/specification/v2_storage.md index 0dee42b1..12da6073 100644 --- a/specification/v2_storage.md +++ b/specification/v2_storage.md @@ -1,6 +1,6 @@ # Inspector V2 Tech Stack - Storage Specification -### Brief | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | V2 Tech Stack | [V2 UX](v2_ux.md) +### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | V2 Tech Stack | [V2 UX](v2_ux.md) | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) #### [Web Client](v2_web_client.md) | [CLI, TUI, Launcher](v2_cli_tui_launcher.md) | [Server](v2_server.md) | Storage ##### Overview | [Server List File](v2_servers_file.md) diff --git a/specification/v2_ux.md b/specification/v2_ux.md index adeb1ff0..08c37b9b 100644 --- a/specification/v2_ux.md +++ b/specification/v2_ux.md @@ -1,6 +1,6 @@ # Inspector V2 UX - Specification -### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | V2 UX +### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | V2 UX | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) #### Overview | [Features](v2_ux_features.md) | [Handlers](v2_ux_handlers.md) | [Screenshots](v2_screenshots.md) | [Components](v2_ux_components.md) | [Interfaces](v2_ux_interfaces.md) ## Table of Contents diff --git a/specification/v2_ux_components.md b/specification/v2_ux_components.md index 72f03c76..bc58aa59 100644 --- a/specification/v2_ux_components.md +++ b/specification/v2_ux_components.md @@ -1,6 +1,6 @@ # Inspector V2 UX - Component Development -### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | V2 UX +### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | V2 UX | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) #### [Overview](v2_ux.md) | [Features](v2_ux_features.md) | [Handlers](v2_ux_handlers.md) | [Screenshots](v2_screenshots.md) | Components | [Interfaces](v2_ux_interfaces.md) We are developing React components with Mantine, Zustand, and Storybook using a pattern called "Presentational Components" that emphasizes separation of concerns and testability. diff --git a/specification/v2_ux_features.md b/specification/v2_ux_features.md index 203b0657..17e65bd5 100644 --- a/specification/v2_ux_features.md +++ b/specification/v2_ux_features.md @@ -1,6 +1,6 @@ # Inspector V2 UX - Feature Screens -### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | V2 UX +### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | V2 UX | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) #### [Overview](v2_ux.md) | Features | [Handlers](v2_ux_handlers.md) | [Screenshots](v2_screenshots.md) | [Components](v2_ux_components.md) | [Interfaces](v2_ux_interfaces.md) --- diff --git a/specification/v2_ux_handlers.md b/specification/v2_ux_handlers.md index af57e172..060f8f3f 100644 --- a/specification/v2_ux_handlers.md +++ b/specification/v2_ux_handlers.md @@ -1,6 +1,6 @@ # Inspector V2 UX - Handlers & Patterns -### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | V2 UX +### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | V2 UX | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) #### [Overview](v2_ux.md) | [Features](v2_ux_features.md) | Handlers | [Screenshots](v2_screenshots.md) | [Components](v2_ux_components.md) | [Interfaces](v2_ux_interfaces.md) --- diff --git a/specification/v2_ux_interfaces.md b/specification/v2_ux_interfaces.md index 0c906d28..f1e3a158 100644 --- a/specification/v2_ux_interfaces.md +++ b/specification/v2_ux_interfaces.md @@ -1,6 +1,6 @@ # Inspector V2 UX - Component Interfaces -### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | V2 UX +### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | V2 UX | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) #### [Overview](v2_ux.md) | [Features](v2_ux_features.md) | [Handlers](v2_ux_handlers.md) | [Screenshots](v2_screenshots.md) | [Components](v2_ux_components.md) | Interfaces ## Summary diff --git a/specification/v2_ux_interfaces_plan.md b/specification/v2_ux_interfaces_plan.md index 2fc71087..9868f5e2 100644 --- a/specification/v2_ux_interfaces_plan.md +++ b/specification/v2_ux_interfaces_plan.md @@ -1,6 +1,6 @@ # Inspector V2 UX - Component Interfaces Implementation Plan -### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | V2 UX +### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | V2 UX | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) #### [Overview](v2_ux.md) | [Features](v2_ux_features.md) | [Handlers](v2_ux_handlers.md) | [Screenshots](v2_screenshots.md) | [Components](v2_ux_components.md) | [Interfaces](v2_ux_interfaces.md) | Plan ## Goal diff --git a/specification/v2_ux_storybook.md b/specification/v2_ux_storybook.md index f0907d16..290ee412 100644 --- a/specification/v2_ux_storybook.md +++ b/specification/v2_ux_storybook.md @@ -1,6 +1,6 @@ # Inspector V2 UX - Storybook Component Plan -### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | V2 UX +### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | V2 UX | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) #### [Overview](v2_ux.md) | [Features](v2_ux_features.md) | [Handlers](v2_ux_handlers.md) | [Screenshots](v2_screenshots.md) | [Components](v2_ux_components.md) | [Interfaces](v2_ux_interfaces.md) | Storybook --- diff --git a/specification/v2_web_client.md b/specification/v2_web_client.md index 074e6680..26d6540d 100644 --- a/specification/v2_web_client.md +++ b/specification/v2_web_client.md @@ -1,6 +1,6 @@ # Inspector V2 Tech Stack -### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | V2 Tech Stack | [V2 UX](v2_ux.md) +### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | V2 Tech Stack | [V2 UX](v2_ux.md) | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) #### Web Client | [CLI, TUI, Launcher](v2_cli_tui_launcher.md) | [Server](v2_server.md) | [Storage](v2_storage.md) diff --git a/test-servers/configs/oauth-step-up-demo.json b/test-servers/configs/oauth-step-up-demo.json new file mode 100644 index 00000000..3efbc0e5 --- /dev/null +++ b/test-servers/configs/oauth-step-up-demo.json @@ -0,0 +1,22 @@ +{ + "serverInfo": { + "name": "oauth-step-up-demo", + "version": "1.0.0" + }, + "tools": [ + { "preset": "echo" }, + { "preset": "get_temp", "requiredScopes": ["weather:read"] } + ], + "oauth": { + "enabled": true, + "mode": "combined", + "requireAuth": true, + "scopesSupported": ["mcp", "tools:read", "weather:read"], + "supportRefreshTokens": true, + "supportDCR": true + }, + "transport": { + "type": "streamable-http", + "port": 8081 + } +} diff --git a/test-servers/configs/xaa-ema-http.json b/test-servers/configs/xaa-ema-http.json index ee5736d8..7e51e0c1 100644 --- a/test-servers/configs/xaa-ema-http.json +++ b/test-servers/configs/xaa-ema-http.json @@ -3,7 +3,10 @@ "name": "xaa-ema-resource", "version": "1.0.0" }, - "tools": [{ "preset": "echo" }, { "preset": "get_temp" }], + "tools": [ + { "preset": "echo", "requiredScopes": ["tools:read"] }, + { "preset": "get_env", "requiredScopes": ["tools:read", "env:read"] } + ], "transport": { "type": "streamable-http", "port": 8080 @@ -13,7 +16,7 @@ "mode": "protected-resource", "authorizationServers": ["https://auth.resource.xaa.dev"], "requireAuth": true, - "scopesSupported": ["mcp"], + "scopesSupported": ["mcp", "tools:read", "env:read"], "accessTokenIssuers": ["https://auth.resource.xaa.dev"], "jwksUri": "https://auth.resource.xaa.dev/jwks", "resource": "http://localhost:8080/", diff --git a/test-servers/src/composable-test-server.ts b/test-servers/src/composable-test-server.ts index c8627ffc..4c9440e7 100644 --- a/test-servers/src/composable-test-server.ts +++ b/test-servers/src/composable-test-server.ts @@ -100,6 +100,8 @@ export interface ToolDefinition { name: string; description: string; inputSchema?: ToolInputSchema; + /** OAuth scopes required to invoke this tool (enforced at HTTP layer). */ + requiredScopes?: string[]; /** Optional Zod object schema for tool output; when set, handler must return structuredContent. */ outputSchema?: unknown; /** Passed through to the SDK so clients can read tool-level `_meta` (e.g. `_meta.ui.resourceUri` for MCP App tools). */ @@ -115,6 +117,8 @@ export interface TaskToolDefinition { name: string; description: string; inputSchema?: ToolInputSchema; + /** OAuth scopes required to invoke this tool (enforced at HTTP layer). */ + requiredScopes?: string[]; execution?: { taskSupport: "required" | "optional" }; /** Passed through to the SDK so clients can read tool-level `_meta` (e.g. `_meta.ui.resourceUri` for an App-flavored task tool). Mirrors {@link ToolDefinition._meta}. */ _meta?: Record; @@ -132,11 +136,15 @@ export interface ResourceDefinition { * Only the default read handler applies this; a `customHandler` from `config.onRegisterResource` replaces the `contents` wholesale, so such a handler must re-add `_meta` itself if the resource needs it on the read response. */ _meta?: Record; + /** OAuth scopes required to read this resource (enforced at HTTP layer). */ + requiredScopes?: string[]; } export interface PromptDefinition { name: string; description?: string; + /** OAuth scopes required to fetch this prompt (enforced at HTTP layer). */ + requiredScopes?: string[]; promptString: string; // The prompt text with optional {argName} placeholders argsSchema?: PromptArgsSchema; // Can include completable() schemas // Optional completion callbacks keyed by argument name @@ -154,6 +162,8 @@ export interface ResourceTemplateDefinition { name: string; uriTemplate: string; // URI template with {variable} placeholders (RFC 6570) description?: string; + /** OAuth scopes required to read resources from this template (enforced at HTTP layer). */ + requiredScopes?: string[]; inputSchema?: ZodRawShapeCompat; // Schema for template variables handler: ( uri: URL, diff --git a/test-servers/src/load-config.ts b/test-servers/src/load-config.ts index 34b90102..7ae9f4c4 100644 --- a/test-servers/src/load-config.ts +++ b/test-servers/src/load-config.ts @@ -10,6 +10,8 @@ import YAML from "yaml"; export interface PresetRef { preset: string; params?: Record; + /** OAuth scopes the bearer token must include to use this capability. */ + requiredScopes?: string[]; } export interface ConfigFileOAuth { diff --git a/test-servers/src/preset-registry.ts b/test-servers/src/preset-registry.ts index 51dd2db7..0907b398 100644 --- a/test-servers/src/preset-registry.ts +++ b/test-servers/src/preset-registry.ts @@ -12,6 +12,7 @@ import type { } from "./composable-test-server.js"; import { createEchoTool, + createGetEnvTool, createAddTool, createGetSumTool, createWriteToStderrTool, @@ -80,6 +81,9 @@ function resolveToolPreset( switch (name) { case "echo": return createEchoTool(); + case "get-env": + case "get_env": + return createGetEnvTool(); case "add": return createAddTool(); case "get_sum": diff --git a/test-servers/src/resolve-config.ts b/test-servers/src/resolve-config.ts index 01513a6b..331bb549 100644 --- a/test-servers/src/resolve-config.ts +++ b/test-servers/src/resolve-config.ts @@ -14,12 +14,22 @@ import { createTestServerInfo } from "./test-server-fixtures.js"; import { resolvePreset } from "./preset-registry.js"; import type { ConfigFile, PresetRef } from "./load-config.js"; -function resolvePresetRefs( +function mergeRequiredScopes( + item: T, + ref: PresetRef, +): T { + if (!ref.requiredScopes?.length) { + return item; + } + return { ...item, requiredScopes: ref.requiredScopes }; +} + +function resolvePresetRefs( refs: Array | undefined, type: "tool" | "resource" | "resourceTemplate" | "prompt", ): T[] { if (!refs || refs.length === 0) return []; - const result: T[] = []; + const result: Array<{ requiredScopes?: string[] }> = []; for (const entry of refs) { const items = Array.isArray(entry) ? entry : [entry]; for (const ref of items) { @@ -31,10 +41,14 @@ function resolvePresetRefs( } const resolved = resolvePreset(type, presetName, ref.params); const arr = Array.isArray(resolved) ? resolved : [resolved]; - result.push(...(arr as T[])); + for (const item of arr) { + result.push(mergeRequiredScopes(item, ref)); + } } } - return result; + // The caller pairs `type` with the matching `T`, so each resolved preset is + // the requested definition kind; TS can't see that correspondence. + return result as T[]; } /** diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts index fe52bc28..bcaf6e1b 100644 --- a/test-servers/src/test-server-fixtures.ts +++ b/test-servers/src/test-server-fixtures.ts @@ -169,6 +169,22 @@ export function createEchoTool(): ToolDefinition { }; } +/** + * Create a "get-env" tool matching @modelcontextprotocol/server-everything. + * Returns the server process environment as pretty-printed JSON text. + */ +export function createGetEnvTool(): ToolDefinition { + return { + name: "get-env", + description: + "Returns all environment variables, helpful for debugging MCP server configuration", + inputSchema: {}, + handler: async () => { + return toToolResult(JSON.stringify(process.env, null, 2)); + }, + }; +} + /** * Create a tool that writes a message to stderr. Used to test stderr capture/piping. */ diff --git a/test-servers/src/test-server-http.ts b/test-servers/src/test-server-http.ts index b85f368c..4ebb5a37 100644 --- a/test-servers/src/test-server-http.ts +++ b/test-servers/src/test-server-http.ts @@ -11,6 +11,9 @@ import type { ServerConfig } from "./test-server-fixtures.js"; import { setupOAuthRoutes, createBearerTokenMiddleware, + buildScopeRequirementRegistry, + scopeRequirementRegistryHasEntries, + createScopeCheckMiddleware, } from "./test-server-oauth.js"; import { setTestServerControl, @@ -195,6 +198,10 @@ export class TestServerHttp { const mcpMiddleware: express.RequestHandler[] = []; if (this.config.oauth?.enabled && this.config.oauth.requireAuth) { mcpMiddleware.push(createBearerTokenMiddleware(this.config.oauth)); + const scopeRegistry = buildScopeRequirementRegistry(this.config); + if (scopeRequirementRegistryHasEntries(scopeRegistry)) { + mcpMiddleware.push(createScopeCheckMiddleware(scopeRegistry)); + } } // Set up Express route to handle MCP requests @@ -328,6 +335,10 @@ export class TestServerHttp { const sseMiddleware: express.RequestHandler[] = []; if (this.config.oauth?.enabled && this.config.oauth.requireAuth) { sseMiddleware.push(createBearerTokenMiddleware(this.config.oauth)); + const scopeRegistry = buildScopeRequirementRegistry(this.config); + if (scopeRequirementRegistryHasEntries(scopeRegistry)) { + sseMiddleware.push(createScopeCheckMiddleware(scopeRegistry)); + } } // One McpServer per connection (same pattern as streamable-http) diff --git a/test-servers/src/test-server-oauth-jwt.ts b/test-servers/src/test-server-oauth-jwt.ts index 2df746d4..519e0408 100644 --- a/test-servers/src/test-server-oauth-jwt.ts +++ b/test-servers/src/test-server-oauth-jwt.ts @@ -33,6 +33,34 @@ function audienceMatches(aud: unknown, expected: string): boolean { return false; } +function parseScopeString(scope: string | undefined): string[] { + if (!scope?.trim()) { + return []; + } + return scope.trim().split(/\s+/).filter(Boolean); +} + +/** Extract granted OAuth scopes from a verified JWT access token payload. */ +export function extractScopesFromJwtPayload( + payload: Record, +): string[] { + if (typeof payload.scope === "string") { + return parseScopeString(payload.scope); + } + if (Array.isArray(payload.scp)) { + return payload.scp.filter((s): s is string => typeof s === "string"); + } + if (Array.isArray(payload.scopes)) { + return payload.scopes.filter((s): s is string => typeof s === "string"); + } + return []; +} + +export interface ValidatedAccessToken { + valid: boolean; + scopes: string[]; +} + export class ExternalAccessTokenValidator { private jwks: ReturnType | null = null; private allowedIssuers = new Set(); @@ -97,11 +125,15 @@ export class ExternalAccessTokenValidator { }); } - async validateAccessToken(token: string): Promise { + async validateAccessTokenWithScopes( + token: string, + ): Promise { if (!this.jwks) { await this.ensureReady(); } - if (!this.jwks) return false; + if (!this.jwks) { + return { valid: false, scopes: [] }; + } try { const { payload } = await jwtVerify(token, this.jwks, { @@ -111,13 +143,20 @@ export class ExternalAccessTokenValidator { if (this.config.resourceAudience) { const aud = payload.aud; if (!audienceMatches(aud, this.config.resourceAudience)) { - return false; + return { valid: false, scopes: [] }; } } - return true; + return { + valid: true, + scopes: extractScopesFromJwtPayload(payload as Record), + }; } catch { - return false; + return { valid: false, scopes: [] }; } } + + async validateAccessToken(token: string): Promise { + return (await this.validateAccessTokenWithScopes(token)).valid; + } } diff --git a/test-servers/src/test-server-oauth.ts b/test-servers/src/test-server-oauth.ts index 94416e0b..4ea0ac30 100644 --- a/test-servers/src/test-server-oauth.ts +++ b/test-servers/src/test-server-oauth.ts @@ -11,6 +11,11 @@ import express from "express"; import type { ServerConfig } from "./composable-test-server.js"; import { ExternalAccessTokenValidator } from "./test-server-oauth-jwt.js"; +type OAuthRequest = Request & { + oauthToken?: string; + oauthTokenScopes?: string[]; +}; + /** * OAuth configuration from ServerConfig */ @@ -87,14 +92,21 @@ export function createBearerTokenMiddleware( const token = authHeader.substring(7); // Remove "Bearer " prefix let valid = false; + let grantedScopes: string[] = []; if (mode === "protected-resource") { try { - valid = await externalValidator!.validateAccessToken(token); + const validated = + await externalValidator!.validateAccessTokenWithScopes(token); + valid = validated.valid; + grantedScopes = validated.scopes; } catch { valid = false; } } else { valid = isValidToken(token); + if (valid) { + grantedScopes = getAccessTokenScopes(token); + } } if (!valid) { @@ -115,7 +127,9 @@ export function createBearerTokenMiddleware( } // Attach token info to request for use in handlers - (req as Request & { oauthToken?: string }).oauthToken = token; + const oauthReq = req as OAuthRequest; + oauthReq.oauthToken = token; + oauthReq.oauthTokenScopes = grantedScopes; next(); }; } @@ -190,86 +204,218 @@ function setupMetadataEndpoints( } /** - * Set up OAuth authorization endpoint - * For test servers, this auto-approves requests and redirects with authorization code + * Set up OAuth authorization endpoint. + * Shows a simple consent page so users know they reached the test authorization server. */ function setupAuthorizationEndpoint( app: express.Application, config: OAuthConfig, ): void { app.get("/oauth/authorize", async (req: Request, res: Response) => { - const { - client_id, - redirect_uri, - response_type, - scope, - state, - code_challenge, - code_challenge_method, - } = req.query; + const parsed = await parseAuthorizationRequest(req.query, config); + if (!parsed.ok) { + res.status(parsed.status).json(parsed.body); + return; + } - // Validate required parameters - if (!client_id || !redirect_uri || !response_type) { - res.status(400).json({ + res.type("html").send(renderOAuthConsentPage(parsed.value)); + }); + + app.post( + "/oauth/authorize", + express.urlencoded({ extended: true }), + async (req: Request, res: Response) => { + const parsed = await parseAuthorizationRequest(req.body, config); + if (!parsed.ok) { + res.status(parsed.status).json(parsed.body); + return; + } + + completeAuthorizationRedirect(res, parsed.value); + }, + ); +} + +interface AuthorizationRequestParams { + clientId: string; + redirectUri: string; + responseType: string; + scope?: string; + state?: string; + codeChallenge?: string; + codeChallengeMethod?: string; +} + +type AuthorizationRequestResult = + | { ok: true; value: AuthorizationRequestParams } + | { ok: false; status: number; body: Record }; + +async function parseAuthorizationRequest( + input: Record, + config: OAuthConfig, +): Promise { + const client_id = input.client_id; + const redirect_uri = input.redirect_uri; + const response_type = input.response_type; + const scope = input.scope; + const state = input.state; + const code_challenge = input.code_challenge; + const code_challenge_method = input.code_challenge_method; + + if ( + typeof client_id !== "string" || + typeof redirect_uri !== "string" || + typeof response_type !== "string" + ) { + return { + ok: false, + status: 400, + body: { error: "invalid_request", error_description: "Missing required parameters", - }); - return; - } + }, + }; + } - if (response_type !== "code") { - res.status(400).json({ error: "unsupported_response_type" }); - return; - } + if (response_type !== "code") { + return { ok: false, status: 400, body: { error: "unsupported_response_type" } }; + } - // Validate client (check static clients, DCR, or CIMD) - const client = await findClient(client_id as string, config); - if (!client) { - res.status(400).json({ error: "invalid_client" }); - return; - } + const client = await findClient(client_id, config); + if (!client) { + return { ok: false, status: 400, body: { error: "invalid_client" } }; + } - // Validate redirect_uri - if ( - client.redirectUris && - !client.redirectUris.includes(redirect_uri as string) - ) { - res.status(400).json({ + if ( + client.redirectUris && + !client.redirectUris.includes(redirect_uri) + ) { + return { + ok: false, + status: 400, + body: { error: "invalid_request", error_description: "Invalid redirect_uri", - }); - return; - } + }, + }; + } - // Validate PKCE - if (code_challenge_method && code_challenge_method !== "S256") { - res.status(400).json({ + if ( + typeof code_challenge_method === "string" && + code_challenge_method !== "S256" + ) { + return { + ok: false, + status: 400, + body: { error: "invalid_request", error_description: "Unsupported code_challenge_method", - }); - return; - } + }, + }; + } - // For test servers, auto-approve and generate authorization code - const authCode = generateAuthorizationCode(); + return { + ok: true, + value: { + clientId: client_id, + redirectUri: redirect_uri, + responseType: response_type, + ...(typeof scope === "string" ? { scope } : {}), + ...(typeof state === "string" ? { state } : {}), + ...(typeof code_challenge === "string" + ? { codeChallenge: code_challenge } + : {}), + ...(typeof code_challenge_method === "string" + ? { codeChallengeMethod: code_challenge_method } + : {}), + }, + }; +} - // Store authorization code temporarily (in production, use proper storage) - storeAuthorizationCode(authCode, { - clientId: client_id as string, - redirectUri: redirect_uri as string, - codeChallenge: code_challenge as string | undefined, - scope: scope as string | undefined, - }); - - // Redirect with authorization code - const redirectUrl = new URL(redirect_uri as string); - redirectUrl.searchParams.set("code", authCode); - if (state) { - redirectUrl.searchParams.set("state", state as string); - } - - res.redirect(redirectUrl.href); +function completeAuthorizationRedirect( + res: Response, + params: AuthorizationRequestParams, +): void { + const authCode = generateAuthorizationCode(); + storeAuthorizationCode(authCode, { + clientId: params.clientId, + redirectUri: params.redirectUri, + codeChallenge: params.codeChallenge, + scope: params.scope, }); + + const redirectUrl = new URL(params.redirectUri); + redirectUrl.searchParams.set("code", authCode); + if (params.state) { + redirectUrl.searchParams.set("state", params.state); + } + res.redirect(redirectUrl.href); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function renderOAuthConsentPage(params: AuthorizationRequestParams): string { + const scopeList = parseScopeString(params.scope); + const scopeItems = + scopeList.length > 0 + ? scopeList + .map((scope) => `
  • ${escapeHtml(scope)}
  • `) + .join("") + : "
  • No scopes requested
  • "; + + const hiddenFields = [ + ["client_id", params.clientId], + ["redirect_uri", params.redirectUri], + ["response_type", params.responseType], + ...(params.scope ? [["scope", params.scope] as const] : []), + ...(params.state ? [["state", params.state] as const] : []), + ...(params.codeChallenge + ? [["code_challenge", params.codeChallenge] as const] + : []), + ...(params.codeChallengeMethod + ? [["code_challenge_method", params.codeChallengeMethod] as const] + : []), + ] + .map( + ([name, value]) => + ``, + ) + .join("\n"); + + return ` + + + + + Authorize — MCP test server + + + +

    Authorize MCP Inspector

    +

    You were redirected to the local composable test authorization server.

    +

    Client: ${escapeHtml(params.clientId)}

    +

    Requested scopes:

    +
      ${scopeItems}
    +
    + ${hiddenFields} + +
    + +`; } /** @@ -382,7 +528,9 @@ function setupTokenEndpoint( } // Generate access token - const accessToken = generateAccessToken(); + const tokenScope = + authCodeData.scope || config.scopesSupported?.[0] || "mcp"; + const accessToken = generateAccessToken(tokenScope); const tokenExpiration = config.tokenExpirationSeconds || 3600; const response: { @@ -395,7 +543,7 @@ function setupTokenEndpoint( access_token: accessToken, token_type: "Bearer", expires_in: tokenExpiration, - scope: authCodeData.scope || config.scopesSupported?.[0] || "mcp", + scope: tokenScope, }; // Add refresh token if supported @@ -422,14 +570,16 @@ function setupTokenEndpoint( return; } - const accessToken = generateAccessToken(); + const tokenScope = + refreshTokenData.scope || config.scopesSupported?.[0] || "mcp"; + const accessToken = generateAccessToken(tokenScope); const tokenExpiration = config.tokenExpirationSeconds || 3600; res.json({ access_token: accessToken, token_type: "Bearer", expires_in: tokenExpiration, - scope: refreshTokenData.scope || config.scopesSupported?.[0] || "mcp", + scope: tokenScope, }); } else { res.status(400).json({ error: "unsupported_grant_type" }); @@ -501,6 +651,8 @@ interface RegisteredClient { const authorizationCodes = new Map(); const accessTokens = new Set(); +/** Granted OAuth scope string per access token (space-separated). */ +const accessTokenScopes = new Map(); const refreshTokens = new Map(); const registeredClients = new Map(); @@ -627,9 +779,10 @@ function getAuthorizationCode(code: string): AuthorizationCodeData | null { return data; } -function generateAccessToken(): string { +function generateAccessToken(scope?: string): string { const token = `test_access_token_${Date.now()}_${Math.random().toString(36).substring(7)}`; accessTokens.add(token); + accessTokenScopes.set(token, scope?.trim() || "mcp"); return token; } @@ -663,12 +816,191 @@ function isValidToken(token: string): boolean { return accessTokens.has(token); } +function parseScopeString(scope: string | undefined): string[] { + if (!scope?.trim()) { + return []; + } + return scope.trim().split(/\s+/).filter(Boolean); +} + +/** Test helper: mint an access token with the given granted scopes. */ +export function mintTestAccessToken(scope: string): string { + return generateAccessToken(scope); +} + +/** Granted scopes for a test-server access token (empty when unknown). */ +export function getAccessTokenScopes(token: string): string[] { + return parseScopeString(accessTokenScopes.get(token)); +} + +export interface ScopeRequirementRegistry { + tools: Map; + resources: Map; + prompts: Map; + resourceTemplates: Map; +} + +function resourceUriMatchesTemplate(uri: string, uriTemplate: string): boolean { + const brace = uriTemplate.indexOf("{"); + const prefix = brace >= 0 ? uriTemplate.slice(0, brace) : uriTemplate; + return uri.startsWith(prefix); +} + +/** Build lookup tables from merged ServerConfig capability definitions. */ +export function buildScopeRequirementRegistry( + config: ServerConfig, +): ScopeRequirementRegistry { + const registry: ScopeRequirementRegistry = { + tools: new Map(), + resources: new Map(), + prompts: new Map(), + resourceTemplates: new Map(), + }; + + for (const tool of config.tools ?? []) { + if (tool.requiredScopes?.length) { + registry.tools.set(tool.name, tool.requiredScopes); + } + } + for (const resource of config.resources ?? []) { + if (resource.requiredScopes?.length) { + registry.resources.set(resource.uri, resource.requiredScopes); + } + } + for (const prompt of config.prompts ?? []) { + if (prompt.requiredScopes?.length) { + registry.prompts.set(prompt.name, prompt.requiredScopes); + } + } + for (const template of config.resourceTemplates ?? []) { + if (template.requiredScopes?.length) { + registry.resourceTemplates.set(template.uriTemplate, template.requiredScopes); + } + } + + return registry; +} + +export function scopeRequirementRegistryHasEntries( + registry: ScopeRequirementRegistry, +): boolean { + return ( + registry.tools.size > 0 || + registry.resources.size > 0 || + registry.prompts.size > 0 || + registry.resourceTemplates.size > 0 + ); +} + +function parseMcpOperation(body: unknown): { + method?: string; + target?: string; +} { + if (!body || typeof body !== "object") { + return {}; + } + const rpc = body as Record; + const method = typeof rpc.method === "string" ? rpc.method : undefined; + const params = + rpc.params && typeof rpc.params === "object" + ? (rpc.params as Record) + : undefined; + + if (method === "tools/call" && typeof params?.name === "string") { + return { method, target: params.name }; + } + if (method === "resources/read" && typeof params?.uri === "string") { + return { method, target: params.uri }; + } + if (method === "prompts/get" && typeof params?.name === "string") { + return { method, target: params.name }; + } + + return { method }; +} + +function tokenHasRequiredScopes( + granted: string[], + required: string[], +): boolean { + const grantedSet = new Set(granted); + return required.every((scope) => grantedSet.has(scope)); +} + +/** + * Enforce per-capability OAuth scopes after bearer validation. + * Returns 403 + insufficient_scope when the token is valid but lacks scope. + */ +export function createScopeCheckMiddleware( + registry: ScopeRequirementRegistry, +): express.RequestHandler { + return (req: Request, res: Response, next: express.NextFunction) => { + const oauthReq = req as OAuthRequest; + const token = oauthReq.oauthToken; + if (!token) { + return next(); + } + + const { method, target } = parseMcpOperation(req.body); + if (!method || !target) { + return next(); + } + + let requiredScopes: string[] | undefined; + if (method === "tools/call") { + requiredScopes = registry.tools.get(target); + } else if (method === "resources/read") { + requiredScopes = registry.resources.get(target); + if (!requiredScopes?.length) { + for (const [uriTemplate, scopes] of registry.resourceTemplates) { + if (resourceUriMatchesTemplate(target, uriTemplate)) { + requiredScopes = scopes; + break; + } + } + } + } else if (method === "prompts/get") { + requiredScopes = registry.prompts.get(target); + } + + if (!requiredScopes?.length) { + return next(); + } + + const granted = + oauthReq.oauthTokenScopes ?? getAccessTokenScopes(token); + if (tokenHasRequiredScopes(granted, requiredScopes)) { + return next(); + } + + const grantedSet = new Set(granted); + const missingScopes = requiredScopes.filter((scope) => !grantedSet.has(scope)); + const scopeHeader = missingScopes.join(" ") || requiredScopes.join(" "); + res.status(403); + res.setHeader("Content-Type", "application/json"); + res.setHeader( + "WWW-Authenticate", + `Bearer error="insufficient_scope", scope="${scopeHeader}"`, + ); + res.json({ + jsonrpc: "2.0", + error: { + code: -32603, + message: `Forbidden: insufficient scope (403). Required: ${scopeHeader}`, + }, + id: null, + }); + return; + }; +} + /** * Clear all OAuth test data (useful for test cleanup) */ export function clearOAuthTestData(): void { authorizationCodes.clear(); accessTokens.clear(); + accessTokenScopes.clear(); refreshTokens.clear(); registeredClients.clear(); dcrRequests.length = 0; @@ -689,4 +1021,5 @@ export function getDCRRequests(): Array<{ redirect_uris: string[] }> { */ export function invalidateAccessToken(token: string): void { accessTokens.delete(token); + accessTokenScopes.delete(token); } diff --git a/test-servers/tsconfig.json b/test-servers/tsconfig.json index 7eda8adb..3f2a2079 100644 --- a/test-servers/tsconfig.json +++ b/test-servers/tsconfig.json @@ -10,7 +10,9 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "types": ["node"], + "typeRoots": ["../clients/web/node_modules/@types"] }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "build"]