8546748759
Dependencies
- Security: body-parser and multer DoS advisories fixed (npm audit: 0).
- Server: Express 5 (query parser kept "extended", req.body defaults to {},
SPA catch-all /{*splat}, send-file helper restores Express 4's dot-folder
rule for sendFile/download, alerts ack route without inline regex),
better-sqlite3 13, js-yaml 5 (server/lib/yaml.js keeps js-yaml 4's default
schema), uuid dropped for crypto.randomUUID, node-pty beta.18, multer 2.5.
- Client: React 19, xterm 6 (WebGL renderer replaces the removed canvas one,
DOM fallback on context loss; Alt+arrow word moves re-mapped), lucide-react 1,
TypeScript 7, i18next/react-i18next/codemirror minors.
Workspace rename step 4
- `agenthub lanes` and `--lane` exit 1 with "renamed: use …"; client api.ts,
the CLI and the MCP server call /api/workspaces; /api/lanes stays mounted.
Server mode (S3): members manage their own Claude Code config
- server/lib/cc-target.js resolves whose config a /cc-config request touches
and gates the router by allowlist; /profiles* and /plugins/update-all stay
admin-only. Member writes go through write-as, CLI calls through sudo as the
member (never -y), reads through openSafe; cc_config_changed reaches only
its owner. Not verified live on an S3 install yet.
Other
- Chat: an HTML file is an artifact only when written whole and not a project
page (index.html, or under src/, public/, templates/, …).
- OpenAPI covers every /api route; openapi-coverage.test.js guards it.
- read-pool test keeps its event loop alive (its worker is unref'd).
- Docs: plan statuses, checklists, CLI.md, LANES.md, SERVER-MODE.md, API.md.
269 lines
11 KiB
JavaScript
269 lines
11 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`/`local` scope is exposed — matching what `readMcpServers`
|
|
* can actually see: `user` is `~/.claude.json` top-level, `local` is
|
|
* `~/.claude.json`'s `projects[<root>]` 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.
|
|
*
|
|
* S3, a member's own config (`acct`): the CLI runs AS the member through
|
|
* sudo (cc-plugin-cli's `runClaude`), `user` scope only (their CLI runs in
|
|
* their home, no project), and with no `-e`/`--header` at all — those carry
|
|
* keys and tokens, and sudo logs its whole argv. Such a server is added from
|
|
* the member's own terminal instead.
|
|
*
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
// 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}`];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* S3, a member's own config: `user` scope only, and no env values or headers
|
|
* — they would cross sudo's argv, which sudo logs.
|
|
*/
|
|
function assertMemberMcp({ scope, headers, env, acct }) {
|
|
if (!acct) return;
|
|
if (scope && scope !== "user") {
|
|
throw makeError("EBADARG", "only scope user can be changed for your own config");
|
|
}
|
|
const has = (o) => o && typeof o === "object" && Object.keys(o).length > 0;
|
|
if (has(headers) || has(env)) {
|
|
throw makeError(
|
|
"EBADARG",
|
|
"env values and headers would be logged by sudo: add this server with `claude mcp add` in your own terminal"
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `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, acct }) {
|
|
const n = assertName(name);
|
|
const sc = assertMcpScope(scope, { required: true });
|
|
assertMemberMcp({ scope: sc, headers, env, acct });
|
|
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], { acct }),
|
|
acct
|
|
);
|
|
}
|
|
|
|
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], { acct }),
|
|
acct
|
|
);
|
|
}
|
|
|
|
async function mcpRemove({ name, scope, acct }) {
|
|
const n = assertName(name);
|
|
const sc = assertMcpScope(scope);
|
|
assertMemberMcp({ scope: sc, acct });
|
|
const scopeFlags = sc ? ["--scope", sc] : [];
|
|
return serialize(() => runClaude(["mcp", "remove", n, ...scopeFlags], { acct }), acct);
|
|
}
|
|
|
|
module.exports = {
|
|
mcpAdd,
|
|
mcpRemove,
|
|
cliMessage,
|
|
// exported for tests
|
|
assertName,
|
|
assertMcpScope,
|
|
NAME_RE,
|
|
MCP_SCOPES,
|
|
};
|