feat(cli,tui): normalize --catalog / --config with web; update --help (#1347) (#1499)

* 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:
Cliff Hall 2026-06-19 17:30:26 -04:00 • committed by GitHub
commit 73b651ff21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 962 additions and 81 deletions

View file

@ -82,7 +82,7 @@ npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --tr
### MCP server (which server to connect to)
Options that specify the MCP server (config file, ad-hoc command/URL, env vars, headers) are shared by the Web, CLI, and TUI and are documented in [MCP server configuration](../../docs/mcp-server-configuration.md): `--config`, `--server`, `-e`, `--cwd`, `--header`, `--transport`, `--server-url`, and the positional `[target...]`.
Options that specify the MCP server (catalog/config file, ad-hoc command/URL, env vars, headers) are shared by the Web, CLI, and TUI and are documented in [MCP server configuration](../../docs/mcp-server-configuration.md): `--catalog` (writable catalog, seeded if missing; default `~/.mcp-inspector/mcp.json` or `MCP_CATALOG_PATH`), `--config` (read-only session, errors if absent), `--server`, `-e`, `--cwd`, `--header`, `--transport`, `--server-url`, and the positional `[target...]`. `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target.
### CLI-specific (what to invoke)

View file

@ -253,6 +253,73 @@ describe("CLI Tests", () => {
});
});
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("Resource Options", () => {
it("should read resource with URI", async () => {
const { command, args } = getTestMcpServerCommand();

View file

@ -19,6 +19,8 @@ import {
import {
createTransportNode,
resolveLaunchServerConfigs,
serverSourceConflict,
hasAdHocServerOptions,
parseKeyValuePair as parseEnvPair,
parseHeaderPair,
} from "@inspector/core/mcp/node/index.js";
@ -288,8 +290,15 @@ function parseArgs(argv?: string[]): {
"[target...]",
"Command and arguments or URL of the MCP server (or use --config and --server)",
)
.option("--config <path>", "Config file path")
.option("--server <name>", "Server name from config file")
.option(
"--catalog <path>",
"Writable catalog file (created if missing; default: ~/.mcp-inspector/mcp.json, or MCP_CATALOG_PATH)",
)
.option(
"--config <path>",
"Read-only session config file (served as-is, never written or seeded; errors if absent)",
)
.option("--server <name>", "Server name from config/catalog file")
.option(
"-e <env>",
"Environment variables for the server (KEY=VALUE)",
@ -364,6 +373,7 @@ function parseArgs(argv?: string[]): {
program.parse(preArgs);
const options = program.opts() as {
catalog?: string;
config?: string;
server?: string;
e?: Record<string, string>;
@ -383,7 +393,10 @@ function parseArgs(argv?: string[]): {
};
const serverOptions = {
configPath: options.config,
// `?.trim() ||` (not `??`) so an explicit empty `--catalog ""` still falls
// back to MCP_CATALOG_PATH — keeps CLI and TUI flag resolution identical.
catalogPath: options.catalog?.trim() || process.env.MCP_CATALOG_PATH,
configPath: options.config?.trim() || undefined,
serverName: options.server,
target: targetArgs.length > 0 ? targetArgs : undefined,
transport: options.transport,
@ -392,6 +405,15 @@ function parseArgs(argv?: string[]): {
env: options.e,
};
const conflict = serverSourceConflict({
hasCatalog: Boolean(serverOptions.catalogPath?.trim()),
hasConfig: Boolean(serverOptions.configPath?.trim()),
hasAdHoc: hasAdHocServerOptions(serverOptions),
});
if (conflict) {
throw new Error(conflict);
}
const configs = resolveLaunchServerConfigs(serverOptions, "single");
const serverConfig = configs[0];
if (!serverConfig) {