Files
AgentHub/server/lib/cc-mcp-cli.js
T
nntrivi2001 fd4edaac98 feat(cc-config): add and remove MCP servers through the claude CLI
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.
2026-09-09 09:15:53 +07:00

247 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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 (/[-]/.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.
*/
function assertEmbeddedValue(value, label) {
if (typeof value !== "string" || !value) {
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 (/[-]/.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(String(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(String(v), `header.${k}`);
return ["--header", `${k}: ${v}`];
});
}
// One in-process queue for every mutating call here — same reasoning as
// cc-plugin-cli.js's `mutationQueue`: this dashboard is the only Node writer,
// so a promise chain is the whole locking story.
let mutationQueue = Promise.resolve();
function serialize(fn) {
const run = mutationQueue.then(fn, fn);
mutationQueue = run.then(
() => undefined,
() => undefined
);
return run;
}
/**
* `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,
};