* 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>
232 lines
7.1 KiB
TypeScript
232 lines
7.1 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|