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.
This commit is contained in:
@@ -1112,4 +1112,98 @@ describe("/api/cc-config", () => {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
// ── MCP add/remove routes (fake `claude` on PATH) ───────────────────────
|
||||
//
|
||||
// `printf`, not `echo` — bash's builtin `echo "-e"` swallows a lone `-e`
|
||||
// argument as its own flag, and `-e KEY=VALUE` is exactly what mcpAdd
|
||||
// emits for a stdio server's env vars.
|
||||
const PRINTF_ARGV = 'for a in "$@"; do printf \'%s\\n\' "$a"; done';
|
||||
|
||||
it("POST /mcp/add builds an http server with a positional url", async () => {
|
||||
const restore = stubClaude(PRINTF_ARGV);
|
||||
try {
|
||||
const { status, body } = await fetchJson("/api/cc-config/mcp/add", {
|
||||
method: "POST",
|
||||
body: { name: "vercel", scope: "user", transport: "http", url: "https://mcp.vercel.com" },
|
||||
});
|
||||
assert.equal(status, 200);
|
||||
assert.deepEqual(body.stdout.trim().split("\n"), [
|
||||
"mcp",
|
||||
"add",
|
||||
"--scope",
|
||||
"user",
|
||||
"--transport",
|
||||
"http",
|
||||
"vercel",
|
||||
"https://mcp.vercel.com",
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /mcp/add builds a stdio server with args after --", async () => {
|
||||
const restore = stubClaude(PRINTF_ARGV);
|
||||
try {
|
||||
const { status, body } = await fetchJson("/api/cc-config/mcp/add", {
|
||||
method: "POST",
|
||||
body: { name: "docs", scope: "user", command: "node", args: ["s.js", "--port", "3000"] },
|
||||
});
|
||||
assert.equal(status, 200);
|
||||
assert.deepEqual(body.stdout.trim().split("\n").slice(-5), [
|
||||
"--",
|
||||
"node",
|
||||
"s.js",
|
||||
"--port",
|
||||
"3000",
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /mcp/add rejects a name that reads as a flag with 400", async () => {
|
||||
const { status, body } = await fetchJson("/api/cc-config/mcp/add", {
|
||||
method: "POST",
|
||||
body: { name: "--all", scope: "user", command: "node", args: [] },
|
||||
});
|
||||
assert.equal(status, 400);
|
||||
assert.equal(body.error.code, "EBADARG");
|
||||
});
|
||||
|
||||
it("POST /mcp/remove surfaces a failed CLI run as 422", async () => {
|
||||
const restore = stubClaude('echo "No MCP server named \\"nope\\"" >&2\nexit 1');
|
||||
try {
|
||||
const { status, body } = await fetchJson("/api/cc-config/mcp/remove", {
|
||||
method: "POST",
|
||||
body: { name: "nope", scope: "user" },
|
||||
});
|
||||
assert.equal(status, 422);
|
||||
assert.equal(body.error.code, "ECLIEXIT");
|
||||
assert.match(body.error.message, /No MCP server named/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /mcp/remove runs the CLI with the given scope", async () => {
|
||||
const restore = stubClaude(PRINTF_ARGV);
|
||||
try {
|
||||
const { status, body } = await fetchJson("/api/cc-config/mcp/remove", {
|
||||
method: "POST",
|
||||
body: { name: "docs", scope: "project" },
|
||||
});
|
||||
assert.equal(status, 200);
|
||||
assert.deepEqual(body.stdout.trim().split("\n"), [
|
||||
"mcp",
|
||||
"remove",
|
||||
"docs",
|
||||
"--scope",
|
||||
"project",
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* @file Tests for server/lib/cc-mcp-cli.js — the `claude mcp add`/`claude mcp
|
||||
* remove` wrapper. Uses a real fake `claude` script on PATH (the fixture
|
||||
* style server/__tests__/cc-plugin-cli.test.js already uses) rather than
|
||||
* mocking child_process, and pins the exact argv each operation builds —
|
||||
* that argv IS the security boundary.
|
||||
*
|
||||
* The argv shapes asserted here were verified against the real
|
||||
* `claude mcp add/remove --help` (v2.1.266), not assumed: there is no
|
||||
* `--url` flag (the url is positional), and `--header` must trail the url.
|
||||
* See cc-mcp-cli.js's file header for the full list of surprises.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const { describe, it, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ccmcp-"));
|
||||
const cli = require("../lib/cc-mcp-cli");
|
||||
|
||||
/**
|
||||
* Put a fake `claude` on PATH; it prints argv, one element per line.
|
||||
* `printf`, not `echo` — bash's builtin `echo "-e"` treats a lone `-e`
|
||||
* argument as its own flag and swallows it, which is exactly one of the
|
||||
* argv elements this wrapper emits (`-e KEY=VALUE`).
|
||||
*/
|
||||
function stubClaude(scriptBody = 'for a in "$@"; do printf \'%s\\n\' "$a"; done') {
|
||||
const bin = path.join(ROOT, `bin-${process.hrtime.bigint()}`);
|
||||
fs.mkdirSync(bin, { recursive: true });
|
||||
const file = path.join(bin, "claude");
|
||||
fs.writeFileSync(file, `#!/usr/bin/env bash\n${scriptBody}\n`);
|
||||
fs.chmodSync(file, 0o755);
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
|
||||
return () => {
|
||||
process.env.PATH = originalPath;
|
||||
};
|
||||
}
|
||||
|
||||
const argvOf = (result) => result.stdout.trim().split("\n");
|
||||
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true, maxRetries: 5 }));
|
||||
|
||||
describe("cc-mcp-cli argument validation", () => {
|
||||
it("accepts letters, numbers, hyphens and underscores as a name", () => {
|
||||
assert.equal(cli.assertName("my-server_1"), "my-server_1");
|
||||
});
|
||||
|
||||
it("rejects a name with characters the CLI itself rejects", () => {
|
||||
for (const bad of ["a/b", "a b", "a.b", ""]) {
|
||||
assert.throws(() => cli.assertName(bad), undefined, `accepted ${bad}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a server name that reads as a flag", async () => {
|
||||
await assert.rejects(() => cli.mcpRemove({ name: "--all" }), /name/i);
|
||||
});
|
||||
|
||||
it("requires an explicit scope on add but not on remove", () => {
|
||||
assert.throws(() => cli.assertMcpScope(undefined, { required: true }), /scope must be one of/);
|
||||
assert.equal(cli.assertMcpScope(undefined), undefined);
|
||||
assert.equal(cli.assertMcpScope("user", { required: true }), "user");
|
||||
});
|
||||
|
||||
it("rejects the settings-only project-local scope and any other unknown scope", () => {
|
||||
for (const bad of ["project-local", "local", "bogus"]) {
|
||||
assert.throws(() => cli.assertMcpScope(bad), /scope must be one of/, `accepted ${bad}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("cc-mcp-cli argv construction", () => {
|
||||
it("builds an http server with a positional url, not a --url flag", async () => {
|
||||
const restore = stubClaude();
|
||||
try {
|
||||
const result = await cli.mcpAdd({
|
||||
name: "vercel",
|
||||
scope: "user",
|
||||
transport: "http",
|
||||
url: "https://mcp.vercel.com",
|
||||
});
|
||||
assert.deepEqual(argvOf(result), [
|
||||
"mcp",
|
||||
"add",
|
||||
"--scope",
|
||||
"user",
|
||||
"--transport",
|
||||
"http",
|
||||
"vercel",
|
||||
"https://mcp.vercel.com",
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("puts stdio command args after the -- separator", async () => {
|
||||
const restore = stubClaude();
|
||||
try {
|
||||
const result = await cli.mcpAdd({
|
||||
name: "docs",
|
||||
scope: "user",
|
||||
command: "node",
|
||||
args: ["s.js", "--port", "3000"],
|
||||
});
|
||||
assert.deepEqual(argvOf(result).slice(-5), ["--", "node", "s.js", "--port", "3000"]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("puts --header flags after the positional url, not before", async () => {
|
||||
// Verified against the real CLI: --header is variadic, so placed before
|
||||
// the url it would swallow the url as one of its own header values.
|
||||
const restore = stubClaude();
|
||||
try {
|
||||
const result = await cli.mcpAdd({
|
||||
name: "corridor",
|
||||
scope: "project",
|
||||
transport: "http",
|
||||
url: "https://app.corridor.dev/api/mcp",
|
||||
headers: { Authorization: "Bearer xyz" },
|
||||
});
|
||||
assert.deepEqual(argvOf(result), [
|
||||
"mcp",
|
||||
"add",
|
||||
"--scope",
|
||||
"project",
|
||||
"--transport",
|
||||
"http",
|
||||
"corridor",
|
||||
"https://app.corridor.dev/api/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer xyz",
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("passes env vars as -e KEY=VALUE for stdio servers, value may start with -", async () => {
|
||||
const restore = stubClaude();
|
||||
try {
|
||||
const result = await cli.mcpAdd({
|
||||
name: "envy",
|
||||
scope: "user",
|
||||
command: "node",
|
||||
args: ["s.js"],
|
||||
env: { API_KEY: "-not-a-flag" },
|
||||
});
|
||||
assert.deepEqual(argvOf(result), [
|
||||
"mcp",
|
||||
"add",
|
||||
"--scope",
|
||||
"user",
|
||||
"envy",
|
||||
"-e",
|
||||
"API_KEY=-not-a-flag",
|
||||
"--",
|
||||
"node",
|
||||
"s.js",
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("builds the remove argv, scope flag omitted when not given", async () => {
|
||||
const restore = stubClaude();
|
||||
try {
|
||||
assert.deepEqual(argvOf(await cli.mcpRemove({ name: "docs" })), ["mcp", "remove", "docs"]);
|
||||
assert.deepEqual(argvOf(await cli.mcpRemove({ name: "docs", scope: "project" })), [
|
||||
"mcp",
|
||||
"remove",
|
||||
"docs",
|
||||
"--scope",
|
||||
"project",
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects url and command together, and neither", async () => {
|
||||
await assert.rejects(
|
||||
() => cli.mcpAdd({ name: "x", scope: "user", url: "https://a", command: "b" }),
|
||||
/either url or command, not both/
|
||||
);
|
||||
await assert.rejects(
|
||||
() => cli.mcpAdd({ name: "x", scope: "user" }),
|
||||
/requires either url or command/
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects env together with url, and headers together with command", async () => {
|
||||
await assert.rejects(
|
||||
() => cli.mcpAdd({ name: "x", scope: "user", url: "https://a", env: { K: "v" } }),
|
||||
/env is not valid together with url/
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
cli.mcpAdd({
|
||||
name: "x",
|
||||
scope: "user",
|
||||
command: "node",
|
||||
headers: { K: "v" },
|
||||
}),
|
||||
/headers is not valid together with command/
|
||||
);
|
||||
});
|
||||
|
||||
it("never lets an args element reach a shell", async () => {
|
||||
// A shell-metacharacter payload is a legal argv element (many real flags
|
||||
// contain punctuation) — the guarantee is execFile's argv array, not a
|
||||
// character blocklist. If this were interpolated into a shell string the
|
||||
// `touch` would run; through execFile it stays one inert literal.
|
||||
const canary = path.join(ROOT, "pwned");
|
||||
const restore = stubClaude();
|
||||
try {
|
||||
const result = await cli.mcpAdd({
|
||||
name: "x",
|
||||
scope: "user",
|
||||
command: "node",
|
||||
args: [`s.js; touch ${canary}`],
|
||||
});
|
||||
assert.equal(argvOf(result).at(-1), `s.js; touch ${canary}`);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
assert.equal(fs.existsSync(canary), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cc-mcp-cli run outcomes", () => {
|
||||
it("reports a non-zero exit as ok:false instead of throwing", async () => {
|
||||
const restore = stubClaude('echo "✘ Server name already exists" >&2\nexit 1');
|
||||
try {
|
||||
const result = await cli.mcpAdd({
|
||||
name: "dup",
|
||||
scope: "user",
|
||||
command: "node",
|
||||
args: ["s.js"],
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.code, 1);
|
||||
assert.match(cli.cliMessage(result), /already exists/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("serializes mutating calls so two clicks cannot interleave", async () => {
|
||||
const log = path.join(ROOT, `order-${process.hrtime.bigint()}.txt`);
|
||||
const restore = stubClaude(`echo "start $3" >> ${log}\nsleep 0.2\necho "end $3" >> ${log}`);
|
||||
try {
|
||||
await Promise.all([cli.mcpRemove({ name: "first" }), cli.mcpRemove({ name: "second" })]);
|
||||
const lines = fs.readFileSync(log, "utf8").trim().split("\n");
|
||||
assert.equal(lines[0].replace("start ", ""), lines[1].replace("end ", ""));
|
||||
assert.equal(lines[2].replace("start ", ""), lines[3].replace("end ", ""));
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -387,6 +387,18 @@ const schemas = {
|
||||
},
|
||||
},
|
||||
|
||||
/** Response of a successful mutating `claude mcp …` run. A non-zero CLI
|
||||
* exit arrives as an HTTP 422 error instead of one of these. */
|
||||
CcConfigCliResult: {
|
||||
type: "object",
|
||||
required: ["ok", "stdout", "stderr"],
|
||||
properties: {
|
||||
ok: { type: "boolean", enum: [true] },
|
||||
stdout: { type: "string" },
|
||||
stderr: { type: "string" },
|
||||
},
|
||||
},
|
||||
|
||||
// ── Hooks ────────────────────────────────────────────────────────────
|
||||
CcConfigHookEntry: {
|
||||
type: "object",
|
||||
@@ -1580,6 +1592,98 @@ const paths = {
|
||||
},
|
||||
},
|
||||
|
||||
"/api/cc-config/mcp/add": {
|
||||
post: {
|
||||
tags: ["CcConfig"],
|
||||
summary: "Add an MCP server",
|
||||
description:
|
||||
"Mutating. Runs `claude mcp add` — never a direct edit of ~/.claude.json, which the running CLI rewrites concurrently. Body carries either `url` (+ optional `transport: http|sse` and `headers`) for a remote server, or `command` + `args` (+ optional `env`) for a stdio server; exactly one of the two forms is required. `scope` is required and is `user`|`project` only — not the settings union's third `project-local` value, which is a different axis. A non-zero CLI exit is returned as 422, not 500. Emits a `cc_config_changed` websocket event on success.",
|
||||
operationId: "ccConfigPostMcpAdd",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["name", "scope"],
|
||||
properties: {
|
||||
name: { type: "string", example: "vercel" },
|
||||
scope: { type: "string", enum: ["user", "project"], example: "user" },
|
||||
transport: { type: "string", enum: ["http", "sse"] },
|
||||
url: { type: "string", example: "https://mcp.vercel.com" },
|
||||
headers: {
|
||||
type: "object",
|
||||
additionalProperties: { type: "string" },
|
||||
example: { Authorization: "Bearer xyz" },
|
||||
},
|
||||
command: { type: "string", example: "npx" },
|
||||
args: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
example: ["-y", "server-github"],
|
||||
},
|
||||
env: {
|
||||
type: "object",
|
||||
additionalProperties: { type: "string" },
|
||||
example: { GITHUB_TOKEN: "xxx" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "The CLI's stdout/stderr on success.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CcConfigCliResult" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { description: "Bad name/scope/args, or an incompatible combination." },
|
||||
422: { description: "The CLI exited non-zero (e.g. the name is already taken)." },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/cc-config/mcp/remove": {
|
||||
post: {
|
||||
tags: ["CcConfig"],
|
||||
summary: "Remove an MCP server",
|
||||
description:
|
||||
"Mutating. Runs `claude mcp remove`. `scope` (`user`|`project`) narrows which config layer to remove from; omit it to let the CLI find the server wherever it exists. A non-zero CLI exit (e.g. the name does not exist) is returned as 422. Emits a `cc_config_changed` websocket event on success.",
|
||||
operationId: "ccConfigPostMcpRemove",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["name"],
|
||||
properties: {
|
||||
name: { type: "string", example: "vercel" },
|
||||
scope: { type: "string", enum: ["user", "project"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "The CLI's stdout/stderr on success.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/CcConfigCliResult" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { description: "Bad name/scope." },
|
||||
422: { description: "The CLI exited non-zero (e.g. no such server)." },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/api/cc-config/hooks": {
|
||||
get: {
|
||||
tags: ["CcConfig"],
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
* artifacts (skills, agents, commands, output styles, memory, per-project
|
||||
* auto-memory), the structured keybindings.json editor, settings.json layers
|
||||
* via a per-key merge (`PATCH /settings`, which is also how hooks are edited),
|
||||
* and plugin/marketplace management delegated to the `claude plugin …` CLI.
|
||||
* Every file write is preceded by a timestamped backup. MCP server config
|
||||
* stays read-only — it is written concurrently by the running CLI.
|
||||
* plugin/marketplace management delegated to the `claude plugin …` CLI, and
|
||||
* MCP server add/remove delegated to the `claude mcp …` CLI. Every file
|
||||
* write is preceded by a timestamped backup; the CLI-delegated writes are
|
||||
* not files this server touches directly, so there is nothing to back up —
|
||||
* the CLI owns `~/.claude.json`.
|
||||
*
|
||||
* Also hosts the auth-profile routes (`/profiles*`) that swap which Anthropic
|
||||
* account the local CLI runs as; those carry identity metadata only, never
|
||||
@@ -25,6 +27,7 @@ const { Router } = require("express");
|
||||
const cc = require("../lib/cc-discovery");
|
||||
const ccMutate = require("../lib/cc-mutate");
|
||||
const ccCli = require("../lib/cc-plugin-cli");
|
||||
const ccMcpCli = require("../lib/cc-mcp-cli");
|
||||
const ccProfiles = require("../lib/cc-profiles");
|
||||
const { broadcast } = require("../websocket");
|
||||
|
||||
@@ -329,6 +332,43 @@ router.post("/marketplaces/action", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── MCP servers (via the `claude` CLI) ─────────────────────────────────
|
||||
//
|
||||
// Same reasoning as the plugin/marketplace routes above: `~/.claude.json` is
|
||||
// rewritten concurrently by the running CLI, so add/remove shell out to
|
||||
// `claude mcp …` rather than editing the file. Reads are unaffected — GET
|
||||
// /mcp above stays on `readMcpServers`; the client refetches it after either
|
||||
// call below succeeds.
|
||||
|
||||
router.post("/mcp/add", async (req, res) => {
|
||||
const { name, scope, transport, command, args, url, headers, env } = req.body || {};
|
||||
try {
|
||||
const result = await ccMcpCli.mcpAdd({
|
||||
name,
|
||||
scope,
|
||||
transport,
|
||||
command,
|
||||
args,
|
||||
url,
|
||||
headers,
|
||||
env,
|
||||
});
|
||||
return cliResult(res, result, { action: "add", type: "mcp", name });
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/mcp/remove", async (req, res) => {
|
||||
const { name, scope } = req.body || {};
|
||||
try {
|
||||
const result = await ccMcpCli.mcpRemove({ name, scope });
|
||||
return cliResult(res, result, { action: "remove", type: "mcp", name });
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/cc-config/settings — merge individual keys into one settings
|
||||
// layer. Body: { scope: "user"|"project"|"project-local", set?: {dotted: value},
|
||||
// unset?: ["dotted"] }. Hooks are edited through this same route as
|
||||
|
||||
Reference in New Issue
Block a user