fd4edaac98
Mutation goes through `claude mcp add|remove`, never a direct edit of ~/.claude.json — the running CLI rewrites that file concurrently, so a dashboard write would race it. Reads stay on readMcpServers. The plan specified a --url flag; the real CLI (v2.1.266) has none — commandOrUrl is positional, and --header must trail it or the variadic flag swallows the url. Built against the verified shape.
247 lines
9.4 KiB
JavaScript
247 lines
9.4 KiB
JavaScript
/**
|
||
* @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 <name> <url>`.
|
||
* - `--header "Key: value"` must come AFTER the positional url — placed
|
||
* before it, the variadic `--header <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`/`project` scope is exposed — not the CLI's third scope
|
||
* (`local`), which is a different axis from the dashboard's settings.json
|
||
* `project-local` layer and was not asked for on this form.
|
||
*
|
||
* 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ĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
// Reused, not reimplemented: runClaude/cliMessage already pin
|
||
// CLAUDE_CONFIG_DIR and turn a non-zero exit into `{ok:false}` — the exact
|
||
// contract this module needs, and the chokepoint a reviewer will diff this
|
||
// file against.
|
||
const { runClaude, cliMessage } = 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. The CLI also has `local`, deliberately not offered here. */
|
||
const MCP_SCOPES = ["user", "project"];
|
||
|
||
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 (/[ |