6d27c115d8
readMcpServers only ever parses ~/.claude.json (top-level = user scope, projects[<root>] = the panel's "projectScoped" bucket, which the CLI itself calls `local`). The previous user/project scope choice was the exact inverse: `--scope project` writes .mcp.json, which readMcpServers never reads (a successful add would vanish), and every "projectScoped" row was actually `local`, so Remove sent a scope the CLI could never match. - cc-mcp-cli.js: MCP_SCOPES -> ["user", "local"]; drop the local mutationQueue/serialize in favor of cc-plugin-cli.js's exported one, so a plugin install and an MCP add can't spawn `claude` concurrently; drop the String(v) coercion in env/header flags (a wrong-typed value is now a 400, not a written "[object Object]"); assertEmbeddedValue allows empty strings (a legitimate empty stdio arg was previously a 400). - cc-plugin-cli.js: export `serialize` for reuse. - CcConfig.tsx: CcMcpScope narrowed to user/local; the projectScoped bucket's McpCard now sends scope="local"; rows sourced from ~/.claude/settings.json (no CLI scope addresses them) no longer render a Remove button. Cleaned up i18n keys my own earlier change made dead (explain.mcp.addCmd/remove/removeButton) and gave the scope <select> its own short option labels instead of reusing full-sentence section headings.
344 lines
11 KiB
JavaScript
344 lines
11 KiB
JavaScript
/**
|
|
* @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 CLI's own `project` scope — readMcpServers never reads .mcp.json", () => {
|
|
// The Critical this pins: `--scope project` writes .mcp.json, which the
|
|
// dashboard's read path never parses, so a successful add would report
|
|
// an "added" toast for a server that never appears in the list.
|
|
for (const bad of ["project", "project-local", "bogus"]) {
|
|
assert.throws(() => cli.assertMcpScope(bad), /scope must be one of/, `accepted ${bad}`);
|
|
}
|
|
});
|
|
|
|
it("accepts local — the CLI scope that actually backs the panel's projectScoped bucket", () => {
|
|
assert.equal(cli.assertMcpScope("local", { required: true }), "local");
|
|
});
|
|
});
|
|
|
|
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: "local",
|
|
transport: "http",
|
|
url: "https://app.corridor.dev/api/mcp",
|
|
headers: { Authorization: "Bearer xyz" },
|
|
});
|
|
assert.deepEqual(argvOf(result), [
|
|
"mcp",
|
|
"add",
|
|
"--scope",
|
|
"local",
|
|
"--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: "user" })), [
|
|
"mcp",
|
|
"remove",
|
|
"docs",
|
|
"--scope",
|
|
"user",
|
|
]);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("builds a local-scoped remove — the argv the CLI actually accepts for the panel's projectScoped rows", async () => {
|
|
// The other Critical this pins: the panel used to send scope: "project"
|
|
// for these rows, which the real CLI never matches (it stores them under
|
|
// "local"), so Remove failed every time. `argvOf` is the exact argv
|
|
// `execFile` spawns — there is no shell in between to paper over a wrong
|
|
// scope string.
|
|
const restore = stubClaude();
|
|
try {
|
|
assert.deepEqual(argvOf(await cli.mcpRemove({ name: "docs", scope: "local" })), [
|
|
"mcp",
|
|
"remove",
|
|
"docs",
|
|
"--scope",
|
|
"local",
|
|
]);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("rejects a remove scoped to the CLI's project (.mcp.json is not addressable here)", async () => {
|
|
await assert.rejects(
|
|
() => cli.mcpRemove({ name: "docs", scope: "project" }),
|
|
/scope must be one of/
|
|
);
|
|
});
|
|
|
|
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("allows an empty stdio arg — only type/length/control-char bounds are enforced", async () => {
|
|
// A trailing empty argv element is invisible to `.trim().split("\n")`
|
|
// (it collapses with the final newline), so assert with a marker arg
|
|
// after it rather than checking the last line directly.
|
|
const restore = stubClaude();
|
|
try {
|
|
const result = await cli.mcpAdd({
|
|
name: "x",
|
|
scope: "user",
|
|
command: "node",
|
|
args: ["", "marker"],
|
|
});
|
|
assert.deepEqual(argvOf(result).slice(-3), ["node", "", "marker"]);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
it("rejects an object env/header value as a 400 instead of writing [object Object]", async () => {
|
|
await assert.rejects(
|
|
() =>
|
|
cli.mcpAdd({
|
|
name: "x",
|
|
scope: "user",
|
|
command: "node",
|
|
env: { KEY: { nope: true } },
|
|
}),
|
|
/KEY is required/
|
|
);
|
|
await assert.rejects(
|
|
() =>
|
|
cli.mcpAdd({
|
|
name: "x",
|
|
scope: "user",
|
|
transport: "http",
|
|
url: "https://a",
|
|
headers: { Authorization: { nope: true } },
|
|
}),
|
|
/Authorization is required/
|
|
);
|
|
});
|
|
|
|
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();
|
|
}
|
|
});
|
|
});
|