feat: port CLI, TUI, and launcher from v1.5 into v2 (#1452)

Ports the CLI, TUI, and launcher from v1.5 into v2 behind a single
`mcp-inspector` entrypoint (launcher → web/cli/tui). Includes the
`mcp-inspector --web --dev` launcher fix (Vite alias resolution + prod-server
double-start), the root `postinstall` install cascade with simplified CI, and
spec/AGENTS docs (no-workspaces rationale, Development setup).

Closes #1246.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bob Dickinson 2026-06-14 17:27:49 -07:00 • committed by GitHub
commit 2488ca5e38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 25594 additions and 236 deletions

View file

@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import { silentLogger } from "../../../../../../core/logging/logger.js";
describe("silentLogger", () => {
it("reports silent level", () => {
expect(silentLogger.level).toBe("silent");
});
it("accepts log calls at every level without throwing", () => {
expect(() => {
silentLogger.fatal("fatal");
silentLogger.error("error");
silentLogger.warn("warn");
silentLogger.info("info");
silentLogger.debug("debug");
silentLogger.trace("trace");
silentLogger.silent("silent");
}).not.toThrow();
});
it("child returns a logger that remains silent", () => {
const child = silentLogger.child({ component: "test" });
expect(child.level).toBe("silent");
expect(() => child.info("nested")).not.toThrow();
expect(child.child()).toBe(child);
});
});

View file

@ -1,11 +1,13 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
parseKeyValuePair,
parseHeaderPair,
withDefaultConfigPath,
resolveServerConfigs,
resolveLaunchServerConfigs,
getNamedServerConfigs,
} from "@inspector/core/mcp/node/config.js";
@ -57,6 +59,86 @@ describe("parseHeaderPair", () => {
});
});
describe("withDefaultConfigPath", () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "inspector-config-test-"));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("leaves options unchanged when --config or ad-hoc target is present", () => {
expect(withDefaultConfigPath({ configPath: "/tmp/mcp.json" })).toEqual({
configPath: "/tmp/mcp.json",
});
expect(withDefaultConfigPath({ target: ["node", "server.js"] })).toEqual({
target: ["node", "server.js"],
});
});
it("injects the default catalog path when no config or ad-hoc target is given", () => {
const prevHome = process.env.HOME;
const homeDir = join(tempDir, "home");
mkdirSync(homeDir, { recursive: true });
process.env.HOME = homeDir;
try {
const options = withDefaultConfigPath({});
expect(options.configPath).toBe(
join(homeDir, ".mcp-inspector", "mcp.json"),
);
} finally {
process.env.HOME = prevHome;
}
});
});
describe("resolveLaunchServerConfigs", () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "inspector-config-test-"));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("uses the default catalog when no target or --config is given", () => {
const prevHome = process.env.HOME;
process.env.HOME = tempDir;
try {
expect(() =>
resolveLaunchServerConfigs({ target: [] }, "single"),
).toThrow(/Config file not found/);
} finally {
process.env.HOME = prevHome;
}
});
it("loads all servers from the default catalog in multi mode", () => {
const prevHome = process.env.HOME;
const homeDir = join(tempDir, "home");
mkdirSync(homeDir, { recursive: true });
const defaultConfig = join(homeDir, ".mcp-inspector", "mcp.json");
mkdirSync(join(homeDir, ".mcp-inspector"), { recursive: true });
writeFileSync(
defaultConfig,
JSON.stringify({ mcpServers: { a: { command: "a" } } }),
);
process.env.HOME = homeDir;
try {
const configs = resolveLaunchServerConfigs({}, "multi");
expect(configs).toHaveLength(1);
expect(configs[0]).toMatchObject({ type: "stdio", command: "a" });
} finally {
process.env.HOME = prevHome;
}
});
});
describe("resolveServerConfigs — single mode", () => {
let tempDir: string;
let configPath: string;
@ -170,7 +252,7 @@ describe("resolveServerConfigs — single mode", () => {
).toThrow(/Only stdio transport can be used with local commands/);
});
it("rejects an empty target with no URL", () => {
it("requires a target when no config path is given", () => {
expect(() => resolveServerConfigs({ target: [] }, "single")).toThrow(
/Target is required/,
);
@ -358,6 +440,12 @@ describe("resolveServerConfigs — multi mode", () => {
expect(configs[0]?.type).toBe("stdio");
});
it("requires a target when no config path is given", () => {
expect(() => resolveServerConfigs({}, "multi")).toThrow(
/Target is required/,
);
});
it("rejects ad-hoc flags alongside a config path", () => {
writeFileSync(
configPath,

View file

@ -0,0 +1,323 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
const { mockClose, startViteDevServer, startHonoServer } = vi.hoisted(() => {
const mockClose = vi.fn().mockResolvedValue(undefined);
return {
mockClose,
startViteDevServer: vi.fn().mockResolvedValue({ close: mockClose }),
startHonoServer: vi.fn().mockResolvedValue({ close: mockClose }),
};
});
vi.mock("../../../../server/start-vite-dev-server.js", () => ({
startViteDevServer,
}));
vi.mock("../../../../server/server.js", () => ({
startHonoServer,
}));
import * as nodeConfig from "../../../../../../core/mcp/node/config.js";
import { runWeb } from "../../../../server/run-web.js";
describe("runWeb", () => {
let logLines: string[];
let warnLines: string[];
let errorLines: string[];
let originalLog: typeof console.log;
let originalWarn: typeof console.warn;
let originalError: typeof console.error;
let exitSpy: ReturnType<typeof vi.spyOn>;
let exitCode: number | undefined;
const signalHandlers = new Map<string, () => void>();
beforeEach(() => {
startViteDevServer.mockClear();
startHonoServer.mockClear();
mockClose.mockClear();
signalHandlers.clear();
exitCode = undefined;
logLines = [];
warnLines = [];
errorLines = [];
originalLog = console.log;
originalWarn = console.warn;
originalError = console.error;
console.log = (...args: unknown[]) => {
logLines.push(args.map(String).join(" "));
};
console.warn = (...args: unknown[]) => {
warnLines.push(args.map(String).join(" "));
};
console.error = (...args: unknown[]) => {
errorLines.push(args.map(String).join(" "));
};
vi.spyOn(process, "on").mockImplementation((event, handler) => {
if (event === "SIGINT" || event === "SIGTERM") {
signalHandlers.set(event, handler as () => void);
}
return process;
});
exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
exitCode = Number(code);
if (code !== 0) {
throw new Error(`process.exit:${String(code)}`);
}
return undefined as never;
});
});
afterEach(() => {
console.log = originalLog;
console.warn = originalWarn;
console.error = originalError;
exitSpy.mockRestore();
vi.restoreAllMocks();
});
async function expectServerStarted(
starter: typeof startHonoServer | typeof startViteDevServer,
) {
await vi.waitFor(() => expect(starter).toHaveBeenCalledTimes(1));
}
it("starts Hono in production mode with no server args", async () => {
void runWeb(["node", "run-web"]);
await expectServerStarted(startHonoServer);
expect(startHonoServer).toHaveBeenCalledWith(
expect.objectContaining({ initialMcpConfig: null }),
);
expect(startHonoServer.mock.calls[0]?.[0]?.staticRoot).toMatch(/dist$/);
expect(logLines.some((l) => l.includes("Starting MCP inspector..."))).toBe(
true,
);
});
it("starts Vite when --dev is set", async () => {
void runWeb(["node", "run-web", "--dev"]);
await expectServerStarted(startViteDevServer);
expect(startViteDevServer).toHaveBeenCalledWith(
expect.objectContaining({ initialMcpConfig: null }),
);
expect(logLines.some((l) => l.includes("development mode"))).toBe(true);
});
it("resolves an ad-hoc HTTP URL into initialMcpConfig", async () => {
void runWeb([
"node",
"run-web",
"https://example.com/mcp",
"--transport",
"http",
]);
await expectServerStarted(startHonoServer);
expect(startHonoServer).toHaveBeenCalledWith(
expect.objectContaining({
initialMcpConfig: expect.objectContaining({
type: "streamable-http",
url: "https://example.com/mcp",
}),
}),
);
});
it("appends positional args after -- to the ad-hoc target", async () => {
void runWeb([
"node",
"run-web",
"--transport",
"http",
"--",
"https://example.com/mcp",
]);
await expectServerStarted(startHonoServer);
expect(startHonoServer).toHaveBeenCalledWith(
expect.objectContaining({
initialMcpConfig: expect.objectContaining({
url: "https://example.com/mcp",
}),
}),
);
});
it("fills stdio cwd when omitted", async () => {
void runWeb(["node", "run-web", "node", "server.js"]);
await expectServerStarted(startHonoServer);
const config = startHonoServer.mock.calls[0]?.[0]?.initialMcpConfig;
expect(config).toMatchObject({
type: "stdio",
command: "node",
args: ["server.js"],
cwd: process.cwd(),
});
});
it("warns when --header is passed", async () => {
void runWeb([
"node",
"run-web",
"https://example.com/mcp",
"--transport",
"http",
"--header",
"Authorization: Bearer x",
]);
await expectServerStarted(startHonoServer);
expect(
warnLines.some((l) => l.includes("Warning: --header is accepted")),
).toBe(true);
});
it("loads a named server from a config file", async () => {
const dir = await mkdtemp(join(tmpdir(), "run-web-test-"));
const configPath = join(dir, "mcp.json");
await writeFile(
configPath,
JSON.stringify({
mcpServers: {
api: {
type: "streamable-http",
url: "https://example.com/mcp",
},
},
}),
);
try {
void runWeb([
"node",
"run-web",
"--config",
configPath,
"--server",
"api",
]);
await expectServerStarted(startHonoServer);
expect(startHonoServer).toHaveBeenCalledWith(
expect.objectContaining({
initialMcpConfig: expect.objectContaining({
type: "streamable-http",
url: "https://example.com/mcp",
}),
}),
);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("exits when server config resolution fails", async () => {
await expect(
runWeb(["node", "run-web", "--config", "/no/such/file.json"]),
).rejects.toThrow("process.exit:1");
expect(errorLines.some((l) => l.startsWith("Error:"))).toBe(true);
expect(startHonoServer).not.toHaveBeenCalled();
});
it("exits when the web server fails to start", async () => {
startHonoServer.mockRejectedValueOnce(new Error("bind failed"));
await expect(runWeb(["node", "run-web"])).rejects.toThrow("process.exit:1");
expect(errorLines.some((l) => l.includes("bind failed"))).toBe(true);
});
it("shuts down via SIGINT after a successful start", async () => {
void runWeb(["node", "run-web"]);
await expectServerStarted(startHonoServer);
const shutdown = signalHandlers.get("SIGINT");
expect(shutdown).toBeDefined();
expect(signalHandlers.get("SIGTERM")).toBeDefined();
shutdown!();
await vi.waitFor(() => expect(mockClose).toHaveBeenCalled());
await vi.waitFor(() => expect(exitCode).toBe(0));
});
it("preserves an existing stdio cwd", async () => {
const dir = await mkdtemp(join(tmpdir(), "run-web-cwd-"));
const configPath = join(dir, "mcp.json");
await writeFile(
configPath,
JSON.stringify({
mcpServers: {
local: {
type: "stdio",
command: "node",
args: ["server.js"],
cwd: "/custom/cwd",
},
},
}),
);
try {
void runWeb([
"node",
"run-web",
"--config",
configPath,
"--server",
"local",
]);
await expectServerStarted(startHonoServer);
expect(
startHonoServer.mock.calls[0]?.[0]?.initialMcpConfig,
).toMatchObject({
cwd: "/custom/cwd",
});
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("resolves --server-url without a positional target", async () => {
void runWeb([
"node",
"run-web",
"--server-url",
"https://example.com/mcp",
"--transport",
"http",
]);
await expectServerStarted(startHonoServer);
expect(startHonoServer).toHaveBeenCalledWith(
expect.objectContaining({
initialMcpConfig: expect.objectContaining({
url: "https://example.com/mcp",
}),
}),
);
});
it("exits when resolveServerConfigs returns no entries", async () => {
vi.spyOn(nodeConfig, "resolveServerConfigs").mockReturnValueOnce([]);
await expect(
runWeb([
"node",
"run-web",
"https://example.com/mcp",
"--transport",
"http",
]),
).rejects.toThrow("process.exit:1");
expect(
errorLines.some((l) => l.includes("Could not resolve server config")),
).toBe(true);
});
});

View file

@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
buildWebServerConfig,
buildWebServerConfigFromEnv,
printServerBanner,
webServerConfigToInitialPayload,
@ -192,6 +193,39 @@ describe("buildWebServerConfigFromEnv", () => {
});
});
describe("buildWebServerConfig", () => {
it("matches buildWebServerConfigFromEnv when initialMcpConfig is omitted", () => {
process.env[API_SERVER_ENV_VARS.AUTH_TOKEN] = "shared";
expect(buildWebServerConfig()).toEqual(buildWebServerConfigFromEnv());
expect(buildWebServerConfig({ initialMcpConfig: null })).toEqual(
buildWebServerConfigFromEnv(),
);
});
it("preserves a stdio initialMcpConfig while applying shared env defaults", () => {
process.env.CLIENT_PORT = "7000";
const initialMcpConfig = {
type: "stdio" as const,
command: "node",
args: ["server.js"],
cwd: "/srv",
};
const cfg = buildWebServerConfig({ initialMcpConfig });
expect(cfg.port).toBe(7000);
expect(cfg.initialMcpConfig).toEqual(initialMcpConfig);
expect(cfg.allowedOrigins).toEqual(["http://localhost:7000"]);
});
it("preserves remote transport initialMcpConfig", () => {
const initialMcpConfig = {
type: "streamable-http" as const,
url: "https://example.com/mcp",
};
const cfg = buildWebServerConfig({ initialMcpConfig });
expect(cfg.initialMcpConfig).toEqual(initialMcpConfig);
});
});
describe("webServerConfigToInitialPayload", () => {
it("returns only defaultEnvironment when initialMcpConfig is null", () => {
const payload = webServerConfigToInitialPayload(baseConfig());