/** * @file cc-mcp-cli.js * @description Drives `claude mcp add`/`claude mcp remove` so the dashboard * can add and remove MCP servers without touching `~/.claude.json` directly. * * Why the CLI and not a direct JSON edit: `~/.claude.json` is rewritten * concurrently by the running Claude Code CLI (health checks, OAuth state, * project bookkeeping) — the same reason `cc-plugin-cli.js` shells out for * plugins/marketplaces instead of editing `installed_plugins.json` by hand. * Reads still go through `cc-discovery.readMcpServers` (parses the JSON * directly); this file only ever mutates. * * The real `claude mcp add --help` (v2.1.266) does NOT match the CLI shape a * first draft of this file assumed: * - There is no `--url` flag. `commandOrUrl` is a positional argument: * `claude mcp add --transport http `. * - `--header "Key: value"` must come AFTER the positional url — placed * before it, the variadic `--header ` swallows the url as one * of its own values and the CLI reports a missing `commandOrUrl`. * - `list`/`get` have no `--json` output — reads correctly stay on * `readMcpServers`, which parses `~/.claude.json` itself; this module * never lists. * - The CLI's own name validator is "letters, numbers, hyphens, and * underscores" — `NAME_RE` below matches that. * Verified by running `claude mcp add/remove/list --help` and exercising * each form against a throwaway `CLAUDE_CONFIG_DIR`, not assumed from docs. * * Hard constraints (do not relax without a follow-up review): * - `execFile` via `cc-plugin-cli.js`'s `runClaude` — argv array, no shell. * - Every argument is validated BEFORE the spawn: a name/command/url may * never start with `-` (it would be parsed as a flag); an `-e`/`--header` * VALUE may, because it is the right-hand side of one `KEY=VALUE` (or * `Key: value`) argv element, never its own argv position. * - Mutating calls are serialized in-process; two dashboard clicks must not * interleave writes to the same `~/.claude.json`. * - Only `user`/`local` scope is exposed — matching what `readMcpServers` * can actually see: `user` is `~/.claude.json` top-level, `local` is * `~/.claude.json`'s `projects[]` block (the panel's * "projectScoped" bucket). The CLI's `project` scope writes `.mcp.json`, * which `readMcpServers` never reads — offering it would add a server * the CLI accepts and the dashboard can never show existed. * * A non-zero CLI exit resolves as `{ok: false}` (data, not an error) so the * route can map it to HTTP 422. Only a bad argument or a missing binary * throws — those are programming/environment errors, not CLI outcomes. * * @author Nguyễn Ngọc Trí Vĩ */ // Reused, not reimplemented: runClaude/cliMessage/serialize already pin // CLAUDE_CONFIG_DIR, turn a non-zero exit into `{ok:false}`, and queue every // mutating call onto ONE chokepoint — the exact contract this module needs, // shared with cc-plugin-cli.js's own callers so a plugin install and an MCP // add can never spawn `claude` concurrently against the same config dir. const { runClaude, cliMessage, serialize } = require("./cc-plugin-cli"); /** The CLI's own name validator: "letters, numbers, hyphens, and underscores". */ const NAME_RE = /^[A-Za-z0-9_-]+$/; /** An environment variable NAME — the left-hand side of `-e KEY=VALUE`. */ const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; /** Scopes this form exposes — see MCP_SCOPES's own comment for why `project` * is excluded and `local` is included; the inverse of what "project" sounds * like it should be. */ const MCP_SCOPES = ["user", "local"]; function makeError(code, message) { const err = new Error(message); err.code = code; return err; } /** * Baseline check for a value that occupies its OWN argv position (a name, a * command, a url, an env-var key). Such a value could be parsed as a flag by * the CLI if it started with `-`, so that is rejected rather than escaped. */ function assertArg(value, label) { if (typeof value !== "string" || !value.trim()) { throw makeError("EBADARG", `${label} is required`); } if (value.length > 512) throw makeError("EBADARG", `${label} is too long`); // eslint-disable-next-line no-control-regex if (/[\u0000-\u001f\u007f]/.test(value)) { throw makeError("EBADARG", `${label} contains control characters`); } if (value.startsWith("-")) throw makeError("EBADARG", `${label} may not start with "-"`); return value; } /** An MCP server name — the routes' shared 400 guard. */ function assertName(value) { assertArg(value, "name"); if (!NAME_RE.test(value)) { throw makeError("EBADARG", "name is not a valid identifier"); } return value; } function assertEnvKey(value, label) { assertArg(value, label); if (!ENV_KEY_RE.test(value)) { throw makeError("EBADARG", `${label} is not a valid identifier`); } return value; } /** * A value that is embedded as the right-hand side of a single argv element * (`KEY=VALUE` for `-e`, `Key: value` for `--header`) rather than being its * own argv position. It may legitimately start with `-` — the surrounding * flag already fixes its argv position, so it can never be parsed as one. * Empty is allowed (a legitimate empty stdio arg or env value); only the * type, length, and control-character bounds are enforced. */ function assertEmbeddedValue(value, label) { if (typeof value !== "string") { throw makeError("EBADARG", `${label} is required`); } if (value.length > 512) throw makeError("EBADARG", `${label} is too long`); // eslint-disable-next-line no-control-regex if (/[\u0000-\u001f\u007f]/.test(value)) { throw makeError("EBADARG", `${label} contains control characters`); } return value; } function assertUrl(value, label) { assertArg(value, label); let parsed; try { parsed = new URL(value); } catch { throw makeError("EBADARG", `${label} must be a valid http(s) URL`); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw makeError("EBADARG", `${label} must be a valid http(s) URL`); } return value; } /** `required:true` for add (the UI always sends one); optional for remove. */ function assertMcpScope(value, { required = false } = {}) { if (value === undefined || value === null || value === "") { if (required) throw makeError("EBADARG", `scope must be one of ${MCP_SCOPES.join(", ")}`); return undefined; } if (!MCP_SCOPES.includes(value)) { throw makeError("EBADARG", `scope must be one of ${MCP_SCOPES.join(", ")}`); } return value; } function envFlags(env) { if (env === undefined) return []; if (typeof env !== "object" || env === null || Array.isArray(env)) { throw makeError("EBADARG", "env must be an object"); } return Object.entries(env).flatMap(([k, v]) => { const key = assertEnvKey(k, "env key"); assertEmbeddedValue(v, `env.${key}`); return ["-e", `${key}=${v}`]; }); } function headerFlags(headers) { if (headers === undefined) return []; if (typeof headers !== "object" || headers === null || Array.isArray(headers)) { throw makeError("EBADARG", "headers must be an object"); } return Object.entries(headers).flatMap(([k, v]) => { assertEmbeddedValue(k, "header name"); assertEmbeddedValue(v, `header.${k}`); return ["--header", `${k}: ${v}`]; }); } /** * `claude mcp add` has two mutually exclusive forms, matching the real CLI * (not a `--url` flag — see the file header): an http/sse server (`name`, * positional `url`, optional `--header`) or a stdio server (`name`, optional * `-e`, `-- command args…`). */ async function mcpAdd({ name, scope, transport, command, args = [], url, headers, env }) { const n = assertName(name); const sc = assertMcpScope(scope, { required: true }); if (url && command) { throw makeError("EBADARG", "mcpAdd accepts either url or command, not both"); } if (!url && !command) { throw makeError("EBADARG", "mcpAdd requires either url or command"); } if (!Array.isArray(args)) throw makeError("EBADARG", "args must be an array"); if (url) { if (env !== undefined) { throw makeError("EBADARG", "env is not valid together with url (stdio-only)"); } const u = assertUrl(url, "url"); if (transport !== undefined && transport !== "http" && transport !== "sse") { throw makeError("EBADARG", "transport must be http or sse when url is set"); } const t = transport === "sse" ? "sse" : "http"; const hFlags = headerFlags(headers); return serialize(() => runClaude(["mcp", "add", "--scope", sc, "--transport", t, n, u, ...hFlags]) ); } if (headers !== undefined) { throw makeError("EBADARG", "headers is not valid together with command (http/sse-only)"); } const cmd = assertArg(command, "command"); // Args land AFTER the `--` separator, so the CLI never parses them as its // own flags — a leading "-" here is a flag for the spawned MCP server // (e.g. `--port`), not for `claude`. Same embedded-value reasoning as // env/header values, applied because of position rather than KEY=VALUE. const cmdArgs = args.map((a, i) => assertEmbeddedValue(a, `args[${i}]`)); const eFlags = envFlags(env); return serialize(() => runClaude(["mcp", "add", "--scope", sc, n, ...eFlags, "--", cmd, ...cmdArgs]) ); } async function mcpRemove({ name, scope }) { const n = assertName(name); const sc = assertMcpScope(scope); const scopeFlags = sc ? ["--scope", sc] : []; return serialize(() => runClaude(["mcp", "remove", n, ...scopeFlags])); } module.exports = { mcpAdd, mcpRemove, cliMessage, // exported for tests assertName, assertMcpScope, NAME_RE, MCP_SCOPES, };