feat(cc-config): add and remove MCP servers through the claude CLI

Mutation goes through `claude mcp add|remove`, never a direct edit of
~/.claude.json — the running CLI rewrites that file concurrently, so a
dashboard write would race it. Reads stay on readMcpServers.

The plan specified a --url flag; the real CLI (v2.1.266) has none —
commandOrUrl is positional, and --header must trail it or the variadic
flag swallows the url. Built against the verified shape.
This commit is contained in:
2026-09-09 09:15:53 +07:00
parent 22fd4797aa
commit fd4edaac98
11 changed files with 878 additions and 18 deletions
+12
View File
@@ -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",
+12
View File
@@ -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",
+43
View File
@@ -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<CcMcpResponse>("/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<CcCliResult>("/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<CcCliResult>("/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<string, string>;
command?: string;
args?: string[];
env?: Record<string, string>;
}
/** One hook binding within a settings.json `hooks` block. */
export interface CcHookEntry {
/** Tool-name matcher pattern (e.g. "Bash", "Edit|Write", or "*"). */
+181 -14
View File
@@ -122,7 +122,9 @@ import type {
CcKeybindings,
CcKeybindingGroup,
CcMarketplacesResponse,
CcMcpAddArgs,
CcMcpResponse,
CcMcpScope,
CcMcpServer,
CcMdItem,
CcMemoryItem,
@@ -958,7 +960,7 @@ function TabPanel({
/>
);
case "mcp":
return <McpPanel data={data.mcp} search={search} />;
return <McpPanel data={data.mcp} search={search} onToast={onToast} onChanged={onChanged} />;
case "hooks":
return (
<HooksPanel
@@ -1822,23 +1824,142 @@ function PluginCard({
// ── MCP servers ───────────────────────────────────────────────────────
function McpPanel({ data, search }: { data: CcMcpResponse | null; search: string }) {
/** Splits a pasted command line on whitespace — good enough for the common
* `npx -y @scope/server --flag value` case. Skipped a full shell-arg
* parser (quoted args with embedded spaces won't survive); add one if a
* real server needs it. */
function splitCommandLine(value: string): [string, string[]] {
const parts = value.trim().split(/\s+/).filter(Boolean);
return [parts[0] || "", parts.slice(1)];
}
function McpPanel({
data,
search,
onToast,
onChanged,
}: {
data: CcMcpResponse | null;
search: string;
onToast: (t: NonNullable<Toast>) => void;
onChanged: () => void;
}) {
const { t } = useTranslation("ccConfig");
const { busy, run } = useConfigAction(onToast, onChanged);
const [name, setName] = useState("");
const [scope, setScope] = useState<CcMcpScope>("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 <SkeletonRows n={3} />;
const all = [...data.user, ...data.projectScoped];
const filter = (arr: CcMcpServer[]) =>
arr.filter((s) => !search || s.name.toLowerCase().includes(search.toLowerCase()));
return (
<div className="space-y-4">
<ExplainerBanner
title={t("explain.mcp.title")}
body={t("explain.mcp.body")}
howTo={t("explain.mcp.add")}
commands={[
{ cmd: t("explain.mcp.listCmd"), note: t("explain.mcp.list") },
{ cmd: t("explain.mcp.addCmd"), note: t("explain.mcp.add") },
]}
commands={[{ cmd: t("explain.mcp.listCmd"), note: t("explain.mcp.list") }]}
/>
<div className="rounded-lg border border-accent/25 bg-accent/5 px-3 py-2.5 space-y-2">
<div className="text-[11px] font-semibold text-fg-secondary">{t("manage.addMcpTitle")}</div>
<div className="flex items-center gap-2 flex-wrap">
<input
value={name}
onChange={(e) => 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"
/>
<select
value={scope}
onChange={(e) => setScope(e.target.value as CcMcpScope)}
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>
</select>
<div className="flex rounded-md border border-border overflow-hidden">
{(["stdio", "http"] as const).map((k) => (
<button
key={k}
type="button"
onClick={() => setTransport(k)}
className={`h-8 px-2.5 text-[11px] font-medium ${
transport === k
? "bg-accent/20 text-accent"
: "bg-surface-1 text-fg-secondary hover:bg-surface-3"
}`}
>
{k === "stdio" ? t("manage.addMcpTransportStdio") : t("manage.addMcpTransportHttp")}
</button>
))}
</div>
</div>
{transport === "stdio" ? (
<input
value={commandLine}
onChange={(e) => 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"
/>
) : (
<input
value={url}
onChange={(e) => 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"
/>
)}
<div className="flex items-center justify-between">
<p className="text-[10px] text-fg-muted leading-relaxed">{t("manage.addMcpHelp")}</p>
<button
type="button"
onClick={() => void submit()}
disabled={!canSubmit || busy === "add"}
className="h-8 shrink-0 text-[11px] font-medium px-3 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"
>
<Plus className="w-3 h-3" />
{busy === "add" ? t("manage.working") : t("manage.addMcpButton")}
</button>
</div>
</div>
{all.length === 0 && (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
{t("mcp.noServers")}
@@ -1851,7 +1972,14 @@ function McpPanel({ data, search }: { data: CcMcpResponse | null; search: string
</h3>
<div className="space-y-2">
{filter(data.user).map((s) => (
<McpCard key={`u:${s.name}:${s.source}`} server={s} />
<McpCard
key={`u:${s.name}:${s.source}`}
server={s}
scope="user"
run={run}
busy={busy}
t={t}
/>
))}
</div>
</div>
@@ -1863,7 +1991,14 @@ function McpPanel({ data, search }: { data: CcMcpResponse | null; search: string
</h3>
<div className="space-y-2">
{filter(data.projectScoped).map((s) => (
<McpCard key={`p:${s.name}:${s.source}`} server={s} />
<McpCard
key={`p:${s.name}:${s.source}`}
server={s}
scope="project"
run={run}
busy={busy}
t={t}
/>
))}
</div>
</div>
@@ -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<unknown>, successMessage: string) => Promise<boolean>;
busy: string | null;
t: (key: string, opts?: Record<string, unknown>) => string;
}) {
const [confirmRemove, setConfirmRemove] = useState(false);
const busyId = `${scope}:${server.name}`;
return (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3">
<div className="flex items-center gap-2 flex-wrap">
@@ -1915,10 +2063,29 @@ function McpCard({ server }: { server: CcMcpServer }) {
)}
</div>
<div className="mt-3">
<CommandSnippet
command={`claude mcp remove ${server.name}`}
label={t("explain.mcp.remove")}
/>
<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>
</div>
</div>
);