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
469 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|