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,42 @@
import { describe, it, expect } from "vitest";
import { expectCliSuccess, expectCliFailure } from "./assertions.js";
import type { CliResult } from "./cli-runner.js";
function assertionMessage(fn: () => void): string {
try {
fn();
return "";
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
}
describe("CLI assertion helpers", () => {
it("expectCliSuccess includes stdout and stderr on failure", () => {
const result: CliResult = {
exitCode: 1,
stdout: "tool output",
stderr: "connect error",
output: "tool outputconnect error",
};
const message = assertionMessage(() => expectCliSuccess(result));
expect(message).toContain("CLI exited with code 1");
expect(message).toContain("stdout: tool output");
expect(message).toContain("stderr: connect error");
});
it("expectCliFailure includes stdout and stderr on unexpected success", () => {
const result: CliResult = {
exitCode: 0,
stdout: "ok",
stderr: "",
output: "ok",
};
const message = assertionMessage(() => expectCliFailure(result));
expect(message).toContain("CLI unexpectedly exited with code 0");
expect(message).toContain("stdout: ok");
expect(message).toContain("stderr: (empty)");
});
});

View file

@ -0,0 +1,64 @@
import { expect } from "vitest";
import type { CliResult } from "./cli-runner.js";
function formatCliOutput(result: CliResult): string {
const out = result.stdout?.trim() || "(empty)";
const err = result.stderr?.trim() || "(empty)";
return `stdout: ${out}\nstderr: ${err}`;
}
/**
* Assert that CLI command succeeded (exit code 0)
*/
export function expectCliSuccess(result: CliResult) {
expect(
result.exitCode,
`CLI exited with code ${result.exitCode}. ${formatCliOutput(result)}`,
).toBe(0);
}
/**
* Assert that CLI command failed (non-zero exit code)
*/
export function expectCliFailure(result: CliResult) {
expect(
result.exitCode,
`CLI unexpectedly exited with code ${result.exitCode}. ${formatCliOutput(result)}`,
).not.toBe(0);
}
/**
* Assert that output contains expected text
*/
export function expectOutputContains(result: CliResult, text: string) {
expect(result.output).toContain(text);
}
/**
* Assert that output contains valid JSON
* Uses stdout (not stderr) since JSON is written to stdout and warnings go to stderr
*/
export function expectValidJson(result: CliResult) {
expect(() => JSON.parse(result.stdout)).not.toThrow();
return JSON.parse(result.stdout);
}
/**
* Assert that output contains JSON with error flag
*/
export function expectJsonError(result: CliResult) {
const json = expectValidJson(result);
expect(json.isError).toBe(true);
return json;
}
/**
* Assert that output contains expected JSON structure
*/
export function expectJsonStructure(result: CliResult, expectedKeys: string[]) {
const json = expectValidJson(result);
expectedKeys.forEach((key) => {
expect(json).toHaveProperty(key);
});
return json;
}

View file

@ -0,0 +1,97 @@
import { spawn } from "child_process";
import { resolve } from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CLI_PATH = resolve(__dirname, "../../build/index.js");
export interface CliResult {
exitCode: number | null;
stdout: string;
stderr: string;
output: string; // Combined stdout + stderr
}
export interface CliOptions {
timeout?: number;
cwd?: string;
env?: Record<string, string>;
signal?: AbortSignal;
}
/**
* Run the CLI with given arguments and capture output
*/
export async function runCli(
args: string[],
options: CliOptions = {},
): Promise<CliResult> {
return new Promise((resolve, reject) => {
const child = spawn("node", [CLI_PATH, ...args], {
stdio: ["pipe", "pipe", "pipe"],
cwd: options.cwd,
env: { ...process.env, ...options.env },
signal: options.signal,
// Own process group on Unix so timeout can kill CLI + stdio grandchildren.
detached: process.platform !== "win32",
});
let stdout = "";
let stderr = "";
let resolved = false;
// Default timeout of 10 seconds (less than vitest's 15s)
const timeoutMs = options.timeout ?? 10000;
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
// Kill the process and all its children
try {
if (process.platform !== "win32" && child.pid != null) {
process.kill(-child.pid, "SIGTERM");
} else {
child.kill("SIGTERM");
}
} catch {
// Process might already be dead, try direct kill
try {
child.kill("SIGKILL");
} catch {
// Process is definitely dead
}
}
reject(new Error(`CLI command timed out after ${timeoutMs}ms`));
}
}, timeoutMs);
child.stdout.on("data", (data) => {
stdout += data.toString();
});
child.stderr.on("data", (data) => {
stderr += data.toString();
});
child.on("close", (code) => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
resolve({
exitCode: code,
stdout,
stderr,
output: stdout + stderr,
});
}
});
child.on("error", (error) => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
reject(error);
}
});
});
}

View file

@ -0,0 +1,90 @@
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import * as crypto from "crypto";
import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server";
import type { MCPServerConfig } from "@modelcontextprotocol/inspector-core/mcp/index.js";
/**
* Sentinel value for tests that don't need a real server
* (tests that expect failure before connecting)
*/
export const NO_SERVER_SENTINEL = "invalid-command-that-does-not-exist";
/**
* Create a sample test config with test-stdio and test-http servers
* Returns a temporary config file path that should be cleaned up with deleteConfigFile()
* @param httpUrl - Optional full URL (including /mcp path) for test-http server.
* If not provided, uses a placeholder URL. The test-http server exists
* to test server selection logic and may not actually be used.
*/
export function createSampleTestConfig(httpUrl?: string): string {
const { command, args } = getTestMcpServerCommand();
return createTestConfig({
mcpServers: {
"test-stdio": {
type: "stdio",
command,
args,
env: {
HELLO: "Hello MCP!",
},
},
"test-http": {
type: "streamable-http",
url: httpUrl || "http://localhost:3001/mcp",
},
},
});
}
/**
* Create a temporary directory for test files
* Uses crypto.randomUUID() to ensure uniqueness even when called in parallel
*/
function createTempDir(prefix: string = "mcp-inspector-test-"): string {
const uniqueId = crypto.randomUUID();
const tempDir = path.join(os.tmpdir(), `${prefix}${uniqueId}`);
fs.mkdirSync(tempDir, { recursive: true });
return tempDir;
}
/**
* Clean up temporary directory
*/
function cleanupTempDir(dir: string) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
}
/**
* Create a test config file
*/
export function createTestConfig(config: {
mcpServers: Record<string, MCPServerConfig>;
}): string {
const tempDir = createTempDir("mcp-inspector-config-");
const configPath = path.join(tempDir, "config.json");
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
return configPath;
}
/**
* Create an invalid config file (malformed JSON)
*/
export function createInvalidConfig(): string {
const tempDir = createTempDir("mcp-inspector-config-");
const configPath = path.join(tempDir, "invalid-config.json");
fs.writeFileSync(configPath, '{\n "mcpServers": {\n "invalid": {');
return configPath;
}
/**
* Delete a config file and its containing directory
*/
export function deleteConfigFile(configPath: string): void {
cleanupTempDir(path.dirname(configPath));
}