Merge remote-tracking branch 'origin/v2/main' into 1579-wave-1
# Conflicts: # AGENTS.md # clients/cli/README.md # clients/web/src/App.test.tsx # clients/web/src/App.tsx # clients/web/src/test/core/auth/utils.test.ts # core/mcp/node/transport.ts # test-servers/src/composable-test-server.ts
This commit is contained in:
commit
cfbdfdf2bd
127 changed files with 15921 additions and 1202 deletions
13
AGENTS.md
13
AGENTS.md
|
|
@ -22,9 +22,13 @@ inspector/
|
||||||
│ ├── tui/ # TUI client (Ink + React, tsup bundle)
|
│ ├── tui/ # TUI client (Ink + React, tsup bundle)
|
||||||
│ ├── launcher/ # Shared launcher (relative imports into sibling build/ outputs)
|
│ ├── launcher/ # Shared launcher (relative imports into sibling build/ outputs)
|
||||||
├── core/ # Shared core code (no package.json — consumed via the `@inspector/core` vite alias)
|
├── core/ # Shared core code (no package.json — consumed via the `@inspector/core` vite alias)
|
||||||
│ ├── auth/ # OAuth: state machine, providers, discovery, storage
|
│ ├── auth/ # OAuth: state machine, providers, discovery, storage;
|
||||||
|
│ │ # mid-session recovery (challenge.ts WWW-Authenticate
|
||||||
|
│ │ # parsing, scopes.ts SEP-2350 scope union, oauthUx.ts
|
||||||
|
│ │ # shared copy, mcpAuth.ts force-reauthorization)
|
||||||
│ │ ├── browser/ # Browser-side OAuth (sessionStorage, BrowserNavigation)
|
│ │ ├── browser/ # Browser-side OAuth (sessionStorage, BrowserNavigation)
|
||||||
│ │ ├── node/ # Node-side OAuth (NodeOAuthStorage, OAuthCallbackServer)
|
│ │ ├── node/ # Node-side OAuth (NodeOAuthStorage, OAuthCallbackServer,
|
||||||
|
│ │ │ # runner-interactive-oauth loopback callback flow)
|
||||||
│ │ └── remote/ # Remote OAuth storage (delegates to the remote server)
|
│ │ └── remote/ # Remote OAuth storage (delegates to the remote server)
|
||||||
│ ├── json/ # JSON utilities and parameter/argument conversion
|
│ ├── json/ # JSON utilities and parameter/argument conversion
|
||||||
│ ├── logging/ # Silent pino logger singleton
|
│ ├── logging/ # Silent pino logger singleton
|
||||||
|
|
@ -189,14 +193,15 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i
|
||||||
|
|
||||||
### Lint-fixed, Formatted code
|
### Lint-fixed, Formatted code
|
||||||
- ALWAYS do `npm run format` before committing — it auto-fixes any Prettier issues. `validate` runs `format:check` (the non-fixing variant) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it.
|
- ALWAYS do `npm run format` before committing — it auto-fixes any Prettier issues. `validate` runs `format:check` (the non-fixing variant) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it.
|
||||||
- ALWAYS do `npm run validate` before pushing any changes — from the repo root it chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each delegates to that client's own `npm run validate` = `format:check` + `lint` + `build` + `test` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies).
|
- **`npm run ci` before pushing** — runs the same steps as `.github/workflows/main.yml` (minus `npm install`): `validate` → web `test:integration` → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). Use this when you want CI parity; expect several minutes. **`npm run validate`** remains the fast loop during development (unit tests only — no web integration, smoke, or Storybook).
|
||||||
|
- ALWAYS do `npm run format` before committing, then **`npm run ci`** (or at minimum `npm run validate`) before pushing. From the repo root, `validate` chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each delegates to that client's own `npm run validate` = `format:check` + `lint` + `build` + `test` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies).
|
||||||
- The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && build && test`.
|
- The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && build && test`.
|
||||||
- Before pushing, also run **`npm run coverage`** — `validate` is fast and does NOT enforce the per-file gate (or, for web, run the integration project); `coverage` does both. **CI runs `coverage`** too (#1550), so the gate blocks PRs that regress below 90 — running it locally first just catches failures before CI does. CI has no separate `test:integration` step: web's `test:coverage` already runs the integration project.
|
- Before pushing, also run **`npm run coverage`** — `validate` is fast and does NOT enforce the per-file gate (or, for web, run the integration project); `coverage` does both. **CI runs `coverage`** too (#1550), so the gate blocks PRs that regress below 90 — running it locally first just catches failures before CI does. CI has no separate `test:integration` step: web's `test:coverage` already runs the integration project.
|
||||||
- **`smoke` is a separate top-level target, NOT part of `validate`.** Run it (or the individual `smoke:*`) after a build/validate: `npm run validate && npm run smoke`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `smoke`. Storybook is the only CI step left out (see below).
|
- **`smoke` is a separate top-level target, NOT part of `validate`.** Run it (or the individual `smoke:*`) after a build/validate: `npm run validate && npm run smoke`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `smoke`. Storybook is the only CI step left out (see below).
|
||||||
- `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below.
|
- `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below.
|
||||||
- `smoke:web` (`scripts/smoke-web.mjs`) starts `mcp-inspector --web` (prod, no `--dev`) against the built `clients/web/dist` and asserts `GET /` serves the SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. Prod `--web` serves from `clients/web/dist`, which ships in the published package but is absent in a fresh checkout — the runner builds it on demand (`build:client` = `vite build`) on first launch, or exits with an actionable error if that build can't run (see `clients/web/server/ensure-web-build.ts` and the launcher README). `--dev` runs Vite directly and never needs `dist`.
|
- `smoke:web` (`scripts/smoke-web.mjs`) starts `mcp-inspector --web` (prod, no `--dev`) against the built `clients/web/dist` and asserts `GET /` serves the SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. Prod `--web` serves from `clients/web/dist`, which ships in the published package but is absent in a fresh checkout — the runner builds it on demand (`build:client` = `vite build`) on first launch, or exits with an actionable error if that build can't run (see `clients/web/server/ensure-web-build.ts` and the launcher README). `--dev` runs Vite directly and never needs `dist`.
|
||||||
- `smoke:cli` (`scripts/smoke-cli.mjs`) drives `mcp-inspector --cli` through the built launcher against the bundled stdio test server via a temp `--catalog`: it asserts `tools/list` returns the server's tools (real connect over stdio), the default writable catalog is seeded empty on first run, a missing read-only `--config` errors without seeding, and `--catalog` + `--config` is rejected. `smoke:tui` (`scripts/smoke-tui.mjs`) launches `mcp-inspector --tui --catalog <temp>` and asserts the Ink app renders its first frame (the "MCP Servers" panel) within a timeout, then SIGTERMs it — a shallow boot/render check, not full interaction. **`smoke:tui` is local-only: it self-skips when `process.env.CI` is set**, because the Ink TUI needs a real TTY (raw mode) that headless CI lacks — so run it (via `npm run smoke`) on your own machine before pushing. Both build `test-servers/build` on demand if it's missing.
|
- `smoke:cli` (`scripts/smoke-cli.mjs`) drives `mcp-inspector --cli` through the built launcher against the bundled stdio test server via a temp `--catalog`: it asserts `tools/list` returns the server's tools (real connect over stdio), the default writable catalog is seeded empty on first run, a missing read-only `--config` errors without seeding, and `--catalog` + `--config` is rejected. `smoke:tui` (`scripts/smoke-tui.mjs`) launches `mcp-inspector --tui --catalog <temp>` and asserts the Ink app renders its first frame (the "MCP Servers" panel) within a timeout, then SIGTERMs it — a shallow boot/render check, not full interaction. **`smoke:tui` is local-only: it self-skips when `process.env.CI` is set**, because the Ink TUI needs a real TTY (raw mode) that headless CI lacks — so run it (via `npm run smoke`) on your own machine before pushing. Both build `test-servers/build` on demand if it's missing.
|
||||||
- Also run `npm run test:storybook` from `clients/web/` before pushing — it executes every story's `play` function in headless Chromium via `@vitest/browser-playwright` (~10s). CI runs this as a separate step (from `clients/web`) after `validate`; failures block merge. It is kept out of `validate` because it needs the Playwright browser binary and is much slower than the unit suite. (There is no root-level `test:storybook` aggregate — run it in the web client.)
|
- Storybook play-function tests (`clients/web` `test:storybook`) run in headless Chromium via `@vitest/browser-playwright` (~10s). They are part of `npm run ci` (which installs Playwright chromium first); kept out of `validate` because they need the browser binary and are slower than the unit suite.
|
||||||
|
|
||||||
### Typescript instructions
|
### Typescript instructions
|
||||||
- Use TypeScript for all new code
|
- Use TypeScript for all new code
|
||||||
|
|
|
||||||
|
|
@ -108,9 +108,15 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en
|
||||||
|
|
||||||
### CLI-specific (OAuth for HTTP servers)
|
### CLI-specific (OAuth for HTTP servers)
|
||||||
|
|
||||||
The CLI **reuses** OAuth tokens from `~/.mcp-inspector/storage/oauth.json` (same file as the TUI). Complete first-time authorization in the **web** or **TUI** client, then run one-shot CLI commands against HTTP/SSE servers without signing in again.
|
The CLI runs the same loopback callback server as the TUI (`http://127.0.0.1:6276/oauth/callback` by default). On connect **401** or mid-session interactive auth (re-login / step-up), it:
|
||||||
|
|
||||||
The CLI does **not** start a local callback server or retry connect on 401. If tokens are missing or expired, connect fails; `ConsoleNavigation` may print an authorize URL to stdout, but the CLI cannot finish the redirect flow. Use the TUI for interactive runner OAuth until Phase 4 adds a CLI callback server.
|
1. Starts the callback listener on `--callback-url` (or `MCP_OAUTH_CALLBACK_URL`)
|
||||||
|
2. Prints the authorization URL to the console (`ConsoleNavigation`)
|
||||||
|
3. Waits for the browser redirect, exchanges the code, and retries connect or the failed RPC
|
||||||
|
|
||||||
|
**Step-up (standard OAuth):** when an RPC needs extra scopes, the CLI prompts on stderr: `Proceed with step-up authorization? [y/N]`. **y** continues; **N** exits with an error. EMA step-up re-mints silently (no prompt).
|
||||||
|
|
||||||
|
**Shared OAuth storage:** the CLI **reuses** tokens from `~/.mcp-inspector/storage/oauth.json` when they already exist (same file as other Inspector clients). That is passive file sharing, not launching another app.
|
||||||
|
|
||||||
**Shared with TUI** (config only, not interactive login):
|
**Shared with TUI** (config only, not interactive login):
|
||||||
|
|
||||||
|
|
@ -121,13 +127,13 @@ The CLI does **not** start a local callback server or retry connect on 401. If t
|
||||||
|
|
||||||
#### OAuth callback URL
|
#### OAuth callback URL
|
||||||
|
|
||||||
| Surface | Default callback |
|
| Surface | Default callback |
|
||||||
| ------- | ----------------------------------------------------------------------------------------- |
|
| ------- | ---------------- |
|
||||||
| **Web** | `http://localhost:6274/oauth/callback` |
|
| **Web** | `http://localhost:6274/oauth/callback` |
|
||||||
| **TUI** | `http://127.0.0.1:6276/oauth/callback` (interactive — callback server) |
|
| **TUI** | `http://127.0.0.1:6276/oauth/callback` (interactive — callback server) |
|
||||||
| **CLI** | `http://127.0.0.1:6276/oauth/callback` (redirect URI in OAuth metadata only; no listener) |
|
| **CLI** | `http://127.0.0.1:6276/oauth/callback` (interactive — same callback server as TUI) |
|
||||||
|
|
||||||
Register `http://127.0.0.1:6276/oauth/callback` on static or enterprise IdPs that require pre-registered redirect URIs before using the **TUI** (or when your OAuth app expects that URI). Override with `--callback-url` or `MCP_OAUTH_CALLBACK_URL`. The CLI passes this value as `redirect_uri` when an OAuth flow runs, but does not listen on the port.
|
Register `http://127.0.0.1:6276/oauth/callback` on static or enterprise IdPs that require pre-registered redirect URIs before using the **TUI** or **CLI**. Override with `--callback-url` or `MCP_OAUTH_CALLBACK_URL`. Only one process should bind the default port at a time.
|
||||||
|
|
||||||
#### Flags
|
#### Flags
|
||||||
|
|
||||||
|
|
@ -147,7 +153,7 @@ npx @modelcontextprotocol/inspector --cli --catalog mcp.json --server my-http-se
|
||||||
--method tools/list
|
--method tools/list
|
||||||
```
|
```
|
||||||
|
|
||||||
See [EMA / enterprise-managed auth](../../specification/v2_auth_ema.md) and [OAuth smoke testing](../../specification/v2_auth_smoke_testing.md) for configuration details and staging servers.
|
See [EMA / enterprise-managed auth](../../specification/v2_auth_ema.md) and [OAuth smoke testing](../../specification/v2_auth_smoke_testing.md) (§3 Stytch/CIMD; [§5 mid-session manual validation](v2_auth_smoke_testing.md#5-mid-session-auth--step-up--manual-validation) — CLI **C1–C2**).
|
||||||
|
|
||||||
## Exit codes & error envelopes
|
## Exit codes & error envelopes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ npm run test:cli # cli.test.ts
|
||||||
npm run test:cli-tools # tools.test.ts
|
npm run test:cli-tools # tools.test.ts
|
||||||
npm run test:cli-headers # headers.test.ts
|
npm run test:cli-headers # headers.test.ts
|
||||||
npm run test:cli-metadata # metadata.test.ts
|
npm run test:cli-metadata # metadata.test.ts
|
||||||
|
npx vitest run oauth-interactive.test.ts cliOAuth.test.ts # OAuth interactive smoke parity
|
||||||
```
|
```
|
||||||
|
|
||||||
## How the CLI is exercised
|
## How the CLI is exercised
|
||||||
|
|
@ -42,7 +43,10 @@ root provides a further end-to-end check of the binary.)
|
||||||
- `metadata.test.ts` - Metadata functionality: General metadata, tool-specific metadata, parsing, merging, validation
|
- `metadata.test.ts` - Metadata functionality: General metadata, tool-specific metadata, parsing, merging, validation
|
||||||
- `methods.test.ts` - Method/option-validation paths not covered elsewhere (resource templates, missing/invalid options, the `--` target separator)
|
- `methods.test.ts` - Method/option-validation paths not covered elsewhere (resource templates, missing/invalid options, the `--` target separator)
|
||||||
- `error-handler.test.ts` - The binary's `handleError` error sink, exercised in-process with `process.exit` stubbed
|
- `error-handler.test.ts` - The binary's `handleError` error sink, exercised in-process with `process.exit` stubbed
|
||||||
- `e2e.test.ts` - Out-of-process spawn of the built binary (exit codes + boot)
|
- `oauth-runner.test.ts` - OAuth flag wiring (`--client-config`, `--callback-url`, CIMD overrides)
|
||||||
|
- `cliOAuth.test.ts` - Unit tests for `cliOAuth.ts` (step-up confirm, helper wiring, retry)
|
||||||
|
- `oauth-interactive.test.ts` - **Integration** smoke parity for CLI interactive OAuth: connect-time callback server + step-up **y/N** against composable `TestServerHttp` (auto-completes authorize URL programmatically; not a subprocess binary e2e)
|
||||||
|
- `e2e.test.ts` - Out-of-process spawn of the built binary (exit codes + boot; no OAuth)
|
||||||
|
|
||||||
## Helpers
|
## Helpers
|
||||||
|
|
||||||
|
|
|
||||||
230
clients/cli/__tests__/cliOAuth.test.ts
Normal file
230
clients/cli/__tests__/cliOAuth.test.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||||
|
import { MutableRedirectUrlProvider } from "@inspector/core/auth/index.js";
|
||||||
|
import * as runnerInteractive from "@inspector/core/auth/node/runner-interactive-oauth.js";
|
||||||
|
import {
|
||||||
|
handleCliAuthRecoveryRequired,
|
||||||
|
isStandardOAuthStepUp,
|
||||||
|
runCliInteractiveOAuth,
|
||||||
|
withCliAuthRecoveryRetry,
|
||||||
|
} from "../src/cliOAuth.js";
|
||||||
|
|
||||||
|
describe("cliOAuth", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isStandardOAuthStepUp", () => {
|
||||||
|
it("returns true for insufficient_scope on non-EMA servers", () => {
|
||||||
|
expect(
|
||||||
|
isStandardOAuthStepUp(
|
||||||
|
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for EMA servers", () => {
|
||||||
|
expect(
|
||||||
|
isStandardOAuthStepUp(
|
||||||
|
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
|
||||||
|
{ enterpriseManaged: true },
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runCliInteractiveOAuth writes success to stderr", async () => {
|
||||||
|
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({
|
||||||
|
kind: "success",
|
||||||
|
});
|
||||||
|
const client = {
|
||||||
|
authenticate: vi.fn(),
|
||||||
|
beginInteractiveAuthorization: vi.fn(),
|
||||||
|
completeOAuthFlow: vi.fn(),
|
||||||
|
checkAuthChallengeSatisfied: vi.fn(),
|
||||||
|
};
|
||||||
|
const redirectUrlProvider = new MutableRedirectUrlProvider();
|
||||||
|
const stderrSpy = vi
|
||||||
|
.spyOn(process.stderr, "write")
|
||||||
|
.mockImplementation(() => true);
|
||||||
|
|
||||||
|
await runCliInteractiveOAuth(client, redirectUrlProvider, {
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: 6276,
|
||||||
|
pathname: "/oauth/callback",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stderrSpy).toHaveBeenCalledWith("Authorization complete.\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runCliInteractiveOAuth throws when scopes remain insufficient", async () => {
|
||||||
|
const challenge = {
|
||||||
|
reason: "insufficient_scope" as const,
|
||||||
|
requiredScopes: ["weather:read"],
|
||||||
|
};
|
||||||
|
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({
|
||||||
|
kind: "insufficient_scope",
|
||||||
|
challenge,
|
||||||
|
});
|
||||||
|
const client = {
|
||||||
|
authenticate: vi.fn(),
|
||||||
|
beginInteractiveAuthorization: vi.fn(),
|
||||||
|
completeOAuthFlow: vi.fn(),
|
||||||
|
checkAuthChallengeSatisfied: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runCliInteractiveOAuth(
|
||||||
|
client,
|
||||||
|
new MutableRedirectUrlProvider(),
|
||||||
|
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||||
|
{ authChallenge: challenge },
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/required scopes were not granted/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handleCliAuthRecoveryRequired declines standard step-up when user says no", async () => {
|
||||||
|
const runSpy = vi
|
||||||
|
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||||
|
.mockResolvedValue({ kind: "success" });
|
||||||
|
const client = {
|
||||||
|
authenticate: vi.fn(),
|
||||||
|
beginInteractiveAuthorization: vi.fn(),
|
||||||
|
completeOAuthFlow: vi.fn(),
|
||||||
|
checkAuthChallengeSatisfied: vi.fn(),
|
||||||
|
};
|
||||||
|
const error = new AuthRecoveryRequiredError(
|
||||||
|
new URL("https://as.example/authorize"),
|
||||||
|
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handleCliAuthRecoveryRequired(
|
||||||
|
client,
|
||||||
|
error,
|
||||||
|
new MutableRedirectUrlProvider(),
|
||||||
|
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||||
|
{},
|
||||||
|
async () => false,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("Step-up authorization declined.");
|
||||||
|
|
||||||
|
expect(runSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handleCliAuthRecoveryRequired runs OAuth after step-up confirm", async () => {
|
||||||
|
const runSpy = vi
|
||||||
|
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||||
|
.mockResolvedValue({ kind: "success" });
|
||||||
|
const client = {
|
||||||
|
authenticate: vi.fn(),
|
||||||
|
beginInteractiveAuthorization: vi.fn(),
|
||||||
|
completeOAuthFlow: vi.fn(),
|
||||||
|
checkAuthChallengeSatisfied: vi.fn(),
|
||||||
|
};
|
||||||
|
const authorizationUrl = new URL("https://as.example/authorize");
|
||||||
|
const challenge = {
|
||||||
|
reason: "insufficient_scope" as const,
|
||||||
|
requiredScopes: ["weather:read"],
|
||||||
|
};
|
||||||
|
const error = new AuthRecoveryRequiredError(authorizationUrl, challenge);
|
||||||
|
|
||||||
|
await handleCliAuthRecoveryRequired(
|
||||||
|
client,
|
||||||
|
error,
|
||||||
|
new MutableRedirectUrlProvider(),
|
||||||
|
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||||
|
{},
|
||||||
|
async () => true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(runSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
authorizationUrl,
|
||||||
|
authChallenge: challenge,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handleCliAuthRecoveryRequired skips step-up when storage already satisfies", async () => {
|
||||||
|
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||||
|
const client = {
|
||||||
|
authenticate: vi.fn(),
|
||||||
|
beginInteractiveAuthorization: vi.fn(),
|
||||||
|
completeOAuthFlow: vi.fn(),
|
||||||
|
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true),
|
||||||
|
};
|
||||||
|
const error = new AuthRecoveryRequiredError(
|
||||||
|
new URL("https://as.example/authorize"),
|
||||||
|
{
|
||||||
|
reason: "insufficient_scope",
|
||||||
|
requiredScopes: ["weather:read"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await handleCliAuthRecoveryRequired(
|
||||||
|
client,
|
||||||
|
error,
|
||||||
|
new MutableRedirectUrlProvider(),
|
||||||
|
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(runSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handleCliAuthRecoveryRequired skips OAuth when storage already satisfies reauth", async () => {
|
||||||
|
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||||
|
const client = {
|
||||||
|
authenticate: vi.fn(),
|
||||||
|
beginInteractiveAuthorization: vi.fn(),
|
||||||
|
completeOAuthFlow: vi.fn(),
|
||||||
|
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true),
|
||||||
|
};
|
||||||
|
const error = new AuthRecoveryRequiredError(
|
||||||
|
new URL("https://as.example/authorize"),
|
||||||
|
{ reason: "token_expired" },
|
||||||
|
);
|
||||||
|
|
||||||
|
await handleCliAuthRecoveryRequired(
|
||||||
|
client,
|
||||||
|
error,
|
||||||
|
new MutableRedirectUrlProvider(),
|
||||||
|
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(runSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("withCliAuthRecoveryRetry reruns the operation after interactive recovery", async () => {
|
||||||
|
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({
|
||||||
|
kind: "success",
|
||||||
|
});
|
||||||
|
const client = {
|
||||||
|
authenticate: vi.fn(),
|
||||||
|
beginInteractiveAuthorization: vi.fn(),
|
||||||
|
completeOAuthFlow: vi.fn(),
|
||||||
|
checkAuthChallengeSatisfied: vi.fn(),
|
||||||
|
};
|
||||||
|
const fn = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(
|
||||||
|
new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), {
|
||||||
|
reason: "unauthorized",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce("ok");
|
||||||
|
|
||||||
|
const result = await withCliAuthRecoveryRetry(
|
||||||
|
client,
|
||||||
|
new MutableRedirectUrlProvider(),
|
||||||
|
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||||
|
{ enterpriseManaged: true },
|
||||||
|
fn,
|
||||||
|
async () => true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("ok");
|
||||||
|
expect(fn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
242
clients/cli/__tests__/oauth-interactive.test.ts
Normal file
242
clients/cli/__tests__/oauth-interactive.test.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import { rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import {
|
||||||
|
TestServerHttp,
|
||||||
|
waitForOAuthWellKnown,
|
||||||
|
getDefaultServerConfig,
|
||||||
|
createOAuthTestServerConfig,
|
||||||
|
clearOAuthTestData,
|
||||||
|
} from "@modelcontextprotocol/inspector-test-server";
|
||||||
|
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||||
|
import { createTransportNode } from "@inspector/core/mcp/node/index.js";
|
||||||
|
import {
|
||||||
|
CallbackNavigation,
|
||||||
|
MutableRedirectUrlProvider,
|
||||||
|
} from "@inspector/core/auth/index.js";
|
||||||
|
import { NodeOAuthStorage } from "@inspector/core/auth/node/index.js";
|
||||||
|
import {
|
||||||
|
connectInspectorWithOAuth,
|
||||||
|
withCliAuthRecoveryRetry,
|
||||||
|
} from "../src/cliOAuth.js";
|
||||||
|
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
|
||||||
|
|
||||||
|
const oauthTestStatePath = join(
|
||||||
|
tmpdir(),
|
||||||
|
`mcp-oauth-${process.pid}-cli-interactive.json`,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function completeOAuthAuthorization(
|
||||||
|
authorizationUrl: URL,
|
||||||
|
): Promise<string> {
|
||||||
|
let response = await fetch(authorizationUrl.toString(), {
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Local composable test AS shows an HTML consent page on GET; approve via POST.
|
||||||
|
if (response.status === 200) {
|
||||||
|
const body = new URLSearchParams(authorizationUrl.searchParams);
|
||||||
|
response = await fetch(
|
||||||
|
`${authorizationUrl.origin}${authorizationUrl.pathname}`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body,
|
||||||
|
redirect: "manual",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status !== 302 && response.status !== 301) {
|
||||||
|
throw new Error(
|
||||||
|
`Expected redirect (302/301), got ${response.status}: ${await response.text()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const location = response.headers.get("location");
|
||||||
|
if (!location) {
|
||||||
|
throw new Error("Missing Location header");
|
||||||
|
}
|
||||||
|
const redirect = new URL(location, authorizationUrl.origin);
|
||||||
|
const code = redirect.searchParams.get("code");
|
||||||
|
if (!code) {
|
||||||
|
throw new Error("Missing authorization code");
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAutoCompleteNavigation(
|
||||||
|
redirectUrlProvider: MutableRedirectUrlProvider,
|
||||||
|
) {
|
||||||
|
return new CallbackNavigation(async (url) => {
|
||||||
|
const code = await completeOAuthAuthorization(url);
|
||||||
|
const redirect = redirectUrlProvider.redirectUrl;
|
||||||
|
if (!redirect) {
|
||||||
|
throw new Error("redirectUrl not set");
|
||||||
|
}
|
||||||
|
await fetch(`${redirect}?code=${encodeURIComponent(code)}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const callbackUrlConfig = {
|
||||||
|
hostname: "127.0.0.1",
|
||||||
|
port: 6276,
|
||||||
|
pathname: "/oauth/callback",
|
||||||
|
};
|
||||||
|
const presetRedirectUrl = "http://127.0.0.1:6276/oauth/callback";
|
||||||
|
|
||||||
|
describe("CLI interactive OAuth (integration)", () => {
|
||||||
|
let mcpServer: TestServerHttp | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (mcpServer) {
|
||||||
|
await mcpServer.stop();
|
||||||
|
mcpServer = null;
|
||||||
|
}
|
||||||
|
clearOAuthTestData();
|
||||||
|
try {
|
||||||
|
rmSync(oauthTestStatePath, { force: true });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it("connects to an OAuth-protected server via the loopback callback server", async () => {
|
||||||
|
const serverConfig = {
|
||||||
|
...getDefaultServerConfig(),
|
||||||
|
serverType: "streamable-http" as const,
|
||||||
|
...createOAuthTestServerConfig({
|
||||||
|
requireAuth: true,
|
||||||
|
supportDCR: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
mcpServer = new TestServerHttp(serverConfig);
|
||||||
|
const port = await mcpServer.start();
|
||||||
|
const serverUrl = `http://localhost:${port}`;
|
||||||
|
await waitForOAuthWellKnown(serverUrl);
|
||||||
|
|
||||||
|
const redirectUrlProvider = new MutableRedirectUrlProvider();
|
||||||
|
redirectUrlProvider.redirectUrl = presetRedirectUrl;
|
||||||
|
const navigation = createAutoCompleteNavigation(redirectUrlProvider);
|
||||||
|
const client = new InspectorClient(
|
||||||
|
{
|
||||||
|
type: "streamable-http",
|
||||||
|
url: `${serverUrl}/mcp`,
|
||||||
|
} as MCPServerConfig,
|
||||||
|
{
|
||||||
|
environment: {
|
||||||
|
transport: createTransportNode,
|
||||||
|
oauth: {
|
||||||
|
storage: new NodeOAuthStorage(oauthTestStatePath),
|
||||||
|
navigation,
|
||||||
|
redirectUrlProvider,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
directAuthRecovery: true,
|
||||||
|
oauth: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await connectInspectorWithOAuth(
|
||||||
|
client,
|
||||||
|
{ type: "streamable-http", url: `${serverUrl}/mcp` },
|
||||||
|
redirectUrlProvider,
|
||||||
|
callbackUrlConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tools = await client.listTools();
|
||||||
|
expect(tools.tools.length).toBeGreaterThan(0);
|
||||||
|
await client.disconnect();
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it("retries an RPC after step-up authorization when the user confirms", async () => {
|
||||||
|
const baseConfig = getDefaultServerConfig();
|
||||||
|
|
||||||
|
const serverConfig = {
|
||||||
|
...baseConfig,
|
||||||
|
serverType: "streamable-http" as const,
|
||||||
|
tools: baseConfig.tools!.map((tool) =>
|
||||||
|
tool.name === "get_temp"
|
||||||
|
? { ...tool, requiredScopes: ["weather:read"] }
|
||||||
|
: tool,
|
||||||
|
),
|
||||||
|
...createOAuthTestServerConfig({
|
||||||
|
requireAuth: true,
|
||||||
|
supportRefreshTokens: true,
|
||||||
|
supportDCR: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
mcpServer = new TestServerHttp(serverConfig);
|
||||||
|
const port = await mcpServer.start();
|
||||||
|
const serverUrl = `http://localhost:${port}`;
|
||||||
|
await waitForOAuthWellKnown(serverUrl);
|
||||||
|
|
||||||
|
const redirectUrlProvider = new MutableRedirectUrlProvider();
|
||||||
|
redirectUrlProvider.redirectUrl = presetRedirectUrl;
|
||||||
|
const navigation = createAutoCompleteNavigation(redirectUrlProvider);
|
||||||
|
const client = new InspectorClient(
|
||||||
|
{
|
||||||
|
type: "streamable-http",
|
||||||
|
url: `${serverUrl}/mcp`,
|
||||||
|
} as MCPServerConfig,
|
||||||
|
{
|
||||||
|
environment: {
|
||||||
|
transport: createTransportNode,
|
||||||
|
oauth: {
|
||||||
|
storage: new NodeOAuthStorage(oauthTestStatePath),
|
||||||
|
navigation,
|
||||||
|
redirectUrlProvider,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
directAuthRecovery: true,
|
||||||
|
oauth: {
|
||||||
|
scope: "mcp tools:read",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await connectInspectorWithOAuth(
|
||||||
|
client,
|
||||||
|
{ type: "streamable-http", url: `${serverUrl}/mcp` },
|
||||||
|
redirectUrlProvider,
|
||||||
|
callbackUrlConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
const tools = await client.listTools();
|
||||||
|
const getTempTool = tools.tools.find((tool) => tool.name === "get_temp");
|
||||||
|
expect(getTempTool).toBeDefined();
|
||||||
|
|
||||||
|
let stepUpPrompted = false;
|
||||||
|
const originalWrite = process.stderr.write.bind(process.stderr);
|
||||||
|
process.stderr.write = ((chunk, ...rest) => {
|
||||||
|
const text = typeof chunk === "string" ? chunk : String(chunk);
|
||||||
|
if (text.includes("Proceed with step-up authorization?")) {
|
||||||
|
stepUpPrompted = true;
|
||||||
|
}
|
||||||
|
return originalWrite(chunk, ...rest);
|
||||||
|
}) as typeof process.stderr.write;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await withCliAuthRecoveryRetry(
|
||||||
|
client,
|
||||||
|
redirectUrlProvider,
|
||||||
|
callbackUrlConfig,
|
||||||
|
{},
|
||||||
|
() =>
|
||||||
|
client.callTool(getTempTool!, {
|
||||||
|
city: "NYC",
|
||||||
|
units: "C",
|
||||||
|
}),
|
||||||
|
async () => true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(stepUpPrompted).toBe(true);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
} finally {
|
||||||
|
process.stderr.write = originalWrite;
|
||||||
|
await client.disconnect();
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
|
|
@ -28,6 +28,10 @@ import {
|
||||||
MutableRedirectUrlProvider,
|
MutableRedirectUrlProvider,
|
||||||
} from "@inspector/core/auth/index.js";
|
} from "@inspector/core/auth/index.js";
|
||||||
import { NodeOAuthStorage } from "@inspector/core/auth/node/index.js";
|
import { NodeOAuthStorage } from "@inspector/core/auth/node/index.js";
|
||||||
|
import {
|
||||||
|
connectInspectorWithOAuth,
|
||||||
|
withCliAuthRecoveryRetry,
|
||||||
|
} from "./cliOAuth.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_RUNNER_OAUTH_CALLBACK_URL,
|
DEFAULT_RUNNER_OAUTH_CALLBACK_URL,
|
||||||
formatRunnerOAuthRedirectUrl,
|
formatRunnerOAuthRedirectUrl,
|
||||||
|
|
@ -67,8 +71,8 @@ async function callMethod(
|
||||||
serverSettings: InspectorServerSettings | undefined,
|
serverSettings: InspectorServerSettings | undefined,
|
||||||
args: MethodArgs & { method: string },
|
args: MethodArgs & { method: string },
|
||||||
clientConfig: ClientConfig,
|
clientConfig: ClientConfig,
|
||||||
cliAuthOverrides?: RunnerClientConfigOverrides,
|
cliAuthOverrides: RunnerClientConfigOverrides,
|
||||||
callbackUrlConfig?: RunnerOAuthCallbackConfig,
|
callbackUrlConfig: RunnerOAuthCallbackConfig,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const packageJsonPath = join(__dirname, "../package.json");
|
const packageJsonPath = join(__dirname, "../package.json");
|
||||||
|
|
@ -89,9 +93,8 @@ async function callMethod(
|
||||||
};
|
};
|
||||||
const redirectUrlProvider = new MutableRedirectUrlProvider();
|
const redirectUrlProvider = new MutableRedirectUrlProvider();
|
||||||
if (isOAuthCapableServerConfig(serverConfig)) {
|
if (isOAuthCapableServerConfig(serverConfig)) {
|
||||||
redirectUrlProvider.redirectUrl = formatRunnerOAuthRedirectUrl(
|
redirectUrlProvider.redirectUrl =
|
||||||
callbackUrlConfig!,
|
formatRunnerOAuthRedirectUrl(callbackUrlConfig);
|
||||||
);
|
|
||||||
environment.oauth = {
|
environment.oauth = {
|
||||||
storage: new NodeOAuthStorage(),
|
storage: new NodeOAuthStorage(),
|
||||||
navigation: new ConsoleNavigation(),
|
navigation: new ConsoleNavigation(),
|
||||||
|
|
@ -122,9 +125,7 @@ async function callMethod(
|
||||||
null;
|
null;
|
||||||
let managedPromptsState: ManagedPromptsState | null = null;
|
let managedPromptsState: ManagedPromptsState | null = null;
|
||||||
|
|
||||||
try {
|
const runMethod = async (): Promise<McpResponse> => {
|
||||||
await inspectorClient.connect();
|
|
||||||
|
|
||||||
let result: McpResponse;
|
let result: McpResponse;
|
||||||
|
|
||||||
if (args.method === "tools/list" || args.method === "tools/call") {
|
if (args.method === "tools/list" || args.method === "tools/call") {
|
||||||
|
|
@ -248,6 +249,26 @@ async function callMethod(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connectInspectorWithOAuth(
|
||||||
|
inspectorClient,
|
||||||
|
serverConfig,
|
||||||
|
redirectUrlProvider,
|
||||||
|
callbackUrlConfig,
|
||||||
|
serverSettings,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await withCliAuthRecoveryRetry(
|
||||||
|
inspectorClient,
|
||||||
|
redirectUrlProvider,
|
||||||
|
callbackUrlConfig,
|
||||||
|
serverSettings,
|
||||||
|
runMethod,
|
||||||
|
);
|
||||||
|
|
||||||
await awaitableLog(JSON.stringify(result, null, 2));
|
await awaitableLog(JSON.stringify(result, null, 2));
|
||||||
} finally {
|
} finally {
|
||||||
managedToolsState?.destroy();
|
managedToolsState?.destroy();
|
||||||
|
|
|
||||||
184
clients/cli/src/cliOAuth.ts
Normal file
184
clients/cli/src/cliOAuth.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
|
||||||
|
import {
|
||||||
|
AuthRecoveryRequiredError,
|
||||||
|
isStandardOAuthStepUp as isCoreStandardOAuthStepUp,
|
||||||
|
isUnauthorizedError,
|
||||||
|
stepUpConfirmMessage,
|
||||||
|
stepUpInsufficientScopeMessage,
|
||||||
|
MutableRedirectUrlProvider,
|
||||||
|
} from "@inspector/core/auth/index.js";
|
||||||
|
import {
|
||||||
|
createOAuthCallbackServer,
|
||||||
|
runRunnerInteractiveOAuth,
|
||||||
|
} from "@inspector/core/auth/node/index.js";
|
||||||
|
import type { RunnerOAuthCallbackConfig } from "@inspector/core/auth/node/runner-oauth-callback.js";
|
||||||
|
import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||||
|
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
|
||||||
|
import { isOAuthCapableServerConfig } from "@inspector/core/client/runner.js";
|
||||||
|
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
|
||||||
|
import { createInterface } from "node:readline/promises";
|
||||||
|
|
||||||
|
/** Standard-OAuth step-up (not EMA silent re-mint). */
|
||||||
|
export function isStandardOAuthStepUp(
|
||||||
|
challenge: AuthChallenge,
|
||||||
|
settings?: InspectorServerSettings,
|
||||||
|
): boolean {
|
||||||
|
return isCoreStandardOAuthStepUp(challenge, {
|
||||||
|
enterpriseManaged: settings?.enterpriseManaged,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmStepUpFromStdin(): Promise<boolean> {
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
||||||
|
try {
|
||||||
|
const answer = await rl.question("");
|
||||||
|
const normalized = answer.trim().toLowerCase();
|
||||||
|
return normalized === "y" || normalized === "yes";
|
||||||
|
} finally {
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptStepUpConfirm(
|
||||||
|
challenge: AuthChallenge,
|
||||||
|
confirmStepUp: () => Promise<boolean>,
|
||||||
|
): Promise<boolean> {
|
||||||
|
process.stderr.write(`${stepUpConfirmMessage(challenge)}\n`);
|
||||||
|
process.stderr.write("Proceed with step-up authorization? [y/N] ");
|
||||||
|
return confirmStepUp();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runCliInteractiveOAuth(
|
||||||
|
client: InspectorClient,
|
||||||
|
redirectUrlProvider: MutableRedirectUrlProvider,
|
||||||
|
callbackUrlConfig: RunnerOAuthCallbackConfig,
|
||||||
|
options?: {
|
||||||
|
authorizationUrl?: URL;
|
||||||
|
authChallenge?: AuthChallenge;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
const result = await runRunnerInteractiveOAuth({
|
||||||
|
client,
|
||||||
|
redirectUrlProvider,
|
||||||
|
callbackListen: callbackUrlConfig,
|
||||||
|
createCallbackServer: createOAuthCallbackServer,
|
||||||
|
authorizationUrl: options?.authorizationUrl,
|
||||||
|
authChallenge: options?.authChallenge,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.kind === "insufficient_scope") {
|
||||||
|
throw new Error(stepUpInsufficientScopeMessage(result.challenge));
|
||||||
|
}
|
||||||
|
if (result.kind === "success") {
|
||||||
|
process.stderr.write("Authorization complete.\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCliAuthRecoveryRequired(
|
||||||
|
client: InspectorClient,
|
||||||
|
error: AuthRecoveryRequiredError,
|
||||||
|
redirectUrlProvider: MutableRedirectUrlProvider,
|
||||||
|
callbackUrlConfig: RunnerOAuthCallbackConfig,
|
||||||
|
serverSettings?: InspectorServerSettings,
|
||||||
|
confirmStepUp: () => Promise<boolean> = confirmStepUpFromStdin,
|
||||||
|
): Promise<void> {
|
||||||
|
if (isStandardOAuthStepUp(error.authChallenge, serverSettings)) {
|
||||||
|
if (await client.checkAuthChallengeSatisfied(error.authChallenge)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const proceed = await promptStepUpConfirm(
|
||||||
|
error.authChallenge,
|
||||||
|
confirmStepUp,
|
||||||
|
);
|
||||||
|
if (!proceed) {
|
||||||
|
throw new Error("Step-up authorization declined.");
|
||||||
|
}
|
||||||
|
} else if (await client.checkAuthChallengeSatisfied(error.authChallenge)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await runCliInteractiveOAuth(client, redirectUrlProvider, callbackUrlConfig, {
|
||||||
|
authorizationUrl: error.authorizationUrl,
|
||||||
|
...(error.authChallenge.reason === "insufficient_scope" && {
|
||||||
|
authChallenge: error.authChallenge,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectInspectorWithOAuth(
|
||||||
|
inspectorClient: InspectorClient,
|
||||||
|
serverConfig: MCPServerConfig,
|
||||||
|
redirectUrlProvider: MutableRedirectUrlProvider,
|
||||||
|
callbackUrlConfig: RunnerOAuthCallbackConfig,
|
||||||
|
serverSettings?: InspectorServerSettings,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await inspectorClient.connect();
|
||||||
|
} catch (err) {
|
||||||
|
if (!isOAuthCapableServerConfig(serverConfig)) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err instanceof AuthRecoveryRequiredError) {
|
||||||
|
if (
|
||||||
|
await inspectorClient.checkAuthChallengeSatisfied(err.authChallenge)
|
||||||
|
) {
|
||||||
|
await inspectorClient.connect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleCliAuthRecoveryRequired(
|
||||||
|
inspectorClient,
|
||||||
|
err,
|
||||||
|
redirectUrlProvider,
|
||||||
|
callbackUrlConfig,
|
||||||
|
serverSettings,
|
||||||
|
);
|
||||||
|
await inspectorClient.connect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUnauthorizedError(err)) {
|
||||||
|
await inspectorClient.disconnect().catch(() => {});
|
||||||
|
await runCliInteractiveOAuth(
|
||||||
|
inspectorClient,
|
||||||
|
redirectUrlProvider,
|
||||||
|
callbackUrlConfig,
|
||||||
|
);
|
||||||
|
await inspectorClient.connect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn` once; on {@link AuthRecoveryRequiredError}, complete interactive OAuth
|
||||||
|
* and retry `fn` a single time (no further recovery attempts).
|
||||||
|
*/
|
||||||
|
export async function withCliAuthRecoveryRetry<T>(
|
||||||
|
inspectorClient: InspectorClient,
|
||||||
|
redirectUrlProvider: MutableRedirectUrlProvider,
|
||||||
|
callbackUrlConfig: RunnerOAuthCallbackConfig,
|
||||||
|
serverSettings: InspectorServerSettings | undefined,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
confirmStepUp: () => Promise<boolean> = confirmStepUpFromStdin,
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof AuthRecoveryRequiredError)) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
await handleCliAuthRecoveryRequired(
|
||||||
|
inspectorClient,
|
||||||
|
err,
|
||||||
|
redirectUrlProvider,
|
||||||
|
callbackUrlConfig,
|
||||||
|
serverSettings,
|
||||||
|
confirmStepUp,
|
||||||
|
);
|
||||||
|
process.stderr.write("Authorization complete. Retrying…\n");
|
||||||
|
return await fn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -55,6 +55,9 @@ const h = vi.hoisted(() => {
|
||||||
clearOAuthTokens: vi.fn(),
|
clearOAuthTokens: vi.fn(),
|
||||||
completeOAuthFlow: vi.fn(async (): Promise<void> => {}),
|
completeOAuthFlow: vi.fn(async (): Promise<void> => {}),
|
||||||
getOAuthState: vi.fn(async () => undefined),
|
getOAuthState: vi.fn(async () => undefined),
|
||||||
|
callTool: vi.fn(),
|
||||||
|
checkAuthChallengeSatisfied: vi.fn(async () => false),
|
||||||
|
handleAuthChallenge: vi.fn(async () => ({ kind: "satisfied" as const })),
|
||||||
};
|
};
|
||||||
// Captured options from the most recent callbackServer.start(), so a test can
|
// Captured options from the most recent callbackServer.start(), so a test can
|
||||||
// drive the onCallback / onError handlers the OAuth flows register.
|
// drive the onCallback / onError handlers the OAuth flows register.
|
||||||
|
|
@ -95,6 +98,11 @@ const h = vi.hoisted(() => {
|
||||||
completeOAuthFlow = (...a: unknown[]) =>
|
completeOAuthFlow = (...a: unknown[]) =>
|
||||||
clientSpies.completeOAuthFlow(...a);
|
clientSpies.completeOAuthFlow(...a);
|
||||||
getOAuthState = (...a: unknown[]) => clientSpies.getOAuthState(...a);
|
getOAuthState = (...a: unknown[]) => clientSpies.getOAuthState(...a);
|
||||||
|
callTool = (...a: unknown[]) => clientSpies.callTool(...a);
|
||||||
|
checkAuthChallengeSatisfied = (...a: unknown[]) =>
|
||||||
|
clientSpies.checkAuthChallengeSatisfied(...a);
|
||||||
|
handleAuthChallenge = (...a: unknown[]) =>
|
||||||
|
clientSpies.handleAuthChallenge(...a);
|
||||||
readResource = vi.fn(async () => ({
|
readResource = vi.fn(async () => ({
|
||||||
result: { contents: [{ uri: "file://x", text: "hello" }] },
|
result: { contents: [{ uri: "file://x", text: "hello" }] },
|
||||||
}));
|
}));
|
||||||
|
|
@ -185,16 +193,25 @@ vi.mock("@inspector/core/auth/index.js", async (importOriginal) => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
vi.mock("@inspector/core/auth/node/index.js", () => ({
|
vi.mock("@inspector/core/auth/node/index.js", async (importOriginal) => {
|
||||||
createOAuthCallbackServer: h.createOAuthCallbackServer,
|
const actual =
|
||||||
NodeOAuthStorage: class {},
|
await importOriginal<typeof import("@inspector/core/auth/node/index.js")>();
|
||||||
}));
|
return {
|
||||||
|
...actual,
|
||||||
|
createOAuthCallbackServer: h.createOAuthCallbackServer,
|
||||||
|
NodeOAuthStorage: class {},
|
||||||
|
};
|
||||||
|
});
|
||||||
vi.mock("../src/utils/openUrl.js", () => ({
|
vi.mock("../src/utils/openUrl.js", () => ({
|
||||||
openUrl: h.openUrl,
|
openUrl: h.openUrl,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import App from "../src/App.js";
|
import App from "../src/App.js";
|
||||||
import type { TuiServer } from "../src/tui-servers.js";
|
import type { TuiServer } from "../src/tui-servers.js";
|
||||||
|
import {
|
||||||
|
AuthRecoveryRequiredError,
|
||||||
|
EMA_STEP_UP_PENDING_URL,
|
||||||
|
} from "@inspector/core/auth/challenge.js";
|
||||||
|
|
||||||
const tick = () => new Promise((r) => setTimeout(r, 25));
|
const tick = () => new Promise((r) => setTimeout(r, 25));
|
||||||
const callbackUrlConfig = { hostname: "127.0.0.1", port: 0, pathname: "/cb" };
|
const callbackUrlConfig = { hostname: "127.0.0.1", port: 0, pathname: "/cb" };
|
||||||
|
|
@ -234,6 +251,27 @@ function oneHttp(): Record<string, TuiServer> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function oneEmaHttp(): Record<string, TuiServer> {
|
||||||
|
return {
|
||||||
|
ema: {
|
||||||
|
config: { type: "streamable-http", url: "http://localhost:8080/mcp" },
|
||||||
|
settings: {
|
||||||
|
requestTimeout: 0,
|
||||||
|
metadata: [],
|
||||||
|
headers: [],
|
||||||
|
env: [],
|
||||||
|
roots: [],
|
||||||
|
maxFetchRequests: 1000,
|
||||||
|
taskTtl: 0,
|
||||||
|
connectionTimeout: 0,
|
||||||
|
oauthClientId: "client_ema_test",
|
||||||
|
oauthScopes: "tools:read",
|
||||||
|
enterpriseManaged: true,
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Mixed catalog: an OAuth-capable http server first (auto-selected) followed by
|
// Mixed catalog: an OAuth-capable http server first (auto-selected) followed by
|
||||||
// a stdio server <20> drives per-server tab gating + the tab-switch-away effects.
|
// a stdio server <20> drives per-server tab gating + the tab-switch-away effects.
|
||||||
function httpThenStdio(): Record<string, TuiServer> {
|
function httpThenStdio(): Record<string, TuiServer> {
|
||||||
|
|
@ -466,6 +504,11 @@ beforeEach(() => {
|
||||||
h.clientSpies.completeOAuthFlow.mockResolvedValue(undefined);
|
h.clientSpies.completeOAuthFlow.mockResolvedValue(undefined);
|
||||||
h.clientSpies.getOAuthState.mockReset();
|
h.clientSpies.getOAuthState.mockReset();
|
||||||
h.clientSpies.getOAuthState.mockResolvedValue(undefined);
|
h.clientSpies.getOAuthState.mockResolvedValue(undefined);
|
||||||
|
h.clientSpies.callTool.mockReset();
|
||||||
|
h.clientSpies.checkAuthChallengeSatisfied.mockReset();
|
||||||
|
h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false);
|
||||||
|
h.clientSpies.handleAuthChallenge.mockReset();
|
||||||
|
h.clientSpies.handleAuthChallenge.mockResolvedValue({ kind: "satisfied" });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -936,4 +979,65 @@ describe("App (OAuth flows)", () => {
|
||||||
await h.cb.opts!.onCallback({ code: "x" });
|
await h.cb.opts!.onCallback({ code: "x" });
|
||||||
await expectFrame(r, "cb-string");
|
await expectFrame(r, "cb-string");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows EMA step-up confirmation on tool auth recovery instead of auto OAuth", async () => {
|
||||||
|
const challenge = {
|
||||||
|
reason: "insufficient_scope" as const,
|
||||||
|
requiredScopes: ["env:read"],
|
||||||
|
authorizationScopes: ["tools:read", "env:read"],
|
||||||
|
context: { toolName: "get-env" },
|
||||||
|
};
|
||||||
|
h.clientSpies.callTool.mockRejectedValue(
|
||||||
|
new AuthRecoveryRequiredError(EMA_STEP_UP_PENDING_URL, challenge, {
|
||||||
|
emaStepUpConfirm: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false);
|
||||||
|
h.ctrl.status = "connected";
|
||||||
|
h.ctrl.tools = [sampleTool];
|
||||||
|
const r = await mount(oneEmaHttp());
|
||||||
|
await press(r, ["t", TAB, ENTER]);
|
||||||
|
await expectFrame(r, "MOCK_FORM");
|
||||||
|
await press(r, [ENTER]);
|
||||||
|
await waitUntil(() => h.clientSpies.callTool.mock.calls.length > 0);
|
||||||
|
await expectFrame(r, "organization before it can continue");
|
||||||
|
const frame = r.lastFrame() ?? "";
|
||||||
|
expect(frame).toMatch(/organization|get-env/i);
|
||||||
|
expect(frame).not.toMatch(/opens browser/i);
|
||||||
|
expect(frame).not.toMatch(/OAuth: authenticating/i);
|
||||||
|
expect(h.callbackStart).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs confirmed EMA step-up via handleAuthChallenge when user authorizes", async () => {
|
||||||
|
const challenge = {
|
||||||
|
reason: "insufficient_scope" as const,
|
||||||
|
requiredScopes: ["env:read"],
|
||||||
|
authorizationScopes: ["tools:read", "env:read"],
|
||||||
|
context: { toolName: "get-env" },
|
||||||
|
};
|
||||||
|
h.clientSpies.callTool.mockRejectedValue(
|
||||||
|
new AuthRecoveryRequiredError(EMA_STEP_UP_PENDING_URL, challenge, {
|
||||||
|
emaStepUpConfirm: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false);
|
||||||
|
h.clientSpies.handleAuthChallenge.mockResolvedValue({ kind: "satisfied" });
|
||||||
|
h.ctrl.status = "connected";
|
||||||
|
h.ctrl.tools = [sampleTool];
|
||||||
|
const r = await mount(oneEmaHttp());
|
||||||
|
await press(r, ["t", TAB, ENTER]);
|
||||||
|
await expectFrame(r, "MOCK_FORM");
|
||||||
|
await press(r, [ENTER]);
|
||||||
|
await waitUntil(() => h.clientSpies.callTool.mock.calls.length > 0);
|
||||||
|
await expectFrame(r, "organization before it can continue");
|
||||||
|
await press(r, ["a"]);
|
||||||
|
await waitUntil(
|
||||||
|
() => h.clientSpies.handleAuthChallenge.mock.calls.length > 0,
|
||||||
|
);
|
||||||
|
expect(h.clientSpies.handleAuthChallenge).toHaveBeenCalledWith(challenge, {
|
||||||
|
confirmedStepUp: true,
|
||||||
|
});
|
||||||
|
expect(h.callbackStart).not.toHaveBeenCalled();
|
||||||
|
await expectFrame(r, "Step-up authorization succeeded");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,15 @@ const baseProps = {
|
||||||
connectionStatus: "disconnected" as const,
|
connectionStatus: "disconnected" as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pendingStepUp = {
|
||||||
|
challenge: {
|
||||||
|
reason: "insufficient_scope" as const,
|
||||||
|
requiredScopes: ["env:read"],
|
||||||
|
authorizationScopes: ["tools:read", "env:read"],
|
||||||
|
},
|
||||||
|
enterpriseManaged: true,
|
||||||
|
};
|
||||||
|
|
||||||
describe("AuthTab", () => {
|
describe("AuthTab", () => {
|
||||||
it("renders the placeholder when there is no server", () => {
|
it("renders the placeholder when there is no server", () => {
|
||||||
const { lastFrame } = render(
|
const { lastFrame } = render(
|
||||||
|
|
@ -220,6 +229,86 @@ describe("AuthTab", () => {
|
||||||
expect(lastFrame() ?? "").toContain("OAuth Details");
|
expect(lastFrame() ?? "").toContain("OAuth Details");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("navigates step-up choices with arrows and activates with Enter", async () => {
|
||||||
|
const onAuthorizeStepUp = vi.fn();
|
||||||
|
const onCancelStepUp = vi.fn();
|
||||||
|
const { client } = makeClient(sampleOAuthState);
|
||||||
|
const { stdin } = render(
|
||||||
|
<AuthTab
|
||||||
|
{...baseProps}
|
||||||
|
focused
|
||||||
|
inspectorClient={client}
|
||||||
|
oauthStatus="idle"
|
||||||
|
oauthMessage={null}
|
||||||
|
pendingStepUp={pendingStepUp}
|
||||||
|
onAuthorizeStepUp={onAuthorizeStepUp}
|
||||||
|
onCancelStepUp={onCancelStepUp}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await tick();
|
||||||
|
stdin.write("\r");
|
||||||
|
await tick();
|
||||||
|
expect(onAuthorizeStepUp).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onCancelStepUp).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
onAuthorizeStepUp.mockClear();
|
||||||
|
stdin.write(DOWN);
|
||||||
|
await tick();
|
||||||
|
stdin.write("\r");
|
||||||
|
await tick();
|
||||||
|
expect(onCancelStepUp).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onAuthorizeStepUp).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows step-up footer with selection hints when focused", async () => {
|
||||||
|
const { client } = makeClient(sampleOAuthState);
|
||||||
|
const { lastFrame } = render(
|
||||||
|
<AuthTab
|
||||||
|
{...baseProps}
|
||||||
|
focused
|
||||||
|
inspectorClient={client}
|
||||||
|
oauthStatus="idle"
|
||||||
|
oauthMessage={null}
|
||||||
|
pendingStepUp={pendingStepUp}
|
||||||
|
onAuthorizeStepUp={vi.fn()}
|
||||||
|
onCancelStepUp={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await tick();
|
||||||
|
expect(lastFrame() ?? "").toContain("↑/↓ select, Enter confirm");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes OAuth state when connection becomes connected", async () => {
|
||||||
|
const { client, getOAuthState } = makeClient(undefined);
|
||||||
|
const { lastFrame, rerender } = render(
|
||||||
|
<AuthTab
|
||||||
|
{...baseProps}
|
||||||
|
inspectorClient={client}
|
||||||
|
oauthStatus="idle"
|
||||||
|
oauthMessage={null}
|
||||||
|
connectionStatus="disconnected"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await tick();
|
||||||
|
expect(getOAuthState).toHaveBeenCalledTimes(1);
|
||||||
|
expect(lastFrame() ?? "").toContain("No OAuth information yet");
|
||||||
|
|
||||||
|
getOAuthState.mockResolvedValue(sampleOAuthState);
|
||||||
|
rerender(
|
||||||
|
<AuthTab
|
||||||
|
{...baseProps}
|
||||||
|
inspectorClient={client}
|
||||||
|
oauthStatus="idle"
|
||||||
|
oauthMessage={null}
|
||||||
|
connectionStatus="connected"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await tick();
|
||||||
|
expect(getOAuthState).toHaveBeenCalledTimes(2);
|
||||||
|
expect(lastFrame() ?? "").toContain("Authorized");
|
||||||
|
expect(lastFrame() ?? "").toContain("OAuth Details");
|
||||||
|
});
|
||||||
|
|
||||||
it("refreshes OAuth state when oauthComplete fires", async () => {
|
it("refreshes OAuth state when oauthComplete fires", async () => {
|
||||||
const { client, getOAuthState, fire, listeners } =
|
const { client, getOAuthState, fire, listeners } =
|
||||||
makeClient(sampleOAuthState);
|
makeClient(sampleOAuthState);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React from "react";
|
||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
import { render } from "ink-testing-library";
|
import { render } from "ink-testing-library";
|
||||||
import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||||
|
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||||
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
|
import type { Prompt } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
|
||||||
// ScrollView: passthrough so the results JSX actually mounts (and is counted
|
// ScrollView: passthrough so the results JSX actually mounts (and is counted
|
||||||
|
|
@ -341,4 +342,34 @@ describe("PromptTestModal", () => {
|
||||||
await tick();
|
await tick();
|
||||||
expect(true).toBe(true);
|
expect(true).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("delegates AuthRecoveryRequiredError to onAuthRecoveryRequired and closes", async () => {
|
||||||
|
const recovery = new AuthRecoveryRequiredError(
|
||||||
|
new URL("https://auth.example.com/authorize"),
|
||||||
|
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
|
||||||
|
);
|
||||||
|
const getPrompt = vi.fn().mockRejectedValue(recovery);
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const onAuthRecoveryRequired = vi.fn();
|
||||||
|
setFormSubmitValue({ topic: "x" });
|
||||||
|
|
||||||
|
const { stdin } = render(
|
||||||
|
<PromptTestModal
|
||||||
|
prompt={makePrompt()}
|
||||||
|
inspectorClient={{ getPrompt } as unknown as InspectorClient}
|
||||||
|
width={120}
|
||||||
|
height={30}
|
||||||
|
onClose={onClose}
|
||||||
|
onAuthRecoveryRequired={onAuthRecoveryRequired}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
stdin.write(ENTER);
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
expect(onAuthRecoveryRequired).toHaveBeenCalledWith(recovery);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React from "react";
|
||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
import { render } from "ink-testing-library";
|
import { render } from "ink-testing-library";
|
||||||
import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||||
|
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||||
|
|
||||||
// ScrollView passthrough so the results JSX actually mounts (and is covered).
|
// ScrollView passthrough so the results JSX actually mounts (and is covered).
|
||||||
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
|
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));
|
||||||
|
|
@ -264,4 +265,32 @@ describe("ResourceTestModal", () => {
|
||||||
await tick();
|
await tick();
|
||||||
api.unmount();
|
api.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("delegates AuthRecoveryRequiredError to onAuthRecoveryRequired and closes", async () => {
|
||||||
|
const recovery = new AuthRecoveryRequiredError(
|
||||||
|
new URL("https://auth.example.com/authorize"),
|
||||||
|
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
|
||||||
|
);
|
||||||
|
const read = vi.fn().mockRejectedValue(recovery);
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const onAuthRecoveryRequired = vi.fn();
|
||||||
|
const api = render(
|
||||||
|
<ResourceTestModal
|
||||||
|
template={makeTemplate()}
|
||||||
|
inspectorClient={fakeClient(read)}
|
||||||
|
width={80}
|
||||||
|
height={24}
|
||||||
|
onClose={onClose}
|
||||||
|
onAuthRecoveryRequired={onAuthRecoveryRequired}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await tick();
|
||||||
|
setSubmitValue({ name: "world" });
|
||||||
|
api.stdin.write("\r");
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
expect(onAuthRecoveryRequired).toHaveBeenCalledWith(recovery);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
api.unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
49
clients/tui/__tests__/tuiOAuth.test.ts
Normal file
49
clients/tui/__tests__/tuiOAuth.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
isStandardOAuthStepUp,
|
||||||
|
isStepUpConfirmation,
|
||||||
|
stepUpConfirmMessage,
|
||||||
|
stepUpInsufficientScopeMessage,
|
||||||
|
} from "../src/utils/tuiOAuth.js";
|
||||||
|
|
||||||
|
describe("tuiOAuth", () => {
|
||||||
|
it("detects standard OAuth step-up", () => {
|
||||||
|
expect(
|
||||||
|
isStandardOAuthStepUp(
|
||||||
|
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
|
||||||
|
{ enterpriseManaged: false },
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isStandardOAuthStepUp(
|
||||||
|
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
|
||||||
|
{ enterpriseManaged: true },
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects EMA step-up confirmation", () => {
|
||||||
|
expect(
|
||||||
|
isStepUpConfirmation(
|
||||||
|
{ reason: "insufficient_scope", requiredScopes: ["env:read"] },
|
||||||
|
{ enterpriseManaged: true },
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isStepUpConfirmation(
|
||||||
|
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
|
||||||
|
{ enterpriseManaged: false },
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats step-up messages", () => {
|
||||||
|
const challenge = {
|
||||||
|
reason: "insufficient_scope" as const,
|
||||||
|
requiredScopes: ["weather:read"],
|
||||||
|
context: { toolName: "get_temp" },
|
||||||
|
};
|
||||||
|
expect(stepUpConfirmMessage(challenge)).toMatch(/get_temp/);
|
||||||
|
expect(stepUpInsufficientScopeMessage(challenge)).toMatch(/get_temp/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -45,7 +45,10 @@ import {
|
||||||
CallbackNavigation,
|
CallbackNavigation,
|
||||||
MutableRedirectUrlProvider,
|
MutableRedirectUrlProvider,
|
||||||
isUnauthorizedError,
|
isUnauthorizedError,
|
||||||
|
AuthRecoveryRequiredError,
|
||||||
} from "@inspector/core/auth/index.js";
|
} from "@inspector/core/auth/index.js";
|
||||||
|
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
|
||||||
|
import type { TypedEvent } from "@inspector/core/mcp/inspectorClientEventTarget.js";
|
||||||
import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
|
import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
|
||||||
import type { ClientConfig } from "@inspector/core/client/types.js";
|
import type { ClientConfig } from "@inspector/core/client/types.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -57,9 +60,15 @@ import {
|
||||||
createOAuthCallbackServer,
|
createOAuthCallbackServer,
|
||||||
type OAuthCallbackServer,
|
type OAuthCallbackServer,
|
||||||
NodeOAuthStorage,
|
NodeOAuthStorage,
|
||||||
|
runRunnerInteractiveOAuth,
|
||||||
} from "@inspector/core/auth/node/index.js";
|
} from "@inspector/core/auth/node/index.js";
|
||||||
import { getTuiLogger } from "./logger.js";
|
import { getTuiLogger } from "./logger.js";
|
||||||
import { openUrl } from "./utils/openUrl.js";
|
import { openUrl } from "./utils/openUrl.js";
|
||||||
|
import {
|
||||||
|
isStepUpConfirmation,
|
||||||
|
stepUpInsufficientScopeMessage,
|
||||||
|
} from "./utils/tuiOAuth.js";
|
||||||
|
import { emaStepUpFailureMessage } from "@inspector/core/auth/oauthUx.js";
|
||||||
import { Tabs } from "./components/Tabs.js";
|
import { Tabs } from "./components/Tabs.js";
|
||||||
import { type TabType, tabs as tabList } from "./components/tabsConfig.js";
|
import { type TabType, tabs as tabList } from "./components/tabsConfig.js";
|
||||||
import { InfoTab } from "./components/InfoTab.js";
|
import { InfoTab } from "./components/InfoTab.js";
|
||||||
|
|
@ -141,14 +150,6 @@ function App({
|
||||||
callbackUrlConfig,
|
callbackUrlConfig,
|
||||||
}: AppProps) {
|
}: AppProps) {
|
||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
const callbackServerBaseOptions = useMemo(
|
|
||||||
() => ({
|
|
||||||
port: callbackUrlConfig.port,
|
|
||||||
hostname: callbackUrlConfig.hostname,
|
|
||||||
path: callbackUrlConfig.pathname,
|
|
||||||
}),
|
|
||||||
[callbackUrlConfig],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getTuiLogger().info(
|
getTuiLogger().info(
|
||||||
|
|
@ -174,9 +175,22 @@ function App({
|
||||||
>("idle");
|
>("idle");
|
||||||
const [oauthMessage, setOauthMessage] = useState<string | null>(null);
|
const [oauthMessage, setOauthMessage] = useState<string | null>(null);
|
||||||
const [oauthRevision, setOauthRevision] = useState(0);
|
const [oauthRevision, setOauthRevision] = useState(0);
|
||||||
|
const [pendingStepUp, setPendingStepUp] = useState<{
|
||||||
|
serverName: string;
|
||||||
|
challenge: AuthChallenge;
|
||||||
|
authorizationUrl: URL;
|
||||||
|
enterpriseManaged?: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
const pendingStepUpRef = useRef(pendingStepUp);
|
||||||
|
useEffect(() => {
|
||||||
|
pendingStepUpRef.current = pendingStepUp;
|
||||||
|
}, [pendingStepUp]);
|
||||||
const [connectError, setConnectError] = useState<string | null>(null);
|
const [connectError, setConnectError] = useState<string | null>(null);
|
||||||
const oauthInProgressRef = useRef(false);
|
const oauthInProgressRef = useRef(false);
|
||||||
const callbackServerRef = useRef<OAuthCallbackServer | null>(null);
|
const callbackServerRef = useRef<OAuthCallbackServer | null>(null);
|
||||||
|
const selectedServerRef = useRef<string | null>(null);
|
||||||
|
const mcpServersRef = useRef(mcpServers);
|
||||||
|
const inspectorClientsRef = useRef<Record<string, InspectorClient>>({});
|
||||||
|
|
||||||
// Tool test modal state
|
// Tool test modal state
|
||||||
const [toolTestModal, setToolTestModal] = useState<{
|
const [toolTestModal, setToolTestModal] = useState<{
|
||||||
|
|
@ -419,12 +433,22 @@ function App({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Clear OAuth status when switching servers
|
// Clear OAuth status when switching servers; drop step-up for other servers.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOauthStatus("idle");
|
setOauthStatus("idle");
|
||||||
setOauthMessage(null);
|
setOauthMessage(null);
|
||||||
|
const stepUp = pendingStepUpRef.current;
|
||||||
|
if (stepUp && selectedServer && stepUp.serverName !== selectedServer) {
|
||||||
|
setPendingStepUp(null);
|
||||||
|
}
|
||||||
}, [selectedServer]);
|
}, [selectedServer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedServerRef.current = selectedServer;
|
||||||
|
mcpServersRef.current = mcpServers;
|
||||||
|
inspectorClientsRef.current = inspectorClients;
|
||||||
|
}, [selectedServer, mcpServers, inspectorClients]);
|
||||||
|
|
||||||
// Switch away from Auth tab when server is not OAuth-capable
|
// Switch away from Auth tab when server is not OAuth-capable
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
|
|
@ -534,79 +558,203 @@ function App({
|
||||||
selectedManagedPromptsState,
|
selectedManagedPromptsState,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Shared ref for OAuth callback server; stop before starting new (avoids EADDRINUSE when prior auth failed without redirect)
|
// Connect — on 401 or mid-session auth recovery, run OAuth then retry.
|
||||||
|
type TuiOAuthRunResult =
|
||||||
|
| "success"
|
||||||
|
| "already_authorized"
|
||||||
|
| "insufficient_scope"
|
||||||
|
| "skipped"
|
||||||
|
| "unsupported";
|
||||||
|
|
||||||
// Connect — on 401, run OAuth then retry (same pattern as web App.tsx).
|
const runOAuthAuthentication = useCallback(
|
||||||
const runOAuthAuthentication = useCallback(async () => {
|
async (options?: {
|
||||||
if (
|
challenge?: AuthChallenge;
|
||||||
!selectedServer ||
|
authorizationUrl?: URL;
|
||||||
!selectedInspectorClient ||
|
/** When set, run OAuth for this server (may differ from the selected server). */
|
||||||
!selectedServerConfig ||
|
serverName?: string;
|
||||||
!isOAuthCapableServerConfig(selectedServerConfig)
|
}): Promise<TuiOAuthRunResult> => {
|
||||||
) {
|
const serverName = options?.serverName ?? selectedServer;
|
||||||
return;
|
if (!serverName) {
|
||||||
}
|
return "unsupported";
|
||||||
if (oauthInProgressRef.current) return;
|
}
|
||||||
oauthInProgressRef.current = true;
|
const client = inspectorClientsRef.current[serverName];
|
||||||
getTuiLogger().info(
|
const serverEntry = mcpServersRef.current[serverName];
|
||||||
{ server: selectedServer },
|
const serverConfig = serverEntry?.config;
|
||||||
"OAuth authentication started",
|
if (
|
||||||
);
|
!client ||
|
||||||
const existing = callbackServerRef.current;
|
!serverConfig ||
|
||||||
if (existing) {
|
!isOAuthCapableServerConfig(serverConfig)
|
||||||
await existing.stop();
|
) {
|
||||||
callbackServerRef.current = null;
|
return "unsupported";
|
||||||
}
|
}
|
||||||
const callbackServer = createOAuthCallbackServer();
|
if (oauthInProgressRef.current) {
|
||||||
callbackServerRef.current = callbackServer;
|
return "skipped";
|
||||||
let flowResolve: () => void;
|
}
|
||||||
let flowReject: (err: Error) => void;
|
oauthInProgressRef.current = true;
|
||||||
const flowDone = new Promise<void>((resolve, reject) => {
|
getTuiLogger().info(
|
||||||
flowResolve = resolve;
|
{ server: serverName },
|
||||||
flowReject = reject;
|
"OAuth authentication started",
|
||||||
});
|
);
|
||||||
try {
|
const existing = callbackServerRef.current;
|
||||||
const { redirectUrl } = await callbackServer.start({
|
if (existing) {
|
||||||
...callbackServerBaseOptions,
|
await existing.stop();
|
||||||
onCallback: async (params) => {
|
callbackServerRef.current = null;
|
||||||
try {
|
}
|
||||||
await selectedInspectorClient!.completeOAuthFlow(params.code);
|
const redirectUrlProvider = redirectUrlProvidersRef.current[serverName];
|
||||||
flowResolve!();
|
if (!redirectUrlProvider) {
|
||||||
} catch (err) {
|
oauthInProgressRef.current = false;
|
||||||
flowReject!(err instanceof Error ? err : new Error(String(err)));
|
return "unsupported";
|
||||||
} finally {
|
}
|
||||||
callbackServerRef.current = null;
|
try {
|
||||||
}
|
const result = await runRunnerInteractiveOAuth({
|
||||||
},
|
client,
|
||||||
onError: (params) => {
|
redirectUrlProvider,
|
||||||
flowReject!(
|
callbackListen: callbackUrlConfig,
|
||||||
new Error(
|
createCallbackServer: createOAuthCallbackServer,
|
||||||
params.error_description ?? params.error ?? "OAuth error",
|
onCallbackServer: (server) => {
|
||||||
),
|
callbackServerRef.current = server;
|
||||||
);
|
},
|
||||||
void callbackServer.stop();
|
authorizationUrl: options?.authorizationUrl,
|
||||||
callbackServerRef.current = null;
|
authChallenge: options?.challenge,
|
||||||
},
|
});
|
||||||
|
|
||||||
|
if (result.kind === "insufficient_scope") {
|
||||||
|
setOauthStatus("error");
|
||||||
|
setOauthMessage(stepUpInsufficientScopeMessage(result.challenge));
|
||||||
|
return "insufficient_scope";
|
||||||
|
}
|
||||||
|
if (result.kind === "success" || result.kind === "already_authorized") {
|
||||||
|
setOauthRevision((n) => n + 1);
|
||||||
|
return result.kind;
|
||||||
|
}
|
||||||
|
return "unsupported";
|
||||||
|
} finally {
|
||||||
|
oauthInProgressRef.current = false;
|
||||||
|
callbackServerRef.current = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedServer, callbackUrlConfig],
|
||||||
|
);
|
||||||
|
|
||||||
|
const presentStepUpForServer = useCallback(
|
||||||
|
(
|
||||||
|
serverName: string,
|
||||||
|
challenge: AuthChallenge,
|
||||||
|
authorizationUrl: URL,
|
||||||
|
enterpriseManaged?: boolean,
|
||||||
|
) => {
|
||||||
|
const pending = pendingStepUpRef.current;
|
||||||
|
if (pending && pending.serverName !== serverName) {
|
||||||
|
setOauthMessage(
|
||||||
|
"A step-up prompt is already open. Complete or decline it before continuing.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pending?.serverName === serverName) {
|
||||||
|
setOauthMessage(
|
||||||
|
"Updated step-up request — review the scopes on the Auth tab.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setOauthMessage(null);
|
||||||
|
}
|
||||||
|
setSelectedServer(serverName);
|
||||||
|
setPendingStepUp({
|
||||||
|
serverName,
|
||||||
|
challenge,
|
||||||
|
authorizationUrl,
|
||||||
|
enterpriseManaged,
|
||||||
});
|
});
|
||||||
const redirectUrlProvider =
|
setActiveTab("auth");
|
||||||
redirectUrlProvidersRef.current[selectedServer];
|
setOauthStatus("idle");
|
||||||
if (redirectUrlProvider) {
|
setFocus("tabContentList");
|
||||||
redirectUrlProvider.redirectUrl = redirectUrl;
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAuthRecoveryRequired = useCallback(
|
||||||
|
(serverName: string, error: AuthRecoveryRequiredError) => {
|
||||||
|
const serverEntry = mcpServersRef.current[serverName];
|
||||||
|
const settings = serverEntry?.settings;
|
||||||
|
const client = inspectorClientsRef.current[serverName];
|
||||||
|
const needsStepUpConfirm =
|
||||||
|
error.emaStepUpConfirm ||
|
||||||
|
isStepUpConfirmation(error.authChallenge, settings);
|
||||||
|
if (needsStepUpConfirm) {
|
||||||
|
void (async () => {
|
||||||
|
if (
|
||||||
|
client &&
|
||||||
|
(await client.checkAuthChallengeSatisfied(error.authChallenge))
|
||||||
|
) {
|
||||||
|
setOauthStatus("idle");
|
||||||
|
setOauthMessage("Authorization updated. Retry your action.");
|
||||||
|
setOauthRevision((n) => n + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
presentStepUpForServer(
|
||||||
|
serverName,
|
||||||
|
error.authChallenge,
|
||||||
|
error.authorizationUrl,
|
||||||
|
settings?.enterpriseManaged,
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const authUrl = await selectedInspectorClient.authenticate();
|
void (async () => {
|
||||||
if (authUrl !== undefined) {
|
if (
|
||||||
await flowDone;
|
client &&
|
||||||
|
(await client.checkAuthChallengeSatisfied(error.authChallenge))
|
||||||
|
) {
|
||||||
|
setOauthStatus("idle");
|
||||||
|
setOauthMessage("Authorization updated. Retry your action.");
|
||||||
|
setOauthRevision((n) => n + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const needsSwitch = selectedServerRef.current !== serverName;
|
||||||
|
if (needsSwitch) {
|
||||||
|
setSelectedServer(serverName);
|
||||||
|
setActiveTab("auth");
|
||||||
|
setOauthMessage(
|
||||||
|
`Authentication required for "${serverName}". Re-authenticating…`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setOauthMessage(null);
|
||||||
|
}
|
||||||
|
setOauthStatus("authenticating");
|
||||||
|
try {
|
||||||
|
const oauthResult = await runOAuthAuthentication({
|
||||||
|
challenge: error.authChallenge,
|
||||||
|
authorizationUrl: error.authorizationUrl,
|
||||||
|
serverName,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
oauthResult === "success" ||
|
||||||
|
oauthResult === "already_authorized"
|
||||||
|
) {
|
||||||
|
setOauthStatus("idle");
|
||||||
|
setOauthMessage("Authorization updated. Retry your action.");
|
||||||
|
} else if (oauthResult === "skipped") {
|
||||||
|
setOauthStatus("idle");
|
||||||
|
setOauthMessage("OAuth already in progress.");
|
||||||
|
}
|
||||||
|
} catch (authErr) {
|
||||||
|
const authMsg =
|
||||||
|
authErr instanceof Error ? authErr.message : String(authErr);
|
||||||
|
setOauthStatus("error");
|
||||||
|
setOauthMessage(authMsg);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
[presentStepUpForServer, runOAuthAuthentication],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onAuthRecoveryRequired = useCallback(
|
||||||
|
(error: AuthRecoveryRequiredError) => {
|
||||||
|
if (selectedServer) {
|
||||||
|
handleAuthRecoveryRequired(selectedServer, error);
|
||||||
}
|
}
|
||||||
setOauthRevision((n) => n + 1);
|
},
|
||||||
} finally {
|
[selectedServer, handleAuthRecoveryRequired],
|
||||||
oauthInProgressRef.current = false;
|
);
|
||||||
}
|
|
||||||
}, [
|
|
||||||
selectedServer,
|
|
||||||
selectedInspectorClient,
|
|
||||||
selectedServerConfig,
|
|
||||||
callbackServerBaseOptions,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleConnect = useCallback(async () => {
|
const handleConnect = useCallback(async () => {
|
||||||
if (!selectedServer || !selectedInspectorClient || !selectedServerConfig) {
|
if (!selectedServer || !selectedInspectorClient || !selectedServerConfig) {
|
||||||
|
|
@ -618,6 +766,7 @@ function App({
|
||||||
setConnectError(null);
|
setConnectError(null);
|
||||||
setOauthStatus("idle");
|
setOauthStatus("idle");
|
||||||
setOauthMessage(null);
|
setOauthMessage(null);
|
||||||
|
setOauthRevision((n) => n + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -639,13 +788,22 @@ function App({
|
||||||
try {
|
try {
|
||||||
setOauthStatus("authenticating");
|
setOauthStatus("authenticating");
|
||||||
setOauthMessage(null);
|
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 disconnectInspector();
|
||||||
await runOAuthAuthentication();
|
const oauthResult = await runOAuthAuthentication();
|
||||||
await finishConnect();
|
if (
|
||||||
|
oauthResult === "success" ||
|
||||||
|
oauthResult === "already_authorized"
|
||||||
|
) {
|
||||||
|
await finishConnect();
|
||||||
|
} else if (oauthResult === "skipped") {
|
||||||
|
setOauthStatus("idle");
|
||||||
|
setOauthMessage("OAuth already in progress.");
|
||||||
|
}
|
||||||
} catch (authErr) {
|
} catch (authErr) {
|
||||||
|
if (authErr instanceof AuthRecoveryRequiredError) {
|
||||||
|
handleAuthRecoveryRequired(selectedServer, authErr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const authMsg =
|
const authMsg =
|
||||||
authErr instanceof Error ? authErr.message : String(authErr);
|
authErr instanceof Error ? authErr.message : String(authErr);
|
||||||
setConnectError(authMsg);
|
setConnectError(authMsg);
|
||||||
|
|
@ -659,6 +817,11 @@ function App({
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (err instanceof AuthRecoveryRequiredError && selectedServer) {
|
||||||
|
handleAuthRecoveryRequired(selectedServer, err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
selectedServer,
|
selectedServer,
|
||||||
|
|
@ -667,8 +830,63 @@ function App({
|
||||||
connectInspector,
|
connectInspector,
|
||||||
disconnectInspector,
|
disconnectInspector,
|
||||||
runOAuthAuthentication,
|
runOAuthAuthentication,
|
||||||
|
handleAuthRecoveryRequired,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cleanups: Array<() => void> = [];
|
||||||
|
|
||||||
|
for (const [serverName, client] of Object.entries(inspectorClients)) {
|
||||||
|
const onAmbient = (): void => {
|
||||||
|
if (selectedServerRef.current !== serverName) return;
|
||||||
|
setOauthStatus("idle");
|
||||||
|
setOauthMessage("Refreshing authorization…");
|
||||||
|
};
|
||||||
|
const onRecovered = (): void => {
|
||||||
|
if (selectedServerRef.current !== serverName) return;
|
||||||
|
setOauthMessage(null);
|
||||||
|
setOauthRevision((n) => n + 1);
|
||||||
|
};
|
||||||
|
const onInteractive = (
|
||||||
|
event: TypedEvent<"authChallengeInteractive">,
|
||||||
|
): void => {
|
||||||
|
handleAuthRecoveryRequired(
|
||||||
|
serverName,
|
||||||
|
new AuthRecoveryRequiredError(
|
||||||
|
event.detail.authorizationUrl,
|
||||||
|
event.detail.challenge,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const onOAuthError = (event: TypedEvent<"oauthError">): void => {
|
||||||
|
if (selectedServerRef.current !== serverName) return;
|
||||||
|
const message =
|
||||||
|
event.detail.error instanceof Error
|
||||||
|
? event.detail.error.message
|
||||||
|
: String(event.detail.error);
|
||||||
|
setOauthStatus("error");
|
||||||
|
setOauthMessage(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
client.addEventListener("authChallengeAmbient", onAmbient);
|
||||||
|
client.addEventListener("authChallengeRecovered", onRecovered);
|
||||||
|
client.addEventListener("authChallengeInteractive", onInteractive);
|
||||||
|
client.addEventListener("oauthError", onOAuthError);
|
||||||
|
cleanups.push(() => {
|
||||||
|
client.removeEventListener("authChallengeAmbient", onAmbient);
|
||||||
|
client.removeEventListener("authChallengeRecovered", onRecovered);
|
||||||
|
client.removeEventListener("authChallengeInteractive", onInteractive);
|
||||||
|
client.removeEventListener("oauthError", onOAuthError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const cleanup of cleanups) {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [inspectorClients, handleAuthRecoveryRequired]);
|
||||||
|
|
||||||
// Disconnect handler
|
// Disconnect handler
|
||||||
const handleDisconnect = useCallback(async () => {
|
const handleDisconnect = useCallback(async () => {
|
||||||
if (!selectedServer) return;
|
if (!selectedServer) return;
|
||||||
|
|
@ -1108,15 +1326,15 @@ function App({
|
||||||
);
|
);
|
||||||
if (tabAccelerators[input.toLowerCase()]) {
|
if (tabAccelerators[input.toLowerCase()]) {
|
||||||
const nextTab = tabAccelerators[input.toLowerCase()]!;
|
const nextTab = tabAccelerators[input.toLowerCase()]!;
|
||||||
setActiveTab(nextTab);
|
const authStepUpAccelerator =
|
||||||
setFocus(nextTab === "auth" ? "tabContentList" : "tabs");
|
input.toLowerCase() === "a" &&
|
||||||
} else if (
|
nextTab === "auth" &&
|
||||||
activeTab === "auth" &&
|
activeTab === "auth" &&
|
||||||
showAuthTab &&
|
pendingStepUp?.serverName === selectedServer;
|
||||||
input.toLowerCase() === "s"
|
if (!authStepUpAccelerator) {
|
||||||
) {
|
setActiveTab(nextTab);
|
||||||
void handleClearOAuth();
|
setFocus(nextTab === "auth" ? "tabContentList" : "tabs");
|
||||||
setFocus("tabContentList");
|
}
|
||||||
} else if (key.tab && !key.shift) {
|
} else if (key.tab && !key.shift) {
|
||||||
// Flat focus order: servers -> tabs -> list -> details -> wrap to servers
|
// Flat focus order: servers -> tabs -> list -> details -> wrap to servers
|
||||||
const focusOrder: FocusArea[] =
|
const focusOrder: FocusArea[] =
|
||||||
|
|
@ -1470,6 +1688,102 @@ function App({
|
||||||
oauthStatus={oauthStatus}
|
oauthStatus={oauthStatus}
|
||||||
oauthMessage={oauthMessage}
|
oauthMessage={oauthMessage}
|
||||||
oauthRevision={oauthRevision}
|
oauthRevision={oauthRevision}
|
||||||
|
pendingStepUp={
|
||||||
|
pendingStepUp?.serverName === selectedServer
|
||||||
|
? {
|
||||||
|
challenge: pendingStepUp.challenge,
|
||||||
|
authorizationScopes:
|
||||||
|
pendingStepUp.challenge.authorizationScopes,
|
||||||
|
enterpriseManaged: pendingStepUp.enterpriseManaged,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onAuthorizeStepUp={() => {
|
||||||
|
if (!pendingStepUp || !selectedInspectorClient) return;
|
||||||
|
const { challenge, authorizationUrl, enterpriseManaged } =
|
||||||
|
pendingStepUp;
|
||||||
|
setPendingStepUp(null);
|
||||||
|
setOauthStatus("authenticating");
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
if (enterpriseManaged) {
|
||||||
|
const outcome =
|
||||||
|
await selectedInspectorClient.handleAuthChallenge(
|
||||||
|
challenge,
|
||||||
|
{ confirmedStepUp: true },
|
||||||
|
);
|
||||||
|
if (outcome.kind === "satisfied") {
|
||||||
|
await disconnectInspector().catch(() => {});
|
||||||
|
await connectInspector();
|
||||||
|
setOauthStatus("idle");
|
||||||
|
setOauthMessage(
|
||||||
|
"Step-up authorization succeeded. Retry your action.",
|
||||||
|
);
|
||||||
|
setOauthRevision((n) => n + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (outcome.kind === "interactive") {
|
||||||
|
const oauthResult = await runOAuthAuthentication({
|
||||||
|
challenge: outcome.challenge,
|
||||||
|
authorizationUrl: outcome.authorizationUrl,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
oauthResult === "success" ||
|
||||||
|
oauthResult === "already_authorized"
|
||||||
|
) {
|
||||||
|
setOauthStatus("idle");
|
||||||
|
setOauthMessage(
|
||||||
|
"Step-up authorization succeeded. Retry your action.",
|
||||||
|
);
|
||||||
|
} else if (oauthResult === "skipped") {
|
||||||
|
setOauthStatus("idle");
|
||||||
|
setOauthMessage("OAuth already in progress.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (outcome.kind === "failed") {
|
||||||
|
setOauthStatus("error");
|
||||||
|
setOauthMessage(
|
||||||
|
emaStepUpFailureMessage(outcome.error.message),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOauthStatus("idle");
|
||||||
|
setOauthMessage(
|
||||||
|
"Step-up authorization succeeded. Retry your action.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const oauthResult = await runOAuthAuthentication({
|
||||||
|
challenge,
|
||||||
|
authorizationUrl,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
oauthResult === "success" ||
|
||||||
|
oauthResult === "already_authorized"
|
||||||
|
) {
|
||||||
|
setOauthStatus("idle");
|
||||||
|
setOauthMessage(
|
||||||
|
"Step-up authorization succeeded. Retry your action.",
|
||||||
|
);
|
||||||
|
} else if (oauthResult === "skipped") {
|
||||||
|
setOauthStatus("idle");
|
||||||
|
setOauthMessage("OAuth already in progress.");
|
||||||
|
}
|
||||||
|
} catch (authErr) {
|
||||||
|
const authMsg =
|
||||||
|
authErr instanceof Error
|
||||||
|
? authErr.message
|
||||||
|
: String(authErr);
|
||||||
|
setOauthStatus("error");
|
||||||
|
setOauthMessage(authMsg);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
onCancelStepUp={() => {
|
||||||
|
setPendingStepUp(null);
|
||||||
|
setOauthMessage("Authorization cancelled.");
|
||||||
|
}}
|
||||||
width={contentWidth}
|
width={contentWidth}
|
||||||
height={contentHeight}
|
height={contentHeight}
|
||||||
focused={
|
focused={
|
||||||
|
|
@ -1517,8 +1831,14 @@ function App({
|
||||||
inspectorClient: selectedInspectorClient,
|
inspectorClient: selectedInspectorClient,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onAuthRecoveryRequired={onAuthRecoveryRequired}
|
||||||
modalOpen={
|
modalOpen={
|
||||||
!!(toolTestModal || resourceTestModal || detailsModal)
|
!!(
|
||||||
|
toolTestModal ||
|
||||||
|
resourceTestModal ||
|
||||||
|
promptTestModal ||
|
||||||
|
detailsModal
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : activeTab === "prompts" &&
|
) : activeTab === "prompts" &&
|
||||||
|
|
@ -1552,6 +1872,7 @@ function App({
|
||||||
inspectorClient: selectedInspectorClient,
|
inspectorClient: selectedInspectorClient,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onAuthRecoveryRequired={onAuthRecoveryRequired}
|
||||||
modalOpen={
|
modalOpen={
|
||||||
!!(
|
!!(
|
||||||
toolTestModal ||
|
toolTestModal ||
|
||||||
|
|
@ -1680,6 +2001,10 @@ function App({
|
||||||
width={dimensions.width}
|
width={dimensions.width}
|
||||||
height={dimensions.height}
|
height={dimensions.height}
|
||||||
onClose={() => setToolTestModal(null)}
|
onClose={() => setToolTestModal(null)}
|
||||||
|
onAuthRecoveryRequired={(error) => {
|
||||||
|
onAuthRecoveryRequired(error);
|
||||||
|
setToolTestModal(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -1691,6 +2016,10 @@ function App({
|
||||||
width={dimensions.width}
|
width={dimensions.width}
|
||||||
height={dimensions.height}
|
height={dimensions.height}
|
||||||
onClose={() => setResourceTestModal(null)}
|
onClose={() => setResourceTestModal(null)}
|
||||||
|
onAuthRecoveryRequired={(error) => {
|
||||||
|
onAuthRecoveryRequired(error);
|
||||||
|
setResourceTestModal(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -1701,6 +2030,10 @@ function App({
|
||||||
width={dimensions.width}
|
width={dimensions.width}
|
||||||
height={dimensions.height}
|
height={dimensions.height}
|
||||||
onClose={() => setPromptTestModal(null)}
|
onClose={() => setPromptTestModal(null)}
|
||||||
|
onAuthRecoveryRequired={(error) => {
|
||||||
|
onAuthRecoveryRequired(error);
|
||||||
|
setPromptTestModal(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,19 @@ import type {
|
||||||
ConnectionStatus,
|
ConnectionStatus,
|
||||||
} from "@inspector/core/mcp/index.js";
|
} from "@inspector/core/mcp/index.js";
|
||||||
import type { OAuthConnectionState } from "@inspector/core/auth/types.js";
|
import type { OAuthConnectionState } from "@inspector/core/auth/types.js";
|
||||||
|
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
|
||||||
import {
|
import {
|
||||||
formatAuthProtocol,
|
formatAuthProtocol,
|
||||||
formatClientRegistrationKind,
|
formatClientRegistrationKind,
|
||||||
formatIdpSession,
|
formatIdpSession,
|
||||||
formatScopes,
|
formatScopes,
|
||||||
} from "../utils/oauthDisplay.js";
|
} from "../utils/oauthDisplay.js";
|
||||||
|
import {
|
||||||
|
stepUpAuthorizeActionLabel,
|
||||||
|
stepUpConfirmMessage,
|
||||||
|
stepUpFollowUpMessage,
|
||||||
|
stepUpModalTitle,
|
||||||
|
} from "../utils/tuiOAuth.js";
|
||||||
|
|
||||||
interface AuthTabProps {
|
interface AuthTabProps {
|
||||||
serverName: string | null;
|
serverName: string | null;
|
||||||
|
|
@ -22,6 +29,13 @@ interface AuthTabProps {
|
||||||
oauthStatus: "idle" | "authenticating" | "error";
|
oauthStatus: "idle" | "authenticating" | "error";
|
||||||
oauthMessage: string | null;
|
oauthMessage: string | null;
|
||||||
oauthRevision: number;
|
oauthRevision: number;
|
||||||
|
pendingStepUp?: {
|
||||||
|
challenge: AuthChallenge;
|
||||||
|
authorizationScopes?: string[];
|
||||||
|
enterpriseManaged?: boolean;
|
||||||
|
} | null;
|
||||||
|
onAuthorizeStepUp?: () => void;
|
||||||
|
onCancelStepUp?: () => void;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
focused?: boolean;
|
focused?: boolean;
|
||||||
|
|
@ -44,6 +58,9 @@ export function AuthTab({
|
||||||
oauthStatus,
|
oauthStatus,
|
||||||
oauthMessage,
|
oauthMessage,
|
||||||
oauthRevision,
|
oauthRevision,
|
||||||
|
pendingStepUp,
|
||||||
|
onAuthorizeStepUp,
|
||||||
|
onCancelStepUp,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
focused = false,
|
focused = false,
|
||||||
|
|
@ -58,6 +75,7 @@ export function AuthTab({
|
||||||
>(undefined);
|
>(undefined);
|
||||||
const [clearedConfirmation, setClearedConfirmation] = useState(false);
|
const [clearedConfirmation, setClearedConfirmation] = useState(false);
|
||||||
const [lastClearDisconnected, setLastClearDisconnected] = useState(false);
|
const [lastClearDisconnected, setLastClearDisconnected] = useState(false);
|
||||||
|
const [stepUpChoiceIndex, setStepUpChoiceIndex] = useState(0);
|
||||||
|
|
||||||
const refreshOAuthState = useCallback(async () => {
|
const refreshOAuthState = useCallback(async () => {
|
||||||
if (!inspectorClient) {
|
if (!inspectorClient) {
|
||||||
|
|
@ -70,13 +88,17 @@ export function AuthTab({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshOAuthState();
|
void refreshOAuthState();
|
||||||
}, [refreshOAuthState, oauthRevision]);
|
}, [refreshOAuthState, oauthRevision, connectionStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setClearedConfirmation(false);
|
setClearedConfirmation(false);
|
||||||
setLastClearDisconnected(false);
|
setLastClearDisconnected(false);
|
||||||
}, [oauthRevision]);
|
}, [oauthRevision]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setStepUpChoiceIndex(0);
|
||||||
|
}, [pendingStepUp]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!inspectorClient) return;
|
if (!inspectorClient) return;
|
||||||
|
|
||||||
|
|
@ -93,6 +115,34 @@ export function AuthTab({
|
||||||
(input: string, key: Key) => {
|
(input: string, key: Key) => {
|
||||||
if (!focused) return;
|
if (!focused) return;
|
||||||
|
|
||||||
|
if (pendingStepUp) {
|
||||||
|
if (key.upArrow) {
|
||||||
|
setStepUpChoiceIndex((i) => Math.max(0, i - 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.downArrow) {
|
||||||
|
setStepUpChoiceIndex((i) => Math.min(1, i + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.return) {
|
||||||
|
if (stepUpChoiceIndex === 0) {
|
||||||
|
onAuthorizeStepUp?.();
|
||||||
|
} else {
|
||||||
|
onCancelStepUp?.();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (input.toLowerCase() === "a") {
|
||||||
|
onAuthorizeStepUp?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (input.toLowerCase() === "c") {
|
||||||
|
onCancelStepUp?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (key.upArrow && scrollViewRef.current) {
|
if (key.upArrow && scrollViewRef.current) {
|
||||||
scrollViewRef.current.scrollBy(-1);
|
scrollViewRef.current.scrollBy(-1);
|
||||||
} else if (key.downArrow && scrollViewRef.current) {
|
} else if (key.downArrow && scrollViewRef.current) {
|
||||||
|
|
@ -139,6 +189,66 @@ export function AuthTab({
|
||||||
{oauthStatus === "error" && oauthMessage && (
|
{oauthStatus === "error" && oauthMessage && (
|
||||||
<Text color="red">{oauthMessage}</Text>
|
<Text color="red">{oauthMessage}</Text>
|
||||||
)}
|
)}
|
||||||
|
{oauthStatus === "idle" && oauthMessage && (
|
||||||
|
<Text color="cyan">{oauthMessage}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pendingStepUp ? (
|
||||||
|
<Box marginTop={1} flexDirection="column" gap={0}>
|
||||||
|
<Text bold color="yellow">
|
||||||
|
{stepUpModalTitle({
|
||||||
|
enterpriseManaged: pendingStepUp.enterpriseManaged,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
{stepUpConfirmMessage(pendingStepUp.challenge, {
|
||||||
|
enterpriseManaged: pendingStepUp.enterpriseManaged,
|
||||||
|
})}{" "}
|
||||||
|
{stepUpFollowUpMessage({
|
||||||
|
enterpriseManaged: pendingStepUp.enterpriseManaged,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
{(() => {
|
||||||
|
const scopes =
|
||||||
|
pendingStepUp.authorizationScopes ??
|
||||||
|
pendingStepUp.challenge.requiredScopes;
|
||||||
|
if (!scopes?.length) return null;
|
||||||
|
return (
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text dimColor>
|
||||||
|
{pendingStepUp.enterpriseManaged
|
||||||
|
? "Permissions requested:"
|
||||||
|
: "Scopes requested:"}
|
||||||
|
</Text>
|
||||||
|
{scopes.map((scope) => (
|
||||||
|
<Text key={scope} dimColor>
|
||||||
|
{" "}
|
||||||
|
• {scope}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<SelectableItem isSelected={stepUpChoiceIndex === 0} bold>
|
||||||
|
{(() => {
|
||||||
|
const label = stepUpAuthorizeActionLabel({
|
||||||
|
enterpriseManaged: pendingStepUp.enterpriseManaged,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text underline>{label[0]}</Text>
|
||||||
|
{label.slice(1)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</SelectableItem>
|
||||||
|
<SelectableItem isSelected={stepUpChoiceIndex === 1}>
|
||||||
|
<Text underline>C</Text>ancel
|
||||||
|
</SelectableItem>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{oauthState ? (
|
{oauthState ? (
|
||||||
<Box flexDirection="column" marginTop={1} gap={0}>
|
<Box flexDirection="column" marginTop={1} gap={0}>
|
||||||
|
|
@ -229,7 +339,9 @@ export function AuthTab({
|
||||||
backgroundColor="gray"
|
backgroundColor="gray"
|
||||||
>
|
>
|
||||||
<Text bold color="white">
|
<Text bold color="white">
|
||||||
S {isLiveConnection ? "clear+disconnect" : "clear"}, ↑/↓ scroll
|
{pendingStepUp
|
||||||
|
? "↑/↓ select, Enter confirm, A authorize, C cancel"
|
||||||
|
: `S ${isLiveConnection ? "clear+disconnect" : "clear"}, ↑/↓ scroll`}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useState } from "react";
|
||||||
import { Box, Text, useInput, type Key } from "ink";
|
import { Box, Text, useInput, type Key } from "ink";
|
||||||
import { Form } from "ink-form";
|
import { Form } from "ink-form";
|
||||||
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||||
|
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||||
import type {
|
import type {
|
||||||
Prompt,
|
Prompt,
|
||||||
GetPromptResult,
|
GetPromptResult,
|
||||||
|
|
@ -29,6 +30,7 @@ interface PromptTestModalProps {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModalState = "form" | "loading" | "results";
|
type ModalState = "form" | "loading" | "results";
|
||||||
|
|
@ -47,6 +49,7 @@ export function PromptTestModal({
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
onClose,
|
onClose,
|
||||||
|
onAuthRecoveryRequired,
|
||||||
}: PromptTestModalProps) {
|
}: PromptTestModalProps) {
|
||||||
const [state, setState] = useState<ModalState>("form");
|
const [state, setState] = useState<ModalState>("form");
|
||||||
const [result, setResult] = useState<PromptResult | null>(null);
|
const [result, setResult] = useState<PromptResult | null>(null);
|
||||||
|
|
@ -144,6 +147,11 @@ export function PromptTestModal({
|
||||||
});
|
});
|
||||||
setState("results");
|
setState("results");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof AuthRecoveryRequiredError) {
|
||||||
|
onAuthRecoveryRequired?.(error);
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
const errorMessage = getErrorMessage(error);
|
const errorMessage = getErrorMessage(error);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from "react";
|
||||||
import { Box, Text, useInput, type Key } from "ink";
|
import { Box, Text, useInput, type Key } from "ink";
|
||||||
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
|
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
|
||||||
import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||||
|
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||||
import type {
|
import type {
|
||||||
Prompt,
|
Prompt,
|
||||||
PromptArgument,
|
PromptArgument,
|
||||||
|
|
@ -18,6 +19,7 @@ interface PromptsTabProps {
|
||||||
focusedPane?: "list" | "details" | null;
|
focusedPane?: "list" | "details" | null;
|
||||||
onViewDetails?: (prompt: Prompt & { result?: GetPromptResult }) => void;
|
onViewDetails?: (prompt: Prompt & { result?: GetPromptResult }) => void;
|
||||||
onFetchPrompt?: (prompt: Prompt) => void;
|
onFetchPrompt?: (prompt: Prompt) => void;
|
||||||
|
onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void;
|
||||||
modalOpen?: boolean;
|
modalOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,6 +31,7 @@ export function PromptsTab({
|
||||||
focusedPane = null,
|
focusedPane = null,
|
||||||
onViewDetails,
|
onViewDetails,
|
||||||
onFetchPrompt,
|
onFetchPrompt,
|
||||||
|
onAuthRecoveryRequired,
|
||||||
modalOpen = false,
|
modalOpen = false,
|
||||||
}: PromptsTabProps) {
|
}: PromptsTabProps) {
|
||||||
const visibleCount = Math.max(1, height - 7);
|
const visibleCount = Math.max(1, height - 7);
|
||||||
|
|
@ -64,6 +67,10 @@ export function PromptsTab({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof AuthRecoveryRequiredError) {
|
||||||
|
onAuthRecoveryRequired?.(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(
|
setError(
|
||||||
error instanceof Error ? error.message : "Failed to get prompt",
|
error instanceof Error ? error.message : "Failed to get prompt",
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useState } from "react";
|
||||||
import { Box, Text, useInput, type Key } from "ink";
|
import { Box, Text, useInput, type Key } from "ink";
|
||||||
import { Form } from "ink-form";
|
import { Form } from "ink-form";
|
||||||
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||||
|
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||||
import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
|
import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { uriTemplateToForm } from "../utils/uriTemplateToForm.js";
|
import { uriTemplateToForm } from "../utils/uriTemplateToForm.js";
|
||||||
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
|
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
|
||||||
|
|
@ -30,6 +31,7 @@ interface ResourceTestModalProps {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModalState = "form" | "loading" | "results";
|
type ModalState = "form" | "loading" | "results";
|
||||||
|
|
@ -49,6 +51,7 @@ export function ResourceTestModal({
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
onClose,
|
onClose,
|
||||||
|
onAuthRecoveryRequired,
|
||||||
}: ResourceTestModalProps) {
|
}: ResourceTestModalProps) {
|
||||||
const [state, setState] = useState<ModalState>("form");
|
const [state, setState] = useState<ModalState>("form");
|
||||||
const [result, setResult] = useState<ResourceResult | null>(null);
|
const [result, setResult] = useState<ResourceResult | null>(null);
|
||||||
|
|
@ -150,6 +153,11 @@ export function ResourceTestModal({
|
||||||
});
|
});
|
||||||
setState("results");
|
setState("results");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof AuthRecoveryRequiredError) {
|
||||||
|
onAuthRecoveryRequired?.(error);
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
const errorMessage = getErrorMessage(error);
|
const errorMessage = getErrorMessage(error);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import { Box, Text, useInput, type Key } from "ink";
|
import { Box, Text, useInput, type Key } from "ink";
|
||||||
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
|
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
|
||||||
import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||||
|
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||||
import type {
|
import type {
|
||||||
Resource,
|
Resource,
|
||||||
ReadResourceResult,
|
ReadResourceResult,
|
||||||
|
|
@ -27,6 +28,7 @@ interface ResourcesTabProps {
|
||||||
) => void;
|
) => void;
|
||||||
onFetchResource?: (resource: Resource) => void;
|
onFetchResource?: (resource: Resource) => void;
|
||||||
onFetchTemplate?: (template: ResourceTemplate) => void;
|
onFetchTemplate?: (template: ResourceTemplate) => void;
|
||||||
|
onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void;
|
||||||
modalOpen?: boolean;
|
modalOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,6 +43,7 @@ export function ResourcesTab({
|
||||||
onViewDetails,
|
onViewDetails,
|
||||||
onFetchResource,
|
onFetchResource,
|
||||||
onFetchTemplate,
|
onFetchTemplate,
|
||||||
|
onAuthRecoveryRequired,
|
||||||
modalOpen = false,
|
modalOpen = false,
|
||||||
}: ResourcesTabProps) {
|
}: ResourcesTabProps) {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -169,6 +172,10 @@ export function ResourcesTab({
|
||||||
await inspectorClient.readResource(shouldFetchResource);
|
await inspectorClient.readResource(shouldFetchResource);
|
||||||
setResourceContent(invocation.result);
|
setResourceContent(invocation.result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof AuthRecoveryRequiredError) {
|
||||||
|
onAuthRecoveryRequired?.(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(
|
setError(
|
||||||
err instanceof Error ? err.message : "Failed to read resource",
|
err instanceof Error ? err.message : "Failed to read resource",
|
||||||
);
|
);
|
||||||
|
|
@ -180,7 +187,7 @@ export function ResourcesTab({
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchContent();
|
fetchContent();
|
||||||
}, [shouldFetchResource, inspectorClient]);
|
}, [shouldFetchResource, inspectorClient, onAuthRecoveryRequired]);
|
||||||
|
|
||||||
const listWidth = Math.floor(width * 0.4);
|
const listWidth = Math.floor(width * 0.4);
|
||||||
const detailWidth = width - listWidth;
|
const detailWidth = width - listWidth;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useState } from "react";
|
||||||
import { Box, Text, useInput, type Key } from "ink";
|
import { Box, Text, useInput, type Key } from "ink";
|
||||||
import { Form } from "ink-form";
|
import { Form } from "ink-form";
|
||||||
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||||
|
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||||
import type { Tool, CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
import type { Tool, CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||||
import type { JsonValue } from "@inspector/core/mcp/index.js";
|
import type { JsonValue } from "@inspector/core/mcp/index.js";
|
||||||
import { schemaToForm } from "../utils/schemaToForm.js";
|
import { schemaToForm } from "../utils/schemaToForm.js";
|
||||||
|
|
@ -13,6 +14,7 @@ interface ToolTestModalProps {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModalState = "form" | "loading" | "results";
|
type ModalState = "form" | "loading" | "results";
|
||||||
|
|
@ -31,6 +33,7 @@ export function ToolTestModal({
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
onClose,
|
onClose,
|
||||||
|
onAuthRecoveryRequired,
|
||||||
}: ToolTestModalProps) {
|
}: ToolTestModalProps) {
|
||||||
const [state, setState] = useState<ModalState>("form");
|
const [state, setState] = useState<ModalState>("form");
|
||||||
const [result, setResult] = useState<ToolResult | null>(null);
|
const [result, setResult] = useState<ToolResult | null>(null);
|
||||||
|
|
@ -150,6 +153,11 @@ export function ToolTestModal({
|
||||||
}
|
}
|
||||||
setState("results");
|
setState("results");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof AuthRecoveryRequiredError) {
|
||||||
|
onAuthRecoveryRequired?.(error);
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
const errorObj =
|
const errorObj =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
|
|
|
||||||
36
clients/tui/src/utils/tuiOAuth.ts
Normal file
36
clients/tui/src/utils/tuiOAuth.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
|
||||||
|
import {
|
||||||
|
isStandardOAuthStepUp as isCoreStandardOAuthStepUp,
|
||||||
|
isStepUpConfirmation as isCoreStepUpConfirmation,
|
||||||
|
stepUpConfirmMessage,
|
||||||
|
stepUpInsufficientScopeMessage,
|
||||||
|
} from "@inspector/core/auth/oauthUx.js";
|
||||||
|
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
|
||||||
|
|
||||||
|
export { stepUpConfirmMessage, stepUpInsufficientScopeMessage };
|
||||||
|
|
||||||
|
export {
|
||||||
|
stepUpAuthorizeActionLabel,
|
||||||
|
stepUpFollowUpMessage,
|
||||||
|
stepUpModalTitle,
|
||||||
|
} from "@inspector/core/auth/oauthUx.js";
|
||||||
|
|
||||||
|
/** Standard-OAuth step-up (not EMA silent re-mint). */
|
||||||
|
export function isStandardOAuthStepUp(
|
||||||
|
challenge: AuthChallenge,
|
||||||
|
settings?: InspectorServerSettings,
|
||||||
|
): boolean {
|
||||||
|
return isCoreStandardOAuthStepUp(challenge, {
|
||||||
|
enterpriseManaged: settings?.enterpriseManaged,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Standard or EMA step-up that requires in-app confirmation before OAuth. */
|
||||||
|
export function isStepUpConfirmation(
|
||||||
|
challenge: AuthChallenge,
|
||||||
|
settings?: InspectorServerSettings,
|
||||||
|
): boolean {
|
||||||
|
return isCoreStepUpConfirmation(challenge, {
|
||||||
|
enterpriseManaged: settings?.enterpriseManaged,
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue