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": {
|
"mcp": {
|
||||||
"userScope": "User-scoped servers",
|
"userScope": "User-scoped servers",
|
||||||
"projectScope": "Project-scoped servers",
|
"projectScope": "Project-scoped servers",
|
||||||
|
"name": "Name",
|
||||||
|
"scope": "Scope",
|
||||||
"command": "Command",
|
"command": "Command",
|
||||||
"args": "Args",
|
"args": "Args",
|
||||||
"env": "Env names",
|
"env": "Env names",
|
||||||
@@ -317,6 +319,16 @@
|
|||||||
"marketplaceAdded": "Added marketplace {{name}}.",
|
"marketplaceAdded": "Added marketplace {{name}}.",
|
||||||
"marketplaceUpdated": "Updated marketplace {{name}}.",
|
"marketplaceUpdated": "Updated marketplace {{name}}.",
|
||||||
"marketplaceRemoved": "Removed 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)",
|
"hookEvent": "Event name (e.g. PreToolUse)",
|
||||||
"hookAddEvent": "Add event",
|
"hookAddEvent": "Add event",
|
||||||
"hookRemoveEvent": "Remove event",
|
"hookRemoveEvent": "Remove event",
|
||||||
|
|||||||
@@ -124,6 +124,8 @@
|
|||||||
"mcp": {
|
"mcp": {
|
||||||
"userScope": "Máy chủ phạm vi người dùng",
|
"userScope": "Máy chủ phạm vi người dùng",
|
||||||
"projectScope": "Máy chủ phạm vi dự án",
|
"projectScope": "Máy chủ phạm vi dự án",
|
||||||
|
"name": "Tên",
|
||||||
|
"scope": "Phạm vi",
|
||||||
"command": "Lệnh",
|
"command": "Lệnh",
|
||||||
"args": "Tham số",
|
"args": "Tham số",
|
||||||
"env": "Tên env",
|
"env": "Tên env",
|
||||||
@@ -312,6 +314,16 @@
|
|||||||
"marketplaceAdded": "Đã thêm marketplace {{name}}.",
|
"marketplaceAdded": "Đã thêm marketplace {{name}}.",
|
||||||
"marketplaceUpdated": "Đã cập nhật marketplace {{name}}.",
|
"marketplaceUpdated": "Đã cập nhật marketplace {{name}}.",
|
||||||
"marketplaceRemoved": "Đã xóa 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)",
|
"hookEvent": "Tên sự kiện (vd PreToolUse)",
|
||||||
"hookAddEvent": "Thêm sự kiện",
|
"hookAddEvent": "Thêm sự kiện",
|
||||||
"hookRemoveEvent": "Xóa sự kiện",
|
"hookRemoveEvent": "Xóa sự kiện",
|
||||||
|
|||||||
@@ -97,6 +97,8 @@
|
|||||||
* - `CcPluginsResponse` — exported API; see TSDoc on the symbol for behavior.
|
* - `CcPluginsResponse` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `CcMcpServer` — 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.
|
* - `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.
|
* - `CcHookEntry` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `CcHookSource` — 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.
|
* - `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`.
|
* @returns {@link CcMcpResponse} — servers split into `user`/`projectScoped`.
|
||||||
*/
|
*/
|
||||||
mcp: () => request<CcMcpResponse>("/cc-config/mcp"),
|
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.
|
* GET /api/cc-config/hooks - hook entries from every settings.json layer.
|
||||||
* @returns `{ items }` — one {@link CcHookSource} per settings layer.
|
* @returns `{ items }` — one {@link CcHookSource} per settings layer.
|
||||||
@@ -2647,6 +2671,25 @@ export interface CcMcpResponse {
|
|||||||
projectScoped: CcMcpServer[];
|
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. */
|
/** One hook binding within a settings.json `hooks` block. */
|
||||||
export interface CcHookEntry {
|
export interface CcHookEntry {
|
||||||
/** Tool-name matcher pattern (e.g. "Bash", "Edit|Write", or "*"). */
|
/** Tool-name matcher pattern (e.g. "Bash", "Edit|Write", or "*"). */
|
||||||
|
|||||||
+181
-14
@@ -122,7 +122,9 @@ import type {
|
|||||||
CcKeybindings,
|
CcKeybindings,
|
||||||
CcKeybindingGroup,
|
CcKeybindingGroup,
|
||||||
CcMarketplacesResponse,
|
CcMarketplacesResponse,
|
||||||
|
CcMcpAddArgs,
|
||||||
CcMcpResponse,
|
CcMcpResponse,
|
||||||
|
CcMcpScope,
|
||||||
CcMcpServer,
|
CcMcpServer,
|
||||||
CcMdItem,
|
CcMdItem,
|
||||||
CcMemoryItem,
|
CcMemoryItem,
|
||||||
@@ -958,7 +960,7 @@ function TabPanel({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "mcp":
|
case "mcp":
|
||||||
return <McpPanel data={data.mcp} search={search} />;
|
return <McpPanel data={data.mcp} search={search} onToast={onToast} onChanged={onChanged} />;
|
||||||
case "hooks":
|
case "hooks":
|
||||||
return (
|
return (
|
||||||
<HooksPanel
|
<HooksPanel
|
||||||
@@ -1822,23 +1824,142 @@ function PluginCard({
|
|||||||
|
|
||||||
// ── MCP servers ───────────────────────────────────────────────────────
|
// ── 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 { 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} />;
|
if (!data) return <SkeletonRows n={3} />;
|
||||||
const all = [...data.user, ...data.projectScoped];
|
const all = [...data.user, ...data.projectScoped];
|
||||||
const filter = (arr: CcMcpServer[]) =>
|
const filter = (arr: CcMcpServer[]) =>
|
||||||
arr.filter((s) => !search || s.name.toLowerCase().includes(search.toLowerCase()));
|
arr.filter((s) => !search || s.name.toLowerCase().includes(search.toLowerCase()));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ExplainerBanner
|
<ExplainerBanner
|
||||||
title={t("explain.mcp.title")}
|
title={t("explain.mcp.title")}
|
||||||
body={t("explain.mcp.body")}
|
body={t("explain.mcp.body")}
|
||||||
howTo={t("explain.mcp.add")}
|
howTo={t("explain.mcp.add")}
|
||||||
commands={[
|
commands={[{ cmd: t("explain.mcp.listCmd"), note: t("explain.mcp.list") }]}
|
||||||
{ cmd: t("explain.mcp.listCmd"), note: t("explain.mcp.list") },
|
|
||||||
{ cmd: t("explain.mcp.addCmd"), note: t("explain.mcp.add") },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<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 && (
|
{all.length === 0 && (
|
||||||
<div className="rounded-lg border border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
|
<div className="rounded-lg border border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
|
||||||
{t("mcp.noServers")}
|
{t("mcp.noServers")}
|
||||||
@@ -1851,7 +1972,14 @@ function McpPanel({ data, search }: { data: CcMcpResponse | null; search: string
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{filter(data.user).map((s) => (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
@@ -1863,7 +1991,14 @@ function McpPanel({ data, search }: { data: CcMcpResponse | null; search: string
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{filter(data.projectScoped).map((s) => (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
@@ -1872,8 +2007,21 @@ function McpPanel({ data, search }: { data: CcMcpResponse | null; search: string
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function McpCard({ server }: { server: CcMcpServer }) {
|
function McpCard({
|
||||||
const { t } = useTranslation("ccConfig");
|
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 (
|
return (
|
||||||
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3">
|
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@@ -1915,10 +2063,29 @@ function McpCard({ server }: { server: CcMcpServer }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<CommandSnippet
|
<ActionButton
|
||||||
command={`claude mcp remove ${server.name}`}
|
icon={Trash2}
|
||||||
label={t("explain.mcp.remove")}
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+5
-1
@@ -1560,7 +1560,7 @@ curl "http://localhost:4820/api/sessions?sources=local,4d1f0e2a-7b9c-4c33-8a21-9
|
|||||||
|
|
||||||
### Claude Config Explorer
|
### 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
|
```http
|
||||||
GET /api/cc-config/overview
|
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/plugins/action Body: { key, action: enable|disable|update|uninstall }
|
||||||
POST /api/cc-config/marketplaces/add Body: { source, scope? }
|
POST /api/cc-config/marketplaces/add Body: { source, scope? }
|
||||||
POST /api/cc-config/marketplaces/action Body: { name, action: update|remove }
|
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 `"<redacted>"`.
|
`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 `"<redacted>"`.
|
||||||
@@ -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 `<name>` or `<name>@<marketplace>`; `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 }`.
|
**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 `<name>` or `<name>@<marketplace>`; `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 `<CLAUDE_HOME>/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).
|
`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 `<CLAUDE_HOME>/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 `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` inside the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.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.
|
**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 `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` inside the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.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.
|
||||||
|
|||||||
+117
@@ -2094,6 +2094,21 @@ components:
|
|||||||
description: MCP servers scoped to the current project root in ~/.claude.json.
|
description: MCP servers scoped to the current project root in ~/.claude.json.
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/CcConfigMcpServer'
|
$ref: '#/components/schemas/CcConfigMcpServer'
|
||||||
|
CcConfigCliResult:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- ok
|
||||||
|
- stdout
|
||||||
|
- stderr
|
||||||
|
properties:
|
||||||
|
ok:
|
||||||
|
type: boolean
|
||||||
|
enum:
|
||||||
|
- true
|
||||||
|
stdout:
|
||||||
|
type: string
|
||||||
|
stderr:
|
||||||
|
type: string
|
||||||
CcConfigHookEntry:
|
CcConfigHookEntry:
|
||||||
type: object
|
type: object
|
||||||
description: A single flattened hook command bound to a matcher.
|
description: A single flattened hook command bound to a matcher.
|
||||||
@@ -7996,6 +8011,108 @@ paths:
|
|||||||
url: https://mcp.example.com/sse
|
url: https://mcp.example.com/sse
|
||||||
headers:
|
headers:
|
||||||
- Authorization
|
- 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:
|
/api/cc-config/hooks:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
@@ -1112,4 +1112,98 @@ describe("/api/cc-config", () => {
|
|||||||
process.env.PATH = originalPath;
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Binary file not shown.
@@ -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 ────────────────────────────────────────────────────────────
|
// ── Hooks ────────────────────────────────────────────────────────────
|
||||||
CcConfigHookEntry: {
|
CcConfigHookEntry: {
|
||||||
type: "object",
|
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": {
|
"/api/cc-config/hooks": {
|
||||||
get: {
|
get: {
|
||||||
tags: ["CcConfig"],
|
tags: ["CcConfig"],
|
||||||
|
|||||||
@@ -10,9 +10,11 @@
|
|||||||
* artifacts (skills, agents, commands, output styles, memory, per-project
|
* artifacts (skills, agents, commands, output styles, memory, per-project
|
||||||
* auto-memory), the structured keybindings.json editor, settings.json layers
|
* auto-memory), the structured keybindings.json editor, settings.json layers
|
||||||
* via a per-key merge (`PATCH /settings`, which is also how hooks are edited),
|
* via a per-key merge (`PATCH /settings`, which is also how hooks are edited),
|
||||||
* and plugin/marketplace management delegated to the `claude plugin …` CLI.
|
* plugin/marketplace management delegated to the `claude plugin …` CLI, and
|
||||||
* Every file write is preceded by a timestamped backup. MCP server config
|
* MCP server add/remove delegated to the `claude mcp …` CLI. Every file
|
||||||
* stays read-only — it is written concurrently by the running CLI.
|
* 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
|
* Also hosts the auth-profile routes (`/profiles*`) that swap which Anthropic
|
||||||
* account the local CLI runs as; those carry identity metadata only, never
|
* 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 cc = require("../lib/cc-discovery");
|
||||||
const ccMutate = require("../lib/cc-mutate");
|
const ccMutate = require("../lib/cc-mutate");
|
||||||
const ccCli = require("../lib/cc-plugin-cli");
|
const ccCli = require("../lib/cc-plugin-cli");
|
||||||
|
const ccMcpCli = require("../lib/cc-mcp-cli");
|
||||||
const ccProfiles = require("../lib/cc-profiles");
|
const ccProfiles = require("../lib/cc-profiles");
|
||||||
const { broadcast } = require("../websocket");
|
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
|
// PATCH /api/cc-config/settings — merge individual keys into one settings
|
||||||
// layer. Body: { scope: "user"|"project"|"project-local", set?: {dotted: value},
|
// layer. Body: { scope: "user"|"project"|"project-local", set?: {dotted: value},
|
||||||
// unset?: ["dotted"] }. Hooks are edited through this same route as
|
// unset?: ["dotted"] }. Hooks are edited through this same route as
|
||||||
|
|||||||
Reference in New Issue
Block a user