"use client"; import * as React from "react"; import { PlusIcon, RefreshCwIcon, ServerIcon, Trash2Icon } from "lucide-react"; import { useTranslations } from "next-intl"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { api } from "@/lib/api"; import type { Agent, MCPServer, MCPTool } from "@/lib/types"; type Transport = "stdio" | "http" | "sse"; type FormState = { name: string; transport: Transport; command: string; args: string; url: string; env: string; insecure: boolean; }; const emptyForm: FormState = { name: "", transport: "stdio", command: "", args: "", url: "", env: "", insecure: false, }; export default function MCPPage() { const t = useTranslations("mcpPage"); const [servers, setServers] = React.useState([]); const [agents, setAgents] = React.useState([]); const [visibility, setVisibility] = React.useState>({}); const [open, setOpen] = React.useState(false); const [editing, setEditing] = React.useState(null); // null = add mode const [tab, setTab] = React.useState<"config" | "tools">("config"); const [form, setForm] = React.useState(emptyForm); const [saving, setSaving] = React.useState(false); const [tools, setTools] = React.useState([]); const [toolsLoading, setToolsLoading] = React.useState(false); const [refreshing, setRefreshing] = React.useState(false); const load = React.useCallback(() => { api .agents() .then(setAgents) .catch(() => { /* 조회 실패는 무시하고 기본 상태를 유지한다 */ }); api .mcpServers() .then((ss) => { setServers(ss); ss.forEach((s) => { void api .resourceVisibility("mcp", s.id) .then((ids) => setVisibility((v) => ({ ...v, [s.id]: ids }))) .catch(() => { /* 가시성 조회 실패는 무시한다 */ }); }); }) .catch(() => { /* 조회 실패는 무시하고 기본 상태를 유지한다 */ }); }, []); React.useEffect(() => { load(); }, [load]); function setF(patch: Partial) { setForm((f) => ({ ...f, ...patch })); } function parseEnv(text: string): Record { const out: Record = {}; for (const line of text.split("\n")) { const trimmed = line.trim(); if (!trimmed) continue; const idx = trimmed.indexOf("="); if (idx > 0) { const key = trimmed.slice(0, idx).trim(); if (key) out[key] = trimmed.slice(idx + 1).trim(); } } return out; } function envToText(env: Record | undefined): string { return Object.entries(env ?? {}) .map(([k, v]) => `${k}=${v}`) .join("\n"); } function openAdd() { setEditing(null); setForm(emptyForm); setTools([]); setTab("config"); setOpen(true); } function openEdit(s: MCPServer) { setEditing(s); setForm({ name: s.name, transport: s.transport, command: s.command ?? "", args: (s.args ?? []).join(" "), url: s.url ?? "", env: envToText(s.env), insecure: s.insecure ?? false, }); setTab("config"); setOpen(true); void loadTools(s.id); } async function loadTools(id: number) { setToolsLoading(true); try { setTools(await api.mcpTools(id)); } catch { setTools([]); } finally { setToolsLoading(false); } } async function saveForm() { if (!form.name.trim()) { toast.error(t("errNoName")); return; } if (form.transport === "stdio" && !form.command.trim()) { toast.error(t("errNoCmd")); return; } if (form.transport !== "stdio" && !form.url.trim()) { toast.error(t("errNoUrl")); return; } setSaving(true); try { const base = form.transport !== "stdio" ? { transport: form.transport, url: form.url.trim(), command: "", args: [] as string[], env: parseEnv(form.env), // 远程模式下 env 即请求头 insecure: form.insecure, } : { transport: "stdio" as const, command: form.command.trim(), args: form.args.trim() ? form.args.trim().split(/\s+/) : [], env: parseEnv(form.env), insecure: false, }; await api.saveMcpServer({ ...(editing ? { id: editing.id } : {}), name: form.name.trim(), enabled: editing ? editing.enabled : true, ...base, }); toast.success(editing ? t("saved") : t("added")); if (!editing) setOpen(false); load(); } catch (e) { toast.error(t("saveFailed", { msg: (e as Error).message })); } finally { setSaving(false); } } async function refreshTools() { if (!editing) return; setRefreshing(true); try { const found = await api.refreshMcpServer(editing.id); setTools(found); toast.success(t("foundTools", { count: found.length })); load(); } catch (e) { toast.error(t("refreshFailed", { msg: (e as Error).message })); } finally { setRefreshing(false); } } async function removeServer(s: MCPServer) { try { await api.deleteMcpServer(s.id); toast.success(t("removed", { name: s.name })); setOpen(false); load(); } catch (e) { toast.error(t("deleteFailed", { msg: (e as Error).message })); } } async function toggleEnabled(s: MCPServer) { try { await api.saveMcpServer({ ...s, enabled: !s.enabled }); load(); } catch (e) { toast.error(t("actionFailed", { msg: (e as Error).message })); } } async function toggleVisibility(serverId: number, agentId: string, agentName: string) { const on = (visibility[serverId] ?? []).includes(agentId); try { await api.toggleVisibility(agentId, "mcp", serverId, !on); toast.success(on ? t("visibilityRevoked", { name: agentName }) : t("visibilityGranted", { name: agentName })); load(); } catch (e) { toast.error(t("actionFailed", { msg: (e as Error).message })); } } function renderForm() { return (
setF({ name: e.target.value })} />
{form.transport === "stdio" ? ( <>
setF({ command: e.target.value })} />
setF({ args: e.target.value })} />
) : (
setF({ url: e.target.value })} />
)}