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:
+181
-14
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user