From 071e0e8cd34fe47eec36b3e8af099e8bda9c00b4 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Thu, 25 Jun 2026 16:03:59 -0700 Subject: [PATCH 1/8] refactor(web): update OAuth E2E tests to use new authentication method - Replaced `runGuidedAuth` with `authenticate` in the InspectorClient OAuth end-to-end tests to support quick authentication with CIMD pre-registration. - Updated comments to reflect the change in authentication method and its support for HTTP test metadata URLs. - Ensured that the tests continue to validate the expected authorization URL after the change. Additionally, imported `ensureCimdClientRegistration` in the OAuthManager to streamline client registration during the authentication process. --- clients/web/src/test/core/auth/cimd.test.ts | 84 +++++++++++++++++++ .../mcp/inspectorClient-oauth-e2e.test.ts | 6 +- core/auth/cimd.ts | 52 ++++++++++++ core/auth/index.ts | 2 + core/mcp/oauthManager.ts | 7 ++ 5 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 clients/web/src/test/core/auth/cimd.test.ts create mode 100644 core/auth/cimd.ts diff --git a/clients/web/src/test/core/auth/cimd.test.ts b/clients/web/src/test/core/auth/cimd.test.ts new file mode 100644 index 000000000..90eec6f99 --- /dev/null +++ b/clients/web/src/test/core/auth/cimd.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { OAuthStorage } from "@inspector/core/auth/storage.js"; +import { BaseOAuthClientProvider } from "@inspector/core/auth/providers.js"; +import { ensureCimdClientRegistration } from "@inspector/core/auth/cimd.js"; + +const SERVER_URL = "http://127.0.0.1:9999/mcp"; +const METADATA_URL = "http://127.0.0.1:8888/client-metadata.json"; + +function createProvider(storage: OAuthStorage): BaseOAuthClientProvider { + return new BaseOAuthClientProvider( + SERVER_URL, + { + storage, + redirectUrlProvider: { + getRedirectUrl: () => "http://127.0.0.1:3000/oauth/callback", + }, + navigation: { navigateToAuthorization: vi.fn() }, + clientMetadataUrl: METADATA_URL, + }, + "quick", + ); +} + +describe("ensureCimdClientRegistration", () => { + let storage: OAuthStorage; + + beforeEach(() => { + storage = { + getClientInformation: vi.fn(async () => undefined), + saveClientInformation: vi.fn(async () => {}), + getScope: vi.fn(() => undefined), + getTokens: vi.fn(async () => undefined), + saveTokens: vi.fn(async () => {}), + clear: vi.fn(), + } as unknown as OAuthStorage; + }); + + it("pre-registers URL-based client id when AS supports CIMD", async () => { + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/.well-known/oauth-protected-resource")) { + return new Response(JSON.stringify({ resource: SERVER_URL })); + } + if (url.includes("/.well-known/oauth-authorization-server")) { + return new Response( + JSON.stringify({ + issuer: "http://127.0.0.1:9999", + authorization_endpoint: "http://127.0.0.1:9999/oauth/authorize", + token_endpoint: "http://127.0.0.1:9999/oauth/token", + response_types_supported: ["code"], + client_id_metadata_document_supported: true, + }), + ); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + const provider = createProvider(storage); + await ensureCimdClientRegistration({ + serverUrl: SERVER_URL, + provider, + fetchFn, + }); + + expect(storage.saveClientInformation).toHaveBeenCalledWith(SERVER_URL, { + client_id: METADATA_URL, + }); + }); + + it("no-ops when client information is already stored", async () => { + storage.getClientInformation = vi.fn(async () => ({ + client_id: "existing-client", + })); + + const provider = createProvider(storage); + await ensureCimdClientRegistration({ + serverUrl: SERVER_URL, + provider, + fetchFn: vi.fn(), + }); + + expect(storage.saveClientInformation).not.toHaveBeenCalled(); + }); +}); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts index 4588003e3..c65d9180a 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts @@ -425,8 +425,8 @@ describe("InspectorClient OAuth E2E", () => { clientConfig, ); - // CIMD uses guided mode (HTTP clientMetadataUrl); auth() requires HTTPS - const authUrl = await client.runGuidedAuth(); + // Quick auth with CIMD pre-registration (supports http:// test metadata URLs) + const authUrl = await client.authenticate(); if (!authUrl) throw new Error("Expected authorization URL"); expect(authUrl.href).toContain("/oauth/authorize"); @@ -504,7 +504,7 @@ describe("InspectorClient OAuth E2E", () => { clientConfig, ); - const authUrl = await client.runGuidedAuth(); + const authUrl = await client.authenticate(); if (!authUrl) throw new Error("Expected authorization URL"); const authCode = await completeOAuthAuthorization(authUrl); await client.completeOAuthFlow(authCode); diff --git a/core/auth/cimd.ts b/core/auth/cimd.ts new file mode 100644 index 000000000..39638de24 --- /dev/null +++ b/core/auth/cimd.ts @@ -0,0 +1,52 @@ +import { + discoverAuthorizationServerMetadata, + discoverOAuthProtectedResourceMetadata, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import type { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { getAuthorizationServerUrl } from "./discovery.js"; +import type { BaseOAuthClientProvider } from "./providers.js"; + +/** + * When the authorization server supports URL-based client IDs (SEP-991 / CIMD), + * pre-register `{ client_id: clientMetadataUrl }` before SDK `auth()`. + * + * SDK `auth()` rejects non-HTTPS `clientMetadataUrl` during registration, but + * accepts an already-stored `client_id` (including `http://` URLs used by local + * dev/test metadata servers). Production CIMD metadata documents should still + * use HTTPS per SEP-991. + */ +export async function ensureCimdClientRegistration(params: { + serverUrl: string; + provider: BaseOAuthClientProvider; + fetchFn?: typeof fetch; +}): Promise { + const clientMetadataUrl = params.provider.clientMetadataUrl?.trim(); + if (!clientMetadataUrl) return; + + const existing = await params.provider.clientInformation(); + if (existing?.client_id) return; + + let resourceMetadata; + try { + resourceMetadata = await discoverOAuthProtectedResourceMetadata( + params.serverUrl, + ); + } catch { + resourceMetadata = undefined; + } + + const authServerUrl = getAuthorizationServerUrl( + params.serverUrl, + resourceMetadata, + ); + + const metadata = await discoverAuthorizationServerMetadata(authServerUrl, { + ...(params.fetchFn && { fetchFn: params.fetchFn }), + }); + if (!metadata?.client_id_metadata_document_supported) return; + + const clientInformation: OAuthClientInformation = { + client_id: clientMetadataUrl, + }; + await params.provider.saveClientInformation(clientInformation); +} diff --git a/core/auth/index.ts b/core/auth/index.ts index 4caf088f0..556ccd9e6 100644 --- a/core/auth/index.ts +++ b/core/auth/index.ts @@ -21,6 +21,8 @@ export { } from "./connection-state.js"; export type { BuildOAuthConnectionStateParams } from "./connection-state.js"; +export { ensureCimdClientRegistration } from "./cimd.js"; + // Storage export type { OAuthStorage, IdpSessionState } from "./storage.js"; export { getServerSpecificKey, OAUTH_STORAGE_KEYS } from "./storage.js"; diff --git a/core/mcp/oauthManager.ts b/core/mcp/oauthManager.ts index 96da7bf10..5d80e2506 100644 --- a/core/mcp/oauthManager.ts +++ b/core/mcp/oauthManager.ts @@ -27,6 +27,7 @@ import { 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 { @@ -212,6 +213,12 @@ export class OAuthManager { provider.clearCapturedAuthUrl(); + await ensureCimdClientRegistration({ + serverUrl, + provider, + fetchFn: this.params.effectiveAuthFetch, + }); + const result = await auth(provider, { serverUrl, scope: provider.scope, From e9560279461410c5483966d464615f5188a8cd62 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 26 Jun 2026 21:51:56 -0700 Subject: [PATCH 2/8] Guided-auth removal, OAuth smoke testing, and TUI/CLI support --- .gitignore | 1 + clients/cli/README.md | 43 + clients/cli/package-lock.json | 220 ++++ clients/cli/package.json | 1 + clients/cli/src/cli.ts | 115 +- clients/cli/tsup.config.ts | 1 + clients/tui/README.md | 41 +- clients/tui/__tests__/oauthDisplay.test.ts | 33 + clients/tui/__tests__/tui-servers.test.ts | 30 +- clients/tui/package-lock.json | 220 ++++ clients/tui/package.json | 1 + clients/tui/src/App.tsx | 412 ++----- clients/tui/src/components/AuthTab.tsx | 485 +++----- clients/tui/src/utils/oauthDisplay.ts | 42 + clients/tui/tsup.config.ts | 1 + clients/tui/tui.tsx | 62 +- clients/web/server/start-vite-dev-server.ts | 11 +- clients/web/server/vite-base-config.ts | 75 +- clients/web/server/vite-hono-plugin.ts | 11 +- clients/web/src/App.tsx | 66 +- .../elements/CopyButton/CopyButton.tsx | 8 +- .../ClientSettingsForm/ClientSettingsForm.tsx | 50 +- .../clientSettingsValues.test.ts | 161 ++- .../clientSettingsValues.ts | 60 +- .../ClientSettingsModal.stories.tsx | 12 +- .../ClientSettingsModal.test.tsx | 27 +- .../ClientSettingsModal.tsx | 8 +- .../ConnectionInfoContent.test.tsx | 51 +- .../ConnectionInfoContent.tsx | 51 +- .../OAuthAccessTokenField.test.tsx | 65 +- .../OAuthAccessTokenField.tsx | 88 +- .../oauthDetailsFromConnectionState.test.ts | 3 +- .../oauthDetailsFromConnectionState.ts | 3 + .../ConnectionInfoModal.stories.tsx | 1 + .../ConnectionInfoModal.tsx | 3 + .../ServerSettingsForm.test.tsx | 17 + .../ServerSettingsForm/ServerSettingsForm.tsx | 28 + .../ServerSettingsModal.tsx | 3 + clients/web/src/test/core/auth/cimd.test.ts | 28 +- .../test/core/auth/connection-state.test.ts | 80 +- .../web/src/test/core/auth/providers.test.ts | 4 +- .../core/auth/runner-oauth-callback.test.ts | 63 ++ .../test/core/auth/storage-browser.test.ts | 50 +- .../src/test/core/auth/storage-remote.test.ts | 18 +- clients/web/src/test/core/auth/utils.test.ts | 248 ++-- .../web/src/test/core/client/config.test.ts | 76 ++ .../web/src/test/core/client/runner.test.ts | 83 ++ clients/web/src/test/core/mcp/config.test.ts | 21 + .../src/test/core/mcp/node/servers.test.ts | 82 +- .../src/test/core/mcp/oauthManager.test.ts | 28 +- .../auth/node/oauth-callback-server.test.ts | 4 +- .../integration/auth/node/storage.test.ts | 46 +- .../integration/auth/state-machine.test.ts | 441 -------- .../test/integration/mcp/ema-mock-servers.ts | 2 +- .../mcp/inspectorClient-oauth-e2e.test.ts | 1001 +++-------------- ...torClient-oauth-remote-storage-e2e.test.ts | 6 +- .../mcp/inspectorClient-oauth.test.ts | 8 +- .../integration/mcp/inspectorClient.test.ts | 6 +- .../mcp/remote/client-store-route.test.ts | 28 + .../server/vite-base-config.test.ts | 55 +- clients/web/src/theme/Code.ts | 3 + .../src/utils/clearServerOAuthState.test.ts | 51 + .../web/src/utils/clearServerOAuthState.ts | 32 + clients/web/src/utils/oauthFlow.ts | 25 +- clients/web/vite.config.ts | 12 +- core/auth/browser/providers.ts | 2 +- core/auth/cimd.ts | 4 +- core/auth/connection-state.ts | 60 +- core/auth/discovery.ts | 2 +- core/auth/ema/idpOidc.ts | 4 +- core/auth/index.ts | 11 +- core/auth/node/index.ts | 9 + core/auth/node/oauth-callback-server.ts | 5 +- core/auth/node/runner-oauth-callback.ts | 85 ++ core/auth/oauth-storage.ts | 18 +- core/auth/providers.ts | 33 +- core/auth/state-machine.ts | 292 ----- core/auth/storage.ts | 19 +- core/auth/store.ts | 4 +- core/auth/types.ts | 18 +- core/auth/utils.ts | 59 +- core/client/config-parse.ts | 52 +- core/client/index.ts | 10 + core/client/runner.ts | 116 ++ core/client/types.ts | 21 + core/mcp/config.ts | 13 + core/mcp/index.ts | 6 +- core/mcp/inspectorClient.ts | 115 +- core/mcp/inspectorClientEventTarget.ts | 6 - core/mcp/node/index.ts | 1 + core/mcp/node/server-secrets.ts | 34 + core/mcp/node/servers.ts | 15 +- core/mcp/oauthManager.ts | 267 +---- core/react/useClientSettingsDraft.ts | 35 +- package.json | 3 +- ...erprise_managed_auth.md => v2_auth_ema.md} | 93 +- specification/v2_auth_hardening.md | 185 +++ specification/v2_auth_mid_session.md | 502 +++++++++ specification/v2_auth_smoke_testing.md | 573 ++++++++++ specification/v2_scope.md | 9 +- specification/v2_servers_file.md | 2 +- test-servers/src/test-server-oauth.ts | 2 +- 102 files changed, 4549 insertions(+), 3147 deletions(-) create mode 100644 clients/tui/__tests__/oauthDisplay.test.ts create mode 100644 clients/tui/src/utils/oauthDisplay.ts create mode 100644 clients/web/src/test/core/auth/runner-oauth-callback.test.ts create mode 100644 clients/web/src/test/core/client/runner.test.ts delete mode 100644 clients/web/src/test/integration/auth/state-machine.test.ts create mode 100644 clients/web/src/utils/clearServerOAuthState.test.ts create mode 100644 clients/web/src/utils/clearServerOAuthState.ts create mode 100644 core/auth/node/runner-oauth-callback.ts delete mode 100644 core/auth/state-machine.ts create mode 100644 core/client/runner.ts create mode 100644 core/mcp/node/server-secrets.ts rename specification/{v2_enterprise_managed_auth.md => v2_auth_ema.md} (87%) create mode 100644 specification/v2_auth_hardening.md create mode 100644 specification/v2_auth_mid_session.md create mode 100644 specification/v2_auth_smoke_testing.md diff --git a/.gitignore b/.gitignore index 1e6869667..09f8427e4 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ test-servers/build /*.png /inspector-history-*.json /*.server.json +/configs/ diff --git a/clients/cli/README.md b/clients/cli/README.md index 8f7c41d8c..ce03c465d 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -100,6 +100,49 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en | `--metadata ` | General metadata (key=value); applied to all methods. | | `--tool-metadata ` | Tool-specific metadata for `tools/call`. | +### 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 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. + +**Shared with TUI** (config only, not interactive login): + +- Per-server OAuth fields from `mcp.json` (static client, EMA resource credentials, scopes) +- Install-level settings from **`~/.mcp-inspector/storage/client.json`** (or `--client-config` / `MCP_CLIENT_CONFIG_PATH`) — EMA IdP, CIMD +- CLI flags `--client-id`, `--client-secret`, `--client-metadata-url` override `client.json` when set +- Keychain-backed secrets in `mcp.json` are rehydrated on catalog load (same as TUI) + +#### 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) | + +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. + +#### Flags + +| Option | Env | Description | +| ------ | --- | ----------- | +| `--client-config ` | `MCP_CLIENT_CONFIG_PATH` | Install-level client config (default: `~/.mcp-inspector/storage/client.json`). | +| `--client-id ` | — | OAuth client ID (static client); overrides `client.json`. | +| `--client-secret ` | — | OAuth client secret; overrides `client.json`. | +| `--client-metadata-url ` | — | CIMD metadata URL; overrides `client.json`. | +| `--callback-url ` | `MCP_OAUTH_CALLBACK_URL` | Redirect URI sent to the authorization server (default: `http://127.0.0.1:6276/oauth/callback`). | + +**Example** — list tools on an OAuth-protected server using stored tokens and CIMD from the command line: + +```bash +npx @modelcontextprotocol/inspector --cli --catalog mcp.json --server my-http-server \ + --client-metadata-url https://example.com/.well-known/oauth/client-metadata.json \ + --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. + ## Why use the CLI? While the Web Client provides a rich visual interface, the CLI is designed for: diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index 4514ee2a3..940cfb559 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", + "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", "commander": "^13.1.0", @@ -907,6 +908,225 @@ } } }, + "node_modules/@napi-rs/keyring": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", + "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.3.0", + "@napi-rs/keyring-darwin-x64": "1.3.0", + "@napi-rs/keyring-freebsd-x64": "1.3.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", + "@napi-rs/keyring-linux-arm64-musl": "1.3.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-musl": "1.3.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", + "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", diff --git a/clients/cli/package.json b/clients/cli/package.json index 0175b69a7..d76b3b455 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -33,6 +33,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", + "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", "commander": "^13.1.0", diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 6539da8d2..c231b11ac 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -6,6 +6,7 @@ import { awaitableLog } from "./utils/awaitable-log.js"; import type { InspectorServerSettings, MCPServerConfig, + InspectorClientEnvironment, } from "@inspector/core/mcp/types.js"; import { InspectorClient } from "@inspector/core/mcp/index.js"; import { @@ -22,6 +23,24 @@ import { parseHeaderPair, } from "@inspector/core/mcp/node/index.js"; import type { JsonValue } from "@inspector/core/mcp/index.js"; +import { + ConsoleNavigation, + MutableRedirectUrlProvider, +} from "@inspector/core/auth/index.js"; +import { NodeOAuthStorage } from "@inspector/core/auth/node/index.js"; +import { + DEFAULT_RUNNER_OAUTH_CALLBACK_URL, + formatRunnerOAuthRedirectUrl, + parseRunnerOAuthCallbackUrl, + type RunnerOAuthCallbackConfig, +} from "@inspector/core/auth/node/runner-oauth-callback.js"; +import type { ClientConfig } from "@inspector/core/client/types.js"; +import { + buildRunnerClientAuthOptions, + isOAuthCapableServerConfig, + loadRunnerClientConfig, + type RunnerClientConfigOverrides, +} from "@inspector/core/client/runner.js"; import { LoggingLevelSchema, type LoggingLevel, @@ -47,6 +66,9 @@ async function callMethod( serverConfig: MCPServerConfig, serverSettings: InspectorServerSettings | undefined, args: MethodArgs & { method: string }, + clientConfig: ClientConfig, + cliAuthOverrides?: RunnerClientConfigOverrides, + callbackUrlConfig?: RunnerOAuthCallbackConfig, ): Promise { const __dirname = dirname(fileURLToPath(import.meta.url)); const packageJsonPath = join(__dirname, "../package.json"); @@ -62,16 +84,36 @@ async function callMethod( const version = packageJson.version; const clientIdentity = { name, version }; + const environment: InspectorClientEnvironment = { + transport: createTransportNode, + }; + const redirectUrlProvider = new MutableRedirectUrlProvider(); + if (isOAuthCapableServerConfig(serverConfig)) { + redirectUrlProvider.redirectUrl = formatRunnerOAuthRedirectUrl( + callbackUrlConfig!, + ); + environment.oauth = { + storage: new NodeOAuthStorage(), + navigation: new ConsoleNavigation(), + redirectUrlProvider, + }; + } + + const clientAuthOptions = buildRunnerClientAuthOptions( + clientConfig, + serverSettings, + cliAuthOverrides, + ); + const inspectorClient = new InspectorClient(serverConfig, { - environment: { - transport: createTransportNode, - }, + environment, clientIdentity, initialLoggingLevel: "debug", progress: false, sample: false, elicit: false, serverSettings, + ...clientAuthOptions, }); let managedToolsState: ManagedToolsState | null = null; @@ -235,11 +277,16 @@ function parseKeyValuePair( return { ...previous, [key as string]: parsedValue }; } -function parseArgs(argv?: string[]): { +async function parseArgs(argv?: string[]): Promise<{ serverConfig: MCPServerConfig; serverSettings: InspectorServerSettings | undefined; methodArgs: MethodArgs & { method: string }; -} { + clientConfigPath?: string; + clientId?: string; + clientSecret?: string; + clientMetadataUrl?: string; + callbackUrl?: string; +}> { const program = new Command(); // On a parse/usage ERROR (exitCode !== 0), throw the CommanderError instead // of letting commander call process.exit(). The binary entry (index.ts) still @@ -359,6 +406,26 @@ function parseArgs(argv?: string[]): { "Tool-specific metadata as key=value pairs (for tools/call method only)", parseKeyValuePair, {}, + ) + .option( + "--client-config ", + "Install-level client config (default: ~/.mcp-inspector/storage/client.json, or MCP_CLIENT_CONFIG_PATH)", + ) + .option( + "--client-id ", + "OAuth client ID (static client) for HTTP servers", + ) + .option( + "--client-secret ", + "OAuth client secret (for confidential clients)", + ) + .option( + "--client-metadata-url ", + "OAuth Client ID Metadata Document URL (CIMD) for HTTP servers", + ) + .option( + "--callback-url ", + `OAuth redirect/callback listener URL (default: ${DEFAULT_RUNNER_OAUTH_CALLBACK_URL}, or MCP_OAUTH_CALLBACK_URL)`, ); program.parse(preArgs); @@ -381,6 +448,11 @@ function parseArgs(argv?: string[]): { transport?: "sse" | "http" | "stdio"; serverUrl?: string; header?: Record; + clientConfig?: string; + clientId?: string; + clientSecret?: string; + clientMetadataUrl?: string; + callbackUrl?: string; }; const serverOptions = { @@ -401,7 +473,7 @@ function parseArgs(argv?: string[]): { // Shared with the TUI: resolves the catalog/config source (or ad-hoc target), // enforces the conflict matrix, and lifts disk headers/timeouts/OAuth into // per-server settings. `--server` selects one when the file has several. - const entries = loadServerEntries(serverOptions); + const entries = await loadServerEntries(serverOptions); const { config: serverConfig, settings: serverSettings } = selectServerEntry( entries, options.server, @@ -443,12 +515,37 @@ function parseArgs(argv?: string[]): { serverConfig, serverSettings, methodArgs, + clientConfigPath: options.clientConfig, + clientId: options.clientId, + clientSecret: options.clientSecret, + clientMetadataUrl: options.clientMetadataUrl, + callbackUrl: options.callbackUrl, }; } export async function runCli(argv?: string[]): Promise { - const { serverConfig, serverSettings, methodArgs } = parseArgs( - argv ?? process.argv, + const { + serverConfig, + serverSettings, + methodArgs, + clientConfigPath, + clientId, + clientSecret, + clientMetadataUrl, + callbackUrl, + } = await parseArgs(argv ?? process.argv); + const clientConfig = await loadRunnerClientConfig({ clientConfigPath }); + const callbackUrlConfig = parseRunnerOAuthCallbackUrl(callbackUrl); + await callMethod( + serverConfig, + serverSettings, + methodArgs, + clientConfig, + { + clientId, + clientSecret, + clientMetadataUrl, + }, + callbackUrlConfig, ); - await callMethod(serverConfig, serverSettings, methodArgs); } diff --git a/clients/cli/tsup.config.ts b/clients/cli/tsup.config.ts index 1361090ed..0091499c5 100644 --- a/clients/cli/tsup.config.ts +++ b/clients/cli/tsup.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ platform: 'node', // Bundle core source; leave npm deps external. noExternal: [/^@inspector\/core/], + external: ['@napi-rs/keyring', '@modelcontextprotocol/sdk', 'commander', 'pino', 'zustand'], esbuildOptions(options) { options.alias = { '@inspector/core': path.join(repoRoot, 'core'), diff --git a/clients/tui/README.md b/clients/tui/README.md index 4ca7af491..d5a335493 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -33,14 +33,39 @@ Options that specify the MCP server(s) (catalog/config file, ad-hoc command/URL, ### TUI-specific (OAuth for HTTP servers) -When connecting to SSE or Streamable HTTP servers that use OAuth, you can pass: - -| Option | Description | -| ----------------------------- | ------------------------------------------------------------------------------------ | -| `--client-id ` | OAuth client ID (static client). | -| `--client-secret ` | OAuth client secret (confidential clients). | -| `--client-metadata-url ` | OAuth Client ID Metadata Document URL (CIMD). | -| `--callback-url ` | OAuth redirect/callback listener URL (default: `http://127.0.0.1:0/oauth/callback`). | +The TUI supports OAuth for **SSE** and **Streamable HTTP** servers. Per-server OAuth fields in `mcp.json` (static client id/secret, scopes, enterprise-managed flag) are applied automatically when loaded from `--catalog` or `--config`. Install-wide settings (CIMD, enterprise IdP) come from **`~/.mcp-inspector/storage/client.json`** — the same file the web **Client Settings** dialog writes. You can point at a different file with `--client-config` or `MCP_CLIENT_CONFIG_PATH`. + +#### OAuth callback URL + +The TUI starts a small loopback HTTP server to receive the authorization redirect after you sign in in the browser. Defaults: + +| Surface | Default callback | +| ------- | ---------------- | +| **Web** | `http://localhost:6274/oauth/callback` (main app server) | +| **TUI** | `http://127.0.0.1:6276/oauth/callback` (dedicated runner port; avoids colliding with web on 6274) | + +OAuth redirect URIs must match **exactly** what you register on the authorization server — `localhost` and `127.0.0.1` are different URIs. Register the TUI default on your OAuth app / IdP when using pre-registered (static) or enterprise-managed clients. + +Override the TUI listener with `--callback-url` or `MCP_OAUTH_CALLBACK_URL`. Use `http://127.0.0.1:0/oauth/callback` for an OS-assigned ephemeral port when the authorization server registers redirect URIs dynamically (DCR). + +#### Flags + +| Option | Env | Description | +| ------ | --- | ----------- | +| `--client-config ` | `MCP_CLIENT_CONFIG_PATH` | Install-level client config (default: `~/.mcp-inspector/storage/client.json`). | +| `--client-id ` | — | OAuth client ID (static client); overrides `client.json`. | +| `--client-secret ` | — | OAuth client secret (confidential clients); overrides `client.json`. | +| `--client-metadata-url ` | — | Client ID Metadata Document URL (CIMD); overrides `client.json`. | +| `--callback-url ` | `MCP_OAUTH_CALLBACK_URL` | OAuth redirect/callback listener (default: `http://127.0.0.1:6276/oauth/callback`). | + +#### Authenticating in the TUI + +1. Select an HTTP/SSE server and press **C** to connect. +2. If authorization is required, the TUI starts OAuth automatically (browser opens for sign-in). +3. After the callback completes, connect finishes without a second **C**. +4. Use the **Auth** tab to inspect OAuth state (same fields as web Connection Info) or **Clear OAuth state** (disconnects when connected). + +See also [EMA / enterprise-managed auth](../../specification/v2_auth_ema.md) and [OAuth smoke testing](../../specification/v2_auth_smoke_testing.md) for staging servers and verification steps. ## Features diff --git a/clients/tui/__tests__/oauthDisplay.test.ts b/clients/tui/__tests__/oauthDisplay.test.ts new file mode 100644 index 000000000..21ead0a60 --- /dev/null +++ b/clients/tui/__tests__/oauthDisplay.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from "vitest"; +import { + formatAuthProtocol, + formatClientRegistrationKind, + formatIdpSession, + formatScopes, +} from "../src/utils/oauthDisplay.js"; +import type { OAuthConnectionState } from "@inspector/core/auth/types.js"; + +describe("oauthDisplay", () => { + it("formatAuthProtocol distinguishes standard and EMA", () => { + expect(formatAuthProtocol("standard")).toBe("Standard"); + expect(formatAuthProtocol("ema")).toBe("Enterprise-managed"); + }); + + it("formatClientRegistrationKind covers registration kinds", () => { + expect(formatClientRegistrationKind("cimd")).toBe( + "Client ID Metadata (CIMD)", + ); + }); + + it("formatIdpSession maps session states", () => { + expect(formatIdpSession("logged_in")).toBe("Signed in"); + expect(formatIdpSession("none")).toBe("Not signed in"); + }); + + it("formatScopes joins granted scope", () => { + const state = { + grantedScope: "openid profile", + } as OAuthConnectionState; + expect(formatScopes(state)).toBe("openid, profile"); + }); +}); diff --git a/clients/tui/__tests__/tui-servers.test.ts b/clients/tui/__tests__/tui-servers.test.ts index 722e2eb98..00ace5fcc 100644 --- a/clients/tui/__tests__/tui-servers.test.ts +++ b/clients/tui/__tests__/tui-servers.test.ts @@ -21,7 +21,7 @@ describe("loadTuiServers", () => { rmSync(tempDir, { recursive: true, force: true }); }); - it("loads named servers from a read-only --config file", () => { + it("loads named servers from a read-only --config file", async () => { const configPath = join(tempDir, "mcp.json"); writeFileSync( configPath, @@ -29,7 +29,7 @@ describe("loadTuiServers", () => { mcpServers: { foo: { command: "node", args: ["foo.js"] } }, }), ); - const servers = loadTuiServers({ configPath }); + const servers = await loadTuiServers({ configPath }); expect(Object.keys(servers)).toEqual(["foo"]); expect(servers.foo?.config).toMatchObject({ type: "stdio", @@ -37,9 +37,9 @@ describe("loadTuiServers", () => { }); }); - it("seeds an empty writable catalog when --catalog is missing", () => { + it("seeds an empty writable catalog when --catalog is missing", async () => { const catalogPath = join(tempDir, "catalog.json"); - const servers = loadTuiServers({ catalogPath }); + const servers = await loadTuiServers({ catalogPath }); expect(servers).toEqual({}); expect(existsSync(catalogPath)).toBe(true); expect(JSON.parse(readFileSync(catalogPath, "utf-8"))).toEqual({ @@ -47,34 +47,34 @@ describe("loadTuiServers", () => { }); }); - it("throws when a read-only --config file is missing (never seeds)", () => { + it("throws when a read-only --config file is missing (never seeds)", async () => { const configPath = join(tempDir, "absent.json"); - expect(() => loadTuiServers({ configPath })).toThrow( + await expect(loadTuiServers({ configPath })).rejects.toThrow( /Config file not found/, ); expect(existsSync(configPath)).toBe(false); }); - it("rejects --catalog and --config together", () => { + it("rejects --catalog and --config together", async () => { const catalogPath = join(tempDir, "catalog.json"); const configPath = join(tempDir, "config.json"); writeFileSync(catalogPath, JSON.stringify({ mcpServers: {} })); writeFileSync(configPath, JSON.stringify({ mcpServers: {} })); - expect(() => loadTuiServers({ catalogPath, configPath })).toThrow( + await expect(loadTuiServers({ catalogPath, configPath })).rejects.toThrow( /mutually exclusive/, ); }); - it("rejects --catalog combined with an ad-hoc target", () => { + it("rejects --catalog combined with an ad-hoc target", async () => { const catalogPath = join(tempDir, "catalog.json"); writeFileSync(catalogPath, JSON.stringify({ mcpServers: {} })); - expect(() => + await expect( loadTuiServers({ catalogPath, target: ["my-server"] }), - ).toThrow(/--catalog cannot be combined/); + ).rejects.toThrow(/--catalog cannot be combined/); }); - it("builds a single ad-hoc server from a positional target", () => { - const servers = loadTuiServers({ target: ["my-server", "--flag"] }); + it("builds a single ad-hoc server from a positional target", async () => { + const servers = await loadTuiServers({ target: ["my-server", "--flag"] }); expect(Object.keys(servers)).toEqual(["default"]); expect(servers.default?.config).toMatchObject({ type: "stdio", @@ -83,7 +83,7 @@ describe("loadTuiServers", () => { }); }); - it("merges --header into per-server settings for catalog servers", () => { + it("merges --header into per-server settings for catalog servers", async () => { const catalogPath = join(tempDir, "catalog.json"); writeFileSync( catalogPath, @@ -91,7 +91,7 @@ describe("loadTuiServers", () => { mcpServers: { web: { type: "streamable-http", url: "http://x/mcp" } }, }), ); - const servers = loadTuiServers({ + const servers = await loadTuiServers({ catalogPath, headers: { Authorization: "Bearer t" }, }); diff --git a/clients/tui/package-lock.json b/clients/tui/package-lock.json index 9329febf9..94e13dd8c 100644 --- a/clients/tui/package-lock.json +++ b/clients/tui/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", + "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", "commander": "^13.1.0", @@ -1158,6 +1159,225 @@ } } }, + "node_modules/@napi-rs/keyring": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", + "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.3.0", + "@napi-rs/keyring-darwin-x64": "1.3.0", + "@napi-rs/keyring-freebsd-x64": "1.3.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", + "@napi-rs/keyring-linux-arm64-musl": "1.3.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-musl": "1.3.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", + "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", diff --git a/clients/tui/package.json b/clients/tui/package.json index f1c5ff057..424f12e22 100644 --- a/clients/tui/package.json +++ b/clients/tui/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", + "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", "commander": "^13.1.0", diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 0464533d7..763eaa01f 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -12,7 +12,6 @@ import { dirname, join } from "path"; import type { MessageEntry, FetchRequestEntry, - MCPServerConfig, InspectorClientOptions, InspectorClientEnvironment, } from "@inspector/core/mcp/index.js"; @@ -45,7 +44,15 @@ import { useStderrLog } from "@inspector/core/react/useStderrLog.js"; import { CallbackNavigation, MutableRedirectUrlProvider, + isUnauthorizedError, } from "@inspector/core/auth/index.js"; +import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js"; +import type { ClientConfig } from "@inspector/core/client/types.js"; +import { + buildRunnerClientAuthOptions, + isOAuthCapableServerConfig, +} from "@inspector/core/client/runner.js"; +import { formatRunnerOAuthRedirectUrl } from "@inspector/core/auth/node/runner-oauth-callback.js"; import { createOAuthCallbackServer, type OAuthCallbackServer, @@ -114,6 +121,7 @@ type FocusArea = interface AppProps { mcpServers: Record; + clientConfig: ClientConfig; clientId?: string; clientSecret?: string; clientMetadataUrl?: string; @@ -124,15 +132,9 @@ interface AppProps { }; } -/** HTTP transports (SSE, streamable-http) can use OAuth. No config gate. */ -function isOAuthCapableServer(config: MCPServerConfig | null): boolean { - if (!config) return false; - const c = config as MCPServerConfig & { oauth?: unknown }; - return c.type === "sse" || c.type === "streamable-http"; -} - function App({ mcpServers, + clientConfig, clientId, clientSecret, clientMetadataUrl, @@ -168,13 +170,13 @@ function App({ logging?: number; }>({}); const [oauthStatus, setOauthStatus] = useState< - "idle" | "authenticating" | "success" | "error" + "idle" | "authenticating" | "error" >("idle"); const [oauthMessage, setOauthMessage] = useState(null); + const [oauthRevision, setOauthRevision] = useState(0); + const [connectError, setConnectError] = useState(null); const oauthInProgressRef = useRef(false); - const [selectedAuthAction, setSelectedAuthAction] = useState< - "guided" | "quick" | "clear" - >("guided"); + const callbackServerRef = useRef(null); // Tool test modal state const [toolTestModal, setToolTestModal] = useState<{ @@ -287,23 +289,11 @@ function App({ .map((m) => [m.key, m.value]), ) : undefined; - const oauthFromSettings = - savedSettings && - (savedSettings.oauthClientId || - savedSettings.oauthClientSecret || - savedSettings.oauthScopes) - ? { - ...(savedSettings.oauthClientId && { - clientId: savedSettings.oauthClientId, - }), - ...(savedSettings.oauthClientSecret && { - clientSecret: savedSettings.oauthClientSecret, - }), - ...(savedSettings.oauthScopes && { - scope: savedSettings.oauthScopes, - }), - } - : undefined; + const clientAuthOptions = buildRunnerClientAuthOptions( + clientConfig, + savedSettings, + { clientId, clientSecret, clientMetadataUrl }, + ); const opts: InspectorClientOptions = { environment, pipeStderr: true, @@ -316,12 +306,15 @@ function App({ defaultMetadata, }), ...(savedSettings && { serverSettings: savedSettings }), + ...clientAuthOptions, }; - if (isOAuthCapableServer(serverConfig)) { + if (isOAuthCapableServerConfig(serverConfig)) { const redirectUrlProvider = redirectUrlProvidersRef.current[serverName] ?? (redirectUrlProvidersRef.current[serverName] = new MutableRedirectUrlProvider()); + redirectUrlProvider.redirectUrl = + formatRunnerOAuthRedirectUrl(callbackUrlConfig); environment.oauth = { storage: new NodeOAuthStorage(), navigation: new CallbackNavigation( @@ -329,12 +322,6 @@ function App({ ), redirectUrlProvider, }; - opts.oauth = { - ...(oauthFromSettings ?? {}), - ...(clientId && { clientId }), - ...(clientSecret && { clientSecret }), - ...(clientMetadataUrl && { clientMetadataUrl }), - }; } const client = new InspectorClient(serverConfig, opts); newClients[serverName] = client; @@ -373,11 +360,19 @@ function App({ setStderrLogStates((prev) => ({ ...prev, ...newStderrLogStates })); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [clientId, clientSecret, clientMetadataUrl]); + }, [ + clientConfig, + clientId, + clientSecret, + clientMetadataUrl, + callbackUrlConfig, + ]); // Cleanup: destroy managers and disconnect all clients on unmount useEffect(() => { return () => { + void callbackServerRef.current?.stop(); + callbackServerRef.current = null; Object.values(managedToolsStates).forEach((manager) => { manager.destroy(); }); @@ -435,7 +430,7 @@ function App({ if ( activeTab === "auth" && selectedServerConfig && - !isOAuthCapableServer(selectedServerConfig) + !isOAuthCapableServerConfig(selectedServerConfig) ) { setActiveTab("info"); } @@ -455,6 +450,7 @@ function App({ instructions: inspectorInstructions, connect: connectInspector, disconnect: disconnectInspector, + lastError: inspectorLastError, } = useInspectorClient(selectedInspectorClient); // Log state from managers (per-server) @@ -538,46 +534,23 @@ function App({ selectedManagedPromptsState, ); - // Connect handler - InspectorClient now handles fetching server data automatically - const handleConnect = useCallback(async () => { - if (!selectedServer || !selectedInspectorClient) return; - - try { - await connectInspector(); - // InspectorClient automatically fetches server data (capabilities, tools, resources, resource templates, prompts, etc.) - // on connect, so we don't need to do anything here - } catch { - // Error handling is done by InspectorClient and will be reflected in status - } - }, [selectedServer, selectedInspectorClient, connectInspector]); - - // Disconnect handler - const handleDisconnect = useCallback(async () => { - if (!selectedServer) return; - await disconnectInspector(); - // InspectorClient will update status automatically, and data is preserved - }, [selectedServer, disconnectInspector]); - // Shared ref for OAuth callback server; stop before starting new (avoids EADDRINUSE when prior auth failed without redirect) - const callbackServerRef = useRef(null); - // OAuth Quick Auth (quick execution; callback server + open URL) - const handleQuickAuth = useCallback(async () => { + // Connect — on 401, run OAuth then retry (same pattern as web App.tsx). + const runOAuthAuthentication = useCallback(async () => { if ( !selectedServer || !selectedInspectorClient || !selectedServerConfig || - !isOAuthCapableServer(selectedServerConfig) + !isOAuthCapableServerConfig(selectedServerConfig) ) { return; } if (oauthInProgressRef.current) return; oauthInProgressRef.current = true; - setOauthStatus("authenticating"); - setOauthMessage(null); getTuiLogger().info( { server: selectedServer }, - "OAuth authentication started (Quick Auth)", + "OAuth authentication started", ); const existing = callbackServerRef.current; if (existing) { @@ -624,12 +597,7 @@ function App({ if (authUrl !== undefined) { await flowDone; } - setOauthStatus("success"); - setOauthMessage("OAuth complete. Press C to connect."); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - setOauthStatus("error"); - setOauthMessage(msg); + setOauthRevision((n) => n + 1); } finally { oauthInProgressRef.current = false; } @@ -640,186 +608,92 @@ function App({ callbackServerBaseOptions, ]); - // OAuth Guided Auth - step-by-step - const handleGuidedStart = useCallback(async () => { - if ( - !selectedServer || - !selectedInspectorClient || - !selectedServerConfig || - !isOAuthCapableServer(selectedServerConfig) - ) { + const handleConnect = useCallback(async () => { + if (!selectedServer || !selectedInspectorClient || !selectedServerConfig) { return; } - if (oauthInProgressRef.current) return; - oauthInProgressRef.current = true; - setOauthStatus("authenticating"); - setOauthMessage(null); - getTuiLogger().info( - { server: selectedServer }, - "OAuth authentication started (Guided Auth)", - ); - // Stop any previous callback server (e.g. from failed auth where AS never redirected) - const existing = callbackServerRef.current; - if (existing) { - await existing.stop(); - callbackServerRef.current = null; - } - const callbackServer = createOAuthCallbackServer(); - callbackServerRef.current = callbackServer; - try { - const { redirectUrl } = await callbackServer.start({ - ...callbackServerBaseOptions, - onCallback: async (params) => { - try { - await selectedInspectorClient!.completeOAuthFlow(params.code); - setOauthStatus("success"); - setOauthMessage("OAuth complete. Press C to connect."); - } catch (err) { - setOauthStatus("error"); - setOauthMessage(err instanceof Error ? err.message : String(err)); - } finally { - callbackServerRef.current = null; - } - }, - onError: (params) => { - setOauthStatus("error"); - setOauthMessage( - params.error_description ?? params.error ?? "OAuth error", - ); - void callbackServer.stop(); - callbackServerRef.current = null; - }, - }); - const redirectUrlProvider = - redirectUrlProvidersRef.current[selectedServer]; - if (redirectUrlProvider) { - redirectUrlProvider.redirectUrl = redirectUrl; - } - await selectedInspectorClient.beginGuidedAuth(); + + const finishConnect = async () => { + await connectInspector(); + setConnectError(null); setOauthStatus("idle"); - } catch (err) { - setOauthStatus("error"); - setOauthMessage(err instanceof Error ? err.message : String(err)); - } finally { - oauthInProgressRef.current = false; - } - }, [ - selectedServer, - selectedInspectorClient, - selectedServerConfig, - callbackServerBaseOptions, - ]); + setOauthMessage(null); + }; - const handleGuidedAdvance = useCallback(async () => { - if (!selectedInspectorClient) return; - if (oauthInProgressRef.current) return; - oauthInProgressRef.current = true; - setOauthStatus("authenticating"); - setOauthMessage(null); - getTuiLogger().info( - "OAuth authentication started (Guided Auth advance step)", - ); try { - await selectedInspectorClient.proceedOAuthStep(); - const state = selectedInspectorClient.getOAuthFlowState(); - if (state?.oauthStep === "authorization_code" && state.authorizationUrl) { - await openUrl(state.authorizationUrl); - } - setOauthStatus("idle"); + await finishConnect(); } catch (err) { - setOauthStatus("error"); - setOauthMessage(err instanceof Error ? err.message : String(err)); - } finally { - oauthInProgressRef.current = false; - } - }, [selectedInspectorClient]); + const msg = err instanceof Error ? err.message : String(err); + setConnectError(msg); - const handleRunGuidedToCompletion = useCallback(async () => { - if ( - !selectedServer || - !selectedInspectorClient || - !selectedServerConfig || - !isOAuthCapableServer(selectedServerConfig) - ) { - return; - } - if (oauthInProgressRef.current) return; - oauthInProgressRef.current = true; - setOauthStatus("authenticating"); - setOauthMessage(null); - getTuiLogger().info( - { server: selectedServer }, - "OAuth authentication started (Run Guided Auth to completion)", - ); + if (isEmaClientNotConfiguredError(err)) { + setOauthStatus("error"); + setOauthMessage(err.message); + return; + } - const ensureCallbackServer = async () => { - if (callbackServerRef.current) return; - const callbackServer = createOAuthCallbackServer(); - callbackServerRef.current = callbackServer; - const { redirectUrl } = await callbackServer.start({ - ...callbackServerBaseOptions, - onCallback: async (params) => { - try { - await selectedInspectorClient!.completeOAuthFlow(params.code); - setOauthStatus("success"); - setOauthMessage("OAuth complete. Press C to connect."); - } catch (err) { + if ( + isOAuthCapableServerConfig(selectedServerConfig) && + isUnauthorizedError(err) + ) { + try { + setOauthStatus("authenticating"); + setOauthMessage(null); + // Tear down the failed handshake transport so the post-OAuth connect + // creates a fresh transport with tokens from storage (same as web, + // which reloads the client after redirect). + await disconnectInspector(); + await runOAuthAuthentication(); + await finishConnect(); + } catch (authErr) { + const authMsg = + authErr instanceof Error ? authErr.message : String(authErr); + setConnectError(authMsg); + if (isEmaClientNotConfiguredError(authErr)) { setOauthStatus("error"); - setOauthMessage(err instanceof Error ? err.message : String(err)); - } finally { - callbackServerRef.current = null; + setOauthMessage(authErr.message); + return; } - }, - onError: (params) => { setOauthStatus("error"); - setOauthMessage( - params.error_description ?? params.error ?? "OAuth error", - ); - void callbackServer.stop(); - callbackServerRef.current = null; - }, - }); - const redirectUrlProvider = - redirectUrlProvidersRef.current[selectedServer]; - if (redirectUrlProvider) { - redirectUrlProvider.redirectUrl = redirectUrl; - } - }; - - try { - await ensureCallbackServer(); - const authUrl = await selectedInspectorClient.runGuidedAuth(); - if (authUrl) { - await openUrl(authUrl); + setOauthMessage(authMsg); + } + return; } - setOauthStatus("idle"); - } catch (err) { - setOauthStatus("error"); - setOauthMessage(err instanceof Error ? err.message : String(err)); - } finally { - oauthInProgressRef.current = false; } }, [ selectedServer, selectedInspectorClient, selectedServerConfig, - callbackServerBaseOptions, + connectInspector, + disconnectInspector, + runOAuthAuthentication, ]); - const handleClearOAuth = useCallback(() => { - if (selectedInspectorClient) { - selectedInspectorClient.clearOAuthTokens(); - setOauthStatus("idle"); - setOauthMessage(null); + // Disconnect handler + const handleDisconnect = useCallback(async () => { + if (!selectedServer) return; + await disconnectInspector(); + // InspectorClient will update status automatically, and data is preserved + }, [selectedServer, disconnectInspector]); + + const handleClearOAuth = useCallback(async () => { + if (!selectedInspectorClient) return; + selectedInspectorClient.clearOAuthTokens(); + setOauthStatus("idle"); + setOauthMessage(null); + setConnectError(null); + if (inspectorStatus === "connected" || inspectorStatus === "connecting") { + await disconnectInspector(); } - }, [selectedInspectorClient]); + setOauthRevision((n) => n + 1); + }, [selectedInspectorClient, inspectorStatus, disconnectInspector]); // Build current server state from InspectorClient data (tools from ManagedToolsState) const currentServerState = useMemo(() => { if (!selectedServer) return null; return { status: inspectorStatus, - error: null, // InspectorClient doesn't track error in state, only emits error events + error: connectError ?? inspectorLastError ?? null, capabilities: inspectorCapabilities, serverInfo: inspectorServerInfo, instructions: inspectorInstructions, @@ -832,6 +706,8 @@ function App({ }, [ selectedServer, inspectorStatus, + connectError, + inspectorLastError, inspectorCapabilities, inspectorServerInfo, inspectorInstructions, @@ -842,22 +718,6 @@ function App({ inspectorStderrLogs, ]); - // 401 on connect → prompt to authenticate (HTTP servers). Hide during/after auth. - const show401AuthHint = useMemo(() => { - if (inspectorStatus !== "error") return false; - if (oauthStatus === "authenticating" || oauthStatus === "success") - return false; - if (!selectedServerConfig || !isOAuthCapableServer(selectedServerConfig)) - return false; - return inspectorFetchRequests.some((r) => r.responseStatus === 401); - }, [ - inspectorStatus, - oauthStatus, - selectedServerConfig, - inspectorFetchRequests, - ]); - - // Helper functions to render details modal content const renderResourceDetails = ( resource: | Resource @@ -1220,29 +1080,11 @@ function App({ exit(); } - // G/Q/S: switch to Auth tab (if not already) and select Guided/Quick/Clear - const showAuthTabForAccel = - !!selectedServer && - !!selectedServerConfig && - isOAuthCapableServer(selectedServerConfig); - const lower = input.toLowerCase(); - if ( - showAuthTabForAccel && - (lower === "g" || lower === "q" || lower === "s") - ) { - setActiveTab("auth"); - setFocus("tabContentList"); - setSelectedAuthAction( - lower === "g" ? "guided" : lower === "q" ? "quick" : "clear", - ); - return; - } - // Tab switching with accelerator keys (first character of tab name) const showAuthTab = !!selectedServer && !!selectedServerConfig && - isOAuthCapableServer(selectedServerConfig); + isOAuthCapableServerConfig(selectedServerConfig); const showLoggingTab = !!selectedServer && inspectorClients[selectedServer]?.getServerType() === "stdio"; @@ -1265,8 +1107,16 @@ function App({ ]), ); if (tabAccelerators[input.toLowerCase()]) { - setActiveTab(tabAccelerators[input.toLowerCase()]); - setFocus("tabs"); + const nextTab = tabAccelerators[input.toLowerCase()]!; + setActiveTab(nextTab); + setFocus(nextTab === "auth" ? "tabContentList" : "tabs"); + } else if ( + activeTab === "auth" && + showAuthTab && + input.toLowerCase() === "s" + ) { + void handleClearOAuth(); + setFocus("tabContentList"); } else if (key.tab && !key.shift) { // Flat focus order: servers -> tabs -> list -> details -> wrap to servers const focusOrder: FocusArea[] = @@ -1322,7 +1172,7 @@ function App({ const showAuthTab = !!selectedServer && !!selectedServerConfig && - isOAuthCapableServer(selectedServerConfig); + isOAuthCapableServerConfig(selectedServerConfig); const showLoggingTab = !!selectedServer && inspectorClients[selectedServer]?.getServerType() === "stdio"; @@ -1358,7 +1208,6 @@ function App({ } // Accelerator keys for connect/disconnect (work from anywhere) - // 'a' switches to Auth tab; use the Auth tab for Quick/Guided auth if (selectedServer) { if ( input.toLowerCase() === "c" && @@ -1543,24 +1392,14 @@ function App({ )} - {show401AuthHint && ( + {oauthStatus === "authenticating" && ( - - 401 Unauthorized. Press A to authenticate. - + OAuth: authenticating… )} - {oauthStatus !== "idle" && ( + {oauthStatus === "error" && oauthMessage && ( - {oauthStatus === "authenticating" && ( - OAuth: authenticating… - )} - {oauthStatus === "success" && oauthMessage && ( - {oauthMessage} - )} - {oauthStatus === "error" && oauthMessage && ( - OAuth: {oauthMessage} - )} + OAuth: {oauthMessage} )} @@ -1577,7 +1416,7 @@ function App({ !!( selectedServer && selectedServerConfig && - isOAuthCapableServer(selectedServerConfig) + isOAuthCapableServerConfig(selectedServerConfig) ) } showLogging={ @@ -1623,26 +1462,23 @@ function App({ {activeTab === "auth" && selectedServer && selectedServerConfig && - isOAuthCapableServer(selectedServerConfig) ? ( + isOAuthCapableServerConfig(selectedServerConfig) ? ( { + void handleClearOAuth(); + }} + connectionStatus={inspectorStatus} /> ) : null} {activeTab === "resources" && diff --git a/clients/tui/src/components/AuthTab.tsx b/clients/tui/src/components/AuthTab.tsx index 4b8d868b0..a3247194a 100644 --- a/clients/tui/src/components/AuthTab.tsx +++ b/clients/tui/src/components/AuthTab.tsx @@ -1,53 +1,41 @@ -import React, { useState, useEffect, useCallback, useRef } from "react"; +import React, { useState, useEffect, useRef, useCallback } from "react"; import { Box, Text, useInput, type Key } from "ink"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; import { SelectableItem } from "./SelectableItem.js"; import type { MCPServerConfig, InspectorClient, + ConnectionStatus, } from "@inspector/core/mcp/index.js"; -import type { OAuthFlowState, OAuthStep } from "@inspector/core/auth/index.js"; - -const STEP_LABELS: Record = { - metadata_discovery: "Metadata Discovery", - client_registration: "Client Registration", - authorization_redirect: "Preparing Authorization", - authorization_code: "Request Authorization Code", - token_request: "Token Request", - complete: "Authentication Complete", -}; - -const STEP_ORDER: OAuthStep[] = [ - "metadata_discovery", - "client_registration", - "authorization_redirect", - "authorization_code", - "token_request", - "complete", -]; - -function stepIndex(step: OAuthStep): number { - const i = STEP_ORDER.indexOf(step); - return i >= 0 ? i : 0; -} +import type { OAuthConnectionState } from "@inspector/core/auth/types.js"; +import { + formatAuthProtocol, + formatClientRegistrationKind, + formatIdpSession, + formatScopes, +} from "../utils/oauthDisplay.js"; interface AuthTabProps { serverName: string | null; serverConfig: MCPServerConfig | null; inspectorClient: InspectorClient | null; - oauthStatus: "idle" | "authenticating" | "success" | "error"; + oauthStatus: "idle" | "authenticating" | "error"; oauthMessage: string | null; + oauthRevision: number; width: number; height: number; focused?: boolean; - selectedAction: "guided" | "quick" | "clear"; - onSelectedActionChange: (action: "guided" | "quick" | "clear") => void; - onQuickAuth: () => Promise; - onGuidedStart: () => Promise; - onGuidedAdvance: () => Promise; - onRunGuidedToCompletion: () => Promise; onClearOAuth: () => void; - isOAuthCapable: boolean; + connectionStatus: ConnectionStatus; +} + +function OAuthDetailRow({ label, value }: { label: string; value: string }) { + return ( + + {label}: + {value} + + ); } export function AuthTab({ @@ -55,115 +43,57 @@ export function AuthTab({ inspectorClient, oauthStatus, oauthMessage, + oauthRevision, width, height, focused = false, - selectedAction, - onSelectedActionChange, - onQuickAuth, - onGuidedStart, - onGuidedAdvance, - onRunGuidedToCompletion, onClearOAuth, - isOAuthCapable, + connectionStatus, }: AuthTabProps) { + const isLiveConnection = + connectionStatus === "connected" || connectionStatus === "connecting"; const scrollViewRef = useRef(null); - const [oauthFlowState, setOauthFlowState] = useState< - OAuthFlowState | undefined + const [oauthState, setOauthState] = useState< + OAuthConnectionState | undefined >(undefined); - const [guidedStarted, setGuidedStarted] = useState(false); const [clearedConfirmation, setClearedConfirmation] = useState(false); + const [lastClearDisconnected, setLastClearDisconnected] = useState(false); - // Sync oauthFlowState from InspectorClient - useEffect(() => { + const refreshOAuthState = useCallback(async () => { if (!inspectorClient) { - setOauthFlowState(undefined); - setGuidedStarted(false); + setOauthState(undefined); return; } - - const update = () => setOauthFlowState(inspectorClient.getOAuthFlowState()); - update(); - - const onStepChange = () => update(); - inspectorClient.addEventListener("oauthStepChange", onStepChange); - inspectorClient.addEventListener("oauthComplete", onStepChange); - return () => { - inspectorClient.removeEventListener("oauthStepChange", onStepChange); - inspectorClient.removeEventListener("oauthComplete", onStepChange); - }; + const state = await inspectorClient.getOAuthState(); + setOauthState(state); }, [inspectorClient]); - // Reset guided state when switching servers useEffect(() => { - setGuidedStarted(false); - }, [serverName]); + void refreshOAuthState(); + }, [refreshOAuthState, oauthRevision]); - // Clear confirmation when switching away from Clear menu item useEffect(() => { - if (selectedAction !== "clear") { - setClearedConfirmation(false); - } - }, [selectedAction]); + setClearedConfirmation(false); + setLastClearDisconnected(false); + }, [oauthRevision]); - const guidedFlowStarted = !!oauthFlowState?.oauthStep; - const currentStep = oauthFlowState?.oauthStep ?? "metadata_discovery"; - const needsAuthCode = - currentStep === "authorization_code" && oauthFlowState?.authorizationUrl; - const isComplete = currentStep === "complete"; + useEffect(() => { + if (!inspectorClient) return; - const handleContinue = useCallback(async () => { - if (!guidedStarted) { - await onGuidedStart(); - setGuidedStarted(true); - } else if (!needsAuthCode && !isComplete) { - await onGuidedAdvance(); - } - }, [ - guidedStarted, - needsAuthCode, - isComplete, - onGuidedStart, - onGuidedAdvance, - ]); + const update = () => { + void refreshOAuthState(); + }; + inspectorClient.addEventListener("oauthComplete", update); + return () => { + inspectorClient.removeEventListener("oauthComplete", update); + }; + }, [inspectorClient, refreshOAuthState]); - // Keyboard: G/Q/S select menu item (handled by App when not focused), - // left/right select, Enter run, up/down scroll useInput( (input: string, key: Key) => { - if (!focused || !isOAuthCapable) return; + if (!focused) return; - const lower = input.toLowerCase(); - if (lower === "g") { - onSelectedActionChange("guided"); - return; - } - if (lower === "q") { - onSelectedActionChange("quick"); - return; - } - if (lower === "s") { - onSelectedActionChange("clear"); - return; - } - - if (key.leftArrow) { - onSelectedActionChange( - selectedAction === "guided" - ? "clear" - : selectedAction === "quick" - ? "guided" - : "quick", - ); - } else if (key.rightArrow) { - onSelectedActionChange( - selectedAction === "guided" - ? "quick" - : selectedAction === "quick" - ? "clear" - : "guided", - ); - } else if (key.upArrow && scrollViewRef.current) { + if (key.upArrow && scrollViewRef.current) { scrollViewRef.current.scrollBy(-1); } else if (key.downArrow && scrollViewRef.current) { scrollViewRef.current.scrollBy(1); @@ -173,210 +103,123 @@ export function AuthTab({ } else if (key.pageDown && scrollViewRef.current) { const h = scrollViewRef.current.getViewportHeight() || 1; scrollViewRef.current.scrollBy(h); - } else if (key.return) { - if (selectedAction === "guided") onRunGuidedToCompletion(); - else if (selectedAction === "quick") onQuickAuth(); - else if (selectedAction === "clear") { - onClearOAuth(); - setClearedConfirmation(true); - } - } else if (input === " " && selectedAction === "guided") { - handleContinue(); + } else if (input.toLowerCase() === "s") { + setLastClearDisconnected(isLiveConnection); + onClearOAuth(); + setClearedConfirmation(true); } }, - { - isActive: focused, - }, + { isActive: focused }, ); - if (!serverName || !isOAuthCapable) { + if (!serverName) { return ( - - Select an OAuth-capable server (SSE or Streamable HTTP) to configure - authentication. - + Select a server to view authentication. ); } + const scopes = oauthState ? formatScopes(oauthState) : undefined; + const accessToken = oauthState?.tokens?.access_token; + return ( - Authentication + OAuth - - {/* Action bar and hint - single container for tight spacing */} - - - - Guided Auth - - - Quick Auth - - - Clear OAuth State - - - - {selectedAction === "guided" && ( - <> - - Press [Space] to advance one step through guided auth. - - - Press [Enter] to run guided auth to completion. - - - )} - {selectedAction === "quick" && ( - Press [Enter] to run quick auth. - )} - {selectedAction === "clear" && ( - Press [Enter] to clear OAuth state. - )} - - - - {selectedAction === "guided" && ( - - Guided OAuth Flow Progress - {STEP_ORDER.map((step) => { - const stepIdx = stepIndex(step); - const currentIdx = stepIndex(currentStep); - const completed = - guidedFlowStarted && - (stepIdx < currentIdx || - (step === currentStep && isComplete)); - const inProgress = - guidedFlowStarted && step === currentStep && !isComplete; - const details = oauthFlowState - ? getStepDetails(oauthFlowState, step) - : null; - - const icon = completed ? "✓" : inProgress ? "→" : "○"; - const color = completed - ? "green" - : inProgress - ? "cyan" - : "gray"; - return ( - - - {icon} {STEP_LABELS[step]} - {inProgress && " (in progress)"} - - {completed && details && ( - - {details} - - )} - {inProgress && details && ( - - {details} - - )} - - ); - })} - - {/* Waiting for auth - URL was opened when we reached this step */} - {oauthFlowState && - needsAuthCode && - oauthFlowState?.authorizationUrl && ( - - Authorization URL opened in browser - - - {oauthFlowState.authorizationUrl.toString()} - - - - - Complete authorization in the browser. You will be - redirected and the flow will complete automatically. - - - - )} - + + + {oauthStatus === "authenticating" && ( + Authenticating… + )} + {oauthStatus === "error" && oauthMessage && ( + {oauthMessage} )} - {selectedAction === "quick" && ( - - {oauthStatus === "authenticating" && ( - Authenticating... - )} - {oauthStatus === "error" && oauthMessage && ( - {oauthMessage} - )} - {oauthStatus === "success" && - oauthFlowState && - oauthFlowState.execution === "quick" && - (oauthFlowState.oauthTokens || - oauthFlowState.oauthClientInfo) && ( - <> - Quick Auth Results - {oauthFlowState.oauthClientInfo && ( - - - Client:{" "} - {JSON.stringify( - oauthFlowState.oauthClientInfo, - null, - 2, - )} - - - )} - {oauthFlowState.oauthTokens && ( - - - Access Token:{" "} - {oauthFlowState.oauthTokens.access_token?.slice( - 0, - 20, - )} - ... - - + {oauthState ? ( + + OAuth Details + + + + {oauthState.client?.clientId && ( + + )} + {oauthState.client?.registrationKind && ( + + /> + )} + {oauthState.protocol === "ema" && + oauthState.ema?.idpSession && ( + + )} + {oauthState.authorizationServerMetadata + ?.authorization_endpoint && ( + + )} + {scopes && } + {accessToken && ( + )} + + ) : ( + oauthStatus !== "authenticating" && ( + + No OAuth information yet. + + Connect (C) to authorize when this server requires it. + + + ) )} - {selectedAction === "clear" && clearedConfirmation && ( - - OAuth state cleared. - - )} - - + + + Clear OAuth State + {isLiveConnection && " and disconnect"} + + {clearedConfirmation && ( + + {lastClearDisconnected + ? "OAuth state cleared. Disconnected." + : "OAuth state cleared."} + + )} + + + {focused && ( - ←/→ select, G/Q/S or Enter run, ↑/↓ scroll + S {isLiveConnection ? "clear+disconnect" : "clear"}, ↑/↓ scroll )} ); } - -function getStepDetails(state: OAuthFlowState, step: OAuthStep): string | null { - switch (step) { - case "metadata_discovery": - if (state.resourceMetadata || state.oauthMetadata) { - const parts: string[] = []; - if (state.resourceMetadata) { - parts.push( - `Resource: ${JSON.stringify(state.resourceMetadata, null, 2)}`, - ); - } - if (state.oauthMetadata) { - parts.push(`OAuth: ${JSON.stringify(state.oauthMetadata, null, 2)}`); - } - return parts.join("\n"); - } - return null; - case "client_registration": - if (state.oauthClientInfo) { - return JSON.stringify(state.oauthClientInfo, null, 2); - } - return null; - case "authorization_redirect": - if (state.authorizationUrl) { - return `URL: ${state.authorizationUrl.toString()}`; - } - return null; - case "authorization_code": - return state.authorizationCode - ? `Code received: ${state.authorizationCode.slice(0, 10)}...` - : null; - case "token_request": - return "Exchanging code for tokens..."; - case "complete": - if (state.oauthTokens) { - return `Tokens: access_token=${state.oauthTokens.access_token?.slice(0, 15)}...`; - } - return null; - default: - return null; - } -} diff --git a/clients/tui/src/utils/oauthDisplay.ts b/clients/tui/src/utils/oauthDisplay.ts new file mode 100644 index 000000000..ed31645ca --- /dev/null +++ b/clients/tui/src/utils/oauthDisplay.ts @@ -0,0 +1,42 @@ +import type { + OAuthClientRegistrationKind, + OAuthConnectionState, +} from "@inspector/core/auth/types.js"; + +export function formatAuthProtocol( + protocol: OAuthConnectionState["protocol"], +): string { + return protocol === "ema" ? "Enterprise-managed" : "Standard"; +} + +export function formatIdpSession( + session: NonNullable["idpSession"], +): string { + switch (session) { + case "logged_in": + return "Signed in"; + case "expired": + return "Session expired"; + default: + return "Not signed in"; + } +} + +export function formatClientRegistrationKind( + kind: OAuthClientRegistrationKind, +): string { + switch (kind) { + case "static": + return "Static (preregistered)"; + case "dcr": + return "Dynamic (DCR)"; + case "cimd": + return "Client ID Metadata (CIMD)"; + } +} + +export function formatScopes(state: OAuthConnectionState): string | undefined { + const scopeSource = state.grantedScope ?? state.configuredScope; + const scopes = scopeSource?.split(" ").filter(Boolean); + return scopes && scopes.length > 0 ? scopes.join(", ") : undefined; +} diff --git a/clients/tui/tsup.config.ts b/clients/tui/tsup.config.ts index a74464454..039a245f9 100644 --- a/clients/tui/tsup.config.ts +++ b/clients/tui/tsup.config.ts @@ -24,6 +24,7 @@ export default defineConfig({ 'pino', 'zustand', '@modelcontextprotocol/sdk', + '@napi-rs/keyring', ], esbuildOptions(options) { options.alias = { diff --git a/clients/tui/tui.tsx b/clients/tui/tui.tsx index b9a27b1e7..aac8dd25c 100644 --- a/clients/tui/tui.tsx +++ b/clients/tui/tui.tsx @@ -6,6 +6,11 @@ import { parseKeyValuePair, parseHeaderPair, } from "@inspector/core/mcp/node/index.js"; +import { loadRunnerClientConfig } from "@inspector/core/client/runner.js"; +import { + parseRunnerOAuthCallbackUrl, + DEFAULT_RUNNER_OAUTH_CALLBACK_URL, +} from "@inspector/core/auth/node/runner-oauth-callback.js"; import App from "./src/App.js"; import { loadTuiServers } from "./src/tui-servers.js"; @@ -48,9 +53,13 @@ export async function runTui(args?: string[]): Promise { "--client-metadata-url ", "OAuth Client ID Metadata Document URL (CIMD) for HTTP servers", ) + .option( + "--client-config ", + "Install-level client config (default: ~/.mcp-inspector/storage/client.json, or MCP_CLIENT_CONFIG_PATH)", + ) .option( "--callback-url ", - "OAuth redirect/callback listener URL (default: http://127.0.0.1:0/oauth/callback)", + `OAuth redirect/callback listener URL (default: ${DEFAULT_RUNNER_OAUTH_CALLBACK_URL}, or MCP_OAUTH_CALLBACK_URL)`, ) .argument( "[target...]", @@ -72,6 +81,7 @@ export async function runTui(args?: string[]): Promise { clientId?: string; clientSecret?: string; clientMetadataUrl?: string; + clientConfig?: string; callbackUrl?: string; transport?: "stdio" | "sse" | "http"; serverUrl?: string; @@ -89,52 +99,13 @@ export async function runTui(args?: string[]): Promise { serverUrl: options.serverUrl?.trim() || undefined, }; - const mcpServers = loadTuiServers(serverOptions); + const mcpServers = await loadTuiServers(serverOptions); - interface CallbackUrlConfig { - hostname: string; - port: number; - pathname: string; - } - - function parseCallbackUrl(raw?: string): CallbackUrlConfig { - if (!raw) { - return { hostname: "127.0.0.1", port: 0, pathname: "/oauth/callback" }; - } - let url: URL; - try { - url = new URL(raw); - } catch (err) { - throw new Error( - `Invalid callback URL: ${(err as Error)?.message ?? String(err)}`, - ); - } - if (url.protocol !== "http:") { - throw new Error("Callback URL must use http scheme"); - } - const hostname = url.hostname; - if (!hostname) { - throw new Error("Callback URL must include a hostname"); - } - const pathname = url.pathname || "/"; - let port: number; - if (url.port === "") { - port = 80; - } else { - port = Number(url.port); - if ( - !Number.isFinite(port) || - !Number.isInteger(port) || - port < 0 || - port > 65535 - ) { - throw new Error("Callback URL port must be between 0 and 65535"); - } - } - return { hostname, port, pathname }; - } + const callbackUrlConfig = parseRunnerOAuthCallbackUrl(options.callbackUrl); - const callbackUrlConfig = parseCallbackUrl(options.callbackUrl); + const clientConfig = await loadRunnerClientConfig({ + clientConfigPath: options.clientConfig, + }); const ansiEraseSavedLines = new RegExp( String.fromCharCode(0x1b) + "\\[3J", @@ -181,6 +152,7 @@ export async function runTui(args?: string[]): Promise { const instance = render( { - // Canonicalize so Vite's config hash is stable and matches the deps cache. + // Canonicalize paths under clients/web. const root = resolve(join(__dirname, "..")); - const baseConfig = getViteBaseConfig(); + clearViteDepsCache(root); // `configFile: false` means this in-process server never loads // `vite.config.ts`, so it must reproduce that file's `resolve` block and // `server.fs.allow` here. Without them, App.tsx's `@inspector/core/*` @@ -38,7 +41,7 @@ export async function startViteDevServer( const { repoRoot, sharedAliases, sharedDedupe, nodeModulesAliases } = vitestSharedPaths(root); const inlineConfig: InlineConfig = { - ...baseConfig, + optimizeDeps: getViteDevOptimizeDeps(), configFile: false, root, resolve: { diff --git a/clients/web/server/vite-base-config.ts b/clients/web/server/vite-base-config.ts index 5596fe4b0..75199b37e 100644 --- a/clients/web/server/vite-base-config.ts +++ b/clients/web/server/vite-base-config.ts @@ -8,35 +8,58 @@ * the parts needed by both the CLI runner and `vite dev` belong here. */ +import { rmSync } from "node:fs"; +import { join } from "node:path"; + +const NODE_ONLY_OPTIMIZE_DEPS_EXCLUDE = [ + "@modelcontextprotocol/sdk/client/stdio.js", + // `atomically` is reached only through `core/storage/store-io.ts`, + // which is imported by `core/mcp/remote/node/server.ts` (the Hono + // app). The module never lands in the browser graph; excluding it + // keeps Vite's dev-time scanner from chasing it through the plugin's + // node-only import chain. + "atomically", + // `chokidar` is only loaded inside `core/mcp/remote/node/server.ts` + // when the lazy mcp.json watcher starts. It transitively imports + // `readdirp` and core node fs/os modules; excluding it keeps Vite's + // dep scanner from walking into them during dev startup. + "chokidar", + "cross-spawn", + "which", + // `@napi-rs/keyring` is loaded only inside + // `core/auth/node/secret-store.ts` from the Hono `/api/servers` + // handlers. It's a native-binding package (no browser code path) so + // excluding it keeps Vite's dep scanner from chasing into the + // platform-specific binaries during dev startup. + "@napi-rs/keyring", +] as const; + export function getViteBaseConfig() { return { optimizeDeps: { - // Node-only modules that the dev backend (core/mcp/remote/node/*, - // core/mcp/node/*) consumes. Excluding them from Vite's dep-pre-bundling - // step keeps `vite dev` from trying to scan/bundle them into the - // browser graph during startup. - exclude: [ - "@modelcontextprotocol/sdk/client/stdio.js", - // `atomically` is reached only through `core/storage/store-io.ts`, - // which is imported by `core/mcp/remote/node/server.ts` (the Hono - // app). The module never lands in the browser graph; excluding it - // keeps Vite's dev-time scanner from chasing it through the plugin's - // node-only import chain. - "atomically", - // `chokidar` is only loaded inside `core/mcp/remote/node/server.ts` - // when the lazy mcp.json watcher starts. It transitively imports - // `readdirp` and core node fs/os modules; excluding it keeps Vite's - // dep scanner from walking into them during dev startup. - "chokidar", - "cross-spawn", - "which", - // `@napi-rs/keyring` is loaded only inside - // `core/auth/node/secret-store.ts` from the Hono `/api/servers` - // handlers. It's a native-binding package (no browser code path) so - // excluding it keeps Vite's dep scanner from chasing into the - // platform-specific binaries during dev startup. - "@napi-rs/keyring", - ], + exclude: [...NODE_ONLY_OPTIMIZE_DEPS_EXCLUDE], }, }; } + +/** + * Dev-server optimizeDeps: rebuild from scratch each launch — no stale + * pre-bundles carried across restarts (avoids 504 Outdated Optimize Dep). + * Do not use under Vitest. + */ +export function getViteDevOptimizeDeps() { + return { + exclude: [...NODE_ONLY_OPTIMIZE_DEPS_EXCLUDE], + force: true, + ignoreOutdatedRequests: true, + include: ["ajv", "@modelcontextprotocol/sdk/validation/ajv"], + }; +} + +/** Remove Vite's pre-bundle cache under clients/web before a dev launch. */ +export function clearViteDepsCache(clientWebRoot: string): void { + rmSync(join(clientWebRoot, "node_modules", ".vite"), { + recursive: true, + force: true, + }); +} diff --git a/clients/web/server/vite-hono-plugin.ts b/clients/web/server/vite-hono-plugin.ts index 4974bd201..67346b428 100644 --- a/clients/web/server/vite-hono-plugin.ts +++ b/clients/web/server/vite-hono-plugin.ts @@ -118,7 +118,16 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin { }; server.httpServer.once("listening", () => { - setImmediate(logBanner); + setImmediate(async () => { + // Pre-bundle the app entry before opening the browser so the first + // page load does not race Vite's dep optimizer (504 Outdated Optimize Dep). + try { + await server.warmupRequest("/src/main.tsx"); + } catch (err) { + console.warn("[vite] entry warmup failed:", err); + } + logBanner(); + }); }); const honoMiddleware = async ( diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index a6fb788b8..0188cfe68 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -57,7 +57,10 @@ import { serializeMcpConfig, } from "@inspector/core/mcp/serverList.js"; import type { ClientConfig } from "@inspector/core/client/types.js"; -import { getActiveEnterpriseManagedAuthIdp } from "@inspector/core/client/types.js"; +import { + getActiveCimdClientMetadataUrl, + getActiveEnterpriseManagedAuthIdp, +} from "@inspector/core/client/types.js"; import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js"; import { loadClientConfigRemote, @@ -146,6 +149,7 @@ import { OAUTH_PENDING_SERVER_KEY, isUnauthorizedError, } from "./utils/oauthFlow"; +import { clearServerOAuthState } from "./utils/clearServerOAuthState"; // OAuth redirect URL provider — points at the dev backend's `/oauth/callback` // handler. The InspectorClient only consults this when the active server @@ -1224,6 +1228,12 @@ function App() { }; }, [connectionStatus, inspectorClient]); + const connectionInfoCanClearOAuth = + connectionStatus === "connected" && + !!inspectorClient && + (connectionInfoTransport === "streamable-http" || + connectionInfoTransport === "sse"); + // Derive log entries from the message log. Filters for // `notifications/message` (the response to `logging/setLevel`). const logs = useMemo( @@ -1315,6 +1325,7 @@ function App() { // from the InspectorClient options we're about to derive from it. const savedSettings = server.settings; const activeIdp = getActiveEnterpriseManagedAuthIdp(clientConfig); + const activeCimdUrl = getActiveCimdClientMetadataUrl(clientConfig); // Flatten the persisted settings into the InspectorClient options shape. // Empty / zero values stay unset so the SDK defaults apply. const defaultMetadata = savedSettings?.metadata @@ -1324,7 +1335,7 @@ function App() { .map((m) => [m.key, m.value]), ) : undefined; - const oauth = + const oauthFromServer = savedSettings && (savedSettings.oauthClientId || savedSettings.oauthClientSecret || @@ -1345,6 +1356,13 @@ function App() { }), } : undefined; + const oauth = + oauthFromServer || activeCimdUrl + ? { + ...(oauthFromServer ?? {}), + ...(activeCimdUrl && { clientMetadataUrl: activeCimdUrl }), + } + : undefined; const client = new InspectorClient(server.config, { environment, // The Tasks tab needs the receiver-task pipeline; the @@ -1450,7 +1468,7 @@ function App() { oauthCallbackHandledRef.current = true; const params = parseOAuthCallbackParams(window.location.search); - // The OAuth `state` round-trips `{execution}:{authId}`; the authId is the + // The OAuth `state` round-trips the auth session id; the authId is the // session key the pre-redirect page saved the fetch log under, so the // rebuilt client can restore those `auth` entries. Read it before the // URL is cleared below. @@ -2387,6 +2405,42 @@ function App() { // target isn't resolvable. const settingsModalIsStdio = settingsModalServerType === "stdio"; + const clearServerOAuthAndDisconnect = useCallback( + async (server: { id: string; name: string; config: MCPServerConfig }) => { + const isActive = server.id === activeServerId; + const cleared = clearServerOAuthState({ + config: server.config, + inspectorClient: isActive ? inspectorClient : null, + isActiveConnection: isActive, + }); + if (!cleared) return; + + if (isActive && inspectorClient) { + await inspectorClient.disconnect(); + setConnectionInfoOAuthWhenConnected(undefined); + } + + notifications.show({ + title: "OAuth state cleared", + message: isActive + ? "Stored tokens and client registration were removed. Reconnect to run a fresh authorization flow." + : `Stored OAuth state was removed for "${server.name}". Connect to authorize again.`, + color: "blue", + }); + }, + [activeServerId, inspectorClient], + ); + + const handleClearConnectionOAuth = useCallback(() => { + if (!activeServer) return; + void clearServerOAuthAndDisconnect(activeServer); + }, [activeServer, clearServerOAuthAndDisconnect]); + + const handleClearStoredOAuthFromSettings = useCallback(() => { + if (!settingsModalTarget) return; + void clearServerOAuthAndDisconnect(settingsModalTarget); + }, [settingsModalTarget, clearServerOAuthAndDisconnect]); + const onSettingsModalClose = useCallback(() => { flushSettingsDraft(); // Apply root edits to the live client once, on close — not on every @@ -2686,6 +2740,9 @@ function App() { isStdio={settingsModalIsStdio} onClose={onSettingsModalClose} onSettingsChange={onSettingsChange} + onClearStoredOAuth={ + settingsModalIsStdio ? undefined : handleClearStoredOAuthFromSettings + } /> )} {({ copied, copy }) => ( @@ -19,6 +24,7 @@ export function CopyButton({ value }: CopyButtonProps) { onClick={copy} fz={24} aria-label={copied ? "Copied" : "Copy"} + {...(flush && { p: 0, h: "auto", w: "auto" })} > {copied ? "\u2713" : "\u2398"} diff --git a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.tsx b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.tsx index daead7a9e..72513c5e6 100644 --- a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.tsx +++ b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.tsx @@ -12,13 +12,17 @@ import { ClearButton } from "../../elements/ClearButton/ClearButton"; import type { EmaIdpLoginState } from "@inspector/core/auth/ema/idpSession.js"; import type { ClientSettingsFormValues } from "./clientSettingsValues.js"; -export type ClientSettingsSection = "ema"; +export type ClientSettingsSection = "ema" | "cimd"; export interface ClientSettingsFormProps { settings: ClientSettingsFormValues; expandedSections: ClientSettingsSection[]; onExpandedSectionsChange: (sections: ClientSettingsSection[]) => void; - onSettingsChange: (settings: ClientSettingsFormValues) => void; + onSettingsChange: ( + settings: + | ClientSettingsFormValues + | ((prev: ClientSettingsFormValues) => ClientSettingsFormValues), + ) => void; emaIdpLoginState?: EmaIdpLoginState; onEmaIdpLogout?: () => void; } @@ -37,7 +41,7 @@ export function ClientSettingsForm({ onEmaIdpLogout, }: ClientSettingsFormProps) { function patch(partial: Partial) { - onSettingsChange({ ...settings, ...partial }); + onSettingsChange((prev) => ({ ...prev, ...partial })); } const showIdpSession = @@ -169,6 +173,46 @@ export function ClientSettingsForm({ + + OAuth Client ID Metadata Document + + + patch({ cimdEnabled: e.currentTarget.checked })} + /> + {settings.cimdEnabled && ( + <> + + The metadata document must be served over HTTPS and list this + redirect URI:{" "} + {typeof window !== "undefined" + ? `${window.location.origin}/oauth/callback` + : "http://localhost:6274/oauth/callback"} + + + patch({ clientMetadataUrl: e.currentTarget.value }) + } + rightSectionPointerEvents="auto" + rightSection={ + settings.clientMetadataUrl ? ( + patch({ clientMetadataUrl: "" })} + /> + ) : null + } + /> + + )} + + + ); } diff --git a/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.test.ts b/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.test.ts index c00391e16..ba945117b 100644 --- a/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.test.ts +++ b/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.test.ts @@ -5,6 +5,8 @@ import { formValuesToClientConfig, } from "./clientSettingsValues"; +const emptyCimd = { cimdEnabled: false, clientMetadataUrl: "" }; + describe("clientSettingsValues", () => { it("clientConfigToFormValues maps enterpriseManagedAuth.idp", () => { expect( @@ -22,6 +24,7 @@ describe("clientSettingsValues", () => { issuer: "https://idp.test", clientId: "cid", clientSecret: "sec", + ...emptyCimd, }); }); @@ -42,6 +45,60 @@ describe("clientSettingsValues", () => { issuer: "https://idp.test", clientId: "cid", clientSecret: "sec", + ...emptyCimd, + }); + }); + + it("clientConfigToFormValues maps cimd when EMA is absent", () => { + expect( + clientConfigToFormValues({ + cimd: { + enabled: true, + clientMetadataUrl: "https://example.com/cimd.json", + }, + }), + ).toEqual({ + emaEnabled: false, + issuer: "", + clientId: "", + clientSecret: "", + cimdEnabled: true, + clientMetadataUrl: "https://example.com/cimd.json", + }); + }); + + it("clientConfigToFormValues preserves CIMD URL when disabled", () => { + expect( + clientConfigToFormValues({ + cimd: { + enabled: false, + clientMetadataUrl: "https://example.com/cimd.json", + }, + }), + ).toEqual({ + emaEnabled: false, + issuer: "", + clientId: "", + clientSecret: "", + cimdEnabled: false, + clientMetadataUrl: "https://example.com/cimd.json", + }); + }); + + it("formValuesToClientConfig always writes the cimd block from the dialog", () => { + expect( + formValuesToClientConfig({ + emaEnabled: false, + issuer: "", + clientId: "", + clientSecret: "", + ...emptyCimd, + }), + ).toEqual({ + cimd: { + enabled: false, + clientMetadataUrl: "", + }, }); }); @@ -52,6 +109,7 @@ describe("clientSettingsValues", () => { issuer: "https://idp.test", clientId: "cid", clientSecret: "sec", + ...emptyCimd, }), ).toEqual({ enterpriseManagedAuth: { @@ -62,18 +120,55 @@ describe("clientSettingsValues", () => { clientSecret: "sec", }, }, + cimd: { + enabled: false, + clientMetadataUrl: "", + }, }); }); - it("formValuesToClientConfig omits block when disabled with no stored fields", () => { + it("formValuesToClientConfig keeps CIMD URL when disabled", () => { expect( formValuesToClientConfig({ emaEnabled: false, issuer: "", clientId: "", clientSecret: "", + cimdEnabled: false, + clientMetadataUrl: "https://example.com/cimd.json", }), - ).toEqual({}); + ).toEqual({ + cimd: { + enabled: false, + clientMetadataUrl: "https://example.com/cimd.json", + }, + }); + }); + + it("formValuesToClientConfig serializes EMA and CIMD together", () => { + expect( + formValuesToClientConfig({ + emaEnabled: true, + issuer: "https://idp.test", + clientId: "cid", + clientSecret: "", + cimdEnabled: true, + clientMetadataUrl: "https://example.com/cimd.json", + }), + ).toEqual({ + enterpriseManagedAuth: { + enabled: true, + idp: { + issuer: "https://idp.test", + clientId: "cid", + clientSecret: "", + }, + }, + cimd: { + enabled: true, + clientMetadataUrl: "https://example.com/cimd.json", + }, + }); }); it("formValuesToClientConfig trims issuer and clientId when enabled", () => { @@ -83,6 +178,7 @@ describe("clientSettingsValues", () => { issuer: " https://idp.test ", clientId: " cid ", clientSecret: "sec", + ...emptyCimd, }), ).toEqual({ enterpriseManagedAuth: { @@ -93,6 +189,28 @@ describe("clientSettingsValues", () => { clientSecret: "sec", }, }, + cimd: { + enabled: false, + clientMetadataUrl: "", + }, + }); + }); + + it("formValuesToClientConfig trims CIMD URL", () => { + expect( + formValuesToClientConfig({ + emaEnabled: false, + issuer: "", + clientId: "", + clientSecret: "", + cimdEnabled: true, + clientMetadataUrl: " https://example.com/cimd.json ", + }), + ).toEqual({ + cimd: { + enabled: true, + clientMetadataUrl: "https://example.com/cimd.json", + }, }); }); @@ -103,6 +221,7 @@ describe("clientSettingsValues", () => { issuer: "", clientId: "", clientSecret: "", + ...emptyCimd, }), ).toBe(true); expect( @@ -111,6 +230,7 @@ describe("clientSettingsValues", () => { issuer: "https://idp.test", clientId: "cid", clientSecret: "", + ...emptyCimd, }), ).toBe(true); expect( @@ -119,7 +239,44 @@ describe("clientSettingsValues", () => { issuer: "", clientId: "cid", clientSecret: "", + ...emptyCimd, }), ).toBe(false); }); + + it("canPersistClientSettingsDraft requires CIMD URL when enabled", () => { + expect( + canPersistClientSettingsDraft({ + emaEnabled: false, + issuer: "", + clientId: "", + clientSecret: "", + cimdEnabled: true, + clientMetadataUrl: "", + }), + ).toBe(false); + expect( + canPersistClientSettingsDraft({ + emaEnabled: false, + issuer: "", + clientId: "", + clientSecret: "", + cimdEnabled: true, + clientMetadataUrl: "https://example.com/cimd.json", + }), + ).toBe(true); + }); + + it("canPersistClientSettingsDraft allows disabling CIMD while keeping the URL", () => { + expect( + canPersistClientSettingsDraft({ + emaEnabled: true, + issuer: "https://idp.test", + clientId: "cid", + clientSecret: "", + cimdEnabled: false, + clientMetadataUrl: "https://example.com/cimd.json", + }), + ).toBe(true); + }); }); diff --git a/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.ts b/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.ts index a72975d9b..551568688 100644 --- a/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.ts +++ b/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.ts @@ -6,6 +6,8 @@ export interface ClientSettingsFormValues { issuer: string; clientId: string; clientSecret: string; + cimdEnabled: boolean; + clientMetadataUrl: string; } export const EMPTY_CLIENT_SETTINGS: ClientSettingsFormValues = { @@ -13,6 +15,8 @@ export const EMPTY_CLIENT_SETTINGS: ClientSettingsFormValues = { issuer: "", clientId: "", clientSecret: "", + cimdEnabled: false, + clientMetadataUrl: "", }; export function clientConfigToFormValues( @@ -20,14 +24,15 @@ export function clientConfigToFormValues( ): ClientSettingsFormValues { const ema = config.enterpriseManagedAuth; const idp = ema?.idp; - if (!idp) { - return { ...EMPTY_CLIENT_SETTINGS }; - } + const cimd = config.cimd; + return { - emaEnabled: ema.enabled !== false, - issuer: idp.issuer, - clientId: idp.clientId, - clientSecret: idp.clientSecret ?? "", + emaEnabled: idp ? ema!.enabled !== false : false, + issuer: idp?.issuer ?? "", + clientId: idp?.clientId ?? "", + clientSecret: idp?.clientSecret ?? "", + cimdEnabled: cimd?.enabled === true, + clientMetadataUrl: cimd?.clientMetadataUrl ?? "", }; } @@ -39,31 +44,40 @@ function hasStoredIdpFields(values: ClientSettingsFormValues): boolean { ); } +/** Serialize the full dialog state. POST replaces client.json wholesale. */ export function formValuesToClientConfig( values: ClientSettingsFormValues, ): ClientConfig { - if (!hasStoredIdpFields(values)) { - return {}; - } - - const idp = { - issuer: values.issuer.trim(), - clientId: values.clientId.trim(), - clientSecret: values.clientSecret, + const result: ClientConfig = { + cimd: { + enabled: values.cimdEnabled, + clientMetadataUrl: values.clientMetadataUrl.trim(), + }, }; - return { - enterpriseManagedAuth: { + if (hasStoredIdpFields(values) || values.emaEnabled) { + result.enterpriseManagedAuth = { enabled: values.emaEnabled, - idp, - }, - }; + idp: { + issuer: values.issuer.trim(), + clientId: values.clientId.trim(), + clientSecret: values.clientSecret, + }, + }; + } + + return result; } -/** Skip debounced persist while EMA is enabled but required IdP fields are blank. */ +/** Skip debounced persist while required fields are blank for enabled features. */ export function canPersistClientSettingsDraft( values: ClientSettingsFormValues, ): boolean { - if (!values.emaEnabled) return true; - return values.issuer.trim() !== "" && values.clientId.trim() !== ""; + if (values.emaEnabled) { + if (!values.issuer.trim() || !values.clientId.trim()) return false; + } + if (values.cimdEnabled) { + if (!values.clientMetadataUrl.trim()) return false; + } + return true; } diff --git a/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.stories.tsx b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.stories.tsx index c41ba4dbc..843992d30 100644 --- a/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.stories.tsx +++ b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.stories.tsx @@ -13,6 +13,8 @@ const configuredSettings: ClientSettingsFormValues = { issuer: "https://idp.example.com", clientId: "inspector-idp-client", clientSecret: "super-secret-idp-value", + cimdEnabled: true, + clientMetadataUrl: "https://example.com/oauth/client.json", }; function InteractiveRender(args: ClientSettingsModalProps) { @@ -25,7 +27,11 @@ function InteractiveRender(args: ClientSettingsModalProps) { {...args} onSettingsChange={(settings) => { args.onSettingsChange(settings); - updateArgs({ settings }); + const next = + typeof settings === "function" + ? settings(args.settings) + : settings; + updateArgs({ settings: next }); }} /> @@ -61,6 +67,8 @@ export const Empty: Story = { issuer: "", clientId: "", clientSecret: "", + cimdEnabled: false, + clientMetadataUrl: "", }, }, }; @@ -72,6 +80,8 @@ export const EnabledEmptyFields: Story = { issuer: "", clientId: "", clientSecret: "", + cimdEnabled: false, + clientMetadataUrl: "", }, }, }; diff --git a/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx index 1411d2177..e01a89251 100644 --- a/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx +++ b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx @@ -2,7 +2,19 @@ import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; import { ClientSettingsModal } from "./ClientSettingsModal"; -import { EMPTY_CLIENT_SETTINGS } from "../ClientSettingsForm/clientSettingsValues"; +import { + EMPTY_CLIENT_SETTINGS, + type ClientSettingsFormValues, +} from "../ClientSettingsForm/clientSettingsValues"; + +function resolveSettingsChange( + call: unknown, + prev: ClientSettingsFormValues, +): ClientSettingsFormValues { + return typeof call === "function" + ? (call as (p: ClientSettingsFormValues) => ClientSettingsFormValues)(prev) + : (call as ClientSettingsFormValues); +} describe("ClientSettingsModal", () => { it("renders the title when opened", () => { @@ -50,7 +62,13 @@ describe("ClientSettingsModal", () => { name: "Enable enterprise IdP configuration", }), ); - expect(onSettingsChange).toHaveBeenCalledWith({ + expect(onSettingsChange).toHaveBeenCalledWith(expect.any(Function)); + expect( + resolveSettingsChange( + onSettingsChange.mock.calls[0]![0], + EMPTY_CLIENT_SETTINGS, + ), + ).toEqual({ ...EMPTY_CLIENT_SETTINGS, emaEnabled: true, }); @@ -81,10 +99,11 @@ describe("ClientSettingsModal", () => { onSettingsChange={onSettingsChange} />, ); + const initial = { ...EMPTY_CLIENT_SETTINGS, emaEnabled: true }; await user.type(screen.getByLabelText("Issuer"), "h"); expect(onSettingsChange).toHaveBeenCalled(); const call = - onSettingsChange.mock.calls[onSettingsChange.mock.calls.length - 1][0]; - expect(call.issuer).toBe("h"); + onSettingsChange.mock.calls[onSettingsChange.mock.calls.length - 1]![0]; + expect(resolveSettingsChange(call, initial).issuer).toBe("h"); }); }); diff --git a/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.tsx b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.tsx index e003186f6..cbc89a1e7 100644 --- a/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.tsx +++ b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.tsx @@ -8,13 +8,17 @@ import { import type { EmaIdpLoginState } from "@inspector/core/auth/ema/idpSession.js"; import type { ClientSettingsFormValues } from "../ClientSettingsForm/clientSettingsValues.js"; -const ALL_SECTIONS: ClientSettingsSection[] = ["ema"]; +const ALL_SECTIONS: ClientSettingsSection[] = ["ema", "cimd"]; export interface ClientSettingsModalProps { opened: boolean; settings: ClientSettingsFormValues; onClose: () => void; - onSettingsChange: (settings: ClientSettingsFormValues) => void; + onSettingsChange: ( + settings: + | ClientSettingsFormValues + | ((prev: ClientSettingsFormValues) => ClientSettingsFormValues), + ) => void; emaIdpLoginState?: EmaIdpLoginState; onEmaIdpLogout?: () => void; } diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx index 27e1a9701..f481911ae 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx @@ -1,10 +1,14 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; import type { ClientCapabilities, InitializeResult, } from "@modelcontextprotocol/sdk/types.js"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; -import { ConnectionInfoContent } from "./ConnectionInfoContent"; +import { + CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL, + ConnectionInfoContent, +} from "./ConnectionInfoContent"; const fullResult: InitializeResult = { protocolVersion: "2025-03-26", @@ -95,6 +99,25 @@ describe("ConnectionInfoContent", () => { expect(screen.queryByText("Server Instructions")).not.toBeInTheDocument(); }); + it("renders client registration kind when provided", () => { + renderWithMantine( + , + ); + expect(screen.getByText("Client registration")).toBeInTheDocument(); + expect(screen.getByText("Client ID Metadata (CIMD)")).toBeInTheDocument(); + }); + it("renders OAuth details when provided", () => { renderWithMantine( { ); expect(screen.queryByText("OAuth Details")).not.toBeInTheDocument(); }); + + it("calls onClearOAuth from the OAuth section", async () => { + const user = userEvent.setup(); + const onClearOAuth = vi.fn(); + renderWithMantine( + , + ); + await user.click( + screen.getByRole("button", { + name: CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL, + }), + ); + expect(onClearOAuth).toHaveBeenCalledTimes(1); + }); }); diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx index 790220cfd..e8cf31644 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx @@ -1,6 +1,8 @@ import { Badge, + Button, Code, + Flex, ScrollArea, SimpleGrid, Stack, @@ -12,6 +14,7 @@ import type { InitializeResult, } from "@modelcontextprotocol/sdk/types.js"; import type { ServerType } from "@inspector/core/mcp/types.js"; +import type { OAuthClientRegistrationKind } from "@inspector/core/auth/types.js"; import { CapabilityItem, type CapabilityKey, @@ -23,6 +26,7 @@ export interface OAuthDetails { protocol: "standard" | "ema"; authorized: boolean; clientId?: string; + clientRegistrationKind?: OAuthClientRegistrationKind; authUrl?: string; scopes?: string[]; accessToken?: string; @@ -35,6 +39,7 @@ export interface ConnectionInfoContentProps { clientCapabilities: ClientCapabilities; transport: ServerType; oauth?: OAuthDetails; + onClearOAuth?: () => void; } const ValueText = Text.withProps({ @@ -68,6 +73,19 @@ function formatIdpSession( } } +function formatClientRegistrationKind( + kind: OAuthClientRegistrationKind, +): string { + switch (kind) { + case "static": + return "Static (preregistered)"; + case "dcr": + return "Dynamic (DCR)"; + case "cimd": + return "Client ID Metadata (CIMD)"; + } +} + const SERVER_CAPABILITY_KEYS: CapabilityKey[] = [ "tools", "resources", @@ -85,6 +103,9 @@ const CLIENT_CAPABILITY_KEYS: CapabilityKey[] = [ "experimental", ]; +export const CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL = + "Clear OAuth state and disconnect"; + function getCapabilityEntries( capabilities: Record, knownKeys: CapabilityKey[], @@ -100,6 +121,7 @@ export function ConnectionInfoContent({ clientCapabilities, transport, oauth, + onClearOAuth, }: ConnectionInfoContentProps) { const { serverInfo, protocolVersion, capabilities, instructions } = initializeResult; @@ -189,6 +211,14 @@ export function ConnectionInfoContent({ {oauth.clientId} )} + {oauth.clientRegistrationKind && ( + + Client registration + + {formatClientRegistrationKind(oauth.clientRegistrationKind)} + + + )} {oauth.protocol === "ema" && oauth.idpSession && ( IdP session @@ -207,8 +237,25 @@ export function ConnectionInfoContent({ {formatScopes(oauth.scopes)} )} - {oauth.accessToken && ( - + {oauth.accessToken ? ( + + ) : ( + onClearOAuth && ( + + + + ) )} diff --git a/clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.test.tsx index bed9eaf8a..f1380f125 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.test.tsx @@ -1,16 +1,17 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL } from "./ConnectionInfoContent"; import { OAuthAccessTokenField } from "./OAuthAccessTokenField"; const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0."; describe("OAuthAccessTokenField", () => { - it("renders token with copy control beside the label", () => { + it("renders token with copy control beside the content", () => { renderWithMantine(); expect(screen.getByText("Access Token")).toBeInTheDocument(); - expect(screen.getByText("eyJhbGciOiJub25lIn0")).toBeInTheDocument(); - expect(screen.getByText("eyJzdWIiOiJ1c2VyIn0")).toBeInTheDocument(); + expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument(); + expect(screen.getByText(/eyJzdWIiOiJ1c2VyIn0/)).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument(); }); @@ -18,13 +19,13 @@ describe("OAuthAccessTokenField", () => { const user = userEvent.setup(); renderWithMantine(); - expect(screen.getByText("eyJhbGciOiJub25lIn0")).toBeInTheDocument(); + expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Decode JWT" })); expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument(); - expect(screen.queryByText("eyJhbGciOiJub25lIn0")).not.toBeInTheDocument(); + expect(screen.queryByText(/eyJhbGciOiJub25lIn0/)).not.toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Show token" })); - expect(screen.getByText("eyJhbGciOiJub25lIn0")).toBeInTheDocument(); + expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument(); expect(screen.queryByText(/"sub": "user"/)).not.toBeInTheDocument(); }); @@ -36,4 +37,54 @@ describe("OAuthAccessTokenField", () => { screen.queryByRole("button", { name: "Decode JWT" }), ).not.toBeInTheDocument(); }); + + it("copies decoded JSON while decode view is shown", async () => { + const user = userEvent.setup(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Decode JWT" })); + await user.click(screen.getByRole("button", { name: "Copy" })); + + expect(writeText).toHaveBeenCalledWith( + expect.stringContaining('"sub": "user"'), + ); + expect(writeText).not.toHaveBeenCalledWith(jwt); + }); + + it("copies the raw token while token view is shown", async () => { + const user = userEvent.setup(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Copy" })); + + expect(writeText).toHaveBeenCalledWith(jwt); + }); + + it("renders clear action on the access token header row", async () => { + const user = userEvent.setup(); + const onClear = vi.fn(); + renderWithMantine( + , + ); + await user.click( + screen.getByRole("button", { + name: CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL, + }), + ); + expect(onClear).toHaveBeenCalledTimes(1); + }); }); diff --git a/clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.tsx b/clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.tsx index d27336e52..825a8507a 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.tsx @@ -5,13 +5,50 @@ import { CopyButton } from "../../elements/CopyButton/CopyButton"; export interface OAuthAccessTokenFieldProps { accessToken: string; + onClear?: () => void; + clearLabel?: string; } -const DecodeButton = Button.withProps({ +const CaptionRow = Flex.withProps({ + justify: "space-between", + align: "center", + gap: "sm", + wrap: "nowrap", +}); + +const Caption = Text.withProps({ size: "sm" }); + +const Toolbar = Flex.withProps({ + gap: 4, + align: "center", + wrap: "nowrap", +}); + +const ToolbarButton = Button.withProps({ variant: "subtle", size: "compact-xs", }); +const TokenRow = Flex.withProps({ + align: "flex-start", + gap: 4, + wrap: "nowrap", +}); + +const TokenColumn = Flex.withProps({ + flex: 1, + miw: 0, + direction: "column", +}); + +const TokenCode = Code.withProps({ + block: true, + py: "xs", + ps: "xs", + pe: 0, + variant: "wrapping", +}); + /** Wrap JWT at segment boundaries; break long segments without orphaning `.`. */ function JwtTokenText({ token }: { token: string }) { const parts = token.split("."); @@ -20,7 +57,7 @@ function JwtTokenText({ token }: { token: string }) { {parts.map((part, index) => ( {index > 0 && "."} - {part} + {part} ))} @@ -29,6 +66,8 @@ function JwtTokenText({ token }: { token: string }) { export function OAuthAccessTokenField({ accessToken, + onClear, + clearLabel = "Clear", }: OAuthAccessTokenFieldProps) { const [showDecoded, setShowDecoded] = useState(false); const isJwt = isJwtFormat(accessToken); @@ -46,31 +85,42 @@ export function OAuthAccessTokenField({ ); }, [jwtDecoded]); + const copyValue = showDecoded && decodedText ? decodedText : accessToken; + return ( - - Access Token - + + Access Token + {jwtDecoded && ( - setShowDecoded((open) => !open)} aria-pressed={showDecoded} > {showDecoded ? "Show token" : "Decode JWT"} - + + )} + {onClear && ( + + {clearLabel} + )} - - - - - {showDecoded && decodedText ? ( - decodedText - ) : isJwt ? ( - - ) : ( - accessToken - )} - + + + + + + {showDecoded && decodedText ? ( + decodedText + ) : isJwt ? ( + + ) : ( + accessToken + )} + + + + ); } diff --git a/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.test.ts b/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.test.ts index 4aced4da0..d6a89da6c 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.test.ts +++ b/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.test.ts @@ -11,7 +11,7 @@ describe("oauthDetailsFromConnectionState", () => { grantedScope: "read write", tokens: { access_token: "tok", token_type: "Bearer" }, client: { - source: "preregistered", + registrationKind: "static", clientId: "client-1", hasClientSecret: true, }, @@ -27,6 +27,7 @@ describe("oauthDetailsFromConnectionState", () => { protocol: "standard", authorized: true, clientId: "client-1", + clientRegistrationKind: "static", authUrl: "https://auth.example.com/authorize", scopes: ["read", "write"], accessToken: "tok", diff --git a/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.ts b/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.ts index f6de192ae..14c8847e6 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.ts +++ b/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.ts @@ -12,6 +12,9 @@ export function oauthDetailsFromConnectionState( protocol: state.protocol, authorized: state.authorized, ...(state.client?.clientId && { clientId: state.client.clientId }), + ...(state.client?.registrationKind && { + clientRegistrationKind: state.client.registrationKind, + }), ...(state.authorizationServerMetadata?.authorization_endpoint && { authUrl: state.authorizationServerMetadata.authorization_endpoint, }), diff --git a/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.stories.tsx b/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.stories.tsx index 73b7ae24d..4c90c0ab5 100644 --- a/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.stories.tsx +++ b/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.stories.tsx @@ -83,6 +83,7 @@ export const WithOAuth: Story = { scopes: ["read", "write"], accessToken: "eyJhbGciOiJSUzI1NiIs...truncated", }, + onClearOAuth: fn(), }, }; diff --git a/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.tsx b/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.tsx index e8b7ddd50..12324984b 100644 --- a/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.tsx +++ b/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.tsx @@ -16,6 +16,7 @@ export interface ConnectionInfoModalProps { clientCapabilities: ClientCapabilities; transport: ServerType; oauth?: OAuthDetails; + onClearOAuth?: () => void; } export function ConnectionInfoModal({ @@ -25,6 +26,7 @@ export function ConnectionInfoModal({ clientCapabilities, transport, oauth, + onClearOAuth, }: ConnectionInfoModalProps) { return ( diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx index a4d21f77d..33810238d 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx @@ -793,6 +793,23 @@ describe("ServerSettingsForm", () => { expect(arg.scopes).toBe("read"); }); + it("calls onClearStoredOAuth from the OAuth section", async () => { + const user = userEvent.setup(); + const onClearStoredOAuth = vi.fn(); + renderWithMantine( + , + ); + await user.click( + screen.getByRole("button", { name: "Clear stored OAuth state" }), + ); + expect(onClearStoredOAuth).toHaveBeenCalledTimes(1); + }); + it("clears a metadata value via its Clear button (onMetadataChange with empty value)", async () => { const user = userEvent.setup(); const onMetadataChange = vi.fn(); diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index 0b9817da5..fe228aa13 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -3,6 +3,7 @@ import { ActionIcon, Button, Checkbox, + Flex, Group, NumberInput, Stack, @@ -56,6 +57,7 @@ export interface ServerSettingsFormProps { onAutoRefreshChange: (value: boolean) => void; onMaxFetchRequestsChange: (value: number) => void; onOAuthChange: (oauth: OAuthSettings) => void; + onClearStoredOAuth?: () => void; onAddRoot: () => void; onRemoveRoot: (index: number) => void; onRootChange: (index: number, uri: string, name: string) => void; @@ -82,6 +84,20 @@ const EmptyHint = Text.withProps({ fs: "italic", }); +const ClearStoredOAuthButton = Button.withProps({ + variant: "light", + color: "red", + size: "compact-sm", + flex: "0 0 auto", +}); + +const ClearStoredOAuthHint = Text.withProps({ + size: "sm", + c: "dimmed", + flex: 1, + miw: "12rem", +}); + function KeyValueRows({ items, onChange, @@ -198,6 +214,7 @@ export function ServerSettingsForm({ onAutoRefreshChange, onMaxFetchRequestsChange, onOAuthChange, + onClearStoredOAuth, onAddRoot, onRemoveRoot, onRootChange, @@ -488,6 +505,17 @@ export function ServerSettingsForm({ ) : null } /> + {onClearStoredOAuth ? ( + + + Clear stored OAuth state + + + Removes stored tokens and client registration for this + server. Disconnects if this server is currently connected. + + + ) : null} diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx index 481e912d5..80f98fdac 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx @@ -43,6 +43,7 @@ export interface ServerSettingsModalProps { isStdio: boolean; onClose: () => void; onSettingsChange: (settings: InspectorServerSettings) => void; + onClearStoredOAuth?: () => void; } export function ServerSettingsModal({ @@ -52,6 +53,7 @@ export function ServerSettingsModal({ isStdio, onClose, onSettingsChange, + onClearStoredOAuth, }: ServerSettingsModalProps) { const sections = allSectionsFor(serverType, isStdio); // Initial expansion is the first ("options") section — where Network Log @@ -222,6 +224,7 @@ export function ServerSettingsModal({ onAutoRefreshChange={handleAutoRefreshChange} onMaxFetchRequestsChange={handleMaxFetchRequestsChange} onOAuthChange={handleOAuthChange} + onClearStoredOAuth={onClearStoredOAuth} onAddRoot={handleAddRoot} onRemoveRoot={handleRemoveRoot} onRootChange={handleRootChange} diff --git a/clients/web/src/test/core/auth/cimd.test.ts b/clients/web/src/test/core/auth/cimd.test.ts index 90eec6f99..98c5b5135 100644 --- a/clients/web/src/test/core/auth/cimd.test.ts +++ b/clients/web/src/test/core/auth/cimd.test.ts @@ -7,18 +7,14 @@ const SERVER_URL = "http://127.0.0.1:9999/mcp"; const METADATA_URL = "http://127.0.0.1:8888/client-metadata.json"; function createProvider(storage: OAuthStorage): BaseOAuthClientProvider { - return new BaseOAuthClientProvider( - SERVER_URL, - { - storage, - redirectUrlProvider: { - getRedirectUrl: () => "http://127.0.0.1:3000/oauth/callback", - }, - navigation: { navigateToAuthorization: vi.fn() }, - clientMetadataUrl: METADATA_URL, + return new BaseOAuthClientProvider(SERVER_URL, { + storage, + redirectUrlProvider: { + getRedirectUrl: () => "http://127.0.0.1:3000/oauth/callback", }, - "quick", - ); + navigation: { navigateToAuthorization: vi.fn() }, + clientMetadataUrl: METADATA_URL, + }); } describe("ensureCimdClientRegistration", () => { @@ -62,9 +58,13 @@ describe("ensureCimdClientRegistration", () => { fetchFn, }); - expect(storage.saveClientInformation).toHaveBeenCalledWith(SERVER_URL, { - client_id: METADATA_URL, - }); + expect(storage.saveClientInformation).toHaveBeenCalledWith( + SERVER_URL, + { + client_id: METADATA_URL, + }, + { registrationKind: "cimd" }, + ); }); it("no-ops when client information is already stored", async () => { diff --git a/clients/web/src/test/core/auth/connection-state.test.ts b/clients/web/src/test/core/auth/connection-state.test.ts index 064aa2599..62716e476 100644 --- a/clients/web/src/test/core/auth/connection-state.test.ts +++ b/clients/web/src/test/core/auth/connection-state.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import { buildOAuthConnectionState, + hasPersistedOAuthServerState, isServerOAuthConfigured, protocolFromOAuthConfig, } from "@inspector/core/auth/connection-state.js"; @@ -13,6 +14,7 @@ function createStorage( tokens: Awaited>; preregistered: Awaited>; dynamic: Awaited>; + registrationKind: ReturnType; scope: string | undefined; serverMetadata: ReturnType; idpSession: Awaited>; @@ -32,6 +34,7 @@ function createStorage( return overrides.serverMetadata ?? null; }), getIdpSession: vi.fn().mockResolvedValue(overrides.idpSession), + getClientRegistrationKind: vi.fn(() => overrides.registrationKind), saveClientInformation: vi.fn(), savePreregisteredClientInformation: vi.fn(), saveTokens: vi.fn(), @@ -59,6 +62,14 @@ describe("isServerOAuthConfigured", () => { it("returns false when all oauth fields are empty", () => { expect(isServerOAuthConfigured({})).toBe(false); }); + + it("returns true when clientMetadataUrl is set", () => { + expect( + isServerOAuthConfigured({ + clientMetadataUrl: "https://example.com/oauth/client.json", + }), + ).toBe(true); + }); }); describe("protocolFromOAuthConfig", () => { @@ -67,6 +78,24 @@ describe("protocolFromOAuthConfig", () => { }); }); +describe("hasPersistedOAuthServerState", () => { + it("returns true when dynamic client information is stored", async () => { + const storage = createStorage({ + dynamic: { client_id: "https://example.com/cimd.json" }, + }); + await expect( + hasPersistedOAuthServerState(storage, SERVER_URL), + ).resolves.toBe(true); + }); + + it("returns false when storage is empty", async () => { + const storage = createStorage(); + await expect( + hasPersistedOAuthServerState(storage, SERVER_URL), + ).resolves.toBe(false); + }); +}); + describe("buildOAuthConnectionState", () => { it("returns authorized standard state from storage tokens", async () => { const storage = createStorage({ @@ -91,7 +120,7 @@ describe("buildOAuthConnectionState", () => { expect(state.authorized).toBe(true); expect(state.protocol).toBe("standard"); expect(state.client).toEqual({ - source: "preregistered", + registrationKind: "static", clientId: "cfg-client", hasClientSecret: false, }); @@ -101,6 +130,55 @@ describe("buildOAuthConnectionState", () => { ); }); + it("returns dcr registration kind for dynamic client slot", async () => { + const storage = createStorage({ + dynamic: { client_id: "dcr-uuid" }, + registrationKind: "dcr", + }); + const state = await buildOAuthConnectionState({ + serverUrl: SERVER_URL, + protocol: "standard", + storage, + }); + expect(state.client).toEqual({ + registrationKind: "dcr", + clientId: "dcr-uuid", + hasClientSecret: false, + }); + }); + + it("returns cimd registration kind for dynamic client slot", async () => { + const storage = createStorage({ + dynamic: { client_id: "https://example.com/cimd.json" }, + registrationKind: "cimd", + }); + const state = await buildOAuthConnectionState({ + serverUrl: SERVER_URL, + protocol: "standard", + storage, + }); + expect(state.client).toEqual({ + registrationKind: "cimd", + clientId: "https://example.com/cimd.json", + hasClientSecret: false, + }); + }); + + it("prefers static registration when preregistered and dynamic slots coexist", async () => { + const storage = createStorage({ + preregistered: { client_id: "static-id" }, + dynamic: { client_id: "https://example.com/cimd.json" }, + registrationKind: "cimd", + }); + const state = await buildOAuthConnectionState({ + serverUrl: SERVER_URL, + protocol: "standard", + storage, + }); + expect(state.client?.registrationKind).toBe("static"); + expect(state.client?.clientId).toBe("static-id"); + }); + it("returns unauthorized when tokens are missing", async () => { const storage = createStorage(); const state = await buildOAuthConnectionState({ diff --git a/clients/web/src/test/core/auth/providers.test.ts b/clients/web/src/test/core/auth/providers.test.ts index 1aa53a869..c43d97202 100644 --- a/clients/web/src/test/core/auth/providers.test.ts +++ b/clients/web/src/test/core/auth/providers.test.ts @@ -85,7 +85,9 @@ describe("OAuthNavigation", () => { // observe the still-current document (location.href not yet reassigned) // so a keepalive request it fires outlives the navigation. const order: string[] = []; - const authUrl = new URL("http://example.com/authorize?state=normal:abc"); + const authUrl = new URL( + `http://example.com/authorize?state=${"a".repeat(64)}`, + ); const navigation = new BrowserNavigation(undefined, (url) => { order.push("before"); // At hook time the redirect has not happened yet. diff --git a/clients/web/src/test/core/auth/runner-oauth-callback.test.ts b/clients/web/src/test/core/auth/runner-oauth-callback.test.ts new file mode 100644 index 000000000..2a17e3c52 --- /dev/null +++ b/clients/web/src/test/core/auth/runner-oauth-callback.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { + DEFAULT_RUNNER_OAUTH_CALLBACK_URL, + RUNNER_OAUTH_CALLBACK_DEFAULT_PORT, + formatRunnerOAuthRedirectUrl, + parseRunnerOAuthCallbackUrl, +} from "@inspector/core/auth/node/runner-oauth-callback.js"; + +describe("runner OAuth callback URL", () => { + const envKey = "MCP_OAUTH_CALLBACK_URL"; + + afterEach(() => { + delete process.env[envKey]; + }); + + it("defaults to 127.0.0.1:6276", () => { + expect(DEFAULT_RUNNER_OAUTH_CALLBACK_URL).toBe( + "http://127.0.0.1:6276/oauth/callback", + ); + expect(parseRunnerOAuthCallbackUrl()).toEqual({ + hostname: "127.0.0.1", + port: RUNNER_OAUTH_CALLBACK_DEFAULT_PORT, + pathname: "/oauth/callback", + }); + }); + + it("prefers CLI flag over env", () => { + process.env[envKey] = "http://127.0.0.1:9999/oauth/callback"; + expect( + parseRunnerOAuthCallbackUrl("http://127.0.0.1:3000/oauth/callback"), + ).toEqual({ + hostname: "127.0.0.1", + port: 3000, + pathname: "/oauth/callback", + }); + }); + + it("uses MCP_OAUTH_CALLBACK_URL when CLI flag absent", () => { + process.env[envKey] = "http://127.0.0.1:8888/custom/callback"; + expect(parseRunnerOAuthCallbackUrl()).toEqual({ + hostname: "127.0.0.1", + port: 8888, + pathname: "/custom/callback", + }); + }); + + it("allows port 0 for ephemeral listener", () => { + expect( + parseRunnerOAuthCallbackUrl("http://127.0.0.1:0/oauth/callback"), + ).toEqual({ + hostname: "127.0.0.1", + port: 0, + pathname: "/oauth/callback", + }); + }); + + it("formatRunnerOAuthRedirectUrl round-trips default config", () => { + const config = parseRunnerOAuthCallbackUrl(); + expect(formatRunnerOAuthRedirectUrl(config)).toBe( + DEFAULT_RUNNER_OAUTH_CALLBACK_URL, + ); + }); +}); diff --git a/clients/web/src/test/core/auth/storage-browser.test.ts b/clients/web/src/test/core/auth/storage-browser.test.ts index c0aef3840..f009b4bd4 100644 --- a/clients/web/src/test/core/auth/storage-browser.test.ts +++ b/clients/web/src/test/core/auth/storage-browser.test.ts @@ -66,7 +66,9 @@ describe("BrowserOAuthStorage", () => { client_secret: "test-secret", }; - storage.saveClientInformation(testServerUrl, clientInfo); + storage.saveClientInformation(testServerUrl, clientInfo, { + registrationKind: "dcr", + }); const result = await storage.getClientInformation(testServerUrl); expect(result).toEqual(clientInfo); @@ -100,7 +102,9 @@ describe("BrowserOAuthStorage", () => { client_id: "test-client-id", }; - storage.saveClientInformation(testServerUrl, clientInfo); + storage.saveClientInformation(testServerUrl, clientInfo, { + registrationKind: "dcr", + }); const result = await storage.getClientInformation(testServerUrl); expect(result).toEqual(clientInfo); @@ -115,11 +119,23 @@ describe("BrowserOAuthStorage", () => { client_id: "second-id", }; - storage.saveClientInformation(testServerUrl, firstInfo); - storage.saveClientInformation(testServerUrl, secondInfo); + storage.saveClientInformation(testServerUrl, firstInfo, { + registrationKind: "dcr", + }); + storage.saveClientInformation(testServerUrl, secondInfo, { + registrationKind: "cimd", + }); const result = await storage.getClientInformation(testServerUrl); expect(result).toEqual(secondInfo); + expect(storage.getClientRegistrationKind(testServerUrl)).toBe("cimd"); + }); + + it("savePreregisteredClientInformation sets static registration kind", async () => { + await storage.savePreregisteredClientInformation(testServerUrl, { + client_id: "static-id", + }); + expect(storage.getClientRegistrationKind(testServerUrl)).toBe("static"); }); }); @@ -267,7 +283,11 @@ describe("BrowserOAuthStorage", () => { describe("clearClientInformation", () => { it("removes the dynamically-registered client info by default", async () => { - storage.saveClientInformation(testServerUrl, { client_id: "dyn" }); + storage.saveClientInformation( + testServerUrl, + { client_id: "dyn" }, + { registrationKind: "dcr" }, + ); expect(await storage.getClientInformation(testServerUrl)).toEqual({ client_id: "dyn", }); @@ -338,7 +358,9 @@ describe("BrowserOAuthStorage", () => { token_type: "Bearer", }; - storage.saveClientInformation(testServerUrl, clientInfo); + storage.saveClientInformation(testServerUrl, clientInfo, { + registrationKind: "dcr", + }); storage.saveTokens(testServerUrl, tokens); storage.clear(testServerUrl); @@ -353,8 +375,12 @@ describe("BrowserOAuthStorage", () => { client_id: "test-client-id", }; - storage.saveClientInformation(testServerUrl, clientInfo); - storage.saveClientInformation(otherServerUrl, clientInfo); + storage.saveClientInformation(testServerUrl, clientInfo, { + registrationKind: "dcr", + }); + storage.saveClientInformation(otherServerUrl, clientInfo, { + registrationKind: "dcr", + }); storage.clear(testServerUrl); @@ -378,8 +404,12 @@ describe("BrowserOAuthStorage", () => { client_id: "client-2", }; - storage.saveClientInformation(server1Url, clientInfo1); - storage.saveClientInformation(server2Url, clientInfo2); + storage.saveClientInformation(server1Url, clientInfo1, { + registrationKind: "dcr", + }); + storage.saveClientInformation(server2Url, clientInfo2, { + registrationKind: "dcr", + }); expect(await storage.getClientInformation(server1Url)).toEqual( clientInfo1, diff --git a/clients/web/src/test/core/auth/storage-remote.test.ts b/clients/web/src/test/core/auth/storage-remote.test.ts index b9075e14d..c20617efc 100644 --- a/clients/web/src/test/core/auth/storage-remote.test.ts +++ b/clients/web/src/test/core/auth/storage-remote.test.ts @@ -25,7 +25,11 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => { }); it("saveClientInformation + getClientInformation round-trip", async () => { - await storage.saveClientInformation(serverUrl, { client_id: "dyn" }); + await storage.saveClientInformation( + serverUrl, + { client_id: "dyn" }, + { registrationKind: "dcr" }, + ); expect(await storage.getClientInformation(serverUrl)).toEqual({ client_id: "dyn", }); @@ -41,7 +45,11 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => { }); it("clearClientInformation default branch removes dynamic info", async () => { - await storage.saveClientInformation(serverUrl, { client_id: "dyn" }); + await storage.saveClientInformation( + serverUrl, + { client_id: "dyn" }, + { registrationKind: "dcr" }, + ); storage.clearClientInformation(serverUrl); expect(await storage.getClientInformation(serverUrl)).toBeUndefined(); }); @@ -90,7 +98,11 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => { }); it("clear() wipes all state for a server", async () => { - await storage.saveClientInformation(serverUrl, { client_id: "x" }); + await storage.saveClientInformation( + serverUrl, + { client_id: "x" }, + { registrationKind: "dcr" }, + ); await storage.saveTokens(serverUrl, { access_token: "t", token_type: "Bearer", diff --git a/clients/web/src/test/core/auth/utils.test.ts b/clients/web/src/test/core/auth/utils.test.ts index 45e7592db..712b95ee6 100644 --- a/clients/web/src/test/core/auth/utils.test.ts +++ b/clients/web/src/test/core/auth/utils.test.ts @@ -3,207 +3,93 @@ import { parseHttpUrl, parseOAuthCallbackParams, generateOAuthState, - generateOAuthStateWithExecution, - generateOAuthStateWithMode, parseOAuthState, generateOAuthErrorDescription, } from "@inspector/core/auth/utils.js"; -describe("auth utils", () => { - describe("parseOAuthCallbackParams", () => { - it("should parse successful callback with code", () => { - const params = parseOAuthCallbackParams("?code=abc123"); - expect(params).toEqual({ successful: true, code: "abc123" }); - }); - - it("should parse error callback", () => { - const params = parseOAuthCallbackParams( - "?error=access_denied&error_description=User%20denied", - ); - expect(params).toEqual({ - successful: false, - error: "access_denied", - error_description: "User denied", - error_uri: null, - }); - }); - - it("should return invalid_request when code and error are missing", () => { - const params = parseOAuthCallbackParams("?foo=bar"); - expect(params).toEqual({ - successful: false, - error: "invalid_request", - error_description: "Missing code or error in response", - error_uri: null, - }); - }); +describe("parseHttpUrl", () => { + it("parses valid URLs", () => { + expect(parseHttpUrl("https://example.com/path", "test").href).toBe( + "https://example.com/path", + ); }); - describe("generateOAuthState", () => { - it("should generate 64-char hex string", () => { - const state = generateOAuthState(); - expect(state).toMatch(/^[0-9a-f]{64}$/); - }); - - it("should generate unique states", () => { - const s1 = generateOAuthState(); - const s2 = generateOAuthState(); - expect(s1).not.toBe(s2); - }); + it("throws on invalid URLs", () => { + expect(() => parseHttpUrl("not-a-url", "test")).toThrow(/Invalid test/); }); +}); - describe("generateOAuthStateWithExecution", () => { - it("should generate state with quick prefix", () => { - const state = generateOAuthStateWithExecution("quick"); - expect(state.startsWith("quick:")).toBe(true); - expect(state.slice(6)).toMatch(/^[0-9a-f]{64}$/); - }); - - it("should generate state with guided prefix", () => { - const state = generateOAuthStateWithExecution("guided"); - expect(state.startsWith("guided:")).toBe(true); - expect(state.slice(7)).toMatch(/^[0-9a-f]{64}$/); - }); - - it("should generate unique states", () => { - const s1 = generateOAuthStateWithExecution("quick"); - const s2 = generateOAuthStateWithExecution("quick"); - expect(s1).not.toBe(s2); - }); - - it("generateOAuthStateWithMode alias matches quick execution", () => { - const state = generateOAuthStateWithMode("quick"); - expect(state.startsWith("quick:")).toBe(true); +describe("parseOAuthCallbackParams", () => { + it("parses successful callback", () => { + expect(parseOAuthCallbackParams("?code=abc123")).toEqual({ + successful: true, + code: "abc123", }); }); - describe("parseOAuthState", () => { - it("should parse quick prefix", () => { - const parsed = parseOAuthState("quick:abc123def456"); - expect(parsed).toEqual({ execution: "quick", authId: "abc123def456" }); - }); - - it("should parse guided prefix", () => { - const parsed = parseOAuthState("guided:a1b2c3d4e5f6"); - expect(parsed).toEqual({ execution: "guided", authId: "a1b2c3d4e5f6" }); - }); - - it("should map legacy normal prefix to quick", () => { - const parsed = parseOAuthState("normal:abc123def456"); - expect(parsed).toEqual({ execution: "quick", authId: "abc123def456" }); - }); - - it("should map legacy ema-idp prefix to quick", () => { - const parsed = parseOAuthState("ema-idp:abc123def456"); - expect(parsed).toEqual({ execution: "quick", authId: "abc123def456" }); - }); - - it("should parse legacy 64-char hex as quick", () => { - const hex = "a".repeat(64); - const parsed = parseOAuthState(hex); - expect(parsed).toEqual({ execution: "quick", authId: hex }); - }); - - it("should return null for invalid state", () => { - expect(parseOAuthState("")).toBeNull(); - expect(parseOAuthState("invalid")).toBeNull(); - expect(parseOAuthState("other:xyz")).toBeNull(); + it("parses error callback", () => { + expect( + parseOAuthCallbackParams( + "?error=access_denied&error_description=User%20denied", + ), + ).toEqual({ + successful: false, + error: "access_denied", + error_description: "User denied", + error_uri: null, }); }); - describe("generateOAuthErrorDescription", () => { - it("should generate error description with error code only", () => { - const params = { - successful: false as const, - error: "access_denied", - error_description: null, - error_uri: null, - }; - - const description = generateOAuthErrorDescription(params); - - expect(description).toBe("Error: access_denied."); - }); - - it("should generate error description with error code and description", () => { - const params = { - successful: false as const, - error: "invalid_request", - error_description: "The request is missing a required parameter", - error_uri: null, - }; - - const description = generateOAuthErrorDescription(params); - - expect(description).toContain("Error: invalid_request."); - expect(description).toContain( - "Details: The request is missing a required parameter.", - ); - }); - - it("should generate error description with all fields", () => { - const params = { - successful: false as const, - error: "server_error", - error_description: "An internal server error occurred", - error_uri: "https://example.com/errors/server_error", - }; - - const description = generateOAuthErrorDescription(params); - - expect(description).toContain("Error: server_error."); - expect(description).toContain( - "Details: An internal server error occurred.", - ); - expect(description).toContain( - "More info: https://example.com/errors/server_error.", - ); - }); - - it("should handle null error_description", () => { - const params = { - successful: false as const, - error: "access_denied", - error_description: null, - error_uri: "https://example.com/error", - }; - - const description = generateOAuthErrorDescription(params); - - expect(description).toContain("Error: access_denied."); - expect(description).not.toContain("Details:"); - expect(description).toContain("More info: https://example.com/error."); + it("returns invalid_request when code and error are missing", () => { + expect(parseOAuthCallbackParams("?foo=bar")).toEqual({ + successful: false, + error: "invalid_request", + error_description: "Missing code or error in response", + error_uri: null, }); + }); +}); - it("should handle null error_uri", () => { - const params = { - successful: false as const, - error: "invalid_client", - error_description: "Invalid client credentials", - error_uri: null, - }; +describe("generateOAuthState", () => { + it("should generate 64-char hex state", () => { + const state = generateOAuthState(); + expect(state).toMatch(/^[a-f0-9]{64}$/i); + }); - const description = generateOAuthErrorDescription(params); + it("should generate unique states", () => { + const s1 = generateOAuthState(); + const s2 = generateOAuthState(); + expect(s1).not.toBe(s2); + }); +}); - expect(description).toContain("Error: invalid_client."); - expect(description).toContain("Details: Invalid client credentials."); - expect(description).not.toContain("More info:"); - }); +describe("parseOAuthState", () => { + it("should parse 64-char hex authId", () => { + const hex = "a".repeat(64); + const parsed = parseOAuthState(hex); + expect(parsed).toEqual({ authId: hex }); }); - describe("parseHttpUrl", () => { - it("parses a valid absolute URL", () => { - expect(parseHttpUrl("https://idp.example.com", "test").href).toBe( - "https://idp.example.com/", - ); - }); + it("should return null for invalid state", () => { + expect(parseOAuthState("")).toBeNull(); + expect(parseOAuthState("invalid")).toBeNull(); + expect(parseOAuthState("abc123")).toBeNull(); + expect(parseOAuthState("other:xyz")).toBeNull(); + }); +}); - it("throws with label and value when URL is invalid", () => { - expect(() => - parseHttpUrl("https;//idp.xaa.dev", "EMA IdP issuer (Client Settings)"), - ).toThrow( - 'Invalid EMA IdP issuer (Client Settings): "https;//idp.xaa.dev"', - ); - }); +describe("generateOAuthErrorDescription", () => { + it("formats error with description and uri", () => { + const message = generateOAuthErrorDescription({ + successful: false, + error: "access_denied", + error_description: "User denied access", + error_uri: "https://example.com/errors/access_denied", + }); + expect(message).toContain("Error: access_denied."); + expect(message).toContain("Details: User denied access."); + expect(message).toContain( + "More info: https://example.com/errors/access_denied.", + ); }); }); diff --git a/clients/web/src/test/core/client/config.test.ts b/clients/web/src/test/core/client/config.test.ts index 5e5da2fd1..8ad3801a2 100644 --- a/clients/web/src/test/core/client/config.test.ts +++ b/clients/web/src/test/core/client/config.test.ts @@ -19,7 +19,9 @@ import { } from "@inspector/core/client/config.js"; import { formatClientConfigLoadError } from "@inspector/core/client/config-parse.js"; import { + getActiveCimdClientMetadataUrl, getActiveEnterpriseManagedAuthIdp, + isCimdEnabled, isEnterpriseManagedAuthEnabled, } from "@inspector/core/client/types.js"; @@ -87,6 +89,80 @@ describe("client config", () => { ).toThrow(); }); + it("parseClientConfig accepts cimd clientMetadataUrl", () => { + const config = parseClientConfig({ + cimd: { + enabled: true, + clientMetadataUrl: "https://example.com/oauth/client.json", + }, + }); + expect(config.cimd?.clientMetadataUrl).toBe( + "https://example.com/oauth/client.json", + ); + expect(isCimdEnabled(config)).toBe(true); + expect(getActiveCimdClientMetadataUrl(config)).toBe( + "https://example.com/oauth/client.json", + ); + }); + + it("parseClientConfig accepts enabled: false with stored CIMD URL", () => { + const config = parseClientConfig({ + cimd: { + enabled: false, + clientMetadataUrl: "https://example.com/oauth/client.json", + }, + }); + expect(config.cimd?.enabled).toBe(false); + expect(isCimdEnabled(config)).toBe(false); + expect(getActiveCimdClientMetadataUrl(config)).toBeUndefined(); + }); + + it("parseClientConfig accepts disabled CIMD with empty URL", () => { + const config = parseClientConfig({ + cimd: { + enabled: false, + clientMetadataUrl: "", + }, + }); + expect(config.cimd).toEqual({ + enabled: false, + clientMetadataUrl: "", + }); + }); + + it("parseClientConfig rejects enabled CIMD with empty URL", () => { + expect(() => + parseClientConfig({ + cimd: { + enabled: true, + clientMetadataUrl: "", + }, + }), + ).toThrow(/required when CIMD is enabled/); + }); + + it("parseClientConfig rejects non-HTTPS CIMD URL", () => { + expect(() => + parseClientConfig({ + cimd: { + enabled: true, + clientMetadataUrl: "http://example.com/oauth/client.json", + }, + }), + ).toThrow(/HTTPS/); + }); + + it("parseClientConfig rejects CIMD URL without path", () => { + expect(() => + parseClientConfig({ + cimd: { + enabled: true, + clientMetadataUrl: "https://example.com/", + }, + }), + ).toThrow(/path/); + }); + it("loadClientConfig returns {} when file is absent", async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "client-config-")); const filePath = path.join(tmpDir, "client.json"); diff --git a/clients/web/src/test/core/client/runner.test.ts b/clients/web/src/test/core/client/runner.test.ts new file mode 100644 index 000000000..87b3d62df --- /dev/null +++ b/clients/web/src/test/core/client/runner.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { + buildRunnerClientAuthOptions, + isOAuthCapableServerConfig, +} from "@inspector/core/client/runner.js"; +import type { ClientConfig } from "@inspector/core/client/types.js"; +import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; + +describe("runner client auth options", () => { + it("isOAuthCapableServerConfig accepts sse and streamable-http only", () => { + expect(isOAuthCapableServerConfig({ type: "sse" })).toBe(true); + expect(isOAuthCapableServerConfig({ type: "streamable-http" })).toBe(true); + expect(isOAuthCapableServerConfig({ type: "stdio" })).toBe(false); + expect(isOAuthCapableServerConfig(null)).toBe(false); + }); + + it("buildRunnerClientAuthOptions wires EMA IdP from client.json", () => { + const clientConfig: ClientConfig = { + enterpriseManagedAuth: { + enabled: true, + idp: { + issuer: "https://idp.example.com", + clientId: "cid", + clientSecret: "secret", + }, + }, + }; + const opts = buildRunnerClientAuthOptions(clientConfig); + expect(opts.enterpriseManagedAuth?.idp.issuer).toBe( + "https://idp.example.com", + ); + expect(opts.installEnterpriseManagedAuth).toEqual( + clientConfig.enterpriseManagedAuth, + ); + }); + + it("buildRunnerClientAuthOptions prefers CLI CIMD over client.json", () => { + const clientConfig: ClientConfig = { + cimd: { + enabled: true, + clientMetadataUrl: "https://example.com/from-config.json", + }, + }; + const opts = buildRunnerClientAuthOptions(clientConfig, undefined, { + clientMetadataUrl: "https://example.com/from-cli.json", + }); + expect(opts.oauth?.clientMetadataUrl).toBe( + "https://example.com/from-cli.json", + ); + }); + + it("buildRunnerClientAuthOptions uses client.json CIMD when CLI flag absent", () => { + const clientConfig: ClientConfig = { + cimd: { + enabled: true, + clientMetadataUrl: "https://example.com/from-config.json", + }, + }; + const opts = buildRunnerClientAuthOptions(clientConfig); + expect(opts.oauth?.clientMetadataUrl).toBe( + "https://example.com/from-config.json", + ); + }); + + it("buildRunnerClientAuthOptions includes enterpriseManaged from server settings", () => { + const settings: InspectorServerSettings = { + enterpriseManaged: true, + oauthClientId: "resource-client", + requestTimeout: 0, + connectionTimeout: 0, + taskTtl: 60000, + maxFetchRequests: 10, + autoRefreshOnListChanged: false, + metadata: [], + headers: [], + env: [], + roots: [], + }; + const opts = buildRunnerClientAuthOptions({}, settings); + expect(opts.oauth?.enterpriseManaged).toBe(true); + expect(opts.oauth?.clientId).toBe("resource-client"); + }); +}); diff --git a/clients/web/src/test/core/mcp/config.test.ts b/clients/web/src/test/core/mcp/config.test.ts index a375fc90c..60d6af657 100644 --- a/clients/web/src/test/core/mcp/config.test.ts +++ b/clients/web/src/test/core/mcp/config.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { + getOAuthServerUrl, getServerType, isOAuthCapableServerType, } from "@inspector/core/mcp/config.js"; @@ -19,3 +20,23 @@ describe("isOAuthCapableServerType", () => { expect(isOAuthCapableServerType(type)).toBe(false); }); }); + +describe("getOAuthServerUrl", () => { + it("returns the MCP URL for HTTP transports", () => { + expect( + getOAuthServerUrl({ + type: "streamable-http", + url: "https://mcp.example.com/mcp", + }), + ).toBe("https://mcp.example.com/mcp"); + expect( + getOAuthServerUrl({ type: "sse", url: "https://mcp.example.com/sse" }), + ).toBe("https://mcp.example.com/sse"); + }); + + it("returns undefined for stdio", () => { + expect( + getOAuthServerUrl({ type: "stdio", command: "node", args: [] }), + ).toBeUndefined(); + }); +}); diff --git a/clients/web/src/test/core/mcp/node/servers.test.ts b/clients/web/src/test/core/mcp/node/servers.test.ts index dd87d539f..d0e3751d8 100644 --- a/clients/web/src/test/core/mcp/node/servers.test.ts +++ b/clients/web/src/test/core/mcp/node/servers.test.ts @@ -8,6 +8,10 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { + InMemorySecretStore, + SECRET_FIELD_OAUTH_CLIENT_SECRET, +} from "@inspector/core/auth/node/secret-store"; import { headersToServerSettings, loadServerEntries, @@ -42,7 +46,7 @@ describe("loadServerEntries", () => { rmSync(tempDir, { recursive: true, force: true }); }); - it("lifts disk headers, timeouts, and OAuth into per-server settings", () => { + it("lifts disk headers, timeouts, and OAuth into per-server settings", async () => { const configPath = join(tempDir, "mcp.json"); writeFileSync( configPath, @@ -64,7 +68,7 @@ describe("loadServerEntries", () => { }), ); - const servers = loadServerEntries({ configPath }); + const servers = await loadServerEntries({ configPath }); const settings = servers.web?.settings; expect(settings?.headers).toEqual([ { key: "Authorization", value: "Bearer disk" }, @@ -76,7 +80,43 @@ describe("loadServerEntries", () => { expect(settings?.oauthScopes).toEqual(["a", "b"]); }); - it("merges --header over disk headers while preserving disk timeouts", () => { + it("rehydrates OAuth client secrets from the keychain when stripped on disk", async () => { + const configPath = join(tempDir, "mcp.json"); + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + Todo0: { + type: "streamable-http", + url: "https://mcp.xaa.dev/mcp", + oauth: { + clientId: "client_febcc047cef20866-at-todo0-mcp", + scopes: "todos.read mcp.access", + enterpriseManaged: true, + }, + }, + }, + }), + ); + + const secretStore = new InMemorySecretStore(); + await secretStore.set( + "Todo0", + SECRET_FIELD_OAUTH_CLIENT_SECRET, + "resource-as-secret-from-keychain", + ); + + const servers = await loadServerEntries({ configPath, secretStore }); + expect(servers.Todo0?.settings?.oauthClientId).toBe( + "client_febcc047cef20866-at-todo0-mcp", + ); + expect(servers.Todo0?.settings?.oauthClientSecret).toBe( + "resource-as-secret-from-keychain", + ); + expect(servers.Todo0?.settings?.enterpriseManaged).toBe(true); + }); + + it("merges --header over disk headers while preserving disk timeouts", async () => { const configPath = join(tempDir, "mcp.json"); writeFileSync( configPath, @@ -92,7 +132,7 @@ describe("loadServerEntries", () => { }), ); - const servers = loadServerEntries({ + const servers = await loadServerEntries({ configPath, headers: { Authorization: "Bearer cli" }, }); @@ -103,7 +143,7 @@ describe("loadServerEntries", () => { expect(servers.web?.settings?.requestTimeout).toBe(9000); }); - it("merges --header into a server that has no disk settings", () => { + it("merges --header into a server that has no disk settings", async () => { const configPath = join(tempDir, "mcp.json"); writeFileSync( configPath, @@ -114,7 +154,7 @@ describe("loadServerEntries", () => { }), ); - const servers = loadServerEntries({ + const servers = await loadServerEntries({ configPath, headers: { Authorization: "Bearer cli" }, }); @@ -123,7 +163,7 @@ describe("loadServerEntries", () => { ]); }); - it("applies env and cwd overrides to stdio configs only", () => { + it("applies env and cwd overrides to stdio configs only", async () => { const configPath = join(tempDir, "mcp.json"); writeFileSync( configPath, @@ -135,7 +175,7 @@ describe("loadServerEntries", () => { }), ); - const servers = loadServerEntries({ + const servers = await loadServerEntries({ configPath, env: { B: "2" }, cwd: "/tmp/work", @@ -153,9 +193,9 @@ describe("loadServerEntries", () => { }); }); - it("seeds an empty writable catalog when --catalog is missing", () => { + it("seeds an empty writable catalog when --catalog is missing", async () => { const catalogPath = join(tempDir, "catalog.json"); - const servers = loadServerEntries({ catalogPath }); + const servers = await loadServerEntries({ catalogPath }); expect(servers).toEqual({}); expect(existsSync(catalogPath)).toBe(true); expect(JSON.parse(readFileSync(catalogPath, "utf-8"))).toEqual({ @@ -163,34 +203,34 @@ describe("loadServerEntries", () => { }); }); - it("throws when a read-only --config file is missing (never seeds)", () => { + it("throws when a read-only --config file is missing (never seeds)", async () => { const configPath = join(tempDir, "absent.json"); - expect(() => loadServerEntries({ configPath })).toThrow( + await expect(loadServerEntries({ configPath })).rejects.toThrow( /Config file not found/, ); expect(existsSync(configPath)).toBe(false); }); - it("rejects --catalog and --config together", () => { + it("rejects --catalog and --config together", async () => { const catalogPath = join(tempDir, "catalog.json"); const configPath = join(tempDir, "config.json"); writeFileSync(catalogPath, JSON.stringify({ mcpServers: {} })); writeFileSync(configPath, JSON.stringify({ mcpServers: {} })); - expect(() => loadServerEntries({ catalogPath, configPath })).toThrow( - /mutually exclusive/, - ); + await expect( + loadServerEntries({ catalogPath, configPath }), + ).rejects.toThrow(/mutually exclusive/); }); - it("rejects --catalog combined with an ad-hoc target", () => { + it("rejects --catalog combined with an ad-hoc target", async () => { const catalogPath = join(tempDir, "catalog.json"); writeFileSync(catalogPath, JSON.stringify({ mcpServers: {} })); - expect(() => + await expect( loadServerEntries({ catalogPath, target: ["my-server"] }), - ).toThrow(/--catalog cannot be combined/); + ).rejects.toThrow(/--catalog cannot be combined/); }); - it("builds a single ad-hoc server from a positional target", () => { - const servers = loadServerEntries({ + it("builds a single ad-hoc server from a positional target", async () => { + const servers = await loadServerEntries({ target: ["my-server", "--flag"], headers: { Authorization: "Bearer t" }, }); diff --git a/clients/web/src/test/core/mcp/oauthManager.test.ts b/clients/web/src/test/core/mcp/oauthManager.test.ts index 5e7389661..996af3a9a 100644 --- a/clients/web/src/test/core/mcp/oauthManager.test.ts +++ b/clients/web/src/test/core/mcp/oauthManager.test.ts @@ -1,7 +1,7 @@ /** * OAuthManager unit tests. Uses mocked getServerUrl, fetch, storage, and * dispatch callbacks to verify config merge, callback invocation, clearOAuthTokens, - * error propagation, and getOAuthFlowState/getOAuthFlowStep after beginGuidedAuth. + * error propagation, and getOAuthFlowState/getOAuthFlowStep. */ import { describe, it, expect, vi } from "vitest"; import { @@ -20,7 +20,6 @@ const SERVER_URL = "https://example.com/mcp"; function createMockParams( overrides?: Partial, ): OAuthManagerParams { - const dispatchOAuthStepChange = vi.fn(); const dispatchOAuthComplete = vi.fn(); const dispatchOAuthAuthorizationRequired = vi.fn(); const dispatchOAuthError = vi.fn(); @@ -28,6 +27,7 @@ function createMockParams( const storage = { getScope: vi.fn().mockReturnValue(undefined), getClientInformation: vi.fn().mockResolvedValue(undefined), + getClientRegistrationKind: vi.fn().mockReturnValue(undefined), saveClientInformation: vi.fn().mockResolvedValue(undefined), savePreregisteredClientInformation: vi.fn().mockResolvedValue(undefined), saveScope: vi.fn().mockResolvedValue(undefined), @@ -70,7 +70,6 @@ function createMockParams( effectiveAuthFetch: vi.fn().mockResolvedValue(new Response("{}")), getEventTarget: vi.fn().mockReturnValue(new EventTarget()), initialConfig, - dispatchOAuthStepChange, dispatchOAuthComplete, dispatchOAuthAuthorizationRequired, dispatchOAuthError, @@ -176,10 +175,9 @@ describe("OAuthManager", () => { }); describe("dispatch callbacks", () => { - it("completeOAuthFlow calls dispatchOAuthError when normal path throws", async () => { + it("completeOAuthFlow calls dispatchOAuthError when auth() throws", async () => { const params = createMockParams(); const manager = new OAuthManager(params); - // Normal path (no guided state): auth() will run and fail (no real server), so catch calls dispatchOAuthError await expect(manager.completeOAuthFlow("bad-code")).rejects.toThrow(); expect(params.dispatchOAuthError).toHaveBeenCalledWith( expect.objectContaining({ @@ -246,26 +244,6 @@ describe("OAuthManager", () => { }); }); - describe("setGuidedAuthorizationCode", () => { - it("throws when not in guided flow", async () => { - const params = createMockParams(); - const manager = new OAuthManager(params); - await expect( - manager.setGuidedAuthorizationCode("code", true), - ).rejects.toThrow("Not in guided OAuth flow"); - }); - }); - - describe("proceedOAuthStep", () => { - it("throws when not in guided flow", async () => { - const params = createMockParams(); - const manager = new OAuthManager(params); - await expect(manager.proceedOAuthStep()).rejects.toThrow( - "Not in guided OAuth flow", - ); - }); - }); - describe("enterprise-managed auth", () => { function createEmaManager( overrides?: Partial, diff --git a/clients/web/src/test/integration/auth/node/oauth-callback-server.test.ts b/clients/web/src/test/integration/auth/node/oauth-callback-server.test.ts index 0e7459faf..f0096a580 100644 --- a/clients/web/src/test/integration/auth/node/oauth-callback-server.test.ts +++ b/clients/web/src/test/integration/auth/node/oauth-callback-server.test.ts @@ -78,12 +78,12 @@ describe("OAuthCallbackServer", () => { expect(received.state).toBeUndefined(); }); - it("GET /oauth/callback/guided returns 404 (single path only)", async () => { + it("GET /oauth/callback/extra returns 404 (single path only)", async () => { server = createOAuthCallbackServer(); const result = await server.start({ port: 0 }); const res = await fetch( - `http://localhost:${result.port}/oauth/callback/guided?code=guided-code`, + `http://localhost:${result.port}/oauth/callback/extra?code=test-code`, ); expect(res.status).toBe(404); diff --git a/clients/web/src/test/integration/auth/node/storage.test.ts b/clients/web/src/test/integration/auth/node/storage.test.ts index 38c2ff178..7e59c597e 100644 --- a/clients/web/src/test/integration/auth/node/storage.test.ts +++ b/clients/web/src/test/integration/auth/node/storage.test.ts @@ -72,7 +72,9 @@ describe("NodeOAuthStorage", () => { client_secret: "test-secret", }; - await storage.saveClientInformation(testServerUrl, clientInfo); + await storage.saveClientInformation(testServerUrl, clientInfo, { + registrationKind: "dcr", + }); const result = await storage.getClientInformation(testServerUrl); expect(result).toBeDefined(); @@ -106,7 +108,9 @@ describe("NodeOAuthStorage", () => { client_id: "test-client-id", }; - await storage.saveClientInformation(testServerUrl, clientInfo); + await storage.saveClientInformation(testServerUrl, clientInfo, { + registrationKind: "dcr", + }); const result = await storage.getClientInformation(testServerUrl); expect(result).toBeDefined(); @@ -122,8 +126,12 @@ describe("NodeOAuthStorage", () => { client_id: "second-id", }; - storage.saveClientInformation(testServerUrl, firstInfo); - storage.saveClientInformation(testServerUrl, secondInfo); + storage.saveClientInformation(testServerUrl, firstInfo, { + registrationKind: "dcr", + }); + storage.saveClientInformation(testServerUrl, secondInfo, { + registrationKind: "dcr", + }); const result = await storage.getClientInformation(testServerUrl); expect(result).toBeDefined(); @@ -292,9 +300,13 @@ describe("NodeOAuthStorage", () => { describe("clearClientInformation", () => { it("removes the dynamically-registered client information by default", async () => { - await storage.saveClientInformation(testServerUrl, { - client_id: "dyn", - }); + await storage.saveClientInformation( + testServerUrl, + { + client_id: "dyn", + }, + { registrationKind: "dcr" }, + ); expect(await storage.getClientInformation(testServerUrl)).toEqual({ client_id: "dyn", }); @@ -365,7 +377,9 @@ describe("NodeOAuthStorage", () => { token_type: "Bearer", }; - await storage.saveClientInformation(testServerUrl, clientInfo); + await storage.saveClientInformation(testServerUrl, clientInfo, { + registrationKind: "dcr", + }); await storage.saveTokens(testServerUrl, tokens); storage.clear(testServerUrl); @@ -380,8 +394,12 @@ describe("NodeOAuthStorage", () => { client_id: "test-client-id", }; - await storage.saveClientInformation(testServerUrl, clientInfo); - await storage.saveClientInformation(otherServerUrl, clientInfo); + await storage.saveClientInformation(testServerUrl, clientInfo, { + registrationKind: "dcr", + }); + await storage.saveClientInformation(otherServerUrl, clientInfo, { + registrationKind: "dcr", + }); storage.clear(testServerUrl); @@ -406,8 +424,12 @@ describe("NodeOAuthStorage", () => { client_id: "client-2", }; - storage.saveClientInformation(server1Url, clientInfo1); - storage.saveClientInformation(server2Url, clientInfo2); + storage.saveClientInformation(server1Url, clientInfo1, { + registrationKind: "dcr", + }); + storage.saveClientInformation(server2Url, clientInfo2, { + registrationKind: "dcr", + }); const result1 = await storage.getClientInformation(server1Url); const result2 = await storage.getClientInformation(server2Url); diff --git a/clients/web/src/test/integration/auth/state-machine.test.ts b/clients/web/src/test/integration/auth/state-machine.test.ts deleted file mode 100644 index 357771324..000000000 --- a/clients/web/src/test/integration/auth/state-machine.test.ts +++ /dev/null @@ -1,441 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { - OAuthStateMachine, - oauthTransitions, -} from "@inspector/core/auth/state-machine.js"; -import type { OAuthFlowState, OAuthStep } from "@inspector/core/auth/types.js"; -import { EMPTY_OAUTH_FLOW_STATE } from "@inspector/core/auth/types.js"; -import type { BaseOAuthClientProvider } from "@inspector/core/auth/providers.js"; -import type { - OAuthMetadata, - OAuthProtectedResourceMetadata, -} from "@modelcontextprotocol/sdk/shared/auth.js"; - -// Mock SDK functions -vi.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ - discoverAuthorizationServerMetadata: vi.fn(), - discoverOAuthProtectedResourceMetadata: vi.fn(), - registerClient: vi.fn(), - startAuthorization: vi.fn(), - exchangeAuthorization: vi.fn(), - selectResourceURL: vi.fn(), -})); - -describe("OAuthStateMachine", () => { - let mockProvider: BaseOAuthClientProvider; - let updateState: (updates: Partial) => void; - let state: OAuthFlowState; - - beforeEach(() => { - state = { ...EMPTY_OAUTH_FLOW_STATE }; - updateState = vi.fn((updates: Partial) => { - state = { ...state, ...updates }; - }); - - mockProvider = { - serverUrl: "http://localhost:3000", - redirectUrl: "http://localhost:3000/callback", - scope: "read write", - clientMetadata: { - redirect_uris: ["http://localhost:3000/callback"], - token_endpoint_auth_method: "none", - grant_types: ["authorization_code"], - response_types: ["code"], - client_name: "Test Client", - scope: "read write", - }, - clientInformation: vi.fn(), - saveClientInformation: vi.fn(), - tokens: vi.fn(), - saveTokens: vi.fn(), - codeVerifier: vi.fn(() => "test-code-verifier"), - clear: vi.fn(), - state: vi.fn(() => "test-state"), - getServerMetadata: vi.fn(() => null), - saveServerMetadata: vi.fn(), - } as unknown as BaseOAuthClientProvider; - }); - - describe("oauthTransitions", () => { - it("should have transitions for all OAuth steps", () => { - const steps: OAuthStep[] = [ - "metadata_discovery", - "client_registration", - "authorization_redirect", - "authorization_code", - "token_request", - "complete", - ]; - - steps.forEach((step) => { - expect(oauthTransitions[step]).toBeDefined(); - expect(oauthTransitions[step].canTransition).toBeDefined(); - expect(oauthTransitions[step].execute).toBeDefined(); - }); - }); - }); - - describe("OAuthStateMachine", () => { - it("should create state machine instance", () => { - const stateMachine = new OAuthStateMachine( - "http://localhost:3000", - mockProvider, - updateState, - ); - - expect(stateMachine).toBeDefined(); - }); - - it("should update state when executeStep is called", async () => { - const stateMachine = new OAuthStateMachine( - "http://localhost:3000", - mockProvider, - updateState, - ); - - const { discoverAuthorizationServerMetadata } = - await import("@modelcontextprotocol/sdk/client/auth.js"); - vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({ - issuer: "http://localhost:3000", - authorization_endpoint: "http://localhost:3000/authorize", - token_endpoint: "http://localhost:3000/token", - response_types_supported: ["code"], - } as OAuthMetadata); - - await stateMachine.executeStep(state); - - expect(updateState).toHaveBeenCalled(); - }); - }); - - describe("Resource metadata discovery and selection", () => { - const serverUrl = "http://localhost:3000"; - const resourceMetadata = { - resource: "http://localhost:3000", - authorization_servers: ["http://localhost:3000"], - scopes_supported: ["read", "write"], - }; - - beforeEach(async () => { - const { - discoverAuthorizationServerMetadata, - discoverOAuthProtectedResourceMetadata, - selectResourceURL, - } = await import("@modelcontextprotocol/sdk/client/auth.js"); - vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({ - issuer: "http://localhost:3000", - authorization_endpoint: "http://localhost:3000/authorize", - token_endpoint: "http://localhost:3000/token", - response_types_supported: ["code"], - } as OAuthMetadata); - vi.mocked(discoverOAuthProtectedResourceMetadata).mockReset(); - vi.mocked(selectResourceURL).mockReset(); - }); - - it("should discover resource metadata from well-known and use first authorization server", async () => { - const selectedResource = new URL("http://localhost:3000"); - const { discoverOAuthProtectedResourceMetadata, selectResourceURL } = - await import("@modelcontextprotocol/sdk/client/auth.js"); - vi.mocked(discoverOAuthProtectedResourceMetadata).mockResolvedValue( - resourceMetadata as OAuthProtectedResourceMetadata, - ); - vi.mocked(selectResourceURL).mockResolvedValue(selectedResource); - - const stateMachine = new OAuthStateMachine( - serverUrl, - mockProvider, - updateState, - ); - await stateMachine.executeStep(state); - - expect(discoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( - serverUrl, - ); - expect(selectResourceURL).toHaveBeenCalledWith( - serverUrl, - mockProvider, - resourceMetadata, - ); - expect(updateState).toHaveBeenCalledWith( - expect.objectContaining({ - resourceMetadata, - resource: selectedResource, - resourceMetadataError: null, - authServerUrl: new URL("http://localhost:3000"), - oauthStep: "client_registration", - }), - ); - }); - - it("should use authorization_servers URL from resource metadata for auth server discovery", async () => { - const authServerUrl = "https://auth-server.com/"; - const resourceMetaDifferentAuth: OAuthProtectedResourceMetadata = { - resource: serverUrl, - authorization_servers: [authServerUrl], - scopes_supported: ["read", "write"], - }; - const selectedResource = new URL(serverUrl); - const { - discoverOAuthProtectedResourceMetadata, - discoverAuthorizationServerMetadata, - selectResourceURL, - } = await import("@modelcontextprotocol/sdk/client/auth.js"); - vi.mocked(discoverOAuthProtectedResourceMetadata).mockResolvedValue( - resourceMetaDifferentAuth, - ); - vi.mocked(selectResourceURL).mockResolvedValue(selectedResource); - - const stateMachine = new OAuthStateMachine( - serverUrl, - mockProvider, - updateState, - ); - await stateMachine.executeStep(state); - - expect(discoverAuthorizationServerMetadata).toHaveBeenCalledWith( - new URL(authServerUrl), - expect.any(Object), - ); - expect(updateState).toHaveBeenCalledWith( - expect.objectContaining({ - resourceMetadata: resourceMetaDifferentAuth, - authServerUrl: new URL(authServerUrl), - oauthStep: "client_registration", - }), - ); - }); - - it("should call selectResourceURL only when resource metadata is present", async () => { - const { discoverOAuthProtectedResourceMetadata, selectResourceURL } = - await import("@modelcontextprotocol/sdk/client/auth.js"); - vi.mocked(discoverOAuthProtectedResourceMetadata).mockRejectedValue( - new Error( - "Resource server does not implement OAuth 2.0 Protected Resource Metadata.", - ), - ); - - const stateMachine = new OAuthStateMachine( - serverUrl, - mockProvider, - updateState, - ); - await stateMachine.executeStep(state); - - expect(selectResourceURL).not.toHaveBeenCalled(); - expect(updateState).toHaveBeenCalledWith( - expect.objectContaining({ - resourceMetadata: null, - resourceMetadataError: expect.any(Error), - oauthStep: "client_registration", - }), - ); - }); - - it("should use default auth server URL when discovery fails", async () => { - const { - discoverOAuthProtectedResourceMetadata, - discoverAuthorizationServerMetadata, - } = await import("@modelcontextprotocol/sdk/client/auth.js"); - vi.mocked(discoverOAuthProtectedResourceMetadata).mockRejectedValue( - new Error("Discovery failed"), - ); - vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({ - issuer: "http://localhost:3000", - authorization_endpoint: "http://localhost:3000/authorize", - token_endpoint: "http://localhost:3000/token", - response_types_supported: ["code"], - } as OAuthMetadata); - - const stateMachine = new OAuthStateMachine( - serverUrl, - mockProvider, - updateState, - ); - await stateMachine.executeStep(state); - - expect(discoverAuthorizationServerMetadata).toHaveBeenCalledWith( - new URL("/", serverUrl), - {}, // No fetchFn when not provided (conditional spread omits it) - ); - expect(updateState).toHaveBeenCalledWith( - expect.objectContaining({ - authServerUrl: new URL("/", serverUrl), - }), - ); - }); - - it("should use default auth server when metadata has empty authorization_servers", async () => { - const { discoverOAuthProtectedResourceMetadata, selectResourceURL } = - await import("@modelcontextprotocol/sdk/client/auth.js"); - const metaNoServers = { - ...resourceMetadata, - authorization_servers: [] as string[], - }; - vi.mocked(discoverOAuthProtectedResourceMetadata).mockResolvedValue( - metaNoServers as OAuthProtectedResourceMetadata, - ); - vi.mocked(selectResourceURL).mockResolvedValue( - new URL("http://localhost:3000"), - ); - - const stateMachine = new OAuthStateMachine( - serverUrl, - mockProvider, - updateState, - ); - await stateMachine.executeStep(state); - - expect(selectResourceURL).toHaveBeenCalledWith( - serverUrl, - mockProvider, - metaNoServers, - ); - expect(updateState).toHaveBeenCalledWith( - expect.objectContaining({ - resourceMetadata: metaNoServers, - authServerUrl: new URL("/", serverUrl), - oauthStep: "client_registration", - }), - ); - }); - - it("should pass fetchFn to registerClient when provided", async () => { - const { registerClient } = - await import("@modelcontextprotocol/sdk/client/auth.js"); - const mockFetchFn = vi.fn(); - vi.mocked(registerClient).mockResolvedValue({ - redirect_uris: ["http://localhost/callback"], - client_id: "registered-client-id", - }); - - const stateMachine = new OAuthStateMachine( - serverUrl, - mockProvider, - updateState, - mockFetchFn, - ); - await stateMachine.executeStep(state); - expect(state.oauthStep).toBe("client_registration"); - - await stateMachine.executeStep(state); - - expect(registerClient).toHaveBeenCalledWith( - serverUrl, - expect.objectContaining({ - fetchFn: mockFetchFn, - }), - ); - }); - - it("should pass fetchFn to exchangeAuthorization when provided", async () => { - const { exchangeAuthorization } = - await import("@modelcontextprotocol/sdk/client/auth.js"); - const mockFetchFn = vi.fn(); - const metadata = { - issuer: "http://localhost:3000", - authorization_endpoint: "http://localhost:3000/authorize", - token_endpoint: "http://localhost:3000/token", - response_types_supported: ["code"], - }; - vi.mocked(exchangeAuthorization).mockResolvedValue({ - access_token: "test-token", - token_type: "Bearer", - }); - - const providerWithMetadata = { - ...mockProvider, - getServerMetadata: vi.fn(() => metadata), - } as unknown as BaseOAuthClientProvider; - - const tokenRequestState: OAuthFlowState = { - ...EMPTY_OAUTH_FLOW_STATE, - oauthStep: "token_request", - oauthMetadata: metadata as OAuthMetadata, - oauthClientInfo: { client_id: "test-client" }, - authorizationCode: "test-code", - }; - - const stateMachine = new OAuthStateMachine( - serverUrl, - providerWithMetadata, - updateState, - mockFetchFn, - ); - await stateMachine.executeStep(tokenRequestState); - - expect(exchangeAuthorization).toHaveBeenCalledWith( - serverUrl, - expect.objectContaining({ - fetchFn: mockFetchFn, - }), - ); - }); - - it("token_request execute throws when client information cannot be obtained", async () => { - const metadata = { - issuer: "http://localhost:3000", - authorization_endpoint: "http://localhost:3000/authorize", - token_endpoint: "http://localhost:3000/token", - response_types_supported: ["code"], - }; - const providerNoClient = { - ...mockProvider, - getServerMetadata: vi.fn(() => metadata), - clientInformation: vi.fn(async () => undefined), - } as unknown as BaseOAuthClientProvider; - - const tokenState: OAuthFlowState = { - ...EMPTY_OAUTH_FLOW_STATE, - oauthStep: "token_request", - oauthMetadata: metadata as OAuthMetadata, - authorizationCode: "code-without-client", - }; - - await expect( - oauthTransitions.token_request.execute({ - state: tokenState, - serverUrl: "http://localhost:3000", - provider: providerNoClient, - updateState, - }), - ).rejects.toThrow("Client information not available for token exchange"); - }); - - it("complete.canTransition always returns false (terminal state)", async () => { - const result = await oauthTransitions.complete.canTransition({ - state: { ...EMPTY_OAUTH_FLOW_STATE, oauthStep: "complete" }, - serverUrl: "http://localhost:3000", - provider: mockProvider, - updateState, - }); - expect(result).toBe(false); - // execute is a no-op - await expect( - oauthTransitions.complete.execute({ - state: { ...EMPTY_OAUTH_FLOW_STATE, oauthStep: "complete" }, - serverUrl: "http://localhost:3000", - provider: mockProvider, - updateState, - }), - ).resolves.toBeUndefined(); - }); - - it("executeStep throws when the current step cannot transition", async () => { - // metadata_discovery.canTransition is unconditional (returns true), but - // token_request requires authorizationCode + metadata + clientInfo; an - // empty state will refuse to transition. - const stateMachine = new OAuthStateMachine( - "http://localhost:3000", - mockProvider, - updateState, - ); - const blockedState: OAuthFlowState = { - ...EMPTY_OAUTH_FLOW_STATE, - oauthStep: "token_request", - }; - await expect(stateMachine.executeStep(blockedState)).rejects.toThrow( - /Cannot transition from token_request/, - ); - }); - }); -}); diff --git a/clients/web/src/test/integration/mcp/ema-mock-servers.ts b/clients/web/src/test/integration/mcp/ema-mock-servers.ts index 4e40b41df..45ea16d0f 100644 --- a/clients/web/src/test/integration/mcp/ema-mock-servers.ts +++ b/clients/web/src/test/integration/mcp/ema-mock-servers.ts @@ -1,7 +1,7 @@ /** * Mock IdP and resource authorization servers for EMA integration tests. * Topology mirrors xaa.dev staging (separate IdP + resource AS) — see - * test-servers/configs/xaa-ema-http.json and specification/v2_enterprise_managed_auth.md. + * test-servers/configs/xaa-ema-http.json and specification/v2_auth_ema.md. */ import { diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts index c65d9180a..b2ed94b01 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts @@ -170,88 +170,11 @@ describe("InspectorClient OAuth E2E", () => { clientConfig, ); - const authUrl = await client.runGuidedAuth(); - if (!authUrl) throw new Error("Expected authorization URL"); - expect(authUrl.href).toContain("/oauth/authorize"); - - const authCode = await completeOAuthAuthorization(authUrl); - await client.completeOAuthFlow(authCode); - await client.connect(); - - // Verify tokens are stored - const tokens = await client.getOAuthTokens(); - expect(tokens).toBeDefined(); - expect(tokens?.access_token).toBeDefined(); - expect(tokens?.token_type).toBe("Bearer"); - - // Connection should now be successful - expect(client.getStatus()).toBe("connected"); - }); - - it("should complete OAuth flow with static client using authenticate() (normal mode)", async () => { - const staticClientId = "test-static-client-normal"; - const staticClientSecret = "test-static-secret-normal"; - - const serverConfig = { - ...getDefaultServerConfig(), - serverType: transport.serverType, - ...createOAuthTestServerConfig({ - requireAuth: true, - supportDCR: true, // Needed for authenticate() to work - staticClients: [ - { - clientId: staticClientId, - clientSecret: staticClientSecret, - redirectUris: [testRedirectUrl], - }, - ], - }), - }; - - server = new TestServerHttp(serverConfig); - const port = await server.start(); - const serverUrl = `http://localhost:${port}`; - await waitForOAuthWellKnown(serverUrl); - 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: oauthConfig.navigation, - redirectUrlProvider: oauthConfig.redirectUrlProvider, - }, - }, - oauth: { - clientId: oauthConfig.clientId, - clientSecret: oauthConfig.clientSecret, - clientMetadataUrl: oauthConfig.clientMetadataUrl, - scope: oauthConfig.scope, - }, - }; - - client = new InspectorClient( - { - type: transport.clientType, - url: `${serverUrl}${transport.endpoint}`, - } as MCPServerConfig, - clientConfig, - ); - - // Use authenticate() (normal mode) - should use SDK's auth() const authUrl = await client.authenticate(); if (!authUrl) throw new Error("Expected authorization URL"); expect(authUrl.href).toContain("/oauth/authorize"); const stateAfterAuth = client.getOAuthFlowState(); - expect(stateAfterAuth?.execution).toBe("quick"); expect(stateAfterAuth?.oauthStep).toBe("authorization_code"); expect(stateAfterAuth?.authorizationUrl?.href).toBe(authUrl.href); expect(stateAfterAuth?.oauthClientInfo).toBeDefined(); @@ -262,16 +185,17 @@ describe("InspectorClient OAuth E2E", () => { await client.connect(); const stateAfterComplete = client.getOAuthFlowState(); - expect(stateAfterComplete?.execution).toBe("quick"); expect(stateAfterComplete?.oauthStep).toBe("complete"); expect(stateAfterComplete?.oauthTokens).toBeDefined(); expect(stateAfterComplete?.completedAt).toBeDefined(); - expect(typeof stateAfterComplete?.completedAt).toBe("number"); + // Verify tokens are stored const tokens = await client.getOAuthTokens(); expect(tokens).toBeDefined(); expect(tokens?.access_token).toBeDefined(); expect(tokens?.token_type).toBe("Bearer"); + + // Connection should now be successful expect(client.getStatus()).toBe("connected"); }); @@ -425,7 +349,7 @@ describe("InspectorClient OAuth E2E", () => { clientConfig, ); - // Quick auth with CIMD pre-registration (supports http:// test metadata URLs) + // CIMD pre-registration via authenticate() (supports http:// test metadata URLs) const authUrl = await client.authenticate(); if (!authUrl) throw new Error("Expected authorization URL"); expect(authUrl.href).toContain("/oauth/authorize"); @@ -502,602 +426,31 @@ describe("InspectorClient OAuth E2E", () => { url: `${serverUrl}${transport.endpoint}`, } as MCPServerConfig, clientConfig, - ); - - const authUrl = await client.authenticate(); - if (!authUrl) throw new Error("Expected authorization URL"); - const authCode = await completeOAuthAuthorization(authUrl); - await client.completeOAuthFlow(authCode); - await client.connect(); - - expect(client.getStatus()).toBe("connected"); - const toolsResult = await client.listTools(); - expect(toolsResult).toBeDefined(); - }); - }, - ); - - describe.each(transports)( - "DCR (Dynamic Client Registration) Mode ($name)", - (transport) => { - it("should register client and complete OAuth flow", async () => { - const serverConfig = { - ...getDefaultServerConfig(), - serverType: transport.serverType, - ...createOAuthTestServerConfig({ - requireAuth: true, - supportDCR: true, - }), - }; - - server = new TestServerHttp(serverConfig); - const port = await server.start(); - const serverUrl = `http://localhost:${port}`; - await waitForOAuthWellKnown(serverUrl); - await waitForOAuthWellKnown(serverUrl); - - const oauthConfig = createTestOAuthConfig({ - mode: "dcr", - redirectUrl: testRedirectUrl, - }); - const clientConfig: InspectorClientOptions = { - environment: { - transport: createTransportNode, - oauth: { - storage: oauthConfig.storage, - navigation: oauthConfig.navigation, - redirectUrlProvider: oauthConfig.redirectUrlProvider, - }, - }, - oauth: { - clientId: oauthConfig.clientId, - clientSecret: oauthConfig.clientSecret, - clientMetadataUrl: oauthConfig.clientMetadataUrl, - scope: oauthConfig.scope, - }, - }; - - client = new InspectorClient( - { - type: transport.clientType, - url: `${serverUrl}${transport.endpoint}`, - } as MCPServerConfig, - clientConfig, - ); - - const authUrl = await client.authenticate(); - if (!authUrl) throw new Error("Expected authorization URL"); - expect(authUrl.href).toContain("/oauth/authorize"); - const authCode = await completeOAuthAuthorization(authUrl); - await client.completeOAuthFlow(authCode); - await client.connect(); - - const tokens = await client.getOAuthTokens(); - expect(tokens).toBeDefined(); - expect(tokens?.access_token).toBeDefined(); - expect(client.getStatus()).toBe("connected"); - }); - - it("should register client and complete OAuth flow using authenticate() (normal mode)", async () => { - const serverConfig = { - ...getDefaultServerConfig(), - serverType: transport.serverType, - ...createOAuthTestServerConfig({ - requireAuth: true, - supportDCR: true, - }), - }; - - server = new TestServerHttp(serverConfig); - const port = await server.start(); - const serverUrl = `http://localhost:${port}`; - await waitForOAuthWellKnown(serverUrl); - await waitForOAuthWellKnown(serverUrl); - - const oauthConfig = createTestOAuthConfig({ - mode: "dcr", - redirectUrl: testRedirectUrl, - }); - const clientConfig: InspectorClientOptions = { - environment: { - transport: createTransportNode, - oauth: { - storage: oauthConfig.storage, - navigation: oauthConfig.navigation, - redirectUrlProvider: oauthConfig.redirectUrlProvider, - }, - }, - oauth: { - clientId: oauthConfig.clientId, - clientSecret: oauthConfig.clientSecret, - clientMetadataUrl: oauthConfig.clientMetadataUrl, - scope: oauthConfig.scope, - }, - }; - - client = new InspectorClient( - { - type: transport.clientType, - url: `${serverUrl}${transport.endpoint}`, - } as MCPServerConfig, - clientConfig, - ); - - // Use authenticate() (normal mode) - should trigger DCR via SDK's auth() - const authUrl = await client.authenticate(); - if (!authUrl) throw new Error("Expected authorization URL"); - expect(authUrl.href).toContain("/oauth/authorize"); - - const stateAfterAuth = client.getOAuthFlowState(); - expect(stateAfterAuth?.execution).toBe("quick"); - expect(stateAfterAuth?.oauthStep).toBe("authorization_code"); - expect(stateAfterAuth?.oauthClientInfo).toBeDefined(); - expect(stateAfterAuth?.oauthClientInfo?.client_id).toBeDefined(); - - const authCode = await completeOAuthAuthorization(authUrl); - await client.completeOAuthFlow(authCode); - await client.connect(); - - const stateAfterComplete = client.getOAuthFlowState(); - expect(stateAfterComplete?.execution).toBe("quick"); - expect(stateAfterComplete?.oauthStep).toBe("complete"); - expect(stateAfterComplete?.oauthTokens).toBeDefined(); - expect(stateAfterComplete?.completedAt).toBeDefined(); - - const tokens = await client.getOAuthTokens(); - expect(tokens).toBeDefined(); - expect(tokens?.access_token).toBeDefined(); - expect(client.getStatus()).toBe("connected"); - }); - - it("should register client and complete OAuth flow using runGuidedAuth() (automated guided mode)", async () => { - const serverConfig = { - ...getDefaultServerConfig(), - serverType: transport.serverType, - ...createOAuthTestServerConfig({ - requireAuth: true, - supportDCR: true, - }), - }; - - server = new TestServerHttp(serverConfig); - const port = await server.start(); - const serverUrl = `http://localhost:${port}`; - await waitForOAuthWellKnown(serverUrl); - await waitForOAuthWellKnown(serverUrl); - - const oauthConfig = createTestOAuthConfig({ - mode: "dcr", - redirectUrl: testRedirectUrl, - }); - const clientConfig: InspectorClientOptions = { - environment: { - transport: createTransportNode, - oauth: { - storage: oauthConfig.storage, - navigation: oauthConfig.navigation, - redirectUrlProvider: oauthConfig.redirectUrlProvider, - }, - }, - oauth: { - clientId: oauthConfig.clientId, - clientSecret: oauthConfig.clientSecret, - clientMetadataUrl: oauthConfig.clientMetadataUrl, - scope: oauthConfig.scope, - }, - }; - - client = new InspectorClient( - { - type: transport.clientType, - url: `${serverUrl}${transport.endpoint}`, - } as MCPServerConfig, - clientConfig, - ); - - const authUrl = await client.runGuidedAuth(); - if (!authUrl) throw new Error("Expected authorization URL"); - expect(authUrl.href).toContain("/oauth/authorize"); - - const authCode = await completeOAuthAuthorization(authUrl); - await client.completeOAuthFlow(authCode); - await client.connect(); - - const stateAfterComplete = client.getOAuthFlowState(); - expect(stateAfterComplete?.execution).toBe("guided"); - expect(stateAfterComplete?.oauthStep).toBe("complete"); - expect(stateAfterComplete?.completedAt).toBeDefined(); - - const tokens = await client.getOAuthTokens(); - expect(tokens).toBeDefined(); - expect(tokens?.access_token).toBeDefined(); - expect(client.getStatus()).toBe("connected"); - }); - - it("should complete OAuth flow using manual guided mode (beginGuidedAuth + proceedOAuthStep)", async () => { - const staticClientId = "test-static-manual"; - const staticClientSecret = "test-static-secret-manual"; - - const serverConfig = { - ...getDefaultServerConfig(), - serverType: transport.serverType, - ...createOAuthTestServerConfig({ - requireAuth: true, - staticClients: [ - { - clientId: staticClientId, - clientSecret: staticClientSecret, - redirectUris: [testRedirectUrl], - }, - ], - }), - }; - - server = new TestServerHttp(serverConfig); - const port = await server.start(); - const serverUrl = `http://localhost:${port}`; - await waitForOAuthWellKnown(serverUrl); - 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: oauthConfig.navigation, - redirectUrlProvider: oauthConfig.redirectUrlProvider, - }, - }, - oauth: { - clientId: oauthConfig.clientId, - clientSecret: oauthConfig.clientSecret, - clientMetadataUrl: oauthConfig.clientMetadataUrl, - scope: oauthConfig.scope, - }, - }; - - client = new InspectorClient( - { - type: transport.clientType, - url: `${serverUrl}${transport.endpoint}`, - } as MCPServerConfig, - clientConfig, - ); - - await client.beginGuidedAuth(); - - while (true) { - const state = client.getOAuthFlowState(); - if ( - state?.oauthStep === "authorization_code" || - state?.oauthStep === "complete" - ) { - break; - } - await client.proceedOAuthStep(); - } - - const state = client.getOAuthFlowState(); - const authUrl = state?.authorizationUrl; - if (!authUrl) throw new Error("Expected authorizationUrl"); - expect(authUrl.href).toContain("/oauth/authorize"); - - const authCode = await completeOAuthAuthorization(authUrl); - await client.completeOAuthFlow(authCode); - await client.connect(); - - const stateAfterComplete = client.getOAuthFlowState(); - expect(stateAfterComplete?.execution).toBe("guided"); - expect(stateAfterComplete?.oauthStep).toBe("complete"); - - const tokens = await client.getOAuthTokens(); - expect(tokens).toBeDefined(); - expect(tokens?.access_token).toBeDefined(); - expect(client.getStatus()).toBe("connected"); - }); - - it("should set authorization code without completing flow (completeFlow=false)", async () => { - const staticClientId = "test-static-set-code-false"; - const staticClientSecret = "test-static-secret-set-code-false"; - - const serverConfig = { - ...getDefaultServerConfig(), - serverType: transport.serverType, - ...createOAuthTestServerConfig({ - requireAuth: true, - staticClients: [ - { - clientId: staticClientId, - clientSecret: staticClientSecret, - redirectUris: [testRedirectUrl], - }, - ], - }), - }; - - server = new TestServerHttp(serverConfig); - const port = await server.start(); - const serverUrl = `http://localhost:${port}`; - await waitForOAuthWellKnown(serverUrl); - 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: oauthConfig.navigation, - redirectUrlProvider: oauthConfig.redirectUrlProvider, - }, - }, - oauth: { - clientId: oauthConfig.clientId, - clientSecret: oauthConfig.clientSecret, - clientMetadataUrl: oauthConfig.clientMetadataUrl, - scope: oauthConfig.scope, - }, - }; - - client = new InspectorClient( - { - type: transport.clientType, - url: `${serverUrl}${transport.endpoint}`, - } as MCPServerConfig, - clientConfig, - ); - - // Start guided auth and progress to authorization_code step - await client.beginGuidedAuth(); - while (true) { - const state = client.getOAuthFlowState(); - if (state?.oauthStep === "authorization_code") { - break; - } - await client.proceedOAuthStep(); - } - - const stateBefore = client.getOAuthFlowState(); - expect(stateBefore?.oauthStep).toBe("authorization_code"); - expect(stateBefore?.authorizationCode).toBe(""); - - const authUrl = stateBefore?.authorizationUrl; - if (!authUrl) throw new Error("Expected authorizationUrl"); - const authCode = await completeOAuthAuthorization(authUrl); - - // Set code without completing flow - const stepEvents: Array<{ step: string; previousStep: string }> = []; - client.addEventListener("oauthStepChange", (event) => { - stepEvents.push({ - step: event.detail.step, - previousStep: event.detail.previousStep, - }); - }); - - await client.setGuidedAuthorizationCode(authCode, false); - - // Verify code was set but flow didn't complete - const stateAfter = client.getOAuthFlowState(); - expect(stateAfter?.oauthStep).toBe("authorization_code"); - expect(stateAfter?.authorizationCode).toBe(authCode); - expect(stateAfter?.oauthTokens).toBeFalsy(); - - // Should have dispatched one event (code set, but step unchanged) - expect(stepEvents.length).toBe(1); - expect(stepEvents[0]?.step).toBe("authorization_code"); - expect(stepEvents[0]?.previousStep).toBe("authorization_code"); - - // Now manually proceed to complete - await client.proceedOAuthStep(); // authorization_code -> token_request - await client.proceedOAuthStep(); // token_request -> complete - - const finalState = client.getOAuthFlowState(); - expect(finalState?.oauthStep).toBe("complete"); - expect(finalState?.oauthTokens).toBeDefined(); - }); - - it("should set authorization code and complete flow (completeFlow=true)", async () => { - const staticClientId = "test-static-set-code-true"; - const staticClientSecret = "test-static-secret-set-code-true"; - - const serverConfig = { - ...getDefaultServerConfig(), - serverType: transport.serverType, - ...createOAuthTestServerConfig({ - requireAuth: true, - staticClients: [ - { - clientId: staticClientId, - clientSecret: staticClientSecret, - redirectUris: [testRedirectUrl], - }, - ], - }), - }; - - server = new TestServerHttp(serverConfig); - const port = await server.start(); - const serverUrl = `http://localhost:${port}`; - await waitForOAuthWellKnown(serverUrl); - 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: oauthConfig.navigation, - redirectUrlProvider: oauthConfig.redirectUrlProvider, - }, - }, - oauth: { - clientId: oauthConfig.clientId, - clientSecret: oauthConfig.clientSecret, - clientMetadataUrl: oauthConfig.clientMetadataUrl, - scope: oauthConfig.scope, - }, - }; - - client = new InspectorClient( - { - type: transport.clientType, - url: `${serverUrl}${transport.endpoint}`, - } as MCPServerConfig, - clientConfig, - ); - - // Start guided auth and progress to authorization_code step - await client.beginGuidedAuth(); - while (true) { - const state = client.getOAuthFlowState(); - if (state?.oauthStep === "authorization_code") { - break; - } - await client.proceedOAuthStep(); - } - - const stateBefore = client.getOAuthFlowState(); - expect(stateBefore?.oauthStep).toBe("authorization_code"); - expect(stateBefore?.authorizationCode).toBe(""); - - const authUrl = stateBefore?.authorizationUrl; - if (!authUrl) throw new Error("Expected authorizationUrl"); - const authCode = await completeOAuthAuthorization(authUrl); - - // Set code with completeFlow=true (should auto-complete) - const stepEvents: Array<{ step: string; previousStep: string }> = []; - client.addEventListener("oauthStepChange", (event) => { - stepEvents.push({ - step: event.detail.step, - previousStep: event.detail.previousStep, - }); - }); - - await client.setGuidedAuthorizationCode(authCode, true); - - // Verify flow completed automatically - const stateAfter = client.getOAuthFlowState(); - expect(stateAfter?.oauthStep).toBe("complete"); - expect(stateAfter?.authorizationCode).toBe(authCode); - expect(stateAfter?.oauthTokens).toBeDefined(); - - // Should have dispatched step change events for transitions (not for code setting) - // authorization_code -> token_request -> complete - expect(stepEvents.length).toBeGreaterThanOrEqual(2); - const lastEvent = stepEvents[stepEvents.length - 1]; - expect(lastEvent?.step).toBe("complete"); - }); - - it("runGuidedAuth continues from already-started guided flow", async () => { - const staticClientId = "test-run-from-started"; - const staticClientSecret = "test-secret-run-from-started"; - - const serverConfig = { - ...getDefaultServerConfig(), - serverType: transport.serverType, - ...createOAuthTestServerConfig({ - requireAuth: true, - staticClients: [ - { - clientId: staticClientId, - clientSecret: staticClientSecret, - redirectUris: [testRedirectUrl], - }, - ], - }), - }; - - server = new TestServerHttp(serverConfig); - const port = await server.start(); - const serverUrl = `http://localhost:${port}`; - await waitForOAuthWellKnown(serverUrl); - 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: oauthConfig.navigation, - redirectUrlProvider: oauthConfig.redirectUrlProvider, - }, - }, - oauth: { - clientId: oauthConfig.clientId, - clientSecret: oauthConfig.clientSecret, - clientMetadataUrl: oauthConfig.clientMetadataUrl, - scope: oauthConfig.scope, - }, - }; - - client = new InspectorClient( - { - type: transport.clientType, - url: `${serverUrl}${transport.endpoint}`, - } as MCPServerConfig, - clientConfig, - ); - - await client.beginGuidedAuth(); - await client.proceedOAuthStep(); - - const stateBeforeRun = client.getOAuthFlowState(); - expect(stateBeforeRun?.oauthStep).not.toBe("authorization_code"); - expect(stateBeforeRun?.oauthStep).not.toBe("complete"); + ); - const authUrl = await client.runGuidedAuth(); + const authUrl = await client.authenticate(); if (!authUrl) throw new Error("Expected authorization URL"); - expect(authUrl.href).toContain("/oauth/authorize"); - const authCode = await completeOAuthAuthorization(authUrl); await client.completeOAuthFlow(authCode); await client.connect(); - const tokens = await client.getOAuthTokens(); - expect(tokens).toBeDefined(); - expect(tokens?.access_token).toBeDefined(); expect(client.getStatus()).toBe("connected"); + const toolsResult = await client.listTools(); + expect(toolsResult).toBeDefined(); }); + }, + ); - it("runGuidedAuth returns undefined when already complete", async () => { - const staticClientId = "test-run-complete"; - const staticClientSecret = "test-secret-run-complete"; - + describe.each(transports)( + "DCR (Dynamic Client Registration) Mode ($name)", + (transport) => { + it("should register client and complete OAuth flow", async () => { const serverConfig = { ...getDefaultServerConfig(), serverType: transport.serverType, ...createOAuthTestServerConfig({ requireAuth: true, - staticClients: [ - { - clientId: staticClientId, - clientSecret: staticClientSecret, - redirectUris: [testRedirectUrl], - }, - ], + supportDCR: true, }), }; @@ -1108,9 +461,7 @@ describe("InspectorClient OAuth E2E", () => { await waitForOAuthWellKnown(serverUrl); const oauthConfig = createTestOAuthConfig({ - mode: "static", - clientId: staticClientId, - clientSecret: staticClientSecret, + mode: "dcr", redirectUrl: testRedirectUrl, }); const clientConfig: InspectorClientOptions = { @@ -1138,16 +489,28 @@ describe("InspectorClient OAuth E2E", () => { clientConfig, ); - const authUrl = await client.runGuidedAuth(); + const authUrl = await client.authenticate(); if (!authUrl) throw new Error("Expected authorization URL"); + expect(authUrl.href).toContain("/oauth/authorize"); + + const stateAfterAuth = client.getOAuthFlowState(); + expect(stateAfterAuth?.oauthStep).toBe("authorization_code"); + expect(stateAfterAuth?.oauthClientInfo).toBeDefined(); + expect(stateAfterAuth?.oauthClientInfo?.client_id).toBeDefined(); + const authCode = await completeOAuthAuthorization(authUrl); await client.completeOAuthFlow(authCode); + await client.connect(); const stateAfterComplete = client.getOAuthFlowState(); expect(stateAfterComplete?.oauthStep).toBe("complete"); + expect(stateAfterComplete?.oauthTokens).toBeDefined(); + expect(stateAfterComplete?.completedAt).toBeDefined(); - const authUrlAgain = await client.runGuidedAuth(); - expect(authUrlAgain).toBeUndefined(); + const tokens = await client.getOAuthTokens(); + expect(tokens).toBeDefined(); + expect(tokens?.access_token).toBeDefined(); + expect(client.getStatus()).toBe("connected"); }); }, ); @@ -1214,7 +577,7 @@ describe("InspectorClient OAuth E2E", () => { expect(uris).toEqual([redirectUrl]); }); - it("should accept single redirect_uri for both normal and guided auth", async () => { + it("should accept single redirect_uri on re-authentication", async () => { const serverConfig = { ...getDefaultServerConfig(), serverType: transport.serverType, @@ -1259,19 +622,20 @@ describe("InspectorClient OAuth E2E", () => { clientConfig, ); - const authUrlNormal = await client.authenticate(); - if (!authUrlNormal) throw new Error("Expected authorization URL"); - const authCodeNormal = await completeOAuthAuthorization(authUrlNormal); - await client.completeOAuthFlow(authCodeNormal); + const authUrlFirst = await client.authenticate(); + if (!authUrlFirst) throw new Error("Expected authorization URL"); + const authCodeFirst = await completeOAuthAuthorization(authUrlFirst); + await client.completeOAuthFlow(authCodeFirst); await client.connect(); expect(client.getStatus()).toBe("connected"); await client.disconnect(); + client.clearOAuthTokens(); - const authUrlGuided = await client.runGuidedAuth(); - if (!authUrlGuided) throw new Error("Expected authorization URL"); - const authCodeGuided = await completeOAuthAuthorization(authUrlGuided); - await client.completeOAuthFlow(authCodeGuided); + const authUrlSecond = await client.authenticate(); + if (!authUrlSecond) throw new Error("Expected authorization URL"); + const authCodeSecond = await completeOAuthAuthorization(authUrlSecond); + await client.completeOAuthFlow(authCodeSecond); await client.connect(); expect(client.getStatus()).toBe("connected"); }); @@ -1344,190 +708,141 @@ describe("InspectorClient OAuth E2E", () => { await client.authenticate(); expect(authEventReceived).toBe(true); }); - }); - describe.each(transports)( - "Resource metadata discovery and oauthStepChange ($name)", - (transport) => { - it("should discover resource metadata and set resource in guided flow", async () => { - const staticClientId = "test-resource-metadata"; - const staticClientSecret = "test-secret-rm"; + it("should not open the browser during connect when no tokens are stored", async () => { + const staticClientId = "test-client-connect-no-nav"; + const staticClientSecret = "test-secret-connect-no-nav"; - const serverConfig = { - ...getDefaultServerConfig(), - serverType: transport.serverType, - ...createOAuthTestServerConfig({ - requireAuth: true, - staticClients: [ - { - clientId: staticClientId, - clientSecret: staticClientSecret, - redirectUris: [testRedirectUrl], - }, - ], - }), - }; + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportDCR: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; - server = new TestServerHttp(serverConfig); - const port = await server.start(); - const serverUrl = `http://localhost:${port}`; - await waitForOAuthWellKnown(serverUrl); - await waitForOAuthWellKnown(serverUrl); + server = new TestServerHttp(serverConfig); + const port = await server.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: oauthConfig.navigation, - redirectUrlProvider: oauthConfig.redirectUrlProvider, - }, - }, + const navigate = vi.fn(); + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + oauthConfig.navigation = { navigateToAuthorization: navigate }; + + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, oauth: { - clientId: oauthConfig.clientId, - clientSecret: oauthConfig.clientSecret, - clientMetadataUrl: oauthConfig.clientMetadataUrl, - scope: oauthConfig.scope, + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, }, - }; + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; - client = new InspectorClient( - { - type: transport.clientType, - url: `${serverUrl}${transport.endpoint}`, - } as MCPServerConfig, - clientConfig, - ); + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); - await client.runGuidedAuth(); + await expect(client.connect()).rejects.toThrow(); + expect(navigate).not.toHaveBeenCalled(); + }); - const state = client.getOAuthFlowState(); - expect(state).toBeDefined(); - expect(state?.execution).toBe("guided"); - expect(state?.resourceMetadata).toBeDefined(); - expect(state?.resourceMetadata?.resource).toBeDefined(); - expect( - state?.resourceMetadata?.authorization_servers?.length, - ).toBeGreaterThanOrEqual(1); - expect(state?.resourceMetadata?.scopes_supported).toBeDefined(); - expect(state?.resource).toBeInstanceOf(URL); - expect(state?.resource?.href).toBe(state?.resourceMetadata?.resource); - expect(state?.resourceMetadataError).toBeNull(); - }); + it("should connect on retry after OAuth without disconnect", async () => { + const staticClientId = "test-client-connect-retry-no-disconnect"; + const staticClientSecret = "test-secret-connect-retry-no-disconnect"; - it("should dispatch oauthStepChange on each step transition in guided flow", async () => { - const staticClientId = "test-step-events"; - const staticClientSecret = "test-secret-se"; + const serverConfig = { + ...getDefaultServerConfig(), + serverType: transport.serverType, + ...createOAuthTestServerConfig({ + requireAuth: true, + supportDCR: true, + staticClients: [ + { + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUris: [testRedirectUrl], + }, + ], + }), + }; - const serverConfig = { - ...getDefaultServerConfig(), - serverType: transport.serverType, - ...createOAuthTestServerConfig({ - requireAuth: true, - staticClients: [ - { - clientId: staticClientId, - clientSecret: staticClientSecret, - redirectUris: [testRedirectUrl], - }, - ], - }), - }; + server = new TestServerHttp(serverConfig); + const port = await server.start(); + const serverUrl = `http://localhost:${port}`; + await waitForOAuthWellKnown(serverUrl); - server = new TestServerHttp(serverConfig); - const port = await server.start(); - const serverUrl = `http://localhost:${port}`; - await waitForOAuthWellKnown(serverUrl); - await waitForOAuthWellKnown(serverUrl); + const navigate = vi.fn(); + const oauthConfig = createTestOAuthConfig({ + mode: "static", + clientId: staticClientId, + clientSecret: staticClientSecret, + redirectUrl: testRedirectUrl, + }); + oauthConfig.navigation = { navigateToAuthorization: navigate }; - const oauthConfig = createTestOAuthConfig({ - mode: "static", - clientId: staticClientId, - clientSecret: staticClientSecret, - redirectUrl: testRedirectUrl, - }); - const clientConfig: InspectorClientOptions = { - environment: { - transport: createTransportNode, - oauth: { - storage: oauthConfig.storage, - navigation: oauthConfig.navigation, - redirectUrlProvider: oauthConfig.redirectUrlProvider, - }, - }, + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, oauth: { - clientId: oauthConfig.clientId, - clientSecret: oauthConfig.clientSecret, - clientMetadataUrl: oauthConfig.clientMetadataUrl, - scope: oauthConfig.scope, + storage: oauthConfig.storage, + navigation: oauthConfig.navigation, + redirectUrlProvider: oauthConfig.redirectUrlProvider, }, - }; - - client = new InspectorClient( - { - type: transport.clientType, - url: `${serverUrl}${transport.endpoint}`, - } as MCPServerConfig, - clientConfig, - ); + }, + oauth: { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + clientMetadataUrl: oauthConfig.clientMetadataUrl, + scope: oauthConfig.scope, + }, + }; - const stepEvents: Array<{ - step: string; - previousStep: string; - state: unknown; - }> = []; - client.addEventListener("oauthStepChange", (event) => { - stepEvents.push({ - step: event.detail.step, - previousStep: event.detail.previousStep, - state: event.detail.state, - }); - }); + client = new InspectorClient( + { + type: transport.clientType, + url: `${serverUrl}${transport.endpoint}`, + } as MCPServerConfig, + clientConfig, + ); - const authUrl = await client.runGuidedAuth(); - if (!authUrl) throw new Error("Expected authorization URL"); - const authCode = await completeOAuthAuthorization(authUrl); - await client.completeOAuthFlow(authCode); + await expect(client.connect()).rejects.toThrow(); + expect(navigate).not.toHaveBeenCalled(); - const expectedTransitions = [ - { previousStep: "metadata_discovery", step: "client_registration" }, - { - previousStep: "client_registration", - step: "authorization_redirect", - }, - { - previousStep: "authorization_redirect", - step: "authorization_code", - }, - { previousStep: "authorization_code", step: "token_request" }, - { previousStep: "token_request", step: "complete" }, - ]; - - expect(stepEvents.length).toBe(expectedTransitions.length); - for (let i = 0; i < expectedTransitions.length; i++) { - const e = stepEvents[i]; - expect(e).toBeDefined(); - expect(e?.step).toBe(expectedTransitions[i]!.step); - expect(e?.previousStep).toBe(expectedTransitions[i]!.previousStep); - expect(e?.state).toBeDefined(); - expect(typeof e?.state === "object" && e?.state !== null).toBe(true); - } + const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); + const authCode = await completeOAuthAuthorization(authUrl); + await client.completeOAuthFlow(authCode); - const finalState = client.getOAuthFlowState(); - expect(finalState?.execution).toBe("guided"); - expect(finalState?.oauthStep).toBe("complete"); - expect(finalState?.oauthTokens).toBeDefined(); - expect(finalState?.completedAt).toBeDefined(); - expect(typeof finalState?.completedAt).toBe("number"); - }); - }, - ); + await client.connect(); + expect(await client.isOAuthAuthorized()).toBe(true); + }); + }); describe.each(transports)( "Token refresh (authProvider) ($name)", @@ -1852,7 +1167,7 @@ describe("InspectorClient OAuth E2E", () => { ); const fetchRequestLogState = new FetchRequestLogState(client); - const authUrl = await client.runGuidedAuth(); + const authUrl = await client.authenticate(); if (!authUrl) throw new Error("Expected authorization URL"); expect(authUrl.href).toContain("/oauth/authorize"); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-storage-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-storage-e2e.test.ts index 34e21aa58..37c2d47fa 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-storage-e2e.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-storage-e2e.test.ts @@ -244,7 +244,7 @@ describe("InspectorClient OAuth E2E with Remote Storage", () => { clientConfig, ); - const authUrl = await client.runGuidedAuth(); + const authUrl = await client.authenticate(); if (!authUrl) throw new Error("Expected authorization URL"); expect(authUrl.href).toContain("/oauth/authorize"); @@ -335,7 +335,7 @@ describe("InspectorClient OAuth E2E with Remote Storage", () => { clientConfig1, ); - const authUrl = await client1.runGuidedAuth(); + const authUrl = await client1.authenticate(); if (!authUrl) throw new Error("Expected authorization URL"); const authCode = await completeOAuthAuthorization(authUrl); await client1.completeOAuthFlow(authCode); @@ -495,7 +495,7 @@ describe("InspectorClient OAuth E2E with Remote Storage", () => { clientConfig, ); - const authUrl = await client.runGuidedAuth(); + const authUrl = await client.authenticate(); if (!authUrl) throw new Error("Expected authorization URL"); expect(authUrl.href).toContain("/oauth/authorize"); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth.test.ts index a26512f93..86d439fce 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-oauth.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth.test.ts @@ -199,7 +199,7 @@ describe("InspectorClient OAuth", () => { } }); - it("should track auth fetches with category 'auth' during guided auth", async () => { + it("should track auth fetches with category 'auth' during authenticate()", async () => { const staticClientId = "test-auth-fetch-client"; const staticClientSecret = "test-auth-fetch-secret"; @@ -253,9 +253,7 @@ describe("InspectorClient OAuth", () => { ); const fetchRequestLogState = new FetchRequestLogState(testClient); - // beginGuidedAuth runs metadata_discovery, client_registration, authorization_redirect - // (stops at authorization_code awaiting user). Produces auth fetches only (no connect yet). - await testClient.beginGuidedAuth(); + await testClient.authenticate(); const fetchRequests = fetchRequestLogState.getFetchRequests(); const authFetches = fetchRequests.filter( @@ -293,7 +291,7 @@ describe("InspectorClient OAuth", () => { const staticClientId = "test-event-client"; const staticClientSecret = "test-event-secret"; - // Create test server with OAuth enabled and DCR support (for authenticate() normal mode) + // Create test server with OAuth enabled and DCR support const serverConfig = { ...getDefaultServerConfig(), serverType: "sse" as const, diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index dbc6739e7..97c291c3f 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -4939,7 +4939,7 @@ describe("InspectorClient", () => { ); }); - it("authenticate / runGuidedAuth / proceedOAuthStep throw via ensureOAuthManager when oauthManager is unset", async () => { + it("authenticate throws via ensureOAuthManager when oauthManager is unset", async () => { const c = new InspectorClient( { type: "stdio", @@ -4949,10 +4949,6 @@ describe("InspectorClient", () => { { environment: { transport: createTransportNode } }, ); await expect(c.authenticate()).rejects.toThrow(/OAuth not configured/); - await expect(c.runGuidedAuth()).rejects.toThrow(/OAuth not configured/); - await expect(c.proceedOAuthStep()).rejects.toThrow( - /OAuth not configured/, - ); }); it("simple session/roots/subscription accessors return empty defaults before connect", () => { diff --git a/clients/web/src/test/integration/mcp/remote/client-store-route.test.ts b/clients/web/src/test/integration/mcp/remote/client-store-route.test.ts index 8624c5a61..d2c52f3b7 100644 --- a/clients/web/src/test/integration/mcp/remote/client-store-route.test.ts +++ b/clients/web/src/test/integration/mcp/remote/client-store-route.test.ts @@ -99,6 +99,34 @@ describe("/api/storage/client keychain", () => { await teardown(h); }); + it("POST persists EMA and CIMD together in client.json", async () => { + const body = { + enterpriseManagedAuth: { + enabled: true, + idp: { + issuer: "https://idp.example.com", + clientId: "inspector-app", + }, + }, + cimd: { + enabled: true, + clientMetadataUrl: "https://example.com/cimd.json", + }, + }; + const res = await fetch(`${h.baseUrl}/api/storage/client`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + expect(res.status).toBe(200); + + const onDisk = JSON.parse( + readFileSync(h.clientPath, "utf-8"), + ) as typeof body; + expect(onDisk.enterpriseManagedAuth).toEqual(body.enterpriseManagedAuth); + expect(onDisk.cimd).toEqual(body.cimd); + }); + it("POST writes IdP clientSecret to keychain, not client.json", async () => { const res = await fetch(`${h.baseUrl}/api/storage/client`, { method: "POST", diff --git a/clients/web/src/test/integration/server/vite-base-config.test.ts b/clients/web/src/test/integration/server/vite-base-config.test.ts index c13cb13a5..1bc464e74 100644 --- a/clients/web/src/test/integration/server/vite-base-config.test.ts +++ b/clients/web/src/test/integration/server/vite-base-config.test.ts @@ -1,5 +1,18 @@ -import { describe, it, expect } from "vitest"; -import { getViteBaseConfig } from "../../../../server/vite-base-config.js"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + existsSync, + rmSync, +} from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + clearViteDepsCache, + getViteBaseConfig, + getViteDevOptimizeDeps, +} from "../../../../server/vite-base-config.js"; describe("getViteBaseConfig", () => { it("excludes node-only deps from optimizeDeps so vite dev doesn't scan them", () => { @@ -21,3 +34,41 @@ describe("getViteBaseConfig", () => { expect(a.optimizeDeps).not.toBe(b.optimizeDeps); }); }); + +describe("getViteDevOptimizeDeps", () => { + it("forces a full pre-bundle on each dev launch with no stale-request 504s", () => { + const config = getViteDevOptimizeDeps(); + expect(config.force).toBe(true); + expect(config.ignoreOutdatedRequests).toBe(true); + expect(config.include).toEqual([ + "ajv", + "@modelcontextprotocol/sdk/validation/ajv", + ]); + expect(config.exclude).toEqual(getViteBaseConfig().optimizeDeps.exclude); + }); +}); + +describe("clearViteDepsCache", () => { + let tempRoot: string; + + beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "vite-cache-clear-")); + mkdirSync(join(tempRoot, "node_modules", ".vite", "deps"), { + recursive: true, + }); + writeFileSync( + join(tempRoot, "node_modules", ".vite", "deps", "metadata.json"), + "{}", + ); + }); + + afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); + }); + + it("removes node_modules/.vite before a dev server start", () => { + expect(existsSync(join(tempRoot, "node_modules", ".vite"))).toBe(true); + clearViteDepsCache(tempRoot); + expect(existsSync(join(tempRoot, "node_modules", ".vite"))).toBe(false); + }); +}); diff --git a/clients/web/src/theme/Code.ts b/clients/web/src/theme/Code.ts index 77609c59f..82bc6b1d4 100644 --- a/clients/web/src/theme/Code.ts +++ b/clients/web/src/theme/Code.ts @@ -9,6 +9,9 @@ export const ThemeCode = Code.extend({ root.wordBreak = "break-all"; root.whiteSpace = "pre-wrap"; } + if (props.block) { + root.margin = "0"; + } return { root }; }, }); diff --git a/clients/web/src/utils/clearServerOAuthState.test.ts b/clients/web/src/utils/clearServerOAuthState.test.ts new file mode 100644 index 000000000..6cdbfeec9 --- /dev/null +++ b/clients/web/src/utils/clearServerOAuthState.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getBrowserOAuthStorage } from "@inspector/core/auth/browser/index.js"; +import { clearServerOAuthState } from "./clearServerOAuthState"; + +describe("clearServerOAuthState", () => { + beforeEach(() => { + getBrowserOAuthStorage().clear("https://mcp.example.com/mcp"); + }); + + it("clears storage by server URL when not the active connection", async () => { + const storage = getBrowserOAuthStorage(); + await storage.saveTokens("https://mcp.example.com/mcp", { + access_token: "tok", + token_type: "Bearer", + }); + + const cleared = clearServerOAuthState({ + config: { type: "streamable-http", url: "https://mcp.example.com/mcp" }, + isActiveConnection: false, + }); + + expect(cleared).toBe(true); + expect( + await storage.getTokens("https://mcp.example.com/mcp"), + ).toBeUndefined(); + }); + + it("uses the live client when clearing the active connection", () => { + const inspectorClient = { + clearOAuthTokens: vi.fn(), + }; + + const cleared = clearServerOAuthState({ + config: { type: "streamable-http", url: "https://mcp.example.com/mcp" }, + inspectorClient: inspectorClient as never, + isActiveConnection: true, + }); + + expect(cleared).toBe(true); + expect(inspectorClient.clearOAuthTokens).toHaveBeenCalledTimes(1); + }); + + it("returns false for stdio servers", () => { + expect( + clearServerOAuthState({ + config: { type: "stdio", command: "node", args: [] }, + isActiveConnection: false, + }), + ).toBe(false); + }); +}); diff --git a/clients/web/src/utils/clearServerOAuthState.ts b/clients/web/src/utils/clearServerOAuthState.ts new file mode 100644 index 000000000..a3fd9d7cc --- /dev/null +++ b/clients/web/src/utils/clearServerOAuthState.ts @@ -0,0 +1,32 @@ +import { getBrowserOAuthStorage } from "@inspector/core/auth/browser/index.js"; +import { getOAuthServerUrl } from "@inspector/core/mcp/config.js"; +import type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; + +export interface ClearServerOAuthStateParams { + config: MCPServerConfig; + /** When set and this server is the active connection, clear via the live client. */ + inspectorClient?: InspectorClient | null; + isActiveConnection: boolean; +} + +/** + * Clear persisted OAuth state (tokens, DCR/CIMD client id, PKCE, etc.) for an + * HTTP MCP server. When clearing the active connection, uses the live client so + * in-memory flow state is reset too. + */ +export function clearServerOAuthState( + params: ClearServerOAuthStateParams, +): boolean { + const serverUrl = getOAuthServerUrl(params.config); + if (!serverUrl) { + return false; + } + + if (params.isActiveConnection && params.inspectorClient) { + params.inspectorClient.clearOAuthTokens(); + } else { + getBrowserOAuthStorage().clear(serverUrl); + } + return true; +} diff --git a/clients/web/src/utils/oauthFlow.ts b/clients/web/src/utils/oauthFlow.ts index a595cc3a1..fb1395e64 100644 --- a/clients/web/src/utils/oauthFlow.ts +++ b/clients/web/src/utils/oauthFlow.ts @@ -4,6 +4,8 @@ * independently of the React component that orchestrates it. */ +export { isUnauthorizedError } from "@inspector/core/auth/utils.js"; + /** * The pathname the auth server redirects back to after the user authorizes. * `App.tsx`'s `redirectUrlProvider` points OAuth flows at @@ -14,31 +16,10 @@ export const OAUTH_CALLBACK_PATH = "/oauth/callback"; /** * sessionStorage key holding the id of the server whose OAuth flow is in - * flight. The OAuth `state` parameter only carries `{execution}:{authId}`, not which + * flight. The OAuth `state` parameter carries the auth session id; the full * configured server initiated the flow, and the full-page redirect to the auth * server wipes all in-memory React state. The id is stashed here right before * redirecting so the post-callback page load can rebuild the right * `InspectorClient` and resume the connection. */ export const OAUTH_PENDING_SERVER_KEY = "mcp-inspector:oauth-pending-server-id"; - -/** - * True when a thrown connect error represents an upstream 401. The remote - * transport preserves the status on the error object - * (`remoteClientTransport` sets `error.status`); this structured check is the - * primary path. As a fallback for cases where the status is lost crossing an - * SDK boundary, we match the transport's own formatted wording — - * `"Remote (connect|send|events stream) failed (401): …"` — anchored on - * `failed …(401)` rather than a bare `(401)`, so an unrelated `(401)` spliced - * into an error message (e.g. from a tool result) can't trip the OAuth flow. - * A 401 on connect to an HTTP/SSE server is the signal to start OAuth. - */ -export function isUnauthorizedError(err: unknown): boolean { - if (typeof err === "object" && err !== null) { - const status = (err as { status?: number; code?: number }).status; - const code = (err as { code?: number }).code; - if (status === 401 || code === 401) return true; - } - const message = err instanceof Error ? err.message : String(err); - return /\bfailed\b[^\n]*\(401\)/i.test(message); -} diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index 29cd06f97..b1fc013ee 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url'; import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; import { playwright } from '@vitest/browser-playwright'; import { honoMiddlewarePlugin } from './server/vite-hono-plugin'; -import { getViteBaseConfig } from './server/vite-base-config'; +import { getViteBaseConfig, getViteDevOptimizeDeps } from './server/vite-base-config'; import { buildWebServerConfigFromEnv } from './server/web-server-config'; import { vitestSharedPaths } from '../../vitest.shared.mts'; const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); @@ -28,7 +28,9 @@ const { repoRoot, sharedDedupe, nodeModulesAliases, projectResolve, sharedAliase const integrationGlob = 'clients/web/src/test/integration/**/*.test.{ts,tsx}'; // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon -export default defineConfig({ +export default defineConfig(({ command }) => { + const isDevServer = command === "serve" && !process.env.VITEST; + return { // `honoMiddlewarePlugin` is gated by `apply: 'serve'` so it only attaches // during `vite dev` / `vite preview` — vitest projects share this config // but never invoke `configureServer`, so the plugin stays inert there. @@ -49,7 +51,10 @@ export default defineConfig({ // (`@modelcontextprotocol/sdk/client/stdio.js`, `cross-spawn`, `which`) // consumed by the dev backend aren't scanned for browser pre-bundling. // Browser code reaches the node-side stack via the Hono plugin only. - ...getViteBaseConfig(), + // Dev server: force a full dep pre-bundle each launch (no stale cache). + optimizeDeps: isDevServer + ? getViteDevOptimizeDeps() + : getViteBaseConfig().optimizeDeps, resolve: { // NOTE: the unit vitest project (below) overrides this — see comment there. // @@ -250,4 +255,5 @@ export default defineConfig({ }, ], }, +}; }); diff --git a/core/auth/browser/providers.ts b/core/auth/browser/providers.ts index 13728725c..4c1d34530 100644 --- a/core/auth/browser/providers.ts +++ b/core/auth/browser/providers.ts @@ -55,6 +55,6 @@ export class BrowserOAuthClientProvider extends BaseOAuthClientProvider { }; const navigation = new BrowserNavigation(); - super(serverUrl, { storage, redirectUrlProvider, navigation }, "quick"); + super(serverUrl, { storage, redirectUrlProvider, navigation }); } } diff --git a/core/auth/cimd.ts b/core/auth/cimd.ts index 39638de24..b595b03dc 100644 --- a/core/auth/cimd.ts +++ b/core/auth/cimd.ts @@ -48,5 +48,7 @@ export async function ensureCimdClientRegistration(params: { const clientInformation: OAuthClientInformation = { client_id: clientMetadataUrl, }; - await params.provider.saveClientInformation(clientInformation); + await params.provider.saveClientInformation(clientInformation, { + registrationKind: "cimd", + }); } diff --git a/core/auth/connection-state.ts b/core/auth/connection-state.ts index 8fccf5d15..3ae4466b0 100644 --- a/core/auth/connection-state.ts +++ b/core/auth/connection-state.ts @@ -7,7 +7,7 @@ import { getEmaIdpLoginState, normalizeIdpIssuer } from "./ema/idpSession.js"; import { idpOAuthStorageKey } from "./ema/storage.js"; import { isJwtExpired } from "./ema/jwt.js"; import type { OAuthStorage } from "./storage.js"; -import type { AuthProtocol, OAuthConnectionState, OAuthFlowState } from "./types.js"; +import type { AuthProtocol, OAuthClientRegistrationKind, OAuthConnectionState, OAuthFlowState } from "./types.js"; import { authProtocolFromEnterpriseManaged } from "./types.js"; export interface BuildOAuthConnectionStateParams { @@ -27,13 +27,28 @@ function isAccessTokenUsable(tokens: OAuthTokens | undefined): boolean { function resolveClient( preregistered: OAuthClientInformation | undefined, dynamic: OAuthClientInformation | undefined, + dynamicRegistrationKind: Extract< + OAuthClientRegistrationKind, + "dcr" | "cimd" + > | undefined, ): OAuthConnectionState["client"] | undefined { - const info = preregistered ?? dynamic; - if (!info?.client_id) return undefined; + if (preregistered?.client_id) { + return { + registrationKind: "static", + clientId: preregistered.client_id, + hasClientSecret: + preregistered.client_secret !== undefined && + preregistered.client_secret !== "", + }; + } + if (!dynamic?.client_id) return undefined; return { - source: preregistered ? "preregistered" : "dynamic", - clientId: info.client_id, - hasClientSecret: info.client_secret !== undefined && info.client_secret !== "", + ...(dynamicRegistrationKind && { + registrationKind: dynamicRegistrationKind, + }), + clientId: dynamic.client_id, + hasClientSecret: + dynamic.client_secret !== undefined && dynamic.client_secret !== "", }; } @@ -76,7 +91,17 @@ export async function buildOAuthConnectionState( const authorized = isAccessTokenUsable(tokens); const grantedScope = resolveGrantedScope(tokens, storageScope); - const client = resolveClient(preregistered, dynamic); + const registrationKind = storage.getClientRegistrationKind(serverUrl); + const dynamicRegistrationKind = + registrationKind === "dcr" || registrationKind === "cimd" + ? registrationKind + : undefined; + + const client = resolveClient( + preregistered, + dynamic, + preregistered?.client_id ? undefined : dynamicRegistrationKind, + ); const state: OAuthConnectionState = { authorized, @@ -114,12 +139,31 @@ export function isServerOAuthConfigured(config: { clientSecret?: string; scope?: string; enterpriseManaged?: boolean; + clientMetadataUrl?: string; }): boolean { return ( config.enterpriseManaged === true || !!config.clientId?.trim() || !!config.clientSecret?.trim() || - !!config.scope?.trim() + !!config.scope?.trim() || + !!config.clientMetadataUrl?.trim() + ); +} + +/** True when persisted OAuth storage has tokens or client registration for a server. */ +export async function hasPersistedOAuthServerState( + storage: OAuthStorage, + serverUrl: string, +): Promise { + const [tokens, preregistered, dynamic] = await Promise.all([ + storage.getTokens(serverUrl), + storage.getClientInformation(serverUrl, true), + storage.getClientInformation(serverUrl), + ]); + return !!( + tokens?.access_token || + preregistered?.client_id || + dynamic?.client_id ); } diff --git a/core/auth/discovery.ts b/core/auth/discovery.ts index 07367a122..1a860ab9f 100644 --- a/core/auth/discovery.ts +++ b/core/auth/discovery.ts @@ -11,7 +11,7 @@ export function getAuthorizationServerUrl( resourceMetadata?: OAuthProtectedResourceMetadata | null, ): URL { const first = resourceMetadata?.authorization_servers?.[0]; - // Use truthy check to match original state-machine: empty string falls back to serverUrl + // Empty string falls back to serverUrl if (first) { return parseHttpUrl(first, "protected resource authorization_servers[0]"); } diff --git a/core/auth/ema/idpOidc.ts b/core/auth/ema/idpOidc.ts index 5b7aea3b8..8dff2ae6a 100644 --- a/core/auth/ema/idpOidc.ts +++ b/core/auth/ema/idpOidc.ts @@ -10,7 +10,7 @@ import type { import { OAuthMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; import type { OAuthStorage } from "../storage.js"; import type { EnterpriseManagedAuthIdpConfig } from "../../client/types.js"; -import { generateOAuthStateWithExecution, parseHttpUrl } from "../utils.js"; +import { generateOAuthState, parseHttpUrl } from "../utils.js"; import { IDP_OIDC_SCOPES } from "./constants.js"; import { isJwtExpired, jwtExpiresAtMs } from "./jwt.js"; import { idpOAuthStorageKey, normalizeIdpIssuer } from "./storage.js"; @@ -66,7 +66,7 @@ export async function startIdpOidcAuthorization(params: { const metadata = await discoverIdpMetadata(issuer, params.fetchFn); const clientInformation = idpClientInformation(params.idp); const storageKey = idpOAuthStorageKey(issuer); - const state = generateOAuthStateWithExecution("quick"); + const state = generateOAuthState(); const issuerUrl = parseHttpUrl(issuer, "EMA IdP issuer (Client Settings)"); const { authorizationUrl, codeVerifier } = await startAuthorization( issuerUrl, diff --git a/core/auth/index.ts b/core/auth/index.ts index 556ccd9e6..7d159c9b7 100644 --- a/core/auth/index.ts +++ b/core/auth/index.ts @@ -1,8 +1,8 @@ // Types export type { OAuthStep, - AuthExecution, AuthProtocol, + OAuthClientRegistrationKind, MessageType, StatusMessage, OAuthFlowState, @@ -16,6 +16,7 @@ export { export { buildOAuthConnectionState, + hasPersistedOAuthServerState, isServerOAuthConfigured, protocolFromOAuthConfig, } from "./connection-state.js"; @@ -24,7 +25,7 @@ export type { BuildOAuthConnectionStateParams } from "./connection-state.js"; export { ensureCimdClientRegistration } from "./cimd.js"; // Storage -export type { OAuthStorage, IdpSessionState } from "./storage.js"; +export type { OAuthStorage, IdpSessionState, SaveClientInformationOptions } from "./storage.js"; export { getServerSpecificKey, OAUTH_STORAGE_KEYS } from "./storage.js"; // Providers @@ -46,10 +47,9 @@ export { parseHttpUrl, parseOAuthCallbackParams, generateOAuthState, - generateOAuthStateWithExecution, - generateOAuthStateWithMode, parseOAuthState, generateOAuthErrorDescription, + isUnauthorizedError, } from "./utils.js"; // Discovery @@ -57,6 +57,3 @@ export { discoverScopes } from "./discovery.js"; // Logging (re-exported from core/logging) export { silentLogger } from "../logging/index.js"; -// State Machine -export type { StateMachineContext, StateTransition } from "./state-machine.js"; -export { oauthTransitions, OAuthStateMachine } from "./state-machine.js"; diff --git a/core/auth/node/index.ts b/core/auth/node/index.ts index cbd8476b4..1075a3a3a 100644 --- a/core/auth/node/index.ts +++ b/core/auth/node/index.ts @@ -14,3 +14,12 @@ export type { OAuthCallbackServerStartOptions, OAuthCallbackServerStartResult, } from "./oauth-callback-server.js"; +export { + DEFAULT_RUNNER_OAUTH_CALLBACK_URL, + RUNNER_OAUTH_CALLBACK_DEFAULT_HOSTNAME, + RUNNER_OAUTH_CALLBACK_DEFAULT_PORT, + RUNNER_OAUTH_CALLBACK_PATH, + formatRunnerOAuthRedirectUrl, + parseRunnerOAuthCallbackUrl, +} from "./runner-oauth-callback.js"; +export type { RunnerOAuthCallbackConfig } from "./runner-oauth-callback.js"; diff --git a/core/auth/node/oauth-callback-server.ts b/core/auth/node/oauth-callback-server.ts index 43cf8c074..16c698f39 100644 --- a/core/auth/node/oauth-callback-server.ts +++ b/core/auth/node/oauth-callback-server.ts @@ -52,7 +52,7 @@ export interface OAuthCallbackServerStartResult { /** * Minimal HTTP server that receives OAuth 2.1 redirects at GET /oauth/callback. - * Used by TUI/CLI to complete the authorization code flow (both quick and guided). + * Used by TUI/CLI to complete the authorization code flow. * Caller provides onCallback/onError; typically onCallback calls * InspectorClient.completeOAuthFlow(code) then stops the server. */ @@ -65,7 +65,8 @@ export class OAuthCallbackServer { private onError?: OAuthErrorHandler; /** - * Start the server. Listens on the given port (default 0 = random). + * Start the server. Port defaults to 0 (random) when omitted; TUI/CLI callers + * pass the resolved port from {@link parseRunnerOAuthCallbackUrl} (6276 by default). * Returns port and redirectUrl for use as oauth.redirectUrl. */ async start( diff --git a/core/auth/node/runner-oauth-callback.ts b/core/auth/node/runner-oauth-callback.ts new file mode 100644 index 000000000..4a1a7971c --- /dev/null +++ b/core/auth/node/runner-oauth-callback.ts @@ -0,0 +1,85 @@ +/** + * Default OAuth callback URL for Node runners (TUI / CLI). + * + * Web uses the main Hono server (`localhost:6274` by default). Runners spin up + * a minimal loopback listener; default port 6276 (T9 "MCPO", MCP OAuth) avoids colliding + * with the web dev server while staying in the Inspector 627x family. + */ + +export const RUNNER_OAUTH_CALLBACK_DEFAULT_HOSTNAME = "127.0.0.1"; +/** Default loopback port for TUI/CLI OAuth callback (6276 ≈ T9 "MCPO", MCP OAuth). */ +export const RUNNER_OAUTH_CALLBACK_DEFAULT_PORT = 6276; +export const RUNNER_OAUTH_CALLBACK_PATH = "/oauth/callback"; + +export const DEFAULT_RUNNER_OAUTH_CALLBACK_URL = `http://${RUNNER_OAUTH_CALLBACK_DEFAULT_HOSTNAME}:${RUNNER_OAUTH_CALLBACK_DEFAULT_PORT}${RUNNER_OAUTH_CALLBACK_PATH}`; + +export interface RunnerOAuthCallbackConfig { + hostname: string; + port: number; + pathname: string; +} + +/** + * Resolve the callback listener URL for TUI/CLI. + * Precedence: CLI `--callback-url` → `MCP_OAUTH_CALLBACK_URL` → default 6276. + * Pass `http://127.0.0.1:0/oauth/callback` for an OS-assigned ephemeral port. + */ +export function parseRunnerOAuthCallbackUrl( + cliCallbackUrl?: string, +): RunnerOAuthCallbackConfig { + const raw = + cliCallbackUrl?.trim() || + process.env.MCP_OAUTH_CALLBACK_URL?.trim() || + ""; + if (!raw) { + return { + hostname: RUNNER_OAUTH_CALLBACK_DEFAULT_HOSTNAME, + port: RUNNER_OAUTH_CALLBACK_DEFAULT_PORT, + pathname: RUNNER_OAUTH_CALLBACK_PATH, + }; + } + + let url: URL; + try { + url = new URL(raw); + } catch (err) { + throw new Error( + `Invalid OAuth callback URL: ${(err as Error)?.message ?? String(err)}`, + ); + } + if (url.protocol !== "http:") { + throw new Error("OAuth callback URL must use http scheme"); + } + const hostname = url.hostname; + if (!hostname) { + throw new Error("OAuth callback URL must include a hostname"); + } + const pathname = url.pathname || "/"; + let port: number; + if (url.port === "") { + port = 80; + } else { + port = Number(url.port); + if ( + !Number.isFinite(port) || + !Number.isInteger(port) || + port < 0 || + port > 65535 + ) { + throw new Error("OAuth callback URL port must be between 0 and 65535"); + } + } + return { hostname, port, pathname }; +} + +/** Build the redirect_uri string sent to the authorization server. */ +export function formatRunnerOAuthRedirectUrl( + config: RunnerOAuthCallbackConfig, +): string { + const needsBrackets = + config.hostname.includes(":") && !config.hostname.startsWith("["); + const formattedHost = needsBrackets + ? `[${config.hostname}]` + : config.hostname; + return `http://${formattedHost}:${config.port}${config.pathname}`; +} diff --git a/core/auth/oauth-storage.ts b/core/auth/oauth-storage.ts index e148a28d0..f2246112a 100644 --- a/core/auth/oauth-storage.ts +++ b/core/auth/oauth-storage.ts @@ -9,7 +9,12 @@ import { } from "@modelcontextprotocol/sdk/shared/auth.js"; import type { OAuthStorage } from "./storage.js"; import { type createOAuthStore, type ServerOAuthState } from "./store.js"; -import type { IdpSessionState, SaveTokensOptions } from "./storage.js"; +import type { + IdpSessionState, + OAuthClientRegistrationKind, + SaveClientInformationOptions, + SaveTokensOptions, +} from "./storage.js"; /** * Concrete OAuthStorage implementation parameterized on a Zustand store. @@ -39,12 +44,21 @@ export class OAuthStorageBase implements OAuthStorage { return await OAuthClientInformationSchema.parseAsync(clientInfo); } + getClientRegistrationKind( + serverUrl: string, + ): OAuthClientRegistrationKind | undefined { + return this.store.getState().getServerState(serverUrl) + .clientRegistrationKind; + } + async saveClientInformation( serverUrl: string, clientInformation: OAuthClientInformation, + options: SaveClientInformationOptions, ): Promise { this.store.getState().setServerState(serverUrl, { clientInformation, + clientRegistrationKind: options.registrationKind, }); } @@ -54,6 +68,7 @@ export class OAuthStorageBase implements OAuthStorage { ): Promise { this.store.getState().setServerState(serverUrl, { preregisteredClientInformation: clientInformation, + clientRegistrationKind: "static", }); } @@ -64,6 +79,7 @@ export class OAuthStorageBase implements OAuthStorage { updates.preregisteredClientInformation = undefined; } else { updates.clientInformation = undefined; + updates.clientRegistrationKind = undefined; } this.store.getState().setServerState(serverUrl, updates); diff --git a/core/auth/providers.ts b/core/auth/providers.ts index abcdba350..b01c9fb7f 100644 --- a/core/auth/providers.ts +++ b/core/auth/providers.ts @@ -5,21 +5,20 @@ import type { OAuthTokens, OAuthMetadata, } from "@modelcontextprotocol/sdk/shared/auth.js"; -import type { OAuthStorage } from "./storage.js"; -import type { AuthExecution } from "./types.js"; -import { generateOAuthStateWithExecution } from "./utils.js"; +import type { OAuthStorage, SaveClientInformationOptions } from "./storage.js"; +import { generateOAuthState } from "./utils.js"; /** - * Redirect URL provider. Returns the redirect URL for the requested mode. - * Caller populates the URLs before authenticate() (e.g. from callback server). + * Redirect URL provider. Returns the redirect URL for OAuth flows. + * Caller populates the URL before authenticate() (e.g. from callback server). */ export interface RedirectUrlProvider { - getRedirectUrl(execution?: AuthExecution): string; + getRedirectUrl(): string; } /** * Mutable redirect URL provider for TUI/CLI. Caller sets redirectUrl - * before authenticate(); same URL is used for both quick and guided flows. + * before authenticate(). */ export class MutableRedirectUrlProvider implements RedirectUrlProvider { redirectUrl = ""; @@ -109,19 +108,13 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { protected redirectUrlProvider: RedirectUrlProvider; protected navigation: OAuthNavigation; public clientMetadataUrl?: string; - protected execution: AuthExecution; - constructor( - serverUrl: string, - oauthConfig: OAuthProviderConfig, - execution: AuthExecution = "quick", - ) { + constructor(serverUrl: string, oauthConfig: OAuthProviderConfig) { this.serverUrl = serverUrl; this.storage = oauthConfig.storage; this.redirectUrlProvider = oauthConfig.redirectUrlProvider; this.navigation = oauthConfig.navigation; this.clientMetadataUrl = oauthConfig.clientMetadataUrl; - this.execution = execution; } /** @@ -149,13 +142,12 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { return this.storage.getScope(this.serverUrl); } - /** Redirect URL for the current flow (quick or guided). */ get redirectUrl(): string { - return this.redirectUrlProvider.getRedirectUrl(this.execution); + return this.redirectUrlProvider.getRedirectUrl(); } get redirect_uris(): string[] { - return [this.redirectUrlProvider.getRedirectUrl("quick")]; + return [this.redirectUrl]; } get clientMetadata(): OAuthClientMetadata { @@ -176,7 +168,7 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { } state(): string | Promise { - return generateOAuthStateWithExecution(this.execution); + return generateOAuthState(); } async clientInformation(): Promise { @@ -193,8 +185,11 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { async saveClientInformation( clientInformation: OAuthClientInformation, + options?: SaveClientInformationOptions, ): Promise { - await this.storage.saveClientInformation(this.serverUrl, clientInformation); + await this.storage.saveClientInformation(this.serverUrl, clientInformation, { + registrationKind: options?.registrationKind ?? "dcr", + }); } async saveScope(scope: string | undefined): Promise { diff --git a/core/auth/state-machine.ts b/core/auth/state-machine.ts deleted file mode 100644 index d98e18f19..000000000 --- a/core/auth/state-machine.ts +++ /dev/null @@ -1,292 +0,0 @@ -import type { OAuthStep, OAuthFlowState } from "./types.js"; -import type { BaseOAuthClientProvider } from "./providers.js"; -import { discoverScopes, getAuthorizationServerUrl } from "./discovery.js"; -import { - discoverAuthorizationServerMetadata, - registerClient, - startAuthorization, - exchangeAuthorization, - discoverOAuthProtectedResourceMetadata, - selectResourceURL, -} from "@modelcontextprotocol/sdk/client/auth.js"; -import { - OAuthMetadataSchema, - type OAuthProtectedResourceMetadata, -} from "@modelcontextprotocol/sdk/shared/auth.js"; - -export interface StateMachineContext { - state: OAuthFlowState; - serverUrl: string; - provider: BaseOAuthClientProvider; - updateState: (updates: Partial) => void; - fetchFn?: typeof fetch; -} - -export interface StateTransition { - canTransition: (context: StateMachineContext) => Promise; - execute: (context: StateMachineContext) => Promise; -} - -// State machine transitions -export const oauthTransitions: Record = { - metadata_discovery: { - canTransition: async () => true, - execute: async (context) => { - let resourceMetadata: OAuthProtectedResourceMetadata | null = null; - let resourceMetadataError: Error | null = null; - try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata( - context.serverUrl as string | URL, - ); - } catch (e) { - if (e instanceof Error) { - resourceMetadataError = e; - } else { - resourceMetadataError = new Error(String(e)); - } - } - - const authServerUrl = getAuthorizationServerUrl( - context.serverUrl, - resourceMetadata, - ); - - const resource: URL | undefined = resourceMetadata - ? await selectResourceURL( - context.serverUrl, - context.provider, - resourceMetadata, - ) - : undefined; - - const metadata = await discoverAuthorizationServerMetadata( - authServerUrl, - { - ...(context.fetchFn && { fetchFn: context.fetchFn }), - }, - ); - if (!metadata) { - throw new Error("Failed to discover OAuth metadata"); - } - const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata); - - await context.provider.saveServerMetadata(parsedMetadata); - - context.updateState({ - resourceMetadata, - resource, - resourceMetadataError, - authServerUrl, - oauthMetadata: parsedMetadata, - oauthStep: "client_registration", - }); - }, - }, - - client_registration: { - canTransition: async (context) => !!context.state.oauthMetadata, - execute: async (context) => { - const metadata = context.state.oauthMetadata!; - const clientMetadata = context.provider.clientMetadata; - - // Priority: user-provided scope > discovered scopes - if (!context.provider.scope || context.provider.scope.trim() === "") { - // Prefer scopes from resource metadata if available - const scopesSupported = - context.state.resourceMetadata?.scopes_supported || - metadata.scopes_supported; - // Add all supported scopes to client registration - if (scopesSupported) { - clientMetadata.scope = scopesSupported.join(" "); - } - } - - // Use pre-set client info from state (static client) when present; otherwise provider lookup → CIMD → DCR - let fullInformation = - context.state.oauthClientInfo ?? - (await context.provider.clientInformation()); - if (!fullInformation) { - // Check if provider has clientMetadataUrl (CIMD mode) - const clientMetadataUrl = - "clientMetadataUrl" in context.provider && - context.provider.clientMetadataUrl - ? context.provider.clientMetadataUrl - : undefined; - - // Check for CIMD support (SDK handles this in authInternal - we replicate it here) - const supportsUrlBasedClientId = - metadata?.client_id_metadata_document_supported === true; - const shouldUseUrlBasedClientId = - supportsUrlBasedClientId && clientMetadataUrl; - - if (shouldUseUrlBasedClientId) { - // SEP-991: URL-based Client IDs (CIMD) - // SDK creates { client_id: clientMetadataUrl } directly - no registration needed - fullInformation = { - client_id: clientMetadataUrl, - }; - } else { - // Fallback to DCR registration - fullInformation = await registerClient(context.serverUrl, { - metadata, - clientMetadata, - ...(context.fetchFn && { fetchFn: context.fetchFn }), - }); - } - await context.provider.saveClientInformation(fullInformation); - } - - context.updateState({ - oauthClientInfo: fullInformation, - oauthStep: "authorization_redirect", - }); - }, - }, - - authorization_redirect: { - canTransition: async (context) => - !!context.state.oauthMetadata && !!context.state.oauthClientInfo, - execute: async (context) => { - const metadata = context.state.oauthMetadata!; - const clientInformation = context.state.oauthClientInfo!; - - // Priority: user-provided scope > discovered scopes - let scope = context.provider.scope; - if (!scope || scope.trim() === "") { - scope = await discoverScopes( - context.serverUrl, - context.state.resourceMetadata ?? undefined, - context.fetchFn, - ); - } - - const providerState = context.provider.state(); - const state = await Promise.resolve(providerState); - const { authorizationUrl, codeVerifier } = await startAuthorization( - context.serverUrl, - { - metadata, - clientInformation, - redirectUrl: context.provider.redirectUrl, - scope, - state, - resource: context.state.resource ?? undefined, - }, - ); - - await context.provider.saveCodeVerifier(codeVerifier); - context.updateState({ - authorizationUrl: authorizationUrl, - oauthStep: "authorization_code", - }); - }, - }, - - authorization_code: { - canTransition: async () => true, - execute: async (context) => { - if ( - !context.state.authorizationCode || - context.state.authorizationCode.trim() === "" - ) { - context.updateState({ - validationError: "You need to provide an authorization code", - }); - // Don't advance if no code - throw new Error("Authorization code required"); - } - context.updateState({ - validationError: null, - oauthStep: "token_request", - }); - }, - }, - - token_request: { - canTransition: async (context) => { - const hasMetadata = !!context.provider.getServerMetadata(); - const clientInfo = - context.state.oauthClientInfo ?? - (await context.provider.clientInformation()); - return !!context.state.authorizationCode && hasMetadata && !!clientInfo; - }, - execute: async (context) => { - const codeVerifier = context.provider.codeVerifier(); - const metadata = context.provider.getServerMetadata(); - - if (!metadata) { - throw new Error("OAuth metadata not available"); - } - - const clientInformation = - context.state.oauthClientInfo ?? - (await context.provider.clientInformation()); - if (!clientInformation) { - throw new Error("Client information not available for token exchange"); - } - - const tokens = await exchangeAuthorization(context.serverUrl, { - metadata, - clientInformation, - authorizationCode: context.state.authorizationCode, - codeVerifier, - redirectUri: context.provider.redirectUrl, - resource: context.state.resource - ? context.state.resource instanceof URL - ? context.state.resource - : new URL(context.state.resource) - : undefined, - ...(context.fetchFn && { fetchFn: context.fetchFn }), - }); - - await context.provider.saveTokens(tokens); - context.updateState({ - oauthTokens: tokens, - oauthStep: "complete", - }); - }, - }, - - complete: { - canTransition: async () => false, - execute: async () => { - // No-op for complete state - }, - }, -}; - -export class OAuthStateMachine { - private serverUrl: string; - private provider: BaseOAuthClientProvider; - private updateState: (updates: Partial) => void; - private fetchFn?: typeof fetch; - - constructor( - serverUrl: string, - provider: BaseOAuthClientProvider, - updateState: (updates: Partial) => void, - fetchFn?: typeof fetch, - ) { - this.serverUrl = serverUrl; - this.provider = provider; - this.updateState = updateState; - this.fetchFn = fetchFn; - } - - async executeStep(state: OAuthFlowState): Promise { - const context: StateMachineContext = { - state, - serverUrl: this.serverUrl, - provider: this.provider, - updateState: this.updateState, - ...(this.fetchFn && { fetchFn: this.fetchFn }), - }; - - const transition = oauthTransitions[state.oauthStep]; - if (!(await transition.canTransition(context))) { - throw new Error(`Cannot transition from ${state.oauthStep}`); - } - - await transition.execute(context); - } -} diff --git a/core/auth/storage.ts b/core/auth/storage.ts index 524bbfbf7..f8b46874a 100644 --- a/core/auth/storage.ts +++ b/core/auth/storage.ts @@ -3,6 +3,7 @@ import type { OAuthTokens, OAuthMetadata, } from "@modelcontextprotocol/sdk/shared/auth.js"; +import type { OAuthClientRegistrationKind } from "./types.js"; /** * Abstract storage interface for OAuth state @@ -13,6 +14,12 @@ export interface SaveTokensOptions { enterpriseManaged?: boolean; } +export type { OAuthClientRegistrationKind }; + +export interface SaveClientInformationOptions { + registrationKind: "dcr" | "cimd"; +} + export interface OAuthStorage { /** * Get client information (preregistered or dynamically registered) @@ -22,12 +29,20 @@ export interface OAuthStorage { isPreregistered?: boolean, ): Promise; + /** + * Get how the dynamic client registration slot was established. + */ + getClientRegistrationKind( + serverUrl: string, + ): OAuthClientRegistrationKind | undefined; + /** * Save client information (dynamically registered) */ saveClientInformation( serverUrl: string, clientInformation: OAuthClientInformation, + options: SaveClientInformationOptions, ): Promise; /** @@ -93,12 +108,12 @@ export interface OAuthStorage { clearScope(serverUrl: string): void; /** - * Get server metadata (for guided mode) + * Get server metadata discovered during OAuth */ getServerMetadata(serverUrl: string): OAuthMetadata | null; /** - * Save server metadata (for guided mode) + * Save server metadata discovered during OAuth */ saveServerMetadata(serverUrl: string, metadata: OAuthMetadata): Promise; diff --git a/core/auth/store.ts b/core/auth/store.ts index afd49e1f8..a54bfb309 100644 --- a/core/auth/store.ts +++ b/core/auth/store.ts @@ -10,13 +10,15 @@ import type { OAuthTokens, OAuthMetadata, } from "@modelcontextprotocol/sdk/shared/auth.js"; -import type { IdpSessionState } from "./storage.js"; +import type { IdpSessionState, OAuthClientRegistrationKind } from "./storage.js"; /** * OAuth state for a single server */ export interface ServerOAuthState { clientInformation?: OAuthClientInformation; + /** Set when {@link clientInformation} is saved — DCR vs CIMD. */ + clientRegistrationKind?: OAuthClientRegistrationKind; preregisteredClientInformation?: OAuthClientInformation; tokens?: OAuthTokens; codeVerifier?: string; diff --git a/core/auth/types.ts b/core/auth/types.ts index ed5a01925..b555c9515 100644 --- a/core/auth/types.ts +++ b/core/auth/types.ts @@ -23,12 +23,12 @@ export interface StatusMessage { message: string; } -/** How the auth flow is stepped through (orthogonal to {@link AuthProtocol}). */ -export type AuthExecution = "quick" | "guided"; - -/** Which authorization protocol applies (orthogonal to {@link AuthExecution}). */ +/** Which authorization protocol applies. */ export type AuthProtocol = "standard" | "ema"; +/** How the active OAuth client id was established for this MCP server. */ +export type OAuthClientRegistrationKind = "static" | "dcr" | "cimd"; + /** Persisted OAuth authorization snapshot for an HTTP MCP server (storage + config). */ export interface OAuthConnectionState { authorized: boolean; @@ -38,8 +38,9 @@ export interface OAuthConnectionState { grantedScope?: string; tokens?: OAuthTokens; client?: { - source: "preregistered" | "dynamic"; clientId: string; + /** Absent for legacy storage entries predating registration kind tracking. */ + registrationKind?: OAuthClientRegistrationKind; hasClientSecret: boolean; }; authorizationServerMetadata?: OAuthMetadata; @@ -58,10 +59,8 @@ export function authProtocolFromEnterpriseManaged( return enterpriseManaged ? "ema" : "standard"; } -/** In-memory snapshot while an OAuth flow is active or just completed (quick or guided). */ +/** In-memory snapshot while an OAuth flow is active or just completed. */ export interface OAuthFlowState { - /** Quick (SDK / one-shot) vs guided (state machine with step events). */ - execution: AuthExecution; /** When auth reached step "complete" (ms since epoch), if applicable. */ completedAt: number | null; isInitiatingAuth: boolean; @@ -81,11 +80,10 @@ export interface OAuthFlowState { } export const EMPTY_OAUTH_FLOW_STATE: OAuthFlowState = { - execution: "guided", completedAt: null, isInitiatingAuth: false, oauthTokens: null, - oauthStep: "metadata_discovery", + oauthStep: "authorization_code", oauthMetadata: null, resourceMetadata: null, resourceMetadataError: null, diff --git a/core/auth/utils.ts b/core/auth/utils.ts index 6f18e5af8..638a40807 100644 --- a/core/auth/utils.ts +++ b/core/auth/utils.ts @@ -1,4 +1,3 @@ -import type { AuthExecution } from "./types.js"; import type { CallbackParams } from "./types.js"; /** @@ -71,47 +70,13 @@ export const generateOAuthState = (): string => { }; /** - * Generate OAuth `state` with execution prefix for single-redirect-URL flows. - * Format: `{execution}:{authId}` (e.g. "guided:a1b2c3..."). - * Protocol (standard vs EMA) is not encoded here — it comes from server config. - * The authId is 64 hex chars for CSRF protection and serves as session identifier. + * Parse OAuth `state` to extract the auth session id (CSRF token). + * Must be the 64-char hex value from {@link generateOAuthState}. */ -export const generateOAuthStateWithExecution = ( - execution: AuthExecution, -): string => { - const authId = generateOAuthState(); - return `${execution}:${authId}`; -}; - -/** @deprecated Use {@link generateOAuthStateWithExecution}. */ -export const generateOAuthStateWithMode = generateOAuthStateWithExecution; - -/** - * Parse OAuth `state` to extract execution and authId. - * Returns null if invalid. - * Legacy prefixes `normal:` and `ema-idp:` map to `quick`. - * Plain 64-char hex (no prefix) is treated as quick. - */ -export const parseOAuthState = ( - state: string, -): { execution: AuthExecution; authId: string } | null => { +export const parseOAuthState = (state: string): { authId: string } | null => { if (!state || typeof state !== "string") return null; - if (state.startsWith("quick:")) { - return { execution: "quick", authId: state.slice(6) }; - } - if (state.startsWith("guided:")) { - return { execution: "guided", authId: state.slice(7) }; - } - // Legacy execution prefixes - if (state.startsWith("normal:")) { - return { execution: "quick", authId: state.slice(7) }; - } - if (state.startsWith("ema-idp:")) { - return { execution: "quick", authId: state.slice(8) }; - } - // Legacy: plain 64-char hex if (/^[a-f0-9]{64}$/i.test(state)) { - return { execution: "quick", authId: state }; + return { authId: state }; } return null; }; @@ -136,3 +101,19 @@ export const generateOAuthErrorDescription = ( .filter(Boolean) .join("\n"); }; + +/** + * True when a thrown connect error represents an upstream 401. The remote + * transport preserves the status on the error object; as a fallback, match + * transport wording `"failed …(401)"` so unrelated `(401)` in messages does + * not trigger OAuth. + */ +export function isUnauthorizedError(err: unknown): boolean { + if (typeof err === "object" && err !== null) { + const status = (err as { status?: number; code?: number }).status; + const code = (err as { code?: number }).code; + if (status === 401 || code === 401) return true; + } + const message = err instanceof Error ? err.message : String(err); + return /\bfailed\b[^\n]*\(401\)/i.test(message); +} diff --git a/core/client/config-parse.ts b/core/client/config-parse.ts index 8ccfae114..573ffc364 100644 --- a/core/client/config-parse.ts +++ b/core/client/config-parse.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import type { ClientConfig } from "./types.js"; -const HttpUrlStringSchema = z.string().min(1).superRefine((val, ctx) => { +function refineAbsoluteUrl(val: string, ctx: z.RefinementCtx): void { const trimmed = val.trim(); if (!URL.canParse(trimmed)) { ctx.addIssue({ @@ -13,8 +13,57 @@ const HttpUrlStringSchema = z.string().min(1).superRefine((val, ctx) => { message: `Invalid URL: "${trimmed}" — must be an absolute URL (e.g. https://idp.example.com)`, }); } +} + +const HttpUrlStringSchema = z.string().min(1).superRefine((val, ctx) => { + refineAbsoluteUrl(val, ctx); }); +function refineCimdMetadataUrl( + val: string, + ctx: z.RefinementCtx, + required: boolean, +): void { + const trimmed = val.trim(); + if (trimmed === "") { + if (required) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "CIMD client metadata URL is required when CIMD is enabled", + }); + } + return; + } + refineAbsoluteUrl(trimmed, ctx); + try { + const url = new URL(trimmed); + if (url.protocol !== "https:") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "CIMD client metadata URL must use HTTPS", + }); + } + if (url.pathname === "/" || url.pathname === "") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "CIMD client metadata URL must include a path (not the site root)", + }); + } + } catch { + // refineAbsoluteUrl already reported invalid URL + } +} + +const CimdConfigSchema = z + .object({ + enabled: z.boolean(), + clientMetadataUrl: z.string(), + }) + .superRefine((data, ctx) => { + refineCimdMetadataUrl(data.clientMetadataUrl, ctx, data.enabled === true); + }); + const EnterpriseManagedAuthIdpConfigSchema = z.object({ issuer: HttpUrlStringSchema, clientId: z.string().min(1), @@ -28,6 +77,7 @@ const ClientConfigSchema = z.object({ idp: EnterpriseManagedAuthIdpConfigSchema, }) .optional(), + cimd: CimdConfigSchema.optional(), }); /** diff --git a/core/client/index.ts b/core/client/index.ts index 34b6f891a..fc4c8cd0b 100644 --- a/core/client/index.ts +++ b/core/client/index.ts @@ -1,9 +1,12 @@ export type { ClientConfig, + CimdConfig, EnterpriseManagedAuthIdpConfig, } from "./types.js"; export { + getActiveCimdClientMetadataUrl, getActiveEnterpriseManagedAuthIdp, + isCimdEnabled, isEnterpriseManagedAuthEnabled, } from "./types.js"; export { @@ -18,3 +21,10 @@ export { saveClientConfigRemote, type RemoteClientConfigOptions, } from "./remote.js"; +export { + buildRunnerClientAuthOptions, + isOAuthCapableServerConfig, + loadRunnerClientConfig, + type LoadRunnerClientConfigOptions, + type RunnerClientConfigOverrides, +} from "./runner.js"; diff --git a/core/client/runner.ts b/core/client/runner.ts new file mode 100644 index 000000000..5a9f38d7b --- /dev/null +++ b/core/client/runner.ts @@ -0,0 +1,116 @@ +/** + * Install-level client config loading and InspectorClient auth option wiring + * for Node runners (TUI, CLI). + */ + +import { KeyringSecretStore } from "../auth/node/secret-store.js"; +import type { InspectorClientOptions, InspectorServerSettings } from "../mcp/types.js"; +import { loadClientConfig } from "./config.js"; +import type { ClientConfig } from "./types.js"; +import { + getActiveCimdClientMetadataUrl, + getActiveEnterpriseManagedAuthIdp, +} from "./types.js"; + +export interface LoadRunnerClientConfigOptions { + /** Explicit path from `--client-config` (or MCP_CLIENT_CONFIG_PATH when unset). */ + clientConfigPath?: string; +} + +/** Load install-level client.json with keychain-backed IdP secrets. */ +export async function loadRunnerClientConfig( + options?: LoadRunnerClientConfigOptions, +): Promise { + const customPath = + options?.clientConfigPath?.trim() || + process.env.MCP_CLIENT_CONFIG_PATH?.trim() || + undefined; + const secretStore = new KeyringSecretStore(); + return loadClientConfig({ filePath: customPath, secretStore }); +} + +export interface RunnerClientConfigOverrides { + clientId?: string; + clientSecret?: string; + clientMetadataUrl?: string; +} + +/** HTTP transports (SSE, streamable-http) can use OAuth. */ +export function isOAuthCapableServerConfig( + config: { type?: string } | null | undefined, +): boolean { + if (!config) return false; + return config.type === "sse" || config.type === "streamable-http"; +} + +/** + * Derive OAuth / EMA / CIMD InspectorClient options from install client.json, + * per-server settings, and CLI flag overrides (flags win over client.json). + */ +export function buildRunnerClientAuthOptions( + clientConfig: ClientConfig, + savedSettings?: InspectorServerSettings, + cliOverrides?: RunnerClientConfigOverrides, +): Pick< + InspectorClientOptions, + "oauth" | "enterpriseManagedAuth" | "installEnterpriseManagedAuth" +> { + const activeIdp = getActiveEnterpriseManagedAuthIdp(clientConfig); + const activeCimdUrl = getActiveCimdClientMetadataUrl(clientConfig); + + const oauthFromServer = + savedSettings && + (savedSettings.oauthClientId || + savedSettings.oauthClientSecret || + savedSettings.oauthScopes || + savedSettings.enterpriseManaged) + ? { + ...(savedSettings.oauthClientId && { + clientId: savedSettings.oauthClientId, + }), + ...(savedSettings.oauthClientSecret && { + clientSecret: savedSettings.oauthClientSecret, + }), + ...(savedSettings.oauthScopes && { + scope: savedSettings.oauthScopes, + }), + ...(savedSettings.enterpriseManaged && { + enterpriseManaged: true, + }), + } + : undefined; + + const clientMetadataUrl = + cliOverrides?.clientMetadataUrl?.trim() || activeCimdUrl; + + const oauthFromCli = + cliOverrides?.clientId || + cliOverrides?.clientSecret || + clientMetadataUrl + ? { + ...(cliOverrides?.clientId && { clientId: cliOverrides.clientId }), + ...(cliOverrides?.clientSecret && { + clientSecret: cliOverrides.clientSecret, + }), + ...(clientMetadataUrl && { clientMetadataUrl }), + } + : undefined; + + const oauth = + oauthFromServer || oauthFromCli + ? { + ...(oauthFromServer ?? {}), + ...(oauthFromCli ?? {}), + } + : undefined; + + return { + ...(oauth && { oauth }), + ...(activeIdp && { + enterpriseManagedAuth: { idp: activeIdp }, + }), + ...(clientConfig.enterpriseManagedAuth && { + installEnterpriseManagedAuth: clientConfig.enterpriseManagedAuth, + }), + }; +} diff --git a/core/client/types.ts b/core/client/types.ts index 1901f3027..9c37b5351 100644 --- a/core/client/types.ts +++ b/core/client/types.ts @@ -11,12 +11,20 @@ export interface EnterpriseManagedAuthIdpConfig { clientSecret?: string; } +/** Install-level CIMD (Client ID Metadata Document) settings. */ +export interface CimdConfig { + /** When false, the metadata URL is kept but CIMD is inactive install-wide. */ + enabled?: boolean; + clientMetadataUrl: string; +} + export interface ClientConfig { enterpriseManagedAuth?: { /** When false, IdP credentials are kept but EMA is inactive install-wide. */ enabled?: boolean; idp: EnterpriseManagedAuthIdpConfig; }; + cimd?: CimdConfig; } /** True when install-level EMA IdP config is active (not just stored). */ @@ -34,3 +42,16 @@ export function getActiveEnterpriseManagedAuthIdp( if (!isEnterpriseManagedAuthEnabled(config)) return undefined; return config.enterpriseManagedAuth!.idp; } + +/** True when install-level CIMD is active (not just stored). */ +export function isCimdEnabled(config: ClientConfig): boolean { + return config.cimd?.enabled === true; +} + +export function getActiveCimdClientMetadataUrl( + config: ClientConfig, +): string | undefined { + if (!isCimdEnabled(config)) return undefined; + const url = config.cimd?.clientMetadataUrl?.trim(); + return url || undefined; +} diff --git a/core/mcp/config.ts b/core/mcp/config.ts index c3b634286..808055470 100644 --- a/core/mcp/config.ts +++ b/core/mcp/config.ts @@ -27,3 +27,16 @@ export function getServerType(config: MCPServerConfig): ServerType { export function isOAuthCapableServerType(type: ServerType): boolean { return type === "sse" || type === "streamable-http"; } + +/** + * MCP server URL used as the OAuth storage key (includes path, for discovery). + * Undefined for stdio transports. + */ +export function getOAuthServerUrl( + config: MCPServerConfig, +): string | undefined { + if (config.type === "sse" || config.type === "streamable-http") { + return config.url; + } + return undefined; +} diff --git a/core/mcp/index.ts b/core/mcp/index.ts index cc65b7b7e..f8e37cd3f 100644 --- a/core/mcp/index.ts +++ b/core/mcp/index.ts @@ -11,7 +11,11 @@ export type { // Re-export type-safe event target types for consumers export type { InspectorClientEventMap } from "./inspectorClientEventTarget.js"; -export { getServerType, isOAuthCapableServerType } from "./config.js"; +export { + getOAuthServerUrl, + getServerType, + isOAuthCapableServerType, +} from "./config.js"; // Re-export types used by consumers export type { diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 8cfc0f586..7567d7258 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -179,6 +179,8 @@ export class InspectorClient extends InspectorClientEventTarget { private outputValidator: AjvJsonSchemaValidator | null = null; private transport: Transport | MessageTrackingTransport | null = null; private baseTransport: Transport | null = null; + /** True when the cached transport was built with an OAuth authProvider attached. */ + private transportHasAuthProvider = false; private pipeStderr: boolean; private initialLoggingLevel?: LoggingLevel; private sample: boolean; @@ -308,8 +310,6 @@ export class InspectorClient extends InspectorClientEventTarget { initialConfig: oauthConfig, enterpriseManagedAuth: options.enterpriseManagedAuth, installEnterpriseManagedAuth: options.installEnterpriseManagedAuth, - dispatchOAuthStepChange: (detail) => - this.dispatchTypedEvent("oauthStepChange", detail), dispatchOAuthComplete: (detail) => this.dispatchTypedEvent("oauthComplete", detail), dispatchOAuthAuthorizationRequired: (detail) => @@ -680,6 +680,26 @@ export class InspectorClient extends InspectorClientEventTarget { return updatedTask; } + /** + * Drop the cached MCP transport without a full disconnect() teardown. + * Used when a pre-auth connect failed or tokens arrived after an unauthenticated + * transport was created, so the next connect() can attach authProvider. + */ + private async dropCachedTransport(): Promise { + if (!this.baseTransport && !this.transport) { + this.transportHasAuthProvider = false; + return; + } + try { + await this.client?.close(); + } catch { + // Ignore errors on close + } + this.baseTransport = null; + this.transport = null; + this.transportHasAuthProvider = false; + } + /** * Connect to the MCP server */ @@ -691,6 +711,18 @@ export class InspectorClient extends InspectorClientEventTarget { return; } + const oauthManager = this.oauthManager; + if ( + this.baseTransport && + this.isHttpOAuthConfig() && + oauthManager && + !this.transportHasAuthProvider && + !oauthManager.isEnterpriseManaged() && + (await oauthManager.isOAuthAuthorized()) + ) { + await this.dropCachedTransport(); + } + // Create transport (single place for create / wrap / attach). if (!this.baseTransport) { const transportOptions: CreateTransportOptions = { @@ -707,13 +739,10 @@ export class InspectorClient extends InspectorClientEventTarget { }, ...(this.serverSettings && { settings: this.serverSettings }), }; - const oauthManager = this.oauthManager; if (this.isHttpOAuthConfig() && oauthManager) { if (oauthManager.isEnterpriseManaged()) { await oauthManager.trySilentEnterpriseManagedAuth(); - } - const provider = await oauthManager.createOAuthProviderForTransport(); - if (oauthManager.isEnterpriseManaged()) { + const provider = await oauthManager.createOAuthProviderForTransport(); const tokens = await provider.tokens(); if (!tokens?.access_token) { const err = new Error( @@ -723,9 +752,16 @@ export class InspectorClient extends InspectorClientEventTarget { err.code = 401; throw err; } + transportOptions.authProvider = provider; + } else if (await oauthManager.isOAuthAuthorized()) { + // Without stored tokens, omit authProvider so connect() surfaces a plain + // 401 instead of the SDK opening a browser before the app callback + // server is listening (TUI/CLI run authenticate() explicitly). + transportOptions.authProvider = + await oauthManager.createOAuthProviderForTransport(); } - transportOptions.authProvider = provider; } + this.transportHasAuthProvider = !!transportOptions.authProvider; const { transport: baseTransport } = this.transportClientFactory( this.transportConfig, transportOptions, @@ -1082,6 +1118,9 @@ export class InspectorClient extends InspectorClientEventTarget { } catch (error) { this.status = "error"; this.dispatchTypedEvent("statusChange", this.status); + if (this.baseTransport && !this.transportHasAuthProvider) { + await this.dropCachedTransport(); + } // Deliberately do NOT dispatch the `error` event here: this is the // awaited `connect()` path, so re-throwing hands the reason straight to // the caller. The `error` event is reserved for non-awaited transitions @@ -1143,6 +1182,7 @@ export class InspectorClient extends InspectorClientEventTarget { // Null out transport so next connect() creates a fresh one. this.baseTransport = null; this.transport = null; + this.transportHasAuthProvider = false; // Update status - any onclose fired during close() above deferred to us // (see `disconnecting`), so this is the single place the explicit-disconnect // path settles the status and emits `disconnect`. @@ -2673,54 +2713,15 @@ export class InspectorClient extends InspectorClientEventTarget { } /** - * Initiates OAuth flow using SDK's auth() function (normal mode) - * Can be called directly by user or automatically triggered by 401 errors + * Initiates OAuth flow. Can be called directly by user or automatically + * triggered by 401 errors. */ async authenticate(): Promise { return this.ensureOAuthManager().authenticate(); } /** - * Starts guided OAuth flow (step-by-step). Runs only the first step. - * Use proceedOAuthStep() to advance. When oauthStep is "authorization_code", - * set authorizationCode and call proceedOAuthStep() to complete. - */ - async beginGuidedAuth(): Promise { - return this.ensureOAuthManager().beginGuidedAuth(); - } - - /** - * Runs guided OAuth flow to completion. If already started (via beginGuidedAuth), - * continues from current step. Otherwise initializes and runs from the start. - * Returns the authorization URL when user must authorize, or undefined if already complete. - */ - async runGuidedAuth(): Promise { - return this.ensureOAuthManager().runGuidedAuth(); - } - - /** - * Set authorization code for guided OAuth flow. - * Validates that the client is in guided OAuth mode (has active state machine). - * @param authorizationCode The authorization code from the OAuth callback - * @param completeFlow If true, automatically proceed through all remaining steps to completion. - * If false, only set the code and wait for manual progression via proceedOAuthStep(). - * Defaults to false for manual step-by-step control. - * @throws Error if not in guided OAuth flow or not at authorization_code step - */ - async setGuidedAuthorizationCode( - authorizationCode: string, - completeFlow: boolean = false, - ): Promise { - return this.ensureOAuthManager().setGuidedAuthorizationCode( - authorizationCode, - completeFlow, - ); - } - - /** - * Completes OAuth flow with authorization code. - * For guided mode, this calls setGuidedAuthorizationCode(code, true) internally. - * For normal mode, uses SDK auth() directly. + * Completes OAuth flow with authorization code from the redirect callback. */ async completeOAuthFlow(authorizationCode: string): Promise { return this.ensureOAuthManager().completeOAuthFlow(authorizationCode); @@ -2754,17 +2755,14 @@ export class InspectorClient extends InspectorClientEventTarget { } /** - * In-memory OAuth flow snapshot (quick or guided). Undefined when no flow - * has run on this client instance; use {@link getOAuthState} for persisted - * authorization state. + * In-memory OAuth flow snapshot. Undefined when no flow has run on this + * client instance; use {@link getOAuthState} for persisted authorization state. */ getOAuthFlowState(): OAuthFlowState | undefined { return this.oauthManager?.getOAuthFlowState(); } - /** - * Current step when an OAuth flow is active (quick or guided). - */ + /** Current step when an OAuth flow is active. */ getOAuthFlowStep(): OAuthStep | undefined { return this.oauthManager?.getOAuthFlowStep(); } @@ -2779,11 +2777,4 @@ export class InspectorClient extends InspectorClientEventTarget { } return this.oauthManager.getOAuthState(); } - - /** - * Manually progress to next step in guided OAuth flow - */ - async proceedOAuthStep(): Promise { - return this.ensureOAuthManager().proceedOAuthStep(); - } } diff --git a/core/mcp/inspectorClientEventTarget.ts b/core/mcp/inspectorClientEventTarget.ts index e50ceb16d..a5f9d8822 100644 --- a/core/mcp/inspectorClientEventTarget.ts +++ b/core/mcp/inspectorClientEventTarget.ts @@ -39,7 +39,6 @@ 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 { OAuthStep, OAuthFlowState } from "../auth/types.js"; /** Task with createdAt optional so we can emit synthetic tasks (e.g. on result/error) that omit it. */ export type TaskWithOptionalCreatedAt = Omit & { @@ -146,11 +145,6 @@ export interface InspectorClientEventMap { // Session persistence (dispatched by client; FetchRequestLogState listens and saves) saveSession: { sessionId: string }; // OAuth events (#1302 — fired by the ported oauthManager / InspectorClient) - oauthStepChange: { - step: OAuthStep; - previousStep: OAuthStep; - state: Partial; - }; oauthComplete: { tokens: OAuthTokens }; oauthAuthorizationRequired: { url: URL }; oauthError: { error: Error }; diff --git a/core/mcp/node/index.ts b/core/mcp/node/index.ts index 2162a7241..a398f1c84 100644 --- a/core/mcp/node/index.ts +++ b/core/mcp/node/index.ts @@ -19,4 +19,5 @@ export { type ResolvedServer, type ServerLoadOptions, } from "./servers.js"; +export { rehydrateMcpConfigFromKeychain } from "./server-secrets.js"; export { createTransportNode } from "./transport.js"; diff --git a/core/mcp/node/server-secrets.ts b/core/mcp/node/server-secrets.ts new file mode 100644 index 000000000..4d883fc50 --- /dev/null +++ b/core/mcp/node/server-secrets.ts @@ -0,0 +1,34 @@ +import type { SecretStore } from "../../auth/node/secret-store.js"; +import { + expectedSecretFields, + mergeSecretsIntoStored, +} from "../serverList.js"; +import type { MCPConfig } from "../types.js"; + +/** + * Merge per-server secrets from the OS keychain into an on-disk MCP catalog + * shape. Mirrors the web `/api/servers` GET rehydration path so TUI/CLI see + * the same effective OAuth client secrets and stdio env values as the browser. + */ +export async function rehydrateMcpConfigFromKeychain( + config: MCPConfig, + secretStore: SecretStore, +): Promise { + const out: MCPConfig = { mcpServers: {} }; + await Promise.all( + Object.entries(config.mcpServers).map(async ([id, stored]) => { + const fields = expectedSecretFields(stored); + const secrets: Record = {}; + await Promise.all( + fields.map(async (field) => { + const value = await secretStore.get(id, field); + if (value !== null) { + secrets[field] = value; + } + }), + ); + out.mcpServers[id] = mergeSecretsIntoStored(stored, secrets); + }), + ); + return out; +} diff --git a/core/mcp/node/servers.ts b/core/mcp/node/servers.ts index 10180a044..8bda9cc21 100644 --- a/core/mcp/node/servers.ts +++ b/core/mcp/node/servers.ts @@ -1,3 +1,7 @@ +import { + KeyringSecretStore, + type SecretStore, +} from "../../auth/node/secret-store.js"; import type { InspectorServerSettings, MCPServerConfig } from "../types.js"; import { DEFAULT_MAX_FETCH_REQUESTS, DEFAULT_TASK_TTL_MS } from "../types.js"; import { mcpConfigToServerEntries } from "../serverList.js"; @@ -11,6 +15,7 @@ import { withDefaultCatalogPath, type ServerConfigOptions, } from "./config.js"; +import { rehydrateMcpConfigFromKeychain } from "./server-secrets.js"; /** * A server resolved from a catalog/config file or an ad-hoc target, paired with @@ -27,6 +32,8 @@ export type ResolvedServer = { * broadcast into every resolved server's settings. */ export type ServerLoadOptions = ServerConfigOptions & { headers?: Record; + /** Test injection; defaults to {@link KeyringSecretStore} for catalog/config loads. */ + secretStore?: SecretStore; }; /** @@ -78,9 +85,9 @@ function mergeSettings( * enforces the `--catalog`/`--config`/ad-hoc conflict matrix, and seeds an empty * writable catalog on first run (a missing read-only `--config` still errors). */ -export function loadServerEntries( +export async function loadServerEntries( serverOptions: ServerLoadOptions, -): Record { +): Promise> { serverOptions = withDefaultCatalogPath(serverOptions); const conflict = serverSourceConflict({ @@ -95,7 +102,9 @@ export function loadServerEntries( const source = resolveServerSource(serverOptions); if (source) { - const config = readServerListFile(source.path, source.writable); + let config = readServerListFile(source.path, source.writable); + const secretStore = serverOptions.secretStore ?? new KeyringSecretStore(); + config = await rehydrateMcpConfigFromKeychain(config, secretStore); const entries = mcpConfigToServerEntries(config); const result: Record = {}; for (const entry of entries) { diff --git a/core/mcp/oauthManager.ts b/core/mcp/oauthManager.ts index 5d80e2506..298d39ad8 100644 --- a/core/mcp/oauthManager.ts +++ b/core/mcp/oauthManager.ts @@ -1,13 +1,12 @@ /** * Internal OAuth sub-manager for InspectorClient. - * Holds OAuth config, state machine, and guided state; orchestrates quick and guided flows. + * Holds OAuth config and in-memory flow state; orchestrates authenticate() / completeOAuthFlow(). * Not part of the public API; InspectorClient delegates to this module. */ import { BaseOAuthClientProvider } from "../auth/providers.js"; -import type { AuthExecution, OAuthFlowState, OAuthStep } from "../auth/types.js"; +import type { OAuthFlowState, OAuthStep } from "../auth/types.js"; import { EMPTY_OAUTH_FLOW_STATE } from "../auth/types.js"; -import { OAuthStateMachine } from "../auth/state-machine.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"; @@ -24,6 +23,7 @@ import { } from "../auth/ema/emaFlow.js"; import { buildOAuthConnectionState, + hasPersistedOAuthServerState, isServerOAuthConfigured, protocolFromOAuthConfig, } from "../auth/connection-state.js"; @@ -47,11 +47,6 @@ export interface OAuthManagerParams { enterpriseManagedAuth?: { idp: EnterpriseManagedAuthIdpConfig }; /** Install-level EMA block (including when disabled) for user-facing errors. */ installEnterpriseManagedAuth?: ClientConfig["enterpriseManagedAuth"]; - dispatchOAuthStepChange: (detail: { - step: OAuthStep; - previousStep: OAuthStep; - state: Partial; - }) => void; dispatchOAuthComplete: (detail: { tokens: OAuthTokens }) => void; dispatchOAuthAuthorizationRequired: (detail: { url: URL }) => void; dispatchOAuthError: (detail: { error: Error }) => void; @@ -64,7 +59,6 @@ export interface OAuthManagerParams { export class OAuthManager { private params: OAuthManagerParams; private oauthConfig: OAuthManagerConfig; - private oauthStateMachine: OAuthStateMachine | null = null; private oauthFlowState: OAuthFlowState | null = null; constructor(params: OAuthManagerParams) { @@ -89,9 +83,7 @@ export class OAuthManager { return this.params.getServerUrl(); } - private async createOAuthProvider( - execution: AuthExecution, - ): Promise { + private async createOAuthProvider(): Promise { if ( !this.oauthConfig.storage || !this.oauthConfig.redirectUrlProvider || @@ -103,16 +95,12 @@ export class OAuthManager { } const serverUrl = this.getServerUrl(); - const provider = new BaseOAuthClientProvider( - serverUrl, - { - storage: this.oauthConfig.storage, - redirectUrlProvider: this.oauthConfig.redirectUrlProvider, - navigation: this.oauthConfig.navigation, - clientMetadataUrl: this.oauthConfig.clientMetadataUrl, - }, - execution, - ); + const provider = new BaseOAuthClientProvider(serverUrl, { + storage: this.oauthConfig.storage, + redirectUrlProvider: this.oauthConfig.redirectUrlProvider, + navigation: this.oauthConfig.navigation, + clientMetadataUrl: this.oauthConfig.clientMetadataUrl, + }); provider.setEventTarget(this.params.getEventTarget()); @@ -159,7 +147,7 @@ export class OAuthManager { resourceClientId: this.oauthConfig.clientId, resourceClientSecret: this.oauthConfig.clientSecret, scope: this.oauthConfig.scope, - redirectUrl: this.oauthConfig.redirectUrlProvider.getRedirectUrl("quick"), + redirectUrl: this.oauthConfig.redirectUrlProvider.getRedirectUrl(), storage: this.oauthConfig.storage, fetchFn: this.params.effectiveAuthFetch, }; @@ -196,7 +184,6 @@ export class OAuthManager { this.oauthConfig.navigation!.navigateToAuthorization(authorizationUrl); this.oauthFlowState = { ...EMPTY_OAUTH_FLOW_STATE, - execution: "quick", oauthStep: "authorization_code", authorizationUrl, }; @@ -208,7 +195,7 @@ export class OAuthManager { return this.authenticateEnterpriseManaged(); } - const provider = await this.createOAuthProvider("quick"); + const provider = await this.createOAuthProvider(); const serverUrl = this.getServerUrl(); provider.clearCapturedAuthUrl(); @@ -247,7 +234,6 @@ export class OAuthManager { const clientInfo = await provider.clientInformation(); this.oauthFlowState = { ...EMPTY_OAUTH_FLOW_STATE, - execution: "quick", oauthStep: "authorization_code", authorizationUrl: capturedUrl, oauthClientInfo: clientInfo ?? null, @@ -255,132 +241,6 @@ export class OAuthManager { return capturedUrl; } - async beginGuidedAuth(): Promise { - const provider = await this.createOAuthProvider("guided"); - const serverUrl = this.getServerUrl(); - - this.oauthFlowState = { ...EMPTY_OAUTH_FLOW_STATE }; - if (this.oauthConfig.clientId) { - this.oauthFlowState.oauthClientInfo = { - client_id: this.oauthConfig.clientId, - ...(this.oauthConfig.clientSecret && { - client_secret: this.oauthConfig.clientSecret, - }), - }; - } - this.oauthStateMachine = new OAuthStateMachine( - serverUrl, - provider, - (updates) => { - const state = this.oauthFlowState; - if (!state) throw new Error("OAuth state not initialized"); - const previousStep = state.oauthStep; - this.oauthFlowState = { ...state, ...updates }; - if (updates.oauthStep === "complete") { - this.oauthFlowState!.completedAt = Date.now(); - } - const step = updates.oauthStep ?? previousStep; - this.params.dispatchOAuthStepChange({ - step, - previousStep, - state: updates, - }); - }, - this.params.effectiveAuthFetch, - ); - - await this.oauthStateMachine.executeStep(this.oauthFlowState); - } - - async runGuidedAuth(): Promise { - if (!this.oauthStateMachine || !this.oauthFlowState) { - await this.beginGuidedAuth(); - } - - const machine = this.oauthStateMachine; - if (!machine) { - throw new Error("Guided auth failed to initialize state"); - } - - while (true) { - const state = this.oauthFlowState; - if (!state) { - throw new Error("Guided auth failed to initialize state"); - } - if ( - state.oauthStep === "authorization_code" || - state.oauthStep === "complete" - ) { - break; - } - await machine.executeStep(state); - } - - const state = this.oauthFlowState; - if (state?.oauthStep === "complete") { - return undefined; - } - if (!state?.authorizationUrl) { - throw new Error("Failed to generate authorization URL"); - } - - const stateParam = state.authorizationUrl.searchParams.get("state"); - if (stateParam && this.params.onBeforeOAuthRedirect) { - const parsedState = parseOAuthState(stateParam); - if (parsedState?.authId) { - await this.params.onBeforeOAuthRedirect(parsedState.authId); - } - } - - this.params.dispatchOAuthAuthorizationRequired({ - url: state.authorizationUrl, - }); - - return state.authorizationUrl; - } - - async setGuidedAuthorizationCode( - authorizationCode: string, - completeFlow: boolean = false, - ): Promise { - if (!this.oauthStateMachine || !this.oauthFlowState) { - throw new Error( - "Not in guided OAuth flow. Call beginGuidedAuth() first.", - ); - } - const currentStep = this.oauthFlowState.oauthStep; - if (currentStep !== "authorization_code") { - throw new Error( - `Cannot set authorization code at step ${currentStep}. Expected step: authorization_code`, - ); - } - - this.oauthFlowState.authorizationCode = authorizationCode; - - if (completeFlow) { - await this.oauthStateMachine.executeStep(this.oauthFlowState); - let step: OAuthStep = this.oauthFlowState.oauthStep; - while (step !== "complete") { - await this.oauthStateMachine.executeStep(this.oauthFlowState); - step = this.oauthFlowState.oauthStep; - } - - if (!this.oauthFlowState.oauthTokens) { - throw new Error("Failed to exchange authorization code for tokens"); - } - - this.params.dispatchOAuthComplete({ - tokens: this.oauthFlowState.oauthTokens, - }); - } else { - this.params.dispatchOAuthStepChange({ - step: this.oauthFlowState.oauthStep, - previousStep: this.oauthFlowState.oauthStep, - state: { authorizationCode }, - }); - } - } - async completeOAuthFlow(authorizationCode: string): Promise { try { if (this.isEnterpriseManaged()) { @@ -392,7 +252,6 @@ export class OAuthManager { const completedAt = Date.now(); this.oauthFlowState = { ...EMPTY_OAUTH_FLOW_STATE, - execution: "quick", oauthStep: "complete", oauthTokens: tokens, completedAt, @@ -401,50 +260,45 @@ export class OAuthManager { return; } - if (this.oauthStateMachine && this.oauthFlowState) { - await this.setGuidedAuthorizationCode(authorizationCode, true); - } else { - const provider = await this.createOAuthProvider("quick"); - const serverUrl = this.getServerUrl(); - - const result = await auth(provider, { - serverUrl, - authorizationCode, - fetchFn: this.params.effectiveAuthFetch, - }); - - if (result !== "AUTHORIZED") { - throw new Error( - `Expected AUTHORIZED after providing authorization code, got: ${result}`, - ); - } + const provider = await this.createOAuthProvider(); + const serverUrl = this.getServerUrl(); - const tokens = await provider.tokens(); - if (!tokens) { - throw new Error("Failed to retrieve tokens after authorization"); - } + const result = await auth(provider, { + serverUrl, + authorizationCode, + fetchFn: this.params.effectiveAuthFetch, + }); - const clientInfo = await provider.clientInformation(); - const completedAt = Date.now(); - this.oauthFlowState = this.oauthFlowState - ? { - ...this.oauthFlowState, - oauthStep: "complete", - oauthTokens: tokens, - oauthClientInfo: clientInfo ?? null, - completedAt, - } - : { - ...EMPTY_OAUTH_FLOW_STATE, - execution: "quick", - oauthStep: "complete", - oauthTokens: tokens, - oauthClientInfo: clientInfo ?? null, - completedAt, - }; + if (result !== "AUTHORIZED") { + throw new Error( + `Expected AUTHORIZED after providing authorization code, got: ${result}`, + ); + } - this.params.dispatchOAuthComplete({ tokens }); + const tokens = await provider.tokens(); + if (!tokens) { + throw new Error("Failed to retrieve tokens after authorization"); } + + const clientInfo = await provider.clientInformation(); + const completedAt = Date.now(); + this.oauthFlowState = this.oauthFlowState + ? { + ...this.oauthFlowState, + oauthStep: "complete", + oauthTokens: tokens, + oauthClientInfo: clientInfo ?? null, + completedAt, + } + : { + ...EMPTY_OAUTH_FLOW_STATE, + oauthStep: "complete", + oauthTokens: tokens, + oauthClientInfo: clientInfo ?? null, + completedAt, + }; + + this.params.dispatchOAuthComplete({ tokens }); } catch (error) { this.params.dispatchOAuthError({ error: error instanceof Error ? error : new Error(String(error)), @@ -458,7 +312,7 @@ export class OAuthManager { return this.oauthFlowState.oauthTokens; } - const provider = await this.createOAuthProvider("quick"); + const provider = await this.createOAuthProvider(); try { return await provider.tokens(); } catch { @@ -475,7 +329,6 @@ export class OAuthManager { this.oauthConfig.storage.clear(serverUrl); this.oauthFlowState = null; - this.oauthStateMachine = null; } async isOAuthAuthorized(): Promise { @@ -496,7 +349,17 @@ export class OAuthManager { * Returns undefined when OAuth is not configured for the server. */ async getOAuthState(): Promise { - if (!isServerOAuthConfigured(this.oauthConfig)) { + const storage = this.oauthConfig.storage; + if (!storage) { + return undefined; + } + + const serverUrl = this.getServerUrl(); + const hasConfiguredOptions = isServerOAuthConfigured(this.oauthConfig); + if ( + !hasConfiguredOptions && + !(await hasPersistedOAuthServerState(storage, serverUrl)) + ) { return undefined; } @@ -510,16 +373,6 @@ export class OAuthManager { }); } - async proceedOAuthStep(): Promise { - if (!this.oauthStateMachine || !this.oauthFlowState) { - throw new Error( - "Not in guided OAuth flow. Call authenticateGuided() first.", - ); - } - - await this.oauthStateMachine.executeStep(this.oauthFlowState); - } - /** * Re-run EMA legs 2–3 when resource tokens expire but IdP session remains valid. */ @@ -536,7 +389,7 @@ export class OAuthManager { async createOAuthProviderForTransport(): Promise< BaseOAuthClientProvider | EmaTransportOAuthProvider > { - const provider = await this.createOAuthProvider("quick"); + const provider = await this.createOAuthProvider(); if (this.isEnterpriseManaged()) { return new EmaTransportOAuthProvider(provider, this.getEmaFlowConfig()); } diff --git a/core/react/useClientSettingsDraft.ts b/core/react/useClientSettingsDraft.ts index 00ad25acf..714a78e59 100644 --- a/core/react/useClientSettingsDraft.ts +++ b/core/react/useClientSettingsDraft.ts @@ -18,7 +18,7 @@ export interface UseClientSettingsDraftOptions { export interface UseClientSettingsDraftResult { draft: T | null; - onChange: (next: T) => void; + onChange: (next: T | ((prev: T) => T)) => void; flush: () => void; } @@ -31,24 +31,26 @@ export function useClientSettingsDraft({ }: UseClientSettingsDraftOptions): UseClientSettingsDraftResult { const [draft, setDraft] = useState(null); const timerRef = useRef | undefined>(undefined); + const latestValuesRef = useRef(null); const resolveInitialRef = useRef(resolveInitial); const onPersistRef = useRef(onPersist); const onErrorRef = useRef(onError); - const draftRef = useRef(null); const openedRef = useRef(opened); resolveInitialRef.current = resolveInitial; onPersistRef.current = onPersist; onErrorRef.current = onError; - draftRef.current = draft; openedRef.current = opened; useEffect(() => { if (!opened) { setDraft(null); + latestValuesRef.current = null; return; } - setDraft(resolveInitialRef.current()); + const initial = resolveInitialRef.current(); + setDraft(initial); + latestValuesRef.current = initial; }, [opened]); useEffect(() => { @@ -67,23 +69,34 @@ export function useClientSettingsDraft({ }, []); const onChange = useCallback( - (next: T) => { + (next: T | ((prev: T) => T)) => { if (!opened) return; - setDraft(next); + const prev = latestValuesRef.current; + if (prev === null) return; + const resolved = + typeof next === "function" + ? (next as (prev: T) => T)(prev) + : next; + latestValuesRef.current = resolved; + setDraft(resolved); if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => { timerRef.current = undefined; - persist(next); + const value = latestValuesRef.current; + if (value !== null) { + persist(value); + } }, debounceMs); }, [opened, debounceMs, persist], ); const flush = useCallback(() => { - if (!timerRef.current) return; - clearTimeout(timerRef.current); - timerRef.current = undefined; - const value = draftRef.current; + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = undefined; + } + const value = latestValuesRef.current; if (openedRef.current && value !== null) { persist(value); } diff --git a/package.json b/package.json index abb598aa2..f49ae384f 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ ], "scripts": { "web": "node clients/launcher/build/index.js --web", - "web:dev": "node clients/launcher/build/index.js --web --dev", + "build:web:runner": "cd clients/web && npm run build:runner", + "web:dev": "npm run build:web:runner && node clients/launcher/build/index.js --web --dev", "build": "npm run build:web && npm run build:cli && npm run build:tui && npm run build:launcher", "build:cli": "cd clients/cli && npm run build", "build:tui": "cd clients/tui && npm run build", diff --git a/specification/v2_enterprise_managed_auth.md b/specification/v2_auth_ema.md similarity index 87% rename from specification/v2_enterprise_managed_auth.md rename to specification/v2_auth_ema.md index ad4e8355f..1ac358801 100644 --- a/specification/v2_enterprise_managed_auth.md +++ b/specification/v2_auth_ema.md @@ -1,6 +1,6 @@ # Inspector V2 — Enterprise-Managed Authorization (EMA / XAA) -### [Brief](README.md) | [V2 Scope](v2_scope.md) | [Servers file](v2_servers_file.md) | EMA / XAA +### [Brief](README.md) | [V2 Scope](v2_scope.md) | [Servers file](v2_servers_file.md) | [Auth hardening](v2_auth_hardening.md) | [Mid-session auth](v2_auth_mid_session.md) | [Smoke testing](v2_auth_smoke_testing.md) Tracks [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509). @@ -8,7 +8,7 @@ Tracks [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509). Add support for [Enterprise-Managed Authorization](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization) (EMA, also referred to as XAA / ID-JAG in client implementations). EMA extends the existing OAuth flow so an enterprise IdP (OIDC) can authenticate the client once; any MCP resource authorization server (AS) configured to trust that IdP can then be accessed with minimal or no user prompting. -Inspector v2 already has OAuth infrastructure (`core/auth/`, `core/mcp/oauthManager.ts`, guided auth state machine, per-server OAuth fields in `~/.mcp-inspector/mcp.json` — see [Servers file](v2_servers_file.md)). Phases 1–2 (web **quick** connect) are **implemented** in `core/auth/ema/` and documented below. **Guided EMA is deferred** until the product has a guided OAuth UX (web v2 has connect/quick only today; TUI has guided via `AuthTab`). Phase 4 (CLI/TUI quick EMA) remains planned separately. VS Code reference material in the appendix informs remaining work. +Inspector v2 already has OAuth infrastructure (`core/auth/`, `core/mcp/oauthManager.ts`, per-server OAuth fields in `~/.mcp-inspector/mcp.json` — see [Servers file](v2_servers_file.md)). Phases 1–2 (web connect via `authenticate()`) are **implemented** in `core/auth/ema/` and documented below. TUI and CLI load `client.json` and wire OAuth for HTTP/SSE servers; dedicated Client Settings UX and full interactive EMA on CLI remain follow-ups. VS Code reference material in the appendix informs remaining work. ## Normative references @@ -19,15 +19,13 @@ Inspector v2 already has OAuth infrastructure (`core/auth/`, `core/mcp/oauthMana ## Goals - Support EMA for HTTP MCP servers, aligned with the [MCP EMA extension spec](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization). EMA wire and orchestration live in `core/auth/ema/` — the v1 TypeScript SDK does not expose EMA as a named API. -- **Preserve existing standard OAuth behavior** for non-EMA servers — no regressions to current connect, guided, or configured-credential flows. +- **Preserve existing standard OAuth behavior** for non-EMA servers — no regressions to current connect or configured-credential flows. - Reuse existing inspector OAuth configuration and secret storage where possible. -- **Guided EMA (deferred):** step-through EMA legs in the UI for debugging — **not in scope for the current #1509 slice.** Deferred until Inspector has a **guided OAuth option in the UX** (web: none today; TUI: `AuthTab` guided/quick/clear). Core APIs (`beginGuidedAuth`, `OAuthFlowState`, etc.) exist; web connect uses quick mode only (`authenticate()`). - Work across web, CLI, and TUI clients via shared `core/` auth logic. ## Non-goals - v1 or v1.5/main backport (v2 only; see issue label). -- **Guided EMA UI** on web (or any client) before that client exposes **guided standard OAuth** in the UX — not merely core/API support for guided flows. ## EMA spec and SDK audit @@ -110,9 +108,9 @@ Until **client profiles** (see below) land, install-level client config — star **Do not** put IdP credentials in the OAuth store or per-server `mcp.json`. **Do not** use environment variables for IdP config — all clients read/write the same `client.json` file. -**Runtime auth state** (standard OAuth tokens, PKCE, guided metadata, **and** EMA runtime state: cached IdP ID Token / refresh token, leg-1 in-flight PKCE under `ema-idp:{issuer}`, and per-server resource tokens tagged `enterpriseManaged: true` when minted via EMA legs 2–3) uses the existing **`OAuthStorage`** interface (`core/auth/storage.ts`) — whatever adapter each client already passes to `InspectorClient`. ID-JAG is **not** cached — legs 2–3 re-mint on each connect or 401 refresh. EMA does **not** mandate a specific backing store; it extends the serialized auth state that adapter already persists. +**Runtime auth state** (standard OAuth tokens, PKCE, **and** EMA runtime state: cached IdP ID Token / refresh token, leg-1 in-flight PKCE under `ema-idp:{issuer}`, and per-server resource tokens tagged `enterpriseManaged: true` when minted via EMA legs 2–3) uses the existing **`OAuthStorage`** interface (`core/auth/storage.ts`) — whatever adapter each client already passes to `InspectorClient`. ID-JAG is **not** cached — legs 2–3 re-mint on each connect or 401 refresh. EMA does **not** mandate a specific backing store; it extends the serialized auth state that adapter already persists. -No EMA-specific migration of web OAuth persistence is required for the initial ship — sessionStorage-backed web sessions work for quick EMA today. **Follow-up:** shared file-backed OAuth via `RemoteOAuthStorage` → `/api/storage/oauth` so web matches CLI/TUI (see §Follow-up work). +No EMA-specific migration of web OAuth persistence is required for the initial ship — sessionStorage-backed web sessions work for EMA today. **Follow-up:** shared file-backed OAuth via `RemoteOAuthStorage` → `/api/storage/oauth` so web matches CLI/TUI (see §Follow-up work). | Client | Config (`client.json`) | Runtime auth state (`OAuthStorage`) | | ------ | ---------------------- | ----------------------------------- | @@ -313,7 +311,7 @@ Alternative paths (CLI/TUI, automation, hand-edit): Do not store IdP credentials in the OAuth store — only **runtime OAuth/EMA state** (tokens, PKCE, IdP session cache, etc.) belongs there, via whichever `OAuthStorage` adapter the client uses. -CLI/TUI do not yet have a dedicated Client Settings dialog; they read/write `client.json` via file adapters (Phase 4). +CLI/TUI do not have a dedicated Client Settings dialog. They **load** `client.json` at startup (`--client-config`, default `~/.mcp-inspector/storage/client.json`) and pass IdP/CIMD settings into `InspectorClient`. Edit install config via the web **Client Settings** dialog, hand-edit the file, or **`POST /api/storage/client`**. Default OAuth callback for TUI/CLI: `http://127.0.0.1:6276/oauth/callback` — see `clients/tui/README.md` and `clients/cli/README.md`. #### Connect errors when IdP is missing or disabled (web — implemented) @@ -332,13 +330,13 @@ When the user connects to a server with `oauth.enterpriseManaged: true` but inst - **Protocol** — `standard` or `ema` - **Authorized** — whether a usable access token exists in storage -- **Client ID** — preregistered or dynamic registration source +- **Client ID** — static, DCR, or CIMD registration kind (see `clientRegistrationKind` on connection state) - **IdP session** (EMA only) — `none` / `logged_in` / `expired` from `idpSessions[issuer]` -- **Auth URL** — cached from an in-flight or completed quick OAuth flow (`OAuthFlowState`), when present +- **Auth URL** — cached from an in-flight or completed OAuth flow (`OAuthFlowState`), when present - **Scopes** — configured vs granted - **Access token** — `OAuthAccessTokenField`: copy (raw), decode JWT in place (`core/auth/ema/jwt.ts` helpers); multi-line wrap with segment-aware breaking for JWTs -In-flight guided/quick flow state uses `OAuthFlowState` / `getOAuthFlowState()` / `getOAuthFlowStep()` (renamed from the earlier `AuthGuidedState` names). `getOAuthState()` is the persisted connection snapshot; flow state is separate and ephemeral. +In-flight OAuth flow state uses `OAuthFlowState` / `getOAuthFlowState()` / `getOAuthFlowStep()`. `getOAuthState()` is the persisted connection snapshot; flow state is separate and ephemeral. ### Leg 1 — IdP OIDC (settled) @@ -359,13 +357,13 @@ Leg 1 is **OIDC authorization-code login** against the enterprise IdP using cred Leg 1 reuses the existing OAuth redirect/callback/PKCE machinery but **targets the IdP**, not the resource authorization server. `OAuthManager` today discovers resource AS metadata and builds authorize URLs against the resource AS — the EMA branch must parameterize the same flow with IdP endpoints (from issuer OpenID Provider Metadata) and IdP credentials from `client.json`. -**Web callback disambiguation:** web uses a single `/oauth/callback` path for both resource OAuth and IdP OIDC. Pending server id is stashed in sessionStorage before redirect; **protocol** (standard vs EMA) comes from `oauth.enterpriseManaged` on that server, not from the OAuth `state` query param. The `state` param carries **execution** (`quick` / `guided`) plus `authId` for CSRF and fetch-log restore (`{execution}:{authId}`). +**Web callback disambiguation:** web uses a single `/oauth/callback` path for both resource OAuth and IdP OIDC. Pending server id is stashed in sessionStorage before redirect; **protocol** (standard vs EMA) comes from `oauth.enterpriseManaged` on that server, not from the OAuth `state` query param. The `state` param is a 64-char hex CSRF token (`generateOAuthState()`); `parseOAuthState()` extracts `authId` for fetch-log restore on callback. -**Auth axes (orthogonal):** `AuthProtocol` (`standard` | `ema`) from server/client config; `AuthExecution` (`quick` | `guided`) for SDK one-shot vs state-machine stepping. Guided EMA would be `ema` + `guided` — **deferred** (see §Guided mode). +**Auth protocol:** `AuthProtocol` (`standard` | `ema`) from server/client config. EMA and standard OAuth both use `InspectorClient.authenticate()` / `completeOAuthFlow()`. **Scope for leg 2:** `resourceContext.resolveEmaScopes()` — prefer configured per-server `oauth.scopes`; else join `resourceMetadata.scopes_supported` when present; else omit. Do **not** fall back to IdP `scopes_supported`. -**401 re-auth:** on EMA connections, re-run legs 2–3 (and leg 1 only if the cached ID Token is missing or expired). Do not fall back to standard resource authorization-code OAuth. +**401 re-auth:** on EMA connections, re-run legs 2–3 (and leg 1 only if the cached ID Token is missing or expired). Do not fall back to standard resource authorization-code OAuth. Mid-session detection, step-up scopes, and web remote propagation are specified in **[Mid-session auth](v2_auth_mid_session.md)**. **EMA resource token tagging + sign-out:** per-server `oauth.enterpriseManaged` in `mcp.json` is **config** (routing); the OAuth store does not read the catalog on sign-out. Instead, when EMA legs 2–3 persist a resource access token, the store tags that entry (`ServerOAuthState.enterpriseManaged: true` via `SaveTokensOptions`). Sign-out uses that tag to find and clear EMA resource state inside the same `OAuthStorage` blob without the client enumerating MCP servers. This avoids clearing standard OAuth servers and avoids clearing EMA catalog entries that were never connected. @@ -404,41 +402,24 @@ Inspector touchpoints to extend: - `core/auth/connection-state.ts` — `buildOAuthConnectionState`, `OAuthConnectionState` - `core/auth/storage.ts` + `core/auth/store.ts` — `OAuthStorage` / `OAuthStoreState`: store-root `idpSessions`; `ServerOAuthState.enterpriseManaged` tag; `SaveTokensOptions`; `clearEnterpriseManagedResourceServers()`; all adapters pick up the extended shape automatically - `core/auth/ema/` — `idpOidc.ts` (leg 1), `wire.ts` (legs 2–3), `emaFlow.ts` (orchestration + tagged `saveTokens`), `transportProvider.ts` (401 re-auth + tagged `saveTokens`), `resourceContext.ts`, `idpSession.ts` (`getEmaIdpLoginState`, `clearEmaIdpSession`), `jwt.ts`, `storage.ts`, `constants.ts`, `clientConfigError.ts` (`EmaClientNotConfiguredError`) -- `core/mcp/oauthManager.ts` — branch on `enterpriseManaged`; EMA quick connect via `emaFlow`; `getOAuthState()`; `createOAuthProvider()` returns `EmaTransportOAuthProvider` for EMA servers (401 re-auth); standard quick/guided unchanged +- `core/mcp/oauthManager.ts` — branch on `enterpriseManaged`; EMA connect via `emaFlow`; `getOAuthState()`; `createOAuthProvider()` returns `EmaTransportOAuthProvider` for EMA servers (401 re-auth); standard OAuth unchanged - `core/mcp/inspectorClient.ts` — declare EMA extension in `initialize` capabilities when `enterpriseManaged`; `getOAuthState()` - `core/react/useEmaIdpLoginState.ts` — Client Settings IdP session status; calls `clearEmaIdpSession` on sign-out (no catalog wiring) - `core/auth/browser/storage.ts` — `getBrowserOAuthStorage()` singleton (web) -- `clients/web` — **Client Settings** modal; **Connection Info** OAuth snapshot; friendly EMA connect toasts; load client config at startup; `ServerSettingsForm` OAuth section (HTTP/SSE only); web callback flow tagging for IdP vs resource OAuth. **Guided EMA UI: deferred** until guided OAuth is a product option in the UX. +- `clients/web` — **Client Settings** modal; **Connection Info** OAuth snapshot; friendly EMA connect toasts; load client config at startup; `ServerSettingsForm` OAuth section (HTTP/SSE only); web callback flow tagging for IdP vs resource OAuth - `clients/cli`, `clients/tui` — load `client.json` at startup; pass into `InspectorClient`; leg 1 via same `OAuthCallbackServer` + `NodeOAuthStorage` stack as TUI standard OAuth today -### Guided mode (deferred) - -**Status: deferred.** Guided EMA is out of scope for the current #1509 delivery. It resumes only after Inspector exposes **guided standard OAuth** as a user-facing option (step-through or run-to-completion), not merely as `InspectorClient` / `OAuthManager` APIs used in tests and TUI. - -**Why:** Web v2 has no guided OAuth UI. Connect uses **quick** mode only — `App.tsx` calls `InspectorClient.authenticate()` on 401, not `beginGuidedAuth()` / `runGuidedAuth()`. Guided OAuth exists in **core** and in the **TUI** (`AuthTab` — guided / quick / clear), but there is no web affordance to start or step through a guided flow. Building guided EMA on web without guided OAuth would add dead-end APIs with no entry point. - -**When unblocked**, EMA guided mode would extend the existing guided OAuth model (`OAuthFlowState` / `OAuthStep` in `core/auth/types.ts`). Leg 1 reuses the same redirect/callback/code-exchange steps as standard guided OAuth, pointed at the IdP. Legs 2–3 add EMA-specific steps: - -- **IdP OIDC login** (authorization redirect + code exchange — same machinery as guided resource OAuth; skippable when cached ID Token is valid) -- **ID-JAG exchange** (leg 2) -- **Resource metadata / token endpoint discovery** (if not already resolved) -- **Resource token redemption** (leg 3) - -On silent success, skip interactive IdP OIDC steps when a valid cached ID Token exists; still run legs 2–3. - -**Possible interim path:** implement guided EMA on **TUI first** (guided OAuth UX already exists there) without waiting for web guided OAuth — that would be an explicit product decision, not the default plan. - ### Clients | Client | Notes | | ------ | ----- | -| Web | **First** — implement and test EMA here; **Client Settings** for IdP + per-server EMA checkbox (HTTP/SSE only); **quick connect only** (no guided OAuth UI yet — see §Guided mode); leg 1 via browser redirect + `/oauth/callback` (same path as standard OAuth; disambiguate pending flow) | -| TUI | Follow web; extend existing guided/quick OAuth (`clients/tui/src/App.tsx`) for EMA legs 2–3; leg 1 via existing `OAuthCallbackServer` flow | +| Web | **First** — implement and test EMA here; **Client Settings** for IdP + per-server EMA checkbox (HTTP/SSE only); connect via `authenticate()` on 401 or explicit auth; leg 1 via browser redirect + `/oauth/callback` (same path as standard OAuth; disambiguate pending flow) | +| TUI | Follow web; extend existing OAuth auth tab (`clients/tui/src/App.tsx`, `AuthTab`) for EMA legs 2–3; leg 1 via existing `OAuthCallbackServer` flow | | CLI | Follow web/TUI; same Node OAuth stack as TUI when interactive IdP login is required | ### Implementation order (settled) -**Web first** for development and testing. Core types, `OAuthManager` EMA branch, and `OAuthStorage` extensions live in `core/` and are client-agnostic; the shipped #1509 path is web **quick** connect plus Client Settings / Server Settings UX. Guided EMA is deferred until guided OAuth is a UX option (see §Guided mode). TUI and CLI quick EMA remain Phase 4. +**Web first** for development and testing. Core types, `OAuthManager` EMA branch, and `OAuthStorage` extensions live in `core/` and are client-agnostic; the shipped #1509 path is web connect plus Client Settings / Server Settings UX. TUI/CLI load install config and support OAuth connect on HTTP/SSE; CLI interactive callback and terminal-native Client Settings remain follow-ups. Design decisions for EMA are complete. Remaining work is the phased plan and checklist below (client profiles are a separate, later track — not a blocker for `#1509`). @@ -460,17 +441,14 @@ Design decisions for EMA are complete. Remaining work is the phased plan and che 9. `InspectorClient`: declare EMA extension in `initialize` when `enterpriseManaged`. EMA 401 re-auth via `EmaTransportOAuthProvider` in `OAuthManager.createOAuthProvider()` (re-run legs 2–3, not resource OAuth redirect). 10. Web callback: disambiguate IdP OIDC vs resource OAuth pending flows at `/oauth/callback`. -#### Phase 3 — Guided EMA + tests (deferred) - -**Deferred** until a client exposes guided OAuth in the UX (prerequisite for guided EMA). Not part of the current #1509 close-out. +#### Phase 3 — Integration tests -11. *(deferred)* Guided EMA: leg 1 reuses guided OAuth redirect/code-exchange steps; add legs 2–3 as new steps. -12. Tests: EMA wire/orchestration unit tests and mock IdP/AS integration. **Implemented:** `parseClientConfig`, `clientSettingsValues`, `idpSessions` storage, `enterpriseManaged` server-list round-trip, OAuth `state` parsing, `clientConfigError`, `connection-state`, OAuthManager EMA not-configured paths, keychain secret migration on server-id rename (`servers-route.test.ts`), **Phase 3b automated tests** (§Phase 3b test plan — Layers 1–3). **Manual staging:** live xaa.dev quick EMA verified (§Staging validation). **Optional follow-up:** `RemoteOAuthStorage` EMA E2E variant, 401 re-auth stretch case. +11. Tests: EMA wire/orchestration unit tests and mock IdP/AS integration. **Implemented:** `parseClientConfig`, `clientSettingsValues`, `idpSessions` storage, `enterpriseManaged` server-list round-trip, OAuth `state` parsing, `clientConfigError`, `connection-state`, OAuthManager EMA not-configured paths, keychain secret migration on server-id rename (`servers-route.test.ts`), **Phase 3b automated tests** (§Phase 3b test plan — Layers 1–3). **Manual staging:** live xaa.dev EMA verified (§Staging validation). **Optional follow-up:** `RemoteOAuthStorage` EMA E2E variant, 401 re-auth stretch case. #### Phase 4 — Other clients (after web works) -13. Wire TUI/CLI to load `client.json` and pass `enterpriseManagedAuth` into `InspectorClient`. 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/CLI guided/quick OAuth extensions for EMA legs 2–3. +13. ~~Wire TUI/CLI to load `client.json` and pass `enterpriseManagedAuth` into `InspectorClient`.~~ **Done** — `loadRunnerClientConfig` + `buildRunnerClientAuthOptions` in TUI/CLI entrypoints. EMA sign-out (`clearEmaIdpSession`) is already client-agnostic in `core/` — Phase 4 clients can call it without catalog enumeration once Client Settings / logout UX lands. +14. ~~TUI interactive EMA + standard OAuth connect~~ **Done (TUI)** — 401 → `authenticate()` → `OAuthCallbackServer` on `6276` → reconnect; Auth tab OAuth snapshot + **S** clear (disconnects when connected). **Still open:** dedicated Client Settings UX in terminal, CLI local callback server for interactive login, EMA-specific terminal UX refinements. ## Implementation checklist @@ -502,29 +480,23 @@ Design decisions for EMA are complete. Remaining work is the phased plan and che - [x] Integrate EMA routing in `OAuthManager` (branch on `enterpriseManaged`; leg 1 IdP OIDC; legs 2–3 via `wire.ts` / `emaFlow.ts`) - [x] Declare EMA extension in `initialize` when connecting with `enterpriseManaged` - [x] EMA 401 re-auth: re-run legs 2–3 (leg 1 only if ID Token expired/missing) via `EmaTransportOAuthProvider` -- [x] Web: disambiguate IdP vs resource OAuth at `/oauth/callback` (shared callback; `completeOAuthFlow` branches on `enterpriseManaged`; protocol from server config, execution in OAuth `state` prefix) - -### Phase 3 — Guided EMA (deferred) - -- [ ] **Blocked on product:** guided OAuth UX — user-facing step-through or run-to-completion (web: not implemented; TUI: `AuthTab`) -- [ ] *(deferred)* Guided EMA: legs 2–3 as new steps; leg 1 reuses guided OAuth redirect/code-exchange steps -- [ ] *(deferred)* Tests: guided EMA UI steps +- [x] Web: disambiguate IdP vs resource OAuth at `/oauth/callback` (shared callback; `completeOAuthFlow` branches on `enterpriseManaged`; protocol from server config; OAuth `state` is 64-char hex CSRF token) -### Phase 3b — Integration tests +### Phase 3 — Integration tests -- [x] **Manual staging validation** — full quick EMA connect against live xaa.dev (see §Staging validation). Confirms legs 1–3 and web UX outside CI. +- [x] **Manual staging validation** — full EMA connect against live xaa.dev (see §Staging validation). Confirms legs 1–3 and web UX outside CI. - [x] **Automated integration tests** — mock IdP + mock resource AS + composable protected-resource server (see §Phase 3b test plan). Live xaa.dev is **not** required for CI. - [ ] *(optional)* `RemoteOAuthStorage` EMA E2E variant — same happy path via `/api/storage/oauth` (shared-storage follow-up). - [ ] *(optional)* 401 re-auth stretch — invalidate resource token, assert legs 2–3 re-run. ### Phase 4 — Other clients -- [ ] Wire TUI/CLI to load client config → `InspectorClientOptions.enterpriseManagedAuth` -- [ ] TUI/CLI guided/quick OAuth extensions for EMA +- [x] Wire TUI/CLI to load client config → `InspectorClientOptions.enterpriseManagedAuth` (+ CIMD / per-server OAuth via `buildRunnerClientAuthOptions`) +- [x] TUI interactive EMA + standard OAuth connect (401 → authenticate → callback → reconnect; Auth tab; keychain secret rehydration via `loadServerEntries`) +- [ ] TUI/CLI polish: Client Settings dialog, CLI interactive callback server, terminal EMA UX refinements ### Later -- [ ] **Guided EMA** (web or cross-client) — after guided OAuth is a UX option; see §Guided mode (deferred) - [ ] **Remove Zustand from OAuth persistence** — direct read/write of OAuth blob; see §Follow-up work - [ ] **Web shared OAuth store** — `RemoteOAuthStorage` / `oauth.json` for web + CLI + TUI parity; see §Follow-up work - [ ] Client profile persistence (migrate from `client.json`; may extend or replace web Client Settings) @@ -535,7 +507,7 @@ Design decisions for EMA are complete. Remaining work is the phased plan and che ## Staging validation (manual — verified) -Full end-to-end **quick EMA** has been exercised manually against live **xaa.dev** (June 2026). This validates the #1509 web path outside CI. +Full end-to-end EMA has been exercised manually against live **xaa.dev** (June 2026). This validates the #1509 web path outside CI. ### Topology @@ -551,7 +523,7 @@ Registration on xaa.dev: composable test server registered as a **resource serve 1. Client Settings — Enterprise IdP configured and enabled. 2. Server Settings — `enterpriseManaged` on; resource AS client credentials from xaa.dev resource registration. -3. Connect (quick) — IdP login when needed → ID-JAG mint → resource access token → MCP `initialize` with EMA extension capability. +3. Connect — IdP login when needed → ID-JAG mint → resource access token → MCP `initialize` with EMA extension capability. 4. Reconnect / 401 — silent or re-auth paths; Connection Info shows EMA OAuth snapshot. ### Known-good fixture @@ -562,7 +534,7 @@ Registration on xaa.dev: composable test server registered as a **resource serve ## Follow-up work (not blocking #1509 close-out) -These items came out of EMA staging and apply beyond EMA. They are **not** required to ship quick EMA on web but should be tracked. +These items came out of EMA staging and apply beyond EMA. They are **not** required to ship EMA on web but should be tracked. ### Remove Zustand from OAuth persistence @@ -592,7 +564,7 @@ Today: ## Phase 3b test plan (automated integration) -Goal: CI tests that prove **quick EMA** legs 1–3 through `InspectorClient` / `OAuthManager` without calling live xaa.dev. Manual staging (§Staging validation) already covers live IdP/AS; automation encodes regressions. +Goal: CI tests that prove EMA legs 1–3 through `InspectorClient` / `OAuthManager` without calling live xaa.dev. Manual staging (§Staging validation) already covers live IdP/AS; automation encodes regressions. **Status (June 2026):** Layers 1–3 implemented and green in CI (`npm run test:integration`). Optional items (RemoteOAuthStorage variant, 401 re-auth) remain open. @@ -601,7 +573,7 @@ Goal: CI tests that prove **quick EMA** legs 1–3 through `InspectorClient` / ` - **No live xaa.dev in default CI** — flaky, credential-bound, network-dependent. Optional `describe.skipIf` / manual job later. - **Reuse existing patterns** — `TestServerHttp` + `createExternalResourceOAuthTestServerConfig`, `inspectorClient-oauth-e2e.test.ts`, `inspectorClient-oauth-remote-storage-e2e.test.ts`, `jose` for JWTs (`test-server-protected-resource.test.ts`). - **Mock at HTTP boundary** — `fetchFn` injection on `InspectorClient` / `EmaFlowConfig` so `wire.ts` and `idpOidc.ts` run real code against local mock servers. -- **Guided execution not required** — quick path only (`authenticate()` / `trySilentEmaAuth` / `completeOAuthFlow`). +- **Single auth path** — `authenticate()` / `trySilentEmaAuth` / `completeOAuthFlow()` (same as standard OAuth). ### Test layers @@ -659,7 +631,6 @@ Existing coverage: `xaa-ema-http.json` config load, `ExternalAccessTokenValidato ### Out of scope for Phase 3b - Live xaa.dev (covered by §Staging validation) -- Guided EMA steps (deferred) - Browser / Playwright UI tests - TUI/CLI (Phase 4) diff --git a/specification/v2_auth_hardening.md b/specification/v2_auth_hardening.md new file mode 100644 index 000000000..5f9a42b0e --- /dev/null +++ b/specification/v2_auth_hardening.md @@ -0,0 +1,185 @@ +# Inspector V2 — Authorization hardening (MCP 2026-07-28) + +### [Brief](README.md) | [V2 Scope](v2_scope.md) | [Mid-session auth](v2_auth_mid_session.md) | [EMA / XAA](v2_auth_ema.md) | [Smoke testing](v2_auth_smoke_testing.md) + +Align Inspector v2 with the **authorization hardening** in the upcoming MCP **`2026-07-28`** specification release — six SEPs that tighten OAuth/OIDC client behavior for MCP's single-client, many-server deployment pattern. + +## Summary + +The [2026-07-28 release candidate](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) adds six authorization SEPs. All are **OAuth client** requirements. Inspector implements OAuth through a mix of SDK delegation (connect-time standard OAuth via `auth()`) and local code (EMA wire, storage, web callback, mid-session recovery). + +**Strategy:** Upgrade to the v2 TypeScript SDK (`@modelcontextprotocol/client`) when its auth-hardening PRs land. Do **not** reimplement SDK OAuth logic in Inspector — delegate connect-time standard OAuth to the SDK and wire the gaps (callback parameters, storage shape, client type). **Mid-session authorization** is a separate, near-term track ([Mid-session auth](v2_auth_mid_session.md)); this doc covers connect-time and storage hardening that mid-session builds on. + +## Normative references + +- [MCP authorization (draft — 2026-07-28 RC target)](https://modelcontextprotocol.io/specification/draft/basic/authorization) +- [MCP authorization (2025-11-25 — current stable)](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) +- [2026-07-28 RC announcement — Authorization Hardening](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/#authorization-hardening) +- [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207) — OAuth 2.0 Authorization Server Issuer Identification (`iss` parameter) +- [RFC 6750 §3.1](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1) — Bearer token `insufficient_scope` +- TypeScript SDK tracking: [modelcontextprotocol/typescript-sdk](https://github.com/modelcontextprotocol/typescript-sdk) issues `#2197`–`#2201`, `#2198`, `#2256`; PRs `#2265`, `#2271`, `#2272` + +## Relationship to mid-session auth + +[Mid-session auth](v2_auth_mid_session.md) ships in the **near term**. Overlap: + +| Concern | Mid-session spec | This doc | +| ------- | ---------------- | -------- | +| **SEP-2350** scope union on 403 step-up | `handleAuthChallenge()` owns union + UX for runtime challenges | Connect-time step-up inherits SDK union after v2 upgrade; mid-session still uses `saveScope(authorizationScopes)` in the challenge handler | +| **SEP-2207** refresh / `offline_access` | EMA legs 2–3 re-mint; standard OAuth silent refresh | Connect-time scope selection and DCR metadata | +| **SEP-2468** `iss` validation | Applies to any interactive re-auth redirect (401 mid-session) | Connect-time callback wiring is the first place to land `iss` passthrough | +| **SEP-2352** issuer-bound credentials | Re-register after AS migration may surface as mid-session failure | Storage and `invalidateCredentials` must be correct before mid-session recovery can succeed | + +Implement mid-session auth now. Land auth hardening on the v2 SDK upgrade path without blocking mid-session work — use compatible storage and callback shapes so both tracks merge cleanly. + +## The six SEPs + +| SEP | Requirement | Primary owner | +| --- | ------------- | ------------- | +| **[SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468)** | Validate `iss` on authorization responses per RFC 9207; reject mix-up across authorization servers | Client (+ SDK reference impl) | +| **[SEP-837](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837)** | Declare OIDC `application_type` during Dynamic Client Registration (`native` vs `web`) | Client (+ SDK DCR body) | +| **[SEP-2352](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352)** | Bind persisted DCR credentials to the AS `issuer`; re-register when resource migrates between ASes | Client storage (+ SDK AS-change detection) | +| **[SEP-2207](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207)** | Request refresh tokens from OIDC-style ASes (`offline_access` in scope when appropriate); do not assume refresh tokens are issued | Client (+ SDK `determineScope`) | +| **[SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350)** | On 403 step-up, re-authorize with **union** of previously requested scopes and challenge scopes; on 401 re-login, **replace** scope | Client (+ SDK transport retry for direct transport) | +| **[SEP-2351](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2351)** | Stable RFC 8414 `.well-known` discovery suffix for authorization and resource metadata | Spec / discovery (SDK helpers) | + +Servers emit per-operation scopes in 403 challenges (SEP-2350 server posture). Inspector consumes them; server-side scope-challenge middleware is out of scope unless needed for tests. + +## SDK vs Inspector responsibilities + +Inspector today uses **`@modelcontextprotocol/sdk` ^1.29.0** (v1 monolith). Auth hardening lands on **v2 `main`** (`@modelcontextprotocol/client`). Upgrade is part of this work. + +```text +Connect-time standard OAuth EMA Web remote backend +─────────────────────────── ─── ────────────────── +SDK auth() + BaseOAuthClientProvider Local wire (ema/) Frozen token stub +OAuthStorage keyed by serverUrl IdP OIDC in idpOidc.ts No interactive OAuth +completeOAuthFlow(code) only No SDK CrossAppAccess Mid-session → browser +``` + +### What the v2 SDK provides (after upgrade) + +| SEP | SDK status (Jun 2026) | Inspector gets | +| --- | --------------------- | -------------- | +| SEP-2207 | Implemented on v2 `main` ([#2199](https://github.com/modelcontextprotocol/typescript-sdk/issues/2199)) | `offline_access` augmentation in authorize scope for standard OAuth via `auth()` | +| SEP-2350 | Open PR [#2265](https://github.com/modelcontextprotocol/typescript-sdk/pull/2265) | `unionScopes()` + 403 retry uses union on streamable HTTP direct transport | +| SEP-2352 | Open PR [#2271](https://github.com/modelcontextprotocol/typescript-sdk/pull/2271) | AS migration detection → `invalidateCredentials` → re-DCR | +| SEP-2468 | Open PR [#2272](https://github.com/modelcontextprotocol/typescript-sdk/pull/2272) | `iss` validation in code-exchange path when host passes `iss` | +| SEP-837 | Open [#2198](https://github.com/modelcontextprotocol/typescript-sdk/issues/2198) | `application_type` in DCR request body | +| SEP-2351 | Tracking [#2256](https://github.com/modelcontextprotocol/typescript-sdk/issues/2256) | Discovery URL suffix in SDK metadata helpers | + +### What Inspector must still implement + +| SEP | Inspector work | Why not SDK-only | +| --- | -------------- | ---------------- | +| SEP-2468 | Parse `iss` from OAuth callback URL; extend `completeOAuthFlow(code, iss?)`; pass through to SDK `auth()` | Host owns the redirect handler — SDK cannot read the callback URL | +| SEP-837 | Ensure web vs TUI/CLI set correct client type (`web` / `native`) in provider metadata or environment | SDK may infer from redirect URI; Inspector must not mis-declare cross-environment | +| SEP-2352 | Key `OAuthStorage` credentials by AS `issuer` (not only `serverUrl`); implement `invalidateCredentials` on `BaseOAuthClientProvider`; migrate shared `oauth.json` | Storage is Inspector-owned; SDK calls provider hooks | +| SEP-2207 | EMA leg 1 already requests `openid offline_access` (`IDP_OIDC_SCOPES`); verify standard OAuth path after upgrade | EMA bypasses SDK authorize for leg 1 | +| SEP-2350 | Mid-session union in `handleAuthChallenge()` + `saveScope(authorizationScopes)`; web/EMA paths bypass SDK transport retry | See [Mid-session auth](v2_auth_mid_session.md) | +| SEP-2351 | Verify discovery after upgrade; no duplicate discovery logic in Inspector | Uses SDK `discoverOAuthProtectedResourceMetadata` today | + +**Do not duplicate** SDK `auth.ts` logic (RFC 9207 decision table, AS migration edge cases, scope union helpers). Upgrade and wire. + +## Implementation strategy + +### Principles + +1. **Delegate** connect-time standard OAuth to the v2 SDK `auth()` flow. +2. **Wire** callback parameters, storage, and client-type metadata in Inspector. +3. **Extend** EMA and mid-session paths locally where the SDK has no EMA/XAA API. +4. **Upgrade** from `@modelcontextprotocol/sdk` v1 to v2 packages as a gated step — not a silent dependency bump. + +### Order of work + +| Step | When | Work | +| ---- | ---- | ---- | +| 1 | **Now (parallel with mid-session auth)** | Compatible prep: callback type includes optional `iss`; storage schema reserves `authorizationServerIssuer`; mid-session `saveScope(union)` per [Mid-session auth](v2_auth_mid_session.md) | +| 2 | **When v2 SDK auth PRs merge** | Upgrade to `@modelcontextprotocol/client`; run existing OAuth integration tests | +| 3 | **Immediately after upgrade** | SEP-2468 callback passthrough; SEP-2352 storage + `invalidateCredentials`; SEP-837 client type per environment | +| 4 | **Verify** | Smoke scenarios in [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md); add hardening-specific cases | + +Land order in the SDK (maintainer plan): **SEP-2350 → SEP-2352 → SEP-2468**, with **SEP-837** in the same auth-release series. + +### Per-SEP Inspector mapping + +#### SEP-2468 — `iss` validation (RFC 9207) + +- **Web:** `App.tsx` OAuth callback — read `iss` from query string alongside `code`; pass to `InspectorClient.completeOAuthFlow(code, iss?)`. +- **TUI / CLI:** Node callback server — same passthrough. +- **Core:** `OAuthManager.completeOAuthFlow` forwards `iss` to SDK `auth()`. +- **EMA:** Leg 1 OIDC callback is separate (`completeEmaIdpAuthorizationAndMint`); evaluate whether IdP responses require `iss` validation on the OIDC path (may reuse SDK OIDC helpers after upgrade). + +#### SEP-837 — `application_type` in DCR + +- **Web (`BrowserOAuthClientProvider`):** `application_type: "web"` (https redirect). +- **TUI / CLI:** `application_type: "native"` (localhost / custom scheme redirect). +- **Pre-registered clients:** N/A — no DCR. +- **CIMD:** Portable client IDs — no `application_type` on DCR. + +#### SEP-2352 — credentials bound to AS issuer + +- Extend `ServerOAuthState` / `oauth.json` with `authorizationServerIssuer` (or key storage by issuer + resource). +- Implement `OAuthClientProvider.invalidateCredentials(scope)` on `BaseOAuthClientProvider` — clear client info and/or tokens per SDK request. +- On AS migration (PRM `authorization_servers` change), SDK triggers invalidation; Inspector storage must honor it and allow re-DCR. +- **CIMD carve-out:** HTTPS `client_id` URLs remain portable across AS changes; tokens still invalidate per SDK behavior. + +#### SEP-2207 — OIDC refresh token guidance + +- **Standard OAuth:** Inherited from v2 SDK `determineScope()` after upgrade. +- **EMA leg 1:** Already requests `openid offline_access`; IdP refresh in `refreshIdpOidcSession`. +- **Mid-session:** Silent refresh and EMA re-mint per [Mid-session auth](v2_auth_mid_session.md); do not assume refresh tokens exist. + +#### SEP-2350 — step-up scope accumulation + +- **Connect-time (direct transport):** v2 SDK transport 403 retry after [#2265](https://github.com/modelcontextprotocol/typescript-sdk/pull/2265). +- **Mid-session:** `handleAuthChallenge()` computes `authorizationScopes = union(previous, required)`; `saveScope` after success. Standard OAuth step-up = interactive; EMA step-up = silent legs 2–3 when IdP session valid. +- **401 re-login:** Replace scope set (not union) — mid-session and connect-time. + +#### SEP-2351 — discovery suffix + +- Rely on v2 SDK discovery helpers used by `auth()`, CIMD (`core/auth/cimd.ts`), and EMA (`core/auth/ema/resourceContext.ts`). +- No Inspector-specific discovery URL construction unless regression found in smoke tests. + +## Non-goals + +- Reimplementing MCP authorization-server or resource-server wire formats. +- v1 / v1.5 backport. +- **Client credentials grant** ([#1225](https://github.com/modelcontextprotocol/inspector/issues/1225)) — separate track. +- Full MCP **2026-07-28 stateless transport** migration (separate from auth hardening). +- Server-side scope-challenge middleware in Inspector test servers beyond what [Mid-session auth](v2_auth_mid_session.md) requires for 403 fixtures. + +## Testing + +| SEP | Test | +| --- | ---- | +| SEP-2468 | Callback with matching `iss` succeeds; mismatched `iss` rejected; AS advertising support without `iss` rejected | +| SEP-837 | DCR succeeds for web (https redirect) and TUI/CLI (localhost redirect) against OIDC AS requiring `application_type` | +| SEP-2352 | Simulated AS migration in PRM → credentials invalidated → re-DCR → connect succeeds | +| SEP-2207 | Authorize request includes `offline_access` when AS advertises it; connect succeeds without refresh token when AS omits one | +| SEP-2350 | Connect-time and mid-session 403 step-up use union scopes; 401 re-login replaces scopes | +| SEP-2351 | Discovery resolves against draft suffix paths | + +Document manual scenarios in [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) after implementation. + +## File touch list (expected) + +| Area | Files | +| ---- | ----- | +| Upgrade | Root and client `package.json`; import paths v1 → v2 | +| Callback | `clients/web/src/App.tsx`, `core/auth/node/oauth-callback-server.ts`, `core/mcp/oauthManager.ts`, `core/mcp/inspectorClient.ts` | +| Provider | `core/auth/providers.ts`, `core/auth/browser/providers.ts` | +| Storage | `core/auth/store.ts`, `core/auth/oauth-storage.ts`, `core/mcp/remote/node/remoteOAuthStorage.ts` (if landed) | +| Mid-session overlap | `core/auth/challenge.ts`, `core/mcp/oauthManager.ts` (`handleAuthChallenge`, `saveScope`) — see [Mid-session auth](v2_auth_mid_session.md) | +| Tests | `clients/web/src/test/core/auth/`, `clients/web/src/test/integration/mcp/inspectorClient-oauth*.test.ts` | +| Smoke doc | `specification/v2_auth_smoke_testing.md` | + +## Related specs + +| Doc | Relationship | +| --- | ------------ | +| [v2_auth_mid_session.md](v2_auth_mid_session.md) | Mid-session auth (near term); owns runtime SEP-2350 behavior and most recovery UX | +| [v2_auth_ema.md](v2_auth_ema.md) | EMA legs 1–3; SEP-2207 leg 1 scopes; future v2 SDK Layer-2 helpers | +| [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) | Manual verification ladder | +| [v2_scope.md](v2_scope.md) | OAuth handling scope item | +| [v2_storage.md](v2_storage.md) | Shared `oauth.json` — issuer-keyed credentials affect storage schema | diff --git a/specification/v2_auth_mid_session.md b/specification/v2_auth_mid_session.md new file mode 100644 index 000000000..0f35851b8 --- /dev/null +++ b/specification/v2_auth_mid_session.md @@ -0,0 +1,502 @@ +# Inspector V2 — Mid-session authorization + +### [Brief](README.md) | [V2 Scope](v2_scope.md) | [Auth hardening](v2_auth_hardening.md) | [EMA / XAA](v2_auth_ema.md) | [Smoke testing](v2_auth_smoke_testing.md) + +Design for **mid-session authorization** in Inspector: detecting when MCP traffic needs new or elevated credentials, responding with the correct OAuth or EMA flow, and restoring the connection — across web, TUI, and CLI. + +This spec generalizes beyond **expired access tokens** to include **step-up authorization** (e.g. a tool call returns **403** with `error="insufficient_scope"` and the scopes required for that operation — see [SEP-2350](#sep-2350-step-up-authorization) below). + +## Summary + +Inspector v2 already supports **connect-time** OAuth and EMA: + +- `InspectorClient.authenticate()` / `completeOAuthFlow()` ([V2 Scope](v2_scope.md)) +- Connect-time 401 handling in web `App.tsx` and TUI Auth tab hints +- EMA legs 2–3 refresh via `EmaTransportOAuthProvider` when a **live** `OAuthClientProvider` is on the transport ([EMA spec](v2_auth_ema.md)) + +**Gap:** Authorization can fail whenever the MCP server rejects the credentials in use — **during** `connect()` (including reconnect with a stored token snapshot), **after** a successful connect (expired or revoked tokens), or on **insufficient scope** for a specific request. The web client compounds this: MCP runs on the Hono backend with a **token snapshot** at connect time (`createTokenAuthProvider`), so the backend cannot complete interactive OAuth or reliable silent refresh on its own. + +This spec defines: + +1. A normalized **`AuthChallenge`** model (what went wrong + what is required). +2. A single core entry point **`handleAuthChallenge()`** (how Inspector responds). +3. **Remote event propagation** so the browser can run auth and reconnect. +4. **Phased delivery** (recovery → step-up → client parity → RPC replay) — see [Architecture](#architecture). + +## Architecture + +### Code layout + +- Challenge types live in `core/auth/challenge.ts`. +- `OAuthManager.handleAuthChallenge()` implements the handler; `InspectorClient` exposes a delegating wrapper. +- Web orchestration starts in `App.tsx`; extract `clients/web/src/utils/authChallengeFlow.ts` if wiring exceeds ~50 lines. + +### Web: detection and wire protocol + +- **Backend detection:** fetch wrapper on the transport passed to `createTransportNode` — intercept the MCP HTTP `Response` before the SDK consumes it, parse `WWW-Authenticate`, emit an SSE `auth_challenge` event. The frozen `createTokenAuthProvider` stub cannot complete interactive OAuth; do not rely on stub `auth()`. +- **SSE event type:** dedicated `auth_challenge` (not `transport_error`). +- **Browser dispatch:** `InspectorClient` `authChallenge` typed event. +- **HTTP status helpers:** `isAuthChallengeError()` for mid-session 401 and 403; `isUnauthorizedError()` remains for connect-time 401 only. +- **Post-recovery:** `disconnect()` → `connect()` to re-snapshot tokens to the backend. No token-push API in v1. +- **Deduplication:** in-memory per session, keyed by `reason` + sorted `requiredScopes`; suppress duplicates until satisfied or scopes change. +- **Multi-tab:** duplicate modals are acceptable until Phase 4 `RemoteOAuthStorage`; then `navigator.locks.request()` single-flight per server URL inside `handleAuthChallenge()`. + +### TUI / CLI: detection + +- Same `handleAuthChallenge()` entry via a transport fetch wrapper, before the SDK auth retry path. +- Intercept 401 and 403 on streamable HTTP; run union scopes in `handleAuthChallenge()` for step-up. Do not rely on the SDK built-in 403 retry (challenge-only scope, no SEP-2350 union). +- Legacy SSE transport: 401 only (no 403 step-up in SDK). +- Replace TUI `show401AuthHint` with the `authChallenge` event (Phase 4). +- Phase A: rely on SDK in-flight retry where applicable. Phase C adds explicit pending-RPC replay. + +The SDK (`@modelcontextprotocol/sdk` 1.29.0) auto-retries 401/403 on streamable HTTP `send()` but does not union scopes for step-up — Inspector owns SEP-2350 union in `handleAuthChallenge()`. + +### Scope and EMA + +- **Previously requested scopes:** `OAuthStorage.scope` / `saveScope()` per server. +- **After successful authorize:** `saveScope(authorizationScopes)`; step-up uses union; 401 re-login replaces scope. +- **EMA mint scopes on 401 refresh:** challenge `scope` from `WWW-Authenticate` if present, else configured `oauth.scope`, else PRM `scopes_supported` (`resolveEmaScopes` order). +- **EMA step-up (valid IdP session):** silent legs 2–3 re-mint with `authorizationScopes` (union). Same toast as 401 refresh. No modal, no resource-AS redirect. Resource MCP scopes are on leg 2/3 token requests, not leg 1 (`openid offline_access` only). + +### RPC retry + +- After every successful recovery (401 refresh, EMA re-mint, step-up), **retry the failed MCP request**. Phases 1–3 may ship without auto-retry; Phase 5 queues `AuthChallenge.context.pendingRequest` and replays once after `satisfied` + reconnect (bounded). + +### UX + +| Situation | Behavior | +| --------- | -------- | +| Silent recovery (refresh / EMA re-mint / EMA step-up) | Brief toast: “Refreshing authorization…” | +| **401** — interactive re-auth required | Toast “Session expired, re-authenticating…” → auto-start redirect (same as connect-time). No confirm modal. | +| **403** step-up — standard OAuth | Blocking modal: scopes, tool context, **Authorize** / **Cancel** | +| **Cancel** on standard-OAuth step-up modal | Stay connected. Failed tool shows error. Other scoped operations may still work. Do not disconnect. | +| **401** — user aborts IdP redirect or callback fails | Stay connected (degraded). Persistent re-auth banner. Auth-gated calls fail until recovery. Do not auto-disconnect. | +| Hard failure (`kind: "failed"`) | Persistent error toast | + +TUI: standard-OAuth step-up uses Auth tab message + Cancel semantics. EMA step-up is silent. CLI mirrors when OAuth is wired (Phase 4). + +Connection Info showing effective vs pending scopes is out of scope for v1. + +### When silent recovery fails + +Silent path = refresh token grant (standard OAuth) or EMA legs 2–3 re-mint (valid IdP session). Falls through to interactive when silent cannot succeed: + +| Protocol | Silent fails when | +| -------- | ----------------- | +| Standard OAuth | No `refresh_token`; refresh token expired/revoked; AS rejects refresh; no tokens in storage | +| EMA | No IdP session; legs 2–3 mint error (bad resource client creds, AS/network errors) | +| Step-up (403) | Standard OAuth: interactive consent (modal + resource-AS redirect). EMA (valid IdP session): silent legs 2–3 re-mint — same as 401 refresh | +| Web | Silent runs in the browser after SSE `auth_challenge`; the backend cannot refresh frozen tokens | + +### Test infrastructure + +Extend `test-server-oauth.ts` with scope-check middleware that returns **403** + `insufficient_scope` for scoped tool routes. + +## Normative references + +- [MCP authorization (2025-11-25)](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) — current stable; includes [Step-Up Authorization Flow](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#step-up-authorization-flow) and [Runtime Insufficient Scope Errors](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#runtime-insufficient-scope-errors) +- [MCP authorization (draft — 2026-07-28 RC target)](https://modelcontextprotocol.io/specification/draft/basic/authorization) — upcoming auth hardening; [release candidate announcement](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) lists six authorization SEPs including **SEP-2350** +- **[SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350)** — *Clarify client-side scope accumulation in step-up authorization* ([issue #2349](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2349)); merged into the draft spec's step-up flow +- [EMA extension](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization) — see [v2_auth_ema.md](v2_auth_ema.md) +- OAuth 2.0 Bearer Token Usage (`WWW-Authenticate`, `insufficient_scope`) — [RFC 6750 §3.1](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1) +- `@modelcontextprotocol/sdk` **1.29.0** (pinned in repo) — `StreamableHTTPClientTransport` invokes SDK `auth()` on **401** and **403 `insufficient_scope`** during `send()` when an `authProvider` is attached; legacy `SSEClientTransport` handles **401 only** on `send()` (no 403 step-up) + +## Goals + +### Phase A — Mid-session token recovery (implement first) + +- **One core path** for all clients: parse or receive a challenge → `handleAuthChallenge()` → updated tokens or interactive auth. +- **Web remote architecture:** backend detects and **emits** challenges; browser **handles** auth (never runs OAuth redirects on the server). +- Support **token refresh** (silent when possible) for **401 / invalid or expired tokens** at runtime. +- **EMA-aware:** re-run legs 2–3 when the resource token expires; leg 1 only when IdP session is missing or expired ([EMA 401 rules](v2_auth_ema.md)). +- Preserve existing connect-time OAuth behavior — no regressions. + +### Phase B — MCP step-up authorization (after Phase A) + +- Implement **[SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350)** client-side scope accumulation when servers emit runtime **`403 insufficient_scope`** challenges per the [draft Step-Up Authorization Flow](https://modelcontextprotocol.io/specification/draft/basic/authorization#step-up-authorization-flow). +- Align with the upcoming **2026-07-28** MCP authorization revision (Inspector targets the draft semantics even while pinned to SDK/spec `2025-11-25` today). + +### Phase C — Pending RPC retry (after Phase A recovery works) + +- After any successful auth recovery (401 refresh, EMA re-mint, step-up), **automatically retry the MCP request that failed**. +- Phases 1–3 may ship without auto-retry; Phase 5 adds queued replay of `AuthChallenge.context.pendingRequest`. + +## Non-goals + +- v1 / v1.5 backport (v2 only). +- **Client credentials grant** ([#1225](https://github.com/modelcontextprotocol/inspector/issues/1225)) — separate track. +- **SAML** EMA leg 1 — out of scope per EMA spec. +- **IdP RP-initiated logout (end-session)** — local sign-out only today; see EMA spec §Future. +- Defining MCP server or authorization-server wire formats — Inspector consumes whatever the SDK and HTTP responses expose; extensibility hooks documented below. + +## Terminology + +| Term | Meaning | +| ---- | ------- | +| **Auth challenge** | Structured description of why authorization failed and what is required to proceed. Not raw HTTP. | +| **`handleAuthChallenge()`** | Core orchestrator: given a challenge, attempt silent satisfaction, else start interactive auth. | +| **Connect-time auth** | First authorization during `InspectorClient.connect()` when **no** token snapshot is sent (web: `App.tsx` → `authenticate()` on plain 401). Already implemented for that path. | +| **Mid-session auth** | Any authorization failure **after** tokens were supplied to the transport — including reconnect with a stored snapshot, post-connect RPCs, expiry, revocation, and step-up scopes. **This spec.** | +| **Step-up auth** | MCP [Step-Up Authorization Flow](https://modelcontextprotocol.io/specification/draft/basic/authorization#step-up-authorization-flow): token is valid but **insufficient scope** for the current operation. Runtime signal is typically **HTTP 403** + `WWW-Authenticate: Bearer error="insufficient_scope"`. Governed by **[SEP-2350](#sep-2350-step-up-authorization)**. | +| **Recover / refresh** | Informal shorthand for satisfying a **`token_expired`** (or similar) challenge without user interaction when refresh or EMA re-mint succeeds. Prefer **`handleAuthChallenge`** in API names. | +| **Token snapshot** | Web-only: OAuth tokens copied into `POST /api/mcp/connect` and frozen in `createTokenAuthProvider` on the backend. | + +## Current architecture (why mid-session fails on web) + +```text +TUI / CLI Web +───────── ─── +InspectorClient InspectorClient (browser) + └─ live OAuthClientProvider └─ live OAuthClientProvider + └─ MCP SDK transport └─ RemoteClientTransport.start() + └─ 401 → provider.auth() └─ snapshots tokens once + └─ Hono backend + └─ createTokenAuthProvider (frozen) + └─ MCP SDK transport + └─ 401 → stub cannot refresh/redirect +``` + +**TUI/CLI:** OAuth authority and MCP transport live in the same process. The SDK can call `tokens()`, refresh, or fire `oauthAuthorizationRequired`. + +**Web:** OAuth authority is in the browser; MCP HTTP is on the backend. Only **connect-time** 401 is wired today when **no** token snapshot is sent (`App.tsx` → `authenticate()`). When the browser **does** send a snapshot (reconnect with stored tokens, or post-OAuth `connect()` where the server still rejects the token), failures on `/api/mcp/send` — **including `initialize` during `connect()`** — do not trigger browser re-auth. The MCP SDK invokes `auth()` on the frozen `createTokenAuthProvider` stub; recovery fails and often surfaces as **HTTP 500** (e.g. SDK error *"OAuth client information must be saveable for dynamic registration"*) instead of **401**, because the stub cannot persist DCR results or run browser redirects. + +### Known failure: reconnect with stored tokens (pre–Phase 2) + +This is the same root cause as mid-session tool-call failures; only the triggering RPC differs (`initialize` during `connect()` vs e.g. `tools/list` after connected). + +| Situation | Today (web remote) | After Phase 2 | +| --------- | ------------------ | ------------- | +| No tokens in storage; user clicks **Connect** | Remote 401 → browser `authenticate()` → OAuth → connect | Unchanged (connect-time path) | +| Stored tokens present (expired, revoked, wrong registration, or server-invalidated); user clicks **Connect** | Token snapshot sent → MCP 401 → stub `auth()` → **500** / opaque SDK error; user sees **Failed to connect**, not re-auth | Fetch wrapper emits **`auth_challenge`** → browser `handleAuthChallenge()` → refresh or interactive re-auth → reconnect | +| Connected; token becomes invalid; user calls a tool | Same stub failure or opaque error on `/api/mcp/send` | Same **`auth_challenge`** → recovery → reconnect | + +**Workaround until Phase 2:** **Clear stored OAuth state** for the server (Server Settings or Connection Info), then **Connect** again to take the no-snapshot connect-time path. Do **not** rely on proactive JWT `exp` checks at connect as a substitute for Phase 2 — they only cover clock-expired JWTs, not server-rejected tokens, opaque access tokens, or registration mismatches; challenge detection from the MCP HTTP response remains the source of truth (see [Parsing](#parsing-best-effort-extensible)). + +## SEP-2350 — step-up authorization + +[SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350) clarifies how MCP clients should behave during **step-up authorization** — when a server rejects a request because the access token lacks scopes needed for **that specific operation**. + +**Inspector implements SEP-2350 in Phase B** (after basic mid-session 401 / token-refresh handling). Until then, step-up challenges may surface as opaque tool-call failures. + +### What the upcoming MCP spec requires + +From the [draft authorization spec](https://modelcontextprotocol.io/specification/draft/basic/authorization) (incorporating SEP-2350): + +| Situation | HTTP status | `WWW-Authenticate` | Client behavior | +| --------- | ----------- | ------------------ | --------------- | +| No token / invalid / expired token | **401** | `scope` may guide initial selection | Refresh if possible; else full (re-)authorization. **Replace** scope set on full re-login (down-scoping opportunity). | +| Valid token, insufficient scope (runtime) | **403** | `error="insufficient_scope"`, `scope="…"` per [RFC 6750 §3.1](https://datatracker.ietf.org/doc/html/rfc6750#section-3.1) | **Step-up flow** — see below. | + +**Server posture (SEP-2350):** servers emit scopes needed for the **current operation only**, not the union of everything the client was ever granted. Servers remain stateless regarding client scope history. + +**Client posture (SEP-2350 — Step-Up Authorization Flow step 2):** + +1. Parse `WWW-Authenticate` from the 403 (or AS error) response. +2. Compute **`requiredScopes = union(previouslyRequestedScopes, challengeScopes)`** — union of the client's previously **requested** scope set and the scopes from the current challenge. Do **not** replace the prior set with the challenge scopes alone (that would drop permissions needed for other tools). +3. Initiate (re-)authorization with the union scope set. +4. Retry the original MCP request with the new token (bounded retries). + +Reference implementation discussion: [python-sdk PR #2676](https://github.com/modelcontextprotocol/python-sdk/pull/2676) (403 → union; 401 → replace). + +### Inspector mapping for SEP-2350 + +- Persist **`previouslyRequestedScopes`** per server in `OAuthStorage.scope` via `saveScope()`. +- `parseAuthChallengeFromResponse()` maps **403 + `insufficient_scope`** → `AuthChallenge { reason: "insufficient_scope", requiredScopes }`. +- `handleAuthChallenge()` for **standard OAuth**: build authorize URL with **union scopes** (interactive). For **EMA** (valid IdP session): silent legs 2–3 re-mint with **`authorizationScopes`** — resource scopes are on the leg 2/3 token requests, not leg 1 OIDC scopes. +- UX: **standard OAuth** step-up — modal (“**Tool X** needs additional permissions…”). **EMA** step-up — silent toast only (valid IdP session); mint failure surfaces error toast. + +## Auth challenge model + +### Type shape (`core/auth/challenge.ts`) + +```typescript +/** Why authorization failed for this MCP interaction. */ +export type AuthChallengeReason = + | "unauthorized" // Generic 401 — details unknown + | "token_expired" // Access token no longer accepted + | "insufficient_scope" // Step-up: more scopes required + | "invalid_token"; // Malformed or wrong audience/resource + +/** Normalized challenge for handleAuthChallenge(). */ +export interface AuthChallenge { + reason: AuthChallengeReason; + + /** Scopes from the current challenge (step-up). Per RFC 6750 §3.1 / MCP Runtime Insufficient Scope Errors — scopes needed for this operation. */ + requiredScopes?: string[]; + + /** + * For step-up (SEP-2350): union of previously requested scopes and requiredScopes. + * Set by handleAuthChallenge before re-authorization; not sent on the wire. + */ + authorizationScopes?: string[]; + + /** Resource indicator / MCP resource URL when known (EMA RFC 8707). */ + resource?: string; + + /** Resource authorization server audience when known. */ + audience?: string; + + /** Optional human-readable detail from server or SDK (for UI, not parsing). */ + message?: string; + + /** MCP method / tool name that triggered the challenge (for UX: “authorizing for tool X”). */ + context?: { + method?: string; + toolName?: string; + /** Phase C: JSON-RPC request to replay after successful recovery. */ + pendingRequest?: import("@modelcontextprotocol/sdk/types.js").JSONRPCMessage; + }; + + /** Opaque raw hints for logging and forward-compatible parsers. */ + raw?: { + httpStatus?: number; + wwwAuthenticate?: string; + }; +} +``` + +### Parsing (best effort, extensible) + +Challenge construction is **layered** — do not message-guess. Parse at the point of failure: when the MCP transport returns **401/403**, when `transport.send()` throws, or when `onerror` fires with an auth status code. + +1. **SDK / transport error** — preserve HTTP status / `code` (existing pattern in `core/mcp/remote/node/server.ts`; web uses `isAuthChallengeError()` for mid-session detection). Treat **401** and **403** separately per MCP [Error Handling](https://modelcontextprotocol.io/specification/draft/basic/authorization#error-handling) (401 = invalid/missing token; 403 = insufficient scope). +2. **`WWW-Authenticate` Bearer** — parse `error="insufficient_scope"`, `scope="…"`, `error="invalid_token"`, `resource_metadata="…"`, etc. from the **HTTP response headers on the failing MCP request**. See [Runtime Insufficient Scope Errors](https://modelcontextprotocol.io/specification/draft/basic/authorization#runtime-insufficient-scope-errors). +3. **Future MCP extensions** — challenge payloads attached to JSON-RPC errors; map into the same struct without changing `handleAuthChallenge()`'s signature. + +When parsing fails, use `reason: "unauthorized"` and still allow interactive re-auth. + +### Challenge vs connect-time 401 + +| | Connect-time (no snapshot) | Runtime / reconnect (snapshot sent) | +| --- | --- | --- | +| **When** | First connect with no stored tokens; `initialize` gets 401 before any bearer token was sent to the backend | Reconnect with stored tokens, or any MCP request after a token snapshot was frozen on the backend — **including `initialize` during `connect()`** | +| **Detection** | `connect()` throws **401** to the browser | MCP HTTP **401/403** on backend transport → **`auth_challenge`** (Phase 2); today often **500** from stub `auth()` | +| **Handler** | `authenticate()` (today) | `handleAuthChallenge()` (this spec) | +| **Web follow-up** | Redirect or silent connect | Recover tokens in browser → **disconnect + connect** to re-snapshot → Phase C replays pending RPC | + +Both paths may call the same underlying OAuth/EMA primitives (`authenticate()`, refresh, `completeOAuthFlow()`); only **detection** and **re-snapshot reconnect** differ. Phase 2 unifies recovery for the snapshot path; it does **not** replace the no-snapshot connect-time path. + +## Core API — `handleAuthChallenge()` + +**Location:** `OAuthManager.handleAuthChallenge()`; `InspectorClient` exposes a delegating wrapper. + +```typescript +export type AuthChallengeOutcome = + | { kind: "satisfied" } // New tokens in OAuthStorage; caller may reconnect transport + | { kind: "interactive"; authorizationUrl: URL } + | { kind: "failed"; error: Error }; + +/** Satisfy an auth challenge when possible. */ +async handleAuthChallenge(challenge: AuthChallenge): Promise; +``` + +### Strategy by protocol and reason + +#### Standard OAuth (`protocol: "standard"`) + +| Reason | Silent path | Interactive path | +| ------ | ----------- | ---------------- | +| `token_expired`, `invalid_token`, `unauthorized` | SDK refresh via stored `refresh_token` when AS supports it | New authorization code flow (`authenticate()`) | +| `insufficient_scope` | Not applicable — need new consent | **[SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350):** authorization URL with **`authorizationScopes`** = union(previously requested, `requiredScopes`) — not replace | + +Uses existing `BaseOAuthClientProvider`, storage, and `authenticate()` / `completeOAuthFlow()`. + +#### EMA (`protocol: "ema"`) + +Per [EMA 401 rules](v2_auth_ema.md): **do not** fall back to standard resource-OAuth redirect. + +| Reason | Silent path | Interactive path | +| ------ | ----------- | ---------------- | +| `token_expired`, `unauthorized` (resource token) | `refreshEmaResourceTokens()` / legs 2–3; scopes: challenge `WWW-Authenticate` scope → configured `oauth.scope` → PRM `scopes_supported` | — | +| IdP session missing / expired | — | Leg 1 IdP OIDC redirect (`startEmaIdpAuthorization`) then legs 2–3 | +| `insufficient_scope` | **Silent:** re-mint legs 2–3 with **`authorizationScopes`** (union) | **Leg 1** IdP redirect when IdP session invalid — separate from step-up UX; then legs 2–3 with union scopes | + +Uses `EmaTransportOAuthProvider`, `emaFlow.ts`, and `resourceContext.ts` (extend scope resolution to prefer challenge scopes when present). + +### After `kind: "satisfied"` + +| Client | Action | +| ------ | ------ | +| **TUI / CLI** | Live provider on transport; SDK may retry in flight. Phase C: replay `context.pendingRequest` if set. | +| **Web** | **`disconnect()` → `connect()`** to re-snapshot tokens. Phase C: replay `context.pendingRequest` after reconnect. | + +Until Phase C, the failed tool call may still require a manual retry after recovery. + +### After `kind: "interactive"` + +Same as connect-time today: + +- **Web (401):** toast → auto-redirect → `/oauth/callback` → `completeOAuthFlow()` → reconnect. +- **Web (403, standard OAuth):** modal → on Authorize, stash pending server id → redirect → `/oauth/callback` → `completeOAuthFlow()` → reconnect. +- **EMA (IdP session missing or expired):** leg 1 IdP redirect → callback → legs 2–3 → reconnect. +- **TUI / CLI:** `oauthAuthorizationRequired` → browser → callback → `completeOAuthFlow()` → reconnect if needed. + +### After `kind: "failed"` or user **Cancel** (standard-OAuth step-up modal only) + +Do **not** disconnect the MCP session for recoverable challenges. + +| Reason | Cancel / failed outcome | +| ------ | ------------------------ | +| `insufficient_scope` (standard OAuth, user Cancelled) | Stay connected; failed tool shows error; other scoped operations may still work | +| `insufficient_scope` (EMA, silent path) | No Cancel — auto re-mint; on mint failure → `kind: "failed"` toast, stay connected (degraded) | +| `token_expired` / `unauthorized` | Stay connected (**degraded**); banner to re-authenticate; auth-gated calls fail until recovery | + +## Remote wire protocol (web) + +Backend **reports** challenges; browser **handles** them. + +### Detection + +Auth challenges are detected when MCP traffic fails — on the HTTP response from the MCP server, in `transport.send()` error handling, or in transport `onerror`. The backend emits a structured event; the browser runs `handleAuthChallenge()`. + +```text +MCP SDK transport (RemoteSession on Hono backend) + └─ HTTP 401/403 or SDK auth failure on send / stream + └─ parseAuthChallengeFromResponse() at this hook + └─ RemoteSession.pushEvent({ type: "auth_challenge", data }) + └─ SSE → browser RemoteClientTransport + └─ InspectorClient → handleAuthChallenge() +``` + +#### Web — detection (`core/mcp/remote/node/`) + +Inside an active `RemoteSession`, when MCP traffic fails with an auth error: + +- **Fetch wrapper on the backend transport** — wrap the fetch passed to `createTransportNode`. Intercept the MCP HTTP `Response` **before** the SDK consumes it. On **401** or **403**, parse `WWW-Authenticate`, emit `auth_challenge`, and do not let the SDK call `auth()` on the frozen `createTokenAuthProvider` stub. +- **`/api/mcp/send`** — extend to preserve **403** and map stub-auth failures to structured errors (today: **401** only; stub failures often return **500**). +- **Transport `onerror`** — secondary path when the SDK reports auth-related failures without a parseable response (preserve status/code; do not collapse to generic 500). + +Parse `WWW-Authenticate` from the response headers on the failing request. + +Do **not** confuse MCP server OAuth with Inspector launcher auth (`x-mcp-remote-auth` on requests to the Hono API — that is session auth to the remote backend, not MCP server OAuth). + +#### TUI / CLI — detection (direct transport) + +Same **`handleAuthChallenge()`** entry via **transport fetch wrapper** (before SDK auth retry): + +- Intercept **401** and **403** on streamable HTTP; run `handleAuthChallenge()` with SEP-2350 union scopes for step-up. Do **not** rely on SDK built-in 403 retry alone. +- Legacy **SSE** transport: **401** only (no 403 step-up in SDK). +- Dispatch **`authChallenge`** on `InspectorClient` (Phase 4 replaces TUI `show401AuthHint`). +- **`oauthAuthorizationRequired`** fires when `handleAuthChallenge()` returns `interactive`. + +### SSE event + +Extend `RemoteEvent` in `core/mcp/remote/types.ts`: + +```typescript +export interface RemoteAuthChallengeEvent { + type: "auth_challenge"; + data: AuthChallenge & { + /** Server catalog id — browser resolves InspectorClient instance. */ + serverId?: string; + }; +} +``` + +**Rules:** + +- Emit **once per recoverable 401/403 auth challenge** (dedupe per [Architecture §Web: detection and wire protocol](#web-detection-and-wire-protocol)). +- Do **not** mark transport dead for recoverable auth challenges unless the SDK closed the connection. +- Include `requiredScopes` when parsed from `WWW-Authenticate`. +- Attach **`context.pendingRequest`** when the failing RPC is known (Phase C). + +### Browser handling + +1. `RemoteClientTransport` receives `auth_challenge` on SSE. +2. `InspectorClient` dispatches **`authChallenge`**. +3. App calls `handleAuthChallenge(challenge)` (via `authChallengeFlow.ts` once extracted). +4. On `satisfied` or post-callback success: reconnect active server; Phase C replays pending RPC. +5. UX per [Architecture §UX](#ux). + +## Client matrix + +| Concern | Web | TUI | CLI | +| ------- | --- | --- | --- | +| Challenge detection | SSE `auth_challenge` from `RemoteSession` | `InspectorClient` auth hook on live transport / provider | Same as TUI when OAuth wired | +| Auth execution | Browser `OAuthManager` | Node `OAuthManager` | Node (when implemented) | +| OAuth storage today | `BrowserOAuthStorage` (sessionStorage) | `NodeOAuthStorage` (file) | None | +| OAuth storage target | `RemoteOAuthStorage` → shared `oauth.json` ([EMA spec §Shared storage](v2_auth_ema.md)) | File | File | +| Post-success | Remote reconnect (+ Phase C RPC replay) | Reconnect / SDK retry (+ Phase C) | Same as TUI when OAuth wired | +| Step-up UX | Modal (standard OAuth); silent (EMA) | Same | Same as TUI when OAuth wired | +| EMA IdP config | Client Settings | `client.json` (Phase 4) | `client.json` (Phase 4) | + +## Relationship to other specs + +| Doc | Relationship | +| --- | ------------ | +| [v2_auth_ema.md](v2_auth_ema.md) | EMA legs 2–3 re-mint on resource-token challenges; scope resolution; no resource-OAuth fallback | +| [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) | Manual smokes after implementation; add mid-session / step-up scenarios | +| [v2_storage.md](v2_storage.md) | Shared `oauth.json` via `RemoteOAuthStorage` | +| [v2_scope.md](v2_scope.md) | Mid-session authorization extends “OAuth Handling” | +| [v2_auth_hardening.md](v2_auth_hardening.md) | Connect-time SEPs (2468, 837, 2352, 2207, 2351); v2 SDK upgrade path; overlaps SEP-2350 scope union | + +## Phased implementation + +Phases 1–2 deliver **Phase A** (token recovery). Phase 3 delivers **Phase B** (SEP-2350 step-up). Phase 4 is client parity and shared storage. Phase 5 delivers **Phase C** (pending RPC replay). + +### Phase 1 — Foundation (core + types) + +- [ ] Add `AuthChallenge`, `AuthChallengeReason`, `AuthChallengeOutcome` in `core/auth/challenge.ts` +- [ ] Add `parseAuthChallengeFromResponse(...)` — **401 and 403**, `WWW-Authenticate`, SDK error +- [ ] Add `isAuthChallengeError()` in web utils +- [ ] Implement `OAuthManager.handleAuthChallenge()` for **standard OAuth** (`token_expired` / generic 401 → refresh or interactive) +- [ ] Unit tests for parser and standard-OAuth branches + +### Phase 2 — Web remote propagation (401 / token recovery) + +- [ ] Backend fetch wrapper: detect MCP **401/403** before frozen stub `auth()`; emit SSE **`auth_challenge`** (applies to **`/api/mcp/send` and failures during connect handshake**, e.g. `initialize`) +- [ ] Extend `/api/mcp/send` and **connect-time** transport failures for **403** and stub-auth error mapping (never surface raw SDK *saveable for dynamic registration* as an opaque **500** to the browser) +- [ ] Browser: `RemoteClientTransport` → `InspectorClient` **`authChallenge`** event → `handleAuthChallenge()` +- [ ] On satisfaction: disconnect + reconnect; wire 401 auto-redirect; standard-OAuth step-up modal +- [ ] Integration test (mid-session): invalidate access token **after** connect → challenge → reconnect → `tools/list` succeeds (manual tool retry until Phase 5) +- [ ] Integration test (reconnect): complete OAuth, invalidate access token (or use expired JWT fixture), **disconnect** → **`connect()`** → challenge → recovery → connected (must **not** throw *saveable for dynamic registration*) +- [ ] Integration test (silent refresh, web remote): static client + `refresh_token`, invalidate access token only → challenge → silent refresh → reconnect → success (mirror local `inspectorClient-oauth-e2e` refresh test via `createRemoteTransport`) + +### Phase 3 — SEP-2350 step-up + EMA scope challenges (Phase B) + +- [ ] Parse **403 `insufficient_scope`**; scope union via `saveScope(authorizationScopes)` +- [ ] EMA 403: silent legs 2–3 with union scopes (valid IdP session); leg 1 only when IdP session invalid +- [ ] Extend **`test-server-oauth.ts`** with **403** + `insufficient_scope` fixture +- [ ] Integration test: 403 step-up → union re-auth → tool succeeds (manual retry until Phase 5) +- [ ] Backend: **`auth_challenge`** for **403** (included in Phase 2 wrapper; verify step-up path) + +### Phase 4 — Client parity + storage + +- [ ] TUI: fetch wrapper + `authChallenge` event (replace `show401AuthHint`) +- [ ] CLI: wire `environment.oauth`; same handler +- [ ] Web: `RemoteOAuthStorage` (shared `oauth.json`) + `navigator.locks` single-flight +- [ ] Multi-tab dedupe once shared storage lands + +### Phase 5 — Pending RPC replay (Phase C) + +- [ ] Attach failing JSON-RPC to `AuthChallenge.context.pendingRequest` at detection +- [ ] After `satisfied` + reconnect (web) or satisfied on live transport (TUI/CLI): **replay once** (bounded) +- [ ] Integration tests: 401 refresh, EMA re-mint, and 403 step-up all replay the original tool call +- [ ] On replay failure: surface tool error; do not loop + +## Testing + +| Layer | What to prove | +| ----- | ------------- | +| Unit | Challenge parsing; scope merge; EMA scope preference over config | +| Integration (local AS) | Expired token → silent refresh → success (TUI direct transport) | +| Integration (web remote, mid-session) | Invalidate token after connect → SSE `auth_challenge` → reconnect → `tools/list` | +| Integration (web remote, reconnect) | Invalidate/expired token before `connect()` with stored snapshot → challenge → recovery → connected (no stub DCR **500**) | +| Integration (web remote, refresh) | Invalidate access token only; `refresh_token` present → silent refresh → reconnect | +| Integration (Phase C replay) | 401 / EMA / 403 recovery → original tool call replays automatically | +| Integration (SEP-2350 step-up) | MCP server returns **403** `insufficient_scope` → union re-auth → retried tool call | +| EMA | Invalidate resource JWT only; legs 2–3 re-run; IdP session still valid | +| Manual | Document in [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) §Mid-session auth | + +## File touch list (expected) + +| Area | Files | +| ---- | ----- | +| Types | `core/auth/challenge.ts` | +| Handler | `core/mcp/oauthManager.ts`, `core/auth/ema/emaFlow.ts`, `core/auth/ema/resourceContext.ts` | +| Remote | `core/mcp/remote/types.ts`, `core/mcp/remote/node/remote-session.ts`, `core/mcp/remote/node/server.ts`, `core/mcp/remote/remoteClientTransport.ts`, transport fetch wrapper in `core/mcp/node/transport.ts` | +| Web app | `clients/web/src/App.tsx`, `clients/web/src/utils/authChallengeFlow.ts`, `clients/web/src/utils/oauthFlow.ts` (`isAuthChallengeError`) | +| TUI | `clients/tui/src/App.tsx` | +| Test server | `test-servers/src/test-server-oauth.ts` | +| Tests | `clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts`, new remote auth-challenge + Phase C replay tests | + diff --git a/specification/v2_auth_smoke_testing.md b/specification/v2_auth_smoke_testing.md new file mode 100644 index 000000000..23df077da --- /dev/null +++ b/specification/v2_auth_smoke_testing.md @@ -0,0 +1,573 @@ +# Inspector V2 — OAuth smoke testing (real servers) + +### [Brief](README.md) | [V2 Scope](v2_scope.md) | [Servers file](v2_servers_file.md) | [Auth hardening](v2_auth_hardening.md) | [Mid-session auth](v2_auth_mid_session.md) | [EMA / XAA](v2_auth_ema.md) + +Manual smoke procedures for exercising Inspector OAuth against **hosted** MCP servers. Complements automated coverage in `clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts`, which uses the in-repo `TestServerHttp` (`createOAuthTestServerConfig`). + +This document does **not** replace CI. It records known-good real endpoints, which client-ID mechanism each server supports, and how to configure Inspector (web, TUI, or CLI). + +## Install-level client config (TUI / CLI) + +EMA and CIMD credentials live in **`~/.mcp-inspector/storage/client.json`** (same file the web **Client Settings** dialog writes). TUI and CLI load it automatically at startup: + +| Flag / env | Default | +| ---------- | ------- | +| `--client-config ` | `~/.mcp-inspector/storage/client.json` | +| `MCP_CLIENT_CONFIG_PATH` | Same default when `--client-config` is omitted | + +For repo smoke fixtures, point at the checked-in template: + +```bash +--client-config configs/client.json +``` + +CLI flags (`--client-metadata-url`, `--client-id`, `--client-secret`) override values from `client.json` when present. Per-server OAuth fields in `mcp.json` still apply for static/DCR/EMA resource credentials. + +OAuth callback URL (TUI/CLI only): + +| Flag / env | Default | +| ---------- | ------- | +| `--callback-url ` | `http://127.0.0.1:6276/oauth/callback` | +| `MCP_OAUTH_CALLBACK_URL` | Same default when `--callback-url` is omitted | + +Web uses `http://localhost:6274/oauth/callback` on the main app server — not these runner settings. + +## Terminology + +| Term | Meaning in this doc | +| ---- | ------------------- | +| **Static / preregistered client** | You supply `oauthClientId` and optionally `oauthClientSecret` in Server Settings. Inspector skips DCR and uses your credentials. | +| **DCR** | Dynamic Client Registration (RFC 7591). Inspector registers at the AS `registration_endpoint` on first connect; no client id in config. | +| **CIMD** | Client ID Metadata Document (SEP-991). Inspector uses an HTTPS metadata URL as `client_id` when the AS advertises `client_id_metadata_document_supported`. | +| **Client credentials grant** | OAuth 2.0 machine-to-machine `grant_type=client_credentials`. **Not** what we mean by “static client credentials” here. Inspector does not implement this grant yet ([#1225](https://github.com/modelcontextprotocol/inspector/issues/1225)). | + +## Prerequisites + +1. **Inspector web, TUI, or CLI** with OAuth environment wired (`environment.oauth` — web always; TUI/CLI for HTTP/SSE servers). +2. **Redirect URI** must match what the authorization server expects: + - **Web (dev):** `http://localhost:6274/oauth/callback` (default `CLIENT_PORT`; see `clients/web/server/web-server-config.ts`). + - **Web (prod launcher):** `{origin}/oauth/callback` (same host/port as the Hono server). + - **TUI / CLI:** loopback callback from `createOAuthCallbackServer()` (TUI) — default **`http://127.0.0.1:6276/oauth/callback`** (port 6276 ≈ T9 “MCPO”, MCP OAuth; separate from web `6274`). Override with `--callback-url` or `MCP_OAUTH_CALLBACK_URL`. Use `http://127.0.0.1:0/oauth/callback` for an OS-assigned ephemeral port when the AS registers redirect URIs dynamically (DCR). +3. **Catalog entry** in `~/.mcp-inspector/mcp.json` (or ad-hoc connect) with `type: "streamable-http"` and the server URL. +4. **Keychain-backed secrets (TUI/CLI):** when OAuth client secrets or stdio env values were saved via web **Server Settings**, they live in the OS keychain — not in `mcp.json`. TUI and CLI merge keychain values on catalog load (same effective config as web `GET /api/servers`; see [Servers file](v2_servers_file.md) §Secret storage). For static/EMA smokes you can also pass `--client-secret` (CLI/TUI), use a local hand-edited `mcp.json` with plaintext secrets (dev only — never commit), or save secrets once via web on the same machine before running the TUI/CLI. + +### Example catalog shape (HTTP server) + +```jsonc +{ + "mcpServers": { + "mcp-example-everything": { + "type": "streamable-http", + "url": "https://example-server.modelcontextprotocol.io/mcp" + }, + "github-mcp": { + "type": "streamable-http", + "url": "https://api.githubcopilot.com/mcp/" + }, + "stytch-mcp-demo": { + "type": "streamable-http", + "url": "https://stytch-as-demo.val.run/mcp" + }, + "stytch-mcp": { + "type": "streamable-http", + "url": "https://mcp.stytch.dev/mcp" + } + } +} +``` + +Per-server OAuth fields live on the same entry (lifted to `InspectorServerSettings` in memory). On disk (#1358 flat shape): + +```jsonc +"oauth": { + "clientId": "", + "clientSecret": "", + "scopes": "repo read:user" +} +``` + +## Smoke matrix (real servers) + +| Server | URL | Mechanism to smoke | Credentials in repo? | +| ------ | --- | ------------------ | -------------------- | +| **MCP Example “Everything”** (hosted) | `https://example-server.modelcontextprotocol.io/mcp` | **DCR** | No — register at connect time | +| **GitHub MCP** (remote) | `https://api.githubcopilot.com/mcp/` | **Static OAuth App** (or PAT header bypass) | No — you create a GitHub OAuth App | +| **Stytch MCP demo** (hosted) | `https://stytch-as-demo.val.run/mcp` | **CIMD** (also DCR) | No — use [MCPJam CIMD](#cimd-credentials-for-smoke-mcpjam) (default for smoke) | +| **Stytch MCP** (management API) | `https://mcp.stytch.dev/mcp` | **CIMD** (also DCR) | No — same MCPJam CIMD URL; real Stytch login at `stytch.com` | +| **Composable test server** (local) | `http://127.0.0.1:/mcp` | Static, DCR, CIMD (all) | Fake ids in e2e tests only | +| **xaa.dev EMA** | Local resource + `auth.resource.xaa.dev` | EMA (not standard OAuth ladder) | Registered on xaa.dev — see [EMA staging](v2_auth_ema.md#staging-validation-manual--verified) | + +--- + +## 1. MCP Example “Everything” server (DCR) + +**Hosted reference server** implementing the full MCP feature surface (tools, resources, prompts, sampling, elicitation). Source: [modelcontextprotocol/example-remote-server](https://github.com/modelcontextprotocol/example-remote-server). Public deployment: + +| Field | Value | +| ----- | ----- | +| MCP URL | `https://example-server.modelcontextprotocol.io/mcp` | +| Resource identifier | `https://example-server.modelcontextprotocol.io/` | +| Authorization server | Same host (combined resource + AS) | +| Protected resource metadata | `https://example-server.modelcontextprotocol.io/.well-known/oauth-protected-resource` | +| AS metadata | `https://example-server.modelcontextprotocol.io/.well-known/oauth-authorization-server` | + +**Verified AS capabilities (June 2026):** + +- `registration_endpoint`: present → **DCR supported** +- `client_id_metadata_document_supported`: **not advertised** → CIMD not available on this host +- `token_endpoint_auth_methods_supported`: `["none"]` → public client after DCR +- `code_challenge_methods_supported`: `["S256"]` → PKCE required + +### Procedure (web) + +1. Add catalog entry (no `oauth` block needed for DCR). +2. Start Inspector web (`mcp-inspector --web --dev` or launcher). +3. Connect → expect **401**, then OAuth redirect (or use Connection flow that calls `authenticate()` on 401). +4. Complete browser authorization on the example AS. +5. **Pass:** `tools/list` succeeds; Connection Info shows authorized + dynamic client id from DCR. + +### Procedure (TUI) + +1. `mcp-inspector --tui` with catalog containing the server. +2. Press **C** to connect — OAuth starts automatically when the server returns 401. +3. Complete browser flow against callback server (`http://127.0.0.1:6276/oauth/callback` by default). +4. **Pass:** connect succeeds without a second **C**; Auth tab shows authorized state. + +### Notes + +- First connect performs discovery + DCR + authorization code + PKCE. Tokens persist in `~/.mcp-inspector/storage/oauth.json` (TUI/CLI path) or browser session storage (web). +- Reconnect should reuse stored DCR `client_id` unless storage was cleared. + +--- + +## 2. GitHub MCP server (static / preregistered OAuth App) + +**Remote GitHub MCP** is the usual choice for **static client credential** smoke testing against a production authorization server that does **not** expose DCR to arbitrary MCP clients. + +| Field | Value | +| ----- | ----- | +| MCP URL | `https://api.githubcopilot.com/mcp/` | +| Resource identifier | `https://api.githubcopilot.com/mcp` | +| Protected resource metadata | `https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp` | +| Authorization server | `https://github.com/login/oauth` | +| Upstream docs | [github/github-mcp-server](https://github.com/github/github-mcp-server) — [remote-server.md](https://github.com/github/github-mcp-server/blob/main/docs/remote-server.md) | + +**Verified PRM (June 2026):** `authorization_servers: ["https://github.com/login/oauth"]`. Scopes advertised include `repo`, `read:org`, `read:user`, `user:email`, `gist`, `workflow`, etc. + +### Why GitHub is the static-credentials smoke server + +- GitHub’s OAuth platform expects a **pre-registered OAuth App** (or GitHub App) with a fixed callback URL. There is no open `registration_endpoint` for Inspector-style DCR. +- VS Code, Cursor, and other hosts register **their own** GitHub OAuth applications; Inspector does not ship shared production client id/secret pairs. +- This matches the “user knows static credentials are required” path: set `oauthClientId` / `oauthClientSecret` in Server Settings before or after the first 401. + +### Credentials to use (you create these) + +There are **no shared Inspector test credentials** in this repository. Create a **GitHub OAuth App** under your account or org: + +1. Open [GitHub Developer settings → OAuth Apps](https://github.com/settings/developers) → **New OAuth App**. +2. Suggested values for local web smoke: + + | Field | Value | + | ----- | ----- | + | Application name | `MCP Inspector (local dev)` | + | Homepage URL | `http://localhost:6274` | + | Authorization callback URL | `http://localhost:6274/oauth/callback` | + + For TUI/CLI, register **`http://127.0.0.1:6276/oauth/callback`** (default) on the OAuth app, or override with `--callback-url` / `MCP_OAUTH_CALLBACK_URL`. + +3. After creation, copy **Client ID** and generate a **Client secret**. +4. In Inspector **Server Settings → OAuth** for the GitHub MCP entry: + - **Client ID** → `oauth.clientId` on disk + - **Client secret** → `oauth.clientSecret` (stored via keychain on web backend when saved through UI) + - **Scopes** → space-separated subset of PRM scopes, e.g. `read:user repo` (match what you need for `tools/list`; tighter scopes reduce consent friction) + +Example `mcp.json` fragment: + +```jsonc +"github-mcp": { + "type": "streamable-http", + "url": "https://api.githubcopilot.com/mcp/", + "oauth": { + "clientId": "Ov23liXXXXXXXXXXXX", + "clientSecret": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "scopes": "read:user repo" + } +} +``` + +**Never commit real secrets.** Use local catalog only. + +### Procedure (web) + +Run both phases in order. **Clear OAuth state** between phases: **Server Settings → OAuth → Clear stored OAuth state**, or (while connected) **Connection Info → OAuth Details → Clear and disconnect**. + +#### Phase 1 — static credentials off (expect failure) + +1. Add the GitHub MCP catalog entry with **no** Server Settings OAuth fields (no `oauth` block on disk). +2. Connect → **401** → authenticate → complete or attempt GitHub login. +3. **Pass (negative control):** connect does **not** reach an authorized MCP session. Typical outcomes: token exchange or registration error, or 401 persists after callback — GitHub has no DCR `registration_endpoint`, so Inspector cannot obtain a client id without your OAuth App credentials. + +#### Phase 2 — static credentials on (expect success) + +1. Create the GitHub OAuth App and fill **Server Settings → OAuth** (Client ID, Client secret, scopes) as above. +2. Clear OAuth state for this server again (Server Settings or Connection Info as above). +3. Connect → **401** → authenticate → GitHub login/consent for **your** OAuth App. +4. **Pass:** connected; `tools/list` returns GitHub tools; Connection Info shows **Client registration = Static (preregistered)** and your OAuth App client id. + +### Procedure (TUI) + +Same two phases: + +1. **Off:** catalog entry without `oauth` block → connect, **A** to authenticate → **expect failure** (no DCR on GitHub). +2. **On:** add `oauth` block or `--client-id` / `--client-secret` → clear stored OAuth tokens for the server → connect → **expect success** as in Phase 2 (web). + +### Alternative: Personal Access Token (non-OAuth smoke) + +GitHub documents PAT auth with a static header (bypasses OAuth flow entirely). Useful for MCP tool smoke, **not** for testing Inspector OAuth: + +```jsonc +"github-mcp-pat": { + "type": "streamable-http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "Authorization": "Bearer " + } +} +``` + +See [GitHub MCP README — Using a GitHub PAT](https://github.com/github/github-mcp-server#remote-github-mcp-server). + +### GitHub org / enterprise + +- Org may block OAuth Apps or MCP policies — see [policies-and-governance.md](https://github.com/github/github-mcp-server/blob/main/docs/policies-and-governance.md). +- **GitHub Enterprise Cloud (ghe.com):** use `https://copilot-api.octocorp.ghe.com/mcp` and enterprise OAuth settings ([README](https://github.com/github/github-mcp-server#github-enterprise)). + +--- + +## 3. Stytch MCP server (CIMD) + +Stytch advertises first-class [CIMD support for MCP](https://stytch.com/blog/oauth-client-id-metadata-mcp/). Inspector smoke testing uses **two** hosted Stytch MCP targets: + +| Target | When to use | +| ------ | ----------- | +| **`stytch-mcp-demo`** — `https://stytch-as-demo.val.run/mcp` | **Default for local CIMD smoke.** Test Stytch project; authorize at the demo app (`/oauth/authorize`) with email OTP / test login — not the `stytch.com` dashboard. | +| **`stytch-mcp`** — `https://mcp.stytch.dev/mcp` | **Production-style smoke.** Stytch’s hosted Management API MCP; authorize at `https://stytch.com/oauth/authorize` with a real Stytch workspace account (often social SSO). | + +### 3a. Stytch demo MCP (preferred for dev) + +| Field | Value | +| ----- | ----- | +| MCP URL | `https://stytch-as-demo.val.run/mcp` | +| Resource identifier | `https://stytch-as-demo.val.run/mcp` | +| Protected resource metadata | `https://stytch-as-demo.val.run/.well-known/oauth-protected-resource/mcp` | +| Authorization server (issuer) | `https://industrious-dress-4239.customers.stytch.dev` *(resolve from PRM — may change)* | +| AS metadata | `https://industrious-dress-4239.customers.stytch.dev/.well-known/oauth-authorization-server` | +| Token endpoint | `https://industrious-dress-4239.customers.stytch.dev/v1/oauth2/token` | +| DCR registration endpoint | `https://industrious-dress-4239.customers.stytch.dev/v1/oauth2/register` | +| Authorization UI | `https://stytch-as-demo.val.run/oauth/authorize` (demo-hosted; test login — **not** `stytch.com`) | + +**Verified discovery (June 2026):** PRM at the URL above currently returns: + +```json +{ + "resource": "https://stytch-as-demo.val.run/mcp", + "authorization_servers": ["https://industrious-dress-4239.customers.stytch.dev"], + "scopes_supported": ["openid", "email"] +} +``` + +AS metadata from that issuer currently includes: + +- `issuer`: `https://industrious-dress-4239.customers.stytch.dev` +- `client_id_metadata_document_supported`: **true** → CIMD +- `registration_endpoint`: `…/v1/oauth2/register` → **DCR also works** on the same server +- `token_endpoint`: `…/v1/oauth2/token` +- `authorization_endpoint`: `https://stytch-as-demo.val.run/oauth/authorize` (not `stytch.com`) +- `code_challenge_methods_supported`: `["S256"]` +- `token_endpoint_auth_methods_supported`: `["client_secret_basic", "client_secret_post", "none"]` + +Use this server when validating Inspector CIMD/DCR against a **real** Connected Apps AS without needing a Stytch Management workspace login. + +**Typical network sequence (CIMD, web):** + +1. `POST https://stytch-as-demo.val.run/mcp` → **401** (no token) +2. `GET …/.well-known/oauth-protected-resource/mcp` → PRM +3. `GET …/.well-known/oauth-authorization-server` → AS metadata (on the issuer host from PRM) +4. Browser redirect to `https://stytch-as-demo.val.run/oauth/authorize?client_id=…` (not logged as an Inspector fetch) +5. Callback to `http://localhost:6274/oauth/callback` +6. `POST …/v1/oauth2/token` → **200** (see [Verifying CIMD](#verifying-cimd-vs-dcr-network-and-tokens) for the request body) +7. `POST https://stytch-as-demo.val.run/mcp` → **200** + +CIMD does **not** call `POST …/v1/oauth2/register` — Inspector stores the metadata URL as `client_id` locally before `auth()`. Stytch fetches the metadata document **server-side** during authorize; that fetch does not appear in Inspector’s network log. + +### 3b. Stytch Management MCP (production-style) + +| Field | Value | +| ----- | ----- | +| MCP URL | `https://mcp.stytch.dev/mcp` | +| Resource identifier | `https://mcp.stytch.dev` | +| Protected resource metadata | `https://mcp.stytch.dev/.well-known/oauth-protected-resource` | +| Product docs | [Stytch MCP Server](https://stytch.com/docs/resources/workspace-management/stytch-mcp-server), [mcp.stytch.dev](https://mcp.stytch.dev/) | + +**Verified discovery (June 2026):** PRM at the URL above currently returns: + +```json +{ + "resource": "https://mcp.stytch.dev", + "authorization_servers": ["https://rustic-kilogram-6347.customers.stytch.com"], + "scopes_supported": [ + "openid", "email", "profile", + "admin:projects", "manage:api_keys", "manage:api_keys:test", + "manage:project_settings", "manage:project_data" + ] +} +``` + +AS metadata from that issuer includes: + +- `client_id_metadata_document_supported`: **true** → CIMD +- `registration_endpoint`: present → **DCR also works** on the same server +- `authorization_endpoint`: `https://stytch.com/oauth/authorize` (Stytch workspace login) +- `code_challenge_methods_supported`: `["S256"]` + +Resolve `token_endpoint` and `registration_endpoint` fresh from AS metadata on the issuer host (`rustic-kilogram-6347.customers.stytch.com` at time of writing). Issuer hostnames are project-specific and may change — always follow PRM → AS discovery rather than hardcoding. + +Use this server when you need to smoke CIMD against Stytch’s **live** Management API MCP and can sign in with a real Stytch account. + +### CIMD credentials for smoke (MCPJam) + +Inspector Stytch CIMD smoke uses **[MCPJam](https://www.mcpjam.com/)’s public Client ID Metadata Document** — no hosting or OAuth App setup required. + +CIMD does **not** use `oauth.clientId` / `oauth.clientSecret` in `mcp.json`. Configure install-wide in **Client Settings** (web), **`client.json`** (TUI/CLI default path or `--client-config`), or **`--client-metadata-url`** (CLI/TUI override). + +#### Metadata URL (use this in Client Settings) + +``` +https://www.mcpjam.com/.well-known/oauth/client-metadata.json +``` + +Live document (June 2026; fetch fresh if debugging redirect mismatches): + +```json +{ + "client_id": "https://www.mcpjam.com/.well-known/oauth/client-metadata.json", + "client_name": "MCPJam", + "client_uri": "https://www.mcpjam.com", + "logo_uri": "https://www.mcpjam.com/mcp_jam_2row.png", + "redirect_uris": [ + "mcpjam://oauth/callback", + "mcpjam://authkit/callback", + "http://127.0.0.1:6274/oauth/callback", + "http://localhost:6274/oauth/callback", + "…" + ], + "grant_types": ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "application_type": "native" +} +``` + +The full `redirect_uris` list also includes other localhost ports and MCPJam app URLs — see the [live document](https://www.mcpjam.com/.well-known/oauth/client-metadata.json). + +#### Inspector configuration + +| Surface | Setting | +| ------- | ------- | +| **Web** | **Client Settings** → **Client ID Metadata Document** → paste the MCPJam URL above → enable **Use Client ID Metadata Document** | +| **TUI** | `--client-metadata-url https://www.mcpjam.com/.well-known/oauth/client-metadata.json` (or enable CIMD in `client.json` / `--client-config`) | +| **CLI** | Same flags; reuses tokens from `~/.mcp-inspector/storage/oauth.json` when already authorized via web/TUI | + +#### Why MCPJam’s document works with Inspector + +CIMD treats the metadata **HTTPS URL** as the OAuth `client_id`. On authorize, Stytch (or any CIMD-capable AS) fetches that JSON and checks: + +1. The document’s `client_id` field matches the URL. +2. The `redirect_uri` on the request appears **exactly** in `redirect_uris`. + +Inspector web dev uses `http://localhost:6274/oauth/callback` by default (`CLIENT_PORT=6274`). MCPJam’s published metadata **includes that URI** (and `127.0.0.1:6274`), so a smoke run succeeds without Inspector hosting its own CIMD file. + +Implications for smoke (expected, not bugs): + +- **Connection Info → Client ID** shows the MCPJam metadata URL, not “MCP Inspector”. +- **Consent UI** may show **“MCPJam”** name/logo from the document — you authorized MCPJam’s published OAuth identity, not a separate Inspector-specific registration. +- **No `POST …/v1/oauth2/register`** — Inspector stores the metadata URL locally; Stytch validates by fetching MCPJam’s HTTPS document server-side. +- **Access token JWT** still carries Stytch’s internal `connected-app-test-…` id; see [Verifying CIMD](#verifying-cimd-vs-dcr-network-and-tokens). + +This is appropriate for **protocol smoke only**. MCPJam’s doc is a shared dev identity with permissive localhost redirects; production Inspector deployments should use an Inspector-owned metadata URL (future — see below). + +**Reference only (won’t work for Inspector callback):** [client.dev/oauth/metadata.json](https://client.dev/oauth/metadata.json) is Stytch’s public CIMD demo document; its `redirect_uris` point at `client.dev`, not localhost. + +Or use **DCR** against the same Stytch server with CIMD disabled (see [Alternative: DCR on Stytch](#alternative-dcr-on-stytch-no-metadata-hosting)). + +#### Inspector-owned CIMD (future) + +Hosting a dedicated `https://…/inspector/oauth-client.json` with Inspector branding and a minimal `redirect_uris` list (only Inspector callbacks) is the right model for non-smoke use. Not required for Stytch smoke today; document shape and Stytch requirements will be added here when we ship a hosted metadata URL. + +### Verifying CIMD vs DCR (network and tokens) + +Stytch **normalizes** both CIMD and DCR into internal Connected App records. The **access token JWT** looks the same either way — `client_id` in the decoded payload is Stytch’s internal id (e.g. `connected-app-test-0bb7b586-…`), **not** your metadata URL. **Do not use the JWT alone to distinguish CIMD from DCR.** + +| Check | CIMD | DCR | +| ----- | ---- | --- | +| **Connection Info → Client ID** | MCPJam metadata URL | Opaque `connected-app-test-…` | +| **Connection Info → Client registration** | Client ID Metadata (CIMD) | Dynamic (DCR) | +| **`POST …/v1/oauth2/token` body → `client_id`** | Metadata URL | `connected-app-test-…` | +| **Authorize URL → `client_id` param** (browser) | Metadata URL | `connected-app-test-…` | +| **`POST …/v1/oauth2/register`** (after cleared OAuth state) | **Absent** | Present on first connect | +| **Decoded access token JWT → `client_id`** | Internal Stytch id *(same shape as DCR)* | Internal Stytch id | + +**Definitive smoke signal:** expand the **`POST …/v1/oauth2/token`** row in the network log (auth category). The form body must include the MCPJam metadata URL as `client_id`. Verified example against **`stytch-mcp-demo`** (June 2026): + +``` +grant_type=authorization_code +code= +code_verifier= +redirect_uri=http://localhost:6274/oauth/callback +resource=https://stytch-as-demo.val.run/mcp +client_id=https://www.mcpjam.com/.well-known/oauth/client-metadata.json +``` + +Example decoded access token payload from the same flow (Stytch internal id — **expected**, not a CIMD failure): + +```json +{ + "aud": ["project-test-d06972e8-6af2-4952-bcb0-44d795ec5d6f"], + "client_id": "connected-app-test-0bb7b586-e16a-43f6-b2b4-6511a146cd1d", + "iss": "https://industrious-dress-4239.customers.stytch.dev", + "scope": "openid email", + "sub": "user-test-5ccf82bc-485d-4919-9e11-40084b02dc28" +} +``` + +### Procedure (web) + +Use **`stytch-mcp-demo`** unless you explicitly need the Management API host. Configure the [MCPJam CIMD URL](#cimd-credentials-for-smoke-mcpjam), then run both phases. **Clear OAuth state** between phases: **Server Settings → OAuth → Clear stored OAuth state**, or (while connected) **Connection Info → OAuth Details → Clear and disconnect**. + +**Stytch note:** Both hosts advertise DCR. With CIMD disabled, Inspector may still authorize via dynamic registration. Phase 1 therefore checks that CIMD was **not** used (client id ≠ MCPJam metadata URL), not necessarily that connect hard-fails. For a strict fail-without-CIMD smoke, use the [local composable server](#local-fallback-composable-test-server) with `supportCIMD: true` and `supportDCR: false`. + +#### Phase 1 — CIMD off (expect failure or non-CIMD client id) + +1. **Client Settings** → **Client ID Metadata Document**: paste the [MCPJam URL](#cimd-credentials-for-smoke-mcpjam) but leave **Use Client ID Metadata Document** **unchecked** (URL stays saved for Phase 2). +2. Add Stytch demo MCP (`https://stytch-as-demo.val.run/mcp`) with **no** per-server OAuth fields. +3. Connect → **401** → authenticate. +4. **Pass (negative control):** either connect **does not** reach an authorized session (e.g. you blocked DCR in your Stytch workspace), **or** Connection Info shows a **dynamic DCR client id** (not the MCPJam metadata URL). If you already see the MCPJam URL as client id, CIMD was still active — clear OAuth storage and confirm the checkbox is off. + +#### Phase 2 — CIMD on (expect success) + +1. Enable **Use Client ID Metadata Document** (MCPJam URL from [above](#cimd-credentials-for-smoke-mcpjam)). +2. Clear OAuth state for the Stytch server again. +3. Connect → **401** → authenticate → demo login/consent at `stytch-as-demo.val.run` (or `stytch.com` if using Management MCP). +4. **Pass:** connected; `tools/list` succeeds; Connection Info shows **client id = MCPJam metadata URL** and **Client registration = Client ID Metadata (CIMD)**; **`POST …/v1/oauth2/token` request body** shows the same URL as `client_id` (see [Verifying CIMD](#verifying-cimd-vs-dcr-network-and-tokens)). + +### Procedure (TUI) + +Same two phases (prefer **`stytch-mcp-demo`** URL): + +1. **Off:** launch without CIMD enabled in `client.json` and without `--client-metadata-url` → connect (**C**) → complete browser auth if prompted → **expect failure or DCR client id** as in Phase 1 (web). +2. **On:** + ```bash + mcp-inspector --tui --catalog configs/mcp.json \ + --client-metadata-url https://www.mcpjam.com/.well-known/oauth/client-metadata.json + ``` + Or enable CIMD in `client.json` and pass `--client-config configs/client.json` (or rely on the default install path after saving via web Client Settings). + Clear OAuth tokens → connect (**C**) → **expect success** with client id = metadata URL. + +### Procedure (CLI) + +Reuse tokens from a prior web/TUI session, or run after interactive auth in TUI: + +```bash +mcp-inspector --cli --catalog configs/mcp.json --server stytch-mcp-demo \ + --client-metadata-url https://www.mcpjam.com/.well-known/oauth/client-metadata.json \ + --method tools/list +``` + +Interactive OAuth on CLI prints the authorize URL to stdout (`ConsoleNavigation`); prefer TUI for first-time login smokes. + +### Alternative: DCR on Stytch (no metadata hosting) + +Same MCP URL (`stytch-as-demo.val.run/mcp` or `mcp.stytch.dev/mcp`), leave CIMD disabled and `clientMetadataUrl` unset — Inspector registers via `registration_endpoint` on the Stytch AS (demo: `POST https://industrious-dress-4239.customers.stytch.dev/v1/oauth2/register` at time of writing). First connect should show that register call in the network log before authorize. Useful when you only want to verify remote DCR + PKCE without hosting CIMD JSON. Token exchange will use the returned `connected-app-test-…` id as `client_id`, not an HTTPS metadata URL. + +### Local fallback (composable test server) + +For offline CIMD regression — or a **strict** fail-with-CIMD-off / succeed-with-CIMD-on pair without Stytch’s DCR fallback — use the in-repo `TestServerHttp` with `supportCIMD: true` and **`supportDCR: false`** (see `inspectorClient-oauth-e2e.test.ts`). `ensureCimdClientRegistration` allows `http://127.0.0.1` metadata URLs in tests only. + +```bash +cd test-servers && npm run build +node build/server-composable.js --config path/to/oauth-cimd-only.json +``` + +Example composable OAuth flags: `"supportCIMD": true`, `"supportDCR": false`. With CIMD off in Client Settings, connect + authenticate should **fail**; with CIMD on and a valid local metadata URL, it should **succeed**. + +--- + +## 4. EMA (xaa.dev) — cross-reference + +Enterprise-managed auth uses a **different** credential model (IdP in Client Settings + resource AS client on server). Documented in [v2_auth_ema.md § Staging validation](v2_auth_ema.md#staging-validation-manual--verified): + +- IdP: `https://idp.xaa.dev` +- Resource AS: `https://auth.resource.xaa.dev` +- Local MCP resource: `test-servers/configs/xaa-ema-http.json` + +Do not use xaa.dev for standard DCR/static/CIMD ladder testing unless explicitly testing EMA. + +**TUI EMA smoke** (IdP in `client.json`, resource server with `enterpriseManaged: true` in catalog): + +```bash +mcp-inspector --tui --catalog configs/mcp.json \ + --client-config configs/client.json +``` + +Register **`http://127.0.0.1:6276/oauth/callback`** on the xaa.dev IdP before leg 1 (default runner callback). IdP `clientSecret` belongs in `client.json` (web **Client Settings** or `--client-config` fixture). Per-server resource `oauth.clientSecret` belongs in the OS keychain (web **Server Settings** save path) or a local fixture — TUI/CLI rehydrate both IdP and resource secrets from keychain on catalog load (see prerequisite §4 above). + +--- + +## What to verify (all smokes) + +| Check | Where | +| ----- | ----- | +| **Negative control** (mechanism off → fail or wrong client id source) | Connection Info / error toast | +| **Clear OAuth state** between smoke phases | **Web:** Server Settings → OAuth → **Clear stored OAuth state**; or Connection Info → **Clear and disconnect** (standard) / **Clear OAuth state** (EMA). **TUI:** Auth tab → **S** (**Clear OAuth State**; disconnects when connected) | +| **Positive path** (mechanism on → authorized session) | Connect + `tools/list` | +| 401 on first connect without tokens | Network / Requests tab | +| OAuth discovery + correct mechanism (DCR vs static vs CIMD) | Network log (auth category) | +| **CIMD:** token exchange `client_id` = metadata URL | `POST …/v1/oauth2/token` request body | +| **CIMD:** no DCR register call (after cleared state) | Absence of `POST …/v1/oauth2/register` | +| **Stytch:** JWT `client_id` is internal id (not metadata URL) | Decode access token — informational only | +| Authorization redirect opens | Browser | +| Callback completes (`/oauth/callback`) | Web URL or TUI callback server | +| Second connect uses stored access token | Connect without re-login (until expiry) | +| `tools/list` JSON | Tools tab / CLI `--method tools/list` | +| Connection Info: protocol, authorized, client id, registration kind | Connection Info OAuth section | + +## Automated parity + +| Mode | Real server (this doc) | CI (in-repo) | +| ---- | ---------------------- | ------------ | +| DCR | example-server.modelcontextprotocol.io (or Stytch MCP) | `inspectorClient-oauth-e2e.test.ts` | +| Static | GitHub MCP + your OAuth App | `test-static-client` / `test-static-secret` on TestServerHttp | +| CIMD | **Stytch demo MCP** (`stytch-as-demo.val.run`) + [MCPJam metadata URL](#cimd-credentials-for-smoke-mcpjam) (or local composable) | `createClientMetadataServer()` in e2e | +| EMA | xaa.dev staging | `inspectorClient-ema-e2e.test.ts` + mocks | + +## Known gaps (Inspector) + +See **[Mid-session authorization](v2_auth_mid_session.md)** for the design to address mid-session 401, token refresh, and step-up scope challenges (including web remote reconnect). + +See **[Auth hardening (MCP 2026-07-28)](v2_auth_hardening.md)** for connect-time OAuth hardening (SEP-2468, SEP-837, SEP-2352, SEP-2207, SEP-2350, SEP-2351) and the v2 SDK upgrade strategy. + +- **CLI interactive OAuth:** no local callback server yet — reuse tokens from web/TUI or complete auth in TUI first; CLI prints authorize URLs to stdout when a new login is required. +- **Client credentials grant:** not implemented ([#1225](https://github.com/modelcontextprotocol/inspector/issues/1225)). + +## References + +- [MCP authorization spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) +- [example-remote-server](https://github.com/modelcontextprotocol/example-remote-server) +- [GitHub MCP remote server](https://github.com/github/github-mcp-server/blob/main/docs/remote-server.md) +- [Stytch MCP demo](https://stytch-as-demo.val.run/mcp) — local CIMD/DCR smoke (test login at demo `/oauth/authorize`) +- [Stytch MCP](https://mcp.stytch.dev/) — Management API MCP (production-style `stytch.com` login) +- [MCPJam CIMD metadata](https://www.mcpjam.com/.well-known/oauth/client-metadata.json) — shared dev metadata URL +- [CIMD for MCP](https://stytch.com/blog/oauth-client-id-metadata-mcp/), [Stytch client types](https://stytch.com/docs/connected-apps/oauth-learn-more/client-types) +- [client.dev](https://client.dev/) — CIMD format reference +- Inspector e2e: `clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts` +- Local OAuth AS: `test-servers/src/test-server-oauth.ts`, `createOAuthTestServerConfig()` in `test-server-fixtures.ts` diff --git a/specification/v2_scope.md b/specification/v2_scope.md index af7b3624a..09c312a25 100644 --- a/specification/v2_scope.md +++ b/specification/v2_scope.md @@ -40,9 +40,12 @@ * Ping ## OAuth Handling - * Quick Flow - * Guided Flow - * Basic (non debugger) Flow + * `authenticate()` / `completeOAuthFlow()` (SDK-backed authorization-code flow) + * Connection Info for auth debugging + * **Enterprise-managed authorization (EMA / XAA)** — see [EMA / XAA](v2_auth_ema.md) + * **Mid-session authorization** (401 after connect: token refresh, step-up scopes, web remote reconnect) — see [Mid-session auth](v2_auth_mid_session.md) + * **Authorization hardening** (MCP 2026-07-28 SEPs: `iss`, DCR client type, issuer-bound credentials, step-up scope union) — see [Auth hardening](v2_auth_hardening.md) + * **OAuth smoke testing** (manual procedures against hosted servers) — see [Smoke testing](v2_auth_smoke_testing.md) ## Transport Types * STDIO diff --git a/specification/v2_servers_file.md b/specification/v2_servers_file.md index 8b391c93a..8c559ac77 100644 --- a/specification/v2_servers_file.md +++ b/specification/v2_servers_file.md @@ -243,7 +243,7 @@ Each server entry may carry these Inspector-extension fields at the top level: - `connectionTimeout` → `Promise.race` wrapper around `InspectorClient.connect()` in the web client. - `oauth.clientId` / `oauth.clientSecret` / `oauth.scopes` → pre-seeded OAuth client credentials via `InspectorClientOptions.oauth` (the disk-side `oauth` object is lifted into the flat `oauthClientId` / etc. fields on `InspectorServerSettings` for the form). - **First-connect contract**: settings apply on the *first* outbound request after the entry loads from disk — no need to open the settings form. The browser sends `settings` to the backend in the `/api/mcp/connect` body; the backend reads it from `RemoteConnectRequest` and threads it into `createTransportNode`. -- **Secret storage (#1356)**: `oauth.clientSecret` and stdio `env` values are persisted in the OS keychain (macOS Keychain Services / Windows Credential Manager / Linux libsecret via `@napi-rs/keyring`), keyed by `(serverId, field)` under the service name `mcp-inspector`. Field names: `oauth-client-secret`, `env:` (one per stdio env variable). The on-disk `mcp.json` is stripped of these values — `oauth.clientSecret` is omitted entirely, stdio env keys are preserved with empty-string placeholders (`"env": { "API_KEY": "" }`) so the file still documents the env interface the server expects. The wire shape returned by `GET /api/servers` is unchanged from before #1356: the handler rehydrates values from the keychain so browser code sees the same JSON it has always seen. The keychain interactions live in `core/auth/node/secret-store.ts` behind a `SecretStore` interface; `KeyringSecretStore` is the production impl and `InMemorySecretStore` is the test double the integration suite injects via `RemoteServerOptions.secretStore`. +- **Secret storage (#1356)**: `oauth.clientSecret` and stdio `env` values are persisted in the OS keychain (macOS Keychain Services / Windows Credential Manager / Linux libsecret via `@napi-rs/keyring`), keyed by `(serverId, field)` under the service name `mcp-inspector`. Field names: `oauth-client-secret`, `env:` (one per stdio env variable). The on-disk `mcp.json` is stripped of these values — `oauth.clientSecret` is omitted entirely, stdio env keys are preserved with empty-string placeholders (`"env": { "API_KEY": "" }`) so the file still documents the env interface the server expects. The wire shape returned by `GET /api/servers` is unchanged from before #1356: the handler rehydrates values from the keychain so browser code sees the same JSON it has always seen. **TUI/CLI rehydration:** the shared `loadServerEntries()` path (`core/mcp/node/servers.ts`) calls `rehydrateMcpConfigFromKeychain()` (`core/mcp/node/server-secrets.ts`) after reading `mcp.json`, so runner clients see the same effective OAuth client secrets and stdio env values as the web catalog list. This path is read-only — it does not migrate plaintext secrets into the keychain (that remains the web `GET /api/servers` migration sweep). The keychain interactions live in `core/auth/node/secret-store.ts` behind a `SecretStore` interface; `KeyringSecretStore` is the production impl and `InMemorySecretStore` is the test double the integration suite injects via `RemoteServerOptions.secretStore`. - **Migration**: on every `GET /api/servers`, the handler walks the freshly-read config and, for any entry that still carries plaintext secrets (older Inspector builds, hand-edited files, files imported from another tool), lifts each value into the keychain and rewrites the file with the stripped shape. The migration is idempotent — when the keychain already holds a value for `(serverId, field)`, the keychain wins and the disk plaintext is dropped unread. After the rewrite the disk file no longer contains the secret material. - **Linux without libsecret**: `KeyringSecretStore` is *tolerant* — only the `set` operation throws `KeychainUnavailableError` (translated to a `503` by the handlers); `get` returns `null` and the destructive operations silently no-op. The result is that no-secret flows (creating a stdio server with no env values, deleting an entry, reading the list, the defensive sweep on POST) all work normally on a minimal Linux box without libsecret. Only the moments where a secret would actually be lost — saving an OAuth client secret, saving a stdio env value, or migrating a plaintext value into the keychain — surface a clear error. macOS and Windows always have a working keychain so this only matters on minimal Linux installs. - **Migration tolerance**: when migration encounters `KeychainUnavailableError`, the GET handler logs a warning, leaves the on-disk plaintext untouched, and serves the (still-plaintext) response. Subsequent reads retry — installing libsecret later lifts the secrets on the next GET without any user action. diff --git a/test-servers/src/test-server-oauth.ts b/test-servers/src/test-server-oauth.ts index d15cb1cfb..94416e0bd 100644 --- a/test-servers/src/test-server-oauth.ts +++ b/test-servers/src/test-server-oauth.ts @@ -676,7 +676,7 @@ export function clearOAuthTestData(): void { /** * Returns recorded DCR request bodies (redirect_uris) for tests that verify - * both normal and guided redirect URLs are registered. + * redirect URI registration. */ export function getDCRRequests(): Array<{ redirect_uris: string[] }> { return dcrRequests; From a119138af3181d0b2edf6d7f189d5bb7d18971ef Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 26 Jun 2026 23:09:18 -0700 Subject: [PATCH 3/8] Added CLI OAuth runner flags tests --- clients/cli/__tests__/helpers/fixtures.ts | 17 ++ clients/cli/__tests__/oauth-runner.test.ts | 194 +++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 clients/cli/__tests__/oauth-runner.test.ts diff --git a/clients/cli/__tests__/helpers/fixtures.ts b/clients/cli/__tests__/helpers/fixtures.ts index bfae186ac..78164cf2d 100644 --- a/clients/cli/__tests__/helpers/fixtures.ts +++ b/clients/cli/__tests__/helpers/fixtures.ts @@ -88,3 +88,20 @@ export function createInvalidConfig(): string { export function deleteConfigFile(configPath: string): void { cleanupTempDir(path.dirname(configPath)); } + +/** + * Create a temporary install-level client.json for --client-config tests. + */ +export function createClientConfigFile(config: Record): string { + const tempDir = createTempDir("mcp-inspector-client-config-"); + const configPath = path.join(tempDir, "client.json"); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + return configPath; +} + +/** + * Delete a client config file and its containing directory + */ +export function deleteClientConfigFile(configPath: string): void { + cleanupTempDir(path.dirname(configPath)); +} diff --git a/clients/cli/__tests__/oauth-runner.test.ts b/clients/cli/__tests__/oauth-runner.test.ts new file mode 100644 index 000000000..649dc7615 --- /dev/null +++ b/clients/cli/__tests__/oauth-runner.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import * as runner from "@inspector/core/client/runner.js"; +import { + createTestServerHttp, + createEchoTool, + createTestServerInfo, +} from "@modelcontextprotocol/inspector-test-server"; +import { runCli } from "./helpers/cli-runner.js"; +import { + expectCliFailure, + expectCliSuccess, + expectOutputContains, +} from "./helpers/assertions.js"; +import { + createClientConfigFile, + deleteClientConfigFile, +} from "./helpers/fixtures.js"; + +/** + * Covers OAuth runner flag wiring in `src/cli.ts` (#1514): --client-config, + * --client-id/--client-secret/--client-metadata-url, and --callback-url / + * MCP_OAUTH_CALLBACK_URL. Shared auth logic is unit-tested in + * clients/web/src/test/core/client/runner.test.ts; these assert the CLI + * parses flags and passes them through on HTTP (OAuth-capable) connects. + */ +describe("CLI OAuth runner flags", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("connects over HTTP with OAuth CLI overrides and custom callback URL", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + const buildSpy = vi.spyOn(runner, "buildRunnerClientAuthOptions"); + + try { + await server.start(); + const result = await runCli([ + server.url, + "--cli", + "--method", + "tools/list", + "--transport", + "http", + "--client-id", + "test-client-id", + "--client-secret", + "test-client-secret", + "--client-metadata-url", + "https://example.com/oauth/client-metadata.json", + "--callback-url", + "http://127.0.0.1:9999/oauth/callback", + ]); + + expectCliSuccess(result); + expect(buildSpy).toHaveBeenCalled(); + expect(buildSpy.mock.calls[0]?.[2]).toEqual({ + clientId: "test-client-id", + clientSecret: "test-client-secret", + clientMetadataUrl: "https://example.com/oauth/client-metadata.json", + }); + } finally { + await server.stop(); + } + }); + + it("loads install client.json from --client-config and prefers CLI CIMD override", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + const buildSpy = vi.spyOn(runner, "buildRunnerClientAuthOptions"); + const clientConfigPath = createClientConfigFile({ + cimd: { + enabled: true, + clientMetadataUrl: "https://example.com/from-client-json.json", + }, + }); + + try { + await server.start(); + const result = await runCli([ + server.url, + "--cli", + "--method", + "tools/list", + "--transport", + "http", + "--client-config", + clientConfigPath, + "--client-metadata-url", + "https://example.com/from-cli-flag.json", + ]); + + expectCliSuccess(result); + expect(buildSpy).toHaveBeenCalled(); + expect(buildSpy.mock.calls[0]?.[0]).toMatchObject({ + cimd: { + enabled: true, + clientMetadataUrl: "https://example.com/from-client-json.json", + }, + }); + expect(buildSpy.mock.calls[0]?.[2]).toEqual({ + clientMetadataUrl: "https://example.com/from-cli-flag.json", + }); + } finally { + await server.stop(); + deleteClientConfigFile(clientConfigPath); + } + }); + + it("accepts port 0 callback URL for ephemeral listener binding", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + + try { + await server.start(); + const result = await runCli([ + server.url, + "--cli", + "--method", + "tools/list", + "--transport", + "http", + "--callback-url", + "http://127.0.0.1:0/oauth/callback", + ]); + + expectCliSuccess(result); + } finally { + await server.stop(); + } + }); + + it("uses MCP_OAUTH_CALLBACK_URL when --callback-url is absent", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + + try { + await server.start(); + const result = await runCli( + [ + server.url, + "--cli", + "--method", + "tools/list", + "--transport", + "http", + ], + { + env: { + MCP_OAUTH_CALLBACK_URL: "http://127.0.0.1:8888/custom/oauth/callback", + }, + }, + ); + + expectCliSuccess(result); + } finally { + await server.stop(); + } + }); + + it("rejects an invalid OAuth callback URL before connecting", async () => { + const server = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + }); + + try { + await server.start(); + const result = await runCli([ + server.url, + "--cli", + "--method", + "tools/list", + "--transport", + "http", + "--callback-url", + "not-a-valid-url", + ]); + + expectCliFailure(result); + expectOutputContains(result, "Invalid OAuth callback URL"); + } finally { + await server.stop(); + } + }); +}); From fdfdc9514ea505055a46919a22998d978589f6c8 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 26 Jun 2026 23:11:29 -0700 Subject: [PATCH 4/8] Added clarifying comment on port 0 binding --- core/auth/node/runner-oauth-callback.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/core/auth/node/runner-oauth-callback.ts b/core/auth/node/runner-oauth-callback.ts index 4a1a7971c..326872a36 100644 --- a/core/auth/node/runner-oauth-callback.ts +++ b/core/auth/node/runner-oauth-callback.ts @@ -72,7 +72,11 @@ export function parseRunnerOAuthCallbackUrl( return { hostname, port, pathname }; } -/** Build the redirect_uri string sent to the authorization server. */ +/** + * Build the redirect_uri string sent to the authorization server. + * Port 0 yields `…:0/…`; the TUI overwrites `redirectUrlProvider.redirectUrl` + * with the bound listener URL before OAuth starts, so :0 is never sent to the AS. + */ export function formatRunnerOAuthRedirectUrl( config: RunnerOAuthCallbackConfig, ): string { From e981e301344ab7df2dda80642b9df0cd42c37ea3 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 26 Jun 2026 23:17:20 -0700 Subject: [PATCH 5/8] README notes on TUI oauth callback port, prettier fixes for CLI tests --- clients/cli/__tests__/helpers/fixtures.ts | 4 +++- clients/cli/__tests__/oauth-runner.test.ts | 12 +++--------- clients/tui/README.md | 6 ++++-- core/auth/node/runner-oauth-callback.ts | 4 ++++ 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/clients/cli/__tests__/helpers/fixtures.ts b/clients/cli/__tests__/helpers/fixtures.ts index 78164cf2d..457986a5c 100644 --- a/clients/cli/__tests__/helpers/fixtures.ts +++ b/clients/cli/__tests__/helpers/fixtures.ts @@ -92,7 +92,9 @@ export function deleteConfigFile(configPath: string): void { /** * Create a temporary install-level client.json for --client-config tests. */ -export function createClientConfigFile(config: Record): string { +export function createClientConfigFile( + config: Record, +): string { const tempDir = createTempDir("mcp-inspector-client-config-"); const configPath = path.join(tempDir, "client.json"); fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); diff --git a/clients/cli/__tests__/oauth-runner.test.ts b/clients/cli/__tests__/oauth-runner.test.ts index 649dc7615..180b29b5f 100644 --- a/clients/cli/__tests__/oauth-runner.test.ts +++ b/clients/cli/__tests__/oauth-runner.test.ts @@ -145,17 +145,11 @@ describe("CLI OAuth runner flags", () => { try { await server.start(); const result = await runCli( - [ - server.url, - "--cli", - "--method", - "tools/list", - "--transport", - "http", - ], + [server.url, "--cli", "--method", "tools/list", "--transport", "http"], { env: { - MCP_OAUTH_CALLBACK_URL: "http://127.0.0.1:8888/custom/oauth/callback", + MCP_OAUTH_CALLBACK_URL: + "http://127.0.0.1:8888/custom/oauth/callback", }, }, ); diff --git a/clients/tui/README.md b/clients/tui/README.md index d5a335493..28b0a7453 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -44,9 +44,11 @@ The TUI starts a small loopback HTTP server to receive the authorization redirec | **Web** | `http://localhost:6274/oauth/callback` (main app server) | | **TUI** | `http://127.0.0.1:6276/oauth/callback` (dedicated runner port; avoids colliding with web on 6274) | -OAuth redirect URIs must match **exactly** what you register on the authorization server — `localhost` and `127.0.0.1` are different URIs. Register the TUI default on your OAuth app / IdP when using pre-registered (static) or enterprise-managed clients. +**Why a fixed default port?** Enterprise-managed auth (EMA), CIMD, and many static OAuth apps require **pre-registered redirect URIs**. A predictable default (`http://127.0.0.1:6276/oauth/callback`) lets you register once on the IdP and reuse it across TUI sessions. Dynamic registration (DCR) can use ephemeral ports instead — see below. -Override the TUI listener with `--callback-url` or `MCP_OAUTH_CALLBACK_URL`. Use `http://127.0.0.1:0/oauth/callback` for an OS-assigned ephemeral port when the authorization server registers redirect URIs dynamically (DCR). +**Trade-off:** only **one TUI OAuth flow at a time** can listen on the default port. A second instance starting OAuth while another is in progress may fail with `EADDRINUSE`. Override the listener with `--callback-url` or `MCP_OAUTH_CALLBACK_URL` (e.g. a different fixed port per instance, or `http://127.0.0.1:0/oauth/callback` when the authorization server accepts dynamically registered redirect URIs). + +OAuth redirect URIs must match **exactly** what you register on the authorization server — `localhost` and `127.0.0.1` are different URIs. Register the TUI default on your OAuth app / IdP when using pre-registered (static), CIMD, or enterprise-managed clients. Override the listener with `--callback-url` or `MCP_OAUTH_CALLBACK_URL`; use `http://127.0.0.1:0/oauth/callback` for an OS-assigned ephemeral port when the authorization server registers redirect URIs dynamically (DCR). #### Flags diff --git a/core/auth/node/runner-oauth-callback.ts b/core/auth/node/runner-oauth-callback.ts index 326872a36..b106e459d 100644 --- a/core/auth/node/runner-oauth-callback.ts +++ b/core/auth/node/runner-oauth-callback.ts @@ -4,6 +4,10 @@ * Web uses the main Hono server (`localhost:6274` by default). Runners spin up * a minimal loopback listener; default port 6276 (T9 "MCPO", MCP OAuth) avoids colliding * with the web dev server while staying in the Inspector 627x family. + * + * Port 6276 is fixed so EMA, CIMD, and static OAuth apps can pre-register + * `http://127.0.0.1:6276/oauth/callback`. Concurrent TUI OAuth on that port is + * unsupported; override via `--callback-url` / `MCP_OAUTH_CALLBACK_URL`. */ export const RUNNER_OAUTH_CALLBACK_DEFAULT_HOSTNAME = "127.0.0.1"; From ecbd84df50b960f134d8075c21043f6674f8b545 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sun, 28 Jun 2026 16:05:28 -0700 Subject: [PATCH 6/8] CIMD validation uses inline like EMA IdP --- .../ClientSettingsForm.test.tsx | 65 +++++++++++ .../ClientSettingsForm/ClientSettingsForm.tsx | 1 + .../clientSettingsValues.test.ts | 110 ++++++++++++++++++ .../clientSettingsValues.ts | 20 +++- core/client/config-parse.ts | 61 ++++++---- 5 files changed, 230 insertions(+), 27 deletions(-) diff --git a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx index cfd78bdf6..caf3e8841 100644 --- a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx @@ -2,6 +2,10 @@ import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; import { ClientSettingsForm } from "./ClientSettingsForm"; +import { + CIMD_METADATA_URL_HTTPS_ERROR, + CIMD_METADATA_URL_INVALID_ERROR, +} from "@inspector/core/client/config-parse.js"; import { EMPTY_CLIENT_SETTINGS, ISSUER_URL_ERROR, @@ -99,6 +103,67 @@ describe("ClientSettingsForm EMA IdP session", () => { expect(screen.queryByText(ISSUER_URL_ERROR)).not.toBeInTheDocument(); }); + it("shows an inline error for an invalid CIMD metadata URL", () => { + renderWithMantine( + , + ); + + expect( + screen.getByText(CIMD_METADATA_URL_INVALID_ERROR), + ).toBeInTheDocument(); + }); + + it("shows an inline error for a non-HTTPS CIMD metadata URL", () => { + renderWithMantine( + , + ); + + expect(screen.getByText(CIMD_METADATA_URL_HTTPS_ERROR)).toBeInTheDocument(); + }); + + it("shows no CIMD URL error for a valid URL", () => { + renderWithMantine( + , + ); + + expect( + screen.queryByText(CIMD_METADATA_URL_INVALID_ERROR), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(CIMD_METADATA_URL_HTTPS_ERROR), + ).not.toBeInTheDocument(); + }); + it("shows expired state with sign out", () => { renderWithMantine( patch({ clientMetadataUrl: e.currentTarget.value }) } + error={errors.clientMetadataUrl} rightSectionPointerEvents="auto" rightSection={ settings.clientMetadataUrl ? ( diff --git a/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.test.ts b/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.test.ts index 615c7517f..ff4c8a9a0 100644 --- a/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.test.ts +++ b/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.test.ts @@ -1,4 +1,9 @@ import { describe, it, expect } from "vitest"; +import { + CIMD_METADATA_URL_HTTPS_ERROR, + CIMD_METADATA_URL_INVALID_ERROR, + CIMD_METADATA_URL_PATH_ERROR, +} from "@inspector/core/client/config-parse.js"; import { canPersistClientSettingsDraft, clientConfigToFormValues, @@ -347,4 +352,109 @@ describe("clientSettingsValues", () => { }), ).toEqual({}); }); + + it("canPersistClientSettingsDraft blocks an invalid CIMD URL", () => { + expect( + canPersistClientSettingsDraft({ + emaEnabled: false, + issuer: "", + clientId: "", + clientSecret: "", + cimdEnabled: true, + clientMetadataUrl: "not-a-url", + }), + ).toBe(false); + expect( + canPersistClientSettingsDraft({ + emaEnabled: false, + issuer: "", + clientId: "", + clientSecret: "", + cimdEnabled: true, + clientMetadataUrl: "http://example.com/cimd.json", + }), + ).toBe(false); + expect( + canPersistClientSettingsDraft({ + emaEnabled: false, + issuer: "", + clientId: "", + clientSecret: "", + cimdEnabled: true, + clientMetadataUrl: "https://example.com/", + }), + ).toBe(false); + }); + + it("validateClientSettings flags an invalid CIMD URL", () => { + expect( + validateClientSettings({ + emaEnabled: false, + issuer: "", + clientId: "", + clientSecret: "", + cimdEnabled: true, + clientMetadataUrl: "not-a-url", + }), + ).toEqual({ clientMetadataUrl: CIMD_METADATA_URL_INVALID_ERROR }); + expect( + validateClientSettings({ + emaEnabled: false, + issuer: "", + clientId: "", + clientSecret: "", + cimdEnabled: true, + clientMetadataUrl: "http://example.com/cimd.json", + }), + ).toEqual({ clientMetadataUrl: CIMD_METADATA_URL_HTTPS_ERROR }); + expect( + validateClientSettings({ + emaEnabled: false, + issuer: "", + clientId: "", + clientSecret: "", + cimdEnabled: true, + clientMetadataUrl: "https://example.com/", + }), + ).toEqual({ clientMetadataUrl: CIMD_METADATA_URL_PATH_ERROR }); + }); + + it("validateClientSettings passes a valid CIMD URL", () => { + expect( + validateClientSettings({ + emaEnabled: false, + issuer: "", + clientId: "", + clientSecret: "", + cimdEnabled: true, + clientMetadataUrl: "https://example.com/cimd.json", + }), + ).toEqual({}); + }); + + it("validateClientSettings ignores an empty CIMD URL", () => { + expect( + validateClientSettings({ + emaEnabled: false, + issuer: "", + clientId: "", + clientSecret: "", + cimdEnabled: true, + clientMetadataUrl: "", + }), + ).toEqual({}); + }); + + it("validateClientSettings ignores CIMD URL when CIMD disabled", () => { + expect( + validateClientSettings({ + emaEnabled: false, + issuer: "", + clientId: "", + clientSecret: "", + cimdEnabled: false, + clientMetadataUrl: "not-a-url", + }), + ).toEqual({}); + }); }); diff --git a/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.ts b/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.ts index 6015592af..ff4684797 100644 --- a/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.ts +++ b/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.ts @@ -1,5 +1,8 @@ import type { ClientConfig } from "@inspector/core/client/types.js"; -import { isAbsoluteHttpUrl } from "@inspector/core/client/config-parse.js"; +import { + getCimdClientMetadataUrlError, + isAbsoluteHttpUrl, +} from "@inspector/core/client/config-parse.js"; /** Field-level error message for an issuer that is not an http(s) URL. */ export const ISSUER_URL_ERROR = @@ -8,6 +11,7 @@ export const ISSUER_URL_ERROR = /** Field-level validation errors for the client settings form. */ export interface ClientSettingsErrors { issuer?: string; + clientMetadataUrl?: string; } /** @@ -26,6 +30,12 @@ export function validateClientSettings( ) { errors.issuer = ISSUER_URL_ERROR; } + if (values.cimdEnabled && values.clientMetadataUrl.trim() !== "") { + const cimdError = getCimdClientMetadataUrlError(values.clientMetadataUrl); + if (cimdError) { + errors.clientMetadataUrl = cimdError; + } + } return errors; } @@ -105,13 +115,11 @@ export function canPersistClientSettingsDraft( if (values.emaEnabled) { if (values.issuer.trim() === "" || values.clientId.trim() === "") return false; - // Defer to validateClientSettings so the persist gate and the inline field - // errors can never drift: an invalid issuer is never sent to the backend, - // and the field error guides the user instead of a raw validation toast. - if (Object.keys(validateClientSettings(values)).length > 0) return false; } if (values.cimdEnabled) { if (!values.clientMetadataUrl.trim()) return false; } - return true; + // Defer to validateClientSettings so the persist gate and inline field errors + // can never drift — invalid values are never sent to the backend. + return Object.keys(validateClientSettings(values)).length === 0; } diff --git a/core/client/config-parse.ts b/core/client/config-parse.ts index a60415473..451900e7f 100644 --- a/core/client/config-parse.ts +++ b/core/client/config-parse.ts @@ -27,6 +27,43 @@ const HttpUrlStringSchema = z.string().min(1).superRefine((val, ctx) => { } }); +/** Field-level error when a CIMD metadata URL is not a parseable http(s) URL. */ +export const CIMD_METADATA_URL_INVALID_ERROR = + "Must be a valid URL, like https://example.com/oauth/client.json"; + +/** Field-level error when a CIMD metadata URL is not HTTPS. */ +export const CIMD_METADATA_URL_HTTPS_ERROR = + "CIMD client metadata URL must use HTTPS"; + +/** Field-level error when a CIMD metadata URL has no path segment. */ +export const CIMD_METADATA_URL_PATH_ERROR = + "Must include a path (not the site root), like https://example.com/oauth/client.json"; + +/** + * Inline / form validation for a non-empty CIMD client metadata URL. + * Returns undefined when the value is valid; empty strings are not flagged here + * (required-field gating lives in {@link canPersistClientSettingsDraft}). + */ +export function getCimdClientMetadataUrlError(value: string): string | undefined { + const trimmed = value.trim(); + if (trimmed === "") return undefined; + if (!isAbsoluteHttpUrl(trimmed)) { + return CIMD_METADATA_URL_INVALID_ERROR; + } + try { + const url = new URL(trimmed); + if (url.protocol !== "https:") { + return CIMD_METADATA_URL_HTTPS_ERROR; + } + if (url.pathname === "/" || url.pathname === "") { + return CIMD_METADATA_URL_PATH_ERROR; + } + } catch { + return CIMD_METADATA_URL_INVALID_ERROR; + } + return undefined; +} + function refineCimdMetadataUrl( val: string, ctx: z.RefinementCtx, @@ -42,30 +79,12 @@ function refineCimdMetadataUrl( } return; } - if (!isAbsoluteHttpUrl(trimmed)) { + const error = getCimdClientMetadataUrlError(trimmed); + if (error) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `Invalid URL: "${trimmed}" — must be an http(s) URL (e.g. https://idp.example.com)`, + message: error, }); - return; - } - try { - const url = new URL(trimmed); - if (url.protocol !== "https:") { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "CIMD client metadata URL must use HTTPS", - }); - } - if (url.pathname === "/" || url.pathname === "") { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: - "CIMD client metadata URL must include a path (not the site root)", - }); - } - } catch { - // isAbsoluteHttpUrl already reported invalid URL } } From 88a8e5c4efece9f24451fb1ca7b4d0c7728bb78a Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Mon, 29 Jun 2026 10:53:44 -0700 Subject: [PATCH 7/8] Fixed TUI test failures from merge --- clients/tui/__tests__/App.test.tsx | 338 ++++------------ clients/tui/__tests__/AuthTab.test.tsx | 512 ++++--------------------- 2 files changed, 139 insertions(+), 711 deletions(-) diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index 15f867603..ddefbecc9 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -52,11 +52,9 @@ const h = vi.hoisted(() => { authenticate: vi.fn( async (): Promise => "https://auth.example/start", ), - beginGuidedAuth: vi.fn(async (): Promise => {}), - runGuidedAuth: vi.fn(async (): Promise => undefined), - proceedOAuthStep: vi.fn(async (): Promise => {}), clearOAuthTokens: vi.fn(), completeOAuthFlow: vi.fn(async (): Promise => {}), + getOAuthState: vi.fn(async () => undefined), }; // Captured options from the most recent callbackServer.start(), so a test can // drive the onCallback / onError handlers the OAuth flows register. @@ -92,14 +90,11 @@ const h = vi.hoisted(() => { | "sse" | "streamable-http", ); - getOAuthFlowState = vi.fn(() => ctrl.oauthFlowState); authenticate = (...a: unknown[]) => clientSpies.authenticate(...a); - beginGuidedAuth = (...a: unknown[]) => clientSpies.beginGuidedAuth(...a); - runGuidedAuth = (...a: unknown[]) => clientSpies.runGuidedAuth(...a); - proceedOAuthStep = (...a: unknown[]) => clientSpies.proceedOAuthStep(...a); clearOAuthTokens = (...a: unknown[]) => clientSpies.clearOAuthTokens(...a); completeOAuthFlow = (...a: unknown[]) => clientSpies.completeOAuthFlow(...a); + getOAuthState = (...a: unknown[]) => clientSpies.getOAuthState(...a); readResource = vi.fn(async () => ({ result: { contents: [{ uri: "file://x", text: "hello" }] }, })); @@ -179,12 +174,17 @@ vi.mock("@inspector/core/react/useFetchRequestLog.js", () => ({ vi.mock("@inspector/core/react/useStderrLog.js", () => ({ useStderrLog: h.useStderrLog, })); -vi.mock("@inspector/core/auth/index.js", () => ({ - CallbackNavigation: class {}, - MutableRedirectUrlProvider: class { - redirectUrl = ""; - }, -})); +vi.mock("@inspector/core/auth/index.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + CallbackNavigation: class {}, + MutableRedirectUrlProvider: class { + redirectUrl = ""; + }, + }; +}); vi.mock("@inspector/core/auth/node/index.js", () => ({ createOAuthCallbackServer: h.createOAuthCallbackServer, NodeOAuthStorage: class {}, @@ -198,6 +198,7 @@ import type { TuiServer } from "../src/tui-servers.js"; const tick = () => new Promise((r) => setTimeout(r, 25)); const callbackUrlConfig = { hostname: "127.0.0.1", port: 0, pathname: "/cb" }; +const emptyClientConfig = {}; function stdioServer(): Record { return { @@ -234,7 +235,7 @@ function oneHttp(): Record { } // 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. +// a stdio server � drives per-server tab gating + the tab-switch-away effects. function httpThenStdio(): Record { return { web: { config: { type: "streamable-http", url: "http://x" } } as never, @@ -351,13 +352,17 @@ const bareRequest = { }; const stderrLog = { timestamp: new Date(0), message: "log line" }; -// Track rendered instances so each is unmounted after its test — concurrent +// Track rendered instances so each is unmounted after its test � concurrent // mounted ink apps share raw-mode stdin handling and interfere with useInput. const mounted: RenderResult[] = []; function renderApp(servers: Record) { const r = render( - , + , ); mounted.push(r); return r; @@ -390,7 +395,7 @@ const ENTER = "\r"; /** * Write each key in order. ink re-subscribes the active component's useInput on * re-render, so a key that changes focus/tab must let that re-render flush - * before the next key — otherwise the next key is routed to the old handler. + * before the next key � otherwise the next key is routed to the old handler. * Two ticks per key keeps multi-step navigation deterministic under the heavier * v8 coverage instrumentation (where a single tick can race the render). */ @@ -423,7 +428,7 @@ async function waitForFrame(r: RenderResult, substr: string, tries = 25) { await waitUntil(() => (r.lastFrame() ?? "").includes(substr), tries); } -/** Poll until the frame contains `substr`, then assert it — stable under load. */ +/** Poll until the frame contains `substr`, then assert it � stable under load. */ async function expectFrame(r: RenderResult, substr: string) { await waitForFrame(r, substr); expect(r.lastFrame() ?? "").toContain(substr); @@ -456,15 +461,11 @@ beforeEach(() => { h.callbackStop.mockClear(); h.clientSpies.authenticate.mockReset(); h.clientSpies.authenticate.mockResolvedValue("https://auth.example/start"); - h.clientSpies.beginGuidedAuth.mockReset(); - h.clientSpies.beginGuidedAuth.mockResolvedValue(undefined); - h.clientSpies.runGuidedAuth.mockReset(); - h.clientSpies.runGuidedAuth.mockResolvedValue(undefined); - h.clientSpies.proceedOAuthStep.mockReset(); - h.clientSpies.proceedOAuthStep.mockResolvedValue(undefined); h.clientSpies.clearOAuthTokens.mockReset(); h.clientSpies.completeOAuthFlow.mockReset(); h.clientSpies.completeOAuthFlow.mockResolvedValue(undefined); + h.clientSpies.getOAuthState.mockReset(); + h.clientSpies.getOAuthState.mockResolvedValue(undefined); }); afterEach(() => { @@ -522,14 +523,14 @@ describe("App (foundation)", () => { // shift+tab is delivered as ESC [ Z stdin.write(""); await tick(); - // no assertion on hidden focus state — exercising the branches + // no assertion on hidden focus state � exercising the branches }); - it("shows the Auth tab and G/Q/S accelerators for an OAuth-capable server", async () => { + it("shows the Auth tab via the accelerator for an OAuth-capable server", async () => { h.ctrl.serverType = "streamable-http"; const r = await mount(httpServer()); - await press(r, ["g"]); // guided auth accelerator -> Auth tab - await expectFrame(r, "Auth"); + await press(r, ["a"]); + await expectFrame(r, "OAuth"); }); it("renders connected status with capabilities", async () => { @@ -554,11 +555,12 @@ describe("App (status, layout, modals)", () => { await expectFrame(r, "error"); }); - it("shows the 401 auth hint for an http server with a 401 response", async () => { + it("shows error status when an http server has a 401 response logged", async () => { h.ctrl.status = "error"; h.ctrl.fetchRequests = [{ ...errorRequest, responseStatus: 401 }]; const r = await mount(oneHttp()); - await expectFrame(r, "401 Unauthorized"); + await expectFrame(r, "error"); + await expectFrame(r, "HTTP Requests (1)"); }); it("updates dimensions when the terminal resizes", async () => { @@ -803,10 +805,10 @@ describe("App (input handling, focus, effects)", () => { it("switches away from the Auth tab when selecting a non-OAuth server", async () => { const r = await mount(httpThenStdio()); - await press(r, ["a"]); // Auth tab (http is OAuth-capable) - await press(r, [STAB]); // tabs -> serverList - await press(r, [DOWN]); // select the stdio server -> effect leaves Auth + await press(r, ["a", STAB, STAB, DOWN]); + await waitUntil(() => (r.lastFrame() ?? "").includes("Type: stdio")); await expectFrame(r, "Server Configuration"); + expect(r.lastFrame() ?? "").not.toContain("No OAuth information yet"); }); it("switches away from the Logging tab when selecting a non-stdio server", async () => { @@ -834,6 +836,7 @@ describe("App (input handling, focus, effects)", () => { const r = render( { }); describe("App (OAuth flows)", () => { - it("runs quick auth to success when no auth URL is returned", async () => { + const unauthorized = Object.assign(new Error("request failed (401)"), { + status: 401, + }); + + it("runs OAuth on connect when the server returns 401", async () => { + h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined); h.clientSpies.authenticate.mockResolvedValue(undefined); const r = await mount(oneHttp()); - await press(r, ["q", ENTER]); - await tick(); - expect(h.callbackStart).toHaveBeenCalled(); + await press(r, ["c"]); + await waitUntil(() => h.callbackStart.mock.calls.length > 0); + expect(h.disconnect).toHaveBeenCalled(); expect(h.clientSpies.authenticate).toHaveBeenCalled(); - await expectFrame(r, "OAuth complete"); + expect(h.connect).toHaveBeenCalledTimes(2); }); - it("completes quick auth when the OAuth callback fires", async () => { + it("completes OAuth when the callback fires during a 401 connect", async () => { + h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined); const r = await mount(oneHttp()); - await press(r, ["q", ENTER]); + await press(r, ["c"]); await waitUntil(() => h.cb.opts !== null); - expect(h.cb.opts).not.toBeNull(); await h.cb.opts!.onCallback({ code: "abc" }); await tick(); expect(h.clientSpies.completeOAuthFlow).toHaveBeenCalledWith("abc"); - await expectFrame(r, "OAuth complete"); }); - it("reports an error when the OAuth callback errors", async () => { + it("reports an OAuth callback error during a 401 connect", async () => { + h.connect.mockRejectedValueOnce(unauthorized); const r = await mount(oneHttp()); - await press(r, ["q", ENTER]); + await press(r, ["c"]); await waitUntil(() => h.cb.opts !== null); - expect(h.cb.opts).not.toBeNull(); h.cb.opts!.onError({ error_description: "denied" }); - await tick(); await expectFrame(r, "denied"); }); - it("runs guided auth to completion and opens the auth URL", async () => { - h.clientSpies.runGuidedAuth.mockResolvedValue("http://auth/x"); - const r = await mount(oneHttp()); - await press(r, ["g", ENTER]); - await tick(); - expect(h.clientSpies.runGuidedAuth).toHaveBeenCalled(); - expect(h.openUrl).toHaveBeenCalledWith("http://auth/x"); - }); - - it("reports an error when guided-to-completion fails", async () => { - h.clientSpies.runGuidedAuth.mockRejectedValue(new Error("nope")); - const r = await mount(oneHttp()); - await press(r, ["g", ENTER]); - await tick(); - await expectFrame(r, "nope"); - }); - - it("starts guided auth then advances a step, opening the auth URL", async () => { - const r = await mount(oneHttp()); - await press(r, ["g", " "]); // Space starts the guided flow - await tick(); - h.ctrl.oauthFlowState = { - oauthStep: "authorization_code", - authorizationUrl: "http://auth/code", - }; - await press(r, [" "]); // Space again advances one step - await tick(); - expect(h.clientSpies.beginGuidedAuth).toHaveBeenCalled(); - expect(h.clientSpies.proceedOAuthStep).toHaveBeenCalled(); - expect(h.openUrl).toHaveBeenCalledWith("http://auth/code"); - }); - - it("advances guided auth without opening a URL", async () => { - h.ctrl.oauthFlowState = { oauthStep: "token_request" }; - const r = await mount(oneHttp()); - await press(r, ["g", " "]); - await tick(); - await press(r, [" "]); - await tick(); - expect(h.clientSpies.proceedOAuthStep).toHaveBeenCalled(); - expect(h.openUrl).not.toHaveBeenCalled(); - }); - - it("reports an error when guided start fails", async () => { - h.clientSpies.beginGuidedAuth.mockRejectedValue(new Error("startfail")); - const r = await mount(oneHttp()); - await press(r, ["g", " "]); - await tick(); - await expectFrame(r, "startfail"); - }); - - it("reports an error when advancing guided auth fails", async () => { - h.clientSpies.proceedOAuthStep.mockRejectedValue(new Error("advfail")); - const r = await mount(oneHttp()); - await press(r, ["g", " "]); - await tick(); - await press(r, [" "]); - await tick(); - await expectFrame(r, "advfail"); - }); - - it("clears OAuth state via the Clear action", async () => { + it("clears OAuth state from the Auth tab", async () => { const r = await mount(oneHttp()); - await press(r, ["s", ENTER]); + await press(r, ["a", "s"]); await tick(); expect(h.clientSpies.clearOAuthTokens).toHaveBeenCalled(); - await expectFrame(r, "OAuth state cleared"); }); - it("reports an error when quick callback completion fails", async () => { + it("reports callback completion failure during a 401 connect", async () => { + h.connect.mockRejectedValueOnce(unauthorized); h.clientSpies.completeOAuthFlow.mockRejectedValue(new Error("qcfail")); const r = await mount(oneHttp()); - await press(r, ["q", ENTER]); + await press(r, ["c"]); await waitUntil(() => h.cb.opts !== null); - expect(h.cb.opts).not.toBeNull(); await h.cb.opts!.onCallback({ code: "x" }); - await tick(); await expectFrame(r, "qcfail"); }); - it("stops a prior callback server before starting quick auth again", async () => { - h.clientSpies.authenticate.mockResolvedValue(undefined); - const r = await mount(oneHttp()); - await press(r, ["q", ENTER]); // first run completes, leaving the server set - await tick(); - await press(r, ["q", ENTER]); // second run stops the prior server - await tick(); - expect(h.callbackStop).toHaveBeenCalled(); - }); - - it("completes guided auth when the callback fires", async () => { - const r = await mount(oneHttp()); - await press(r, ["g", " "]); // Space starts guided + registers callback server - await waitUntil(() => h.cb.opts !== null); - expect(h.cb.opts).not.toBeNull(); - await h.cb.opts!.onCallback({ code: "gc" }); - await tick(); - expect(h.clientSpies.completeOAuthFlow).toHaveBeenCalledWith("gc"); - await expectFrame(r, "OAuth complete"); - }); - - it("reports an error when the guided callback errors", async () => { - const r = await mount(oneHttp()); - await press(r, ["g", " "]); - await waitUntil(() => h.cb.opts !== null); - expect(h.cb.opts).not.toBeNull(); - h.cb.opts!.onError({ error: "guided-bad" }); - await tick(); - await expectFrame(r, "guided-bad"); - }); - - it("reports an error when guided callback completion fails", async () => { - h.clientSpies.completeOAuthFlow.mockRejectedValue(new Error("gfail")); - const r = await mount(oneHttp()); - await press(r, ["g", " "]); - await waitUntil(() => h.cb.opts !== null); - await h.cb.opts!.onCallback({ code: "x" }); - await tick(); - await expectFrame(r, "gfail"); - }); - - it("completes run-to-completion auth when the callback fires", async () => { - const r = await mount(oneHttp()); - await press(r, ["g", ENTER]); // runs to completion -> ensureCallbackServer - await waitUntil(() => h.cb.opts !== null); - expect(h.cb.opts).not.toBeNull(); - await h.cb.opts!.onCallback({ code: "rc" }); - await tick(); - expect(h.clientSpies.completeOAuthFlow).toHaveBeenCalledWith("rc"); - await expectFrame(r, "OAuth complete"); - }); - - it("reports an error when the run-to-completion callback errors", async () => { - const r = await mount(oneHttp()); - await press(r, ["g", ENTER]); - await waitUntil(() => h.cb.opts !== null); - expect(h.cb.opts).not.toBeNull(); - h.cb.opts!.onError({ error: "rc-bad" }); - await tick(); - await expectFrame(r, "rc-bad"); - }); - - it("stringifies a non-Error quick auth rejection", async () => { + it("stringifies a non-Error authenticate rejection on 401 connect", async () => { + h.connect.mockRejectedValueOnce(unauthorized); h.clientSpies.authenticate.mockRejectedValue("plainstring"); const r = await mount(oneHttp()); - await press(r, ["q", ENTER]); - await tick(); + await press(r, ["c"]); await expectFrame(r, "plainstring"); }); it("uses the default OAuth error label when the callback error is empty", async () => { + h.connect.mockRejectedValueOnce(unauthorized); const r = await mount(oneHttp()); - await press(r, ["q", ENTER]); - await waitUntil(() => h.cb.opts !== null); - expect(h.cb.opts).not.toBeNull(); - h.cb.opts!.onError({}); - await tick(); - await expectFrame(r, "OAuth error"); - }); - - it("stringifies a non-Error guided callback completion failure", async () => { - h.clientSpies.completeOAuthFlow.mockRejectedValue("guided-string"); - const r = await mount(oneHttp()); - await press(r, ["g", " "]); - await waitUntil(() => h.cb.opts !== null); - await h.cb.opts!.onCallback({ code: "x" }); - await tick(); - await expectFrame(r, "guided-string"); - }); - - it("falls back to params.error when the quick callback has no description", async () => { - const r = await mount(oneHttp()); - await press(r, ["q", ENTER]); - await waitUntil(() => h.cb.opts !== null); - h.cb.opts!.onError({ error: "quick-error-code" }); - await tick(); - await expectFrame(r, "quick-error-code"); - }); - - it("uses the default label when the guided callback error is empty", async () => { - const r = await mount(oneHttp()); - await press(r, ["g", " "]); + await press(r, ["c"]); await waitUntil(() => h.cb.opts !== null); h.cb.opts!.onError({}); - await tick(); await expectFrame(r, "OAuth error"); }); - it("uses the default label when the run-to-completion error is empty", async () => { + it("falls back to params.error when the callback has no description", async () => { + h.connect.mockRejectedValueOnce(unauthorized); const r = await mount(oneHttp()); - await press(r, ["g", ENTER]); + await press(r, ["c"]); await waitUntil(() => h.cb.opts !== null); - h.cb.opts!.onError({}); - await tick(); - await expectFrame(r, "OAuth error"); + h.cb.opts!.onError({ error: "oauth-error-code" }); + await expectFrame(r, "oauth-error-code"); }); - it("wraps a non-Error quick callback rejection into an Error", async () => { - h.clientSpies.completeOAuthFlow.mockRejectedValue("quick-cb-string"); + it("wraps a non-Error callback completion failure into an Error", async () => { + h.connect.mockRejectedValueOnce(unauthorized); + h.clientSpies.completeOAuthFlow.mockRejectedValue("cb-string"); const r = await mount(oneHttp()); - await press(r, ["q", ENTER]); + await press(r, ["c"]); await waitUntil(() => h.cb.opts !== null); - expect(h.cb.opts).not.toBeNull(); await h.cb.opts!.onCallback({ code: "x" }); - await tick(); - // quick auth's onCallback wraps the throw via new Error(String(err)); the - // flow rejects and the catch surfaces the stringified message. - await expectFrame(r, "quick-cb-string"); - }); - - it("stringifies a non-Error guided-start rejection", async () => { - h.clientSpies.beginGuidedAuth.mockRejectedValue("startfail-string"); - const r = await mount(oneHttp()); - await press(r, ["g", " "]); - await tick(); - await expectFrame(r, "startfail-string"); - }); - - it("stringifies a non-Error guided-advance rejection", async () => { - h.clientSpies.proceedOAuthStep.mockRejectedValue("advfail-string"); - const r = await mount(oneHttp()); - await press(r, ["g", " "]); - await tick(); - await press(r, [" "]); - await tick(); - await expectFrame(r, "advfail-string"); - }); - - it("stringifies a non-Error run-to-completion rejection", async () => { - h.clientSpies.runGuidedAuth.mockRejectedValue("runfail-string"); - const r = await mount(oneHttp()); - await press(r, ["g", ENTER]); - await tick(); - await expectFrame(r, "runfail-string"); - }); - - it("ignores a re-entrant quick-auth trigger while one is in progress", async () => { - // Hold the first flow open by leaving authenticate pending, so the second - // 'q' hits the `if (oauthInProgressRef.current) return` guard. - let release!: () => void; - h.clientSpies.authenticate.mockImplementation( - () => new Promise((res) => (release = () => res(undefined))), - ); - const r = await mount(oneHttp()); - await press(r, ["q", ENTER]); - await tick(); - await press(r, ["q", ENTER]); // re-entrant: guarded out - await tick(); - expect(h.callbackStart).toHaveBeenCalledTimes(1); - release(); - await tick(); + await expectFrame(r, "cb-string"); }); }); diff --git a/clients/tui/__tests__/AuthTab.test.tsx b/clients/tui/__tests__/AuthTab.test.tsx index b3d42a887..431391d2f 100644 --- a/clients/tui/__tests__/AuthTab.test.tsx +++ b/clients/tui/__tests__/AuthTab.test.tsx @@ -1,23 +1,14 @@ import React from "react"; import { describe, it, expect, vi } from "vitest"; import { render } from "ink-testing-library"; -import { - EMPTY_OAUTH_FLOW_STATE, - type OAuthFlowState, -} from "@inspector/core/auth/index.js"; +import type { OAuthConnectionState } from "@inspector/core/auth/types.js"; import type { InspectorClient } from "@inspector/core/mcp/index.js"; -// MUST mock ink-scroll-view: the real ScrollView renders a placeholder minimap -// in the non-TTY test env and never mounts its children. This passthrough -// renders children directly and stubs scrollBy/scrollTo/getViewportHeight. vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js")); import { AuthTab } from "../src/components/AuthTab.js"; -// Ink processes stdin keypresses asynchronously — await this after stdin.write. const tick = async () => { - // Flush several macrotask cycles so an effect -> setState -> re-render chain - // settles before assertions, even on slow/loaded CI (a single tick can race). for (let i = 0; i < 8; i++) await new Promise((resolve) => setTimeout(resolve, 4)); }; @@ -25,16 +16,32 @@ const tick = async () => { const ESC = String.fromCharCode(27); const UP = `${ESC}[A`; const DOWN = `${ESC}[B`; -const LEFT = `${ESC}[D`; -const RIGHT = `${ESC}[C`; const PAGE_UP = `${ESC}[5~`; const PAGE_DOWN = `${ESC}[6~`; -/** Minimal fake InspectorClient that only implements the surface AuthTab uses. */ -function makeClient(state?: OAuthFlowState) { +const sampleOAuthState: OAuthConnectionState = { + authorized: true, + protocol: "standard", + serverUrl: "http://x/mcp", + client: { + clientId: "abc123", + registrationKind: "dcr", + }, + tokens: { + access_token: "tok-abcdefghijklmnopqrstuvwxyz", + token_type: "Bearer", + }, + authorizationServerMetadata: { + authorization_endpoint: "https://auth.example.com/authorize", + }, + configuredScope: "read write", +}; + +function makeClient(oauthState?: OAuthConnectionState) { const listeners = new Map void>>(); + const getOAuthState = vi.fn(async () => oauthState); const client = { - getOAuthFlowState: () => state, + getOAuthState, addEventListener: (event: string, fn: () => void) => { if (!listeners.has(event)) listeners.set(event, new Set()); listeners.get(event)!.add(fn); @@ -48,51 +55,20 @@ function makeClient(state?: OAuthFlowState) { }; return { client: client as unknown as InspectorClient, + getOAuthState, fire, listeners, }; } -const flow = (over: Partial): OAuthFlowState => ({ - ...EMPTY_OAUTH_FLOW_STATE, - ...over, -}); - -// A state at "complete" with every detail field populated so getStepDetails -// returns a non-null value for every step (all rendered as completed). -const completeState = flow({ - execution: "guided", - oauthStep: "complete", - resourceMetadata: { - resource: "https://api.example.com", - } as OAuthFlowState["resourceMetadata"], - oauthMetadata: { - issuer: "https://issuer.example.com", - } as OAuthFlowState["oauthMetadata"], - oauthClientInfo: { - client_id: "abc123", - } as OAuthFlowState["oauthClientInfo"], - authorizationUrl: new URL("https://auth.example.com/authorize?x=1"), - authorizationCode: "code-abcdef1234567890", - oauthTokens: { - access_token: "tok-abcdefghijklmnopqrstuvwxyz", - token_type: "Bearer", - }, -}); - const baseProps = { serverName: "srv" as string | null, serverConfig: null, width: 120, height: 30, - isOAuthCapable: true, - selectedAction: "guided" as "guided" | "quick" | "clear", - onSelectedActionChange: vi.fn(), - onQuickAuth: vi.fn(async () => {}), - onGuidedStart: vi.fn(async () => {}), - onGuidedAdvance: vi.fn(async () => {}), - onRunGuidedToCompletion: vi.fn(async () => {}), + oauthRevision: 0, onClearOAuth: vi.fn(), + connectionStatus: "disconnected" as const, }; describe("AuthTab", () => { @@ -106,75 +82,16 @@ describe("AuthTab", () => { oauthMessage={null} />, ); - expect(lastFrame() ?? "").toContain("Select an OAuth-capable server"); - }); - - it("renders the placeholder when the server is not OAuth-capable", () => { - const { lastFrame } = render( - , - ); - expect(lastFrame() ?? "").toContain("Select an OAuth-capable server"); - }); - - it("renders the guided action bar, hint, and progress (unfocused)", async () => { - const { client } = makeClient(completeState); - // tall enough that no step detail is clipped by the fixed-height Box - const { lastFrame } = render( - , + expect(lastFrame() ?? "").toContain( + "Select a server to view authentication", ); - await tick(); - const frame = lastFrame() ?? ""; - expect(frame).toContain("Authentication"); - expect(frame).toContain("uided Auth"); - expect(frame).toContain("uick Auth"); - expect(frame).toContain("Clear OAuth"); - expect(frame).toContain("Press [Space] to advance one step"); - expect(frame).toContain("Press [Enter] to run guided auth to completion"); - expect(frame).toContain("Guided OAuth Flow Progress"); - // every step label, all completed (✓) - expect(frame).toContain("Metadata Discovery"); - expect(frame).toContain("Client Registration"); - expect(frame).toContain("Preparing Authorization"); - expect(frame).toContain("Request Authorization Code"); - expect(frame).toContain("Token Request"); - expect(frame).toContain("Authentication Complete"); - expect(frame).toContain("✓"); - // completed detail strings from getStepDetails - expect(frame).toContain("Resource:"); - expect(frame).toContain("OAuth:"); - expect(frame).toContain("Code received:"); - expect(frame).toContain("Exchanging code for tokens..."); - expect(frame).toContain("Tokens: access_token="); - // no focused footer - expect(frame).not.toContain("select, G/Q/S or Enter run"); }); - it("renders an in-progress step (cyan →) and not-started steps (○)", async () => { - const midState = flow({ - execution: "guided", - oauthStep: "client_registration", - oauthClientInfo: { - client_id: "mid-client", - } as OAuthFlowState["oauthClientInfo"], - }); - const { client } = makeClient(midState); + it("renders OAuth details from getOAuthState", async () => { + const { client } = makeClient(sampleOAuthState); const { lastFrame } = render( { ); await tick(); const frame = lastFrame() ?? ""; - expect(frame).toContain("(in progress)"); - expect(frame).toContain("→"); - expect(frame).toContain("○"); - // in-progress detail (client_registration → oauthClientInfo JSON) - expect(frame).toContain("mid-client"); + expect(frame).toContain("OAuth Details"); + expect(frame).toContain("Authorized"); + expect(frame).toContain("abc123"); + expect(frame).toContain("Dynamic (DCR)"); + expect(frame).toContain("read, write"); + expect(frame).toContain("tok-abcdefghijklmnopqrst"); }); - it("renders the 'authorization URL opened' block when awaiting an auth code", async () => { - const awaitingState = flow({ - execution: "guided", - oauthStep: "authorization_code", - authorizationUrl: new URL("https://auth.example.com/go?code=here"), - }); - const { client } = makeClient(awaitingState); + it("shows the not-yet-authorized hint when getOAuthState is empty", async () => { + const { client } = makeClient(undefined); const { lastFrame } = render( { ); await tick(); const frame = lastFrame() ?? ""; - expect(frame).toContain("Authorization URL opened in browser"); - expect(frame).toContain("auth.example.com/go"); - expect(frame).toContain("Complete authorization in the browser"); + expect(frame).toContain("No OAuth information yet"); + expect(frame).toContain("Connect (C) to authorize"); }); - it("covers metadata details when only the resource metadata is present", async () => { - const resourceOnly = flow({ - oauthStep: "complete", - resourceMetadata: { - resource: "https://only-resource.example.com", - } as OAuthFlowState["resourceMetadata"], - }); - const { client } = makeClient(resourceOnly); - const { lastFrame } = render( + it("renders authenticating and error status messages", async () => { + const { client } = makeClient(undefined); + const { lastFrame, rerender } = render( , - ); - await tick(); - const frame = lastFrame() ?? ""; - expect(frame).toContain("Resource:"); - expect(frame).not.toContain("OAuth: {"); - }); - - it("covers metadata details when only the oauth metadata is present", async () => { - const oauthOnly = flow({ - oauthStep: "complete", - oauthMetadata: { - issuer: "https://only-issuer.example.com", - } as OAuthFlowState["oauthMetadata"], - }); - const { client } = makeClient(oauthOnly); - const { lastFrame } = render( - , - ); - await tick(); - expect(lastFrame() ?? "").toContain("OAuth:"); - }); - - it("renders guided progress with no details when the flow state is empty", () => { - const { client } = makeClient(flow({})); - const { lastFrame } = render( - , - ); - const frame = lastFrame() ?? ""; - expect(frame).toContain("Guided OAuth Flow Progress"); - expect(frame).not.toContain("Resource:"); - }); - - it("renders guided progress with no inspector client (no flow state)", () => { - const { lastFrame } = render( - , - ); - const frame = lastFrame() ?? ""; - expect(frame).toContain("Guided OAuth Flow Progress"); - expect(frame).toContain("○"); - }); - - it("renders the quick hint and 'Authenticating...' status", () => { - const { client } = makeClient(flow({ execution: "quick" })); - const { lastFrame } = render( - , ); - const frame = lastFrame() ?? ""; - expect(frame).toContain("Press [Enter] to run quick auth"); - expect(frame).toContain("Authenticating..."); - }); + expect(lastFrame() ?? "").toContain("Authenticating"); - it("renders the quick error message", () => { - const { client } = makeClient(flow({ execution: "quick" })); - const { lastFrame } = render( + rerender( { expect(lastFrame() ?? "").toContain("Something went wrong"); }); - it("renders quick auth results with client info and tokens", async () => { - const quickSuccess = flow({ - execution: "quick", - oauthClientInfo: { - client_id: "quick-client", - } as OAuthFlowState["oauthClientInfo"], - oauthTokens: { - access_token: "quick-token-abcdefghijklmnop", - token_type: "Bearer", - }, - }); - const { client } = makeClient(quickSuccess); - const { lastFrame } = render( - , - ); - await tick(); - const frame = lastFrame() ?? ""; - expect(frame).toContain("Quick Auth Results"); - expect(frame).toContain("quick-client"); - expect(frame).toContain("Access Token:"); - expect(frame).toContain("quick-token-abcdefgh"); - }); - - it("renders the clear hint and the confirmation after pressing Enter", async () => { + it("clears OAuth state on S and shows confirmation", async () => { const onClearOAuth = vi.fn(); - const { client } = makeClient(flow({})); + const { client } = makeClient(sampleOAuthState); const { lastFrame, stdin } = render( , ); - expect(lastFrame() ?? "").toContain("Press [Enter] to clear OAuth state"); - // a leading no-op key absorbs any dropped first keypress - stdin.write("x"); - await tick(); - stdin.write("\r"); - await tick(); + stdin.write("s"); await tick(); expect(onClearOAuth).toHaveBeenCalled(); - expect(lastFrame() ?? "").toContain("OAuth state cleared."); + expect(lastFrame() ?? "").toContain("OAuth state cleared"); }); - it("shows the focused footer and header highlight when focused", () => { - const { client } = makeClient(completeState); + it("shows clear+disconnect label when connected", async () => { + const { client } = makeClient(sampleOAuthState); const { lastFrame } = render( , - ); - expect(lastFrame() ?? "").toContain("select, G/Q/S or Enter run"); - }); - - it("selects actions via G/Q/S keys when focused", async () => { - const onSelectedActionChange = vi.fn(); - const { client } = makeClient(flow({})); - const { stdin } = render( - , ); - stdin.write("g"); - await tick(); - stdin.write("q"); await tick(); - stdin.write("s"); - await tick(); - expect(onSelectedActionChange).toHaveBeenCalledWith("guided"); - expect(onSelectedActionChange).toHaveBeenCalledWith("quick"); - expect(onSelectedActionChange).toHaveBeenCalledWith("clear"); + expect(lastFrame() ?? "").toContain("clear+disconnect"); }); - it("cycles selection with left/right arrows from 'guided'", async () => { - const onSelectedActionChange = vi.fn(); - const { client } = makeClient(flow({})); - const { stdin } = render( - , - ); - stdin.write(LEFT); - await tick(); - stdin.write(RIGHT); - await tick(); - expect(onSelectedActionChange).toHaveBeenCalledWith("clear"); - expect(onSelectedActionChange).toHaveBeenCalledWith("quick"); - }); - - it("cycles selection with left/right arrows from 'quick'", async () => { - const onSelectedActionChange = vi.fn(); - const { client } = makeClient(flow({ execution: "quick" })); - const { stdin } = render( - , - ); - stdin.write(LEFT); - await tick(); - stdin.write(RIGHT); - await tick(); - expect(onSelectedActionChange).toHaveBeenCalledWith("guided"); - expect(onSelectedActionChange).toHaveBeenCalledWith("clear"); - }); - - it("cycles selection with left/right arrows from 'clear'", async () => { - const onSelectedActionChange = vi.fn(); - const { client } = makeClient(flow({})); - const { stdin } = render( + it("shows the focused footer when focused", async () => { + const { client } = makeClient(sampleOAuthState); + const { lastFrame } = render( , ); - stdin.write(LEFT); await tick(); - stdin.write(RIGHT); - await tick(); - expect(onSelectedActionChange).toHaveBeenCalledWith("quick"); - expect(onSelectedActionChange).toHaveBeenCalledWith("guided"); + expect(lastFrame() ?? "").toContain("S clear"); }); - it("scrolls with up/down/pageUp/pageDown when focused", async () => { - const { client } = makeClient(completeState); + it("scrolls with arrow and page keys when focused", async () => { + const { client } = makeClient(sampleOAuthState); const { lastFrame, stdin } = render( , ); + await tick(); stdin.write(UP); await tick(); stdin.write(DOWN); @@ -497,126 +217,28 @@ describe("AuthTab", () => { await tick(); stdin.write(PAGE_DOWN); await tick(); - // still rendered (scroll stubs are no-ops) - expect(lastFrame() ?? "").toContain("Guided OAuth Flow Progress"); - }); - - it("runs guided to completion on Enter when 'guided' is selected", async () => { - const onRunGuidedToCompletion = vi.fn(async () => {}); - const { client } = makeClient(flow({})); - const { stdin } = render( - , - ); - // a leading no-op key absorbs any dropped first keypress - stdin.write("x"); - await tick(); - stdin.write("\r"); - await tick(); - expect(onRunGuidedToCompletion).toHaveBeenCalled(); - }); - - it("runs quick auth on Enter when 'quick' is selected", async () => { - const onQuickAuth = vi.fn(async () => {}); - const { client } = makeClient(flow({ execution: "quick" })); - const { stdin } = render( - , - ); - stdin.write("x"); - await tick(); - stdin.write("\r"); - await tick(); - expect(onQuickAuth).toHaveBeenCalled(); - }); - - it("advances guided one step on Space (start then advance)", async () => { - const onGuidedStart = vi.fn(async () => {}); - const onGuidedAdvance = vi.fn(async () => {}); - // mid-flow state so needsAuthCode/isComplete are both false on advance - const { client } = makeClient( - flow({ execution: "guided", oauthStep: "client_registration" }), - ); - const { stdin } = render( - , - ); - // first space starts the flow - stdin.write(" "); - await tick(); - // second space advances one step - stdin.write(" "); - await tick(); - // third space (in case the first was dropped) keeps both reachable - stdin.write(" "); - await tick(); - expect(onGuidedStart).toHaveBeenCalled(); - expect(onGuidedAdvance).toHaveBeenCalled(); - }); - - it("does not act on input when not OAuth-capable but focused", async () => { - const onSelectedActionChange = vi.fn(); - const { stdin, lastFrame } = render( - , - ); - stdin.write("g"); - await tick(); - expect(onSelectedActionChange).not.toHaveBeenCalled(); - expect(lastFrame() ?? "").toContain("Select an OAuth-capable server"); + expect(lastFrame() ?? "").toContain("OAuth Details"); }); - it("subscribes to oauth events and refreshes when they fire", async () => { - const { client, fire, listeners } = makeClient(completeState); + it("refreshes OAuth state when oauthComplete fires", async () => { + const { client, getOAuthState, fire, listeners } = + makeClient(sampleOAuthState); const { unmount } = render( , ); await tick(); - expect(listeners.get("oauthStepChange")?.size).toBe(1); + expect(getOAuthState).toHaveBeenCalled(); expect(listeners.get("oauthComplete")?.size).toBe(1); - // firing the listeners runs the update() callback - fire("oauthStepChange"); + getOAuthState.mockClear(); fire("oauthComplete"); await tick(); - // unmount runs the cleanup (removeEventListener) + expect(getOAuthState).toHaveBeenCalled(); unmount(); - expect(listeners.get("oauthStepChange")?.size).toBe(0); expect(listeners.get("oauthComplete")?.size).toBe(0); }); }); From 6e057ab2e0b1dd0b74b2569843295d4119d552ee Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 29 Jun 2026 15:29:22 -0400 Subject: [PATCH 8/8] test: bring 5 OAuth files over the 90 per-file coverage gate The guided-auth-removal work added/touched several files that fell below the repo's >=90 per-file coverage gate (npm run coverage). CI doesn't run the gate (it's local-only), so validate + test:integration passed while these gaps slipped through. Bring them all back over the line: - core/auth/node/runner-oauth-callback.ts: test bad-URL/non-http-scheme throws, no-port->80 default, and IPv6 hostname bracketing. Annotate the provably-dead guards (empty host, out-of-range port, String(err) fallback, pathname "/" fallback) -- all rejected by new URL() upstream -- with justified v8 ignore comments instead of lowering the gate. - core/auth/cimd.ts: test the AS-doesn't-support-CIMD early return. - core/react/useClientSettingsDraft.ts: test the functional-updater path, the stale-onChange-after-close prev===null guard, and the timer-fires-after-close value===null guard. - ClientSettingsForm.tsx: test the CIMD checkbox toggle and CIMD URL clear. - clients/tui/src/utils/oauthDisplay.ts: test static/dcr registration kinds, the expired IdP session, and the configured-scope/empty-scope branches of formatScopes. npm run coverage now passes (exit 0) across web, cli, tui, launcher. Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/tui/__tests__/oauthDisplay.test.ts | 19 ++++++ .../ClientSettingsForm.test.tsx | 54 +++++++++++++++ clients/web/src/test/core/auth/cimd.test.ts | 30 +++++++++ .../core/auth/runner-oauth-callback.test.ts | 42 ++++++++++++ .../react/useClientSettingsDraft.test.tsx | 67 +++++++++++++++++++ core/auth/node/runner-oauth-callback.ts | 11 ++- 6 files changed, 220 insertions(+), 3 deletions(-) diff --git a/clients/tui/__tests__/oauthDisplay.test.ts b/clients/tui/__tests__/oauthDisplay.test.ts index 21ead0a60..abaf2148c 100644 --- a/clients/tui/__tests__/oauthDisplay.test.ts +++ b/clients/tui/__tests__/oauthDisplay.test.ts @@ -14,6 +14,10 @@ describe("oauthDisplay", () => { }); it("formatClientRegistrationKind covers registration kinds", () => { + expect(formatClientRegistrationKind("static")).toBe( + "Static (preregistered)", + ); + expect(formatClientRegistrationKind("dcr")).toBe("Dynamic (DCR)"); expect(formatClientRegistrationKind("cimd")).toBe( "Client ID Metadata (CIMD)", ); @@ -21,6 +25,7 @@ describe("oauthDisplay", () => { it("formatIdpSession maps session states", () => { expect(formatIdpSession("logged_in")).toBe("Signed in"); + expect(formatIdpSession("expired")).toBe("Session expired"); expect(formatIdpSession("none")).toBe("Not signed in"); }); @@ -30,4 +35,18 @@ describe("oauthDisplay", () => { } as OAuthConnectionState; expect(formatScopes(state)).toBe("openid, profile"); }); + + it("formatScopes falls back to configured scope", () => { + const state = { + configuredScope: "read write", + } as OAuthConnectionState; + expect(formatScopes(state)).toBe("read, write"); + }); + + it("formatScopes returns undefined when no scopes are present", () => { + expect(formatScopes({} as OAuthConnectionState)).toBeUndefined(); + expect( + formatScopes({ grantedScope: " " } as OAuthConnectionState), + ).toBeUndefined(); + }); }); diff --git a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx index 915b307c4..4ea9c47ce 100644 --- a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx @@ -366,6 +366,60 @@ describe("ClientSettingsForm interactions", () => { }); }); + it("toggles the CIMD enable checkbox via onSettingsChange", async () => { + const user = userEvent.setup(); + const onSettingsChange = vi.fn(); + renderWithMantine( + , + ); + + await user.click( + screen.getByRole("checkbox", { + name: "Use Client ID Metadata Document", + }), + ); + expect(onSettingsChange).toHaveBeenCalledWith(expect.any(Function)); + expect( + resolveSettingsChange( + onSettingsChange.mock.calls[0]![0], + EMPTY_CLIENT_SETTINGS, + ), + ).toEqual({ + ...EMPTY_CLIENT_SETTINGS, + cimdEnabled: true, + }); + }); + + it("clears the CIMD metadata URL via its clear button", async () => { + const user = userEvent.setup(); + const onSettingsChange = vi.fn(); + const filled = { + ...EMPTY_CLIENT_SETTINGS, + cimdEnabled: true, + clientMetadataUrl: "https://example.com/cimd.json", + }; + renderWithMantine( + , + ); + + await user.click(screen.getByRole("button", { name: "Clear" })); + expect(onSettingsChange).toHaveBeenCalledWith(expect.any(Function)); + expect( + resolveSettingsChange(onSettingsChange.mock.calls[0]![0], filled) + .clientMetadataUrl, + ).toBe(""); + }); + it("edits the IdP text fields via onSettingsChange", async () => { const user = userEvent.setup(); const onSettingsChange = vi.fn(); diff --git a/clients/web/src/test/core/auth/cimd.test.ts b/clients/web/src/test/core/auth/cimd.test.ts index 98c5b5135..7f3bc38bf 100644 --- a/clients/web/src/test/core/auth/cimd.test.ts +++ b/clients/web/src/test/core/auth/cimd.test.ts @@ -67,6 +67,36 @@ describe("ensureCimdClientRegistration", () => { ); }); + it("does not register when the AS metadata omits CIMD support", async () => { + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/.well-known/oauth-protected-resource")) { + return new Response(JSON.stringify({ resource: SERVER_URL })); + } + if (url.includes("/.well-known/oauth-authorization-server")) { + return new Response( + JSON.stringify({ + issuer: "http://127.0.0.1:9999", + authorization_endpoint: "http://127.0.0.1:9999/oauth/authorize", + token_endpoint: "http://127.0.0.1:9999/oauth/token", + response_types_supported: ["code"], + // client_id_metadata_document_supported intentionally absent. + }), + ); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + const provider = createProvider(storage); + await ensureCimdClientRegistration({ + serverUrl: SERVER_URL, + provider, + fetchFn, + }); + + expect(storage.saveClientInformation).not.toHaveBeenCalled(); + }); + it("no-ops when client information is already stored", async () => { storage.getClientInformation = vi.fn(async () => ({ client_id: "existing-client", diff --git a/clients/web/src/test/core/auth/runner-oauth-callback.test.ts b/clients/web/src/test/core/auth/runner-oauth-callback.test.ts index 2a17e3c52..4836cbe3a 100644 --- a/clients/web/src/test/core/auth/runner-oauth-callback.test.ts +++ b/clients/web/src/test/core/auth/runner-oauth-callback.test.ts @@ -54,10 +54,52 @@ describe("runner OAuth callback URL", () => { }); }); + it("defaults to port 80 when the URL omits a port", () => { + expect( + parseRunnerOAuthCallbackUrl("http://127.0.0.1/oauth/callback"), + ).toEqual({ + hostname: "127.0.0.1", + port: 80, + pathname: "/oauth/callback", + }); + }); + + it("throws on an unparseable callback URL", () => { + expect(() => parseRunnerOAuthCallbackUrl("not a url")).toThrow( + /Invalid OAuth callback URL/, + ); + }); + + it("rejects a non-http scheme", () => { + expect(() => + parseRunnerOAuthCallbackUrl("https://127.0.0.1:6276/oauth/callback"), + ).toThrow(/must use http scheme/); + }); + it("formatRunnerOAuthRedirectUrl round-trips default config", () => { const config = parseRunnerOAuthCallbackUrl(); expect(formatRunnerOAuthRedirectUrl(config)).toBe( DEFAULT_RUNNER_OAUTH_CALLBACK_URL, ); }); + + it("formatRunnerOAuthRedirectUrl brackets a bare IPv6 hostname", () => { + expect( + formatRunnerOAuthRedirectUrl({ + hostname: "::1", + port: 6276, + pathname: "/oauth/callback", + }), + ).toBe("http://[::1]:6276/oauth/callback"); + }); + + it("formatRunnerOAuthRedirectUrl leaves an already-bracketed IPv6 host alone", () => { + expect( + formatRunnerOAuthRedirectUrl({ + hostname: "[::1]", + port: 6276, + pathname: "/oauth/callback", + }), + ).toBe("http://[::1]:6276/oauth/callback"); + }); }); diff --git a/clients/web/src/test/core/react/useClientSettingsDraft.test.tsx b/clients/web/src/test/core/react/useClientSettingsDraft.test.tsx index fc056daec..23592a35e 100644 --- a/clients/web/src/test/core/react/useClientSettingsDraft.test.tsx +++ b/clients/web/src/test/core/react/useClientSettingsDraft.test.tsx @@ -97,6 +97,73 @@ describe("useClientSettingsDraft", () => { expect(result.current.draft).toEqual({ text: "ab", rows: [] }); }); + it("applies a functional updater against the latest draft", () => { + const { result } = renderHook(() => + useClientSettingsDraft({ + opened: true, + resolveInitial: () => ({ text: "seed", rows: ["a"] }), + onPersist: vi.fn(), + onError: vi.fn(), + }), + ); + act(() => { + result.current.onChange((prev) => ({ ...prev, text: prev.text + "!" })); + }); + expect(result.current.draft).toEqual({ text: "seed!", rows: ["a"] }); + }); + + it("ignores a stale onChange captured before the modal closed", () => { + // Capture the onChange created while open (its closure still sees + // opened === true), then close the modal — which nulls latestValuesRef. + // Invoking the stale handler must short-circuit on the `prev === null` + // guard rather than scheduling a persist of nothing. + const onPersist = vi.fn(async () => {}); + const { result, rerender } = renderHook( + ({ opened }: { opened: boolean }) => + useClientSettingsDraft({ + opened, + resolveInitial: () => EMPTY, + onPersist, + onError: vi.fn(), + }), + { initialProps: { opened: true } }, + ); + const staleOnChange = result.current.onChange; + rerender({ opened: false }); + act(() => { + staleOnChange({ text: "stale", rows: [] }); + }); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(onPersist).not.toHaveBeenCalled(); + }); + + it("skips the debounced persist when the modal closed before the timer fired", () => { + // A persist is scheduled while open; the modal then closes (nulling + // latestValuesRef) without unmounting, so the pending timer still fires. + // The `value !== null` guard inside the timer must suppress the persist. + const onPersist = vi.fn(async () => {}); + const { result, rerender } = renderHook( + ({ opened }: { opened: boolean }) => + useClientSettingsDraft({ + opened, + resolveInitial: () => EMPTY, + onPersist, + onError: vi.fn(), + }), + { initialProps: { opened: true } }, + ); + act(() => { + result.current.onChange({ text: "pending", rows: [] }); + }); + rerender({ opened: false }); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(onPersist).not.toHaveBeenCalled(); + }); + it("debounces onPersist by the configured window", () => { const onPersist = vi.fn(async () => {}); const { result } = renderHook(() => diff --git a/core/auth/node/runner-oauth-callback.ts b/core/auth/node/runner-oauth-callback.ts index b106e459d..cb53eeb9d 100644 --- a/core/auth/node/runner-oauth-callback.ts +++ b/core/auth/node/runner-oauth-callback.ts @@ -47,23 +47,27 @@ export function parseRunnerOAuthCallbackUrl( try { url = new URL(raw); } catch (err) { - throw new Error( - `Invalid OAuth callback URL: ${(err as Error)?.message ?? String(err)}`, - ); + /* v8 ignore next -- new URL() only throws an Error with a message; the String(err) fallback is unreachable */ + const reason = (err as Error)?.message ?? String(err); + throw new Error(`Invalid OAuth callback URL: ${reason}`); } if (url.protocol !== "http:") { throw new Error("OAuth callback URL must use http scheme"); } const hostname = url.hostname; + /* v8 ignore start -- a parseable http: URL always has a non-empty hostname; empty-host http URLs throw at new URL() above */ if (!hostname) { throw new Error("OAuth callback URL must include a hostname"); } + /* v8 ignore stop */ + /* v8 ignore next -- an http: URL always has a non-empty pathname (min "/"), so the fallback is unreachable */ const pathname = url.pathname || "/"; let port: number; if (url.port === "") { port = 80; } else { port = Number(url.port); + /* v8 ignore start -- new URL() already rejects out-of-range/non-numeric ports, so url.port is always a valid 0-65535 numeric string here */ if ( !Number.isFinite(port) || !Number.isInteger(port) || @@ -72,6 +76,7 @@ export function parseRunnerOAuthCallbackUrl( ) { throw new Error("OAuth callback URL port must be between 0 and 65535"); } + /* v8 ignore stop */ } return { hostname, port, pathname }; }