Addresses review round 5 on #2099 (three suppressed comments, no posted ones). The `fetch: createProxyFetch()` line in each Node client is the whole of that client's proxy support — it is what puts the proxy at the BOTTOM of the fetch stack, where InspectorClient's wrappers compose over it rather than discarding it. `InspectorClient` sets `fetchFn` unconditionally, so the transport-level fallback never fires for these clients. Both lines could have been deleted with every existing test still green. - CLI: a test that runs a real `tools/list` against a real HTTP test server with HTTP_PROXY pointed at a real forwarding proxy, asserting the request traversed it. Behavioral rather than structural on purpose — the defect this replaces was a runtime dispatch failure no structural assertion would have caught. It clears all six proxy vars first so an ambient value cannot route it elsewhere. - TUI: the mock added earlier always returned undefined, so the proxy branch was never exercised. `createProxyFetch`'s return is now controllable, and two tests assert the sentinel reaches `environment.fetch` when a proxy is configured and that the slot stays unset when one is not. Both were confirmed to fail with their client's wiring line removed. Also updates the `InspectorClientEnvironment.fetch` doc in core/mcp/types.ts, which still told callers that Node leaves this undefined and uses the global fetch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H Signed-off-by: cliffhall <cliff@futurescale.com>
1230 lines
35 KiB
TypeScript
1230 lines
35 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import { mkdtempSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { runCli } from "./helpers/cli-runner.js";
|
|
import { runCli as runCliDirect } from "../src/cli.js";
|
|
import {
|
|
expectCliSuccess,
|
|
expectCliFailure,
|
|
expectValidJson,
|
|
} from "./helpers/assertions.js";
|
|
import {
|
|
NO_SERVER_SENTINEL,
|
|
createSampleTestConfig,
|
|
createTestConfig,
|
|
createInvalidConfig,
|
|
deleteConfigFile,
|
|
} from "./helpers/fixtures.js";
|
|
import {
|
|
getTestMcpServerCommand,
|
|
createTestServerHttp,
|
|
createEchoTool,
|
|
createListRootsTool,
|
|
createTestServerInfo,
|
|
} from "@modelcontextprotocol/inspector-test-server";
|
|
import type { MCPServerConfig } from "@inspector/core/mcp/index.js";
|
|
import { createServer, request } from "node:http";
|
|
|
|
/**
|
|
* Every proxy variable undici's EnvHttpProxyAgent consults. The proxy test
|
|
* clears all of them rather than only setting HTTP_PROXY: the agent prefers the
|
|
* lowercase forms and honors NO_PROXY dynamically, so an ambient value in the
|
|
* developer's or CI's environment could route the request elsewhere, or bypass
|
|
* the test's proxy entirely, and fail the assertion for the wrong reason.
|
|
*/
|
|
const PROXY_ENV_VARS = [
|
|
"HTTP_PROXY",
|
|
"http_proxy",
|
|
"HTTPS_PROXY",
|
|
"https_proxy",
|
|
"NO_PROXY",
|
|
"no_proxy",
|
|
] as const;
|
|
|
|
describe("CLI Tests", () => {
|
|
describe("Basic CLI Mode", () => {
|
|
it("should execute tools/list successfully", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("tools");
|
|
expect(Array.isArray(json.tools)).toBe(true);
|
|
|
|
// Validate expected tools from test-mcp-server
|
|
const toolNames = json.tools.map((tool: { name: string }) => tool.name);
|
|
expect(toolNames).toContain("echo");
|
|
expect(toolNames).toContain("get_sum");
|
|
expect(toolNames).toContain("get_annotated_message");
|
|
});
|
|
|
|
it("should fail with nonexistent method", async () => {
|
|
const result = await runCli([
|
|
NO_SERVER_SENTINEL,
|
|
"--cli",
|
|
"--method",
|
|
"nonexistent/method",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
});
|
|
|
|
it("should fail without method", async () => {
|
|
const result = await runCli([NO_SERVER_SENTINEL, "--cli"]);
|
|
|
|
expectCliFailure(result);
|
|
});
|
|
});
|
|
|
|
describe("Environment Variables", () => {
|
|
it("should accept environment variables", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"-e",
|
|
"KEY1=value1",
|
|
"-e",
|
|
"KEY2=value2",
|
|
"--cli",
|
|
"--method",
|
|
"resources/read",
|
|
"--uri",
|
|
"test://env",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("contents");
|
|
expect(Array.isArray(json.contents)).toBe(true);
|
|
expect(json.contents.length).toBeGreaterThan(0);
|
|
|
|
// Parse the env vars from the resource
|
|
const envVars = JSON.parse(json.contents[0].text);
|
|
expect(envVars.KEY1).toBe("value1");
|
|
expect(envVars.KEY2).toBe("value2");
|
|
});
|
|
|
|
it("should reject invalid environment variable format", async () => {
|
|
const result = await runCli([
|
|
NO_SERVER_SENTINEL,
|
|
"-e",
|
|
"INVALID_FORMAT",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
});
|
|
|
|
it("should handle environment variable with equals sign in value", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"-e",
|
|
"API_KEY=abc123=xyz789==",
|
|
"--cli",
|
|
"--method",
|
|
"resources/read",
|
|
"--uri",
|
|
"test://env",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
const envVars = JSON.parse(json.contents[0].text);
|
|
expect(envVars.API_KEY).toBe("abc123=xyz789==");
|
|
});
|
|
|
|
it("should handle environment variable with base64-encoded value", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"-e",
|
|
"JWT_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0=",
|
|
"--cli",
|
|
"--method",
|
|
"resources/read",
|
|
"--uri",
|
|
"test://env",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
const envVars = JSON.parse(json.contents[0].text);
|
|
expect(envVars.JWT_TOKEN).toBe(
|
|
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0=",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Config File", () => {
|
|
it("should use config file with CLI mode", async () => {
|
|
const configPath = createSampleTestConfig();
|
|
try {
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--server",
|
|
"test-stdio",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("tools");
|
|
expect(Array.isArray(json.tools)).toBe(true);
|
|
expect(json.tools.length).toBeGreaterThan(0);
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
|
|
it("should fail when using config file without server name", async () => {
|
|
const configPath = createSampleTestConfig();
|
|
try {
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
|
|
it("should fail when using server name without config file", async () => {
|
|
const result = await runCli([
|
|
"--server",
|
|
"test-stdio",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
});
|
|
|
|
it("should fail with nonexistent config file", async () => {
|
|
const result = await runCli([
|
|
"--config",
|
|
"./nonexistent-config.json",
|
|
"--server",
|
|
"test-stdio",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
});
|
|
|
|
it("should fail with invalid config file format", async () => {
|
|
// Create invalid config temporarily
|
|
const invalidConfigPath = createInvalidConfig();
|
|
try {
|
|
const result = await runCli([
|
|
"--config",
|
|
invalidConfigPath,
|
|
"--server",
|
|
"test-stdio",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
} finally {
|
|
deleteConfigFile(invalidConfigPath);
|
|
}
|
|
});
|
|
|
|
it("should fail with nonexistent server in config", async () => {
|
|
const configPath = createSampleTestConfig();
|
|
try {
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--server",
|
|
"nonexistent",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Catalog File", () => {
|
|
it("should use a writable catalog with CLI mode", async () => {
|
|
const catalogPath = createSampleTestConfig();
|
|
try {
|
|
const result = await runCli([
|
|
"--catalog",
|
|
catalogPath,
|
|
"--server",
|
|
"test-stdio",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("tools");
|
|
expect(Array.isArray(json.tools)).toBe(true);
|
|
} finally {
|
|
deleteConfigFile(catalogPath);
|
|
}
|
|
});
|
|
|
|
it("should fail when --catalog and --config are combined", async () => {
|
|
const catalogPath = createSampleTestConfig();
|
|
try {
|
|
const result = await runCli([
|
|
"--catalog",
|
|
catalogPath,
|
|
"--config",
|
|
catalogPath,
|
|
"--server",
|
|
"test-stdio",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
expect(result.stderr).toMatch(/mutually exclusive/);
|
|
} finally {
|
|
deleteConfigFile(catalogPath);
|
|
}
|
|
});
|
|
|
|
it("should fail when --catalog is combined with an ad-hoc target", async () => {
|
|
const catalogPath = createSampleTestConfig();
|
|
const { command, args } = getTestMcpServerCommand();
|
|
try {
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--catalog",
|
|
catalogPath,
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
expect(result.stderr).toMatch(/--catalog cannot be combined/);
|
|
} finally {
|
|
deleteConfigFile(catalogPath);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("HTTP proxy (#2067)", () => {
|
|
it("routes the MCP connection through HTTP_PROXY (#2067)", async () => {
|
|
// Proxy support is one line in cli.ts — `fetch: createProxyFetch()` on the
|
|
// environment — and it is what puts the proxy at the BOTTOM of the fetch
|
|
// stack, where InspectorClient's own wrappers compose over it rather than
|
|
// discarding it. `InspectorClient` sets `fetchFn` unconditionally, so the
|
|
// transport-level fallback never fires for this client; delete that line
|
|
// and CLI proxy support disappears with every core test still green.
|
|
//
|
|
// Asserted behaviorally, through a real forwarding proxy, rather than by
|
|
// inspecting the environment object: the bug this replaces was a runtime
|
|
// dispatch failure that no structural assertion would have caught.
|
|
const server = createTestServerHttp({
|
|
serverInfo: createTestServerInfo(),
|
|
tools: [createEchoTool()],
|
|
});
|
|
const seen: string[] = [];
|
|
const proxy = createServer((req, res) => {
|
|
seen.push(req.url ?? "");
|
|
const target = new URL(req.url ?? "");
|
|
const upstream = request(
|
|
{
|
|
host: target.hostname,
|
|
port: target.port,
|
|
path: target.pathname + target.search,
|
|
method: req.method,
|
|
headers: req.headers,
|
|
},
|
|
(up) => {
|
|
res.writeHead(up.statusCode ?? 502, up.headers);
|
|
up.pipe(res);
|
|
},
|
|
);
|
|
upstream.on("error", () => {
|
|
res.writeHead(502);
|
|
res.end();
|
|
});
|
|
req.pipe(upstream);
|
|
});
|
|
const savedProxyEnv: Record<string, string | undefined> = {};
|
|
for (const name of PROXY_ENV_VARS) {
|
|
savedProxyEnv[name] = process.env[name];
|
|
delete process.env[name];
|
|
}
|
|
try {
|
|
await server.start();
|
|
await new Promise<void>((r) => proxy.listen(0, "127.0.0.1", () => r()));
|
|
const addr = proxy.address();
|
|
const port = typeof addr === "object" && addr !== null ? addr.port : 0;
|
|
process.env.HTTP_PROXY = `http://127.0.0.1:${port}`;
|
|
|
|
const result = await runCli([
|
|
server.url,
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
expect(JSON.stringify(expectValidJson(result))).toContain("echo");
|
|
// The point of the test: it did not go direct.
|
|
expect(seen.length).toBeGreaterThan(0);
|
|
expect(seen.every((u) => u.startsWith(server.url))).toBe(true);
|
|
} finally {
|
|
for (const name of PROXY_ENV_VARS) {
|
|
const previous = savedProxyEnv[name];
|
|
if (previous === undefined) delete process.env[name];
|
|
else process.env[name] = previous;
|
|
}
|
|
await new Promise<void>((r) => proxy.close(() => r()));
|
|
await server.stop();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Roots capability (#1797)", () => {
|
|
it("answers a server's roots/list instead of -32601", async () => {
|
|
// The CLI used to omit `roots` when constructing its InspectorClient, so
|
|
// the capability was never advertised and no `roots/list` handler was
|
|
// registered — a server that asked got -32601 Method not found. (And
|
|
// `--method roots/set` could not announce the change: the SDK refuses
|
|
// `roots/list_changed` from a client that never declared it, so nothing
|
|
// reached the wire.) `cli.ts` now always passes the `roots` option —
|
|
// empty on this ad-hoc path, since there is no config file.
|
|
const server = createTestServerHttp({
|
|
serverInfo: createTestServerInfo(),
|
|
tools: [createListRootsTool()],
|
|
});
|
|
try {
|
|
await server.start();
|
|
|
|
const result = await runCli([
|
|
server.url,
|
|
"--cli",
|
|
"--method",
|
|
"tools/call",
|
|
"--tool-name",
|
|
"list_roots",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
// The tool asks the client for roots and renders what it got. An
|
|
// unregistered handler surfaces as an isError result quoting -32601.
|
|
expect(json.isError).toBeFalsy();
|
|
expect(JSON.stringify(json)).toContain("Roots:");
|
|
expect(JSON.stringify(json)).not.toContain("-32601");
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
it("answers with the roots configured for the server in the config file", async () => {
|
|
// Answering `roots/list` is only half the fix: the roots a user
|
|
// configured in mcp.json have to be the answer. They ride in on
|
|
// `serverSettings.roots` (lifted by `mcpConfigToServerEntries`), which is
|
|
// what web passes too — a CLI that seeded `[]` would let a server fall
|
|
// back to its own defaults, the outcome #1797 is about.
|
|
const server = createTestServerHttp({
|
|
serverInfo: createTestServerInfo(),
|
|
tools: [createListRootsTool()],
|
|
});
|
|
let configPath: string | undefined;
|
|
try {
|
|
await server.start();
|
|
configPath = createTestConfig({
|
|
mcpServers: {
|
|
web: {
|
|
type: "streamable-http",
|
|
url: server.url,
|
|
roots: [{ uri: "file:///configured", name: "Configured" }],
|
|
} as unknown as MCPServerConfig,
|
|
},
|
|
});
|
|
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--server",
|
|
"web",
|
|
"--cli",
|
|
"--method",
|
|
"tools/call",
|
|
"--tool-name",
|
|
"list_roots",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json.isError).toBeFalsy();
|
|
expect(JSON.stringify(json)).toContain("file:///configured");
|
|
} finally {
|
|
await server.stop();
|
|
if (configPath) deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Config-file settings lifting (#1482)", () => {
|
|
it("applies a config file's custom header on a tools/call over HTTP", async () => {
|
|
const server = createTestServerHttp({
|
|
serverInfo: createTestServerInfo(),
|
|
tools: [createEchoTool()],
|
|
});
|
|
let configPath: string | undefined;
|
|
try {
|
|
await server.start();
|
|
// Write a config whose server carries disk-level headers + a timeout.
|
|
// `headers`/`requestTimeout` are Inspector-extension fields not present
|
|
// on MCPServerConfig, so build the entry as a loose record.
|
|
configPath = createTestConfig({
|
|
mcpServers: {
|
|
web: {
|
|
type: "streamable-http",
|
|
url: server.url,
|
|
headers: { "X-Custom-Token": "secret-123" },
|
|
requestTimeout: 8000,
|
|
} as unknown as MCPServerConfig,
|
|
},
|
|
});
|
|
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--server",
|
|
"web",
|
|
"--cli",
|
|
"--method",
|
|
"tools/call",
|
|
"--tool-name",
|
|
"echo",
|
|
"--tool-arg",
|
|
"message=hi",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
// The server should have received a request carrying the disk header
|
|
// (Express lower-cases header names), proving the CLI lifted it from
|
|
// the config file into the connection's settings.
|
|
const sawHeader = server
|
|
.getRecordedRequests()
|
|
.some((r) => r.headers?.["x-custom-token"] === "secret-123");
|
|
expect(sawHeader).toBe(true);
|
|
} finally {
|
|
await server.stop();
|
|
if (configPath) deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
|
|
it("overrides a config file's header with --header", async () => {
|
|
const server = createTestServerHttp({
|
|
serverInfo: createTestServerInfo(),
|
|
tools: [createEchoTool()],
|
|
});
|
|
let configPath: string | undefined;
|
|
try {
|
|
await server.start();
|
|
configPath = createTestConfig({
|
|
mcpServers: {
|
|
web: {
|
|
type: "streamable-http",
|
|
url: server.url,
|
|
headers: { "X-Custom-Token": "from-disk" },
|
|
} as unknown as MCPServerConfig,
|
|
},
|
|
});
|
|
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--server",
|
|
"web",
|
|
"--header",
|
|
"X-Custom-Token: from-cli",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const tokens = server
|
|
.getRecordedRequests()
|
|
.map((r) => r.headers?.["x-custom-token"]);
|
|
expect(tokens).toContain("from-cli");
|
|
expect(tokens).not.toContain("from-disk");
|
|
} finally {
|
|
await server.stop();
|
|
if (configPath) deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Resource Options", () => {
|
|
it("should read resource with URI", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--cli",
|
|
"--method",
|
|
"resources/read",
|
|
"--uri",
|
|
"demo://resource/static/document/architecture.md",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("contents");
|
|
expect(Array.isArray(json.contents)).toBe(true);
|
|
expect(json.contents.length).toBeGreaterThan(0);
|
|
expect(json.contents[0]).toHaveProperty(
|
|
"uri",
|
|
"demo://resource/static/document/architecture.md",
|
|
);
|
|
expect(json.contents[0]).toHaveProperty("mimeType", "text/markdown");
|
|
expect(json.contents[0]).toHaveProperty("text");
|
|
expect(json.contents[0].text).toContain("Architecture Documentation");
|
|
});
|
|
|
|
it("should fail when reading resource without URI", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--cli",
|
|
"--method",
|
|
"resources/read",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
});
|
|
});
|
|
|
|
describe("Prompt Options", () => {
|
|
it("should get prompt by name", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--cli",
|
|
"--method",
|
|
"prompts/get",
|
|
"--prompt-name",
|
|
"simple_prompt",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("messages");
|
|
expect(Array.isArray(json.messages)).toBe(true);
|
|
expect(json.messages.length).toBeGreaterThan(0);
|
|
expect(json.messages[0]).toHaveProperty("role", "user");
|
|
expect(json.messages[0]).toHaveProperty("content");
|
|
expect(json.messages[0].content).toHaveProperty("type", "text");
|
|
expect(json.messages[0].content.text).toBe(
|
|
"This is a simple prompt for testing purposes.",
|
|
);
|
|
});
|
|
|
|
it("should get prompt with arguments", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--cli",
|
|
"--method",
|
|
"prompts/get",
|
|
"--prompt-name",
|
|
"args_prompt",
|
|
"--prompt-args",
|
|
"city=New York",
|
|
"state=NY",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("messages");
|
|
expect(Array.isArray(json.messages)).toBe(true);
|
|
expect(json.messages.length).toBeGreaterThan(0);
|
|
expect(json.messages[0]).toHaveProperty("role", "user");
|
|
expect(json.messages[0]).toHaveProperty("content");
|
|
expect(json.messages[0].content).toHaveProperty("type", "text");
|
|
// Verify that the arguments were actually used in the response
|
|
expect(json.messages[0].content.text).toContain("city=New York");
|
|
expect(json.messages[0].content.text).toContain("state=NY");
|
|
});
|
|
|
|
it("should fail when getting prompt without name", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--cli",
|
|
"--method",
|
|
"prompts/get",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
});
|
|
});
|
|
|
|
describe("Logging Options", () => {
|
|
it("should set log level", async () => {
|
|
const server = createTestServerHttp({
|
|
serverInfo: createTestServerInfo(),
|
|
logging: true,
|
|
});
|
|
|
|
try {
|
|
await server.start();
|
|
const result = await runCli([
|
|
server.url,
|
|
"--cli",
|
|
"--method",
|
|
"logging/setLevel",
|
|
"--log-level",
|
|
"debug",
|
|
"--transport",
|
|
"http",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
// Validate the response - logging/setLevel should return an empty result
|
|
const json = expectValidJson(result);
|
|
expect(json).toEqual({});
|
|
|
|
// Validate that the server actually received and recorded the log level
|
|
expect(server.getCurrentLogLevel()).toBe("debug");
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
it("should reject invalid log level", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const result = await runCli([
|
|
command,
|
|
...args,
|
|
"--cli",
|
|
"--method",
|
|
"logging/setLevel",
|
|
"--log-level",
|
|
"invalid",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
});
|
|
});
|
|
|
|
describe("Combined Options", () => {
|
|
it("should handle config file with environment variables", async () => {
|
|
const configPath = createSampleTestConfig();
|
|
try {
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--server",
|
|
"test-stdio",
|
|
"-e",
|
|
"CLI_ENV_VAR=cli_value",
|
|
"--cli",
|
|
"--method",
|
|
"resources/read",
|
|
"--uri",
|
|
"test://env",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("contents");
|
|
expect(Array.isArray(json.contents)).toBe(true);
|
|
expect(json.contents.length).toBeGreaterThan(0);
|
|
|
|
// Parse the env vars from the resource
|
|
const envVars = JSON.parse(json.contents[0].text);
|
|
expect(envVars).toHaveProperty("CLI_ENV_VAR");
|
|
expect(envVars.CLI_ENV_VAR).toBe("cli_value");
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
|
|
it("should handle all options together", async () => {
|
|
const configPath = createSampleTestConfig();
|
|
try {
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--server",
|
|
"test-stdio",
|
|
"-e",
|
|
"CLI_ENV_VAR=cli_value",
|
|
"--cli",
|
|
"--method",
|
|
"tools/call",
|
|
"--tool-name",
|
|
"echo",
|
|
"--tool-arg",
|
|
"message=Hello",
|
|
"--log-level",
|
|
"debug",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("content");
|
|
expect(Array.isArray(json.content)).toBe(true);
|
|
expect(json.content.length).toBeGreaterThan(0);
|
|
expect(json.content[0]).toHaveProperty("type", "text");
|
|
expect(json.content[0].text).toBe("Echo: Hello");
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Config Transport Types", () => {
|
|
it("should work with stdio transport type", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const configPath = createTestConfig({
|
|
mcpServers: {
|
|
"test-stdio": {
|
|
type: "stdio",
|
|
command,
|
|
args,
|
|
env: {
|
|
TEST_ENV: "test-value",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
try {
|
|
// First validate tools/list works
|
|
const toolsResult = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--server",
|
|
"test-stdio",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliSuccess(toolsResult);
|
|
const toolsJson = expectValidJson(toolsResult);
|
|
expect(toolsJson).toHaveProperty("tools");
|
|
expect(Array.isArray(toolsJson.tools)).toBe(true);
|
|
expect(toolsJson.tools.length).toBeGreaterThan(0);
|
|
|
|
// Then validate env vars from config are passed to server
|
|
const envResult = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--server",
|
|
"test-stdio",
|
|
"--cli",
|
|
"--method",
|
|
"resources/read",
|
|
"--uri",
|
|
"test://env",
|
|
]);
|
|
|
|
expectCliSuccess(envResult);
|
|
const envJson = expectValidJson(envResult);
|
|
const envVars = JSON.parse(envJson.contents[0].text);
|
|
expect(envVars).toHaveProperty("TEST_ENV");
|
|
expect(envVars.TEST_ENV).toBe("test-value");
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
|
|
it("should fail with SSE transport type in CLI mode (connection error)", async () => {
|
|
const configPath = createTestConfig({
|
|
mcpServers: {
|
|
"test-sse": {
|
|
type: "sse",
|
|
url: "http://localhost:3000/sse",
|
|
},
|
|
},
|
|
});
|
|
try {
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--server",
|
|
"test-sse",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
|
|
it("should fail with HTTP transport type in CLI mode (connection error)", async () => {
|
|
const configPath = createTestConfig({
|
|
mcpServers: {
|
|
"test-http": {
|
|
type: "streamable-http",
|
|
url: "http://localhost:3001/mcp",
|
|
},
|
|
},
|
|
});
|
|
try {
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--server",
|
|
"test-http",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
|
|
it("should work with legacy config without type field", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const configPath = createTestConfig({
|
|
mcpServers: {
|
|
"test-legacy": {
|
|
command,
|
|
args,
|
|
env: {
|
|
LEGACY_ENV: "legacy-value",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
try {
|
|
// First validate tools/list works
|
|
const toolsResult = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--server",
|
|
"test-legacy",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliSuccess(toolsResult);
|
|
const toolsJson = expectValidJson(toolsResult);
|
|
expect(toolsJson).toHaveProperty("tools");
|
|
expect(Array.isArray(toolsJson.tools)).toBe(true);
|
|
expect(toolsJson.tools.length).toBeGreaterThan(0);
|
|
|
|
// Then validate env vars from config are passed to server
|
|
const envResult = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--server",
|
|
"test-legacy",
|
|
"--cli",
|
|
"--method",
|
|
"resources/read",
|
|
"--uri",
|
|
"test://env",
|
|
]);
|
|
|
|
expectCliSuccess(envResult);
|
|
const envJson = expectValidJson(envResult);
|
|
const envVars = JSON.parse(envJson.contents[0].text);
|
|
expect(envVars).toHaveProperty("LEGACY_ENV");
|
|
expect(envVars.LEGACY_ENV).toBe("legacy-value");
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Default Server Selection", () => {
|
|
it("should auto-select single server", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const configPath = createTestConfig({
|
|
mcpServers: {
|
|
"only-server": {
|
|
command,
|
|
args,
|
|
},
|
|
},
|
|
});
|
|
try {
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("tools");
|
|
expect(Array.isArray(json.tools)).toBe(true);
|
|
expect(json.tools.length).toBeGreaterThan(0);
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
|
|
it("should require explicit server selection even with default-server key (multiple servers)", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const configPath = createTestConfig({
|
|
mcpServers: {
|
|
"default-server": {
|
|
command,
|
|
args,
|
|
},
|
|
"other-server": {
|
|
command: "node",
|
|
args: ["other.js"],
|
|
},
|
|
},
|
|
});
|
|
try {
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
|
|
it("should require explicit server selection with multiple servers", async () => {
|
|
const { command, args } = getTestMcpServerCommand();
|
|
const configPath = createTestConfig({
|
|
mcpServers: {
|
|
server1: {
|
|
command,
|
|
args,
|
|
},
|
|
server2: {
|
|
command: "node",
|
|
args: ["other.js"],
|
|
},
|
|
},
|
|
});
|
|
try {
|
|
const result = await runCli([
|
|
"--config",
|
|
configPath,
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
} finally {
|
|
deleteConfigFile(configPath);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("HTTP Transport", () => {
|
|
it("should infer HTTP transport from URL ending with /mcp", async () => {
|
|
const server = createTestServerHttp({
|
|
serverInfo: createTestServerInfo(),
|
|
tools: [createEchoTool()],
|
|
});
|
|
|
|
try {
|
|
await server.start();
|
|
const result = await runCli([
|
|
server.url,
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("tools");
|
|
expect(Array.isArray(json.tools)).toBe(true);
|
|
expect(json.tools.length).toBeGreaterThan(0);
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
it("should work with explicit --transport http flag", async () => {
|
|
const server = createTestServerHttp({
|
|
serverInfo: createTestServerInfo(),
|
|
tools: [createEchoTool()],
|
|
});
|
|
|
|
try {
|
|
await server.start();
|
|
const result = await runCli([
|
|
server.url,
|
|
"--transport",
|
|
"http",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("tools");
|
|
expect(Array.isArray(json.tools)).toBe(true);
|
|
expect(json.tools.length).toBeGreaterThan(0);
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
it("should work with explicit transport flag and URL suffix", async () => {
|
|
const server = createTestServerHttp({
|
|
serverInfo: createTestServerInfo(),
|
|
tools: [createEchoTool()],
|
|
});
|
|
|
|
try {
|
|
await server.start();
|
|
const result = await runCli([
|
|
server.url,
|
|
"--transport",
|
|
"http",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliSuccess(result);
|
|
const json = expectValidJson(result);
|
|
expect(json).toHaveProperty("tools");
|
|
expect(Array.isArray(json.tools)).toBe(true);
|
|
expect(json.tools.length).toBeGreaterThan(0);
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
it("should fail when SSE transport is given to HTTP server", async () => {
|
|
const server = createTestServerHttp({
|
|
serverInfo: createTestServerInfo(),
|
|
tools: [createEchoTool()],
|
|
});
|
|
|
|
try {
|
|
await server.start();
|
|
const result = await runCli([
|
|
server.url,
|
|
"--transport",
|
|
"sse",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
});
|
|
|
|
it("should fail when HTTP transport is specified without URL", async () => {
|
|
const result = await runCli([
|
|
"--transport",
|
|
"http",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
});
|
|
|
|
it("should fail when SSE transport is specified without URL", async () => {
|
|
const result = await runCli([
|
|
"--transport",
|
|
"sse",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
]);
|
|
|
|
expectCliFailure(result);
|
|
});
|
|
});
|
|
|
|
describe("argv placeholder fallbacks", () => {
|
|
// The cli-runner helper always prepends `["node", "inspector-cli", ...]`, so
|
|
// `rawArgs[0]`/`rawArgs[1]` are never undefined through it. Call `runCli`
|
|
// directly with a too-short argv to exercise the `?? "node"` /
|
|
// `?? "inspector-cli"` fallbacks in parseArgs (commander's `name()`/program
|
|
// bootstrap). With no method present, parseArgs throws "Method is required"
|
|
// before any connection, so this stays fully in-process and fast.
|
|
it("defaults the node/script argv slots when rawArgs has fewer than two entries", async () => {
|
|
// Point the catalog at a fresh temp file so the empty-argv run resolves
|
|
// deterministically (the default ~/.mcp-inspector catalog may already hold
|
|
// several servers on the dev machine). Either way the rawArgs[0]/[1]
|
|
// fallbacks have already run by the time parseArgs throws.
|
|
const tempCatalog = join(
|
|
mkdtempSync(join(tmpdir(), "cli-argv-")),
|
|
"mcp.json",
|
|
);
|
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
const prev = process.env.MCP_CATALOG_PATH;
|
|
process.env.MCP_CATALOG_PATH = tempCatalog;
|
|
try {
|
|
await expect(runCliDirect([])).rejects.toThrow();
|
|
} finally {
|
|
if (prev === undefined) delete process.env.MCP_CATALOG_PATH;
|
|
else process.env.MCP_CATALOG_PATH = prev;
|
|
errorSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
});
|