feat(mcp): enhance MCP transport support and add server management UI

- Extend McpTransport to support "sse" in schemas.
- Refactor McpToolClient to handle both "streamable_http" and "sse" transports.
- Introduce McpServerDialog for managing MCP server configurations, including transport settings and tool synchronization.
- Replace McpServersSection with the new dialog component for improved server management.
- Add tests for MCP transport handling and server dialog functionality.
This commit is contained in:
Xin Wang
2026-07-19 12:04:11 +08:00
parent c54dac403b
commit f027ed99b7
8 changed files with 887 additions and 784 deletions

View File

@@ -22,7 +22,7 @@ KnowledgeRetrievalMode = Literal["automatic", "on_demand"]
ToolType = Literal["end_call", "http", "mcp"] ToolType = Literal["end_call", "http", "mcp"]
ToolStatus = Literal["active", "archived", "draft"] ToolStatus = Literal["active", "archived", "draft"]
McpServerStatus = Literal["active", "archived", "draft"] McpServerStatus = Literal["active", "archived", "draft"]
McpTransport = Literal["streamable_http"] McpTransport = Literal["streamable_http", "sse"]
ToolParameterType = Literal["string", "number", "integer", "boolean", "object", "array"] ToolParameterType = Literal["string", "number", "integer", "boolean", "object", "array"]
ToolParameterLocation = Literal["path", "query", "body", "header"] ToolParameterLocation = Literal["path", "query", "body", "header"]
DynamicVariableType = Literal["string", "number", "boolean"] DynamicVariableType = Literal["string", "number", "boolean"]

View File

@@ -1,4 +1,4 @@
"""One-shot Streamable HTTP MCP discovery and execution. """One-shot HTTP MCP discovery and execution.
Connections intentionally live for one operation in the MVP. This keeps the Connections intentionally live for one operation in the MVP. This keeps the
async context ownership simple and correct; a session pool can be introduced async context ownership simple and correct; a session pool can be introduced
@@ -15,6 +15,7 @@ from typing import Any, AsyncIterator
import httpx import httpx
from mcp import ClientSession from mcp import ClientSession
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamable_http_client from mcp.client.streamable_http import streamable_http_client
from models import RuntimeMcpServer from models import RuntimeMcpServer
@@ -41,16 +42,14 @@ class McpToolClient:
"""Minimal public MCP SDK wrapper shared by discovery and runtime calls.""" """Minimal public MCP SDK wrapper shared by discovery and runtime calls."""
@asynccontextmanager @asynccontextmanager
async def _session( async def _transport_streams(
self, self,
server: RuntimeMcpServer, server: RuntimeMcpServer,
) -> AsyncIterator[ClientSession]: headers: dict[str, str],
if server.transport != "streamable_http": timeout: int,
raise McpClientError(f"暂不支持 MCP transport: {server.transport}") ) -> AsyncIterator[tuple[Any, Any]]:
"""Open the configured MCP transport and expose its read/write streams."""
headers = {**server.headers, **server.secret_headers} if server.transport == "streamable_http":
timeout = max(1, min(int(server.timeout_seconds), 120))
try:
async with httpx.AsyncClient( async with httpx.AsyncClient(
headers=headers, headers=headers,
timeout=httpx.Timeout(timeout), timeout=httpx.Timeout(timeout),
@@ -61,13 +60,38 @@ class McpToolClient:
http_client=http_client, http_client=http_client,
) as streams: ) as streams:
read_stream, write_stream, _ = streams read_stream, write_stream, _ = streams
async with ClientSession( yield read_stream, write_stream
read_stream, return
write_stream,
read_timeout_seconds=timedelta(seconds=timeout), if server.transport == "sse":
) as session: async with sse_client(
await session.initialize() server.url,
yield session headers=headers,
timeout=timeout,
sse_read_timeout=timeout,
) as streams:
yield streams
return
raise McpClientError(f"暂不支持 MCP transport: {server.transport}")
@asynccontextmanager
async def _session(
self,
server: RuntimeMcpServer,
) -> AsyncIterator[ClientSession]:
headers = {**server.headers, **server.secret_headers}
timeout = max(1, min(int(server.timeout_seconds), 120))
try:
async with self._transport_streams(server, headers, timeout) as streams:
read_stream, write_stream = streams
async with ClientSession(
read_stream,
write_stream,
read_timeout_seconds=timedelta(seconds=timeout),
) as session:
await session.initialize()
yield session
except McpClientError: except McpClientError:
raise raise
except httpx.TimeoutException as exc: except httpx.TimeoutException as exc:

View File

@@ -0,0 +1,51 @@
import unittest
from contextlib import asynccontextmanager
from unittest.mock import patch
from models import RuntimeMcpServer
from schemas import McpServerUpsert
from services.tools.mcp_client import McpToolClient
class McpTransportTests(unittest.IsolatedAsyncioTestCase):
def test_server_schema_accepts_sse(self):
server = McpServerUpsert(
name="旧版 MCP 服务",
transport="sse",
url="https://mcp.example.com/sse",
)
self.assertEqual(server.transport, "sse")
async def test_sse_transport_uses_sdk_sse_client(self):
captured: dict = {}
@asynccontextmanager
async def fake_sse_client(url, **kwargs):
captured["url"] = url
captured.update(kwargs)
yield "read-stream", "write-stream"
server = RuntimeMcpServer(
id="mcp_sse",
transport="sse",
url="https://mcp.example.com/sse",
)
client = McpToolClient()
with patch("services.tools.mcp_client.sse_client", fake_sse_client):
async with client._transport_streams(
server,
{"Authorization": "Bearer test"},
20,
) as streams:
self.assertEqual(streams, ("read-stream", "write-stream"))
self.assertEqual(captured["url"], server.url)
self.assertEqual(captured["headers"], {"Authorization": "Bearer test"})
self.assertEqual(captured["timeout"], 20)
self.assertEqual(captured["sse_read_timeout"], 20)
if __name__ == "__main__":
unittest.main()

View File

