First Commit
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
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
This commit is contained in:
@@ -0,0 +1,468 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user