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:
Bob Dickinson 2026-07-26 13:55:25 -07:00 • committed by GitHub
commit 33e88eac9f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 4041 additions and 520 deletions

View file

@ -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.

View 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();
});
});

View 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);
}
}
});
});

View file

@ -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;
}
}
});
});

View file

@ -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

View 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();
}
});
});

View 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),
}));

View file

@ -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(),

View 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]");
});
});

View file

@ -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 () => {

View file

@ -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);

View 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",
);
});
});

View file

@ -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();

View 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();
}
});
});

View 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;
}
});
});

View 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/);
});
});

View 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");
});
});