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
+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>
);