* 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
4 changed files with 962 additions and 81 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
102
clients/tui/__tests__/tui-servers.test.ts
Normal file
102
clients/tui/__tests__/tui-servers.test.ts
Normal 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue