8.4 KiB
Editable settings.json + MCP for the Claude Code Panel — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Turn the Claude Code panel's two read-only surfaces — settings.json and MCP servers — into surfaces you can change from /settings?tool=claude-code.
Architecture: The backend already does the dangerous half: PATCH /api/cc-config/settings writes through patchSettings, which validates every dotted key path, refuses prototype-polluting segments, rejects a payload still carrying the read path's <redacted> marker, backs the file up and writes atomically. So settings editing is a UI task against an existing, tested API. MCP is the opposite: cc-mutate.js excludes it on purpose, because the live claude CLI writes ~/.claude.json concurrently and a dashboard write would race it. MCP therefore gets the same treatment plugins already get — mutation through the vendor CLI (claude mcp add|remove), never by editing the file.
Tech Stack: Node 22 + Express + node:test, React 18 + Vitest + react-i18next, claude CLI.
Spec: none written — scope fixed by the user in-session: "Mở thêm settings.json + MCP". This plan is the spec of record.
Global Constraints
- Every file created or modified gets the project header with the exact author line
@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>. - User-visible strings live in i18n JSON,
enandvitogether. - Never write
~/.claude.jsondirectly. MCP changes go through theclaudeCLI, argv array, no shell. - A value the read path redacted must never be written back.
patchSettings'assertNoRedactionMarkeralready enforces this; the UI must additionally render redacted fields as non-editable so the user is never invited to try. - Depends on:
2026-09-08-codex-config-panel.mdTask 8 (the extractedcomponents/config/modals). Run that task first, or inline the modal locally and lift it later. - Backend tests:
npm run test:server. Client tests:cd client && npm test.
Task 1: Editable settings.json
Files:
- Modify:
client/src/pages/CcConfig.tsx(theSettingsPanel/SettingsBlockcomponents),client/src/lib/api.ts(addapi.ccConfig.patchSettings),client/src/i18n/locales/{en,vi}/ccConfig.json - Test:
client/src/pages/__tests__/CcConfigSettings.test.tsx
Interfaces:
-
Consumes: existing
PATCH /api/cc-config/settings— body{scope: "user" | "project" | "project-local", set: Record<string, unknown>, unset?: string[]}, response{ok, file, backup} -
Produces:
api.ccConfig.patchSettings(args: {scope, set, unset?}): Promise<{ok: boolean, file: string, backup: string | null}> -
Step 1: Write the failing test
it("sends only the edited key, as a dotted path", async () => {
const patchSettings = vi.fn().mockResolvedValue({ ok: true, file: "/s.json", backup: "/s.bak" });
renderSettingsTab({ settings: [{ scope: "user", file: "/s.json", exists: true, data: { model: "opus" } }], patchSettings });
await userEvent.click(screen.getByRole("button", { name: /edit model/i }));
await userEvent.clear(screen.getByRole("textbox"));
await userEvent.type(screen.getByRole("textbox"), "sonnet");
await userEvent.click(screen.getByRole("button", { name: /save/i }));
expect(patchSettings).toHaveBeenCalledWith({ scope: "user", set: { model: "sonnet" } });
});
it("will not let a redacted value be edited", () => {
renderSettingsTab({ settings: [{ scope: "user", file: "/s.json", exists: true, data: { apiKey: "<redacted>" } }] });
expect(screen.queryByRole("button", { name: /edit apiKey/i })).not.toBeInTheDocument();
});
it("names the backup it wrote, so the change is reversible in one glance", async () => {
const patchSettings = vi.fn().mockResolvedValue({ ok: true, file: "/s.json", backup: "/s.json.2026.bak" });
renderSettingsTab({ settings: [{ scope: "user", file: "/s.json", exists: true, data: { model: "opus" } }], patchSettings });
await saveAnEdit();
expect(await screen.findByText(/s\.json\.2026\.bak/)).toBeInTheDocument();
});
- Step 2: Run test to verify it fails
Run: cd client && npx vitest run src/pages/__tests__/CcConfigSettings.test.tsx
Expected: FAIL — no edit affordance exists
- Step 3: Implement
SettingsBlock renders each leaf with an inline edit control; the dotted path is built from the node's ancestry as it recurses. A value equal to "<redacted>" renders as a static chip with a tooltip saying the dashboard never received it.
- Step 4: Run test + typecheck
Run: cd client && npx tsc -b && npx vitest run src/pages/__tests__/CcConfigSettings.test.tsx
Expected: PASS
- Step 5: Commit
git add client/src/pages/CcConfig.tsx client/src/lib/api.ts client/src/i18n client/src/pages/__tests__/CcConfigSettings.test.tsx
git commit -m "feat(cc-config): edit settings.json values from the dashboard"
Task 2: MCP servers through the claude CLI
Files:
- Create:
server/lib/cc-mcp-cli.js,server/__tests__/cc-mcp-cli.test.js - Modify:
server/routes/cc-config.js(addPOST /mcp/add,POST /mcp/remove),server/openapi-extra/cc-config.js,client/src/lib/api.ts,client/src/pages/CcConfig.tsx(McpPanel),client/src/i18n/locales/{en,vi}/ccConfig.json - Test:
server/__tests__/cc-mcp-cli.test.js, extendserver/__tests__/cc-config.test.js
Interfaces:
-
Produces:
mcpAdd({name, scope, transport, command, args, url, headers, env})→claude mcp add <name> [--scope user|project] [--transport http --url <url> | -- <command> <args…>]mcpRemove({name, scope})→claude mcp remove <name> [--scope …]- both async, returning
{ok, code, stdout, stderr}, argv array, no shell — same contract ascc-plugin-cli.js - Routes return 422 on a non-zero CLI exit, 400 on a name failing
NAME_RE.
-
Step 1: Write the failing test
it("builds an http server as a transport flag, not a shell string", async () => {
const spawned = [];
const cli = proxy({ spawn: (c, a) => { spawned.push([c, a]); return ok(""); } });
await cli.mcpAdd({ name: "vercel", scope: "user", transport: "http", url: "https://mcp.vercel.com" });
assert.deepEqual(spawned[0][1],
["mcp", "add", "vercel", "--scope", "user", "--transport", "http", "--url", "https://mcp.vercel.com"]);
});
it("puts stdio command args after the -- separator", async () => {
const spawned = [];
const cli = proxy({ spawn: (c, a) => { spawned.push([c, a]); return ok(""); } });
await cli.mcpAdd({ name: "docs", scope: "user", command: "node", args: ["s.js", "--port", "3000"] });
assert.deepEqual(spawned[0][1].slice(-5), ["--", "node", "s.js", "--port", "3000"]);
});
it("refuses a server name that reads as a flag", async () => {
await assert.rejects(() => require("../lib/cc-mcp-cli").mcpRemove({ name: "--all" }), /name/i);
});
- Step 2: Run test to verify it fails
Run: node --test server/__tests__/cc-mcp-cli.test.js
Expected: FAIL — module not found
- Step 3: Implement the wrapper, then the two routes, then the UI
McpPanel gains an "Add server" form (name, scope, stdio-vs-http toggle) and a remove action per row. Each row keeps its existing read-only detail view — the source of truth stays readMcpServers, refetched after every CLI call.
- Step 4: Run both suites
Run: npm run test:server && cd client && npx tsc -b && npm test
Expected: PASS
- Step 5: Regenerate OpenAPI, update docs, commit
npm run openapi:yaml
git add server/lib/cc-mcp-cli.js server/routes/cc-config.js server/openapi-extra/cc-config.js server/__tests__ client/src openapi.yaml docs/API.md
git commit -m "feat(cc-config): add and remove MCP servers through the claude CLI"
Self-Review
Spec coverage: "Mở thêm settings.json + MCP" → Task 1 (settings.json, using the API that already exists) and Task 2 (MCP, via CLI because direct file writes would race the CLI). Hooks stay read-only — they live inside settings.json, so Task 1 already covers editing them by key path, and a bespoke hook editor was not asked for.
Placeholder scan: none.
Type consistency: scope for settings is Claude's three-value union "user" | "project" | "project-local" (matching readSettings), while MCP's CLI scope flag accepts only user | project — the UI must not offer project-local on the MCP form.