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
9 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) {

View file

@ -15,17 +15,36 @@ All configuration parsing, config-file loading, and server setup are handled by
`mcp-inspector --web` chooses which server list the UI shows and whether it is
editable (see [specification/v2_catalog_launch_config.md](../../specification/v2_catalog_launch_config.md)):
| Invocation | Server list | Editable in UI? |
|------------|-------------|-----------------|
| `mcp-inspector --web` | Default catalog `~/.mcp-inspector/mcp.json` | Yes |
| `mcp-inspector --web --catalog <path>` (or `MCP_CATALOG_PATH=<path>`) | That file as the active catalog (created/seeded if missing) | Yes |
| `mcp-inspector --web --config <path>` | That file as a **read-only session** — shown but never written, seeded, or migrated (safe for a foreign config) | No |
| `mcp-inspector --web --server-url <url> --transport http --header "Name: Value"` (or a positional command) | One ad-hoc server held in memory, connectable with the given `--header`s | No |
| Invocation | Server list | Editable in UI? |
| ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | --------------- |
| `mcp-inspector --web` | Default catalog `~/.mcp-inspector/mcp.json` | Yes |
| `mcp-inspector --web --catalog <path>` (or `MCP_CATALOG_PATH=<path>`) | That file as the active catalog (created/seeded if missing) | Yes |
| `mcp-inspector --web --config <path>` | That file as a **read-only session** — shown but never written, seeded, or migrated (safe for a foreign config) | No |
| `mcp-inspector --web --server-url <url> --transport http --header "Name: Value"` (or a positional command) | One ad-hoc server held in memory, connectable with the given `--header`s | No |
Rules: `--catalog` and `--config` are mutually exclusive; neither combines with
an ad-hoc target or `--header`; `--header` requires an ad-hoc HTTP/SSE server
and is applied to that connection (it is no longer a warn-only no-op).
## CLI and TUI server-list flags (`--cli` / `--tui`)
The CLI and TUI use the same `--catalog` / `--config` vocabulary as `--web`,
resolved by the shared `core/mcp/node/config.ts` helpers:
| Invocation | Server list source |
| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
| `mcp-inspector --cli` / `--tui` (no source flag, no ad-hoc target) | Default writable catalog `~/.mcp-inspector/mcp.json` (created/seeded empty if missing) |
| `--catalog <path>` (or `MCP_CATALOG_PATH=<path>`) | That file as a **writable catalog** — seeded empty if missing |
| `--config <path>` | That file as a **read-only session** — served as-is, never written or seeded; **errors if absent** |
| positional command / `--server-url <url>` | One ad-hoc server |
Rules (shared `serverSourceConflict`): `--catalog` and `--config` are mutually
exclusive, and neither combines with an ad-hoc command/URL target. The CLI/TUI
do not perform catalog CRUD yet — they are read consumers — so the
writable/read-only split currently surfaces only as **seed-if-missing**
(`--catalog`/default) vs **error-if-missing** (`--config`). Full writable
persistence is tracked in #1482 / #1432.
### Production web build (`--web`, no `--dev`)
Prod `--web` serves static assets from `clients/web/dist/`, which only exists
@ -42,6 +61,23 @@ end-to-end with `npm run smoke:web` (`scripts/smoke-web.mjs`): it starts
`mcp-inspector --web` against the built `dist/` and asserts `GET /` returns the
SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`.
### CLI and TUI smokes
`validate:launcher` also runs end-to-end smokes for the other two modes through
the built launcher artifact (beyond the `--help` checks in `smoke:launcher`):
- `npm run smoke:cli` (`scripts/smoke-cli.mjs`) — runs `mcp-inspector --cli`
against the bundled stdio test server via a temp `--catalog` and asserts
`tools/list` returns the server's tools, plus the `--catalog`/`--config`
resolution paths (default-catalog seed-on-missing, read-only `--config`
error-without-seed, `--catalog`/`--config` conflict).
- `npm run smoke:tui` (`scripts/smoke-tui.mjs`) — launches
`mcp-inspector --tui --catalog <temp>` and asserts the Ink app renders its
first frame within a timeout, then shuts it down (a shallow boot/render
check, not full interaction).
Both build `test-servers/build` on demand if it is missing.
## Publishing
The root `@modelcontextprotocol/inspector` package ships as one fat tarball: `npm run build` at the repo root builds all clients, then `prepack` runs before `npm publish`. Runtime dependencies are declared on the root `package.json`; client builds bundle `@inspector/core` and externalize npm packages resolved from the root install.

View file

@ -14,10 +14,13 @@ npx @modelcontextprotocol/inspector --tui node build/index.js
### With Configuration Files
The TUI can load all servers from an MCP config file:
The TUI can load all servers from an MCP catalog/config file. With no source
flag it uses the default writable catalog `~/.mcp-inspector/mcp.json` (seeded
empty if missing):
```bash
npx @modelcontextprotocol/inspector --tui --config mcp.json
npx @modelcontextprotocol/inspector --tui --catalog mcp.json # writable catalog (seeded if missing)
npx @modelcontextprotocol/inspector --tui --config mcp.json # read-only session (errors if absent)
```
(It does not use `--server`; all servers in the file are available in the TUI.)
@ -26,7 +29,7 @@ npx @modelcontextprotocol/inspector --tui --config mcp.json
### MCP server (which server(s) to connect to)
Options that specify the MCP server(s) (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`, `-e`, `--cwd`, `--header`, `--transport`, `--server-url`, and the positional `[target...]`.
Options that specify the MCP server(s) (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), `-e`, `--cwd`, `--header`, `--transport`, `--server-url`, and the positional `[target...]`. `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target.
### TUI-specific (OAuth for HTTP servers)

View file

@ -0,0 +1,102 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadTuiServers } from "../src/tui-servers.js";
describe("loadTuiServers", () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "tui-servers-test-"));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("loads named servers from a read-only --config file", () => {
const configPath = join(tempDir, "mcp.json");
writeFileSync(
configPath,
JSON.stringify({
mcpServers: { foo: { command: "node", args: ["foo.js"] } },
}),
);
const servers = loadTuiServers({ configPath });
expect(Object.keys(servers)).toEqual(["foo"]);
expect(servers.foo?.config).toMatchObject({
type: "stdio",
command: "node",
});
});
it("seeds an empty writable catalog when --catalog is missing", () => {
const catalogPath = join(tempDir, "catalog.json");
const servers = loadTuiServers({ catalogPath });
expect(servers).toEqual({});
expect(existsSync(catalogPath)).toBe(true);
expect(JSON.parse(readFileSync(catalogPath, "utf-8"))).toEqual({
mcpServers: {},
});
});
it("throws when a read-only --config file is missing (never seeds)", () => {
const configPath = join(tempDir, "absent.json");
expect(() => loadTuiServers({ configPath })).toThrow(
/Config file not found/,
);
expect(existsSync(configPath)).toBe(false);
});
it("rejects --catalog and --config together", () => {
const catalogPath = join(tempDir, "catalog.json");
const configPath = join(tempDir, "config.json");
writeFileSync(catalogPath, JSON.stringify({ mcpServers: {} }));
writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));
expect(() => loadTuiServers({ catalogPath, configPath })).toThrow(
/mutually exclusive/,
);
});
it("rejects --catalog combined with an ad-hoc target", () => {
const catalogPath = join(tempDir, "catalog.json");
writeFileSync(catalogPath, JSON.stringify({ mcpServers: {} }));
expect(() =>
loadTuiServers({ catalogPath, target: ["my-server"] }),
).toThrow(/--catalog cannot be combined/);
});
it("builds a single ad-hoc server from a positional target", () => {
const servers = loadTuiServers({ target: ["my-server", "--flag"] });
expect(Object.keys(servers)).toEqual(["default"]);
expect(servers.default?.config).toMatchObject({
type: "stdio",
command: "my-server",
args: ["--flag"],
});
});
it("merges --header into per-server settings for catalog servers", () => {
const catalogPath = join(tempDir, "catalog.json");
writeFileSync(
catalogPath,
JSON.stringify({
mcpServers: { web: { type: "streamable-http", url: "http://x/mcp" } },
}),
);
const servers = loadTuiServers({
catalogPath,
headers: { Authorization: "Bearer t" },
});
expect(servers.web?.settings?.headers).toEqual([
{ key: "Authorization", value: "Bearer t" },
]);
});
});

View file

@ -1,16 +1,17 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import type {
InspectorServerSettings,
MCPServerConfig,
MCPConfig,
StdioServerConfig,
} from "@inspector/core/mcp/types.js";
import { DEFAULT_TASK_TTL_MS } from "@inspector/core/mcp/types.js";
import { mcpConfigToServerEntries } from "@inspector/core/mcp/serverList.js";
import {
resolveServerConfigs,
withDefaultConfigPath,
resolveServerSource,
serverSourceConflict,
readServerListFile,
hasAdHocServerOptions,
withDefaultCatalogPath,
type ServerConfigOptions,
} from "@inspector/core/mcp/node/config.js";
@ -64,13 +65,21 @@ function mergeSettings(
export function loadTuiServers(
serverOptions: ServerConfigOptions & { headers?: Record<string, string> },
): Record<string, TuiServer> {
serverOptions = withDefaultConfigPath(serverOptions);
const hasConfigPath = Boolean(serverOptions.configPath?.trim());
serverOptions = withDefaultCatalogPath(serverOptions);
if (hasConfigPath) {
const configPath = serverOptions.configPath!;
const resolvedPath = resolve(process.cwd(), configPath);
const config = JSON.parse(readFileSync(resolvedPath, "utf-8")) as MCPConfig;
const conflict = serverSourceConflict({
hasCatalog: Boolean(serverOptions.catalogPath?.trim()),
hasConfig: Boolean(serverOptions.configPath?.trim()),
hasAdHoc: hasAdHocServerOptions(serverOptions),
});
if (conflict) {
throw new Error(conflict);
}
const source = resolveServerSource(serverOptions);
if (source) {
const config = readServerListFile(source.path, source.writable);
const entries = mcpConfigToServerEntries(config);
const result: Record<string, TuiServer> = {};
for (const entry of entries) {
@ -79,6 +88,9 @@ export function loadTuiServers(
env: serverOptions.env,
cwd: serverOptions.cwd,
}),
// Deliberate broadcast: a single `--header` set is merged into EVERY
// server in the catalog/config (fine for the common single-server case;
// for multi-server files, prefer per-server headers in the file itself).
settings: mergeSettings(entry.settings, serverOptions.headers),
};
}
@ -88,7 +100,7 @@ export function loadTuiServers(
const configs = resolveServerConfigs(serverOptions, "multi");
if (configs.length === 0) {
throw new Error(
"At least one server is required. Use --config <path> or ad-hoc target (command/URL).",
"At least one server is required. Use --catalog/--config <path> or an ad-hoc target (command/URL).",
);
}
return {

View file

@ -15,9 +15,13 @@ export async function runTui(args?: string[]): Promise<void> {
program
.name("mcp-inspector-tui")
.description("Terminal UI for MCP Inspector")
.option(
"--catalog <path>",
"Writable catalog file (created if missing; default: ~/.mcp-inspector/mcp.json, or MCP_CATALOG_PATH)",
)
.option(
"--config <path>",
"Path to MCP servers config file (or use ad-hoc server options below)",
"Read-only session config file (served as-is, never written or seeded; errors if absent)",
)
.option(
"-e <key=value...>",
@ -60,6 +64,7 @@ export async function runTui(args?: string[]): Promise<void> {
.parse(args ?? process.argv);
const options = program.opts() as {
catalog?: string;
config?: string;
e?: Record<string, string>;
cwd?: string;
@ -74,6 +79,7 @@ export async function runTui(args?: string[]): Promise<void> {
const targetArgs = program.args as string[];
const serverOptions = {
catalogPath: options.catalog?.trim() || process.env.MCP_CATALOG_PATH,
configPath: options.config?.trim() || undefined,
target: targetArgs.length > 0 ? targetArgs : undefined,
cwd: options.cwd?.trim() || undefined,

View file

@ -1,13 +1,23 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
writeFileSync,
rmSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
parseKeyValuePair,
parseHeaderPair,
withDefaultConfigPath,
withDefaultCatalogPath,
resolveServerConfigs,
resolveLaunchServerConfigs,
resolveServerSource,
serverSourceConflict,
readServerListFile,
getNamedServerConfigs,
} from "@inspector/core/mcp/node/config.js";
@ -59,7 +69,99 @@ describe("parseHeaderPair", () => {
});
});
describe("withDefaultConfigPath", () => {
describe("resolveServerSource", () => {
it("returns the writable catalog source when catalogPath is set", () => {
expect(resolveServerSource({ catalogPath: "/tmp/cat.json" })).toEqual({
path: "/tmp/cat.json",
writable: true,
});
});
it("returns the read-only config source when only configPath is set", () => {
expect(resolveServerSource({ configPath: "/tmp/cfg.json" })).toEqual({
path: "/tmp/cfg.json",
writable: false,
});
});
it("prefers the catalog over the config when both are set", () => {
expect(
resolveServerSource({
catalogPath: "/tmp/cat.json",
configPath: "/tmp/cfg.json",
}),
).toEqual({ path: "/tmp/cat.json", writable: true });
});
it("returns null when neither catalog nor config is set", () => {
expect(resolveServerSource({ target: ["cmd"] })).toBeNull();
expect(resolveServerSource({})).toBeNull();
});
it("ignores blank/whitespace-only paths", () => {
expect(
resolveServerSource({ catalogPath: " ", configPath: " " }),
).toBeNull();
});
});
describe("serverSourceConflict", () => {
it("rejects --catalog and --config together", () => {
expect(
serverSourceConflict({
hasCatalog: true,
hasConfig: true,
hasAdHoc: false,
}),
).toMatch(/mutually exclusive/);
});
it("rejects --catalog with an ad-hoc target", () => {
expect(
serverSourceConflict({
hasCatalog: true,
hasConfig: false,
hasAdHoc: true,
}),
).toMatch(/--catalog cannot be combined/);
});
it("rejects --config with an ad-hoc target", () => {
expect(
serverSourceConflict({
hasCatalog: false,
hasConfig: true,
hasAdHoc: true,
}),
).toMatch(/--config cannot be combined/);
});
it("allows a lone catalog, a lone config, or a lone ad-hoc target", () => {
expect(
serverSourceConflict({
hasCatalog: true,
hasConfig: false,
hasAdHoc: false,
}),
).toBeNull();
expect(
serverSourceConflict({
hasCatalog: false,
hasConfig: true,
hasAdHoc: false,
}),
).toBeNull();
expect(
serverSourceConflict({
hasCatalog: false,
hasConfig: false,
hasAdHoc: true,
}),
).toBeNull();
});
});
describe("withDefaultCatalogPath", () => {
let tempDir: string;
beforeEach(() => {
@ -70,31 +172,77 @@ describe("withDefaultConfigPath", () => {
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({
it("leaves options unchanged when --catalog, --config, or ad-hoc target is present", () => {
expect(withDefaultCatalogPath({ catalogPath: "/tmp/cat.json" })).toEqual({
catalogPath: "/tmp/cat.json",
});
expect(withDefaultCatalogPath({ configPath: "/tmp/mcp.json" })).toEqual({
configPath: "/tmp/mcp.json",
});
expect(withDefaultConfigPath({ target: ["node", "server.js"] })).toEqual({
expect(withDefaultCatalogPath({ target: ["node", "server.js"] })).toEqual({
target: ["node", "server.js"],
});
});
it("injects the default catalog path when no config or ad-hoc target is given", () => {
it("injects the default catalog path into the writable catalog slot when nothing 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(
const options = withDefaultCatalogPath({});
expect(options.catalogPath).toBe(
join(homeDir, ".mcp-inspector", "mcp.json"),
);
expect(options.configPath).toBeUndefined();
} finally {
process.env.HOME = prevHome;
}
});
});
describe("readServerListFile", () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "inspector-config-test-"));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("seeds an empty catalog on disk when a writable source is missing", () => {
const catalogPath = join(tempDir, "nested", "mcp.json");
const config = readServerListFile(catalogPath, true);
expect(config).toEqual({ mcpServers: {} });
expect(existsSync(catalogPath)).toBe(true);
expect(JSON.parse(readFileSync(catalogPath, "utf-8"))).toEqual({
mcpServers: {},
});
});
it("throws when a read-only source is missing (never seeds)", () => {
const configPath = join(tempDir, "absent.json");
expect(() => readServerListFile(configPath, false)).toThrow(
/Config file not found/,
);
expect(existsSync(configPath)).toBe(false);
});
it("reads and normalizes server types from an existing file", () => {
const configPath = join(tempDir, "mcp.json");
writeFileSync(
configPath,
JSON.stringify({
mcpServers: { a: { type: "http", url: "http://example.com/mcp" } },
}),
);
const config = readServerListFile(configPath, false);
expect(config.mcpServers.a).toMatchObject({ type: "streamable-http" });
});
});
describe("resolveLaunchServerConfigs", () => {
let tempDir: string;
@ -106,13 +254,37 @@ describe("resolveLaunchServerConfigs", () => {
rmSync(tempDir, { recursive: true, force: true });
});
it("uses the default catalog when no target or --config is given", () => {
it("seeds the default catalog and reports no servers when it is missing (single mode)", () => {
const prevHome = process.env.HOME;
process.env.HOME = tempDir;
const homeDir = join(tempDir, "home");
mkdirSync(homeDir, { recursive: true });
process.env.HOME = homeDir;
const defaultConfig = join(homeDir, ".mcp-inspector", "mcp.json");
try {
expect(() =>
resolveLaunchServerConfigs({ target: [] }, "single"),
).toThrow(/Config file not found/);
).toThrow(/No servers found/);
// The writable default catalog is seeded on first run (matches web),
// rather than erroring with "Config file not found".
expect(existsSync(defaultConfig)).toBe(true);
expect(JSON.parse(readFileSync(defaultConfig, "utf-8"))).toEqual({
mcpServers: {},
});
} finally {
process.env.HOME = prevHome;
}
});
it("returns an empty list from a seeded default catalog (multi mode)", () => {
const prevHome = process.env.HOME;
const homeDir = join(tempDir, "home");
mkdirSync(homeDir, { recursive: true });
process.env.HOME = homeDir;
try {
expect(resolveLaunchServerConfigs({}, "multi")).toEqual([]);
expect(existsSync(join(homeDir, ".mcp-inspector", "mcp.json"))).toBe(
true,
);
} finally {
process.env.HOME = prevHome;
}
@ -306,7 +478,7 @@ describe("resolveServerConfigs — single mode", () => {
);
});
it("throws when the config file is missing", () => {
it("throws when the read-only config file is missing", () => {
expect(() =>
resolveServerConfigs(
{ configPath: join(tempDir, "nonexistent.json") },
@ -315,6 +487,27 @@ describe("resolveServerConfigs — single mode", () => {
).toThrow(/Config file not found/);
});
it("seeds a missing writable catalog and then reports no servers", () => {
const catalogPath = join(tempDir, "seeded.json");
expect(() => resolveServerConfigs({ catalogPath }, "single")).toThrow(
/No servers found/,
);
expect(existsSync(catalogPath)).toBe(true);
});
it("loads a named server from a writable catalog", () => {
const catalogPath = join(tempDir, "cat.json");
writeFileSync(
catalogPath,
JSON.stringify({ mcpServers: { foo: { command: "node" } } }),
);
const [config] = resolveServerConfigs(
{ catalogPath, serverName: "foo" },
"single",
);
expect(config).toMatchObject({ type: "stdio", command: "node" });
});
it("throws when the config file has no mcpServers element", () => {
writeFileSync(configPath, JSON.stringify({}));
expect(() => resolveServerConfigs({ configPath }, "single")).toThrow(
@ -440,6 +633,21 @@ describe("resolveServerConfigs — multi mode", () => {
expect(configs[0]?.type).toBe("stdio");
});
it("returns all servers from a writable catalog, seeding it if missing", () => {
const catalogPath = join(tempDir, "cat.json");
// Missing → seeded empty → no servers.
expect(resolveServerConfigs({ catalogPath }, "multi")).toEqual([]);
expect(existsSync(catalogPath)).toBe(true);
// Now populated → returned.
writeFileSync(
catalogPath,
JSON.stringify({ mcpServers: { a: { command: "a" } } }),
);
const configs = resolveServerConfigs({ catalogPath }, "multi");
expect(configs).toHaveLength(1);
expect(configs[0]?.type).toBe("stdio");
});
it("requires a target when no config path is given", () => {
expect(() => resolveServerConfigs({}, "multi")).toThrow(
/Target is required/,