CLI: catalog listing, auth flags, and OAuth browser UX (#1782)
* feat(cli): catalog listing, auth flags, and OAuth browser UX Add servers/list and servers/show, --relogin / --stored-auth-only, browser OAuth navigation with OSC 8 links, and extract method handlers under clients/cli/src/handlers. Tracks #1781. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): address #1782 review — relogin clear, stored-auth-only, docs Clear both raw and normalized OAuth keys on --relogin; check the shared store before bailing under --stored-auth-only mid-session; restore the tests README link target; document servers/show redaction limits; keep mcpi groundwork with TODO(#1432) markers. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): gate OAuth browser open and harden #1782 review follow-ups Arm auto-open only for CLI-owned interactive OAuth, honor MCP_AUTO_OPEN_ENABLED, move --relogin clear after validation, skip keychain on servers/list, and cover secret redaction after rehydrate. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): silence disarmed OAuth URLs and round-3 review nits Suppress authorize URL print when navigation is disarmed or --stored-auth-only; document MCP_AUTO_OPEN_ENABLED; redact Cookie/auth headers; keep servers/show unit tests off the OS keychain. Co-authored-by: Cursor <cursoragent@cursor.com> * test(cli): cover withArmedAutoOpen for the ≥90 branch gate CI failed on cliOAuth.ts branch coverage after the auto-open arming helper landed; exercise the armed path through runCliInteractiveOAuth. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): mid-RPC unauthorized recovery and clear round-4 nits Mirror connect's UnauthorizedError path in withCliAuthRecoveryRetry, let MCP_AUTO_OPEN_ENABLED=true force open off-TTY, mock open-url in the in-process runner, reject --relogin on catalog methods, restore the __tests__ README inventory, and narrow OAuth helpers to structural client types. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): reconnect after mid-RPC OAuth and finish round-5 nits Reconnect before retrying after unauthorized recovery, guard non-OAuth configs, decouple forceAutoOpen, move open-url mock to vitest setupFiles, and reject --relogin with stored-auth short-circuits. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): fail fast on non-TTY interactive OAuth and round-6 nits Skip interactive OAuth without a TTY unless MCP_AUTO_OPEN_ENABLED=true; document --stored-auth-only for CI; redact settings.metadata; reject --relogin for stdio; clarify AuthRecoveryRequired reconnect comments. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): admit interactive OAuth when stdin is a TTY (round-7) Gate on stdin||stderr so `2>&1 | tee` still works; fold confirm/auto-open/ TTY into CliOAuthConnectOptions; rename authRequiredFailure; document MCP_AUTO_OPEN_ENABLED=true as non-TTY admit. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): fail fast on step-up without stdin (round-8) Decline [y/N] on readline EOF/close, require a stdin TTY for the default step-up confirmer (force-open admit is not enough), document stderr-only browser auto-open vs stdin||stderr flow admit, and drop the alias wrapper. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): allow piped step-up answers; bound silent pipes (round-9) Drop the hard stdin-TTY gate that broke `echo y | …`. Keep EOF decline and add a short non-TTY timeout so an open pipe that never writes cannot hang. Clarify isTTY as admit-only; extract the readline mock factory. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): keep partial piped step-up answers (round-10) Capture readline `line` before close so `printf y` without a newline is not treated as decline; timeout is auth_required with a distinct message; document the 5s bound and post-confirm callback wait; pin the TTY no-timeout arm. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): tighten step-up timeout messaging and TTY test (round-11) Make the TTY no-timeout assertion use a settled flag (the Promise.race check was vacuous), say "timed out" instead of "declined" on the pipe bound, and document that piped answers need a newline or stdin close. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a7b2fdb2ef
commit
33e88eac9f
37 changed files with 4041 additions and 520 deletions
|
|
@ -1,16 +1,16 @@
|
|||
# MCP Inspector CLI Client
|
||||
|
||||
The CLI mode enables programmatic interaction with MCP servers from the command line. It is ideal for scripting, automation, continuous integration, and establishing an efficient feedback loop with AI coding assistants.
|
||||
CLI for the Inspector: connect, run a `--method`, disconnect. Invoked as `mcp-inspector --cli`.
|
||||
|
||||
## Running the CLI
|
||||
|
||||
You can run the CLI client directly via `npx`:
|
||||
You can run the CLI via `npx`:
|
||||
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector --cli node build/index.js
|
||||
```
|
||||
|
||||
The CLI mode supports operations across tools, resources, and prompts, returning structured JSON output.
|
||||
Supports tools, resources, and prompts (plus `--method servers/list` / `servers/show` for catalog entries without connecting).
|
||||
|
||||
### Examples
|
||||
|
||||
|
|
@ -98,7 +98,7 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en
|
|||
|
||||
| Option | Description |
|
||||
| ----------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `--method <method>` | MCP method to invoke. Supports `initialize` (connect-only probe → `{serverInfo, protocolVersion, capabilities, instructions}`), `tools/list`, `tools/call`, `resources/list`, `resources/read`, `resources/templates/list`, `prompts/list`, `prompts/get`, `logging/setLevel`. |
|
||||
| `--method <method>` | MCP method to invoke. Supports `initialize` (connect-only probe → `{serverInfo, protocolVersion, capabilities, instructions}`), `tools/list`, `tools/call`, `resources/list`, `resources/read`, `resources/templates/list`, `prompts/list`, `prompts/get`, `logging/setLevel`, plus catalog-only `servers/list` / `servers/show` (no MCP connect). Stream / session-only methods (e.g. `logging/tail`) are rejected. |
|
||||
| `--tool-name <name>` | Tool name (for `tools/call`). |
|
||||
| `--tool-arg <key=value>` | Tool argument; repeat for multiple. Use `key='{"json":true}'` for JSON. Values are coerced (JSON-parsed, so `count=1` becomes a number). |
|
||||
| `--tool-args-json <json>` | Tool arguments as a single JSON object (e.g. `'{"zip":"10001"}'`). Passed verbatim — no `key=value` coercion, so `"012"` stays a string. Mutually exclusive with `--tool-arg`. |
|
||||
|
|
@ -111,6 +111,10 @@ Options that specify the MCP server (catalog/config file, ad-hoc command/URL, en
|
|||
| `--connect-timeout <ms>` | Connection timeout in ms. Defaults to `15000` for ad-hoc `--server-url`/target runs (so a black-holed host fails fast) and to the file-level timeout for `--catalog`/`--config` runs. `0` disables the timeout. |
|
||||
| `--app-info` | Probe a tool's MCP App UI metadata without invoking it. With `--method tools/call --tool-name <name>`: prints one JSON line (`hasApp`, `resourceUri`, `csp`, `permissions`, `domain`, …) and exits `0` if the tool has an app or `2` (`no_app`) if not. With `--method tools/list`: emits NDJSON — one app-info line per tool over a single connection. |
|
||||
| `--format <text\|json>` | Output format. `text` (default) pretty-prints the result. `json` emits a single JSON object on stdout (`{ "result": … }`, plus `{ "appInfo": … }` as a sibling key for App tools) with no banners, so the whole output pipes cleanly into `jq`. |
|
||||
| `--relogin` | Delete stored OAuth for this server URL from the shared store before connect; interactive login still only runs if the server requires auth. Requires an HTTP/SSE URL (rejected for stdio). Conflicts with `--stored-auth-only` / `--use-stored-auth` / `--wait-for-auth` / catalog short-circuits. |
|
||||
| `--stored-auth-only` | **CI / non-interactive safe:** never start interactive OAuth / step-up (and never auto-open a browser); use the shared store if present, otherwise fail immediately with `auth_required`. Prefer this over a bare pipe/CI run that would otherwise attempt interactive login. |
|
||||
|
||||
`servers/show` redacts secret-bearing fields (`env` values, sensitive headers / `settings.metadata` keys, `requestInit` / `eventSourceInit` headers, `oauthClientSecret`). It does **not** scrub credentials embedded in a server `url` (userinfo or query tokens) or in stdio `args` — treat `detail` / raw URL fields as potentially sensitive before pasting into issues.
|
||||
|
||||
#### App probing (`--app-info`) and machine-readable output (`--format json`)
|
||||
|
||||
|
|
@ -140,13 +144,17 @@ A `tools/call` that returns `isError:true` still prints its payload but exits `5
|
|||
|
||||
### CLI-specific (OAuth for HTTP servers)
|
||||
|
||||
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 runs the same loopback callback server as the TUI (`http://127.0.0.1:6276/oauth/callback` by default).
|
||||
|
||||
**CLI (`mcp-inspector --cli`):** on connect **401** or mid-session interactive auth (re-login / step-up), it:
|
||||
|
||||
1. Starts the callback listener on `--callback-url` (or `MCP_OAUTH_CALLBACK_URL`)
|
||||
2. Prints the authorization URL to the console (`ConsoleNavigation`)
|
||||
2. Prints the authorization URL to stderr (OSC 8 hyperlink when stderr is a TTY) and **opens the default browser** when allowed. Default: open on a **stderr** TTY (so `2>&1 | tee` prints a plain URL into the log but does not auto-open); plain URL only when stderr is non-TTY. `MCP_AUTO_OPEN_ENABLED=false` never opens; `=true` forces open even on a non-TTY (same as the web launcher).
|
||||
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).
|
||||
Interactive OAuth (connect-time or mid-RPC) requires a TTY on **stdin or stderr** (so `2>&1 | tee` still works — stdin stays a TTY), or `MCP_AUTO_OPEN_ENABLED=true`. When neither stdin nor stderr is a TTY and that env is unset (typical CI), the CLI fails fast with `auth_required` instead of waiting up to 15 minutes on the loopback callback. Use **`--stored-auth-only`** for non-interactive runs that should only consume the shared store.
|
||||
|
||||
**Step-up (standard OAuth):** when an RPC needs extra scopes, the CLI prompts on stderr: `Proceed with step-up authorization? [y/N]`. **y** continues (including piped stdin — `echo y | …` or `printf y | …`); **N** or EOF with no answer (`< /dev/null` / Ctrl-D) declines. Piped answers must be **newline-terminated, or stdin must close** — a bare `y` held open without `\n` or EOF is not flushed as a line and times out. A non-TTY stdin that never sends a line within **5 seconds** fails with `auth_required` (`timed out`, not the same as an explicit **N**). Answering **y** only confirms step-up — the following browser/loopback OAuth can still wait up to 15 minutes; for headless CI prefer **`--stored-auth-only`** with tokens already in the store. 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.
|
||||
|
||||
|
|
@ -176,6 +184,7 @@ Register `http://127.0.0.1:6276/oauth/callback` on static or enterprise IdPs tha
|
|||
| `--client-secret <secret>` | — | OAuth client secret; overrides `client.json`. |
|
||||
| `--client-metadata-url <url>` | — | CIMD metadata URL; overrides `client.json`. |
|
||||
| `--callback-url <url>` | `MCP_OAUTH_CALLBACK_URL` | Redirect URI sent to the authorization server (default: `http://127.0.0.1:6276/oauth/callback`). |
|
||||
| — | `MCP_AUTO_OPEN_ENABLED` | Browser auto-open **and** non-TTY interactive-OAuth admit: `true` (allow interactive OAuth without a TTY **and** force-open the browser — same as the web launcher), `false` (never open), unset (open on a TTY unless `VITEST` is set). For CI that must not hang, prefer `--stored-auth-only`. |
|
||||
|
||||
**Example** — list tools on an OAuth-protected server using stored tokens and CIMD from the command line:
|
||||
|
||||
|
|
@ -256,17 +265,16 @@ While the Web Client provides a rich visual interface, the CLI is designed for:
|
|||
Like the other clients, the CLI self-validates from its own folder:
|
||||
|
||||
```bash
|
||||
npm run validate # format:check && lint && test:coverage
|
||||
npm run validate # format:check && lint && typecheck && test (fast; no coverage gate)
|
||||
npm test # build test-servers + binary, then run all tests
|
||||
npm run test:coverage # build + tests under the per-file coverage gate
|
||||
npm run test:coverage # build + tests under the per-file ≥90 coverage gate
|
||||
```
|
||||
|
||||
The CLI's `test:coverage` **builds the binary first** (its out-of-process
|
||||
`e2e.test.ts` spawns it, so it must run against a fresh build). `validate`
|
||||
therefore folds the build into `test:coverage` rather than repeating it — it is
|
||||
`format:check && lint && test:coverage`, with no separate `build` step (the
|
||||
other clients, whose tests don't spawn their bundle, keep an explicit `build`).
|
||||
The repo-root `validate:cli` just delegates here.
|
||||
The CLI's `test` / `test:coverage` **build the binary first** (out-of-process
|
||||
`e2e.test.ts` spawns it). `validate` is `format:check && lint && typecheck && test`
|
||||
with no separate `build` step (`pretest` builds). Repo-root `validate:cli`
|
||||
delegates here; the coverage gate is `npm run coverage` / `coverage:cli` (also in
|
||||
`npm run ci`), matching AGENTS.md.
|
||||
|
||||
Tests run the CLI **in-process** (importing `runCli()`) so `src/` is measured
|
||||
under coverage, with a thin out-of-process spawn layer for the real binary. See
|
||||
|
|
|
|||
|
|
@ -1,73 +1,65 @@
|
|||
# CLI Tests
|
||||
|
||||
## Running Tests
|
||||
Tests live under `__tests__/` and run via Vitest.
|
||||
|
||||
- Most tests import `runCli()` **in-process** (see `helpers/cli-runner.ts`) so
|
||||
`clients/cli/src` is measured under the coverage gate. Suite-wide
|
||||
`helpers/mock-open-url.ts` (vitest `setupFiles`) mocks `open-url` so an armed
|
||||
interactive OAuth path cannot launch a real browser.
|
||||
- `e2e.test.ts` (and root `scripts/smoke-cli.mjs`) spawn the built binary for
|
||||
shebang / `process.exit` paths — `pretest` builds `test-servers` + the CLI
|
||||
bundle first.
|
||||
|
||||
## Scripts (from `clients/cli/`)
|
||||
|
||||
```bash
|
||||
# Run all tests (pretest builds test-servers + the CLI binary)
|
||||
npm test
|
||||
|
||||
# Run all tests under the per-file coverage gate
|
||||
npm run test:coverage
|
||||
|
||||
# Run in watch mode (useful for test file changes)
|
||||
npm run test:watch
|
||||
|
||||
# Run specific test file
|
||||
npm run test:cli # cli.test.ts
|
||||
npm run test:cli-tools # tools.test.ts
|
||||
npm run test:cli-headers # headers.test.ts
|
||||
npm run test:cli-metadata # metadata.test.ts
|
||||
npx vitest run oauth-interactive.test.ts cliOAuth.test.ts # OAuth interactive smoke parity
|
||||
npm test # pretest build + all tests
|
||||
npm run test:cli # subset: cli.test.ts
|
||||
npm run test:cli-tools # subset: tools.test.ts
|
||||
npm run test:cli-headers # subset: headers.test.ts
|
||||
npm run test:cli-metadata # subset: metadata.test.ts
|
||||
npm run test:coverage # build + ≥90 per-file coverage gate
|
||||
npm run validate # format:check && lint && typecheck && test
|
||||
```
|
||||
|
||||
## How the CLI is exercised
|
||||
## Test files
|
||||
|
||||
Most tests run the CLI **in-process**: `helpers/cli-runner.ts` imports `runCli()`
|
||||
from `../src/cli.ts` and invokes it directly, capturing `process.stdout`/`stderr`
|
||||
and mapping thrown errors to an exit code. This is what lets vitest's v8 coverage
|
||||
instrument `clients/cli/src` (#1484). Because `commander` uses `.exitOverride()`,
|
||||
a parse/usage error throws instead of calling `process.exit` and tearing down the
|
||||
worker.
|
||||
|
||||
`e2e.test.ts` is the deliberately thin **out-of-process** layer: it spawns the
|
||||
**built** `build/index.js` as a real subprocess to cover the shebang, the
|
||||
`index.ts` `isMain` bootstrap, and the actual `process.exit` codes — the only
|
||||
parts the in-process runner can't reach. (`scripts/smoke-cli.mjs` at the repo
|
||||
root provides a further end-to-end check of the binary.)
|
||||
|
||||
## Test Files
|
||||
|
||||
- `cli.test.ts` - Basic CLI functionality: CLI mode, environment variables, config files, resources, prompts, logging, transport types
|
||||
- `tools.test.ts` - Tool-related tests: Tool discovery, JSON argument parsing, error handling, prompts
|
||||
- `headers.test.ts` - Header parsing and 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)
|
||||
- `error-handler.test.ts` - The binary's `handleError` error sink, exercised in-process with `process.exit` stubbed
|
||||
- `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)
|
||||
| File | Focus |
|
||||
| --------------------------------------- | ------------------------------------------- |
|
||||
| `cli.test.ts` | Core connect / method / config matrix |
|
||||
| `tools.test.ts` | `tools/call` argument coercion |
|
||||
| `headers.test.ts` | `--header` merging |
|
||||
| `metadata.test.ts` | `--metadata` / `--tool-metadata` |
|
||||
| `methods.test.ts` | Method allow-list / rejection |
|
||||
| `app-info.test.ts` | `--app-info` probe paths |
|
||||
| `format-json.test.ts` | `--format json` envelopes |
|
||||
| `format-output.test.ts` | Text/json writers |
|
||||
| `emit-result.test.ts` | Result emission helpers |
|
||||
| `method-types.test.ts` | `ONE_SHOT_METHODS` / guards |
|
||||
| `run-method.test.ts` | Handler dispatch against a real test server |
|
||||
| `run-method-mocks.test.ts` | Handler edge cases with mocks |
|
||||
| `servers-list.test.ts` | `servers/list` / `servers/show` + redaction |
|
||||
| `cliOAuth.test.ts` | Connect / mid-RPC OAuth recovery |
|
||||
| `cli-oauth-navigation.test.ts` | OSC 8, arm/disarm, `MCP_AUTO_OPEN_ENABLED` |
|
||||
| `open-url.test.ts` | `open` package wrapper |
|
||||
| `oauth-runner.test.ts` | Runner client-config / CIMD flags |
|
||||
| `oauth-interactive.test.ts` | Loopback callback OAuth (in-process) |
|
||||
| `stored-auth.test.ts` | `--use-stored-auth` / handoff / wait |
|
||||
| `clear-stored-auth-for-relogin.test.ts` | Dual-key `--relogin` clear |
|
||||
| `programmatic-ergonomics.test.ts` | Flag conflicts, timeouts, ergonomics |
|
||||
| `error-handler.test.ts` | Exit codes / error shaping |
|
||||
| `style.test.ts` | ANSI / OSC 8 helpers |
|
||||
| `e2e.test.ts` | Out-of-process binary smoke |
|
||||
| `helpers/assertions.test.ts` | Assertion helpers |
|
||||
|
||||
## Helpers
|
||||
|
||||
The `helpers/` directory contains shared utilities:
|
||||
| Helper | Role |
|
||||
| -------------------------- | --------------------------------------------------------- |
|
||||
| `helpers/cli-runner.ts` | In-process `runCli` with stdout/stderr capture |
|
||||
| `helpers/mock-open-url.ts` | Suite-wide `open-url` mock (vitest `setupFiles`) |
|
||||
| `helpers/assertions.ts` | `expectCliSuccess` / `expectCliFailure` / output matchers |
|
||||
| `helpers/fixtures.ts` | Temp config / client.json factories |
|
||||
|
||||
- `cli-runner.ts` - Invokes `runCli()` in-process and captures stdout/stderr + exit code
|
||||
- `assertions.ts` - Custom assertion helpers for CLI output validation
|
||||
- `fixtures.ts` - Test config/catalog file generators and temporary directory management
|
||||
|
||||
HTTP/SSE test servers (for transport tests) come from the
|
||||
`@modelcontextprotocol/inspector-test-server` package (`createTestServerHttp`),
|
||||
and the bundled stdio server is launched via `getTestMcpServerCommand()`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Test files run in separate forked processes (`pool: 'forks'`), so the in-process
|
||||
runner's `process.std*.write` patching never overlaps across files; tests within
|
||||
a file run sequentially
|
||||
- Config files use `crypto.randomUUID()` for uniqueness
|
||||
- HTTP/SSE servers use dynamic port allocation to avoid conflicts
|
||||
- Coverage is enforced per-file (lines ≥ 90, statements ≥ 85, functions ≥ 80,
|
||||
branches ≥ 50). `src/index.ts` (the binary bootstrap) is excluded because it
|
||||
only runs in the spawned binary, which coverage can't instrument
|
||||
- All tests use the built-in MCP test servers — no external/registry dependencies
|
||||
OAuth interactive tests stub or drive the loopback callback in-process rather
|
||||
than requiring a real browser.
|
||||
|
|
|
|||
112
clients/cli/__tests__/clear-stored-auth-for-relogin.test.ts
Normal file
112
clients/cli/__tests__/clear-stored-auth-for-relogin.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
getStateFilePath,
|
||||
resetNodeOAuthStorageCache,
|
||||
} from "@inspector/core/auth/node/storage-node.js";
|
||||
import { clearStoredAuthForRelogin } from "../src/clear-stored-auth-for-relogin.js";
|
||||
|
||||
describe("clearStoredAuthForRelogin", () => {
|
||||
let dir: string | undefined;
|
||||
let prevPath: string | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
if (prevPath === undefined)
|
||||
delete process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
|
||||
else process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = prevPath;
|
||||
resetNodeOAuthStorageCache();
|
||||
if (dir) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
dir = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it("no-ops when serverUrl is missing or blank", async () => {
|
||||
await clearStoredAuthForRelogin(undefined);
|
||||
await clearStoredAuthForRelogin(" ");
|
||||
});
|
||||
|
||||
it("clears a URL-keyed entry and tolerates non-URL keys", async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-"));
|
||||
const file = path.join(dir, "oauth.json");
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
servers: {
|
||||
"https://example.com/mcp": {
|
||||
tokens: { access_token: "a", token_type: "Bearer" },
|
||||
},
|
||||
"not a url": {
|
||||
tokens: { access_token: "b", token_type: "Bearer" },
|
||||
},
|
||||
},
|
||||
idpSessions: {},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
|
||||
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
|
||||
resetNodeOAuthStorageCache();
|
||||
expect(getStateFilePath()).toBe(file);
|
||||
|
||||
await clearStoredAuthForRelogin("https://example.com/mcp");
|
||||
let blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
||||
servers: Record<string, unknown>;
|
||||
};
|
||||
expect(blob.servers["https://example.com/mcp"]).toBeUndefined();
|
||||
expect(blob.servers["not a url"]).toBeDefined();
|
||||
|
||||
// normalizeServerUrl catch path — clears under the raw key
|
||||
await clearStoredAuthForRelogin("not a url");
|
||||
blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
||||
servers: Record<string, unknown>;
|
||||
};
|
||||
expect(blob.servers["not a url"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears both raw and URL-normalised keys (bare origin / mixed-case host)", async () => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-norm-"));
|
||||
const file = path.join(dir, "oauth.json");
|
||||
// Runtime storage keys by the transport's raw url string — often without a
|
||||
// trailing slash / with mixed-case host — while new URL().href normalises.
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
servers: {
|
||||
"https://example.com": {
|
||||
tokens: { access_token: "bare", token_type: "Bearer" },
|
||||
},
|
||||
"https://example.com/": {
|
||||
tokens: { access_token: "slash", token_type: "Bearer" },
|
||||
},
|
||||
"https://Example.com/mcp": {
|
||||
tokens: { access_token: "mixed", token_type: "Bearer" },
|
||||
},
|
||||
},
|
||||
idpSessions: {},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
|
||||
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
|
||||
resetNodeOAuthStorageCache();
|
||||
|
||||
await clearStoredAuthForRelogin("https://example.com");
|
||||
let blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
||||
servers: Record<string, unknown>;
|
||||
};
|
||||
expect(blob.servers["https://example.com"]).toBeUndefined();
|
||||
expect(blob.servers["https://example.com/"]).toBeUndefined();
|
||||
expect(blob.servers["https://Example.com/mcp"]).toBeDefined();
|
||||
|
||||
await clearStoredAuthForRelogin("https://Example.com/mcp");
|
||||
blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
|
||||
servers: Record<string, unknown>;
|
||||
};
|
||||
expect(blob.servers["https://Example.com/mcp"]).toBeUndefined();
|
||||
// Normalised key (lowercased host) is cleared too when distinct.
|
||||
expect(blob.servers["https://example.com/mcp"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
250
clients/cli/__tests__/cli-oauth-navigation.test.ts
Normal file
250
clients/cli/__tests__/cli-oauth-navigation.test.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createCliOAuthNavigation,
|
||||
isCliAutoOpenForced,
|
||||
resolveCliAutoOpenEnabled,
|
||||
} from "../src/cli-oauth-navigation.js";
|
||||
import { openUrl } from "../src/open-url.js";
|
||||
|
||||
// Local mock (in addition to suite-wide setupFiles) so this file owns a
|
||||
// `vi.mocked(openUrl)` handle and does not depend on the global mock shape.
|
||||
vi.mock("../src/open-url.js", () => ({
|
||||
openUrl: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const openUrlMock = vi.mocked(openUrl);
|
||||
|
||||
describe("resolveCliAutoOpenEnabled", () => {
|
||||
it("honors MCP_AUTO_OPEN_ENABLED true/false and defaults off under VITEST", () => {
|
||||
expect(
|
||||
resolveCliAutoOpenEnabled({
|
||||
MCP_AUTO_OPEN_ENABLED: "true",
|
||||
VITEST: "true",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveCliAutoOpenEnabled({
|
||||
MCP_AUTO_OPEN_ENABLED: "false",
|
||||
VITEST: undefined,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(resolveCliAutoOpenEnabled({ VITEST: "true" })).toBe(false);
|
||||
expect(resolveCliAutoOpenEnabled({})).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCliAutoOpenForced", () => {
|
||||
it("is true only for the explicit string true", () => {
|
||||
expect(isCliAutoOpenForced({ MCP_AUTO_OPEN_ENABLED: "true" })).toBe(true);
|
||||
expect(isCliAutoOpenForced({ MCP_AUTO_OPEN_ENABLED: "false" })).toBe(false);
|
||||
expect(isCliAutoOpenForced({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCliOAuthNavigation", () => {
|
||||
afterEach(() => {
|
||||
openUrlMock.mockClear();
|
||||
openUrlMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("prints OSC 8 link and opens browser when armed on a TTY", async () => {
|
||||
const lines: string[] = [];
|
||||
const openBrowser = vi.fn().mockResolvedValue(undefined);
|
||||
const nav = createCliOAuthNavigation({
|
||||
isTTY: true,
|
||||
// Force ANSI on even when the test runner exports NO_COLOR.
|
||||
noColorEnv: "",
|
||||
write: (line) => lines.push(line),
|
||||
openBrowser,
|
||||
autoOpenControl: { armed: true },
|
||||
autoOpenEnabled: true,
|
||||
});
|
||||
const url = new URL("https://as.example/authorize?x=1");
|
||||
nav.navigateToAuthorization(url);
|
||||
await vi.waitFor(() => expect(openBrowser).toHaveBeenCalledOnce());
|
||||
expect(lines.join("")).toContain("Please navigate to:");
|
||||
expect(lines.join("")).toContain(
|
||||
"\u001b]8;;https://as.example/authorize?x=1\u0007",
|
||||
);
|
||||
expect(openBrowser).toHaveBeenCalledWith(
|
||||
"https://as.example/authorize?x=1",
|
||||
);
|
||||
expect(openUrlMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a silent no-op when disarmed (SDK-during-connect default)", async () => {
|
||||
const lines: string[] = [];
|
||||
const openBrowser = vi.fn();
|
||||
const nav = createCliOAuthNavigation({
|
||||
isTTY: true,
|
||||
noColorEnv: "1",
|
||||
write: (line) => lines.push(line),
|
||||
openBrowser,
|
||||
autoOpenEnabled: true,
|
||||
// no autoOpenControl → never armed
|
||||
});
|
||||
nav.navigateToAuthorization(new URL("https://as.example/authorize"));
|
||||
// Navigation is fire-and-forget; give the microtask a turn.
|
||||
await Promise.resolve();
|
||||
expect(lines).toEqual([]);
|
||||
expect(openBrowser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a silent no-op when disableAutoOpen is set (stored-auth-only)", async () => {
|
||||
const lines: string[] = [];
|
||||
const openBrowser = vi.fn();
|
||||
const nav = createCliOAuthNavigation({
|
||||
isTTY: true,
|
||||
noColorEnv: "1",
|
||||
write: (line) => lines.push(line),
|
||||
openBrowser,
|
||||
autoOpenControl: { armed: true },
|
||||
autoOpenEnabled: true,
|
||||
disableAutoOpen: true,
|
||||
});
|
||||
nav.navigateToAuthorization(new URL("https://as.example/authorize"));
|
||||
await Promise.resolve();
|
||||
expect(lines).toEqual([]);
|
||||
expect(openBrowser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prints but does not open a browser when autoOpenEnabled is false", async () => {
|
||||
const lines: string[] = [];
|
||||
const openBrowser = vi.fn();
|
||||
const nav = createCliOAuthNavigation({
|
||||
isTTY: true,
|
||||
noColorEnv: "1",
|
||||
write: (line) => lines.push(line),
|
||||
openBrowser,
|
||||
autoOpenControl: { armed: true },
|
||||
autoOpenEnabled: false,
|
||||
});
|
||||
nav.navigateToAuthorization(new URL("https://as.example/authorize"));
|
||||
await vi.waitFor(() => expect(lines.length).toBe(1));
|
||||
expect(lines.join("")).toContain("Please navigate to:");
|
||||
expect(openBrowser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prints a plain URL and does not open a browser when not a TTY (unless forced)", async () => {
|
||||
const lines: string[] = [];
|
||||
const openBrowser = vi.fn();
|
||||
const nav = createCliOAuthNavigation({
|
||||
isTTY: false,
|
||||
write: (line) => lines.push(line),
|
||||
openBrowser,
|
||||
autoOpenControl: { armed: true },
|
||||
autoOpenEnabled: true,
|
||||
});
|
||||
nav.navigateToAuthorization(new URL("https://as.example/authorize"));
|
||||
await vi.waitFor(() => expect(lines.length).toBe(1));
|
||||
expect(lines.join("")).toBe(
|
||||
"Please navigate to: https://as.example/authorize\n",
|
||||
);
|
||||
expect(lines.join("")).not.toContain("\u001b]8;;");
|
||||
expect(openBrowser).not.toHaveBeenCalled();
|
||||
expect(openUrlMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens on a non-TTY when forceAutoOpen is set (MCP_AUTO_OPEN_ENABLED=true)", async () => {
|
||||
const lines: string[] = [];
|
||||
const openBrowser = vi.fn().mockResolvedValue(undefined);
|
||||
const nav = createCliOAuthNavigation({
|
||||
isTTY: false,
|
||||
noColorEnv: "1",
|
||||
write: (line) => lines.push(line),
|
||||
openBrowser,
|
||||
autoOpenControl: { armed: true },
|
||||
autoOpenEnabled: true,
|
||||
forceAutoOpen: true,
|
||||
});
|
||||
nav.navigateToAuthorization(new URL("https://as.example/authorize"));
|
||||
await vi.waitFor(() => expect(openBrowser).toHaveBeenCalledOnce());
|
||||
expect(lines.join("")).toContain("Please navigate to:");
|
||||
});
|
||||
|
||||
it("infers force-open from MCP_AUTO_OPEN_ENABLED=true when options omit overrides", async () => {
|
||||
const prev = process.env.MCP_AUTO_OPEN_ENABLED;
|
||||
process.env.MCP_AUTO_OPEN_ENABLED = "true";
|
||||
try {
|
||||
const openBrowser = vi.fn().mockResolvedValue(undefined);
|
||||
const nav = createCliOAuthNavigation({
|
||||
isTTY: false,
|
||||
noColorEnv: "1",
|
||||
write: () => {},
|
||||
openBrowser,
|
||||
autoOpenControl: { armed: true },
|
||||
});
|
||||
nav.navigateToAuthorization(new URL("https://as.example/authorize"));
|
||||
await vi.waitFor(() => expect(openBrowser).toHaveBeenCalledOnce());
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env.MCP_AUTO_OPEN_ENABLED;
|
||||
} else {
|
||||
process.env.MCP_AUTO_OPEN_ENABLED = prev;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("skips OSC 8 when NO_COLOR is set but still opens on a TTY when armed", async () => {
|
||||
const lines: string[] = [];
|
||||
const openBrowser = vi.fn().mockResolvedValue(undefined);
|
||||
const nav = createCliOAuthNavigation({
|
||||
isTTY: true,
|
||||
noColorEnv: "1",
|
||||
write: (line) => lines.push(line),
|
||||
openBrowser,
|
||||
autoOpenControl: { armed: true },
|
||||
autoOpenEnabled: true,
|
||||
});
|
||||
nav.navigateToAuthorization(new URL("https://as.example/a"));
|
||||
await vi.waitFor(() => expect(openBrowser).toHaveBeenCalledOnce());
|
||||
expect(lines.join("")).toBe("Please navigate to: https://as.example/a\n");
|
||||
expect(lines.join("")).not.toContain("\u001b]8;;");
|
||||
});
|
||||
|
||||
it("swallows browser-open failures after printing the URL", async () => {
|
||||
const lines: string[] = [];
|
||||
const openBrowser = vi.fn().mockRejectedValue(new Error("no browser"));
|
||||
const nav = createCliOAuthNavigation({
|
||||
isTTY: true,
|
||||
noColorEnv: "1",
|
||||
write: (line) => lines.push(line),
|
||||
openBrowser,
|
||||
autoOpenControl: { armed: true },
|
||||
autoOpenEnabled: true,
|
||||
});
|
||||
nav.navigateToAuthorization(new URL("https://as.example/a"));
|
||||
await vi.waitFor(() => expect(openBrowser).toHaveBeenCalledOnce());
|
||||
expect(lines.join("")).toContain("Please navigate to:");
|
||||
});
|
||||
|
||||
it("writes to stderr and uses openUrl by default when armed on a TTY", async () => {
|
||||
const writeSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation(() => true);
|
||||
const ttyDesc = Object.getOwnPropertyDescriptor(process.stderr, "isTTY");
|
||||
Object.defineProperty(process.stderr, "isTTY", {
|
||||
configurable: true,
|
||||
get: () => true,
|
||||
});
|
||||
try {
|
||||
const nav = createCliOAuthNavigation({
|
||||
noColorEnv: "1",
|
||||
autoOpenControl: { armed: true },
|
||||
autoOpenEnabled: true,
|
||||
});
|
||||
nav.navigateToAuthorization(new URL("https://as.example/default"));
|
||||
await vi.waitFor(() => expect(openUrlMock).toHaveBeenCalledOnce());
|
||||
expect(openUrlMock).toHaveBeenCalledWith("https://as.example/default");
|
||||
const written = writeSpy.mock.calls.map((c) => String(c[0])).join("");
|
||||
expect(written).toContain(
|
||||
"Please navigate to: https://as.example/default",
|
||||
);
|
||||
} finally {
|
||||
writeSpy.mockRestore();
|
||||
if (ttyDesc) {
|
||||
Object.defineProperty(process.stderr, "isTTY", ttyDesc);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -7,22 +7,69 @@ import {
|
|||
handleCliAuthRecoveryRequired,
|
||||
isStandardOAuthStepUp,
|
||||
runCliInteractiveOAuth,
|
||||
assertInteractiveOAuthAllowed,
|
||||
withCliAuthRecoveryRetry,
|
||||
STEP_UP_PIPE_TIMEOUT_MS,
|
||||
} from "../src/cliOAuth.js";
|
||||
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
|
||||
// `confirmStepUpFromStdin` (the default step-up confirmer) reads a line from
|
||||
// stdin via node:readline/promises. Mock the module so the default path can be
|
||||
// exercised deterministically without real TTY input.
|
||||
const { mockQuestion, mockClose } = vi.hoisted(() => ({
|
||||
mockQuestion: vi.fn(),
|
||||
mockClose: vi.fn(),
|
||||
}));
|
||||
const { mockQuestion, mockClose, createMockReadline } = vi.hoisted(() => {
|
||||
const mockQuestion = vi.fn();
|
||||
const mockClose = vi.fn();
|
||||
function createMockReadline(options?: {
|
||||
question?: () => Promise<string>;
|
||||
/** When set, emit `close` on the next microtask (EOF, no partial line). */
|
||||
emitClose?: boolean;
|
||||
/**
|
||||
* Emit a `line` then `close` on the next microtask — models
|
||||
* `printf 'y' | …` (partial last line without trailing newline).
|
||||
*/
|
||||
emitLineThenClose?: string;
|
||||
}) {
|
||||
const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
|
||||
const rl = {
|
||||
question: options?.question ?? mockQuestion,
|
||||
close: mockClose,
|
||||
on(event: string, cb: (...args: unknown[]) => void) {
|
||||
let set = listeners.get(event);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
listeners.set(event, set);
|
||||
}
|
||||
set.add(cb);
|
||||
return rl;
|
||||
},
|
||||
once(event: string, cb: (...args: unknown[]) => void) {
|
||||
// Match EventEmitter.removeListener(originalFn) after once(fn).
|
||||
return rl.on(event, cb);
|
||||
},
|
||||
removeListener(event: string, cb: (...args: unknown[]) => void) {
|
||||
listeners.get(event)?.delete(cb);
|
||||
return rl;
|
||||
},
|
||||
emit(event: string, ...args: unknown[]) {
|
||||
for (const cb of [...(listeners.get(event) ?? [])]) cb(...args);
|
||||
},
|
||||
};
|
||||
if (options?.emitLineThenClose !== undefined) {
|
||||
const line = options.emitLineThenClose;
|
||||
queueMicrotask(() => {
|
||||
rl.emit("line", line);
|
||||
rl.emit("close");
|
||||
});
|
||||
} else if (options?.emitClose) {
|
||||
queueMicrotask(() => rl.emit("close"));
|
||||
}
|
||||
return rl;
|
||||
}
|
||||
return { mockQuestion, mockClose, createMockReadline };
|
||||
});
|
||||
vi.mock("node:readline/promises", () => ({
|
||||
createInterface: vi.fn(() => ({
|
||||
question: mockQuestion,
|
||||
close: mockClose,
|
||||
})),
|
||||
createInterface: vi.fn(() => createMockReadline()),
|
||||
}));
|
||||
|
||||
const CALLBACK_URL_CONFIG = {
|
||||
|
|
@ -31,6 +78,19 @@ const CALLBACK_URL_CONFIG = {
|
|||
pathname: "/oauth/callback",
|
||||
};
|
||||
|
||||
const OAUTH_HTTP_CONFIG = {
|
||||
type: "streamable-http",
|
||||
url: "https://as.example/mcp",
|
||||
} as MCPServerConfig;
|
||||
|
||||
const STDIO_CONFIG = {
|
||||
type: "stdio",
|
||||
command: "x",
|
||||
} as MCPServerConfig;
|
||||
|
||||
/** Unit tests run without a TTY; opt into interactive OAuth explicitly. */
|
||||
const INTERACTIVE = { isTTY: true as const };
|
||||
|
||||
describe("cliOAuth", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
|
|
@ -82,6 +142,37 @@ describe("cliOAuth", () => {
|
|||
expect(stderrSpy).toHaveBeenCalledWith("Authorization complete.\n");
|
||||
});
|
||||
|
||||
it("runCliInteractiveOAuth arms autoOpenControl only for the interactive flow", async () => {
|
||||
const autoOpenControl = { armed: false };
|
||||
let armedDuringCall = false;
|
||||
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockImplementation(
|
||||
async () => {
|
||||
armedDuringCall = autoOpenControl.armed;
|
||||
return { kind: "success" };
|
||||
},
|
||||
);
|
||||
const client = {
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
};
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation(() => true);
|
||||
|
||||
await runCliInteractiveOAuth(
|
||||
client,
|
||||
new MutableRedirectUrlProvider(),
|
||||
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||
{ autoOpenControl },
|
||||
);
|
||||
|
||||
expect(armedDuringCall).toBe(true);
|
||||
expect(autoOpenControl.armed).toBe(false);
|
||||
expect(stderrSpy).toHaveBeenCalledWith("Authorization complete.\n");
|
||||
});
|
||||
|
||||
it("runCliInteractiveOAuth throws when scopes remain insufficient", async () => {
|
||||
const challenge = {
|
||||
reason: "insufficient_scope" as const,
|
||||
|
|
@ -130,7 +221,7 @@ describe("cliOAuth", () => {
|
|||
new MutableRedirectUrlProvider(),
|
||||
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||
{},
|
||||
async () => false,
|
||||
{ confirmStepUp: async () => false, isTTY: true },
|
||||
),
|
||||
).rejects.toThrow("Step-up authorization declined.");
|
||||
|
||||
|
|
@ -160,7 +251,7 @@ describe("cliOAuth", () => {
|
|||
new MutableRedirectUrlProvider(),
|
||||
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||
{},
|
||||
async () => true,
|
||||
{ confirmStepUp: async () => true, isTTY: true },
|
||||
);
|
||||
|
||||
expect(runSpy).toHaveBeenCalledWith(
|
||||
|
|
@ -224,7 +315,10 @@ describe("cliOAuth", () => {
|
|||
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({
|
||||
kind: "success",
|
||||
});
|
||||
const connect = vi.fn().mockResolvedValue(undefined);
|
||||
const client = {
|
||||
connect,
|
||||
disconnect: vi.fn(),
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
|
|
@ -241,14 +335,16 @@ describe("cliOAuth", () => {
|
|||
|
||||
const result = await withCliAuthRecoveryRetry(
|
||||
client,
|
||||
OAUTH_HTTP_CONFIG,
|
||||
new MutableRedirectUrlProvider(),
|
||||
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||
{ enterpriseManaged: true },
|
||||
fn,
|
||||
async () => true,
|
||||
{ confirmStepUp: async () => true, isTTY: true },
|
||||
);
|
||||
|
||||
expect(result).toBe("ok");
|
||||
expect(connect).toHaveBeenCalledOnce();
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
|
|
@ -272,14 +368,15 @@ describe("cliOAuth", () => {
|
|||
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||
.mockResolvedValue({ kind: "success" });
|
||||
|
||||
// Omitting the confirmStepUp argument exercises the default
|
||||
// confirmStepUpFromStdin, which reads from the mocked readline interface.
|
||||
// Omitting confirmStepUp exercises the default confirmStepUpFromStdin,
|
||||
// which reads from the mocked readline interface.
|
||||
await handleCliAuthRecoveryRequired(
|
||||
clientNeedingStepUp(),
|
||||
standardStepUpError(),
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
{},
|
||||
INTERACTIVE,
|
||||
);
|
||||
|
||||
expect(mockQuestion).toHaveBeenCalled();
|
||||
|
|
@ -299,6 +396,7 @@ describe("cliOAuth", () => {
|
|||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
{},
|
||||
INTERACTIVE,
|
||||
);
|
||||
|
||||
expect(runSpy).toHaveBeenCalled();
|
||||
|
|
@ -315,19 +413,177 @@ describe("cliOAuth", () => {
|
|||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
{},
|
||||
INTERACTIVE,
|
||||
),
|
||||
).rejects.toThrow("Step-up authorization declined.");
|
||||
|
||||
expect(mockClose).toHaveBeenCalled();
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("declines when readline question rejects", async () => {
|
||||
mockQuestion.mockRejectedValue(new Error("stdin closed"));
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
|
||||
await expect(
|
||||
handleCliAuthRecoveryRequired(
|
||||
clientNeedingStepUp(),
|
||||
standardStepUpError(),
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
{},
|
||||
INTERACTIVE,
|
||||
),
|
||||
).rejects.toThrow("Step-up authorization declined.");
|
||||
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("declines when stdin EOF closes the readline interface before an answer", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
vi.mocked(createInterface).mockImplementationOnce(
|
||||
() =>
|
||||
createMockReadline({
|
||||
question: () => new Promise(() => {}),
|
||||
emitClose: true,
|
||||
}) as unknown as ReturnType<typeof createInterface>,
|
||||
);
|
||||
|
||||
await expect(
|
||||
handleCliAuthRecoveryRequired(
|
||||
clientNeedingStepUp(),
|
||||
standardStepUpError(),
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
{},
|
||||
INTERACTIVE,
|
||||
),
|
||||
).rejects.toThrow("Step-up authorization declined.");
|
||||
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a partial last line without trailing newline (printf y | …)", async () => {
|
||||
// On stream end, readline emits the buffered partial line then close
|
||||
// without settling question() — close must use the captured line.
|
||||
const runSpy = vi
|
||||
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||
.mockResolvedValue({ kind: "success" });
|
||||
vi.mocked(createInterface).mockImplementationOnce(
|
||||
() =>
|
||||
createMockReadline({
|
||||
question: () => new Promise(() => {}),
|
||||
emitLineThenClose: "y",
|
||||
}) as unknown as ReturnType<typeof createInterface>,
|
||||
);
|
||||
|
||||
await handleCliAuthRecoveryRequired(
|
||||
clientNeedingStepUp(),
|
||||
standardStepUpError(),
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
{},
|
||||
INTERACTIVE,
|
||||
);
|
||||
|
||||
expect(runSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a piped y when stdin is non-TTY (echo y | …)", async () => {
|
||||
// Force-admit interactive OAuth without a TTY; the confirmer still reads
|
||||
// the piped answer (round-9: do not hard-require stdin.isTTY).
|
||||
vi.stubEnv("MCP_AUTO_OPEN_ENABLED", "true");
|
||||
mockQuestion.mockResolvedValue("y");
|
||||
const runSpy = vi
|
||||
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||
.mockResolvedValue({ kind: "success" });
|
||||
try {
|
||||
await handleCliAuthRecoveryRequired(
|
||||
clientNeedingStepUp(),
|
||||
standardStepUpError(),
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
{},
|
||||
{ isTTY: false },
|
||||
);
|
||||
expect(runSpy).toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it("fails with auth_required when a non-TTY stdin never answers (pipe timeout)", async () => {
|
||||
mockQuestion.mockImplementation(() => new Promise(() => {}));
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
|
||||
await expect(
|
||||
handleCliAuthRecoveryRequired(
|
||||
clientNeedingStepUp(),
|
||||
standardStepUpError(),
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
{},
|
||||
{ isTTY: true, stepUpPromptTimeoutMs: 20 },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
exitCode: 3,
|
||||
message: expect.stringMatching(/no answer on stdin within 20ms/),
|
||||
});
|
||||
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not apply the pipe timeout when stdin is a TTY", async () => {
|
||||
vi.useFakeTimers();
|
||||
const stdinDesc = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
|
||||
Object.defineProperty(process.stdin, "isTTY", {
|
||||
configurable: true,
|
||||
get: () => true,
|
||||
});
|
||||
let resolveQuestion!: (value: string) => void;
|
||||
mockQuestion.mockImplementation(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveQuestion = resolve;
|
||||
}),
|
||||
);
|
||||
const runSpy = vi
|
||||
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||
.mockResolvedValue({ kind: "success" });
|
||||
try {
|
||||
const pending = handleCliAuthRecoveryRequired(
|
||||
clientNeedingStepUp(),
|
||||
standardStepUpError(),
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
{},
|
||||
INTERACTIVE,
|
||||
);
|
||||
let settled = false;
|
||||
void pending.then(
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
() => {
|
||||
settled = true;
|
||||
},
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(STEP_UP_PIPE_TIMEOUT_MS + 60_000);
|
||||
await Promise.resolve(); // flush microtasks from any premature settle
|
||||
expect(settled).toBe(false);
|
||||
resolveQuestion("y");
|
||||
await pending;
|
||||
expect(runSpy).toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
if (stdinDesc) Object.defineProperty(process.stdin, "isTTY", stdinDesc);
|
||||
else delete (process.stdin as { isTTY?: boolean }).isTTY;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("connectInspectorWithOAuth recovery branch", () => {
|
||||
const oauthServerConfig = {
|
||||
type: "streamable-http",
|
||||
url: "https://as.example/mcp",
|
||||
} as MCPServerConfig;
|
||||
const oauthServerConfig = OAUTH_HTTP_CONFIG;
|
||||
|
||||
it("resumes without re-auth when storage already satisfies the challenge", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
|
|
@ -381,6 +637,8 @@ describe("cliOAuth", () => {
|
|||
oauthServerConfig,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
INTERACTIVE,
|
||||
);
|
||||
|
||||
expect(connect).toHaveBeenCalledTimes(2);
|
||||
|
|
@ -407,6 +665,8 @@ describe("cliOAuth", () => {
|
|||
oauthServerConfig,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
INTERACTIVE,
|
||||
);
|
||||
|
||||
expect(client.disconnect).toHaveBeenCalled();
|
||||
|
|
@ -453,5 +713,479 @@ describe("cliOAuth", () => {
|
|||
),
|
||||
).rejects.toThrow("nope (401)");
|
||||
});
|
||||
|
||||
it("fails with AUTH_REQUIRED under --stored-auth-only without interactive OAuth", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const connect = vi.fn().mockRejectedValue(
|
||||
new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), {
|
||||
reason: "token_expired",
|
||||
}),
|
||||
);
|
||||
const client = {
|
||||
connect,
|
||||
disconnect: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
|
||||
await expect(
|
||||
connectInspectorWithOAuth(
|
||||
client,
|
||||
oauthServerConfig,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
{ storedAuthOnly: true },
|
||||
),
|
||||
).rejects.toMatchObject({ exitCode: 3 });
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("under --stored-auth-only reconnects when the store already satisfies", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const connect = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
new AuthRecoveryRequiredError(
|
||||
new URL("https://as.example/authorize"),
|
||||
{ reason: "token_expired" },
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const client = {
|
||||
connect,
|
||||
disconnect: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
await connectInspectorWithOAuth(
|
||||
client,
|
||||
oauthServerConfig,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
{ storedAuthOnly: true },
|
||||
);
|
||||
|
||||
expect(connect).toHaveBeenCalledTimes(2);
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails stored-auth-only on plain unauthorized errors", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const connect = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("Connection failed for server (401)"));
|
||||
const client = {
|
||||
connect,
|
||||
disconnect: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
connectInspectorWithOAuth(
|
||||
client,
|
||||
oauthServerConfig,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
{ storedAuthOnly: true },
|
||||
),
|
||||
).rejects.toMatchObject({ exitCode: 3 });
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("withCliAuthRecoveryRetry respects storedAuthOnly", async () => {
|
||||
const checkAuthChallengeSatisfied = vi.fn().mockResolvedValue(false);
|
||||
const fn = vi.fn().mockRejectedValue(
|
||||
new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), {
|
||||
reason: "token_expired",
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
withCliAuthRecoveryRetry(
|
||||
{
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied,
|
||||
},
|
||||
OAUTH_HTTP_CONFIG,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
fn,
|
||||
{ storedAuthOnly: true },
|
||||
),
|
||||
).rejects.toMatchObject({ exitCode: 3 });
|
||||
expect(checkAuthChallengeSatisfied).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("withCliAuthRecoveryRetry under storedAuthOnly retries when the store satisfies the challenge", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const challenge = {
|
||||
reason: "token_expired" as const,
|
||||
};
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
new AuthRecoveryRequiredError(
|
||||
new URL("https://as.example/authorize"),
|
||||
challenge,
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce("ok");
|
||||
const checkAuthChallengeSatisfied = vi.fn().mockResolvedValue(true);
|
||||
|
||||
const result = await withCliAuthRecoveryRetry(
|
||||
{
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied,
|
||||
},
|
||||
OAUTH_HTTP_CONFIG,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
fn,
|
||||
{ storedAuthOnly: true },
|
||||
);
|
||||
|
||||
expect(result).toBe("ok");
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
expect(checkAuthChallengeSatisfied).toHaveBeenCalledWith(challenge);
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("withCliAuthRecoveryRetry recovers plain unauthorized errors like connect", async () => {
|
||||
const runSpy = vi
|
||||
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||
.mockResolvedValue({ kind: "success" });
|
||||
const connect = vi.fn().mockResolvedValue(undefined);
|
||||
const disconnect = vi.fn().mockResolvedValue(undefined);
|
||||
const callOrder: string[] = [];
|
||||
connect.mockImplementation(async () => {
|
||||
callOrder.push("connect");
|
||||
});
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => {
|
||||
callOrder.push("fn1");
|
||||
throw new Error("RPC failed (401)");
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
callOrder.push("fn2");
|
||||
return "ok";
|
||||
});
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation(() => true);
|
||||
|
||||
const result = await withCliAuthRecoveryRetry(
|
||||
{
|
||||
connect,
|
||||
disconnect,
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
},
|
||||
OAUTH_HTTP_CONFIG,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
fn,
|
||||
INTERACTIVE,
|
||||
);
|
||||
|
||||
expect(result).toBe("ok");
|
||||
expect(disconnect).toHaveBeenCalledOnce();
|
||||
expect(runSpy).toHaveBeenCalledOnce();
|
||||
expect(connect).toHaveBeenCalledOnce();
|
||||
expect(callOrder).toEqual(["fn1", "connect", "fn2"]);
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
"Authorization complete. Retrying…\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("withCliAuthRecoveryRetry fails stored-auth-only on plain unauthorized errors", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const fn = vi.fn().mockRejectedValue(new Error("RPC failed (401)"));
|
||||
|
||||
await expect(
|
||||
withCliAuthRecoveryRetry(
|
||||
{
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
},
|
||||
OAUTH_HTTP_CONFIG,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
fn,
|
||||
{ storedAuthOnly: true },
|
||||
),
|
||||
).rejects.toMatchObject({ exitCode: 3 });
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("withCliAuthRecoveryRetry rethrows unauthorized errors for non-OAuth configs", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const err = new Error("proxied backend failed with status (401)");
|
||||
const fn = vi.fn().mockRejectedValue(err);
|
||||
|
||||
await expect(
|
||||
withCliAuthRecoveryRetry(
|
||||
{
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
},
|
||||
STDIO_CONFIG,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
fn,
|
||||
),
|
||||
).rejects.toBe(err);
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("withCliAuthRecoveryRetry rethrows unrelated errors unchanged", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const err = new Error("something else went wrong");
|
||||
const fn = vi.fn().mockRejectedValue(err);
|
||||
|
||||
await expect(
|
||||
withCliAuthRecoveryRetry(
|
||||
{
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
},
|
||||
OAUTH_HTTP_CONFIG,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
fn,
|
||||
),
|
||||
).rejects.toBe(err);
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("withCliAuthRecoveryRetry stored-auth-only uses a fallback message for non-Error 401s", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const fn = vi.fn().mockRejectedValue({ status: 401 });
|
||||
|
||||
await expect(
|
||||
withCliAuthRecoveryRetry(
|
||||
{
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
},
|
||||
OAUTH_HTTP_CONFIG,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
fn,
|
||||
{ storedAuthOnly: true },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
exitCode: 3,
|
||||
message: expect.stringMatching(/--stored-auth-only/),
|
||||
});
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("assertInteractiveOAuthAllowed fails fast when neither stream is a TTY", () => {
|
||||
expect(() => assertInteractiveOAuthAllowed({ isTTY: false })).toThrow(
|
||||
/Interactive OAuth requires a TTY on stdin or stderr/,
|
||||
);
|
||||
});
|
||||
|
||||
it("assertInteractiveOAuthAllowed admits when isTTY override is true (stdin||stderr path)", () => {
|
||||
expect(() => assertInteractiveOAuthAllowed({ isTTY: true })).not.toThrow();
|
||||
});
|
||||
|
||||
it("assertInteractiveOAuthAllowed admits when stdin is a TTY even if stderr is not", () => {
|
||||
const stdinDesc = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
|
||||
const stderrDesc = Object.getOwnPropertyDescriptor(process.stderr, "isTTY");
|
||||
Object.defineProperty(process.stdin, "isTTY", {
|
||||
configurable: true,
|
||||
get: () => true,
|
||||
});
|
||||
Object.defineProperty(process.stderr, "isTTY", {
|
||||
configurable: true,
|
||||
get: () => false,
|
||||
});
|
||||
try {
|
||||
expect(() => assertInteractiveOAuthAllowed()).not.toThrow();
|
||||
} finally {
|
||||
if (stdinDesc) Object.defineProperty(process.stdin, "isTTY", stdinDesc);
|
||||
else delete (process.stdin as { isTTY?: boolean }).isTTY;
|
||||
if (stderrDesc)
|
||||
Object.defineProperty(process.stderr, "isTTY", stderrDesc);
|
||||
else delete (process.stderr as { isTTY?: boolean }).isTTY;
|
||||
}
|
||||
});
|
||||
|
||||
it("assertInteractiveOAuthAllowed fails when both stdin and stderr are non-TTY", () => {
|
||||
const stdinDesc = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
|
||||
const stderrDesc = Object.getOwnPropertyDescriptor(process.stderr, "isTTY");
|
||||
Object.defineProperty(process.stdin, "isTTY", {
|
||||
configurable: true,
|
||||
get: () => false,
|
||||
});
|
||||
Object.defineProperty(process.stderr, "isTTY", {
|
||||
configurable: true,
|
||||
get: () => false,
|
||||
});
|
||||
try {
|
||||
expect(() => assertInteractiveOAuthAllowed()).toThrow(
|
||||
/Interactive OAuth requires a TTY on stdin or stderr/,
|
||||
);
|
||||
} finally {
|
||||
if (stdinDesc) Object.defineProperty(process.stdin, "isTTY", stdinDesc);
|
||||
else delete (process.stdin as { isTTY?: boolean }).isTTY;
|
||||
if (stderrDesc)
|
||||
Object.defineProperty(process.stderr, "isTTY", stderrDesc);
|
||||
else delete (process.stderr as { isTTY?: boolean }).isTTY;
|
||||
}
|
||||
});
|
||||
|
||||
it("runCliInteractiveOAuth is quiet on already_authorized", async () => {
|
||||
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({
|
||||
kind: "already_authorized",
|
||||
});
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation(() => true);
|
||||
await runCliInteractiveOAuth(
|
||||
{
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
},
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
);
|
||||
expect(stderrSpy).not.toHaveBeenCalledWith("Authorization complete.\n");
|
||||
});
|
||||
|
||||
it("connectInspectorWithOAuth stored-auth-only uses fallback when AuthRecovery message is empty", async () => {
|
||||
const err = new AuthRecoveryRequiredError(
|
||||
new URL("https://as.example/authorize"),
|
||||
{ reason: "token_expired" },
|
||||
);
|
||||
Object.defineProperty(err, "message", { value: "" });
|
||||
const connect = vi.fn().mockRejectedValue(err);
|
||||
await expect(
|
||||
connectInspectorWithOAuth(
|
||||
{
|
||||
connect,
|
||||
disconnect: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false),
|
||||
},
|
||||
OAUTH_HTTP_CONFIG,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
{ storedAuthOnly: true },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
exitCode: 3,
|
||||
message: expect.stringMatching(/--stored-auth-only/),
|
||||
});
|
||||
});
|
||||
|
||||
it("withCliAuthRecoveryRetry refuses interactive OAuth on a non-TTY", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const fn = vi.fn().mockRejectedValue(new Error("RPC failed (401)"));
|
||||
|
||||
await expect(
|
||||
withCliAuthRecoveryRetry(
|
||||
{
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
},
|
||||
OAUTH_HTTP_CONFIG,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
fn,
|
||||
{ isTTY: false },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
exitCode: 3,
|
||||
message: expect.stringMatching(/--stored-auth-only/),
|
||||
});
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("withCliAuthRecoveryRetry allows non-TTY interactive OAuth when MCP_AUTO_OPEN_ENABLED=true", async () => {
|
||||
const prev = process.env.MCP_AUTO_OPEN_ENABLED;
|
||||
process.env.MCP_AUTO_OPEN_ENABLED = "true";
|
||||
try {
|
||||
const runSpy = vi
|
||||
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||
.mockResolvedValue({ kind: "success" });
|
||||
const connect = vi.fn().mockResolvedValue(undefined);
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("RPC failed (401)"))
|
||||
.mockResolvedValueOnce("ok");
|
||||
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
||||
|
||||
const result = await withCliAuthRecoveryRetry(
|
||||
{
|
||||
connect,
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
},
|
||||
OAUTH_HTTP_CONFIG,
|
||||
new MutableRedirectUrlProvider(),
|
||||
CALLBACK_URL_CONFIG,
|
||||
undefined,
|
||||
fn,
|
||||
{ isTTY: false },
|
||||
);
|
||||
|
||||
expect(result).toBe("ok");
|
||||
expect(runSpy).toHaveBeenCalledOnce();
|
||||
expect(connect).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env.MCP_AUTO_OPEN_ENABLED;
|
||||
} else {
|
||||
process.env.MCP_AUTO_OPEN_ENABLED = prev;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,6 +31,22 @@ describe("emitResult", () => {
|
|||
process.stdout.write = originalWrite;
|
||||
});
|
||||
|
||||
it("uses a default appInfo shell when --app-info has no collected info", async () => {
|
||||
const promise = emitResult(undefined as never, undefined, {
|
||||
appInfo: true,
|
||||
toolName: "missing-app",
|
||||
format: "text",
|
||||
});
|
||||
await expect(promise).rejects.toBeInstanceOf(CliExitCodeError);
|
||||
await promise.catch((e: CliExitCodeError) => {
|
||||
expect(e.exitCode).toBe(2);
|
||||
});
|
||||
expect(JSON.parse(stdout.trim())).toMatchObject({
|
||||
hasApp: false,
|
||||
toolName: "missing-app",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws TOOL_ERROR (code:tool_is_error) when the result has isError:true", async () => {
|
||||
const promise = emitResult({ isError: true }, undefined, {
|
||||
toolName: "boom",
|
||||
|
|
@ -108,6 +124,25 @@ describe("collectAppInfo", () => {
|
|||
expect(info.resourceError).toBe("string failure");
|
||||
});
|
||||
|
||||
it("stringifies a non-Error throw from extractAppInfo", async () => {
|
||||
const extract = await import("@inspector/core/mcp/apps.js");
|
||||
const spy = vi
|
||||
.spyOn(extract, "extractAppInfo")
|
||||
.mockImplementationOnce(() => {
|
||||
throw "boom-string";
|
||||
});
|
||||
try {
|
||||
const info = await collectAppInfo(
|
||||
{ readResource: vi.fn() } as never,
|
||||
{ name: "t", inputSchema: { type: "object" as const } },
|
||||
undefined,
|
||||
);
|
||||
expect(info.resourceError).toBe("boom-string");
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("folds a malformed _meta.ui.resourceUri into {hasApp:false, resourceError} instead of throwing", async () => {
|
||||
// extractAppInfo throws when the advertised UI URI is not a `ui://` string;
|
||||
// collectAppInfo must tolerate that so the tools/list --app-info NDJSON loop
|
||||
|
|
|
|||
50
clients/cli/__tests__/format-output.test.ts
Normal file
50
clients/cli/__tests__/format-output.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { writeFormattedResult } from "../src/handlers/format-output.js";
|
||||
|
||||
describe("writeFormattedResult", () => {
|
||||
let originalWrite: typeof process.stdout.write;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWrite) process.stdout.write = originalWrite;
|
||||
});
|
||||
|
||||
function captureStdout(): { get: () => string; restore: () => void } {
|
||||
let out = "";
|
||||
originalWrite = process.stdout.write;
|
||||
process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => {
|
||||
out += String(chunk);
|
||||
const cb = rest.find((x) => typeof x === "function") as
|
||||
| (() => void)
|
||||
| undefined;
|
||||
cb?.();
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
return {
|
||||
get: () => out,
|
||||
restore: () => {
|
||||
process.stdout.write = originalWrite;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("defaults to pretty text and supports the json envelope", async () => {
|
||||
const cap = captureStdout();
|
||||
try {
|
||||
await writeFormattedResult({ ok: 1 });
|
||||
expect(cap.get()).toContain('"ok": 1');
|
||||
expect(cap.get()).not.toContain('"result"');
|
||||
|
||||
// reset buffer by re-capturing
|
||||
cap.restore();
|
||||
const cap2 = captureStdout();
|
||||
try {
|
||||
await writeFormattedResult({ ok: 2 }, "json");
|
||||
expect(JSON.parse(cap2.get())).toEqual({ result: { ok: 2 } });
|
||||
} finally {
|
||||
cap2.restore();
|
||||
}
|
||||
} finally {
|
||||
cap.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
10
clients/cli/__tests__/helpers/mock-open-url.ts
Normal file
10
clients/cli/__tests__/helpers/mock-open-url.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Structural guard for the whole CLI suite: armed interactive OAuth must not
|
||||
* shell out to a real browser. Registered via vitest `setupFiles` so it does
|
||||
* not depend on import order in individual test files.
|
||||
*/
|
||||
vi.mock("../../src/open-url.js", () => ({
|
||||
openUrl: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
|
@ -428,6 +428,43 @@ describe("Metadata Tests", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("JSON.stringifies object/array metadata (not String → [object Object])", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const result = await runCli([
|
||||
server.url,
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
"--metadata",
|
||||
'nested={"key":"value"}',
|
||||
"list=[1,2,3]",
|
||||
"flag=true",
|
||||
"--transport",
|
||||
"http",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
|
||||
const recordedRequests = server.getRecordedRequests();
|
||||
const toolsListRequest = recordedRequests.find(
|
||||
(r) => r.method === "tools/list",
|
||||
);
|
||||
expect(toolsListRequest?.metadata).toEqual({
|
||||
nested: '{"key":"value"}',
|
||||
list: "[1,2,3]",
|
||||
flag: "true",
|
||||
});
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle special characters", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
|
|
|
|||
36
clients/cli/__tests__/method-types.test.ts
Normal file
36
clients/cli/__tests__/method-types.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isOneShotMethod,
|
||||
metaValueToString,
|
||||
ONE_SHOT_METHODS,
|
||||
SESSION_RPC_METHODS,
|
||||
} from "../src/handlers/method-types.js";
|
||||
|
||||
describe("SESSION_RPC_METHODS", () => {
|
||||
it("lists the full RPC method set supported by runMethod", () => {
|
||||
expect(SESSION_RPC_METHODS).toContain("tools/list");
|
||||
expect(SESSION_RPC_METHODS).toContain("tools/call");
|
||||
expect(SESSION_RPC_METHODS).toContain("logging/tail");
|
||||
expect(SESSION_RPC_METHODS).toContain("roots/set");
|
||||
expect(new Set(SESSION_RPC_METHODS).size).toBe(SESSION_RPC_METHODS.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ONE_SHOT_METHODS", () => {
|
||||
it("excludes stream and session-only methods", () => {
|
||||
expect(isOneShotMethod("tools/list")).toBe(true);
|
||||
expect(isOneShotMethod("logging/setLevel")).toBe(true);
|
||||
expect(isOneShotMethod("logging/tail")).toBe(false);
|
||||
expect(isOneShotMethod("resources/subscribe")).toBe(false);
|
||||
expect(isOneShotMethod("tasks/list")).toBe(false);
|
||||
expect(ONE_SHOT_METHODS).not.toContain("logging/tail");
|
||||
});
|
||||
});
|
||||
|
||||
describe("metaValueToString", () => {
|
||||
it("passes strings through and JSON-encodes structured values", () => {
|
||||
expect(metaValueToString("plain")).toBe("plain");
|
||||
expect(metaValueToString({ a: 1 })).toBe('{"a":1}');
|
||||
expect(metaValueToString([1, 2])).toBe("[1,2]");
|
||||
});
|
||||
});
|
||||
|
|
@ -59,20 +59,43 @@ describe("CLI method coverage", () => {
|
|||
expect(Array.isArray(json.tools)).toBe(true);
|
||||
});
|
||||
|
||||
it("throws for an unsupported method after connecting", async () => {
|
||||
// Reaches the final `else` in callMethod: the connection succeeds, none of
|
||||
// the known method branches match, so it throws "Unsupported method".
|
||||
const result = await runCli([
|
||||
it("rejects unsupported and stream methods before connecting", async () => {
|
||||
const unknown = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"definitely/not-a-real-method",
|
||||
]);
|
||||
expectCliFailure(unknown);
|
||||
expectOutputContains(unknown, "Unsupported method");
|
||||
expectOutputContains(unknown, "definitely/not-a-real-method");
|
||||
|
||||
// Long-lived / stream methods are rejected by the CLI method allowlist.
|
||||
const tail = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--cli",
|
||||
"--method",
|
||||
"logging/tail",
|
||||
]);
|
||||
expectCliFailure(tail);
|
||||
expectOutputContains(tail, "Unsupported method");
|
||||
expectOutputContains(tail, "logging/tail");
|
||||
});
|
||||
|
||||
it("rejects bad methods before --use-stored-auth network work", async () => {
|
||||
// No oauth.json / refresh — must fail on allowlist, not no_stored_token.
|
||||
const result = await runCli([
|
||||
"--method",
|
||||
"logging/tail",
|
||||
"--use-stored-auth",
|
||||
"--server-url",
|
||||
"https://example.com/mcp",
|
||||
]);
|
||||
expectCliFailure(result);
|
||||
expectOutputContains(result, "Unsupported method");
|
||||
expectOutputContains(result, "definitely/not-a-real-method");
|
||||
expect(result.stderr).not.toMatch(/No stored OAuth token/);
|
||||
});
|
||||
|
||||
it("rejects an invalid --transport value before connecting", async () => {
|
||||
|
|
|
|||
|
|
@ -147,6 +147,8 @@ describe("CLI interactive OAuth (integration)", () => {
|
|||
{ type: "streamable-http", url: `${serverUrl}/mcp` },
|
||||
redirectUrlProvider,
|
||||
callbackUrlConfig,
|
||||
undefined,
|
||||
{ isTTY: true },
|
||||
);
|
||||
|
||||
const tools = await client.listTools();
|
||||
|
|
@ -206,6 +208,8 @@ describe("CLI interactive OAuth (integration)", () => {
|
|||
{ type: "streamable-http", url: `${serverUrl}/mcp` },
|
||||
redirectUrlProvider,
|
||||
callbackUrlConfig,
|
||||
undefined,
|
||||
{ isTTY: true },
|
||||
);
|
||||
|
||||
const tools = await client.listTools();
|
||||
|
|
@ -225,6 +229,7 @@ describe("CLI interactive OAuth (integration)", () => {
|
|||
try {
|
||||
const result = await withCliAuthRecoveryRetry(
|
||||
client,
|
||||
{ type: "streamable-http", url: `${serverUrl}/mcp` },
|
||||
redirectUrlProvider,
|
||||
callbackUrlConfig,
|
||||
{},
|
||||
|
|
@ -233,7 +238,7 @@ describe("CLI interactive OAuth (integration)", () => {
|
|||
city: "NYC",
|
||||
units: "C",
|
||||
}),
|
||||
async () => true,
|
||||
{ confirmStepUp: async () => true, isTTY: true },
|
||||
);
|
||||
|
||||
expect(stepUpPrompted).toBe(true);
|
||||
|
|
|
|||
31
clients/cli/__tests__/open-url.test.ts
Normal file
31
clients/cli/__tests__/open-url.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const openMock = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("open", () => ({
|
||||
default: (...args: unknown[]) => openMock(...args),
|
||||
}));
|
||||
|
||||
// Suite-wide setup mocks open-url; this file exercises the real wrapper.
|
||||
vi.unmock("../src/open-url.js");
|
||||
|
||||
describe("openUrl", () => {
|
||||
beforeEach(() => {
|
||||
openMock.mockClear();
|
||||
openMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("forwards a string URL to open", async () => {
|
||||
const { openUrl } = await import("../src/open-url.js");
|
||||
await openUrl("https://example.com/auth");
|
||||
expect(openMock).toHaveBeenCalledWith("https://example.com/auth");
|
||||
});
|
||||
|
||||
it("forwards URL.href for URL instances", async () => {
|
||||
const { openUrl } = await import("../src/open-url.js");
|
||||
await openUrl(new URL("https://example.com/callback?code=1"));
|
||||
expect(openMock).toHaveBeenCalledWith(
|
||||
"https://example.com/callback?code=1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -135,6 +135,98 @@ describe("--tool-args-json", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("CLI --relogin flag conflicts", () => {
|
||||
it("rejects --relogin with --stored-auth-only", async () => {
|
||||
const result = await runCli([
|
||||
"--relogin",
|
||||
"--stored-auth-only",
|
||||
"--server-url",
|
||||
"https://example.com/mcp",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
expectCliFailure(result);
|
||||
expect(result.stderr).toMatch(
|
||||
/--relogin cannot be combined with --stored-auth-only/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects --relogin with --use-stored-auth", async () => {
|
||||
const result = await runCli([
|
||||
"--relogin",
|
||||
"--use-stored-auth",
|
||||
"--server-url",
|
||||
"https://example.com/mcp",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
expectCliFailure(result);
|
||||
expect(result.stderr).toMatch(/--use-stored-auth/);
|
||||
});
|
||||
|
||||
it("rejects --relogin with catalog-only methods", async () => {
|
||||
const result = await runCli(["--relogin", "--method", "servers/list"]);
|
||||
expectCliFailure(result);
|
||||
expect(result.stderr).toMatch(/servers\/list or servers\/show/);
|
||||
});
|
||||
|
||||
it("rejects --relogin with --list-stored-auth", async () => {
|
||||
const result = await runCli(["--relogin", "--list-stored-auth"]);
|
||||
expectCliFailure(result);
|
||||
expect(result.stderr).toMatch(/--list-stored-auth or --print-handoff/);
|
||||
});
|
||||
|
||||
it("rejects --relogin for stdio servers", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
const result = await runCli([
|
||||
command,
|
||||
...args,
|
||||
"--relogin",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
expectCliFailure(result);
|
||||
expect(result.stderr).toMatch(/HTTP\/SSE server URL/);
|
||||
});
|
||||
|
||||
it("accepts --relogin and clears stored auth before connect", async () => {
|
||||
// Unreachable port: proves --relogin runs (clears store) then fails connect.
|
||||
const result = await runCli([
|
||||
"--relogin",
|
||||
"--server-url",
|
||||
"http://127.0.0.1:1/mcp",
|
||||
"--transport",
|
||||
"http",
|
||||
"--connect-timeout",
|
||||
"200",
|
||||
"--method",
|
||||
"initialize",
|
||||
]);
|
||||
expectCliFailure(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI --stored-auth-only wiring", () => {
|
||||
it("accepts the flag and fails connect without interactive OAuth prompts", async () => {
|
||||
// Unreachable host: proves the flag is accepted through parse → callMethod
|
||||
// and does not hang on an interactive OAuth prompt.
|
||||
const result = await runCli([
|
||||
"--stored-auth-only",
|
||||
"--server-url",
|
||||
"http://127.0.0.1:1/mcp",
|
||||
"--transport",
|
||||
"http",
|
||||
"--connect-timeout",
|
||||
"200",
|
||||
"--method",
|
||||
"initialize",
|
||||
]);
|
||||
expectCliFailure(result);
|
||||
expect(result.stderr).not.toMatch(/Please navigate to:/);
|
||||
expect(result.stderr).not.toMatch(/Proceed with step-up/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP_CATALOG_PATH with an ad-hoc target", () => {
|
||||
it("does not conflict with an ad-hoc target (env catalog is ignored)", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
|
|
|
|||
232
clients/cli/__tests__/run-method-mocks.test.ts
Normal file
232
clients/cli/__tests__/run-method-mocks.test.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { runMethod } from "../src/handlers/run-method.js";
|
||||
import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||
|
||||
function mockClient(overrides: Partial<InspectorClient> = {}): InspectorClient {
|
||||
const base = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
subscribeToResource: vi.fn().mockResolvedValue(undefined),
|
||||
unsubscribeFromResource: vi.fn().mockResolvedValue(undefined),
|
||||
getCompletions: vi.fn().mockResolvedValue({ values: ["a"] }),
|
||||
getRequestorTask: vi.fn().mockResolvedValue({ taskId: "t1" }),
|
||||
cancelRequestorTask: vi.fn().mockResolvedValue(undefined),
|
||||
getRequestorTaskResult: vi.fn().mockResolvedValue({ content: [] }),
|
||||
getRoots: vi.fn().mockReturnValue([]),
|
||||
setRoots: vi.fn().mockResolvedValue(undefined),
|
||||
setLoggingLevel: vi.fn().mockResolvedValue(undefined),
|
||||
getServerInfo: vi.fn().mockReturnValue({ name: "t" }),
|
||||
getProtocolVersion: vi.fn().mockReturnValue("1"),
|
||||
getCapabilities: vi.fn().mockReturnValue({}),
|
||||
getInstructions: vi.fn().mockReturnValue(undefined),
|
||||
callTool: vi.fn(),
|
||||
callToolStream: vi.fn(),
|
||||
readResource: vi.fn(),
|
||||
getPrompt: vi.fn(),
|
||||
};
|
||||
return { ...base, ...overrides } as unknown as InspectorClient;
|
||||
}
|
||||
|
||||
vi.mock("@inspector/core/mcp/state/index.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@inspector/core/mcp/state/index.js")>();
|
||||
class FakeManaged {
|
||||
setMetadata = vi.fn();
|
||||
refresh = vi.fn().mockResolvedValue(undefined);
|
||||
destroy = vi.fn();
|
||||
getTools = vi
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
{ name: "echo", description: "e", inputSchema: { type: "object" } },
|
||||
]);
|
||||
getResources = vi.fn().mockReturnValue([]);
|
||||
getResourceTemplates = vi.fn().mockReturnValue([]);
|
||||
getPrompts = vi.fn().mockReturnValue([{ name: "p" }]);
|
||||
getTasks = vi.fn().mockReturnValue([{ taskId: "t1" }]);
|
||||
}
|
||||
class FakeLog {
|
||||
static last: FakeLog | undefined;
|
||||
addEventListener = vi.fn();
|
||||
removeEventListener = vi.fn();
|
||||
destroy = vi.fn();
|
||||
constructor() {
|
||||
FakeLog.last = this;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
ManagedToolsState: FakeManaged,
|
||||
ManagedResourcesState: FakeManaged,
|
||||
ManagedResourceTemplatesState: FakeManaged,
|
||||
ManagedPromptsState: FakeManaged,
|
||||
ManagedRequestorTasksState: FakeManaged,
|
||||
MessageLogState: FakeLog,
|
||||
};
|
||||
});
|
||||
|
||||
describe("runMethod (mocked client)", () => {
|
||||
it("covers subscribe stream, tasks, complete, and app-info call", async () => {
|
||||
const client = mockClient({
|
||||
callTool: vi.fn().mockResolvedValue({
|
||||
result: { content: [{ type: "text", text: "ok" }] },
|
||||
toolName: "echo",
|
||||
params: {},
|
||||
timestamp: new Date(),
|
||||
success: true,
|
||||
}),
|
||||
callToolStream: vi.fn().mockResolvedValue({
|
||||
result: { content: [] },
|
||||
toolName: "echo",
|
||||
params: {},
|
||||
timestamp: new Date(),
|
||||
success: true,
|
||||
}),
|
||||
readResource: vi.fn().mockResolvedValue({
|
||||
result: { contents: [] },
|
||||
}),
|
||||
});
|
||||
|
||||
const sub = await runMethod(client, {
|
||||
method: "resources/subscribe",
|
||||
uri: "test://x",
|
||||
});
|
||||
expect(sub.kind).toBe("stream");
|
||||
if (sub.kind === "stream") {
|
||||
const lines: unknown[] = [];
|
||||
const stop = sub.start((o) => lines.push(o));
|
||||
expect(lines[0]).toMatchObject({ type: "subscribed" });
|
||||
// Simulate update event
|
||||
const listener = (
|
||||
client.addEventListener as ReturnType<typeof vi.fn>
|
||||
).mock.calls.find((c) => c[0] === "resourceUpdated")?.[1] as (
|
||||
ev: Event,
|
||||
) => void;
|
||||
listener?.(
|
||||
new CustomEvent("resourceUpdated", { detail: { uri: "test://x" } }),
|
||||
);
|
||||
expect(
|
||||
lines.some(
|
||||
(l) => (l as { type?: string }).type === "resources/updated",
|
||||
),
|
||||
).toBe(true);
|
||||
stop();
|
||||
}
|
||||
|
||||
const tasks = await runMethod(client, { method: "tasks/list" });
|
||||
expect(tasks.kind).toBe("result");
|
||||
|
||||
const got = await runMethod(client, {
|
||||
method: "tasks/get",
|
||||
taskId: "t1",
|
||||
});
|
||||
expect(got.kind).toBe("result");
|
||||
|
||||
const cancelled = await runMethod(client, {
|
||||
method: "tasks/cancel",
|
||||
taskId: "t1",
|
||||
});
|
||||
expect(cancelled.kind).toBe("result");
|
||||
|
||||
const result = await runMethod(client, {
|
||||
method: "tasks/result",
|
||||
taskId: "t1",
|
||||
});
|
||||
expect(result.kind).toBe("result");
|
||||
|
||||
const complete = await runMethod(client, {
|
||||
method: "prompts/complete",
|
||||
completeRefType: "ref/prompt",
|
||||
completeRef: "p",
|
||||
completeArgName: "a",
|
||||
completeArgValue: "x",
|
||||
});
|
||||
expect(complete.kind).toBe("result");
|
||||
|
||||
const completeRes = await runMethod(client, {
|
||||
method: "prompts/complete",
|
||||
completeRefType: "ref/resource",
|
||||
completeRef: "test://x",
|
||||
completeArgName: "a",
|
||||
});
|
||||
expect(completeRes.kind).toBe("result");
|
||||
|
||||
const appOnly = await runMethod(client, {
|
||||
method: "tools/call",
|
||||
toolName: "echo",
|
||||
appInfo: true,
|
||||
});
|
||||
expect(appOnly.kind).toBe("result");
|
||||
|
||||
const tasked = await runMethod(client, {
|
||||
method: "tools/call",
|
||||
toolName: "echo",
|
||||
task: true,
|
||||
toolArg: { message: "x" },
|
||||
});
|
||||
expect(tasked.kind).toBe("result");
|
||||
expect(client.callToolStream).toHaveBeenCalled();
|
||||
|
||||
vi.mocked(client.callTool).mockResolvedValueOnce({
|
||||
result: null,
|
||||
error: "Tool call failed hard",
|
||||
toolName: "echo",
|
||||
params: {},
|
||||
timestamp: new Date(),
|
||||
success: false,
|
||||
} as never);
|
||||
const nullResult = await runMethod(client, {
|
||||
method: "tools/call",
|
||||
toolName: "echo",
|
||||
toolArg: { message: "x" },
|
||||
});
|
||||
expect(nullResult.kind).toBe("result");
|
||||
if (nullResult.kind === "result") {
|
||||
expect(nullResult.result).toMatchObject({ isError: true });
|
||||
}
|
||||
|
||||
await expect(runMethod(client, { method: "tasks/cancel" })).rejects.toThrow(
|
||||
/tasks\/cancel/,
|
||||
);
|
||||
await expect(runMethod(client, { method: "tasks/result" })).rejects.toThrow(
|
||||
/tasks\/result/,
|
||||
);
|
||||
|
||||
await expect(
|
||||
runMethod(client, {
|
||||
method: "roots/set",
|
||||
rootsJson: "not-json",
|
||||
}),
|
||||
).rejects.toThrow(/roots-json is invalid/);
|
||||
|
||||
const tail = await runMethod(client, { method: "logging/tail" });
|
||||
expect(tail.kind).toBe("stream");
|
||||
if (tail.kind === "stream") {
|
||||
const lines: unknown[] = [];
|
||||
const stop = tail.start((o) => lines.push(o));
|
||||
const logMod = await import("@inspector/core/mcp/state/index.js");
|
||||
const FakeLog = logMod.MessageLogState as unknown as {
|
||||
last?: {
|
||||
addEventListener: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
const listener = FakeLog.last?.addEventListener.mock.calls[0]?.[1] as
|
||||
| ((ev: Event) => void)
|
||||
| undefined;
|
||||
listener?.(
|
||||
new CustomEvent("message", {
|
||||
detail: {
|
||||
direction: "notification",
|
||||
message: { method: "notifications/message" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
listener?.(
|
||||
new CustomEvent("message", {
|
||||
detail: { direction: "request", message: { method: "tools/list" } },
|
||||
}),
|
||||
);
|
||||
expect(lines).toHaveLength(1);
|
||||
stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
249
clients/cli/__tests__/run-method.test.ts
Normal file
249
clients/cli/__tests__/run-method.test.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server";
|
||||
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||
import { createTransportNode } from "@inspector/core/mcp/node/index.js";
|
||||
import { runMethod } from "../src/handlers/run-method.js";
|
||||
import { consumeMethodOutcome } from "../src/handlers/consume-outcome.js";
|
||||
|
||||
describe("runMethod", () => {
|
||||
let client: InspectorClient | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (client) {
|
||||
await client.disconnect().catch(() => {});
|
||||
client = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
async function connectStdio(): Promise<InspectorClient> {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
client = new InspectorClient(
|
||||
{ type: "stdio", command, args },
|
||||
{
|
||||
environment: { transport: createTransportNode },
|
||||
clientIdentity: { name: "test", version: "0.0.0" },
|
||||
progress: false,
|
||||
sample: false,
|
||||
elicit: false,
|
||||
},
|
||||
);
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
it("lists tools and reads initialize fields", async () => {
|
||||
const c = await connectStdio();
|
||||
const tools = await runMethod(c, { method: "tools/list" });
|
||||
expect(tools.kind).toBe("result");
|
||||
if (tools.kind === "result") {
|
||||
expect(
|
||||
(tools.result.tools as unknown[] | undefined)?.length,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const init = await runMethod(c, { method: "initialize" });
|
||||
expect(init.kind).toBe("result");
|
||||
});
|
||||
|
||||
it("returns ndjson lines for tools/list --app-info", async () => {
|
||||
const c = await connectStdio();
|
||||
const outcome = await runMethod(c, {
|
||||
method: "tools/list",
|
||||
appInfo: true,
|
||||
});
|
||||
expect(outcome.kind).toBe("ndjson");
|
||||
if (outcome.kind === "ndjson") {
|
||||
expect(outcome.lines.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("lists resources and prompts", async () => {
|
||||
const c = await connectStdio();
|
||||
const resources = await runMethod(c, { method: "resources/list" });
|
||||
expect(resources.kind).toBe("result");
|
||||
const prompts = await runMethod(c, { method: "prompts/list" });
|
||||
expect(prompts.kind).toBe("result");
|
||||
const templates = await runMethod(c, {
|
||||
method: "resources/templates/list",
|
||||
});
|
||||
expect(templates.kind).toBe("result");
|
||||
});
|
||||
|
||||
it("sets logging level", async () => {
|
||||
const c = await connectStdio();
|
||||
const outcome = await runMethod(c, {
|
||||
method: "logging/setLevel",
|
||||
logLevel: "info",
|
||||
});
|
||||
expect(outcome.kind).toBe("result");
|
||||
});
|
||||
|
||||
it("roots/list and roots/set round-trip", async () => {
|
||||
const c = await connectStdio();
|
||||
const listed = await runMethod(c, { method: "roots/list" });
|
||||
expect(listed.kind).toBe("result");
|
||||
const set = await runMethod(c, {
|
||||
method: "roots/set",
|
||||
rootsJson: JSON.stringify([{ uri: "file:///tmp", name: "tmp" }]),
|
||||
});
|
||||
expect(set.kind).toBe("result");
|
||||
if (set.kind === "result") {
|
||||
expect(set.result.roots).toEqual([{ uri: "file:///tmp", name: "tmp" }]);
|
||||
}
|
||||
});
|
||||
|
||||
it("consumeMethodOutcome writes result json", async () => {
|
||||
let stdout = "";
|
||||
const original = process.stdout.write;
|
||||
process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => {
|
||||
stdout += typeof chunk === "string" ? chunk : String(chunk);
|
||||
const cb = rest.find((r) => typeof r === "function") as
|
||||
| (() => void)
|
||||
| undefined;
|
||||
cb?.();
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
try {
|
||||
await consumeMethodOutcome(
|
||||
{ kind: "result", result: { ok: true } },
|
||||
{ format: "json" },
|
||||
);
|
||||
expect(JSON.parse(stdout.trim())).toEqual({ result: { ok: true } });
|
||||
} finally {
|
||||
process.stdout.write = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown methods", async () => {
|
||||
const c = await connectStdio();
|
||||
await expect(runMethod(c, { method: "nope/method" })).rejects.toThrow(
|
||||
/Unsupported method/,
|
||||
);
|
||||
});
|
||||
|
||||
it("calls echo tool and rejects missing tool name", async () => {
|
||||
const c = await connectStdio();
|
||||
await expect(runMethod(c, { method: "tools/call" })).rejects.toThrow(
|
||||
/Tool name is required/,
|
||||
);
|
||||
|
||||
const missing = runMethod(c, {
|
||||
method: "tools/call",
|
||||
toolName: "does-not-exist",
|
||||
});
|
||||
await expect(missing).rejects.toMatchObject({ exitCode: 5 });
|
||||
|
||||
const called = await runMethod(c, {
|
||||
method: "tools/call",
|
||||
toolName: "echo",
|
||||
toolArg: { message: "hi" },
|
||||
format: "json",
|
||||
});
|
||||
expect(called.kind).toBe("result");
|
||||
});
|
||||
|
||||
it("reads a resource and gets a prompt", async () => {
|
||||
const c = await connectStdio();
|
||||
const read = await runMethod(c, {
|
||||
method: "resources/read",
|
||||
uri: "test://env",
|
||||
});
|
||||
expect(read.kind).toBe("result");
|
||||
|
||||
await expect(runMethod(c, { method: "resources/read" })).rejects.toThrow(
|
||||
/URI is required/,
|
||||
);
|
||||
|
||||
const prompts = await runMethod(c, { method: "prompts/list" });
|
||||
expect(prompts.kind).toBe("result");
|
||||
if (prompts.kind === "result") {
|
||||
const list = prompts.result.prompts as { name: string }[];
|
||||
if (list.length > 0) {
|
||||
const got = await runMethod(c, {
|
||||
method: "prompts/get",
|
||||
promptName: list[0]!.name,
|
||||
});
|
||||
expect(got.kind).toBe("result");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a stream starter for logging/tail", async () => {
|
||||
const c = await connectStdio();
|
||||
const outcome = await runMethod(c, { method: "logging/tail" });
|
||||
expect(outcome.kind).toBe("stream");
|
||||
if (outcome.kind === "stream") {
|
||||
const stop = outcome.start(() => {});
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("requires uri for subscribe/unsubscribe", async () => {
|
||||
const c = await connectStdio();
|
||||
await expect(
|
||||
runMethod(c, { method: "resources/subscribe" }),
|
||||
).rejects.toThrow(/URI is required/);
|
||||
await expect(
|
||||
runMethod(c, { method: "resources/unsubscribe" }),
|
||||
).rejects.toThrow(/URI is required/);
|
||||
});
|
||||
|
||||
it("rejects bad roots/set JSON and missing logging level", async () => {
|
||||
const c = await connectStdio();
|
||||
await expect(runMethod(c, { method: "logging/setLevel" })).rejects.toThrow(
|
||||
/Log level is required/,
|
||||
);
|
||||
await expect(
|
||||
runMethod(c, { method: "roots/set", rootsJson: "{}" }),
|
||||
).rejects.toThrow(/must be a JSON array/);
|
||||
await expect(runMethod(c, { method: "roots/set" })).rejects.toThrow(
|
||||
/roots\/set requires/,
|
||||
);
|
||||
await expect(runMethod(c, { method: "tasks/get" })).rejects.toThrow(
|
||||
/Task id is required/,
|
||||
);
|
||||
await expect(runMethod(c, { method: "prompts/get" })).rejects.toThrow(
|
||||
/Prompt name is required/,
|
||||
);
|
||||
await expect(runMethod(c, { method: "prompts/complete" })).rejects.toThrow(
|
||||
/prompts\/complete requires/,
|
||||
);
|
||||
});
|
||||
|
||||
it("consumeMethodOutcome writes ndjson and stream until SIGINT", async () => {
|
||||
let stdout = "";
|
||||
const original = process.stdout.write;
|
||||
process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => {
|
||||
stdout += typeof chunk === "string" ? chunk : String(chunk);
|
||||
const cb = rest.find((r) => typeof r === "function") as
|
||||
| (() => void)
|
||||
| undefined;
|
||||
cb?.();
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
try {
|
||||
await consumeMethodOutcome(
|
||||
{ kind: "ndjson", lines: [{ a: 1 }, { b: 2 }] },
|
||||
{},
|
||||
);
|
||||
expect(stdout.trim().split("\n")).toHaveLength(2);
|
||||
|
||||
const streamPromise = consumeMethodOutcome(
|
||||
{
|
||||
kind: "stream",
|
||||
label: "t",
|
||||
start: (write) => {
|
||||
write({ hi: true });
|
||||
queueMicrotask(() => process.emit("SIGINT"));
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
{},
|
||||
);
|
||||
await streamPromise;
|
||||
expect(stdout).toContain('"hi":true');
|
||||
} finally {
|
||||
process.stdout.write = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
355
clients/cli/__tests__/servers-list.test.ts
Normal file
355
clients/cli/__tests__/servers-list.test.ts
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { runCli } from "./helpers/cli-runner.js";
|
||||
import {
|
||||
createSampleTestConfig,
|
||||
createTestConfig,
|
||||
deleteConfigFile,
|
||||
} from "./helpers/fixtures.js";
|
||||
import { expectCliSuccess } from "./helpers/assertions.js";
|
||||
import {
|
||||
annotateServerEntriesWithSessions,
|
||||
listServerEntries,
|
||||
sanitizeServerConfig,
|
||||
sanitizeServerSettings,
|
||||
showServerEntry,
|
||||
summarizeServerConfig,
|
||||
} from "../src/handlers/servers-list.js";
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
MCPServerConfig,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
import { expectCliFailure } from "./helpers/assertions.js";
|
||||
import {
|
||||
InMemorySecretStore,
|
||||
SECRET_FIELD_OAUTH_CLIENT_SECRET,
|
||||
envSecretField,
|
||||
} from "@inspector/core/auth/node/secret-store.js";
|
||||
|
||||
describe("summarizeServerConfig", () => {
|
||||
it("summarises stdio, sse, and streamable-http configs", () => {
|
||||
expect(
|
||||
summarizeServerConfig({
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
args: ["server.js"],
|
||||
}),
|
||||
).toEqual({ type: "stdio", detail: "node server.js" });
|
||||
expect(
|
||||
summarizeServerConfig({
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
}),
|
||||
).toEqual({ type: "stdio", detail: "node" });
|
||||
expect(
|
||||
summarizeServerConfig({
|
||||
type: "sse",
|
||||
url: "http://localhost/sse",
|
||||
}),
|
||||
).toEqual({ type: "sse", detail: "http://localhost/sse" });
|
||||
expect(
|
||||
summarizeServerConfig({
|
||||
type: "streamable-http",
|
||||
url: "http://localhost/mcp",
|
||||
} as MCPServerConfig),
|
||||
).toEqual({ type: "streamable-http", detail: "http://localhost/mcp" });
|
||||
expect(
|
||||
summarizeServerConfig({
|
||||
type: "streamable-http",
|
||||
} as MCPServerConfig),
|
||||
).toEqual({ type: "streamable-http", detail: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("annotateServerEntriesWithSessions", () => {
|
||||
const entries = [
|
||||
{ name: "a", type: "stdio", detail: "node a" },
|
||||
{ name: "b", type: "stdio", detail: "node b" },
|
||||
];
|
||||
|
||||
it("returns entries unchanged when there are no sessions", () => {
|
||||
expect(annotateServerEntriesWithSessions(entries, [])).toBe(entries);
|
||||
});
|
||||
|
||||
it("marks matching entry names and MRU", () => {
|
||||
expect(
|
||||
annotateServerEntriesWithSessions(entries, [
|
||||
{ name: "b", isMru: true },
|
||||
{ name: "other" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ name: "a", type: "stdio", detail: "node a" },
|
||||
{ name: "b", type: "stdio", detail: "node b", session: "b", isMru: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits isMru when the session is not MRU", () => {
|
||||
expect(
|
||||
annotateServerEntriesWithSessions(entries, [{ name: "a", isMru: false }]),
|
||||
).toEqual([
|
||||
{ name: "a", type: "stdio", detail: "node a", session: "a" },
|
||||
{ name: "b", type: "stdio", detail: "node b" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listServerEntries / --method servers/list", () => {
|
||||
let configPath: string | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
if (configPath) {
|
||||
deleteConfigFile(configPath);
|
||||
configPath = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it("lists catalog entries without connecting", async () => {
|
||||
configPath = createSampleTestConfig();
|
||||
const entries = await listServerEntries({ configPath });
|
||||
expect(entries.map((e) => e.name).sort()).toEqual([
|
||||
"test-http",
|
||||
"test-stdio",
|
||||
]);
|
||||
expect(entries.find((e) => e.name === "test-stdio")?.type).toBe("stdio");
|
||||
});
|
||||
|
||||
it("works via --method servers/list", async () => {
|
||||
configPath = createSampleTestConfig();
|
||||
const result = await runCli([
|
||||
"--config",
|
||||
configPath,
|
||||
"--method",
|
||||
"servers/list",
|
||||
"--format",
|
||||
"json",
|
||||
]);
|
||||
expectCliSuccess(result);
|
||||
const body = JSON.parse(result.stdout) as {
|
||||
result: { servers: { name: string }[] };
|
||||
};
|
||||
expect(body.result.servers.map((s) => s.name).sort()).toEqual([
|
||||
"test-http",
|
||||
"test-stdio",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("showServerEntry / servers/show", () => {
|
||||
let configPath: string | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
if (configPath) {
|
||||
deleteConfigFile(configPath);
|
||||
configPath = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it("redacts env values, init headers, and sensitive settings fields", () => {
|
||||
expect(
|
||||
sanitizeServerConfig({
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
env: { SECRET: "value", HELLO: "world" },
|
||||
}),
|
||||
).toEqual({
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
env: { SECRET: "[redacted]", HELLO: "[redacted]" },
|
||||
});
|
||||
|
||||
expect(
|
||||
sanitizeServerConfig({
|
||||
type: "streamable-http",
|
||||
url: "https://example.com/mcp",
|
||||
requestInit: {
|
||||
headers: {
|
||||
Authorization: "Bearer secret",
|
||||
Cookie: "session=abc",
|
||||
"X-Auth": "t",
|
||||
"Proxy-Authorization": "Basic x",
|
||||
"X-Custom": "ok",
|
||||
},
|
||||
},
|
||||
eventSourceInit: {
|
||||
headers: {
|
||||
"X-Api-Key": "k",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
},
|
||||
} as MCPServerConfig),
|
||||
).toMatchObject({
|
||||
requestInit: {
|
||||
headers: {
|
||||
Authorization: "[redacted]",
|
||||
Cookie: "[redacted]",
|
||||
"X-Auth": "[redacted]",
|
||||
"Proxy-Authorization": "[redacted]",
|
||||
"X-Custom": "ok",
|
||||
},
|
||||
},
|
||||
eventSourceInit: {
|
||||
headers: {
|
||||
"X-Api-Key": "[redacted]",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
sanitizeServerConfig({
|
||||
type: "streamable-http",
|
||||
url: "https://example.com/mcp",
|
||||
requestInit: {
|
||||
headers: [
|
||||
["Authorization", "Bearer secret"],
|
||||
["X-Custom", "ok"],
|
||||
],
|
||||
},
|
||||
} as MCPServerConfig),
|
||||
).toMatchObject({
|
||||
requestInit: {
|
||||
headers: [
|
||||
["Authorization", "[redacted]"],
|
||||
["X-Custom", "ok"],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const settings: InspectorServerSettings = {
|
||||
headers: [
|
||||
{ key: "Authorization", value: "Bearer x" },
|
||||
{ key: "X-Custom", value: "ok" },
|
||||
],
|
||||
metadata: [
|
||||
{ key: "Authorization", value: "Bearer meta" },
|
||||
{ key: "X-Custom", value: "ok" },
|
||||
],
|
||||
env: [
|
||||
{ key: "TOKEN", value: "secret" },
|
||||
{ key: "", value: "still-secret" },
|
||||
],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 60_000,
|
||||
maxFetchRequests: 100,
|
||||
roots: [],
|
||||
oauthClientSecret: "shh",
|
||||
oauthClientId: "cid",
|
||||
};
|
||||
const sanitized = sanitizeServerSettings(settings);
|
||||
expect(sanitized.oauthClientSecret).toBe("[redacted]");
|
||||
expect(sanitized.oauthClientId).toBe("cid");
|
||||
expect(sanitized.headers).toEqual([
|
||||
{ key: "Authorization", value: "[redacted]" },
|
||||
{ key: "X-Custom", value: "ok" },
|
||||
]);
|
||||
expect(sanitized.metadata).toEqual([
|
||||
{ key: "Authorization", value: "[redacted]" },
|
||||
{ key: "X-Custom", value: "ok" },
|
||||
]);
|
||||
expect(sanitized.env).toEqual([
|
||||
{ key: "TOKEN", value: "[redacted]" },
|
||||
{ key: "", value: "[redacted]" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows one resolved entry without connecting", async () => {
|
||||
configPath = createSampleTestConfig();
|
||||
const entry = await showServerEntry("test-stdio", {
|
||||
configPath,
|
||||
secretStore: new InMemorySecretStore(),
|
||||
});
|
||||
expect(entry.name).toBe("test-stdio");
|
||||
expect(entry.type).toBe("stdio");
|
||||
expect(entry.config).toMatchObject({
|
||||
type: "stdio",
|
||||
command: expect.any(String),
|
||||
});
|
||||
expect(entry.config.env).toEqual({ HELLO: "[redacted]" });
|
||||
});
|
||||
|
||||
it("redacts secrets rehydrated from an injected secret store", async () => {
|
||||
configPath = createTestConfig({
|
||||
mcpServers: {
|
||||
"secret-stdio": {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
env: { HELLO: "" },
|
||||
},
|
||||
"secret-http": {
|
||||
type: "streamable-http",
|
||||
url: "https://example.com/mcp",
|
||||
// Disk keeps clientId only; secret lives in the store.
|
||||
oauth: { clientId: "cid" },
|
||||
} as MCPServerConfig,
|
||||
},
|
||||
});
|
||||
const secretStore = new InMemorySecretStore();
|
||||
await secretStore.set(
|
||||
"secret-stdio",
|
||||
envSecretField("HELLO"),
|
||||
"top-secret-env",
|
||||
);
|
||||
await secretStore.set(
|
||||
"secret-http",
|
||||
SECRET_FIELD_OAUTH_CLIENT_SECRET,
|
||||
"top-secret-oauth",
|
||||
);
|
||||
|
||||
const stdio = await showServerEntry("secret-stdio", {
|
||||
configPath,
|
||||
secretStore,
|
||||
});
|
||||
expect(stdio.config.env).toEqual({ HELLO: "[redacted]" });
|
||||
expect(JSON.stringify(stdio)).not.toContain("top-secret-env");
|
||||
|
||||
const http = await showServerEntry("secret-http", {
|
||||
configPath,
|
||||
secretStore,
|
||||
});
|
||||
expect(http.settings?.oauthClientSecret).toBe("[redacted]");
|
||||
expect(http.settings?.oauthClientId).toBe("cid");
|
||||
expect(JSON.stringify(http)).not.toContain("top-secret-oauth");
|
||||
});
|
||||
|
||||
it("works via --method servers/show --server", async () => {
|
||||
configPath = createSampleTestConfig();
|
||||
const result = await runCli([
|
||||
"--config",
|
||||
configPath,
|
||||
"--method",
|
||||
"servers/show",
|
||||
"--server",
|
||||
"test-http",
|
||||
"--format",
|
||||
"json",
|
||||
]);
|
||||
expectCliSuccess(result);
|
||||
const body = JSON.parse(result.stdout) as {
|
||||
result: { name: string; type: string };
|
||||
};
|
||||
expect(body.result.name).toBe("test-http");
|
||||
expect(body.result.type).toBe("streamable-http");
|
||||
});
|
||||
|
||||
it("rejects servers/show without --server", async () => {
|
||||
configPath = createSampleTestConfig();
|
||||
const result = await runCli([
|
||||
"--config",
|
||||
configPath,
|
||||
"--method",
|
||||
"servers/show",
|
||||
]);
|
||||
expectCliFailure(result);
|
||||
expect(result.stderr).toMatch(/--server/);
|
||||
});
|
||||
|
||||
it("rejects unknown server names", async () => {
|
||||
configPath = createSampleTestConfig();
|
||||
await expect(
|
||||
showServerEntry("nope", {
|
||||
configPath,
|
||||
secretStore: new InMemorySecretStore(),
|
||||
}),
|
||||
).rejects.toThrow(/not found/);
|
||||
});
|
||||
});
|
||||
40
clients/cli/__tests__/style.test.ts
Normal file
40
clients/cli/__tests__/style.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
createStyle,
|
||||
resolveAnsiEnabled,
|
||||
styleFromOpts,
|
||||
PLAIN,
|
||||
} from "../src/style.js";
|
||||
|
||||
describe("resolveAnsiEnabled", () => {
|
||||
it("is off for --plain, json, NO_COLOR, and non-TTY", () => {
|
||||
expect(resolveAnsiEnabled({ plain: true, isTTY: true })).toBe(false);
|
||||
expect(resolveAnsiEnabled({ format: "json", isTTY: true })).toBe(false);
|
||||
expect(resolveAnsiEnabled({ isTTY: true, noColorEnv: "1" })).toBe(false);
|
||||
expect(resolveAnsiEnabled({ isTTY: false, noColorEnv: "" })).toBe(false);
|
||||
});
|
||||
|
||||
it("is on for TTY text without plain/NO_COLOR", () => {
|
||||
expect(
|
||||
resolveAnsiEnabled({ isTTY: true, noColorEnv: "", format: "text" }),
|
||||
).toBe(true);
|
||||
expect(styleFromOpts({ isTTY: true, noColorEnv: "" }).ansi).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createStyle", () => {
|
||||
it("PLAIN is identity; ansi wraps SGR and OSC 8", () => {
|
||||
expect(PLAIN.bold("x")).toBe("x");
|
||||
expect(PLAIN.link("https://example.com")).toBe("https://example.com");
|
||||
|
||||
const s = createStyle(true);
|
||||
expect(s.bold("x")).toContain("\u001b[1m");
|
||||
expect(s.dim("x")).toContain("\u001b[2m");
|
||||
expect(s.red("x")).toContain("\u001b[31m");
|
||||
expect(s.link("https://example.com", "ex")).toContain(
|
||||
"\u001b]8;;https://example.com",
|
||||
);
|
||||
expect(s.link("https://example.com", "ex")).toContain("ex");
|
||||
expect(s.link("", "ex")).toBe("ex");
|
||||
});
|
||||
});
|
||||
149
clients/cli/package-lock.json
generated
149
clients/cli/package-lock.json
generated
|
|
@ -16,6 +16,7 @@
|
|||
"ajv": "^8.17.1",
|
||||
"atomically": "^2.1.1",
|
||||
"commander": "^13.1.0",
|
||||
"open": "^10.2.0",
|
||||
"pino": "^9.14.0",
|
||||
"undici": "^8.5.0",
|
||||
"zod": "^4.3.6"
|
||||
|
|
@ -2449,6 +2450,21 @@
|
|||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/bundle-name": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
|
||||
"integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"run-applescript": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bundle-require": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz",
|
||||
|
|
@ -2726,6 +2742,46 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/default-browser": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz",
|
||||
"integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bundle-name": "^4.1.0",
|
||||
"default-browser-id": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/default-browser-id": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz",
|
||||
"integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/define-lazy-prop": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
|
||||
"integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
|
|
@ -3583,6 +3639,21 @@
|
|||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-docker": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
|
||||
"integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"is-docker": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
|
|
@ -3606,6 +3677,24 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-inside-container": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
|
||||
"integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-docker": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"is-inside-container": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-promise": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||
|
|
@ -3613,6 +3702,21 @@
|
|||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/is-wsl": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
|
||||
"integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-inside-container": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
|
|
@ -4311,6 +4415,24 @@
|
|||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/open": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
|
||||
"integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"default-browser": "^5.2.1",
|
||||
"define-lazy-prop": "^3.0.0",
|
||||
"is-inside-container": "^1.0.0",
|
||||
"wsl-utils": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
|
|
@ -4832,6 +4954,18 @@
|
|||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/run-applescript": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
|
||||
"integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
|
|
@ -5693,6 +5827,21 @@
|
|||
"license": "ISC",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/wsl-utils": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
|
||||
"integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-wsl": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@
|
|||
"ajv": "^8.17.1",
|
||||
"atomically": "^2.1.1",
|
||||
"commander": "^13.1.0",
|
||||
"open": "^10.2.0",
|
||||
"pino": "^9.14.0",
|
||||
"undici": "^8.5.0",
|
||||
"zod": "^4.3.6"
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue