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:
@@ -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"]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
51
backend/tests/test_mcp_client.py
Normal file
51
backend/tests/test_mcp_client.py
Normal 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()
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
460
frontend/src/components/tools/McpServerDialog.tsx
Normal file
460
frontend/src/components/tools/McpServerDialog.tsx
Normal 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="普通 Header(JSON)"
|
||||||
|
value={form.headers}
|
||||||
|
onChange={(headers) => setForm((current) => ({ ...current, headers }))}
|
||||||
|
/>
|
||||||
|
<JsonField
|
||||||
|
label="敏感 Header(JSON)"
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">普通 Header(JSON)</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">敏感 Header(JSON)</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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
95
frontend/src/components/tools/tool-form-controls.tsx
Normal file
95
frontend/src/components/tools/tool-form-controls.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user