diff --git a/frontend/src/components/pages/ComponentsToolsPage.tsx b/frontend/src/components/pages/ComponentsToolsPage.tsx
index 0eef270..a2c827f 100644
--- a/frontend/src/components/pages/ComponentsToolsPage.tsx
+++ b/frontend/src/components/pages/ComponentsToolsPage.tsx
@@ -12,6 +12,7 @@ import {
Trash2,
} from "lucide-react";
+import { McpServersSection } from "@/components/tools/McpServersSection";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DataList, type DataListColumn } from "@/components/ui/data-list";
@@ -52,12 +53,12 @@ import {
type ToolUpsert,
} from "@/lib/api";
-type ToolKind = "end_call" | "http";
+type ToolKind = "end_call" | "http" | "mcp";
type HttpMethod = HttpToolDefinition["config"]["method"];
-type ToolFilter = "全部" | "End Call" | "HTTP";
+type ToolFilter = "全部" | "End Call" | "HTTP" | "MCP";
type SortOrder = "newest" | "oldest";
-const toolFilters: readonly ToolFilter[] = ["全部", "End Call", "HTTP"];
+const toolFilters: readonly ToolFilter[] = ["全部", "End Call", "HTTP", "MCP"];
type ToolForm = {
name: string;
@@ -77,6 +78,10 @@ type ToolForm = {
body: string;
dynamicVariableAssignments: string;
secretDynamicVariables: string;
+ mcpServerId: string;
+ remoteToolName: string;
+ inputSchema: string;
+ schemaHash: string;
};
const EMPTY_OBJECT = "{}";
@@ -101,6 +106,10 @@ function blankForm(): ToolForm {
body: EMPTY_OBJECT,
dynamicVariableAssignments: EMPTY_OBJECT,
secretDynamicVariables: EMPTY_OBJECT,
+ mcpServerId: "",
+ remoteToolName: "",
+ inputSchema: EMPTY_OBJECT,
+ schemaHash: "",
};
}
@@ -119,6 +128,17 @@ function formFromTool(tool: Tool): ToolForm {
base.captureReason = tool.definition.config.captureReason;
return base;
}
+ if (tool.definition.type === "mcp") {
+ 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.url = tool.definition.config.url;
base.timeoutSeconds = String(tool.definition.config.timeoutSeconds);
@@ -198,6 +218,29 @@ 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
,
+ },
+ },
+ };
+ }
const timeoutSeconds = Number(form.timeoutSeconds);
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 120) {
@@ -295,7 +338,8 @@ export function ComponentsToolsPage() {
return tools.filter((tool) =>
(filter === "全部" ||
(filter === "End Call" && tool.type === "end_call") ||
- (filter === "HTTP" && tool.type === "http")) &&
+ (filter === "HTTP" && tool.type === "http") ||
+ (filter === "MCP" && tool.type === "mcp")) &&
(!query ||
[tool.name, tool.functionName, tool.description].some((value) =>
value.toLowerCase().includes(query),
@@ -411,7 +455,11 @@ export function ComponentsToolsPage() {
variant="secondary"
className="h-6 bg-surface-strong px-3 text-muted-foreground"
>
- {tool.type === "end_call" ? "End Call" : "HTTP"}
+ {tool.type === "end_call"
+ ? "End Call"
+ : tool.type === "mcp"
+ ? "MCP"
+ : "HTTP"}
),
},
@@ -478,7 +526,7 @@ export function ComponentsToolsPage() {
>
{
event.preventDefault();
void duplicateTool(tool.id);
@@ -527,6 +575,8 @@ export function ComponentsToolsPage() {
}
/>
+
+
@@ -647,6 +701,8 @@ export function ComponentsToolsPage() {
{form.type === "end_call" ? (
+ ) : form.type === "mcp" ? (
+
) : (
)}
@@ -720,6 +776,42 @@ function EndCallFields({
);
}
+function McpFields({
+ form,
+ setForm,
+}: {
+ form: ToolForm;
+ setForm: React.Dispatch>;
+}) {
+ return (
+
+ );
+}
+
function HttpFields({
form,
setForm,
@@ -845,11 +937,13 @@ function JsonField({
value,
onChange,
rows = 4,
+ disabled = false,
}: {
label: string;
value: string;
onChange: (value: string) => void;
rows?: number;
+ disabled?: boolean;
}) {
return (
@@ -857,6 +951,7 @@ function JsonField({
value={value}
onChange={(event) => onChange(event.target.value)}
rows={rows}
+ disabled={disabled}
className="font-mono text-xs"
spellCheck={false}
/>
diff --git a/frontend/src/components/tools/McpServersSection.tsx b/frontend/src/components/tools/McpServersSection.tsx
new file mode 100644
index 0000000..2618276
--- /dev/null
+++ b/frontend/src/components/tools/McpServersSection.tsx
@@ -0,0 +1,449 @@
+"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 {
+ 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;
+}
+
+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;
+}) {
+ const [servers, setServers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [form, setForm] = useState(blankForm);
+ const [formError, setFormError] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [syncingId, setSyncingId] = useState(null);
+ const [deletingId, setDeletingId] = useState(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[] = [
+ {
+ key: "name",
+ header: "MCP SERVER",
+ cell: (server) => (
+ <>
+
+
+ {server.name}
+
+
+ {server.url}
+
+ >
+ ),
+ },
+ {
+ key: "tools",
+ header: "工具",
+ width: "md:w-[120px]",
+ cell: (server) => (
+ {server.toolCount} 个
+ ),
+ },
+ {
+ key: "status",
+ header: "状态",
+ width: "md:w-[120px]",
+ cell: (server) => (
+
+ {server.status === "active"
+ ? "启用"
+ : server.status === "draft"
+ ? "草稿"
+ : "归档"}
+
+ ),
+ },
+ {
+ key: "actions",
+ header: "操作",
+ width: "md:w-[260px]",
+ align: "right",
+ cellClassName: "flex justify-end gap-2",
+ cell: (server) => (
+ <>
+
+
+
+ >
+ ),
+ },
+ ];
+
+ return (
+ <>
+
+
+
+
MCP CONNECTIONS
+
MCP Server
+
+ 连接远端 MCP Server,并将发现的工具同步到下方工具资源。
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/workflow/panels/EdgeSettingsPanel.tsx b/frontend/src/components/workflow/panels/EdgeSettingsPanel.tsx
index dfcd95b..ace445a 100644
--- a/frontend/src/components/workflow/panels/EdgeSettingsPanel.tsx
+++ b/frontend/src/components/workflow/panels/EdgeSettingsPanel.tsx
@@ -3,6 +3,8 @@
import type { Edge } from "@xyflow/react";
import {
Braces,
+ CircleCheck,
+ CircleX,
GitBranch,
MessageSquareText,
Plus,
@@ -92,6 +94,24 @@ export function EdgeSettingsPanel({
publish({ nextRules });
};
+ const applyActionResultPreset = (status: "ok" | "error") => {
+ const nextRules: ExpressionRule[] = [
+ {
+ variable: "system__last_action_status",
+ operator: "eq",
+ value: status,
+ },
+ ];
+ setMode("expression");
+ setCombinator("and");
+ setRules(nextRules);
+ publish({
+ nextMode: "expression",
+ nextCombinator: "and",
+ nextRules,
+ });
+ };
+
const parseValue = (value: string): unknown => {
if (value === "true") return true;
if (value === "false") return false;
@@ -139,6 +159,35 @@ export function EdgeSettingsPanel({
Agent 不能只有默认路径;请改为条件路径,或删除连接以持续对话。
)}
+ {sourceType === "action" && (
+
+
+ Action 常用结果条件
+
+
+
+
+
+
+ )}
{mode !== "always" && (