* feat(cli,tui): normalize --catalog / --config with web; update --help (#1347) Bring the CLI and TUI in line with the web catalog vs read-only session model introduced in #1481/#1483, and close the outstanding --help item from #1347. Core (core/mcp/node/config.ts): - Add `catalogPath` to ServerConfigOptions (writable catalog slot). - `resolveServerSource()` picks the active source: `--catalog` (writable, seed-if-missing) wins over `--config` (read-only, error-if-missing). - Writable sources seed an empty `{"mcpServers":{}}` on first run (matches the web backend) instead of erroring; read-only `--config` still errors. - `serverSourceConflict()` shared conflict matrix (mirrors run-web): --catalog/--config mutually exclusive; neither combines with an ad-hoc target. - `readServerListFile()` public loader for runners (TUI) needing the raw map. - Rename `withDefaultConfigPath` → `withDefaultCatalogPath`; the no-flag default now fills the writable catalog slot, not --config. CLI / TUI: - Add `--catalog <path>` (+ MCP_CATALOG_PATH) and re-document `--config` as a read-only session in --help. - Enforce the shared conflict matrix before resolution. Full writable persistence/CRUD threading remains deferred to #1482 / #1432 (CLI/TUI are read consumers today; the split surfaces as seed- vs error-on-missing). Tests: core resolution matrix (source/conflict/seed/read-only), CLI catalog + conflict integration tests, TUI loadTuiServers unit tests. READMEs updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(launcher): add smoke:cli + smoke:tui e2e launcher smokes (#1347) The launcher smokes only checked `--help` for --cli/--tui; nothing exercised the launcher → CLI/TUI → core path on the built artifact (only smoke:web did, for web). Add two end-to-end smokes wired into validate:launcher between smoke:launcher and smoke:web: - smoke:cli (scripts/smoke-cli.mjs): runs `--cli` against the bundled stdio test server via a temp --catalog and asserts tools/list returns the tools, plus the #1347 resolution paths — default-catalog seed-on-missing, read-only --config error-without-seed, and the --catalog/--config conflict. - smoke:tui (scripts/smoke-tui.mjs): launches `--tui --catalog <temp>` and asserts the Ink app renders its first frame ("MCP Servers") within a timeout, then SIGTERMs it (shallow boot/render check). Both build test-servers/build on demand if missing. Docs updated (AGENTS.md validate description, launcher README). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: address review on #1499 (comment accuracy, CLI/TUI flag parity) Review feedback from @claude on PR #1499 (all non-blocking): - Align CLI catalogPath resolution with the TUI: `options.catalog?.trim() || process.env.MCP_CATALOG_PATH` so an explicit empty `--catalog ""` falls back to MCP_CATALOG_PATH identically in both front-ends (CLI used `??`, which only guards undefined/null). - Clarify the `serverSourceConflict` comment: it mirrors only the source-selection portion of web's matrix and deliberately omits web's `--header` rejection, since CLI/TUI merge --header into per-server settings. - Note the deliberate --header broadcast semantics in loadTuiServers. - Share an EMPTY_CATALOG_CONTENT constant between seedEmptyCatalog and readServerListContent instead of two separate literals. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(cli): trim --config to match --catalog and the TUI (#1499 review) Cosmetic uniformity follow-up from the re-review: configPath now uses `options.config?.trim() || undefined`, matching the catalog slot above it and the TUI. Behavior is unchanged (resolveServerSource/serverSourceConflict already trim downstream). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
36b48e61a2
commit
73b651ff21
2 changed files with 962 additions and 81 deletions
170
scripts/smoke-cli.mjs
Normal file
170
scripts/smoke-cli.mjs
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* End-to-end smoke test for the prod CLI launcher path (#1347).
|
||||
*
|
||||
* `npm run smoke:launcher` only checks `--cli --help`; it never connects to a
|
||||
* server, so the launcher → CLI → core → stdio-transport path and the
|
||||
* `--catalog` / `--config` resolution introduced in #1347 went unverified by
|
||||
* the launcher smokes. This script drives the built `clients/launcher/build`
|
||||
* artifact in `--cli` mode and asserts, end to end:
|
||||
*
|
||||
* 1. `--catalog <populated>` + `--server` runs `tools/list` and returns the
|
||||
* bundled test server's tools (real connect over stdio).
|
||||
* 2. The default writable catalog is seeded empty on first run (and reports
|
||||
* "no servers"), rather than erroring "Config file not found".
|
||||
* 3. A read-only `--config` that is missing errors and is NOT seeded.
|
||||
* 4. `--catalog` + `--config` is rejected as mutually exclusive.
|
||||
*
|
||||
* Exits non-zero (failing CI / `npm run validate`) on any mismatch.
|
||||
*
|
||||
* Expects `clients/launcher/build` and `clients/cli/build` to be built first
|
||||
* (the validate / CI ordering guarantees this). The bundled stdio test server
|
||||
* (`test-servers/build`) is built on demand here if missing.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, "..");
|
||||
const launcher = join(repoRoot, "clients", "launcher", "build", "index.js");
|
||||
const testServer = join(
|
||||
repoRoot,
|
||||
"test-servers",
|
||||
"build",
|
||||
"test-server-stdio.js",
|
||||
);
|
||||
|
||||
function fail(message) {
|
||||
console.error(`smoke:cli FAILED — ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Build the bundled stdio test server if it isn't present yet. */
|
||||
function ensureTestServer() {
|
||||
if (existsSync(testServer)) return;
|
||||
console.log("smoke:cli — building test-servers (missing build output)...");
|
||||
const r = spawnSync("npx", ["tsc", "-p", "test-servers", "--noCheck"], {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (r.status !== 0 || !existsSync(testServer)) {
|
||||
fail(
|
||||
"could not build the stdio test server (test-servers/build/test-server-stdio.js). " +
|
||||
"Run `npm run test-servers:build` from clients/cli.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the launcher in --cli mode. Returns { status, stdout, stderr }. */
|
||||
function runCli(args, extraEnv = {}) {
|
||||
const r = spawnSync(process.execPath, [launcher, "--cli", ...args], {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, ...extraEnv },
|
||||
encoding: "utf-8",
|
||||
});
|
||||
return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
||||
}
|
||||
|
||||
if (!existsSync(launcher)) {
|
||||
fail(`launcher build not found at ${launcher} — run \`npm run build\` first`);
|
||||
}
|
||||
ensureTestServer();
|
||||
|
||||
const work = mkdtempSync(join(tmpdir(), "smoke-cli-"));
|
||||
try {
|
||||
// 1) Populated --catalog → tools/list returns the test server's tools.
|
||||
const catalogPath = join(work, "catalog.json");
|
||||
writeFileSync(
|
||||
catalogPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
test: { type: "stdio", command: process.execPath, args: [testServer] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
const list = runCli([
|
||||
"--catalog",
|
||||
catalogPath,
|
||||
"--server",
|
||||
"test",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
if (list.status !== 0) {
|
||||
fail(
|
||||
`tools/list via --catalog exited ${list.status}\n${list.stderr || list.stdout}`,
|
||||
);
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(list.stdout);
|
||||
} catch {
|
||||
fail(`tools/list did not return JSON on stdout:\n${list.stdout}`);
|
||||
}
|
||||
const toolNames = (parsed.tools ?? []).map((t) => t.name);
|
||||
if (!toolNames.includes("echo")) {
|
||||
fail(
|
||||
`tools/list missing expected "echo" tool; got: ${toolNames.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2) Default writable catalog is seeded empty on first run (HOME redirected).
|
||||
const fakeHome = join(work, "home");
|
||||
const seed = runCli(["--method", "tools/list"], {
|
||||
HOME: fakeHome,
|
||||
USERPROFILE: fakeHome,
|
||||
});
|
||||
if (seed.status === 0) {
|
||||
fail("default catalog with no servers should have exited non-zero");
|
||||
}
|
||||
if (!/No servers found/.test(seed.stderr)) {
|
||||
fail(
|
||||
`expected "No servers found" from seeded default catalog; got:\n${seed.stderr}`,
|
||||
);
|
||||
}
|
||||
if (!existsSync(join(fakeHome, ".mcp-inspector", "mcp.json"))) {
|
||||
fail("default catalog was not seeded at ~/.mcp-inspector/mcp.json");
|
||||
}
|
||||
|
||||
// 3) Read-only --config that is missing errors and is NOT seeded.
|
||||
const missingConfig = join(work, "missing.json");
|
||||
const ro = runCli(["--config", missingConfig, "--method", "tools/list"]);
|
||||
if (ro.status === 0) {
|
||||
fail("missing --config should have exited non-zero");
|
||||
}
|
||||
if (!/Config file not found/.test(ro.stderr)) {
|
||||
fail(
|
||||
`expected "Config file not found" for missing --config; got:\n${ro.stderr}`,
|
||||
);
|
||||
}
|
||||
if (existsSync(missingConfig)) {
|
||||
fail("read-only --config must never be seeded, but the file was created");
|
||||
}
|
||||
|
||||
// 4) --catalog + --config is rejected.
|
||||
const conflict = runCli([
|
||||
"--catalog",
|
||||
catalogPath,
|
||||
"--config",
|
||||
catalogPath,
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
if (conflict.status === 0) {
|
||||
fail("--catalog with --config should have exited non-zero");
|
||||
}
|
||||
if (!/mutually exclusive/.test(conflict.stderr)) {
|
||||
fail(
|
||||
`expected "mutually exclusive" for --catalog + --config; got:\n${conflict.stderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"smoke:cli OK — tools/list over stdio via --catalog; default-catalog seed; " +
|
||||
"read-only --config error (no seed); --catalog/--config conflict",
|
||||
);
|
||||
} finally {
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
}
|
||||
134
scripts/smoke-tui.mjs
Normal file
134
scripts/smoke-tui.mjs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Boot smoke test for the prod TUI launcher path (#1347).
|
||||
*
|
||||
* `npm run smoke:launcher` only checks `--tui --help`; it never actually starts
|
||||
* the Ink app, so the launcher → TUI → core path and `--catalog` loading went
|
||||
* unverified by the launcher smokes. This script launches the built launcher in
|
||||
* `--tui` mode against a temp `--catalog`, waits for the app to render its first
|
||||
* frame (the "MCP Servers" panel) within a timeout, then sends SIGTERM and
|
||||
* exits.
|
||||
*
|
||||
* It asserts the TUI *boots and renders* without crashing — not full
|
||||
* interaction (driving an Ink UI deterministically in CI is flaky, so this is
|
||||
* intentionally a shallow render check). Exits non-zero on a crash-before-render
|
||||
* or a render timeout.
|
||||
*
|
||||
* Expects `clients/launcher/build` and `clients/tui/build` to be built first
|
||||
* (the validate / CI ordering guarantees this). The bundled stdio test server
|
||||
* (`test-servers/build`) is built on demand here if missing.
|
||||
*/
|
||||
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, "..");
|
||||
const launcher = join(repoRoot, "clients", "launcher", "build", "index.js");
|
||||
const testServer = join(
|
||||
repoRoot,
|
||||
"test-servers",
|
||||
"build",
|
||||
"test-server-stdio.js",
|
||||
);
|
||||
const RENDER_MARKER = "MCP Servers";
|
||||
const TIMEOUT_MS = Number(process.env.SMOKE_TUI_TIMEOUT_MS ?? 15000);
|
||||
|
||||
function fail(message) {
|
||||
console.error(`smoke:tui FAILED — ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function ensureTestServer() {
|
||||
if (existsSync(testServer)) return;
|
||||
console.log("smoke:tui — building test-servers (missing build output)...");
|
||||
const r = spawnSync("npx", ["tsc", "-p", "test-servers", "--noCheck"], {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (r.status !== 0 || !existsSync(testServer)) {
|
||||
fail(
|
||||
"could not build the stdio test server (test-servers/build/test-server-stdio.js). " +
|
||||
"Run `npm run test-servers:build` from clients/cli.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync(launcher)) {
|
||||
fail(`launcher build not found at ${launcher} — run \`npm run build\` first`);
|
||||
}
|
||||
ensureTestServer();
|
||||
|
||||
const work = mkdtempSync(join(tmpdir(), "smoke-tui-"));
|
||||
const catalogPath = join(work, "catalog.json");
|
||||
writeFileSync(
|
||||
catalogPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
test: { type: "stdio", command: process.execPath, args: [testServer] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[launcher, "--tui", "--catalog", catalogPath],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
// Redirect HOME so the TUI's storage never touches the real ~/.mcp-inspector.
|
||||
env: { ...process.env, HOME: work, USERPROFILE: work },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
|
||||
let output = "";
|
||||
let settled = false;
|
||||
|
||||
function cleanup() {
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function done(code, message) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (!child.killed) child.kill("SIGTERM");
|
||||
cleanup();
|
||||
if (code === 0) {
|
||||
console.log(`smoke:tui OK — ${message}`);
|
||||
} else {
|
||||
console.error(`smoke:tui FAILED — ${message}`);
|
||||
}
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function onData(chunk) {
|
||||
output += chunk.toString();
|
||||
if (output.includes(RENDER_MARKER)) {
|
||||
done(0, `rendered "${RENDER_MARKER}" panel from --catalog within timeout`);
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout.on("data", onData);
|
||||
child.stderr.on("data", onData);
|
||||
|
||||
child.on("exit", (code) => {
|
||||
if (settled) return;
|
||||
// Exiting before the render marker appeared is a failure (crash on boot).
|
||||
done(
|
||||
1,
|
||||
`TUI exited (code ${code}) before rendering "${RENDER_MARKER}"\n${output.slice(0, 800)}`,
|
||||
);
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
done(1, `failed to spawn TUI: ${err.message}`);
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
done(
|
||||
1,
|
||||
`TUI did not render "${RENDER_MARKER}" within ${TIMEOUT_MS}ms\n${output.slice(0, 800)}`,
|
||||
);
|
||||
}, TIMEOUT_MS);
|
||||
Loading…
Reference in a new issue