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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user