2025-10-08 22:35:07 +08:00
|
|
|
|
import React, { useEffect, useMemo, useState } from "react";
|
|
|
|
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
|
|
import { X, Plus, Save, Trash2, Wrench } from "lucide-react";
|
|
|
|
|
|
import { McpServer, McpStatus } from "../../types";
|
|
|
|
|
|
|
|
|
|
|
|
interface McpPanelProps {
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
onNotify?: (message: string, type: "success" | "error", duration?: number) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const emptyServer: McpServer & { id?: string } = {
|
|
|
|
|
|
type: "stdio",
|
|
|
|
|
|
command: "",
|
|
|
|
|
|
args: [],
|
|
|
|
|
|
env: {},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const parseEnvText = (text: string): Record<string, string> => {
|
|
|
|
|
|
const lines = text
|
|
|
|
|
|
.split("\n")
|
|
|
|
|
|
.map((l) => l.trim())
|
|
|
|
|
|
.filter((l) => l.length > 0);
|
|
|
|
|
|
const env: Record<string, string> = {};
|
|
|
|
|
|
for (const l of lines) {
|
|
|
|
|
|
const idx = l.indexOf("=");
|
|
|
|
|
|
if (idx > 0) {
|
|
|
|
|
|
const k = l.slice(0, idx).trim();
|
|
|
|
|
|
const v = l.slice(idx + 1).trim();
|
|
|
|
|
|
if (k) env[k] = v;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return env;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const formatEnvText = (env?: Record<string, string>): string => {
|
|
|
|
|
|
if (!env) return "";
|
|
|
|
|
|
return Object.entries(env)
|
|
|
|
|
|
.map(([k, v]) => `${k}=${v}`)
|
|
|
|
|
|
.join("\n");
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const McpPanel: React.FC<McpPanelProps> = ({ onClose, onNotify }) => {
|
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
|
const [status, setStatus] = useState<McpStatus | null>(null);
|
|
|
|
|
|
const [servers, setServers] = useState<Record<string, McpServer>>({});
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
|
|
|
|
const [form, setForm] = useState<McpServer & { id?: string }>(emptyServer);
|
|
|
|
|
|
const [formEnvText, setFormEnvText] = useState<string>("");
|
|
|
|
|
|
const [formArgsText, setFormArgsText] = useState<string>("");
|
|
|
|
|
|
|
|
|
|
|
|
const reload = async () => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const s = await window.api.getClaudeMcpStatus();
|
|
|
|
|
|
setStatus(s);
|
|
|
|
|
|
const text = await window.api.readClaudeMcpConfig();
|
|
|
|
|
|
if (text) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const obj = JSON.parse(text);
|
|
|
|
|
|
const list = (obj?.mcpServers || {}) as Record<string, McpServer>;
|
|
|
|
|
|
setServers(list);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error("Failed to parse mcp.json", e);
|
|
|
|
|
|
setServers({});
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setServers({});
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
reload();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2025-10-08 23:22:19 +08:00
|
|
|
|
// 用户级 MCP:不需要项目级启用开关
|
2025-10-08 22:35:07 +08:00
|
|
|
|
|
|
|
|
|
|
const resetForm = () => {
|
|
|
|
|
|
setEditingId(null);
|
|
|
|
|
|
setForm(emptyServer);
|
|
|
|
|
|
setFormArgsText("");
|
|
|
|
|
|
setFormEnvText("");
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const beginEdit = (id?: string) => {
|
|
|
|
|
|
if (!id) {
|
|
|
|
|
|
resetForm();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const spec = servers[id];
|
|
|
|
|
|
setEditingId(id);
|
|
|
|
|
|
setForm({ id, ...spec });
|
|
|
|
|
|
setFormArgsText((spec.args || []).join(" "));
|
|
|
|
|
|
setFormEnvText(formatEnvText(spec.env));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const submitForm = async () => {
|
|
|
|
|
|
if (!form.id || !form.id.trim()) {
|
|
|
|
|
|
onNotify?.(t("mcp.error.idRequired"), "error", 3000);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!form.command || !form.command.trim()) {
|
|
|
|
|
|
onNotify?.(t("mcp.error.commandRequired"), "error", 3000);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setSaving(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const spec: McpServer = {
|
|
|
|
|
|
type: form.type,
|
|
|
|
|
|
command: form.command.trim(),
|
|
|
|
|
|
args: formArgsText
|
|
|
|
|
|
.split(/\s+/)
|
|
|
|
|
|
.map((s) => s.trim())
|
|
|
|
|
|
.filter((s) => s.length > 0),
|
|
|
|
|
|
env: parseEnvText(formEnvText),
|
|
|
|
|
|
...(form.cwd ? { cwd: form.cwd } : {}),
|
|
|
|
|
|
};
|
|
|
|
|
|
await window.api.upsertClaudeMcpServer(form.id.trim(), spec);
|
|
|
|
|
|
await reload();
|
|
|
|
|
|
resetForm();
|
|
|
|
|
|
onNotify?.(t("mcp.msg.saved"), "success", 1500);
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
onNotify?.(e?.message || t("mcp.error.saveFailed"), "error", 6000);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSaving(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const removeServer = async (id: string) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await window.api.deleteClaudeMcpServer(id);
|
|
|
|
|
|
await reload();
|
|
|
|
|
|
if (editingId === id) resetForm();
|
|
|
|
|
|
onNotify?.(t("mcp.msg.deleted"), "success", 1500);
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
onNotify?.(e?.message || t("mcp.error.deleteFailed"), "error", 5000);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const addTemplateFetch = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await window.api.upsertClaudeMcpServer("mcp-fetch", {
|
|
|
|
|
|
type: "stdio",
|
|
|
|
|
|
command: "uvx",
|
|
|
|
|
|
args: ["mcp-server-fetch"],
|
|
|
|
|
|
});
|
|
|
|
|
|
await reload();
|
|
|
|
|
|
onNotify?.(t("mcp.msg.templateAdded"), "success", 1500);
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
onNotify?.(e?.message || t("mcp.error.saveFailed"), "error", 5000);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const validateCommand = async () => {
|
|
|
|
|
|
if (!form.command) return;
|
|
|
|
|
|
const ok = await window.api.validateMcpCommand(form.command.trim());
|
|
|
|
|
|
onNotify?.(
|
|
|
|
|
|
ok ? t("mcp.validation.ok") : t("mcp.validation.fail"),
|
|
|
|
|
|
ok ? "success" : "error",
|
|
|
|
|
|
1500,
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const serverEntries = useMemo(() => Object.entries(servers), [servers]);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
|
|
|
|
{/* Backdrop */}
|
|
|
|
|
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
|
|
|
|
|
|
|
|
|
|
|
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-lg max-w-4xl w-full mx-4 overflow-hidden">
|
|
|
|
|
|
{/* Header */}
|
|
|
|
|
|
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
|
|
|
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
|
|
|
|
{t("mcp.title")}
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
|
className="p-1 text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
<X size={18} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Content */}
|
|
|
|
|
|
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
|
|
|
|
{/* Left: status & list */}
|
|
|
|
|
|
<div>
|
2025-10-08 23:22:19 +08:00
|
|
|
|
<div className="mb-4">
|
|
|
|
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
|
|
|
|
{t("mcp.userLevelPath")}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-xs text-gray-400 dark:text-gray-500 break-all">
|
|
|
|
|
|
{status?.userConfigPath}
|
2025-10-08 22:35:07 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-3 mb-3">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => beginEdit(undefined)}
|
|
|
|
|
|
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md bg-blue-500 text-white hover:bg-blue-600"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Plus size={16} /> {t("mcp.add")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={addTemplateFetch}
|
|
|
|
|
|
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md bg-emerald-500 text-white hover:bg-emerald-600"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Wrench size={16} /> {t("mcp.template.fetch")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="border border-gray-200 dark:border-gray-800 rounded-lg overflow-hidden">
|
|
|
|
|
|
<div className="px-3 py-2 text-xs text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-800 flex items-center justify-between">
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{t("mcp.serverList")} ({status?.serverCount || 0})
|
|
|
|
|
|
</span>
|
2025-10-08 23:22:19 +08:00
|
|
|
|
<span className="text-gray-400">{status?.userConfigPath}</span>
|
2025-10-08 22:35:07 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="max-h-64 overflow-auto divide-y divide-gray-200 dark:divide-gray-800">
|
|
|
|
|
|
{loading && (
|
|
|
|
|
|
<div className="p-4 text-sm text-gray-500">{t("mcp.loading")}</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{!loading && serverEntries.length === 0 && (
|
|
|
|
|
|
<div className="p-4 text-sm text-gray-500">{t("mcp.empty")}</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{!loading &&
|
|
|
|
|
|
serverEntries.map(([id, spec]) => (
|
|
|
|
|
|
<div key={id} className="p-3 flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
|
|
|
|
{id}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
|
|
|
|
{spec.type} · {spec.command} {spec.args?.join(" ")}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => beginEdit(id)}
|
|
|
|
|
|
className="px-2 py-1 text-xs rounded-md bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
|
|
|
|
|
>
|
|
|
|
|
|
{t("common.edit")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => removeServer(id)}
|
|
|
|
|
|
className="px-2 py-1 text-xs rounded-md bg-red-500 text-white hover:bg-red-600 flex items-center gap-1"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 size={14} /> {t("common.delete")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Right: form */}
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
|
|
|
|
|
{editingId ? t("mcp.editServer") : t("mcp.addServer")}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-xs text-gray-500 mb-1">
|
|
|
|
|
|
{t("mcp.id")}
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="w-full px-3 py-2 rounded-md bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 outline-none focus:ring-2 focus:ring-blue-500"
|
|
|
|
|
|
placeholder="my-mcp"
|
|
|
|
|
|
value={form.id || ""}
|
|
|
|
|
|
onChange={(e) => setForm((s) => ({ ...s, id: e.target.value }))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-xs text-gray-500 mb-1">
|
|
|
|
|
|
{t("mcp.type")}
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
className="w-full px-3 py-2 rounded-md bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800"
|
|
|
|
|
|
value={form.type}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setForm((s) => ({ ...s, type: e.target.value as any }))
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="stdio">stdio</option>
|
|
|
|
|
|
<option value="sse">sse</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-xs text-gray-500 mb-1">
|
|
|
|
|
|
{t("mcp.cwd")}
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="w-full px-3 py-2 rounded-md bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800"
|
|
|
|
|
|
placeholder="/path/to/project"
|
|
|
|
|
|
value={form.cwd || ""}
|
|
|
|
|
|
onChange={(e) => setForm((s) => ({ ...s, cwd: e.target.value }))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-xs text-gray-500 mb-1">
|
|
|
|
|
|
{t("mcp.command")}
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="flex-1 px-3 py-2 rounded-md bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800"
|
|
|
|
|
|
placeholder="uvx"
|
|
|
|
|
|
value={form.command}
|
|
|
|
|
|
onChange={(e) => setForm((s) => ({ ...s, command: e.target.value }))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={validateCommand}
|
|
|
|
|
|
className="px-3 py-2 rounded-md bg-emerald-500 text-white hover:bg-emerald-600 text-sm inline-flex items-center gap-1"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Wrench size={16} /> {t("mcp.validateCommand")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-xs text-gray-500 mb-1">{t("mcp.args")}</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
className="w-full px-3 py-2 rounded-md bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800"
|
|
|
|
|
|
placeholder={t("mcp.argsPlaceholder")}
|
|
|
|
|
|
value={formArgsText}
|
|
|
|
|
|
onChange={(e) => setFormArgsText(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="block text-xs text-gray-500 mb-1">{t("mcp.env")}</label>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
className="w-full px-3 py-2 rounded-md bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 h-24"
|
|
|
|
|
|
placeholder={t("mcp.envPlaceholder")}
|
|
|
|
|
|
value={formEnvText}
|
|
|
|
|
|
onChange={(e) => setFormEnvText(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={submitForm}
|
|
|
|
|
|
disabled={saving}
|
|
|
|
|
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-60"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Save size={16} /> {editingId ? t("common.save") : t("common.add")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={resetForm}
|
|
|
|
|
|
className="px-4 py-2 text-sm rounded-md bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
|
|
|
|
|
>
|
|
|
|
|
|
{t("mcp.reset")}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default McpPanel;
|