Files
artex/web/src/app/(main)/system/mcp/page.tsx
T
dela 0335d572de
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
First Commit
2026-10-09 08:38:16 +08:00

469 lines
16 KiB
TypeScript

"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<MCPServer[]>([]);
const [agents, setAgents] = React.useState<Agent[]>([]);
const [visibility, setVisibility] = React.useState<Record<number, string[]>>({});
const [open, setOpen] = React.useState(false);
const [editing, setEditing] = React.useState<MCPServer | null>(null); // null = add mode
const [tab, setTab] = React.useState<"config" | "tools">("config");
const [form, setForm] = React.useState<FormState>(emptyForm);
const [saving, setSaving] = React.useState(false);
const [tools, setTools] = React.useState<MCPTool[]>([]);
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<FormState>) {
setForm((f) => ({ ...f, ...patch }));
}
function parseEnv(text: string): Record<string, string> {
const out: Record<string, string> = {};
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<string, string> | 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 (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>{t("transport")}</Label>
<div className="flex gap-2">
<Button
type="button"
variant={form.transport === "stdio" ? "default" : "outline"}
onClick={() => setF({ transport: "stdio" })}
>
{t("transportStdio")}
</Button>
<Button
type="button"
variant={form.transport === "http" ? "default" : "outline"}
onClick={() => setF({ transport: "http" })}
>
{t("transportHttp")}
</Button>
<Button
type="button"
variant={form.transport === "sse" ? "default" : "outline"}
onClick={() => setF({ transport: "sse" })}
>
{t("transportSse")}
</Button>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="m-name">{t("nameLabel")}</Label>
<Input
id="m-name"
placeholder="filesystem"
value={form.name}
onChange={(e) => setF({ name: e.target.value })}
/>
</div>
{form.transport === "stdio" ? (
<>
<div className="grid gap-2">
<Label htmlFor="m-cmd">{t("cmdLabel")}</Label>
<Input
id="m-cmd"
className="font-mono"
placeholder="npx"
value={form.command}
onChange={(e) => setF({ command: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="m-args">{t("argsLabel")}</Label>
<Input
id="m-args"
className="font-mono"
placeholder="-y @modelcontextprotocol/server-filesystem /data"
value={form.args}
onChange={(e) => setF({ args: e.target.value })}
/>
</div>
</>
) : (
<div className="grid gap-2">
<Label htmlFor="m-url">{t("urlLabel")}</Label>
<Input
id="m-url"
className="font-mono"
placeholder="https://mcp.example.com/mcp"
value={form.url}
onChange={(e) => setF({ url: e.target.value })}
/>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={form.insecure} onCheckedChange={(v) => setF({ insecure: v === true })} />
{t("insecureLabel")}
</label>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="m-env">{form.transport !== "stdio" ? t("headersLabel") : t("envLabel")}</Label>
<Textarea
id="m-env"
className="font-mono"
placeholder={form.transport !== "stdio" ? "Authorization=Bearer xxxx" : "API_KEY=xxxx\nFOO=bar"}
value={form.env}
onChange={(e) => setF({ env: e.target.value })}
/>
</div>
</div>
);
}
function renderTools() {
return (
<div className="flex flex-col gap-3 py-4">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">{t("toolCount", { count: tools.length })}</span>
<Button size="sm" variant="outline" disabled={refreshing} onClick={refreshTools}>
<RefreshCwIcon className={refreshing ? "animate-spin" : ""} /> {t("refresh")}
</Button>
</div>
{toolsLoading ? (
<p className="text-muted-foreground text-sm">{t("loading")}</p>
) : tools.length === 0 ? (
<p className="text-muted-foreground text-sm">{t("toolsEmpty")}</p>
) : (
<div className="flex flex-col divide-y">
{tools.map((t) => (
<div key={t.name} className="py-2.5">
<code className="font-mono text-sm">{t.name}</code>
{t.description && (
<p className="text-muted-foreground mt-0.5 text-xs leading-relaxed">{t.description}</p>
)}
</div>
))}
</div>
)}
</div>
);
}
return (
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div>
<h1 className="text-xl font-semibold tracking-tight">MCP</h1>
<p className="text-muted-foreground text-sm">{t("subtitle")}</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<button
type="button"
onClick={openAdd}
className="text-foreground/70 border-foreground/70 hover:bg-muted/60 hover:shadow-sm flex min-h-[116px] flex-col items-center justify-center gap-2 rounded-xl border border-dashed transition"
>
<PlusIcon className="size-6" />
<span className="text-sm">{t("addCard")}</span>
</button>
{servers.map((s) => (
<Card
key={s.id}
onClick={() => openEdit(s)}
className="hover:border-primary/60 cursor-pointer gap-3 transition hover:shadow-sm"
>
<CardHeader>
<div className="flex items-center gap-2">
<ServerIcon className="text-muted-foreground size-4 shrink-0" />
<CardTitle className="truncate text-base">{s.name}</CardTitle>
<Badge variant="outline" className="uppercase">
{s.transport}
</Badge>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 부모 카드로의 클릭 전파만 차단하는 컨테이너다. 이 div 자체는 상호작용 요소가 아니고, 안쪽 Switch·삭제 버튼은 각각 키보드로 접근된다 */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: 위와 같이 클릭 전파 차단 전용이라 별도 키보드 핸들러가 필요 없다 */}
<div className="ml-auto flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<Switch checked={s.enabled} onCheckedChange={() => toggleEnabled(s)} aria-label={t("enableAria")} />
<Button size="icon" variant="outline" aria-label={t("deleteAria")} onClick={() => removeServer(s)}>
<Trash2Icon className="text-destructive" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="grid gap-3">
<p className="text-muted-foreground text-sm">
{s.tools && s.tools.length > 0 ? t("toolCount", { count: s.tools.length }) : t("noTools")}
</p>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 체크박스 영역의 클릭 전파만 차단하는 컨테이너다. 이 div 자체는 상호작용 요소가 아니고, 안쪽 체크박스는 각각 키보드로 접근된다 */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: 위와 같이 클릭 전파 차단 전용이라 별도 키보드 핸들러가 필요 없다 */}
<div className="grid gap-2" onClick={(e) => e.stopPropagation()}>
<span className="text-muted-foreground text-xs">{t("visibilityLabel")}</span>
<div className="flex flex-wrap gap-x-4 gap-y-2">
{agents.map((a) => (
<label key={a.key} className="flex items-center gap-2 text-sm">
<Checkbox
checked={(visibility[s.id] ?? []).includes(a.id)}
onCheckedChange={() => toggleVisibility(s.id, a.id, a.name)}
/>
{a.name}
</label>
))}
</div>
</div>
</CardContent>
</Card>
))}
</div>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" className="w-full data-[side=right]:sm:max-w-lg">
<SheetHeader>
<SheetTitle>{editing ? editing.name : t("addServer")}</SheetTitle>
<SheetDescription>{t("sheetDesc")}</SheetDescription>
</SheetHeader>
{editing ? (
<Tabs
value={tab}
onValueChange={(v) => setTab(v as "config" | "tools")}
className="flex min-h-0 flex-1 flex-col px-4"
>
<TabsList>
<TabsTrigger value="config">{t("tabConfig")}</TabsTrigger>
<TabsTrigger value="tools">
{t("tabTools")}
{tools.length ? ` (${tools.length})` : ""}
</TabsTrigger>
</TabsList>
<TabsContent value="config" className="min-h-0 flex-1 overflow-y-auto">
{renderForm()}
<div className="flex gap-2 pt-2 pb-6">
<Button onClick={saveForm} disabled={saving}>
{t("save")}
</Button>
</div>
</TabsContent>
<TabsContent value="tools" className="min-h-0 flex-1 overflow-y-auto">
{renderTools()}
</TabsContent>
</Tabs>
) : (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
{renderForm()}
<div className="pt-2 pb-6">
<Button onClick={saveForm} disabled={saving}>
<PlusIcon /> {t("add")}
</Button>
</div>
</div>
)}
</SheetContent>
</Sheet>
</div>
);
}