# 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 `` 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ĩ `. - **User-visible strings live in i18n JSON**, `en` and `vi` together. - **Never write `~/.claude.json` directly.** MCP changes go through the `claude` CLI, argv array, no shell. - **A value the read path redacted must never be written back.** `patchSettings`' `assertNoRedactionMarker` already 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.md` Task 8 (the extracted `components/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` (the `SettingsPanel` / `SettingsBlock` components), `client/src/lib/api.ts` (add `api.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, 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** ```tsx 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: "" } }] }); 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 `""` 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** ```bash 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` (add `POST /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`, extend `server/__tests__/cc-config.test.js` **Interfaces:** - Produces: - `mcpAdd({name, scope, transport, command, args, url, headers, env})` → `claude mcp add [--scope user|project] [--transport http --url | -- ]` - `mcpRemove({name, scope})` → `claude mcp remove [--scope …]` - both async, returning `{ok, code, stdout, stderr}`, argv array, no shell — same contract as `cc-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** ```js 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** ```bash 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.