* 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>
294 lines
8.6 KiB
TypeScript
294 lines
8.6 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server";
|
|
import { runCli } from "./helpers/cli-runner.js";
|
|
import { DEFAULT_CONNECT_TIMEOUT_MS, withConnectTimeout } from "../src/cli.js";
|
|
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
|
|
import {
|
|
expectCliFailure,
|
|
expectCliSuccess,
|
|
expectOutputContains,
|
|
} from "./helpers/assertions.js";
|
|
|
|
describe("withConnectTimeout", () => {
|
|
const baseSettings: InspectorServerSettings = {
|
|
headers: [{ key: "X-Test", value: "1" }],
|
|
metadata: [],
|
|
env: [],
|
|
connectionTimeout: 42,
|
|
requestTimeout: 0,
|
|
taskTtl: 60000,
|
|
maxFetchRequests: 1000,
|
|
autoRefreshOnListChanged: false,
|
|
roots: [],
|
|
};
|
|
|
|
it("returns the settings unchanged when no timeout is given", () => {
|
|
expect(withConnectTimeout(baseSettings, undefined)).toBe(baseSettings);
|
|
expect(withConnectTimeout(undefined, undefined)).toBeUndefined();
|
|
});
|
|
|
|
it("overrides an existing settings' connectionTimeout", () => {
|
|
const result = withConnectTimeout(baseSettings, 5000);
|
|
expect(result).not.toBe(baseSettings);
|
|
expect(result?.connectionTimeout).toBe(5000);
|
|
// Other fields are preserved.
|
|
expect(result?.headers).toEqual(baseSettings.headers);
|
|
});
|
|
|
|
it("builds a minimal settings object when none was provided", () => {
|
|
const result = withConnectTimeout(undefined, DEFAULT_CONNECT_TIMEOUT_MS);
|
|
expect(result?.connectionTimeout).toBe(DEFAULT_CONNECT_TIMEOUT_MS);
|
|
expect(result?.headers).toEqual([]);
|
|
expect(result?.requestTimeout).toBe(0);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Covers the programmatic-ergonomics flags from #1573: `--method initialize`,
|
|
* `--tool-args-json`, `--connect-timeout`, and the MCP_CATALOG_PATH ad-hoc
|
|
* gate. All connect to the bundled stdio test server.
|
|
*/
|
|
describe("--method initialize", () => {
|
|
it("connects and emits the cached InitializeResult fields", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([command, ...args, "--method", "initialize"]);
|
|
expectCliSuccess(result);
|
|
const json = JSON.parse(result.stdout) as {
|
|
serverInfo: { name: string };
|
|
protocolVersion: string;
|
|
capabilities: Record<string, unknown>;
|
|
};
|
|
expect(json.serverInfo.name).toBeTruthy();
|
|
expect(typeof json.protocolVersion).toBe("string");
|
|
expect(typeof json.capabilities).toBe("object");
|
|
});
|
|
});
|
|
|
|
describe("--tool-args-json", () => {
|
|
it("passes the JSON object verbatim (string values stay strings)", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--method",
|
|
"tools/call",
|
|
"--tool-name",
|
|
"echo",
|
|
"--tool-args-json",
|
|
JSON.stringify({ message: "012" }),
|
|
]);
|
|
expectCliSuccess(result);
|
|
// echo returns its message; with --tool-arg message=012 the value would
|
|
// coerce to the number 12 (and fail the string schema), but
|
|
// --tool-args-json preserves the string "012".
|
|
expect(result.stdout).toContain("012");
|
|
});
|
|
|
|
it("rejects --tool-args-json combined with --tool-arg", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--method",
|
|
"tools/call",
|
|
"--tool-name",
|
|
"echo",
|
|
"--tool-arg",
|
|
"x=1",
|
|
"--tool-args-json",
|
|
"{}",
|
|
]);
|
|
expectCliFailure(result);
|
|
expectOutputContains(result, "cannot be combined with --tool-arg");
|
|
});
|
|
|
|
it("rejects malformed JSON", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--method",
|
|
"tools/call",
|
|
"--tool-name",
|
|
"echo",
|
|
"--tool-args-json",
|
|
"{not json}",
|
|
]);
|
|
expectCliFailure(result);
|
|
expectOutputContains(result, "not valid JSON");
|
|
});
|
|
|
|
it("rejects a non-object value", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--method",
|
|
"tools/call",
|
|
"--tool-name",
|
|
"echo",
|
|
"--tool-args-json",
|
|
"[1,2]",
|
|
]);
|
|
expectCliFailure(result);
|
|
expectOutputContains(result, "must be a JSON object");
|
|
});
|
|
});
|
|
|
|
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();
|
|
const result = await runCli([command, ...args, "--method", "tools/list"], {
|
|
env: { MCP_CATALOG_PATH: "/no/such/catalog.json" },
|
|
});
|
|
expectCliSuccess(result);
|
|
});
|
|
});
|
|
|
|
describe("--connect-timeout", () => {
|
|
it("accepts a numeric value and connects within it", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--connect-timeout",
|
|
"5000",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
expectCliSuccess(result);
|
|
});
|
|
|
|
it("accepts 0 (no timeout) and connects", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--connect-timeout",
|
|
"0",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
expectCliSuccess(result);
|
|
});
|
|
|
|
it("rejects a negative value", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--connect-timeout",
|
|
"-1",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
expectCliFailure(result);
|
|
expectOutputContains(result, "non-negative number");
|
|
});
|
|
|
|
it("rejects a non-numeric value", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--connect-timeout",
|
|
"soon",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
expectCliFailure(result);
|
|
expectOutputContains(result, "non-negative number");
|
|
});
|
|
});
|