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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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