fix(cc-config): MCP scope was inverted — expose user/local, not user/project
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.
This commit is contained in:
@@ -1192,7 +1192,7 @@ describe("/api/cc-config", () => {
|
||||
try {
|
||||
const { status, body } = await fetchJson("/api/cc-config/mcp/remove", {
|
||||
method: "POST",
|
||||
body: { name: "docs", scope: "project" },
|
||||
body: { name: "docs", scope: "local" },
|
||||
});
|
||||
assert.equal(status, 200);
|
||||
assert.deepEqual(body.stdout.trim().split("\n"), [
|
||||
@@ -1200,10 +1200,19 @@ describe("/api/cc-config", () => {
|
||||
"remove",
|
||||
"docs",
|
||||
"--scope",
|
||||
"project",
|
||||
"local",
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /mcp/remove rejects the CLI's project scope with 400 — .mcp.json isn't addressable here", async () => {
|
||||
const { status, body } = await fetchJson("/api/cc-config/mcp/remove", {
|
||||
method: "POST",
|
||||
body: { name: "docs", scope: "project" },
|
||||
});
|
||||
assert.equal(status, 400);
|
||||
assert.equal(body.error.code, "EBADARG");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,11 +65,18 @@ describe("cc-mcp-cli argument validation", () => {
|
||||
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"]) {
|
||||
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", () => {
|
||||
@@ -119,7 +126,7 @@ describe("cc-mcp-cli argv construction", () => {
|
||||
try {
|
||||
const result = await cli.mcpAdd({
|
||||
name: "corridor",
|
||||
scope: "project",
|
||||
scope: "local",
|
||||
transport: "http",
|
||||
url: "https://app.corridor.dev/api/mcp",
|
||||
headers: { Authorization: "Bearer xyz" },
|
||||
@@ -128,7 +135,7 @@ describe("cc-mcp-cli argv construction", () => {
|
||||
"mcp",
|
||||
"add",
|
||||
"--scope",
|
||||
"project",
|
||||
"local",
|
||||
"--transport",
|
||||
"http",
|
||||
"corridor",
|
||||
@@ -172,18 +179,45 @@ describe("cc-mcp-cli argv construction", () => {
|
||||
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" })), [
|
||||
assert.deepEqual(argvOf(await cli.mcpRemove({ name: "docs", scope: "user" })), [
|
||||
"mcp",
|
||||
"remove",
|
||||
"docs",
|
||||
"--scope",
|
||||
"project",
|
||||
"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" }),
|
||||
@@ -212,6 +246,48 @@ describe("cc-mcp-cli argv construction", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
Binary file not shown.
@@ -259,6 +259,10 @@ function cliMessage(res) {
|
||||
module.exports = {
|
||||
runClaude,
|
||||
cliMessage,
|
||||
// Exported so cc-mcp-cli.js queues onto this SAME chokepoint — one CLI
|
||||
// process against CLAUDE_CONFIG_DIR at a time, whether the click was a
|
||||
// plugin install or an MCP add.
|
||||
serialize,
|
||||
listAvailable,
|
||||
marketplaceAdd,
|
||||
marketplaceRemove,
|
||||
|
||||
@@ -1597,7 +1597,7 @@ const paths = {
|
||||
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.",
|
||||
"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`|`local` only: `user` writes ~/.claude.json's top level, `local` writes its `projects[<root>]` block (GET /mcp's `projectScoped` bucket). The CLI's own `project` scope (a shared `.mcp.json`) is deliberately not offered — `readMcpServers` never reads that file, so a server added there would report success and never appear in the list. 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,
|
||||
@@ -1608,7 +1608,7 @@ const paths = {
|
||||
required: ["name", "scope"],
|
||||
properties: {
|
||||
name: { type: "string", example: "vercel" },
|
||||
scope: { type: "string", enum: ["user", "project"], example: "user" },
|
||||
scope: { type: "string", enum: ["user", "local"], example: "user" },
|
||||
transport: { type: "string", enum: ["http", "sse"] },
|
||||
url: { type: "string", example: "https://mcp.vercel.com" },
|
||||
headers: {
|
||||
@@ -1652,7 +1652,7 @@ const paths = {
|
||||
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.",
|
||||
"Mutating. Runs `claude mcp remove`. `scope` (`user`|`local`) narrows which config layer to remove from; omit it to let the CLI find the server wherever it exists. The CLI's own `project` scope is refused with 400 — the panel has no row sourced from `.mcp.json` to remove. 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,
|
||||
@@ -1663,7 +1663,7 @@ const paths = {
|
||||
required: ["name"],
|
||||
properties: {
|
||||
name: { type: "string", example: "vercel" },
|
||||
scope: { type: "string", enum: ["user", "project"] },
|
||||
scope: { type: "string", enum: ["user", "local"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user