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:
2026-09-09 09:15:53 +07:00
parent 22fd4797aa
commit fd4edaac98
11 changed files with 878 additions and 18 deletions
+94
View File
@@ -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();
}
});
});