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:
parent
53dc4549be
commit
2488ca5e38
4 changed files with 25594 additions and 236 deletions
42
clients/cli/__tests__/helpers/assertions.test.ts
Normal file
42
clients/cli/__tests__/helpers/assertions.test.ts
Normal 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)");
|
||||
});
|
||||
});
|
||||
64
clients/cli/__tests__/helpers/assertions.ts
Normal file
64
clients/cli/__tests__/helpers/assertions.ts
Normal 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;
|
||||
}
|
||||
97
clients/cli/__tests__/helpers/cli-runner.ts
Normal file
97
clients/cli/__tests__/helpers/cli-runner.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
90
clients/cli/__tests__/helpers/fixtures.ts
Normal file
90
clients/cli/__tests__/helpers/fixtures.ts
Normal 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));
|
||||
}
|
||||
Loading…
Reference in a new issue