inspector/clients/cli/__tests__/run-method.test.ts
2026-08-12 08:57:35 -07:00

290 lines
9.6 KiB
TypeScript

import { describe, it, expect, afterEach, vi } 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,
// Mirrors `cli.ts`, which always passes the option so the capability is
// negotiated and the `roots/list` handler registered (#1797).
roots: [],
},
);
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("roots/set drops a malformed root rather than advertising it", async () => {
// `--roots-json` only checks that the payload parses to an array, so a
// root with no `uri` used to be stored and then handed to the server in
// the `roots/list` reply — an invalid Root on the wire, echoed back to the
// user as success. `setRoots` normalizes through `cleanRoots` now (#1797).
const c = await connectStdio();
// cleanRoots reports the drop; suppress it here and assert it happened, so
// the test also covers that a dropped root is not dropped silently.
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const set = await runMethod(c, {
method: "roots/set",
rootsJson: JSON.stringify([
{ name: "no uri" },
{ uri: "file:///keep" },
]),
});
expect(set.kind).toBe("result");
if (set.kind === "result") {
expect(set.result.roots).toEqual([{ uri: "file:///keep" }]);
}
expect(warn).toHaveBeenCalled();
} finally {
warn.mockRestore();
}
});
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);
// Invoke only the SIGINT handler consumeMethodOutcome registers, rather
// than broadcasting `process.emit("SIGINT")` process-wide: `atomically`
// (loaded via core's store-io) pulls in `when-exit`, whose module-level
// `process.once("SIGINT")` handler re-raises the signal with
// `process.kill(process.pid, "SIGINT")` — on Windows an unconditional
// TerminateProcess that kills the vitest worker fork mid-run (#1941).
const listenersBefore = new Set(process.listeners("SIGINT"));
const streamPromise = consumeMethodOutcome(
{
kind: "stream",
label: "t",
start: (write) => {
write({ hi: true });
return () => {};
},
},
{},
);
const added = process
.listeners("SIGINT")
.filter((listener) => !listenersBefore.has(listener));
expect(added).toHaveLength(1);
for (const listener of added) listener("SIGINT");
await streamPromise;
expect(stdout).toContain('"hi":true');
} finally {
process.stdout.write = original;
}
});
});