CLI: catalog listing, auth flags, and OAuth browser UX (#1782)
* feat(cli): catalog listing, auth flags, and OAuth browser UX Add servers/list and servers/show, --relogin / --stored-auth-only, browser OAuth navigation with OSC 8 links, and extract method handlers under clients/cli/src/handlers. Tracks #1781. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): address #1782 review — relogin clear, stored-auth-only, docs Clear both raw and normalized OAuth keys on --relogin; check the shared store before bailing under --stored-auth-only mid-session; restore the tests README link target; document servers/show redaction limits; keep mcpi groundwork with TODO(#1432) markers. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): gate OAuth browser open and harden #1782 review follow-ups Arm auto-open only for CLI-owned interactive OAuth, honor MCP_AUTO_OPEN_ENABLED, move --relogin clear after validation, skip keychain on servers/list, and cover secret redaction after rehydrate. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): silence disarmed OAuth URLs and round-3 review nits Suppress authorize URL print when navigation is disarmed or --stored-auth-only; document MCP_AUTO_OPEN_ENABLED; redact Cookie/auth headers; keep servers/show unit tests off the OS keychain. Co-authored-by: Cursor <cursoragent@cursor.com> * test(cli): cover withArmedAutoOpen for the ≥90 branch gate CI failed on cliOAuth.ts branch coverage after the auto-open arming helper landed; exercise the armed path through runCliInteractiveOAuth. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): mid-RPC unauthorized recovery and clear round-4 nits Mirror connect's UnauthorizedError path in withCliAuthRecoveryRetry, let MCP_AUTO_OPEN_ENABLED=true force open off-TTY, mock open-url in the in-process runner, reject --relogin on catalog methods, restore the __tests__ README inventory, and narrow OAuth helpers to structural client types. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): reconnect after mid-RPC OAuth and finish round-5 nits Reconnect before retrying after unauthorized recovery, guard non-OAuth configs, decouple forceAutoOpen, move open-url mock to vitest setupFiles, and reject --relogin with stored-auth short-circuits. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): fail fast on non-TTY interactive OAuth and round-6 nits Skip interactive OAuth without a TTY unless MCP_AUTO_OPEN_ENABLED=true; document --stored-auth-only for CI; redact settings.metadata; reject --relogin for stdio; clarify AuthRecoveryRequired reconnect comments. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): admit interactive OAuth when stdin is a TTY (round-7) Gate on stdin||stderr so `2>&1 | tee` still works; fold confirm/auto-open/ TTY into CliOAuthConnectOptions; rename authRequiredFailure; document MCP_AUTO_OPEN_ENABLED=true as non-TTY admit. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): fail fast on step-up without stdin (round-8) Decline [y/N] on readline EOF/close, require a stdin TTY for the default step-up confirmer (force-open admit is not enough), document stderr-only browser auto-open vs stdin||stderr flow admit, and drop the alias wrapper. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): allow piped step-up answers; bound silent pipes (round-9) Drop the hard stdin-TTY gate that broke `echo y | …`. Keep EOF decline and add a short non-TTY timeout so an open pipe that never writes cannot hang. Clarify isTTY as admit-only; extract the readline mock factory. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): keep partial piped step-up answers (round-10) Capture readline `line` before close so `printf y` without a newline is not treated as decline; timeout is auth_required with a distinct message; document the 5s bound and post-confirm callback wait; pin the TTY no-timeout arm. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): tighten step-up timeout messaging and TTY test (round-11) Make the TTY no-timeout assertion use a settled flag (the Promise.race check was vacuous), say "timed out" instead of "declined" on the pipe bound, and document that piped answers need a newline or stdin close. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a7b2fdb2ef
commit
33e88eac9f
1 changed files with 4041 additions and 520 deletions
249
clients/cli/__tests__/run-method.test.ts
Normal file
249
clients/cli/__tests__/run-method.test.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server";
|
||||
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||
import { createTransportNode } from "@inspector/core/mcp/node/index.js";
|
||||
import { runMethod } from "../src/handlers/run-method.js";
|
||||
import { consumeMethodOutcome } from "../src/handlers/consume-outcome.js";
|
||||
|
||||
describe("runMethod", () => {
|
||||
let client: InspectorClient | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (client) {
|
||||
await client.disconnect().catch(() => {});
|
||||
client = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
async function connectStdio(): Promise<InspectorClient> {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
client = new InspectorClient(
|
||||
{ type: "stdio", command, args },
|
||||
{
|
||||
environment: { transport: createTransportNode },
|
||||
clientIdentity: { name: "test", version: "0.0.0" },
|
||||
progress: false,
|
||||
sample: false,
|
||||
elicit: false,
|
||||
},
|
||||
);
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
it("lists tools and reads initialize fields", async () => {
|
||||
const c = await connectStdio();
|
||||
const tools = await runMethod(c, { method: "tools/list" });
|
||||
expect(tools.kind).toBe("result");
|
||||
if (tools.kind === "result") {
|
||||
expect(
|
||||
(tools.result.tools as unknown[] | undefined)?.length,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const init = await runMethod(c, { method: "initialize" });
|
||||
expect(init.kind).toBe("result");
|
||||
});
|
||||
|
||||
it("returns ndjson lines for tools/list --app-info", async () => {
|
||||
const c = await connectStdio();
|
||||
const outcome = await runMethod(c, {
|
||||
method: "tools/list",
|
||||
appInfo: true,
|
||||
});
|
||||
expect(outcome.kind).toBe("ndjson");
|
||||
if (outcome.kind === "ndjson") {
|
||||
expect(outcome.lines.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("lists resources and prompts", async () => {
|
||||
const c = await connectStdio();
|
||||
const resources = await runMethod(c, { method: "resources/list" });
|
||||
expect(resources.kind).toBe("result");
|
||||
const prompts = await runMethod(c, { method: "prompts/list" });
|
||||
expect(prompts.kind).toBe("result");
|
||||
const templates = await runMethod(c, {
|
||||
method: "resources/templates/list",
|
||||
});
|
||||
expect(templates.kind).toBe("result");
|
||||
});
|
||||
|
||||
it("sets logging level", async () => {
|
||||
const c = await connectStdio();
|
||||
const outcome = await runMethod(c, {
|
||||
method: "logging/setLevel",
|
||||
logLevel: "info",
|
||||
});
|
||||
expect(outcome.kind).toBe("result");
|
||||
});
|
||||
|
||||
it("roots/list and roots/set round-trip", async () => {
|
||||
const c = await connectStdio();
|
||||
const listed = await runMethod(c, { method: "roots/list" });
|
||||
expect(listed.kind).toBe("result");
|
||||
const set = await runMethod(c, {
|
||||
method: "roots/set",
|
||||
rootsJson: JSON.stringify([{ uri: "file:///tmp", name: "tmp" }]),
|
||||
});
|
||||
expect(set.kind).toBe("result");
|
||||
if (set.kind === "result") {
|
||||
expect(set.result.roots).toEqual([{ uri: "file:///tmp", name: "tmp" }]);
|
||||
}
|
||||
});
|
||||
|
||||
it("consumeMethodOutcome writes result json", async () => {
|
||||
let stdout = "";
|
||||
const original = process.stdout.write;
|
||||
process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => {
|
||||
stdout += typeof chunk === "string" ? chunk : String(chunk);
|
||||
const cb = rest.find((r) => typeof r === "function") as
|
||||
| (() => void)
|
||||
| undefined;
|
||||
cb?.();
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
try {
|
||||
await consumeMethodOutcome(
|
||||
{ kind: "result", result: { ok: true } },
|
||||
{ format: "json" },
|
||||
);
|
||||
expect(JSON.parse(stdout.trim())).toEqual({ result: { ok: true } });
|
||||
} finally {
|
||||
process.stdout.write = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown methods", async () => {
|
||||
const c = await connectStdio();
|
||||
await expect(runMethod(c, { method: "nope/method" })).rejects.toThrow(
|
||||
/Unsupported method/,
|
||||
);
|
||||
});
|
||||
|
||||
it("calls echo tool and rejects missing tool name", async () => {
|
||||
const c = await connectStdio();
|
||||
await expect(runMethod(c, { method: "tools/call" })).rejects.toThrow(
|
||||
/Tool name is required/,
|
||||
);
|
||||
|
||||
const missing = runMethod(c, {
|
||||
method: "tools/call",
|
||||
toolName: "does-not-exist",
|
||||
});
|
||||
await expect(missing).rejects.toMatchObject({ exitCode: 5 });
|
||||
|
||||
const called = await runMethod(c, {
|
||||
method: "tools/call",
|
||||
toolName: "echo",
|
||||
toolArg: { message: "hi" },
|
||||
format: "json",
|
||||
});
|
||||
expect(called.kind).toBe("result");
|
||||
});
|
||||
|
||||
it("reads a resource and gets a prompt", async () => {
|
||||
const c = await connectStdio();
|
||||
const read = await runMethod(c, {
|
||||
method: "resources/read",
|
||||
uri: "test://env",
|
||||
});
|
||||
expect(read.kind).toBe("result");
|
||||
|
||||
await expect(runMethod(c, { method: "resources/read" })).rejects.toThrow(
|
||||
/URI is required/,
|
||||
);
|
||||
|
||||
const prompts = await runMethod(c, { method: "prompts/list" });
|
||||
expect(prompts.kind).toBe("result");
|
||||
if (prompts.kind === "result") {
|
||||
const list = prompts.result.prompts as { name: string }[];
|
||||
if (list.length > 0) {
|
||||
const got = await runMethod(c, {
|
||||
method: "prompts/get",
|
||||
promptName: list[0]!.name,
|
||||
});
|
||||
expect(got.kind).toBe("result");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a stream starter for logging/tail", async () => {
|
||||
const c = await connectStdio();
|
||||
const outcome = await runMethod(c, { method: "logging/tail" });
|
||||
expect(outcome.kind).toBe("stream");
|
||||
if (outcome.kind === "stream") {
|
||||
const stop = outcome.start(() => {});
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("requires uri for subscribe/unsubscribe", async () => {
|
||||
const c = await connectStdio();
|
||||
await expect(
|
||||
runMethod(c, { method: "resources/subscribe" }),
|
||||
).rejects.toThrow(/URI is required/);
|
||||
await expect(
|
||||
runMethod(c, { method: "resources/unsubscribe" }),
|
||||
).rejects.toThrow(/URI is required/);
|
||||
});
|
||||
|
||||
it("rejects bad roots/set JSON and missing logging level", async () => {
|
||||
const c = await connectStdio();
|
||||
await expect(runMethod(c, { method: "logging/setLevel" })).rejects.toThrow(
|
||||
/Log level is required/,
|
||||
);
|
||||
await expect(
|
||||
runMethod(c, { method: "roots/set", rootsJson: "{}" }),
|
||||
).rejects.toThrow(/must be a JSON array/);
|
||||
await expect(runMethod(c, { method: "roots/set" })).rejects.toThrow(
|
||||
/roots\/set requires/,
|
||||
);
|
||||
await expect(runMethod(c, { method: "tasks/get" })).rejects.toThrow(
|
||||
/Task id is required/,
|
||||
);
|
||||
await expect(runMethod(c, { method: "prompts/get" })).rejects.toThrow(
|
||||
/Prompt name is required/,
|
||||
);
|
||||
await expect(runMethod(c, { method: "prompts/complete" })).rejects.toThrow(
|
||||
/prompts\/complete requires/,
|
||||
);
|
||||
});
|
||||
|
||||
it("consumeMethodOutcome writes ndjson and stream until SIGINT", async () => {
|
||||
let stdout = "";
|
||||
const original = process.stdout.write;
|
||||
process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => {
|
||||
stdout += typeof chunk === "string" ? chunk : String(chunk);
|
||||
const cb = rest.find((r) => typeof r === "function") as
|
||||
| (() => void)
|
||||
| undefined;
|
||||
cb?.();
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
try {
|
||||
await consumeMethodOutcome(
|
||||
{ kind: "ndjson", lines: [{ a: 1 }, { b: 2 }] },
|
||||
{},
|
||||
);
|
||||
expect(stdout.trim().split("\n")).toHaveLength(2);
|
||||
|
||||
const streamPromise = consumeMethodOutcome(
|
||||
{
|
||||
kind: "stream",
|
||||
label: "t",
|
||||
start: (write) => {
|
||||
write({ hi: true });
|
||||
queueMicrotask(() => process.emit("SIGINT"));
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
{},
|
||||
);
|
||||
await streamPromise;
|
||||
expect(stdout).toContain('"hi":true');
|
||||
} finally {
|
||||
process.stdout.write = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue