diff --git a/client/src/i18n/locales/en/ccConfig.json b/client/src/i18n/locales/en/ccConfig.json index 912ecc42..a65b6988 100644 --- a/client/src/i18n/locales/en/ccConfig.json +++ b/client/src/i18n/locales/en/ccConfig.json @@ -129,6 +129,8 @@ "mcp": { "userScope": "User-scoped servers", "projectScope": "Project-scoped servers", + "name": "Name", + "scope": "Scope", "command": "Command", "args": "Args", "env": "Env names", @@ -317,6 +319,16 @@ "marketplaceAdded": "Added marketplace {{name}}.", "marketplaceUpdated": "Updated marketplace {{name}}.", "marketplaceRemoved": "Removed marketplace {{name}}.", + "addMcpTitle": "Add an MCP server", + "addMcpNamePlaceholder": "name", + "addMcpTransportStdio": "Command (stdio)", + "addMcpTransportHttp": "URL (http)", + "addMcpCommandPlaceholder": "npx -y @modelcontextprotocol/server-github", + "addMcpUrlPlaceholder": "https://mcp.example.com", + "addMcpButton": "Add", + "addMcpHelp": "Runs `claude mcp add`. Scope user/project only — a `local`-scoped server is the CLI's per-checkout layer and isn't offered here.", + "mcpAdded": "Added MCP server {{name}}.", + "mcpRemoved": "Removed MCP server {{name}}.", "hookEvent": "Event name (e.g. PreToolUse)", "hookAddEvent": "Add event", "hookRemoveEvent": "Remove event", diff --git a/client/src/i18n/locales/vi/ccConfig.json b/client/src/i18n/locales/vi/ccConfig.json index a310b3a7..0f8344a1 100644 --- a/client/src/i18n/locales/vi/ccConfig.json +++ b/client/src/i18n/locales/vi/ccConfig.json @@ -124,6 +124,8 @@ "mcp": { "userScope": "Máy chủ phạm vi người dùng", "projectScope": "Máy chủ phạm vi dự án", + "name": "Tên", + "scope": "Phạm vi", "command": "Lệnh", "args": "Tham số", "env": "Tên env", @@ -312,6 +314,16 @@ "marketplaceAdded": "Đã thêm marketplace {{name}}.", "marketplaceUpdated": "Đã cập nhật marketplace {{name}}.", "marketplaceRemoved": "Đã xóa marketplace {{name}}.", + "addMcpTitle": "Thêm máy chủ MCP", + "addMcpNamePlaceholder": "tên", + "addMcpTransportStdio": "Lệnh (stdio)", + "addMcpTransportHttp": "URL (http)", + "addMcpCommandPlaceholder": "npx -y @modelcontextprotocol/server-github", + "addMcpUrlPlaceholder": "https://mcp.example.com", + "addMcpButton": "Thêm", + "addMcpHelp": "Chạy lệnh `claude mcp add`. Chỉ có phạm vi user/project — phạm vi `local` của CLI (theo từng checkout) không được cung cấp ở đây.", + "mcpAdded": "Đã thêm máy chủ MCP {{name}}.", + "mcpRemoved": "Đã xóa máy chủ MCP {{name}}.", "hookEvent": "Tên sự kiện (vd PreToolUse)", "hookAddEvent": "Thêm sự kiện", "hookRemoveEvent": "Xóa sự kiện", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index ac93133d..2cdaa073 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -97,6 +97,8 @@ * - `CcPluginsResponse` — exported API; see TSDoc on the symbol for behavior. * - `CcMcpServer` — exported API; see TSDoc on the symbol for behavior. * - `CcMcpResponse` — exported API; see TSDoc on the symbol for behavior. + * - `CcMcpScope` — exported API; see TSDoc on the symbol for behavior. + * - `CcMcpAddArgs` — exported API; see TSDoc on the symbol for behavior. * - `CcHookEntry` — exported API; see TSDoc on the symbol for behavior. * - `CcHookSource` — exported API; see TSDoc on the symbol for behavior. * - `CcSettingsSource` — exported API; see TSDoc on the symbol for behavior. @@ -1404,6 +1406,28 @@ export const api = { * @returns {@link CcMcpResponse} — servers split into `user`/`projectScoped`. */ mcp: () => request("/cc-config/mcp"), + /** + * POST /api/cc-config/mcp/add - add an MCP server via `claude mcp add`. + * Either `url` (http/sse) or `command`+`args` (stdio) must be given, not + * both. `scope` is required — `user` or `project` only, never the + * settings union's third `project-local` value. + * @returns {@link CcCliResult} — the CLI's stdout/stderr on success. + */ + mcpAdd: (args: CcMcpAddArgs) => + request("/cc-config/mcp/add", { + method: "POST", + body: JSON.stringify(args), + }), + /** + * POST /api/cc-config/mcp/remove - remove an MCP server via + * `claude mcp remove`. `scope` narrows which config layer to remove + * from; omit it to let the CLI find the server wherever it exists. + */ + mcpRemove: (name: string, scope?: CcMcpScope) => + request("/cc-config/mcp/remove", { + method: "POST", + body: JSON.stringify({ name, scope }), + }), /** * GET /api/cc-config/hooks - hook entries from every settings.json layer. * @returns `{ items }` — one {@link CcHookSource} per settings layer. @@ -2647,6 +2671,25 @@ export interface CcMcpResponse { projectScoped: CcMcpServer[]; } +/** Scope the `claude mcp` CLI accepts from this form. Deliberately not the + * three-value settings union — the CLI's own third scope (`local`) is a + * different axis and is not offered here. */ +export type CcMcpScope = "user" | "project"; + +/** Body of POST /api/cc-config/mcp/add. Either `url` (http/sse) or + * `command`+`args` (stdio) is required, never both. `env` is stdio-only; + * `headers` is http/sse-only. */ +export interface CcMcpAddArgs { + name: string; + scope: CcMcpScope; + transport?: "http" | "sse"; + url?: string; + headers?: Record; + command?: string; + args?: string[]; + env?: Record; +} + /** One hook binding within a settings.json `hooks` block. */ export interface CcHookEntry { /** Tool-name matcher pattern (e.g. "Bash", "Edit|Write", or "*"). */ diff --git a/client/src/pages/CcConfig.tsx b/client/src/pages/CcConfig.tsx index 935e21d6..7d040201 100644 --- a/client/src/pages/CcConfig.tsx +++ b/client/src/pages/CcConfig.tsx @@ -122,7 +122,9 @@ import type { CcKeybindings, CcKeybindingGroup, CcMarketplacesResponse, + CcMcpAddArgs, CcMcpResponse, + CcMcpScope, CcMcpServer, CcMdItem, CcMemoryItem, @@ -958,7 +960,7 @@ function TabPanel({ /> ); case "mcp": - return ; + return ; case "hooks": return ( ) => void; + onChanged: () => void; +}) { const { t } = useTranslation("ccConfig"); + const { busy, run } = useConfigAction(onToast, onChanged); + const [name, setName] = useState(""); + const [scope, setScope] = useState("user"); + const [transport, setTransport] = useState<"stdio" | "http">("stdio"); + const [commandLine, setCommandLine] = useState(""); + const [url, setUrl] = useState(""); + + const canSubmit = + name.trim().length > 0 && (transport === "stdio" ? commandLine.trim() : url.trim()); + + const submit = useCallback(async () => { + const n = name.trim(); + if (!n) return; + const args: CcMcpAddArgs = + transport === "http" + ? { name: n, scope, transport: "http", url: url.trim() } + : (() => { + const [command, cmdArgs] = splitCommandLine(commandLine); + return { name: n, scope, command, args: cmdArgs }; + })(); + const ok = await run("add", () => api.ccConfig.mcpAdd(args), t("manage.mcpAdded", { name: n })); + if (ok) { + setName(""); + setCommandLine(""); + setUrl(""); + } + }, [commandLine, name, run, scope, t, transport, url]); + if (!data) return ; const all = [...data.user, ...data.projectScoped]; const filter = (arr: CcMcpServer[]) => arr.filter((s) => !search || s.name.toLowerCase().includes(search.toLowerCase())); + return (
+ +
+
{t("manage.addMcpTitle")}
+
+ setName(e.target.value)} + placeholder={t("manage.addMcpNamePlaceholder")} + aria-label={t("mcp.name")} + className="h-8 w-40 bg-surface-1 border border-border rounded px-2 text-[12px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40" + /> + +
+ {(["stdio", "http"] as const).map((k) => ( + + ))} +
+
+ {transport === "stdio" ? ( + setCommandLine(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void submit(); + }} + placeholder={t("manage.addMcpCommandPlaceholder")} + aria-label={t("mcp.command")} + className="h-8 w-full bg-surface-1 border border-border rounded px-2 text-[12px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40" + /> + ) : ( + setUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void submit(); + }} + placeholder={t("manage.addMcpUrlPlaceholder")} + aria-label={t("mcp.url")} + className="h-8 w-full bg-surface-1 border border-border rounded px-2 text-[12px] font-mono text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40" + /> + )} +
+

{t("manage.addMcpHelp")}

+ +
+
+ {all.length === 0 && (
{t("mcp.noServers")} @@ -1851,7 +1972,14 @@ function McpPanel({ data, search }: { data: CcMcpResponse | null; search: string
{filter(data.user).map((s) => ( - + ))}
@@ -1863,7 +1991,14 @@ function McpPanel({ data, search }: { data: CcMcpResponse | null; search: string
{filter(data.projectScoped).map((s) => ( - + ))}
@@ -1872,8 +2007,21 @@ function McpPanel({ data, search }: { data: CcMcpResponse | null; search: string ); } -function McpCard({ server }: { server: CcMcpServer }) { - const { t } = useTranslation("ccConfig"); +function McpCard({ + server, + scope, + run, + busy, + t, +}: { + server: CcMcpServer; + scope: CcMcpScope; + run: (id: string, fn: () => Promise, successMessage: string) => Promise; + busy: string | null; + t: (key: string, opts?: Record) => string; +}) { + const [confirmRemove, setConfirmRemove] = useState(false); + const busyId = `${scope}:${server.name}`; return (
@@ -1915,10 +2063,29 @@ function McpCard({ server }: { server: CcMcpServer }) { )}
- + { + 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")} +
); diff --git a/docs/API.md b/docs/API.md index 478b7942..bf36b53a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1560,7 +1560,7 @@ curl "http://localhost:4820/api/sessions?sources=local,4d1f0e2a-7b9c-4c33-8a21-9 ### Claude Config Explorer -The `/api/cc-config/*` namespace powers the Claude Config Explorer, now the **Claude Code** tab of Settings (`/settings?tool=claude-code`). All read endpoints are pure file reads under `CLAUDE_HOME` and the project's `.claude/` dir. File mutations cover the low-risk text-file artifacts (skills, subagents, slash commands, output styles, memory), `keybindings.json`, and `settings.json` layers **via a per-key merge only** — each always creates a timestamped backup before writing. Plugin and marketplace management is delegated to the `claude plugin …` CLI. MCP server config stays read-only, because it is written concurrently by the running Claude Code CLI. +The `/api/cc-config/*` namespace powers the Claude Config Explorer, now the **Claude Code** tab of Settings (`/settings?tool=claude-code`). All read endpoints are pure file reads under `CLAUDE_HOME` and the project's `.claude/` dir. File mutations cover the low-risk text-file artifacts (skills, subagents, slash commands, output styles, memory), `keybindings.json`, and `settings.json` layers **via a per-key merge only** — each always creates a timestamped backup before writing. Plugin/marketplace management and MCP server add/remove are delegated to the `claude plugin …` / `claude mcp …` CLI, because `~/.claude.json` and the plugin manifests are rewritten concurrently by the running Claude Code CLI. ```http GET /api/cc-config/overview @@ -1593,6 +1593,8 @@ POST /api/cc-config/plugins/install Body: { spec, scope? } POST /api/cc-config/plugins/action Body: { key, action: enable|disable|update|uninstall } POST /api/cc-config/marketplaces/add Body: { source, scope? } POST /api/cc-config/marketplaces/action Body: { name, action: update|remove } +POST /api/cc-config/mcp/add Body: { name, scope, transport?, url?, headers?, command?, args?, env? } +POST /api/cc-config/mcp/remove Body: { name, scope? } ``` `scope` is `"user"`, `"project"`, or `"auto-memory"`. `type` is one of `skills`, `agents`, `commands`, `output-styles`, `memory`, `auto-memory`. `name` is required for everything except `memory` (which is `CLAUDE.md` itself). On `PUT`, `name` is validated against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (for `auto-memory` it must instead be a flat `*.md` filename). Settings are returned with secret-like keys (matching `/token|secret|password|api[_-]?key|auth/i`) replaced by `""`. @@ -1605,6 +1607,8 @@ POST /api/cc-config/marketplaces/action Body: { name, action: update|remove } **Plugins and marketplaces.** These five endpoints shell out to the `claude plugin …` CLI with an explicit argv array (never a shell string), pinned to the dashboard's `CLAUDE_HOME` via `CLAUDE_CONFIG_DIR`; mutating calls are serialized in-process. `source` must be `owner/repo`, an `http(s)://` URL, or an absolute path; `spec`/`key` must be `` or `@`; `scope` is `user` (default), `project` or `local`. Validation failures return 400 `EBADARG`; a missing `claude` binary returns 503 `ECLINOTFOUND`; a CLI timeout returns 504 `ETIMEOUT`; a non-zero CLI exit returns **422 `ECLIEXIT`** with the CLI's message plus `stdout`/`stderr`. Successful runs return `{ ok: true, stdout, stderr }` and broadcast `cc_config_changed`. `GET /plugins/available` proxies `claude plugin list --available --json` as `{ installed, available }`. +**MCP servers.** `POST /api/cc-config/mcp/add` and `POST /api/cc-config/mcp/remove` shell out to `claude mcp add`/`claude mcp remove` the same way the plugin routes shell out to `claude plugin …` — an argv array, never a shell string, serialized in-process. `scope` is `user` or `project` only (required on add, optional on remove — omitted, the CLI removes from whichever scope the server exists in); the settings union's third value `project-local` is a different axis and is not accepted here. `add` takes either `url` (+ optional `transport: "http"|"sse"`, `headers`) for a remote server, or `command` + `args` (+ optional `env`) for a stdio server — exactly one of the two forms. Validation failures return 400 `EBADARG`; a non-zero CLI exit returns 422 `ECLIEXIT`. `GET /api/cc-config/mcp` is unaffected — it still reads `~/.claude.json` directly; the client refetches it after either call succeeds. + `PUT /api/cc-config/keybindings` edits `~/.claude/keybindings.json` from a structured list of context groups (`{ groups: [{ context, bindings: [{ key, action }] }] }`). The server backs the file up first (under `/cc-config-backups/keybindings/`), preserves any top-level metadata (`$schema`/`$docs`), and replaces only the `bindings` array; duplicate contexts or duplicate keys within a context return `EBADCONTENT`. `keybindings.json` is replaced wholesale here because the live CLI does not rewrite it mid-session; `settings.json`, which it does, is only ever key-merged (see `PATCH /settings` above). **Auth profiles (`/profiles*`).** A *profile* is one Anthropic account — one quota — under a name. Switching swaps exactly two identity artifacts and nothing else: `claudeAiOauth` inside `/.credentials.json`, and `oauthAccount` + `userID` inside the `.claude.json` config file(s) (`.json` and `/.claude.json`, each rewritten only if it already exists, key-merged so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account. `mcpOAuth` also stays put: those are the machine's MCP server tokens, not account identity. diff --git a/openapi.yaml b/openapi.yaml index b76a17ff..89d41bdf 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2094,6 +2094,21 @@ components: description: MCP servers scoped to the current project root in ~/.claude.json. items: $ref: '#/components/schemas/CcConfigMcpServer' + CcConfigCliResult: + type: object + required: + - ok + - stdout + - stderr + properties: + ok: + type: boolean + enum: + - true + stdout: + type: string + stderr: + type: string CcConfigHookEntry: type: object description: A single flattened hook command bound to a matcher. @@ -7996,6 +8011,108 @@ paths: url: https://mcp.example.com/sse headers: - Authorization + /api/cc-config/mcp/add: + post: + 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.' + operationId: ccConfigPostMcpAdd + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + - scope + properties: + name: + type: string + example: vercel + scope: + type: string + enum: + - user + - project + example: user + transport: + type: string + enum: + - http + - sse + url: + type: string + example: https://mcp.vercel.com + headers: + type: object + additionalProperties: + type: string + example: + Authorization: Bearer xyz + command: + type: string + example: npx + args: + type: array + items: + type: string + example: + - '-y' + - server-github + env: + type: object + additionalProperties: + type: string + example: + GITHUB_TOKEN: xxx + responses: + '200': + description: The CLI's stdout/stderr on success. + content: + application/json: + schema: + $ref: '#/components/schemas/CcConfigCliResult' + '400': + description: Bad name/scope/args, or an incompatible combination. + '422': + description: The CLI exited non-zero (e.g. the name is already taken). + /api/cc-config/mcp/remove: + post: + 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. + operationId: ccConfigPostMcpRemove + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + example: vercel + scope: + type: string + enum: + - user + - project + responses: + '200': + description: The CLI's stdout/stderr on success. + content: + application/json: + schema: + $ref: '#/components/schemas/CcConfigCliResult' + '400': + description: Bad name/scope. + '422': + description: The CLI exited non-zero (e.g. no such server). /api/cc-config/hooks: get: tags: diff --git a/server/__tests__/cc-config.test.js b/server/__tests__/cc-config.test.js index 84566466..ca9064a7 100644 --- a/server/__tests__/cc-config.test.js +++ b/server/__tests__/cc-config.test.js @@ -1112,4 +1112,98 @@ describe("/api/cc-config", () => { process.env.PATH = originalPath; } }); + + // ── MCP add/remove routes (fake `claude` on PATH) ─────────────────────── + // + // `printf`, not `echo` — bash's builtin `echo "-e"` swallows a lone `-e` + // argument as its own flag, and `-e KEY=VALUE` is exactly what mcpAdd + // emits for a stdio server's env vars. + const PRINTF_ARGV = 'for a in "$@"; do printf \'%s\\n\' "$a"; done'; + + it("POST /mcp/add builds an http server with a positional url", async () => { + const restore = stubClaude(PRINTF_ARGV); + try { + const { status, body } = await fetchJson("/api/cc-config/mcp/add", { + method: "POST", + body: { name: "vercel", scope: "user", transport: "http", url: "https://mcp.vercel.com" }, + }); + assert.equal(status, 200); + assert.deepEqual(body.stdout.trim().split("\n"), [ + "mcp", + "add", + "--scope", + "user", + "--transport", + "http", + "vercel", + "https://mcp.vercel.com", + ]); + } finally { + restore(); + } + }); + + it("POST /mcp/add builds a stdio server with args after --", async () => { + const restore = stubClaude(PRINTF_ARGV); + try { + const { status, body } = await fetchJson("/api/cc-config/mcp/add", { + method: "POST", + body: { name: "docs", scope: "user", command: "node", args: ["s.js", "--port", "3000"] }, + }); + assert.equal(status, 200); + assert.deepEqual(body.stdout.trim().split("\n").slice(-5), [ + "--", + "node", + "s.js", + "--port", + "3000", + ]); + } finally { + restore(); + } + }); + + it("POST /mcp/add rejects a name that reads as a flag with 400", async () => { + const { status, body } = await fetchJson("/api/cc-config/mcp/add", { + method: "POST", + body: { name: "--all", scope: "user", command: "node", args: [] }, + }); + assert.equal(status, 400); + assert.equal(body.error.code, "EBADARG"); + }); + + it("POST /mcp/remove surfaces a failed CLI run as 422", async () => { + const restore = stubClaude('echo "No MCP server named \\"nope\\"" >&2\nexit 1'); + try { + const { status, body } = await fetchJson("/api/cc-config/mcp/remove", { + method: "POST", + body: { name: "nope", scope: "user" }, + }); + assert.equal(status, 422); + assert.equal(body.error.code, "ECLIEXIT"); + assert.match(body.error.message, /No MCP server named/); + } finally { + restore(); + } + }); + + it("POST /mcp/remove runs the CLI with the given scope", async () => { + const restore = stubClaude(PRINTF_ARGV); + try { + const { status, body } = await fetchJson("/api/cc-config/mcp/remove", { + method: "POST", + body: { name: "docs", scope: "project" }, + }); + assert.equal(status, 200); + assert.deepEqual(body.stdout.trim().split("\n"), [ + "mcp", + "remove", + "docs", + "--scope", + "project", + ]); + } finally { + restore(); + } + }); }); diff --git a/server/__tests__/cc-mcp-cli.test.js b/server/__tests__/cc-mcp-cli.test.js new file mode 100644 index 00000000..19af82d0 --- /dev/null +++ b/server/__tests__/cc-mcp-cli.test.js @@ -0,0 +1,267 @@ +/** + * @file Tests for server/lib/cc-mcp-cli.js — the `claude mcp add`/`claude mcp + * remove` wrapper. Uses a real fake `claude` script on PATH (the fixture + * style server/__tests__/cc-plugin-cli.test.js already uses) rather than + * mocking child_process, and pins the exact argv each operation builds — + * that argv IS the security boundary. + * + * The argv shapes asserted here were verified against the real + * `claude mcp add/remove --help` (v2.1.266), not assumed: there is no + * `--url` flag (the url is positional), and `--header` must trail the url. + * See cc-mcp-cli.js's file header for the full list of surprises. + * + * @author Nguyễn Ngọc Trí Vĩ + */ +const { describe, it, after } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ccmcp-")); +const cli = require("../lib/cc-mcp-cli"); + +/** + * Put a fake `claude` on PATH; it prints argv, one element per line. + * `printf`, not `echo` — bash's builtin `echo "-e"` treats a lone `-e` + * argument as its own flag and swallows it, which is exactly one of the + * argv elements this wrapper emits (`-e KEY=VALUE`). + */ +function stubClaude(scriptBody = 'for a in "$@"; do printf \'%s\\n\' "$a"; done') { + const bin = path.join(ROOT, `bin-${process.hrtime.bigint()}`); + fs.mkdirSync(bin, { recursive: true }); + const file = path.join(bin, "claude"); + fs.writeFileSync(file, `#!/usr/bin/env bash\n${scriptBody}\n`); + fs.chmodSync(file, 0o755); + const originalPath = process.env.PATH; + process.env.PATH = `${bin}${path.delimiter}${originalPath}`; + return () => { + process.env.PATH = originalPath; + }; +} + +const argvOf = (result) => result.stdout.trim().split("\n"); + +after(() => fs.rmSync(ROOT, { recursive: true, force: true, maxRetries: 5 })); + +describe("cc-mcp-cli argument validation", () => { + it("accepts letters, numbers, hyphens and underscores as a name", () => { + assert.equal(cli.assertName("my-server_1"), "my-server_1"); + }); + + it("rejects a name with characters the CLI itself rejects", () => { + for (const bad of ["a/b", "a b", "a.b", ""]) { + assert.throws(() => cli.assertName(bad), undefined, `accepted ${bad}`); + } + }); + + it("refuses a server name that reads as a flag", async () => { + await assert.rejects(() => cli.mcpRemove({ name: "--all" }), /name/i); + }); + + it("requires an explicit scope on add but not on remove", () => { + assert.throws(() => cli.assertMcpScope(undefined, { required: true }), /scope must be one of/); + assert.equal(cli.assertMcpScope(undefined), undefined); + 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"]) { + assert.throws(() => cli.assertMcpScope(bad), /scope must be one of/, `accepted ${bad}`); + } + }); +}); + +describe("cc-mcp-cli argv construction", () => { + it("builds an http server with a positional url, not a --url flag", async () => { + const restore = stubClaude(); + try { + const result = await cli.mcpAdd({ + name: "vercel", + scope: "user", + transport: "http", + url: "https://mcp.vercel.com", + }); + assert.deepEqual(argvOf(result), [ + "mcp", + "add", + "--scope", + "user", + "--transport", + "http", + "vercel", + "https://mcp.vercel.com", + ]); + } finally { + restore(); + } + }); + + it("puts stdio command args after the -- separator", async () => { + const restore = stubClaude(); + try { + const result = await cli.mcpAdd({ + name: "docs", + scope: "user", + command: "node", + args: ["s.js", "--port", "3000"], + }); + assert.deepEqual(argvOf(result).slice(-5), ["--", "node", "s.js", "--port", "3000"]); + } finally { + restore(); + } + }); + + it("puts --header flags after the positional url, not before", async () => { + // Verified against the real CLI: --header is variadic, so placed before + // the url it would swallow the url as one of its own header values. + const restore = stubClaude(); + try { + const result = await cli.mcpAdd({ + name: "corridor", + scope: "project", + transport: "http", + url: "https://app.corridor.dev/api/mcp", + headers: { Authorization: "Bearer xyz" }, + }); + assert.deepEqual(argvOf(result), [ + "mcp", + "add", + "--scope", + "project", + "--transport", + "http", + "corridor", + "https://app.corridor.dev/api/mcp", + "--header", + "Authorization: Bearer xyz", + ]); + } finally { + restore(); + } + }); + + it("passes env vars as -e KEY=VALUE for stdio servers, value may start with -", async () => { + const restore = stubClaude(); + try { + const result = await cli.mcpAdd({ + name: "envy", + scope: "user", + command: "node", + args: ["s.js"], + env: { API_KEY: "-not-a-flag" }, + }); + assert.deepEqual(argvOf(result), [ + "mcp", + "add", + "--scope", + "user", + "envy", + "-e", + "API_KEY=-not-a-flag", + "--", + "node", + "s.js", + ]); + } finally { + restore(); + } + }); + + it("builds the remove argv, scope flag omitted when not given", async () => { + 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" })), [ + "mcp", + "remove", + "docs", + "--scope", + "project", + ]); + } finally { + restore(); + } + }); + + it("rejects url and command together, and neither", async () => { + await assert.rejects( + () => cli.mcpAdd({ name: "x", scope: "user", url: "https://a", command: "b" }), + /either url or command, not both/ + ); + await assert.rejects( + () => cli.mcpAdd({ name: "x", scope: "user" }), + /requires either url or command/ + ); + }); + + it("rejects env together with url, and headers together with command", async () => { + await assert.rejects( + () => cli.mcpAdd({ name: "x", scope: "user", url: "https://a", env: { K: "v" } }), + /env is not valid together with url/ + ); + await assert.rejects( + () => + cli.mcpAdd({ + name: "x", + scope: "user", + command: "node", + headers: { K: "v" }, + }), + /headers is not valid together with command/ + ); + }); + + 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 + // character blocklist. If this were interpolated into a shell string the + // `touch` would run; through execFile it stays one inert literal. + const canary = path.join(ROOT, "pwned"); + const restore = stubClaude(); + try { + const result = await cli.mcpAdd({ + name: "x", + scope: "user", + command: "node", + args: [`s.js; touch ${canary}`], + }); + assert.equal(argvOf(result).at(-1), `s.js; touch ${canary}`); + } finally { + restore(); + } + assert.equal(fs.existsSync(canary), false); + }); +}); + +describe("cc-mcp-cli run outcomes", () => { + it("reports a non-zero exit as ok:false instead of throwing", async () => { + const restore = stubClaude('echo "✘ Server name already exists" >&2\nexit 1'); + try { + const result = await cli.mcpAdd({ + name: "dup", + scope: "user", + command: "node", + args: ["s.js"], + }); + assert.equal(result.ok, false); + assert.equal(result.code, 1); + assert.match(cli.cliMessage(result), /already exists/); + } finally { + restore(); + } + }); + + it("serializes mutating calls so two clicks cannot interleave", async () => { + const log = path.join(ROOT, `order-${process.hrtime.bigint()}.txt`); + const restore = stubClaude(`echo "start $3" >> ${log}\nsleep 0.2\necho "end $3" >> ${log}`); + try { + await Promise.all([cli.mcpRemove({ name: "first" }), cli.mcpRemove({ name: "second" })]); + const lines = fs.readFileSync(log, "utf8").trim().split("\n"); + assert.equal(lines[0].replace("start ", ""), lines[1].replace("end ", "")); + assert.equal(lines[2].replace("start ", ""), lines[3].replace("end ", "")); + } finally { + restore(); + } + }); +}); diff --git a/server/lib/cc-mcp-cli.js b/server/lib/cc-mcp-cli.js new file mode 100644 index 00000000..79484d39 Binary files /dev/null and b/server/lib/cc-mcp-cli.js differ diff --git a/server/openapi-extra/cc-config.js b/server/openapi-extra/cc-config.js index 8c7b6392..7f516f6e 100644 --- a/server/openapi-extra/cc-config.js +++ b/server/openapi-extra/cc-config.js @@ -387,6 +387,18 @@ const schemas = { }, }, + /** Response of a successful mutating `claude mcp …` run. A non-zero CLI + * exit arrives as an HTTP 422 error instead of one of these. */ + CcConfigCliResult: { + type: "object", + required: ["ok", "stdout", "stderr"], + properties: { + ok: { type: "boolean", enum: [true] }, + stdout: { type: "string" }, + stderr: { type: "string" }, + }, + }, + // ── Hooks ──────────────────────────────────────────────────────────── CcConfigHookEntry: { type: "object", @@ -1580,6 +1592,98 @@ const paths = { }, }, + "/api/cc-config/mcp/add": { + post: { + 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.", + operationId: "ccConfigPostMcpAdd", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["name", "scope"], + properties: { + name: { type: "string", example: "vercel" }, + scope: { type: "string", enum: ["user", "project"], example: "user" }, + transport: { type: "string", enum: ["http", "sse"] }, + url: { type: "string", example: "https://mcp.vercel.com" }, + headers: { + type: "object", + additionalProperties: { type: "string" }, + example: { Authorization: "Bearer xyz" }, + }, + command: { type: "string", example: "npx" }, + args: { + type: "array", + items: { type: "string" }, + example: ["-y", "server-github"], + }, + env: { + type: "object", + additionalProperties: { type: "string" }, + example: { GITHUB_TOKEN: "xxx" }, + }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: "The CLI's stdout/stderr on success.", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CcConfigCliResult" }, + }, + }, + }, + 400: { description: "Bad name/scope/args, or an incompatible combination." }, + 422: { description: "The CLI exited non-zero (e.g. the name is already taken)." }, + }, + }, + }, + + "/api/cc-config/mcp/remove": { + post: { + 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.", + operationId: "ccConfigPostMcpRemove", + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["name"], + properties: { + name: { type: "string", example: "vercel" }, + scope: { type: "string", enum: ["user", "project"] }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: "The CLI's stdout/stderr on success.", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CcConfigCliResult" }, + }, + }, + }, + 400: { description: "Bad name/scope." }, + 422: { description: "The CLI exited non-zero (e.g. no such server)." }, + }, + }, + }, + "/api/cc-config/hooks": { get: { tags: ["CcConfig"], diff --git a/server/routes/cc-config.js b/server/routes/cc-config.js index 64c67e17..1cf645ba 100644 --- a/server/routes/cc-config.js +++ b/server/routes/cc-config.js @@ -10,9 +10,11 @@ * artifacts (skills, agents, commands, output styles, memory, per-project * auto-memory), the structured keybindings.json editor, settings.json layers * via a per-key merge (`PATCH /settings`, which is also how hooks are edited), - * and plugin/marketplace management delegated to the `claude plugin …` CLI. - * Every file write is preceded by a timestamped backup. MCP server config - * stays read-only — it is written concurrently by the running CLI. + * plugin/marketplace management delegated to the `claude plugin …` CLI, and + * MCP server add/remove delegated to the `claude mcp …` CLI. Every file + * write is preceded by a timestamped backup; the CLI-delegated writes are + * not files this server touches directly, so there is nothing to back up — + * the CLI owns `~/.claude.json`. * * Also hosts the auth-profile routes (`/profiles*`) that swap which Anthropic * account the local CLI runs as; those carry identity metadata only, never @@ -25,6 +27,7 @@ const { Router } = require("express"); const cc = require("../lib/cc-discovery"); const ccMutate = require("../lib/cc-mutate"); const ccCli = require("../lib/cc-plugin-cli"); +const ccMcpCli = require("../lib/cc-mcp-cli"); const ccProfiles = require("../lib/cc-profiles"); const { broadcast } = require("../websocket"); @@ -329,6 +332,43 @@ router.post("/marketplaces/action", async (req, res) => { } }); +// ── MCP servers (via the `claude` CLI) ───────────────────────────────── +// +// Same reasoning as the plugin/marketplace routes above: `~/.claude.json` is +// rewritten concurrently by the running CLI, so add/remove shell out to +// `claude mcp …` rather than editing the file. Reads are unaffected — GET +// /mcp above stays on `readMcpServers`; the client refetches it after either +// call below succeeds. + +router.post("/mcp/add", async (req, res) => { + const { name, scope, transport, command, args, url, headers, env } = req.body || {}; + try { + const result = await ccMcpCli.mcpAdd({ + name, + scope, + transport, + command, + args, + url, + headers, + env, + }); + return cliResult(res, result, { action: "add", type: "mcp", name }); + } catch (err) { + return cliError(res, err); + } +}); + +router.post("/mcp/remove", async (req, res) => { + const { name, scope } = req.body || {}; + try { + const result = await ccMcpCli.mcpRemove({ name, scope }); + return cliResult(res, result, { action: "remove", type: "mcp", name }); + } catch (err) { + return cliError(res, err); + } +}); + // PATCH /api/cc-config/settings — merge individual keys into one settings // layer. Body: { scope: "user"|"project"|"project-local", set?: {dotted: value}, // unset?: ["dotted"] }. Hooks are edited through this same route as