inspector/clients/tui/tui.tsx
Cliff Hall 73b651ff21
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>
2026-06-19 17:30:26 -04:00

204 lines
5.5 KiB
JavaScript

#!/usr/bin/env node
import { Command } from "commander";
import { render } from "ink";
import {
parseKeyValuePair,
parseHeaderPair,
} from "@inspector/core/mcp/node/index.js";
import App from "./src/App.js";
import { loadTuiServers } from "./src/tui-servers.js";
export async function runTui(args?: string[]): Promise<void> {
const program = new Command();
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>",
"Read-only session config file (served as-is, never written or seeded; errors if absent)",
)
.option(
"-e <key=value...>",
"Environment variables for stdio servers",
parseKeyValuePair,
{},
)
.option("--cwd <path>", "Working directory for stdio servers")
.option(
"--header <header...>",
'HTTP headers as "Name: Value"',
parseHeaderPair,
{},
)
.option(
"--client-id <id>",
"OAuth client ID (static client) for HTTP servers",
)
.option(
"--client-secret <secret>",
"OAuth client secret (for confidential clients)",
)
.option(
"--client-metadata-url <url>",
"OAuth Client ID Metadata Document URL (CIMD) for HTTP servers",
)
.option(
"--callback-url <url>",
"OAuth redirect/callback listener URL (default: http://127.0.0.1:0/oauth/callback)",
)
.argument(
"[target...]",
"Command and args or URL for a single ad-hoc server (when not using --config)",
)
.option(
"--transport <type>",
"Transport: stdio, sse, or http (ad-hoc only)",
)
.option("--server-url <url>", "Server URL (ad-hoc only)")
.parse(args ?? process.argv);
const options = program.opts() as {
catalog?: string;
config?: string;
e?: Record<string, string>;
cwd?: string;
header?: Record<string, string>;
clientId?: string;
clientSecret?: string;
clientMetadataUrl?: string;
callbackUrl?: string;
transport?: "stdio" | "sse" | "http";
serverUrl?: string;
};
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,
env: options.e,
headers: options.header,
transport: options.transport,
serverUrl: options.serverUrl?.trim() || undefined,
};
const mcpServers = loadTuiServers(serverOptions);
interface CallbackUrlConfig {
hostname: string;
port: number;
pathname: string;
}
function parseCallbackUrl(raw?: string): CallbackUrlConfig {
if (!raw) {
return { hostname: "127.0.0.1", port: 0, pathname: "/oauth/callback" };
}
let url: URL;
try {
url = new URL(raw);
} catch (err) {
throw new Error(
`Invalid callback URL: ${(err as Error)?.message ?? String(err)}`,
);
}
if (url.protocol !== "http:") {
throw new Error("Callback URL must use http scheme");
}
const hostname = url.hostname;
if (!hostname) {
throw new Error("Callback URL must include a hostname");
}
const pathname = url.pathname || "/";
let port: number;
if (url.port === "") {
port = 80;
} else {
port = Number(url.port);
if (
!Number.isFinite(port) ||
!Number.isInteger(port) ||
port < 0 ||
port > 65535
) {
throw new Error("Callback URL port must be between 0 and 65535");
}
}
return { hostname, port, pathname };
}
const callbackUrlConfig = parseCallbackUrl(options.callbackUrl);
const ansiEraseSavedLines = new RegExp(
String.fromCharCode(0x1b) + "\\[3J",
"g",
);
const originalWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = function (
chunk: string | Buffer,
encoding?: BufferEncoding | ((err?: Error) => void),
cb?: (err?: Error) => void,
): boolean {
if (typeof chunk === "string") {
if (chunk.includes("\x1b[3J")) {
chunk = chunk.replace(ansiEraseSavedLines, "");
}
} else if (Buffer.isBuffer(chunk)) {
if (chunk.includes("\x1b[3J")) {
let str = chunk.toString("utf8");
str = str.replace(ansiEraseSavedLines, "");
chunk = Buffer.from(str, "utf8");
}
}
if (typeof encoding === "function") {
return (
originalWrite as (
chunk: string | Buffer,
cb?: (err?: Error) => void,
) => boolean
)(chunk, encoding);
}
return (
originalWrite as (
chunk: string | Buffer,
encoding?: BufferEncoding,
cb?: (err?: Error) => void,
) => boolean
)(chunk, encoding, cb);
};
if (process.stdout.isTTY) {
process.stdout.write("\x1b[?1049h");
}
const instance = render(
<App
mcpServers={mcpServers}
clientId={options.clientId}
clientSecret={options.clientSecret}
clientMetadataUrl={options.clientMetadataUrl}
callbackUrlConfig={callbackUrlConfig}
/>,
);
try {
await instance.waitUntilExit();
if (process.stdout.isTTY) {
process.stdout.write("\x1b[?1049l");
}
process.exit(0);
} catch (error: unknown) {
if (process.stdout.isTTY) {
process.stdout.write("\x1b[?1049l");
}
console.error("TUI Error:", error);
process.exit(1);
}
}