* 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>
355 lines
9.7 KiB
TypeScript
355 lines
9.7 KiB
TypeScript
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/);
|
|
});
|
|
});
|