@@ -9,10 +9,17 @@ import {
MoreHorizontal, MoreHorizontal,
Pencil, Pencil,
Plus, Plus,
ServerCog,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { McpServersSection } from "@/components/tools/McpServersSection"; import { McpServerDialog } from "@/components/tools/McpServerDialog";
import {
TOOL_DIALOG_CONTENT_CLASS,
ToolFormField as Field,
ToolFormSection as FieldSection,
ToolJsonField as JsonField,
} from "@/components/tools/tool-form-controls";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DataList, type DataListColumn } from "@/components/ui/data-list"; import { DataList, type DataListColumn } from "@/components/ui/data-list";
@@ -45,18 +52,23 @@ import {
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
mcpServersApi,
toolsApi, toolsApi,
type HttpToolDefinition, type HttpToolDefinition,
type McpServer,
type Tool, type Tool,
type ToolParameter, type ToolParameter,
type ToolStatus, type ToolStatus,
type ToolUpsert, type ToolUpsert,
} from "@/lib/api"; } from "@/lib/api";
type ToolKind = "end_call" | "http" | "mcp"; type ToolKind = "end_call" | "http";
type HttpMethod = HttpToolDefinition["config"]["method"]; type HttpMethod = HttpToolDefinition["config"]["method"];
type ToolFilter = "全部" | "End Call" | "HTTP" | "MCP"; type ToolFilter = "全部" | "End Call" | "HTTP" | "MCP";
type SortOrder = "newest" | "oldest"; type SortOrder = "newest" | "oldest";
type ToolResource =
| { kind: "tool"; id: string; tool: Tool }
| { kind: "mcp"; id: string; server: McpServer };
const toolFilters: readonly ToolFilter[] = ["全部", "End Call", "HTTP", "MCP"]; const toolFilters: readonly ToolFilter[] = ["全部", "End Call", "HTTP", "MCP"];
@@ -78,10 +90,6 @@ type ToolForm = {
body: string; body: string;
dynamicVariableAssignments: string; dynamicVariableAssignments: string;
secretDynamicVariables: string; secretDynamicVariables: string;
mcpServerId: string;
remoteToolName: string;
inputSchema: string;
schemaHash: string;
}; };
const EMPTY_OBJECT = "{}"; const EMPTY_OBJECT = "{}";
@@ -106,10 +114,6 @@ function blankForm(): ToolForm {
body: EMPTY_OBJECT, body: EMPTY_OBJECT,
dynamicVariableAssignments: EMPTY_OBJECT, dynamicVariableAssignments: EMPTY_OBJECT,
secretDynamicVariables: EMPTY_OBJECT, secretDynamicVariables: EMPTY_OBJECT,
mcpServerId: "",
remoteToolName: "",
inputSchema: EMPTY_OBJECT,
schemaHash: "",
}; };
} }
@@ -118,6 +122,9 @@ function pretty(value: unknown, fallback: string): string {
} }
function formFromTool(tool: Tool): ToolForm { function formFromTool(tool: Tool): ToolForm {
if (tool.type === "mcp") {
throw new Error("MCP 服务需要使用 MCP 编辑窗口");
}
const base = { ...blankForm(), name: tool.name, functionName: tool.functionName }; const base = { ...blankForm(), name: tool.name, functionName: tool.functionName };
base.type = tool.type; base.type = tool.type;
base.description = tool.description; base.description = tool.description;
@@ -128,17 +135,7 @@ function formFromTool(tool: Tool): ToolForm {
base.captureReason = tool.definition.config.captureReason; base.captureReason = tool.definition.config.captureReason;
return base; return base;
} }
if (tool.definition.type === "mcp") { if (tool.definition.type === "mcp") return base;
base.mcpServerId = tool.mcpServerId ?? "";
base.remoteToolName = tool.definition.config.remoteToolName;
base.inputSchema = pretty(tool.definition.config.inputSchema, EMPTY_OBJECT);
base.schemaHash = tool.definition.config.schemaHash;
base.dynamicVariableAssignments = pretty(
tool.definition.config.dynamicVariableAssignments ?? {},
EMPTY_OBJECT,
);
return base;
}
base.method = tool.definition.config.method; base.method = tool.definition.config.method;
base.url = tool.definition.config.url; base.url = tool.definition.config.url;
base.timeoutSeconds = String(tool.definition.config.timeoutSeconds); base.timeoutSeconds = String(tool.definition.config.timeoutSeconds);
@@ -218,30 +215,6 @@ function payloadFromForm(form: ToolForm): ToolUpsert {
}, },
}; };
} }
if (form.type === "mcp") {
return {
name: form.name.trim(),
functionName: form.functionName,
description: form.description.trim(),
status: form.status,
mcpServerId: form.mcpServerId,
secrets: {},
definition: {
schemaVersion: 1,
type: "mcp",
config: {
remoteToolName: form.remoteToolName,
inputSchema: parseObject(form.inputSchema, "MCP Input Schema"),
schemaHash: form.schemaHash,
dynamicVariableAssignments: parseObject(
form.dynamicVariableAssignments,
"变量赋值",
) as Record<string, string>,
},
},
};
}
const timeoutSeconds = Number(form.timeoutSeconds); const timeoutSeconds = Number(form.timeoutSeconds);
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 120) { if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 120) {
throw new Error("超时时间必须是 1 到 120 秒之间的整数"); throw new Error("超时时间必须是 1 到 120 秒之间的整数");
@@ -301,6 +274,7 @@ function updatedAtValue(value?: string | null): number {
export function ComponentsToolsPage() { export function ComponentsToolsPage() {
const [tools, setTools] = useState<Tool[]>([]); const [tools, setTools] = useState<Tool[]>([]);
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -309,6 +283,8 @@ export function ComponentsToolsPage() {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Tool | null>(null); const [editing, setEditing] = useState<Tool | null>(null);
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
const [editingMcpServer, setEditingMcpServer] = useState<McpServer | null>(null);
const [form, setForm] = useState<ToolForm>(blankForm); const [form, setForm] = useState<ToolForm>(blankForm);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
@@ -316,13 +292,18 @@ export function ComponentsToolsPage() {
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const [duplicatingId, setDuplicatingId] = useState<string | null>(null); const [duplicatingId, setDuplicatingId] = useState<string | null>(null);
const loadTools = useCallback(async () => { const loadResources = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
setTools(await toolsApi.list()); const [nextTools, nextMcpServers] = await Promise.all([
toolsApi.list(),
mcpServersApi.list(),
]);
setTools(nextTools);
setMcpServers(nextMcpServers);
} catch (loadError) { } catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "加载工具失败"); setError(loadError instanceof Error ? loadError.message : "加载工具资源失败");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -330,37 +311,60 @@ export function ComponentsToolsPage() {
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
void loadTools(); void loadResources();
}, [loadTools]); }, [loadResources]);
const filteredTools = useMemo(() => { const resources = useMemo<ToolResource[]>(() => {
const query = search.trim().toLowerCase(); const nativeTools = tools
return tools.filter((tool) => .filter((tool) => tool.type !== "mcp")
(filter === "全部" || .map((tool) => ({ kind: "tool", id: tool.id, tool }) as const);
(filter === "End Call" && tool.type === "end_call") || const servers = mcpServers.map(
(filter === "HTTP" && tool.type === "http") || (server) => ({ kind: "mcp", id: server.id, server }) as const,
(filter === "MCP" && tool.type === "mcp")) &&
(!query ||
[tool.name, tool.functionName, tool.description].some((value) =>
value.toLowerCase().includes(query),
)),
); );
}, [filter, search, tools]); return [...nativeTools, ...servers];
}, [mcpServers, tools]);
const sortedTools = useMemo(() => { const filteredResources = useMemo(() => {
return [...filteredTools].sort((a, b) => { const query = search.trim().toLowerCase();
const diff = updatedAtValue(b.updatedAt) - updatedAtValue(a.updatedAt); return resources.filter((resource) => {
if (resource.kind === "mcp") {
return (
(filter === "全部" || filter === "MCP") &&
(!query ||
[resource.server.name, resource.server.url, resource.server.description].some(
(value) => value.toLowerCase().includes(query),
))
);
}
const tool = resource.tool;
return (
(filter === "全部" ||
(filter === "End Call" && tool.type === "end_call") ||
(filter === "HTTP" && tool.type === "http")) &&
(!query ||
[tool.name, tool.functionName, tool.description].some((value) =>
value.toLowerCase().includes(query),
))
);
});
}, [filter, resources, search]);
const sortedResources = useMemo(() => {
return [...filteredResources].sort((a, b) => {
const aUpdatedAt = a.kind === "mcp" ? a.server.updatedAt : a.tool.updatedAt;
const bUpdatedAt = b.kind === "mcp" ? b.server.updatedAt : b.tool.updatedAt;
const diff = updatedAtValue(bUpdatedAt) - updatedAtValue(aUpdatedAt);
if (diff !== 0) return sortOrder === "newest" ? diff : -diff; if (diff !== 0) return sortOrder === "newest" ? diff : -diff;
return a.id.localeCompare(b.id); return a.id.localeCompare(b.id);
}); });
}, [filteredTools, sortOrder]); }, [filteredResources, sortOrder]);
const pageSize = 5; const pageSize = 5;
const totalPages = Math.max(1, Math.ceil(sortedTools.length / pageSize)); const totalPages = Math.max(1, Math.ceil(sortedResources.length / pageSize));
const safeCurrentPage = Math.min(currentPage, totalPages); const safeCurrentPage = Math.min(currentPage, totalPages);
const pageStart = (safeCurrentPage - 1) * pageSize; const pageStart = (safeCurrentPage - 1) * pageSize;
const pageEnd = pageStart + pageSize; const pageEnd = pageStart + pageSize;
const paginatedTools = sortedTools.slice(pageStart, pageEnd); const paginatedResources = sortedResources.slice(pageStart, pageEnd);
function changeFilter(value: ToolFilter) { function changeFilter(value: ToolFilter) {
setFilter(value); setFilter(value);
@@ -380,6 +384,12 @@ export function ComponentsToolsPage() {
setDialogOpen(true); setDialogOpen(true);
} }
function openCreateMcpServer() {
setDialogOpen(false);
setEditingMcpServer(null);
setMcpDialogOpen(true);
}
function openEdit(tool: Tool) { function openEdit(tool: Tool) {
setEditing(tool); setEditing(tool);
setForm(formFromTool(tool)); setForm(formFromTool(tool));
@@ -388,6 +398,11 @@ export function ComponentsToolsPage() {
setDialogOpen(true); setDialogOpen(true);
} }
function openEditMcpServer(server: McpServer) {
setEditingMcpServer(server);
setMcpDialogOpen(true);
}
async function saveTool() { async function saveTool() {
if (saving) return; if (saving) return;
setSaving(true); setSaving(true);
@@ -397,7 +412,7 @@ export function ComponentsToolsPage() {
if (editing) await toolsApi.update(editing.id, payload); if (editing) await toolsApi.update(editing.id, payload);
else await toolsApi.create(payload); else await toolsApi.create(payload);
setDialogOpen(false); setDialogOpen(false);
await loadTools(); await loadResources();
} catch (saveError) { } catch (saveError) {
setFormError(saveError instanceof Error ? saveError.message : "保存失败"); setFormError(saveError instanceof Error ? saveError.message : "保存失败");
} finally { } finally {
@@ -410,7 +425,20 @@ export function ComponentsToolsPage() {
setDeletingId(tool.id); setDeletingId(tool.id);
try { try {
await toolsApi.remove(tool.id); await toolsApi.remove(tool.id);
await loadTools(); await loadResources();
} catch (removeError) {
setError(removeError instanceof Error ? removeError.message : "删除失败");
} finally {
setDeletingId(null);
}
}
async function removeMcpServer(server: McpServer) {
if (!window.confirm(`确认删除 MCP 工具资源“${server.name}”及其同步工具?`)) return;
setDeletingId(server.id);
try {
await mcpServersApi.remove(server.id);
await loadResources();
} catch (removeError) { } catch (removeError) {
setError(removeError instanceof Error ? removeError.message : "删除失败"); setError(removeError instanceof Error ? removeError.message : "删除失败");
} finally { } finally {
@@ -422,7 +450,7 @@ export function ComponentsToolsPage() {
setDuplicatingId(id); setDuplicatingId(id);
try { try {
await toolsApi.duplicate(id); await toolsApi.duplicate(id);
await loadTools(); await loadResources();
} catch (duplicateError) { } catch (duplicateError) {
setError(duplicateError instanceof Error ? duplicateError.message : "复制失败"); setError(duplicateError instanceof Error ? duplicateError.message : "复制失败");
} finally { } finally {
@@ -430,18 +458,22 @@ export function ComponentsToolsPage() {
} }
} }
const columns: DataListColumn<Tool>[] = [ const columns: DataListColumn<ToolResource>[] = [
{ {
key: "name", key: "name",
header: "工具名称", header: "工具名称",
width: "md:w-[360px]", width: "md:w-[360px]",
cell: (tool) => ( cell: (resource) => (
<> <>
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium text-foreground">{tool.name}</span> <span className="truncate font-medium text-foreground">
{resource.kind === "mcp" ? resource.server.name : resource.tool.name}
</span>
</div> </div>
<div className="mt-1 truncate font-mono text-xs text-muted-soft"> <div className="mt-1 truncate font-mono text-xs text-muted-soft">
{tool.functionName} {resource.kind === "mcp"
? `${resource.server.url} · ${resource.server.toolCount} 个工具`
: resource.tool.functionName}
</div> </div>
</> </>
), ),
@@ -450,29 +482,19 @@ export function ComponentsToolsPage() {
key: "type", key: "type",
header: "类型", header: "类型",
width: "md:w-[128px]", width: "md:w-[128px]",
cell: (tool) => ( cell: (resource) => (
<Badge <Badge
variant="secondary" variant="secondary"
className="h-6 bg-surface-strong px-3 text-muted-foreground" className="h-6 bg-surface-strong px-3 text-muted-foreground"
> >
{tool.type === "end_call" {resource.kind === "mcp"
? "End Call" ? "MCP"
: tool.type === "mcp" : resource.tool.type === "end_call"
? "MCP" ? "End Call"
: "HTTP"} : "HTTP"}
</Badge> </Badge>
), ),
}, },
{
key: "status",
header: "状态",
width: "md:w-[156px]",
cell: (tool) => (
<Badge variant="outline" className="h-6 px-3">
{tool.status === "active" ? "启用" : tool.status === "draft" ? "草稿" : "已归档"}
</Badge>
),
},
{ {
key: "updated", key: "updated",
width: "md:w-[176px]", width: "md:w-[176px]",
@@ -491,74 +513,86 @@ export function ComponentsToolsPage() {
</button> </button>
), ),
cellClassName: "whitespace-nowrap tabular-nums text-muted-foreground", cellClassName: "whitespace-nowrap tabular-nums text-muted-foreground",
cell: (tool) => formatTimestamp(tool.updatedAt), cell: (resource) =>
formatTimestamp(
resource.kind === "mcp" ? resource.server.updatedAt : resource.tool.updatedAt,
),
}, },
{ {
key: "actions", key: "actions",
header: "操作", header: "操作",
align: "right", align: "right",
cellClassName: "flex justify-end gap-2", cellClassName: "flex justify-end gap-2",
cell: (tool) => ( cell: (resource) => {
<> const name = resource.kind === "mcp" ? resource.server.name : resource.tool.name;
<Button const resourceId = resource.id;
variant="outline" return (
size="sm" <>
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground" <Button
onClick={() => openEdit(tool)} variant="outline"
> size="sm"
<Pencil size={14} /> className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
onClick={() =>
</Button> resource.kind === "mcp"
<DropdownMenu> ? openEditMcpServer(resource.server)
<DropdownMenuTrigger asChild> : openEdit(resource.tool)
<Button }
variant="outline"
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
aria-label={`${tool.name} 更多操作`}
>
<MoreHorizontal size={15} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
> >
<DropdownMenuItem <Pencil size={14} />
className="rounded-lg"
disabled={duplicatingId === tool.id || tool.type === "mcp"} </Button>
onSelect={(event) => { <DropdownMenu>
event.preventDefault(); <DropdownMenuTrigger asChild>
void duplicateTool(tool.id); <Button
}} variant="outline"
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
aria-label={`${name} 更多操作`}
>
<MoreHorizontal size={15} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
> >
{duplicatingId === tool.id ? ( <DropdownMenuItem
<Loader2 size={14} className="animate-spin" /> className="rounded-lg"
) : ( disabled={resource.kind === "mcp" || duplicatingId === resourceId}
<Copy size={14} /> onSelect={(event) => {
)} event.preventDefault();
if (resource.kind === "tool") void duplicateTool(resource.tool.id);
</DropdownMenuItem> }}
<DropdownMenuItem >
variant="destructive" {duplicatingId === resourceId ? (
className="rounded-lg" <Loader2 size={14} className="animate-spin" />
disabled={deletingId === tool.id} ) : (
onSelect={(event) => { <Copy size={14} />
event.preventDefault(); )}
void removeTool(tool);
}} </DropdownMenuItem>
> <DropdownMenuItem
{deletingId === tool.id ? ( variant="destructive"
<Loader2 size={14} className="animate-spin" /> className="rounded-lg"
) : ( disabled={deletingId === resourceId}
<Trash2 size={14} /> onSelect={(event) => {
)} event.preventDefault();
if (resource.kind === "mcp") void removeMcpServer(resource.server);
</DropdownMenuItem> else void removeTool(resource.tool);
</DropdownMenuContent> }}
</DropdownMenu> >
</> {deletingId === resourceId ? (
), <Loader2 size={14} className="animate-spin" />
) : (
<Trash2 size={14} />
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
},
}, },
]; ];
@@ -568,15 +602,31 @@ export function ComponentsToolsPage() {
title="工具资源" title="工具资源"
description="管理可复用的助手工具,并将启用的工具绑定到提示词助手。" description="管理可复用的助手工具,并将启用的工具绑定到提示词助手。"
action={ action={
<Button size="lg" className="w-full gap-2 sm:w-auto" onClick={openCreate}> <DropdownMenu>
<Plus size={16} /> <DropdownMenuTrigger asChild>
<Button size="lg" className="w-full gap-2 sm:w-auto">
</Button> <Plus size={16} />
<ChevronDown size={14} className="opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-52 rounded-xl border border-hairline bg-popover p-1"
>
<DropdownMenuItem className="rounded-lg" onSelect={openCreate}>
<Plus size={15} />
</DropdownMenuItem>
<DropdownMenuItem className="rounded-lg" onSelect={openCreateMcpServer}>
<ServerCog size={15} />
MCP
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
} }
/> />
<McpServersSection onToolsChanged={loadTools} />
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm"> <section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
<ListToolbar <ListToolbar
filters={ filters={
@@ -591,18 +641,18 @@ export function ComponentsToolsPage() {
/> />
} }
/> />
<DataList<Tool> <DataList<ToolResource>
columns={columns} columns={columns}
rows={paginatedTools} rows={paginatedResources}
rowKey={(tool) => tool.id} rowKey={(resource) => resource.id}
loading={loading} loading={loading}
loadingText="正在加载工具…" loadingText="正在加载工具…"
error={error} error={error}
onRetry={() => void loadTools()} onRetry={() => void loadResources()}
empty={{ empty={{
title: tools.length === 0 ? "暂无工具资源" : "未找到匹配的工具资源", title: resources.length === 0 ? "暂无工具资源" : "未找到匹配的工具资源",
description: description:
tools.length === 0 resources.length === 0
? "点击右上角「添加工具」开始。" ? "点击右上角「添加工具」开始。"
: "请调整关键词或筛选条件后再试。", : "请调整关键词或筛选条件后再试。",
}} }}
@@ -611,15 +661,15 @@ export function ComponentsToolsPage() {
totalPages, totalPages,
onPageChange: setCurrentPage, onPageChange: setCurrentPage,
summary: summary:
filteredTools.length === 0 filteredResources.length === 0
? "没有数据" ? "没有数据"
: `显示 ${pageStart + 1}-${Math.min(pageEnd, filteredTools.length)} / 共 ${filteredTools.length} 个工具资源`, : `显示 ${pageStart + 1}-${Math.min(pageEnd, filteredResources.length)} / 共 ${filteredResources.length} 个工具资源`,
}} }}
/> />
</section> </section>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-6xl lg:overflow-hidden"> <DialogContent className={TOOL_DIALOG_CONTENT_CLASS}>
<DialogHeader> <DialogHeader>
<DialogTitle>{editing ? "编辑工具资源" : "添加工具资源"}</DialogTitle> <DialogTitle>{editing ? "编辑工具资源" : "添加工具资源"}</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -629,6 +679,21 @@ export function ComponentsToolsPage() {
<div className="grid gap-5 lg:grid-cols-2"> <div className="grid gap-5 lg:grid-cols-2">
<FieldSection title="基本信息" tall> <FieldSection title="基本信息" tall>
<Field label="工具类型" required>
<Select
value={form.type}
disabled={Boolean(editing)}
onValueChange={(type: ToolKind) =>
setForm((current) => ({ ...current, type }))
}
>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="end_call">End Call</SelectItem>
<SelectItem value="http">HTTP</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="工具名称" required> <Field label="工具名称" required>
<Input <Input
value={form.name} value={form.name}
@@ -654,39 +719,6 @@ export function ComponentsToolsPage() {
placeholder="query_order" placeholder="query_order"
/> />
</Field> </Field>
<Field label="工具类型" required>
<Select
value={form.type}
disabled={form.type === "mcp"}
onValueChange={(type: ToolKind) =>
setForm((current) => ({ ...current, type }))
}
>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="end_call">End Call</SelectItem>
<SelectItem value="http">HTTP</SelectItem>
<SelectItem value="mcp" disabled={!editing || form.type !== "mcp"}>
MCP Server
</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="状态">
<Select
value={form.status}
onValueChange={(status: ToolStatus) =>
setForm((current) => ({ ...current, status }))
}
>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="active"></SelectItem>
<SelectItem value="draft">稿</SelectItem>
<SelectItem value="archived"></SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="描述"> <Field label="描述">
<Textarea <Textarea
value={form.description} value={form.description}
@@ -701,8 +733,6 @@ export function ComponentsToolsPage() {
<FieldSection title="参数配置" scrollable tall> <FieldSection title="参数配置" scrollable tall>
{form.type === "end_call" ? ( {form.type === "end_call" ? (
<EndCallFields form={form} setForm={setForm} /> <EndCallFields form={form} setForm={setForm} />
) : form.type === "mcp" ? (
<McpFields form={form} setForm={setForm} />
) : ( ) : (
<HttpFields form={form} setForm={setForm} /> <HttpFields form={form} setForm={setForm} />
)} )}
@@ -722,6 +752,13 @@ export function ComponentsToolsPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<McpServerDialog
open={mcpDialogOpen}
server={editingMcpServer}
onOpenChange={setMcpDialogOpen}
onChanged={loadResources}
/>
</div> </div>
); );
} }
@@ -776,42 +813,6 @@ function EndCallFields({
); );
} }
function McpFields({
form,
setForm,
}: {
form: ToolForm;
setForm: React.Dispatch<React.SetStateAction<ToolForm>>;
}) {
return (
<div className="space-y-4">
<div className="rounded-xl border border-hairline bg-canvas-soft px-4 py-3 text-sm text-muted-foreground">
Schema MCP Server
</div>
<Field label="远端工具名称">
<Input value={form.remoteToolName} disabled className="font-mono" />
</Field>
<Field label="MCP Server ID">
<Input value={form.mcpServerId} disabled className="font-mono" />
</Field>
<JsonField
label="Input Schema只读"
value={form.inputSchema}
onChange={() => undefined}
rows={10}
disabled
/>
<JsonField
label="响应变量赋值"
value={form.dynamicVariableAssignments}
onChange={(dynamicVariableAssignments) =>
setForm((current) => ({ ...current, dynamicVariableAssignments }))
}
/>
</div>
);
}
function HttpFields({ function HttpFields({
form, form,
setForm, setForm,
@@ -877,84 +878,3 @@ function HttpFields({
</div> </div>
); );
} }
function Field({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<label className="block space-y-2">
<span className="text-sm font-medium text-foreground">
{label}
{required && <span className="ml-1 text-destructive">*</span>}
</span>
{children}
</label>
);
}
function FieldSection({
title,
scrollable,
tall,
children,
}: {
title: string;
scrollable?: boolean;
tall?: boolean;
children: React.ReactNode;
}) {
return (
<section
className={[
"rounded-xl border border-hairline bg-surface-strong/20",
tall ? "lg:flex lg:h-[38rem] lg:flex-col" : "",
].join(" ")}
>
<div className="border-b border-hairline px-4 py-3 text-sm font-medium">
{title}
</div>
<div
className={[
"space-y-4 p-4",
scrollable ? "max-h-72 overflow-y-auto" : "",
tall ? "lg:max-h-none lg:flex-1" : "",
].join(" ")}
>
{children}
</div>
</section>
);
}
function JsonField({
label,
value,
onChange,
rows = 4,
disabled = false,
}: {
label: string;
value: string;
onChange: (value: string) => void;
rows?: number;
disabled?: boolean;
}) {
return (
<Field label={label}>
<Textarea
value={value}
onChange={(event) => onChange(event.target.value)}
rows={rows}
disabled={disabled}
className="font-mono text-xs"
spellCheck={false}
/>
</Field>
);
}

View File

@@ -0,0 +1,460 @@
"use client";
import { ChevronDown, ChevronRight, Loader2, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
TOOL_DIALOG_CONTENT_CLASS,
ToolFormField as Field,
ToolFormSection as FormSection,
ToolJsonField as JsonField,
} from "@/components/tools/tool-form-controls";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
mcpServersApi,
type McpServer,
type McpServerUpsert,
type McpToolDefinition,
type Tool,
type ToolStatus,
} from "@/lib/api";
type McpServerForm = {
name: string;
description: string;
transport: McpServer["transport"];
url: string;
timeoutSeconds: string;
headers: string;
secretHeaders: string;
status: ToolStatus;
};
type McpServerDialogProps = {
open: boolean;
server: McpServer | null;
onOpenChange: (open: boolean) => void;
onChanged: () => void | Promise<void>;
};
const EMPTY_OBJECT = "{}";
function blankForm(): McpServerForm {
return {
name: "",
description: "",
transport: "streamable_http",
url: "",
timeoutSeconds: "30",
headers: EMPTY_OBJECT,
secretHeaders: EMPTY_OBJECT,
status: "active",
};
}
function formFromServer(server: McpServer): McpServerForm {
return {
name: server.name,
description: server.description,
transport: server.transport,
url: server.url,
timeoutSeconds: String(server.timeoutSeconds),
headers: JSON.stringify(server.headers ?? {}, null, 2),
secretHeaders: JSON.stringify(server.secretHeaders ?? {}, null, 2),
status: server.status,
};
}
function parseStringMap(value: string, label: string): Record<string, string> {
let parsed: unknown;
try {
parsed = JSON.parse(value || EMPTY_OBJECT);
} catch {
throw new Error(`${label}不是有效的 JSON`);
}
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
throw new Error(`${label}必须是 JSON 对象`);
}
const entries = Object.entries(parsed);
if (entries.some(([, item]) => typeof item !== "string")) {
throw new Error(`${label}的值必须全部是字符串`);
}
return Object.fromEntries(entries) as Record<string, string>;
}
function payloadFromForm(form: McpServerForm): McpServerUpsert {
if (!form.name.trim()) throw new Error("请输入 MCP Server 名称");
if (!form.url.startsWith("http://") && !form.url.startsWith("https://")) {
throw new Error("MCP Server URL 必须使用 http:// 或 https://");
}
const timeoutSeconds = Number(form.timeoutSeconds);
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 120) {
throw new Error("超时时间必须是 1 到 120 秒之间的整数");
}
return {
name: form.name.trim(),
description: form.description.trim(),
transport: form.transport,
url: form.url.trim(),
timeoutSeconds,
headers: parseStringMap(form.headers, "Header"),
secretHeaders: parseStringMap(form.secretHeaders, "敏感 Header"),
status: form.status,
};
}
function formatTimestamp(value?: string | null): string {
if (!value) return "尚未同步";
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
export function McpServerDialog({
open,
server,
onOpenChange,
onChanged,
}: McpServerDialogProps) {
const [form, setForm] = useState<McpServerForm>(blankForm);
const [activeTab, setActiveTab] = useState("connection");
const [serverTools, setServerTools] = useState<Tool[]>([]);
const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null);
const [expandedToolIds, setExpandedToolIds] = useState<Set<string>>(new Set());
const [loadingTools, setLoadingTools] = useState(false);
const [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [toolsError, setToolsError] = useState<string | null>(null);
const loadServerTools = useCallback(async () => {
if (!server) return;
setLoadingTools(true);
setToolsError(null);
try {
setServerTools(await mcpServersApi.tools(server.id));
} catch (error) {
setToolsError(error instanceof Error ? error.message : "加载 MCP 工具失败");
} finally {
setLoadingTools(false);
}
}, [server]);
useEffect(() => {
if (!open) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm(server ? formFromServer(server) : blankForm());
setActiveTab("connection");
setServerTools([]);
setLastSyncedAt(server?.lastSyncedAt ?? null);
setExpandedToolIds(new Set());
setFormError(null);
setToolsError(null);
if (server) void loadServerTools();
}, [loadServerTools, open, server]);
async function saveAndSync() {
if (saving) return;
setSaving(true);
setFormError(null);
try {
const payload = payloadFromForm(form);
const saved = server
? await mcpServersApi.update(server.id, payload)
: await mcpServersApi.create(payload);
await mcpServersApi.sync(saved.id);
await onChanged();
onOpenChange(false);
} catch (error) {
setFormError(error instanceof Error ? error.message : "保存或同步 MCP Server 失败");
} finally {
setSaving(false);
}
}
async function syncTools() {
if (!server || syncing) return;
setSyncing(true);
setToolsError(null);
try {
const result = await mcpServersApi.sync(server.id);
setServerTools(result.tools);
setLastSyncedAt(result.server.lastSyncedAt ?? null);
await onChanged();
} catch (error) {
setToolsError(error instanceof Error ? error.message : "同步 MCP 工具失败");
} finally {
setSyncing(false);
}
}
function toggleTool(toolId: string) {
setExpandedToolIds((current) => {
const next = new Set(current);
if (next.has(toolId)) next.delete(toolId);
else next.add(toolId);
return next;
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={TOOL_DIALOG_CONTENT_CLASS}>
<DialogHeader>
<DialogTitle>{server ? "编辑 MCP 工具资源" : "添加 MCP 工具资源"}</DialogTitle>
<DialogDescription>
MCP Server
</DialogDescription>
</DialogHeader>
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full min-h-0">
<TabsList variant="line" className="w-full justify-start border-b border-hairline px-1">
<TabsTrigger value="connection" className="flex-none px-4">
</TabsTrigger>
<TabsTrigger value="tools" className="flex-none px-4" disabled={!server}>
{server ? ` (${loadingTools ? server.toolCount : serverTools.length})` : ""}
</TabsTrigger>
</TabsList>
<TabsContent value="connection" className="max-h-[60vh] overflow-y-auto px-1 pt-4">
<div className="grid gap-5 lg:grid-cols-2">
<FormSection title="基本信息">
<Field label="资源类型">
<Input value="MCP Server" disabled />
</Field>
<Field label="名称" required>
<Input
value={form.name}
placeholder="例如:订单系统 MCP"
onChange={(event) =>
setForm((current) => ({ ...current, name: event.target.value }))
}
/>
</Field>
<Field label="描述">
<Textarea
rows={4}
value={form.description}
onChange={(event) =>
setForm((current) => ({ ...current, description: event.target.value }))
}
/>
</Field>
</FormSection>
<FormSection title="连接参数">
<Field label="传输方式">
<Select
value={form.transport}
onValueChange={(transport: McpServer["transport"]) =>
setForm((current) => ({ ...current, transport }))
}
>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="streamable_http">Streamable HTTP</SelectItem>
<SelectItem value="sse">SSE</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="Server URL" required>
<Input
value={form.url}
placeholder="https://mcp.example.com/mcp"
onChange={(event) =>
setForm((current) => ({ ...current, url: event.target.value }))
}
/>
</Field>
<Field label="超时时间(秒)">
<Input
type="number"
min={1}
max={120}
value={form.timeoutSeconds}
onChange={(event) =>
setForm((current) => ({
...current,
timeoutSeconds: event.target.value,
}))
}
/>
</Field>
<JsonField
label="普通 HeaderJSON"
value={form.headers}
onChange={(headers) => setForm((current) => ({ ...current, headers }))}
/>
<JsonField
label="敏感 HeaderJSON"
value={form.secretHeaders}
placeholder={'{"Authorization":"Bearer ..."}'}
onChange={(secretHeaders) =>
setForm((current) => ({ ...current, secretHeaders }))
}
hint="密钥只保存在后端,重新打开时显示为打码占位符。"
/>
</FormSection>
</div>
{formError && <div className="mt-4 text-sm text-destructive">{formError}</div>}
</TabsContent>
<TabsContent value="tools" className="min-h-0 pt-4">
<div className="mb-4 flex items-center justify-between gap-4 rounded-xl border border-hairline bg-surface-strong/30 px-4 py-3">
<div>
<div className="text-sm font-medium text-foreground"></div>
<div className="mt-0.5 text-xs text-muted-foreground">
{formatTimestamp(lastSyncedAt)}
</div>
</div>
<Button
variant="outline"
size="sm"
className="gap-2"
disabled={syncing}
onClick={() => void syncTools()}
>
{syncing ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
</Button>
</div>
<div className="max-h-[52vh] overflow-y-auto rounded-xl border border-hairline">
{loadingTools ? (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
</div>
) : toolsError ? (
<div className="px-5 py-10 text-center text-sm text-destructive">{toolsError}</div>
) : serverTools.length === 0 ? (
<div className="px-5 py-12 text-center">
<div className="font-medium text-foreground"></div>
<div className="mt-2 text-sm text-muted-foreground"> MCP Server </div>
</div>
) : (
<div className="divide-y divide-hairline">
{serverTools.map((tool) => (
<McpToolRow
key={tool.id}
tool={tool}
expanded={expandedToolIds.has(tool.id)}
onToggle={() => toggleTool(tool.id)}
/>
))}
</div>
)}
</div>
</TabsContent>
</Tabs>
{activeTab === "connection" && (
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}></Button>
<Button onClick={() => void saveAndSync()} disabled={saving}>
{saving && <Loader2 size={15} className="animate-spin" />}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}
function McpToolRow({
tool,
expanded,
onToggle,
}: {
tool: Tool;
expanded: boolean;
onToggle: () => void;
}) {
const definition = tool.definition as McpToolDefinition;
const schema = definition.config.inputSchema ?? {};
const properties = useMemo(
() => Object.entries((schema.properties as Record<string, unknown> | undefined) ?? {}),
[schema.properties],
);
const required = new Set(Array.isArray(schema.required) ? schema.required.map(String) : []);
return (
<div>
<button
type="button"
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-surface-strong/40"
onClick={onToggle}
aria-expanded={expanded}
>
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground">{tool.name}</div>
<div className="mt-0.5 truncate font-mono text-xs text-muted-soft">
{definition.config.remoteToolName}
</div>
</div>
<Badge variant="outline" className="shrink-0">{properties.length} </Badge>
</button>
{expanded && (
<div className="border-t border-hairline bg-canvas-soft px-5 py-4">
{tool.description && (
<p className="mb-4 text-sm leading-6 text-muted-foreground">{tool.description}</p>
)}
{properties.length === 0 ? (
<div className="text-sm text-muted-foreground"></div>
) : (
<div className="overflow-hidden rounded-lg border border-hairline bg-card">
{properties.map(([name, rawDefinition]) => {
const parameter =
rawDefinition && typeof rawDefinition === "object"
? (rawDefinition as Record<string, unknown>)
: {};
return (
<div
key={name}
className="grid gap-2 border-b border-hairline px-4 py-3 last:border-b-0 sm:grid-cols-[160px_100px_1fr]"
>
<div className="font-mono text-xs text-foreground">
{name}{required.has(name) && <span className="ml-1 text-destructive">*</span>}
</div>
<div className="text-xs text-muted-foreground">
{String(parameter.type ?? "any")}
</div>
<div className="text-xs leading-5 text-muted-foreground">
{String(parameter.description ?? "—")}
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,449 +0,0 @@
"use client";
import {
Loader2,
Pencil,
Plus,
RefreshCw,
ServerCog,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DataList, type DataListColumn } from "@/components/ui/data-list";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
mcpServersApi,
type McpServer,
type McpServerUpsert,
type ToolStatus,
} from "@/lib/api";
type McpServerForm = {
name: string;
description: string;
url: string;
timeoutSeconds: string;
headers: string;
secretHeaders: string;
status: ToolStatus;
};
const EMPTY_OBJECT = "{}";
function blankForm(): McpServerForm {
return {
name: "",
description: "",
url: "",
timeoutSeconds: "30",
headers: EMPTY_OBJECT,
secretHeaders: EMPTY_OBJECT,
status: "active",
};
}
function formFromServer(server: McpServer): McpServerForm {
return {
name: server.name,
description: server.description,
url: server.url,
timeoutSeconds: String(server.timeoutSeconds),
headers: JSON.stringify(server.headers ?? {}, null, 2),
secretHeaders: JSON.stringify(server.secretHeaders ?? {}, null, 2),
status: server.status,
};
}
function parseStringMap(value: string, label: string): Record<string, string> {
let parsed: unknown;
try {
parsed = JSON.parse(value || EMPTY_OBJECT);
} catch {
throw new Error(`${label}不是有效的 JSON`);
}
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
throw new Error(`${label}必须是 JSON 对象`);
}
const entries = Object.entries(parsed);
if (entries.some(([, item]) => typeof item !== "string")) {
throw new Error(`${label}的值必须全部是字符串`);
}
return Object.fromEntries(entries) as Record<string, string>;
}
function payloadFromForm(form: McpServerForm): McpServerUpsert {
if (!form.name.trim()) throw new Error("请输入 MCP Server 名称");
if (!form.url.startsWith("http://") && !form.url.startsWith("https://")) {
throw new Error("MCP Server URL 必须使用 http:// 或 https://");
}
const timeoutSeconds = Number(form.timeoutSeconds);
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 120) {
throw new Error("超时时间必须是 1 到 120 秒之间的整数");
}
return {
name: form.name.trim(),
description: form.description.trim(),
transport: "streamable_http",
url: form.url.trim(),
timeoutSeconds,
headers: parseStringMap(form.headers, "Header"),
secretHeaders: parseStringMap(form.secretHeaders, "敏感 Header"),
status: form.status,
};
}
export function McpServersSection({
onToolsChanged,
}: {
onToolsChanged: () => void | Promise<void>;
}) {
const [servers, setServers] = useState<McpServer[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<McpServer | null>(null);
const [form, setForm] = useState<McpServerForm>(blankForm);
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [syncingId, setSyncingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const loadServers = useCallback(async () => {
setLoading(true);
setError(null);
try {
setServers(await mcpServersApi.list());
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "加载 MCP Server 失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadServers();
}, [loadServers]);
function openCreate() {
setEditing(null);
setForm(blankForm());
setFormError(null);
setDialogOpen(true);
}
function openEdit(server: McpServer) {
setEditing(server);
setForm(formFromServer(server));
setFormError(null);
setDialogOpen(true);
}
async function syncServer(serverId: string) {
setSyncingId(serverId);
setError(null);
try {
await mcpServersApi.sync(serverId);
await Promise.all([loadServers(), onToolsChanged()]);
} catch (syncError) {
setError(syncError instanceof Error ? syncError.message : "同步 MCP 工具失败");
} finally {
setSyncingId(null);
}
}
async function saveAndSync() {
if (saving) return;
setSaving(true);
setFormError(null);
try {
const payload = payloadFromForm(form);
const saved = editing
? await mcpServersApi.update(editing.id, payload)
: await mcpServersApi.create(payload);
await mcpServersApi.sync(saved.id);
setDialogOpen(false);
await Promise.all([loadServers(), onToolsChanged()]);
} catch (saveError) {
setFormError(
saveError instanceof Error ? saveError.message : "保存或同步 MCP Server 失败",
);
} finally {
setSaving(false);
}
}
async function removeServer(server: McpServer) {
if (!window.confirm(`确认删除 MCP Server“${server.name}”及其同步工具?`)) {
return;
}
setDeletingId(server.id);
setError(null);
try {
await mcpServersApi.remove(server.id);
await Promise.all([loadServers(), onToolsChanged()]);
} catch (removeError) {
setError(removeError instanceof Error ? removeError.message : "删除 MCP Server 失败");
} finally {
setDeletingId(null);
}
}
const columns: DataListColumn<McpServer>[] = [
{
key: "name",
header: "MCP SERVER",
cell: (server) => (
<>
<div className="flex min-w-0 items-center gap-2">
<ServerCog size={15} className="shrink-0 text-muted-foreground" />
<span className="truncate font-medium text-foreground">{server.name}</span>
</div>
<div className="mt-1 truncate font-mono text-xs text-muted-soft">
{server.url}
</div>
</>
),
},
{
key: "tools",
header: "工具",
width: "md:w-[120px]",
cell: (server) => (
<span className="text-muted-foreground">{server.toolCount} </span>
),
},
{
key: "status",
header: "状态",
width: "md:w-[120px]",
cell: (server) => (
<Badge variant="outline" className="h-6 px-3">
{server.status === "active"
? "启用"
: server.status === "draft"
? "草稿"
: "归档"}
</Badge>
),
},
{
key: "actions",
header: "操作",
width: "md:w-[260px]",
align: "right",
cellClassName: "flex justify-end gap-2",
cell: (server) => (
<>
<Button
variant="outline"
size="sm"
className="gap-1.5 border-hairline-strong"
disabled={syncingId === server.id}
onClick={() => void syncServer(server.id)}
>
{syncingId === server.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RefreshCw size={14} />
)}
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label={`编辑 ${server.name}`}
onClick={() => openEdit(server)}
>
<Pencil size={14} />
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label={`删除 ${server.name}`}
disabled={deletingId === server.id}
onClick={() => void removeServer(server)}
>
{deletingId === server.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Trash2 size={14} />
)}
</Button>
</>
),
},
];
return (
<>
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="caption-label text-muted-soft">MCP CONNECTIONS</div>
<h2 className="mt-1 text-lg font-medium text-foreground">MCP Server</h2>
<p className="mt-1 text-sm text-muted-foreground">
MCP Server
</p>
</div>
<Button variant="outline" className="gap-2" onClick={openCreate}>
<Plus size={15} />
MCP Server
</Button>
</div>
<DataList<McpServer>
columns={columns}
rows={servers}
rowKey={(server) => server.id}
loading={loading}
loadingText="正在加载 MCP Server…"
error={error}
onRetry={() => void loadServers()}
empty={{
title: "暂无 MCP Server",
description: "添加连接后,系统会显式同步远端工具,不会自动暴露新能力。",
}}
/>
</section>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{editing ? "编辑 MCP Server" : "添加 MCP Server"}</DialogTitle>
<DialogDescription>
Streamable HTTP
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<label className="block">
<span className="mb-1.5 block text-sm font-medium"></span>
<Input
value={form.name}
onChange={(event) =>
setForm((current) => ({ ...current, name: event.target.value }))
}
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium">Streamable HTTP URL</span>
<Input
value={form.url}
placeholder="https://mcp.example.com/mcp"
onChange={(event) =>
setForm((current) => ({ ...current, url: event.target.value }))
}
/>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="block">
<span className="mb-1.5 block text-sm font-medium"></span>
<Input
type="number"
min={1}
max={120}
value={form.timeoutSeconds}
onChange={(event) =>
setForm((current) => ({
...current,
timeoutSeconds: event.target.value,
}))
}
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium"></span>
<Select
value={form.status}
onValueChange={(status: ToolStatus) =>
setForm((current) => ({ ...current, status }))
}
>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="active"></SelectItem>
<SelectItem value="draft">稿</SelectItem>
<SelectItem value="archived"></SelectItem>
</SelectContent>
</Select>
</label>
</div>
<label className="block">
<span className="mb-1.5 block text-sm font-medium"> HeaderJSON</span>
<Textarea
rows={4}
className="font-mono text-xs"
value={form.headers}
onChange={(event) =>
setForm((current) => ({ ...current, headers: event.target.value }))
}
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium"> HeaderJSON</span>
<Textarea
rows={4}
className="font-mono text-xs"
value={form.secretHeaders}
placeholder={'{"Authorization":"Bearer ..."}'}
onChange={(event) =>
setForm((current) => ({
...current,
secretHeaders: event.target.value,
}))
}
/>
<span className="mt-1.5 block text-xs text-muted-foreground">
</span>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium"></span>
<Textarea
rows={3}
value={form.description}
onChange={(event) =>
setForm((current) => ({
...current,
description: event.target.value,
}))
}
/>
</label>
</div>
{formError && <div className="text-sm text-destructive">{formError}</div>}
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
</Button>
<Button onClick={() => void saveAndSync()} disabled={saving}>
{saving && <Loader2 size={15} className="animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import type { ReactNode } from "react";
import { Textarea } from "@/components/ui/textarea";
export const TOOL_DIALOG_CONTENT_CLASS =
"max-h-[calc(100vh-3rem)] grid-rows-[auto_minmax(0,1fr)_auto] overflow-y-auto sm:h-[48.875rem] sm:max-w-6xl lg:overflow-hidden";
export function ToolFormField({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: ReactNode;
}) {
return (
<label className="block space-y-2">
<span className="text-sm font-medium text-foreground">
{label}
{required && <span className="ml-1 text-destructive">*</span>}
</span>
{children}
</label>
);
}
export function ToolFormSection({
title,
scrollable = false,
tall = false,
children,
}: {
title: string;
scrollable?: boolean;
tall?: boolean;
children: ReactNode;
}) {
return (
<section
className={[
"rounded-xl border border-hairline bg-surface-strong/20",
tall ? "lg:flex lg:h-[38rem] lg:flex-col" : "",
].join(" ")}
>
<div className="border-b border-hairline px-4 py-3 text-sm font-medium">
{title}
</div>
<div
className={[
"space-y-4 p-4",
scrollable ? "max-h-72 overflow-y-auto" : "",
tall ? "lg:max-h-none lg:flex-1" : "",
].join(" ")}
>
{children}
</div>
</section>
);
}
export function ToolJsonField({
label,
value,
onChange,
rows = 4,
disabled = false,
placeholder,
hint,
}: {
label: string;
value: string;
onChange: (value: string) => void;
rows?: number;
disabled?: boolean;
placeholder?: string;
hint?: string;
}) {
return (
<ToolFormField label={label}>
<Textarea
value={value}
onChange={(event) => onChange(event.target.value)}
rows={rows}
disabled={disabled}
placeholder={placeholder}
className="font-mono text-xs"
spellCheck={false}
/>
{hint && <span className="block text-xs text-muted-foreground">{hint}</span>}
</ToolFormField>
);
}

View File

@@ -399,7 +399,7 @@ export type McpServer = {
id: string; id: string;
name: string; name: string;
description: string; description: string;
transport: "streamable_http"; transport: "streamable_http" | "sse";
url: string; url: string;
timeoutSeconds: number; timeoutSeconds: number;
headers: Record<string, string>; headers: Record<string, string>;
@@ -438,6 +438,8 @@ export const mcpServersApi = {
request<McpSyncResult>(`/api/mcp-servers/${id}/sync`, { request<McpSyncResult>(`/api/mcp-servers/${id}/sync`, {
method: "POST", method: "POST",
}), }),
tools: (id: string) =>
request<Tool[]>(`/api/mcp-servers/${id}/tools`),
remove: (id: string) => remove: (id: string) =>
request<{ ok: boolean }>(`/api/mcp-servers/${id}`, { request<{ ok: boolean }>(`/api/mcp-servers/${id}`, {
method: "DELETE", method: "DELETE",