diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts
index a646eccc..ad5456b2 100644
--- a/client/src/lib/api.ts
+++ b/client/src/lib/api.ts
@@ -2651,6 +2651,11 @@ export interface CcMcpServer {
name: string;
/** Which config file this entry came from (e.g. a `.mcp.json` path). */
source: string;
+ /**
+ * False when no `claude mcp` scope can address this entry, so the dashboard
+ * cannot remove it. Absent means removable — only the reader sets it.
+ */
+ removable?: boolean;
/** Transport: local subprocess ("stdio"), remote HTTP, or undetermined. */
kind: "stdio" | "http" | "unknown";
/** Launch command, for `kind === "stdio"`. */
diff --git a/client/src/pages/CcConfig.tsx b/client/src/pages/CcConfig.tsx
index f81f3067..35c53411 100644
--- a/client/src/pages/CcConfig.tsx
+++ b/client/src/pages/CcConfig.tsx
@@ -2022,10 +2022,11 @@ 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";
+ // The reader decides this: entries from ~/.claude/settings.json have no CLI
+ // scope that addresses them, so a Remove button would look live and never
+ // work. Reading it off a flag rather than matching `source` keeps a reworded
+ // display string from silently bringing that button back.
+ const removable = server.removable !== false;
return (
diff --git a/server/__tests__/cc-plugin-cli.test.js b/server/__tests__/cc-plugin-cli.test.js
index d72d83a1..dd6946d8 100644
--- a/server/__tests__/cc-plugin-cli.test.js
+++ b/server/__tests__/cc-plugin-cli.test.js
@@ -224,6 +224,29 @@ describe("cc-plugin-cli CLAUDE_CONFIG_DIR pinning", () => {
else process.env.CLAUDE_HOME = originalHome;
}
});
+
+ // The two env tests above pin one half each. This pins the pair: the file
+ // the dashboard READS must be the file the CLI WRITES, under both settings.
+ // `.claude.json` was the only read path in cc-discovery not derived from
+ // getClaudeHome(), which is exactly how the two halves drifted apart.
+ it("reads the same .claude.json the CLI writes, with CLAUDE_HOME set or unset", () => {
+ const cc = require("../lib/cc-discovery");
+ const originalHome = process.env.CLAUDE_HOME;
+
+ // CLI's write target = /.claude.json, and
+ // CLAUDE_CONFIG_DIR is either CLAUDE_HOME or the CLI's own default,
+ // which is the same directory getClaudeHome() returns.
+ try {
+ delete process.env.CLAUDE_HOME;
+ assert.equal(cc.getClaudeJsonPath(), path.join(os.homedir(), ".claude", ".claude.json"));
+
+ process.env.CLAUDE_HOME = "/tmp/some-other-claude-home";
+ assert.equal(cc.getClaudeJsonPath(), "/tmp/some-other-claude-home/.claude.json");
+ } finally {
+ if (originalHome === undefined) delete process.env.CLAUDE_HOME;
+ else process.env.CLAUDE_HOME = originalHome;
+ }
+ });
});
describe("cc-plugin-cli run outcomes", () => {
diff --git a/server/lib/cc-discovery.js b/server/lib/cc-discovery.js
index 053f489c..02b1cd41 100644
--- a/server/lib/cc-discovery.js
+++ b/server/lib/cc-discovery.js
@@ -29,16 +29,13 @@ function getProjectClaudeDir(cwd) {
}
function getClaudeJsonPath() {
- // ~/.claude.json sits beside ~/.claude/, NOT inside it. Resolve from $HOME
- // so a CLAUDE_HOME override doesn't accidentally relocate it.
- //
- // Paired with cc-plugin-cli.js's runClaude(): with CLAUDE_HOME unset (the
- // default), it deliberately leaves CLAUDE_CONFIG_DIR unset too, so the CLI
- // writes here (its own default). With CLAUDE_HOME set, this read stays
- // pinned to $HOME/.claude.json while the CLI writes
- // /.claude.json — a known, unfixed split for that case; see
- // runClaude's comment.
- return path.join(os.homedir(), ".claude.json");
+ // One root for everything: this file used to resolve from $HOME while the
+ // other 19 read paths derived from getClaudeHome(), and cc-plugin-cli.js's
+ // runClaude() pinned CLAUDE_CONFIG_DIR to getClaudeHome() — which is where
+ // the CLI puts .claude.json. The dashboard read one file and made the CLI
+ // write another, so MCP add/remove was inert. Keep this and runClaude's
+ // env in step; they are the same decision seen from two sides.
+ return path.join(getClaudeHome(), ".claude.json");
}
/**
@@ -516,6 +513,11 @@ function readMcpServers(opts = {}) {
out.user.push({
name,
source: "~/.claude/settings.json",
+ // No `claude mcp` scope addresses settings.json, so the dashboard
+ // cannot remove these. A flag, not a string comparison on `source`:
+ // the UI used to match that display text, so rewording it silently
+ // brought back a Remove button that always fails.
+ removable: false,
...summarizeMcpDef(def),
});
}
@@ -911,6 +913,9 @@ function readOverview(opts = {}) {
}
module.exports = {
+ // Exported so a test can pin it against the file the CLI writes — the two
+ // drifted apart once and the mismatch was invisible from either side alone.
+ getClaudeJsonPath,
// surface readers
readSkills,
readAgents,