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:
2026-09-09 09:36:52 +07:00
parent fd4edaac98
commit 6d27c115d8
11 changed files with 159 additions and 59 deletions
+34 -26
View File
@@ -1903,8 +1903,8 @@ function McpPanel({
aria-label={t("mcp.scope")}
className="h-8 bg-surface-1 border border-border rounded px-2 text-[12px] text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
>
<option value="user">{t("mcp.userScope")}</option>
<option value="project">{t("mcp.projectScope")}</option>
<option value="user">{t("mcp.scopeUser")}</option>
<option value="local">{t("mcp.scopeLocal")}</option>
</select>
<div className="flex rounded-md border border-border overflow-hidden">
{(["stdio", "http"] as const).map((k) => (
@@ -1994,7 +1994,7 @@ function McpPanel({
<McpCard
key={`p:${s.name}:${s.source}`}
server={s}
scope="project"
scope="local"
run={run}
busy={busy}
t={t}
@@ -2022,6 +2022,10 @@ function McpCard({
}) {
const [confirmRemove, setConfirmRemove] = useState(false);
const busyId = `${scope}:${server.name}`;
// Entries from ~/.claude/settings.json have no CLI scope that addresses
// them (`claude mcp remove` only ever touches ~/.claude.json / .mcp.json)
// — a Remove button here would look live and never work.
const removable = server.source !== "~/.claude/settings.json";
return (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3">
<div className="flex items-center gap-2 flex-wrap">
@@ -2063,29 +2067,33 @@ function McpCard({
)}
</div>
<div className="mt-3">
<ActionButton
icon={Trash2}
danger
disabled={busy === busyId}
onClick={() => {
if (!confirmRemove) {
setConfirmRemove(true);
return;
}
setConfirmRemove(false);
void run(
busyId,
() => api.ccConfig.mcpRemove(server.name, scope),
t("manage.mcpRemoved", { name: server.name })
);
}}
>
{busy === busyId
? t("manage.working")
: confirmRemove
? t("manage.confirmRemove")
: t("manage.remove")}
</ActionButton>
{removable ? (
<ActionButton
icon={Trash2}
danger
disabled={busy === busyId}
onClick={() => {
if (!confirmRemove) {
setConfirmRemove(true);
return;
}
setConfirmRemove(false);
void run(
busyId,
() => api.ccConfig.mcpRemove(server.name, scope),
t("manage.mcpRemoved", { name: server.name })
);
}}
>
{busy === busyId
? t("manage.working")
: confirmRemove
? t("manage.confirmRemove")
: t("manage.remove")}
</ActionButton>
) : (
<p className="text-[10px] text-fg-muted">{t("mcp.notRemovable")}</p>
)}
</div>
</div>
);