feat: add MCP tool integration

This commit is contained in:
Xin Wang
2026-07-18 00:00:06 +08:00
parent bdf3d3dd9c
commit e39bb48ba8
21 changed files with 1641 additions and 62 deletions

View File

@@ -27,6 +27,7 @@ from routes import (
conversations,
health,
knowledge_bases,
mcp_servers,
model_registry,
node_types,
tools,
@@ -58,6 +59,7 @@ app.include_router(auth.router)
app.include_router(conversations.router)
app.include_router(assistants.router)
app.include_router(knowledge_bases.router)
app.include_router(mcp_servers.router)
app.include_router(model_registry.router)
app.include_router(node_types.router)
app.include_router(tools.router)

View File

@@ -207,15 +207,55 @@ class AssistantModelBinding(Base):
)
class McpServer(Base):
"""Workspace-level remote MCP connection used to discover executable tools."""
__tablename__ = "mcp_servers"
id: Mapped[str] = mapped_column(String(40), primary_key=True)
name: Mapped[str] = mapped_column(String(128))
description: Mapped[str] = mapped_column(String(2048), default="")
transport: Mapped[str] = mapped_column(String(32), default="streamable_http")
url: Mapped[str] = mapped_column(String(2048))
config: Mapped[dict] = mapped_column(JSONB, default=dict)
secrets: Mapped[dict] = mapped_column(JSONB, default=dict)
status: Mapped[str] = mapped_column(String(16), index=True, default="active")
last_synced_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class Tool(Base):
"""Reusable LLM tool definition; supported types are executed at runtime."""
__tablename__ = "tools"
__table_args__ = (
UniqueConstraint(
"mcp_server_id",
"remote_tool_name",
name="uq_tools_mcp_server_remote_name",
),
)
id: Mapped[str] = mapped_column(String(40), primary_key=True)
name: Mapped[str] = mapped_column(String(128))
function_name: Mapped[str] = mapped_column(String(64), unique=True, index=True)
type: Mapped[str] = mapped_column(String(24), index=True)
mcp_server_id: Mapped[str | None] = mapped_column(
String(40),
ForeignKey("mcp_servers.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
remote_tool_name: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
description: Mapped[str] = mapped_column(String(2048), default="")
definition: Mapped[dict] = mapped_column(JSONB, default=dict)
secrets: Mapped[dict] = mapped_column(JSONB, default=dict)

View File

@@ -0,0 +1,107 @@
"""add MCP server connections and discovered tools
Revision ID: 20260717_0008
Revises: 20260712_0007
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260717_0008"
down_revision: str | Sequence[str] | None = "20260712_0007"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"mcp_servers",
sa.Column("id", sa.String(length=40), nullable=False),
sa.Column("name", sa.String(length=128), nullable=False),
sa.Column(
"description",
sa.String(length=2048),
server_default="",
nullable=False,
),
sa.Column(
"transport",
sa.String(length=32),
server_default="streamable_http",
nullable=False,
),
sa.Column("url", sa.String(length=2048), nullable=False),
sa.Column(
"config",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
sa.Column(
"secrets",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
sa.Column(
"status",
sa.String(length=16),
server_default="active",
nullable=False,
),
sa.Column("last_synced_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_mcp_servers_status", "mcp_servers", ["status"])
op.add_column(
"tools",
sa.Column("mcp_server_id", sa.String(length=40), nullable=True),
)
op.add_column(
"tools",
sa.Column("remote_tool_name", sa.String(length=255), nullable=True),
)
op.create_index("ix_tools_mcp_server_id", "tools", ["mcp_server_id"])
op.create_foreign_key(
"fk_tools_mcp_server_id",
"tools",
"mcp_servers",
["mcp_server_id"],
["id"],
ondelete="CASCADE",
)
op.create_unique_constraint(
"uq_tools_mcp_server_remote_name",
"tools",
["mcp_server_id", "remote_tool_name"],
)
def downgrade() -> None:
op.drop_constraint(
"uq_tools_mcp_server_remote_name",
"tools",
type_="unique",
)
op.drop_constraint("fk_tools_mcp_server_id", "tools", type_="foreignkey")
op.drop_index("ix_tools_mcp_server_id", table_name="tools")
op.drop_column("tools", "remote_tool_name")
op.drop_column("tools", "mcp_server_id")
op.drop_index("ix_mcp_servers_status", table_name="mcp_servers")
op.drop_table("mcp_servers")

View File

@@ -15,6 +15,18 @@ from pydantic import BaseModel, Field
RuntimeMode = Literal["pipeline", "realtime"]
class RuntimeMcpServer(BaseModel):
"""Resolved MCP connection containing secrets for one runtime session."""
id: str
name: str = ""
transport: str = "streamable_http"
url: str
timeout_seconds: int = 30
headers: dict[str, str] = Field(default_factory=dict)
secret_headers: dict[str, str] = Field(default_factory=dict)
class RuntimeTool(BaseModel):
"""Tool data resolved from an assistant binding for one runtime session."""
@@ -25,6 +37,7 @@ class RuntimeTool(BaseModel):
description: str = ""
definition: dict = Field(default_factory=dict)
secrets: dict = Field(default_factory=dict)
mcp_server: RuntimeMcpServer | None = None
class RuntimeModelResource(BaseModel):

View File

@@ -2,7 +2,7 @@
# webrtc -> SmallWebRTCTransport / SmallWebRTCConnection + aiortc
# silero -> 本地 VAD(判断用户说话起止),语音必备
# openai -> OpenAI 兼容的 LLM/STT/TTS 客户端(DeepSeek、SenseVoice、CosyVoice 都走它)
pipecat-ai[webrtc,websocket,silero,openai]==1.5.0
pipecat-ai[webrtc,websocket,silero,openai,mcp]==1.5.0
Pillow>=11.1.0,<13
# FastGPT 类型助手:本地 SDK(包 /api/v1/chat/completions 流式 + chatId 会话)

View File

@@ -0,0 +1,304 @@
"""Workspace MCP Server CRUD and explicit remote tool synchronization."""
from __future__ import annotations
import re
import uuid
from datetime import datetime, timezone
from db.models import AssistantToolBinding, McpServer, Tool
from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException
from models import RuntimeMcpServer
from schemas import McpServerOut, McpServerUpsert, McpSyncResult, ToolOut
from services.auth import require_admin
from services.masking import mask_secrets, merge_secrets
from services.tool_resources import tool_to_out
from services.tools import McpClientError, McpToolClient
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(
prefix="/api/mcp-servers",
tags=["mcp-servers"],
dependencies=[Depends(require_admin)],
)
def _runtime_server(server: McpServer) -> RuntimeMcpServer:
config = server.config or {}
secrets = server.secrets or {}
return RuntimeMcpServer(
id=server.id,
name=server.name,
transport=server.transport,
url=server.url,
timeout_seconds=int(config.get("timeout_seconds") or 30),
headers={
str(key): str(value)
for key, value in (config.get("headers") or {}).items()
},
secret_headers={
str(key): str(value)
for key, value in (secrets.get("headers") or {}).items()
},
)
async def _to_out(session: AsyncSession, server: McpServer) -> McpServerOut:
tool_count = int(
(
await session.execute(
select(func.count(Tool.id)).where(Tool.mcp_server_id == server.id)
)
).scalar_one()
)
config = server.config or {}
secrets = server.secrets or {}
return McpServerOut(
id=server.id,
name=server.name,
description=server.description,
transport=server.transport, # type: ignore[arg-type]
url=server.url,
timeout_seconds=int(config.get("timeout_seconds") or 30),
headers=dict(config.get("headers") or {}),
secret_headers=mask_secrets(secrets.get("headers") or {}),
status=server.status, # type: ignore[arg-type]
tool_count=tool_count,
last_synced_at=(
server.last_synced_at.isoformat() if server.last_synced_at else None
),
updated_at=server.updated_at.isoformat() if server.updated_at else None,
)
def _apply_body(
server: McpServer,
body: McpServerUpsert,
) -> None:
server.name = body.name.strip()
server.description = body.description.strip()
server.transport = body.transport
server.url = body.url.strip()
server.config = {
"timeout_seconds": body.timeout_seconds,
"headers": body.headers,
}
server.secrets = merge_secrets(
{"headers": body.secret_headers},
server.secrets or {},
)
server.status = body.status
def _function_slug(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "_", value.lower()).strip("_")
if not slug or not slug[0].isalpha():
slug = f"tool_{slug}"
return slug[:64]
async def _available_function_name(
session: AsyncSession,
server: McpServer,
remote_name: str,
) -> str:
base = _function_slug(f"mcp_{server.name}_{remote_name}")
candidate = base
suffix = 2
while (
await session.execute(
select(Tool.id).where(Tool.function_name == candidate).limit(1)
)
).scalar_one_or_none():
tail = f"_{suffix}"
candidate = f"{base[: 64 - len(tail)]}{tail}"
suffix += 1
return candidate
@router.get("", response_model=list[McpServerOut])
async def list_mcp_servers(session: AsyncSession = Depends(get_session)):
rows = (
await session.execute(
select(McpServer).order_by(McpServer.updated_at.desc())
)
).scalars().all()
return [await _to_out(session, server) for server in rows]
@router.post("", response_model=McpServerOut)
async def create_mcp_server(
body: McpServerUpsert,
session: AsyncSession = Depends(get_session),
):
server = McpServer(
id=f"mcp_{uuid.uuid4().hex[:12]}",
name=body.name.strip(),
url=body.url.strip(),
)
_apply_body(server, body)
session.add(server)
await session.commit()
await session.refresh(server)
return await _to_out(session, server)
@router.get("/{server_id}", response_model=McpServerOut)
async def get_mcp_server(
server_id: str,
session: AsyncSession = Depends(get_session),
):
server = await session.get(McpServer, server_id)
if not server:
raise HTTPException(404, "MCP Server 不存在")
return await _to_out(session, server)
@router.put("/{server_id}", response_model=McpServerOut)
async def update_mcp_server(
server_id: str,
body: McpServerUpsert,
session: AsyncSession = Depends(get_session),
):
server = await session.get(McpServer, server_id)
if not server:
raise HTTPException(404, "MCP Server 不存在")
_apply_body(server, body)
await session.commit()
await session.refresh(server)
return await _to_out(session, server)
@router.post("/{server_id}/sync", response_model=McpSyncResult)
async def sync_mcp_tools(
server_id: str,
session: AsyncSession = Depends(get_session),
):
server = await session.get(McpServer, server_id)
if not server:
raise HTTPException(404, "MCP Server 不存在")
if server.status != "active":
raise HTTPException(400, "请先启用 MCP Server")
try:
discovered = await McpToolClient().list_tools(_runtime_server(server))
except McpClientError as exc:
raise HTTPException(400, str(exc)) from exc
existing_rows = (
await session.execute(
select(Tool).where(Tool.mcp_server_id == server.id)
)
).scalars().all()
existing = {
str(tool.remote_tool_name): tool
for tool in existing_rows
if tool.remote_tool_name
}
created = 0
updated = 0
synchronized: list[Tool] = []
for remote in discovered:
remote_name = str(remote["name"])
tool = existing.get(remote_name)
if tool is None:
tool = Tool(
id=f"tool_{uuid.uuid4().hex[:12]}",
name=f"{server.name} · {remote_name}",
function_name=await _available_function_name(
session,
server,
remote_name,
),
type="mcp",
mcp_server_id=server.id,
remote_tool_name=remote_name,
description=str(remote.get("description") or ""),
definition={},
secrets={},
status="active",
)
session.add(tool)
created += 1
else:
updated += 1
previous_config = (tool.definition or {}).get("config") or {}
tool.description = str(remote.get("description") or tool.description)
tool.definition = {
"schema_version": 1,
"type": "mcp",
"config": {
"remote_tool_name": remote_name,
"input_schema": remote.get("input_schema") or {},
"schema_hash": remote.get("schema_hash") or "",
"dynamic_variable_assignments": previous_config.get(
"dynamic_variable_assignments"
)
or {},
},
}
synchronized.append(tool)
server.last_synced_at = datetime.now(timezone.utc)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise HTTPException(409, "同步工具时发生名称冲突,请重试") from exc
for tool in synchronized:
await session.refresh(tool)
await session.refresh(server)
server_out = await _to_out(session, server)
tool_outputs: list[ToolOut] = [tool_to_out(tool) for tool in synchronized]
return McpSyncResult(
server=server_out,
created=created,
updated=updated,
tools=tool_outputs,
)
@router.get("/{server_id}/tools", response_model=list[ToolOut])
async def list_mcp_tools(
server_id: str,
session: AsyncSession = Depends(get_session),
):
if not await session.get(McpServer, server_id):
raise HTTPException(404, "MCP Server 不存在")
rows = (
await session.execute(
select(Tool)
.where(Tool.mcp_server_id == server_id)
.order_by(Tool.name)
)
).scalars().all()
return [tool_to_out(tool) for tool in rows]
@router.delete("/{server_id}")
async def delete_mcp_server(
server_id: str,
session: AsyncSession = Depends(get_session),
):
server = await session.get(McpServer, server_id)
if not server:
raise HTTPException(404, "MCP Server 不存在")
in_use = (
await session.execute(
select(AssistantToolBinding.tool_id)
.join(Tool, Tool.id == AssistantToolBinding.tool_id)
.where(Tool.mcp_server_id == server_id)
.limit(1)
)
).scalar_one_or_none()
if in_use:
raise HTTPException(409, "MCP 工具正被助手引用,请先解绑")
await session.delete(server)
await session.commit()
return {"ok": True}

View File

@@ -7,7 +7,8 @@ from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException
from schemas import ToolOut, ToolUpsert
from services.auth import require_admin
from services.masking import mask_secrets, merge_secrets
from services.masking import merge_secrets
from services.tool_resources import tool_to_out
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -20,21 +21,10 @@ router = APIRouter(
)
def _to_out(tool: Tool) -> ToolOut:
return ToolOut(
id=tool.id,
name=tool.name,
function_name=tool.function_name,
type=tool.type,
description=tool.description,
definition=tool.definition,
secrets=mask_secrets(tool.secrets or {}),
status=tool.status,
updated_at=tool.updated_at.isoformat() if tool.updated_at else None,
)
def _payload(body: ToolUpsert, stored_secrets: dict | None = None) -> dict:
def _payload(
body: ToolUpsert,
stored_secrets: dict | None = None,
) -> dict:
definition = body.definition.model_dump()
secrets = (
merge_secrets(body.secrets, stored_secrets or {})
@@ -45,6 +35,12 @@ def _payload(body: ToolUpsert, stored_secrets: dict | None = None) -> dict:
"name": body.name.strip(),
"function_name": body.function_name,
"type": definition["type"],
"mcp_server_id": body.mcp_server_id if definition["type"] == "mcp" else None,
"remote_tool_name": (
definition["config"]["remote_tool_name"]
if definition["type"] == "mcp"
else None
),
"description": body.description.strip(),
"definition": definition,
"secrets": secrets,
@@ -66,7 +62,7 @@ async def _commit(session: AsyncSession, tool: Tool) -> ToolOut:
await session.rollback()
raise HTTPException(409, "工具函数名已存在") from exc
await session.refresh(tool)
return _to_out(tool)
return tool_to_out(tool)
@router.get("", response_model=list[ToolOut])
@@ -74,11 +70,13 @@ async def list_tools(session: AsyncSession = Depends(get_session)):
rows = (
await session.execute(select(Tool).order_by(Tool.updated_at.desc()))
).scalars().all()
return [_to_out(tool) for tool in rows]
return [tool_to_out(tool) for tool in rows]
@router.post("", response_model=ToolOut)
async def create_tool(body: ToolUpsert, session: AsyncSession = Depends(get_session)):
if body.definition.type == "mcp":
raise HTTPException(400, "MCP 工具请通过 MCP Server 同步创建")
tool = Tool(id=f"tool_{uuid.uuid4().hex[:12]}", **_payload(body))
session.add(tool)
return await _commit(session, tool)
@@ -89,7 +87,7 @@ async def get_tool(tool_id: str, session: AsyncSession = Depends(get_session)):
tool = await session.get(Tool, tool_id)
if not tool:
raise HTTPException(404, "工具不存在")
return _to_out(tool)
return tool_to_out(tool)
@router.put("/{tool_id}", response_model=ToolOut)
@@ -101,6 +99,14 @@ async def update_tool(
tool = await session.get(Tool, tool_id)
if not tool:
raise HTTPException(404, "工具不存在")
if tool.type == "mcp":
if body.definition.type != "mcp":
raise HTTPException(400, "同步生成的 MCP 工具不能修改类型")
if (
body.mcp_server_id != tool.mcp_server_id
or body.definition.config.remote_tool_name != tool.remote_tool_name
):
raise HTTPException(400, "MCP Server 和远端工具名称不能修改")
for key, value in _payload(body, tool.secrets or {}).items():
setattr(tool, key, value)
return await _commit(session, tool)
@@ -111,6 +117,8 @@ async def duplicate_tool(tool_id: str, session: AsyncSession = Depends(get_sessi
source = await session.get(Tool, tool_id)
if not source:
raise HTTPException(404, "工具不存在")
if source.type == "mcp":
raise HTTPException(400, "MCP 工具由 Server 同步维护,不能单独复制")
function_name = _duplicate_function_name(source.function_name)
existing = (
await session.execute(select(Tool).where(Tool.function_name == function_name))
@@ -124,6 +132,8 @@ async def duplicate_tool(tool_id: str, session: AsyncSession = Depends(get_sessi
name=f"{source.name} 副本",
function_name=function_name,
type=source.type,
mcp_server_id=source.mcp_server_id,
remote_tool_name=source.remote_tool_name,
description=source.description,
definition=dict(source.definition or {}),
secrets=dict(source.secrets or {}),

View File

@@ -19,8 +19,10 @@ ModelType = Literal["LLM", "ASR", "TTS", "Realtime", "Embedding", "Agent"]
AssistantType = Literal["prompt", "workflow", "dify", "fastgpt", "opencode"]
TurnEndStrategy = Literal["silence", "smart_turn"]
KnowledgeRetrievalMode = Literal["automatic", "on_demand"]
ToolType = Literal["end_call", "http"]
ToolType = Literal["end_call", "http", "mcp"]
ToolStatus = Literal["active", "archived", "draft"]
McpServerStatus = Literal["active", "archived", "draft"]
McpTransport = Literal["streamable_http"]
ToolParameterType = Literal["string", "number", "integer", "boolean", "object", "array"]
ToolParameterLocation = Literal["path", "query", "body", "header"]
DynamicVariableType = Literal["string", "number", "boolean"]
@@ -219,8 +221,22 @@ class HttpToolDefinition(CamelModel):
config: HttpToolConfig
class McpToolConfig(CamelModel):
remote_tool_name: str = Field(min_length=1, max_length=255)
input_schema: dict[str, Any] = Field(default_factory=dict)
schema_hash: str = ""
dynamic_variable_assignments: dict[str, str] = Field(default_factory=dict)
class McpToolDefinition(CamelModel):
schema_version: int = 1
type: Literal["mcp"] = "mcp"
config: McpToolConfig
ToolDefinition = Annotated[
Union[EndCallToolDefinition, HttpToolDefinition], Field(discriminator="type")
Union[EndCallToolDefinition, HttpToolDefinition, McpToolDefinition],
Field(discriminator="type"),
]
@@ -231,14 +247,49 @@ class ToolUpsert(CamelModel):
definition: ToolDefinition
secrets: dict[str, Any] = Field(default_factory=dict)
status: ToolStatus = "active"
mcp_server_id: str | None = None
class ToolOut(ToolUpsert):
id: str
type: ToolType
remote_tool_name: str | None = None
updated_at: str | None = None
# ---------- MCP Server ----------
class McpServerUpsert(CamelModel):
name: str = Field(min_length=1, max_length=128)
description: str = Field(default="", max_length=2048)
transport: McpTransport = "streamable_http"
url: str = Field(min_length=1, max_length=2048)
timeout_seconds: int = Field(default=30, ge=1, le=120)
headers: dict[str, str] = Field(default_factory=dict)
secret_headers: dict[str, str] = Field(default_factory=dict)
status: McpServerStatus = "active"
@field_validator("url")
@classmethod
def validate_url(cls, value: str) -> str:
if not value.startswith(("http://", "https://")):
raise ValueError("MCP Server URL 必须使用 http:// 或 https://")
return value
class McpServerOut(McpServerUpsert):
id: str
tool_count: int = 0
last_synced_at: str | None = None
updated_at: str | None = None
class McpSyncResult(CamelModel):
server: McpServerOut
created: int
updated: int
tools: list[ToolOut]
# ---------- 知识库 ----------
class KnowledgeBaseUpsert(CamelModel):
name: str

View File

@@ -55,8 +55,8 @@ class PromptBrain(BaseBrain):
for tool in cfg.tools:
if tool.type == "end_call":
schema, handler = self._make_end_call_tool(tool, runtime)
elif tool.type == "http":
schema, handler = self._make_http_tool(tool, runtime)
elif tool.type in {"http", "mcp"}:
schema, handler = self._make_remote_tool(tool, runtime)
else:
continue
schemas.append(schema)
@@ -96,7 +96,7 @@ class PromptBrain(BaseBrain):
if self._dynamic_enabled and self._runtime is not None:
self._runtime.set_system_prompt(self._store.render(self._cfg.prompt))
def _make_http_tool(self, tool, runtime: BrainRuntime):
def _make_remote_tool(self, tool, runtime: BrainRuntime):
properties, required = self._tools.schema_parts(tool)
self._tools.register_secrets(tool)
@@ -108,7 +108,7 @@ class PromptBrain(BaseBrain):
await params.result_callback(result)
except (ToolExecutionError, ValueError) as exc:
await params.result_callback(
{"status": "error", "message": f"HTTP 工具调用失败: {exc}"}
{"status": "error", "message": f"工具调用失败: {exc}"}
)
schema = FunctionSchema(

View File

@@ -328,7 +328,7 @@ class WorkflowBrain(BaseBrain):
functions: list[FlowsFunctionSchema] = []
for tool_id in stage.tool_ids:
tool = self._tool_by_id.get(str(tool_id))
if tool and tool.type == "http":
if tool and tool.type in {"http", "mcp"}:
functions.append(self._flow_tool(tool, node_id))
knowledge_function = self._knowledge_function(node_id)
if knowledge_function:

View File

@@ -9,12 +9,14 @@ from db.models import (
AssistantModelBinding,
AssistantToolBinding,
KnowledgeBase,
McpServer,
ModelResource,
Tool,
)
from models import (
AssistantConfig,
RuntimeKnowledgeBase,
RuntimeMcpServer,
RuntimeModelResource,
RuntimeTool,
)
@@ -102,18 +104,55 @@ async def _tools_for(session: AsyncSession, assistant: Assistant) -> list[Runtim
.order_by(AssistantToolBinding.created_at, Tool.id)
)
).scalars().all()
return [
RuntimeTool(
id=tool.id,
name=tool.name,
function_name=tool.function_name,
type=tool.type,
description=tool.description,
definition=tool.definition or {},
secrets=tool.secrets or {},
)
server_ids = {
str(tool.mcp_server_id)
for tool in tools
]
if tool.type == "mcp" and tool.mcp_server_id
}
server_rows = (
(
await session.execute(
select(McpServer).where(
McpServer.id.in_(server_ids),
McpServer.status == "active",
)
)
).scalars().all()
if server_ids
else []
)
servers = {server.id: server for server in server_rows}
resolved: list[RuntimeTool] = []
for tool in tools:
mcp_server = None
if tool.type == "mcp":
server = servers.get(str(tool.mcp_server_id or ""))
if server is None:
continue
config = server.config or {}
secrets = server.secrets or {}
mcp_server = RuntimeMcpServer(
id=server.id,
name=server.name,
transport=server.transport,
url=server.url,
timeout_seconds=int(config.get("timeout_seconds") or 30),
headers=dict(config.get("headers") or {}),
secret_headers=dict(secrets.get("headers") or {}),
)
resolved.append(
RuntimeTool(
id=tool.id,
name=tool.name,
function_name=tool.function_name,
type=tool.type,
description=tool.description,
definition=tool.definition or {},
secrets=tool.secrets or {},
mcp_server=mcp_server,
)
)
return resolved
async def resolve_runtime_config(

View File

@@ -14,6 +14,7 @@ from services.runtime_variables import (
DynamicVariableStore,
value_at_path,
)
from services.tools import McpClientError, McpToolClient
class ToolExecutionError(RuntimeError):
@@ -21,8 +22,16 @@ class ToolExecutionError(RuntimeError):
class ToolExecutor:
def __init__(self, store: DynamicVariableStore):
"""Execute supported tools and apply their results to session variables."""
def __init__(
self,
store: DynamicVariableStore,
*,
mcp_client: McpToolClient | None = None,
):
self.store = store
self._mcp_client = mcp_client or McpToolClient()
def register_secrets(self, tool: RuntimeTool) -> None:
dynamic = (tool.secrets or {}).get("dynamic_variables") or {}
@@ -34,6 +43,12 @@ class ToolExecutor:
@staticmethod
def schema_parts(tool: RuntimeTool) -> tuple[dict[str, Any], list[str]]:
config = (tool.definition or {}).get("config") or {}
if tool.type == "mcp":
schema = config.get("input_schema") or {}
return (
dict(schema.get("properties") or {}),
[str(name) for name in schema.get("required") or []],
)
parameters = list(config.get("parameters") or [])
properties = {
str(parameter.get("name")): {
@@ -58,11 +73,16 @@ class ToolExecutor:
result_assignments: dict[str, str] | None = None,
) -> dict[str, Any]:
self.register_secrets(tool)
if tool.type != "http":
raise ToolExecutionError(f"Action 暂不支持工具类型: {tool.type}")
return await self._execute_http(
normalized_arguments = dict(arguments or {})
if tool.type == "http":
result = await self._execute_http(tool, normalized_arguments)
elif tool.type == "mcp":
result = await self._execute_mcp(tool, normalized_arguments)
else:
raise ToolExecutionError(f"不支持工具类型: {tool.type}")
return self._apply_result_assignments(
tool,
dict(arguments or {}),
result,
result_assignments=result_assignments,
)
@@ -70,8 +90,6 @@ class ToolExecutor:
self,
tool: RuntimeTool,
arguments: dict[str, Any],
*,
result_assignments: dict[str, str] | None,
) -> dict[str, Any]:
config = (tool.definition or {}).get("config") or {}
parameters = list(config.get("parameters") or [])
@@ -130,11 +148,59 @@ class ToolExecutor:
except ValueError:
payload = {"text": response.text[:8000]}
return {
"status": "ok",
"status_code": response.status_code,
"data": payload,
}
async def _execute_mcp(
self,
tool: RuntimeTool,
arguments: dict[str, Any],
) -> dict[str, Any]:
server = tool.mcp_server
if server is None:
raise ToolExecutionError("MCP 工具缺少可用的 Server 连接")
config = (tool.definition or {}).get("config") or {}
remote_tool_name = str(config.get("remote_tool_name") or "")
if not remote_tool_name:
raise ToolExecutionError("MCP 工具缺少远端工具名称")
resolved_server = server.model_copy(
update={
"url": self.store.render(server.url),
"headers": self.store.render_data(server.headers),
"secret_headers": self.store.render_data(
server.secret_headers,
allow_secrets=True,
),
}
)
try:
payload = await self._mcp_client.call_tool(
resolved_server,
remote_tool_name,
arguments,
)
except McpClientError as exc:
raise ToolExecutionError(str(exc)) from exc
return {"status": "ok", "data": payload}
def _apply_result_assignments(
self,
tool: RuntimeTool,
result: dict[str, Any],
*,
result_assignments: dict[str, str] | None,
) -> dict[str, Any]:
config = (tool.definition or {}).get("config") or {}
assignments = (
result_assignments
if result_assignments is not None
else config.get("dynamic_variable_assignments") or {}
)
payload = result.get("data")
updated: list[str] = []
for variable_name, path in assignments.items():
try:
@@ -146,9 +212,4 @@ class ToolExecutor:
continue
self.store.assign(str(variable_name), value)
updated.append(str(variable_name))
return {
"status": "ok",
"status_code": response.status_code,
"data": payload,
"updated_variables": updated,
}
return {**result, "updated_variables": updated}

View File

@@ -0,0 +1,21 @@
"""Shared serialization helpers for reusable tool resources."""
from db.models import Tool
from schemas import ToolOut
from services.masking import mask_secrets
def tool_to_out(tool: Tool) -> ToolOut:
return ToolOut(
id=tool.id,
name=tool.name,
function_name=tool.function_name,
type=tool.type, # type: ignore[arg-type]
description=tool.description,
definition=tool.definition,
secrets=mask_secrets(tool.secrets or {}),
status=tool.status, # type: ignore[arg-type]
mcp_server_id=tool.mcp_server_id,
remote_tool_name=tool.remote_tool_name,
updated_at=tool.updated_at.isoformat() if tool.updated_at else None,
)

View File

@@ -0,0 +1,5 @@
"""Small protocol adapters used by the shared ToolExecutor."""
from services.tools.mcp_client import McpClientError, McpToolClient
__all__ = ["McpClientError", "McpToolClient"]

View File

@@ -0,0 +1,152 @@
"""One-shot Streamable HTTP MCP discovery and execution.
Connections intentionally live for one operation in the MVP. This keeps the
async context ownership simple and correct; a session pool can be introduced
later if measurements show MCP handshakes are a meaningful source of latency.
"""
from __future__ import annotations
import hashlib
import json
from contextlib import asynccontextmanager
from datetime import timedelta
from typing import Any, AsyncIterator
import httpx
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
from models import RuntimeMcpServer
MAX_MCP_RESULT_BYTES = 1_000_000
class McpClientError(RuntimeError):
"""Safe error surfaced to tool callers and the administration API."""
def schema_hash(input_schema: dict[str, Any]) -> str:
serialized = json.dumps(
input_schema,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
class McpToolClient:
"""Minimal public MCP SDK wrapper shared by discovery and runtime calls."""
@asynccontextmanager
async def _session(
self,
server: RuntimeMcpServer,
) -> AsyncIterator[ClientSession]:
if server.transport != "streamable_http":
raise McpClientError(f"暂不支持 MCP transport: {server.transport}")
headers = {**server.headers, **server.secret_headers}
timeout = max(1, min(int(server.timeout_seconds), 120))
try:
async with httpx.AsyncClient(
headers=headers,
timeout=httpx.Timeout(timeout),
follow_redirects=False,
) as http_client:
async with streamable_http_client(
server.url,
http_client=http_client,
) 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:
raise
except httpx.TimeoutException as exc:
raise McpClientError("MCP Server 连接或调用超时") from exc
except Exception as exc: # MCP SDK exposes several transport exceptions.
raise McpClientError(f"MCP Server 调用失败: {exc}") from exc
async def list_tools(self, server: RuntimeMcpServer) -> list[dict[str, Any]]:
"""Discover all remote tools, following MCP pagination cursors."""
discovered: list[dict[str, Any]] = []
async with self._session(server) as session:
cursor: str | None = None
while True:
result = await session.list_tools(cursor=cursor)
for tool in result.tools:
input_schema = dict(tool.inputSchema or {})
discovered.append(
{
"name": tool.name,
"description": tool.description or "",
"input_schema": input_schema,
"schema_hash": schema_hash(input_schema),
}
)
cursor = result.nextCursor
if not cursor:
break
return discovered
async def call_tool(
self,
server: RuntimeMcpServer,
remote_tool_name: str,
arguments: dict[str, Any],
) -> dict[str, Any]:
"""Execute one remote tool and normalize the result for local callers."""
async with self._session(server) as session:
result = await session.call_tool(
remote_tool_name,
arguments=arguments,
read_timeout_seconds=timedelta(seconds=server.timeout_seconds),
)
data = self._normalize_result(result)
encoded = json.dumps(data, ensure_ascii=False, default=str).encode("utf-8")
if len(encoded) > MAX_MCP_RESULT_BYTES:
raise McpClientError("MCP 工具响应超过 1 MB 限制")
if bool(result.isError):
message = data.get("text") or "MCP 工具返回执行错误"
raise McpClientError(str(message)[:2048])
return data
@staticmethod
def _normalize_result(result: Any) -> dict[str, Any]:
"""Keep useful text/JSON while deliberately omitting binary payloads."""
content: list[dict[str, Any]] = []
text_parts: list[str] = []
for item in result.content or []:
item_type = str(getattr(item, "type", "unknown"))
if item_type == "text":
text = str(getattr(item, "text", ""))
content.append({"type": "text", "text": text})
if text:
text_parts.append(text)
continue
if item_type == "resource":
resource = getattr(item, "resource", None)
text = getattr(resource, "text", None)
if isinstance(text, str):
content.append({"type": "resource", "text": text})
text_parts.append(text)
else:
content.append({"type": "resource", "omitted": True})
continue
# Audio and image base64 data are not sent into the LLM context in MVP.
content.append({"type": item_type, "omitted": True})
return {
"text": "\n".join(text_parts),
"structuredContent": result.structuredContent,
"content": content,
}

View File

@@ -0,0 +1,105 @@
import unittest
from models import RuntimeMcpServer, RuntimeTool
from services.runtime_variables import DynamicVariableStore
from services.tool_executor import ToolExecutionError, ToolExecutor
from services.tools import McpClientError
def mcp_tool() -> RuntimeTool:
return RuntimeTool(
id="tool_mcp_order",
name="查询订单",
function_name="query_order",
type="mcp",
description="查询订单状态",
definition={
"schema_version": 1,
"type": "mcp",
"config": {
"remote_tool_name": "get_order",
"input_schema": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "订单编号",
}
},
"required": ["order_id"],
},
"dynamic_variable_assignments": {
"order_status": "structuredContent.status"
},
},
},
mcp_server=RuntimeMcpServer(
id="mcp_orders",
name="订单服务",
url="https://mcp.example.com/{{tenant}}",
headers={"X-Tenant": "{{tenant}}"},
secret_headers={"Authorization": "Bearer secret"},
),
)
class FakeMcpClient:
def __init__(self):
self.calls = []
self.error: Exception | None = None
async def call_tool(self, server, remote_tool_name, arguments):
self.calls.append((server, remote_tool_name, arguments))
if self.error:
raise self.error
return {
"text": "订单已发货",
"structuredContent": {"status": "shipped"},
"content": [{"type": "text", "text": "订单已发货"}],
}
class ToolExecutorMcpTests(unittest.IsolatedAsyncioTestCase):
def test_mcp_schema_is_exposed_to_llm(self):
properties, required = ToolExecutor.schema_parts(mcp_tool())
self.assertEqual(properties["order_id"]["type"], "string")
self.assertEqual(required, ["order_id"])
async def test_mcp_execution_renders_connection_and_assigns_result(self):
store = DynamicVariableStore(
{"tenant": "school-a"},
variable_types={"order_status": "string"},
)
client = FakeMcpClient()
executor = ToolExecutor(store, mcp_client=client)
result = await executor.execute(
mcp_tool(),
{"order_id": "A-100"},
)
self.assertEqual(result["status"], "ok")
self.assertEqual(result["updated_variables"], ["order_status"])
self.assertEqual(store.values["order_status"], "shipped")
server, remote_name, arguments = client.calls[0]
self.assertEqual(server.url, "https://mcp.example.com/school-a")
self.assertEqual(server.headers["X-Tenant"], "school-a")
self.assertEqual(server.secret_headers["Authorization"], "Bearer secret")
self.assertEqual(remote_name, "get_order")
self.assertEqual(arguments, {"order_id": "A-100"})
async def test_mcp_client_errors_use_shared_tool_error(self):
client = FakeMcpClient()
client.error = McpClientError("远端不可用")
executor = ToolExecutor(
DynamicVariableStore({"tenant": "school-a"}),
mcp_client=client,
)
with self.assertRaisesRegex(ToolExecutionError, "远端不可用"):
await executor.execute(mcp_tool(), {"order_id": "A-100"})
if __name__ == "__main__":
unittest.main()

View File

@@ -10,6 +10,7 @@ import {
Pencil,
PhoneOff,
Plus,
ServerCog,
Settings2,
Waypoints,
Wrench,
@@ -472,7 +473,13 @@ export function ToolPicker({
key={tool.id}
className="flex h-8 items-center gap-2 rounded-lg border border-hairline-strong bg-background px-2.5 text-sm"
>
{tool.type === "end_call" ? <PhoneOff size={14} /> : <Wrench size={14} />}
{tool.type === "end_call" ? (
<PhoneOff size={14} />
) : tool.type === "mcp" ? (
<ServerCog size={14} />
) : (
<Wrench size={14} />
)}
<span className="max-w-48 truncate">{tool.name}</span>
<button
type="button"
@@ -535,7 +542,11 @@ export function ToolPicker({
{tool.name}
</span>
<Badge variant="secondary">
{tool.type === "end_call" ? "End Call" : "HTTP"}
{tool.type === "end_call"
? "End Call"
: tool.type === "mcp"
? "MCP"
: "HTTP"}
</Badge>
</div>
<div className="mt-0.5 truncate font-mono text-xs text-muted-foreground">

View File

@@ -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<string, string>,
},
},
};
}
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"}
</Badge>
),
},
@@ -478,7 +526,7 @@ export function ComponentsToolsPage() {
>
<DropdownMenuItem
className="rounded-lg"
disabled={duplicatingId === tool.id}
disabled={duplicatingId === tool.id || tool.type === "mcp"}
onSelect={(event) => {
event.preventDefault();
void duplicateTool(tool.id);
@@ -527,6 +575,8 @@ export function ComponentsToolsPage() {
}
/>
<McpServersSection onToolsChanged={loadTools} />
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
<ListToolbar
filters={
@@ -607,6 +657,7 @@ export function ComponentsToolsPage() {
<Field label="工具类型" required>
<Select
value={form.type}
disabled={form.type === "mcp"}
onValueChange={(type: ToolKind) =>
setForm((current) => ({ ...current, type }))
}
@@ -615,6 +666,9 @@ export function ComponentsToolsPage() {
<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>
@@ -647,6 +701,8 @@ export function ComponentsToolsPage() {
<FieldSection title="参数配置" scrollable tall>
{form.type === "end_call" ? (
<EndCallFields form={form} setForm={setForm} />
) : form.type === "mcp" ? (
<McpFields form={form} setForm={setForm} />
) : (
<HttpFields form={form} setForm={setForm} />
)}
@@ -720,6 +776,42 @@ 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({
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 (
<Field label={label}>
@@ -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}
/>

View File

@@ -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<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

@@ -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
</span>
)}
{sourceType === "action" && (
<div className="rounded-xl border border-hairline bg-canvas-soft p-3">
<div className="mb-2 text-xs text-muted-foreground">
Action
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="gap-1.5"
onClick={() => applyActionResultPreset("ok")}
>
<CircleCheck size={14} />
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="gap-1.5"
onClick={() => applyActionResultPreset("error")}
>
<CircleX size={14} />
</Button>
</div>
</div>
)}
{mode !== "always" && (
<label className="block">
<div className="mb-1.5 text-sm font-medium text-foreground">

View File

@@ -347,19 +347,35 @@ export type HttpToolDefinition = {
};
};
export type McpToolDefinition = {
schemaVersion: number;
type: "mcp";
config: {
remoteToolName: string;
inputSchema: Record<string, unknown>;
schemaHash: string;
dynamicVariableAssignments: Record<string, string>;
};
};
export type Tool = {
id: string;
name: string;
functionName: string;
type: "end_call" | "http";
type: "end_call" | "http" | "mcp";
description: string;
definition: EndCallToolDefinition | HttpToolDefinition;
definition: EndCallToolDefinition | HttpToolDefinition | McpToolDefinition;
secrets: Record<string, unknown>;
status: ToolStatus;
mcpServerId?: string | null;
remoteToolName?: string | null;
updatedAt?: string | null;
};
export type ToolUpsert = Omit<Tool, "id" | "type" | "updatedAt">;
export type ToolUpsert = Omit<
Tool,
"id" | "type" | "remoteToolName" | "updatedAt"
>;
export const toolsApi = {
list: () => request<Tool[]>("/api/tools"),
@@ -379,6 +395,55 @@ export const toolsApi = {
request<Tool>(`/api/tools/${id}/duplicate`, { method: "POST" }),
};
export type McpServer = {
id: string;
name: string;
description: string;
transport: "streamable_http";
url: string;
timeoutSeconds: number;
headers: Record<string, string>;
secretHeaders: Record<string, string>;
status: ToolStatus;
toolCount: number;
lastSyncedAt?: string | null;
updatedAt?: string | null;
};
export type McpServerUpsert = Omit<
McpServer,
"id" | "toolCount" | "lastSyncedAt" | "updatedAt"
>;
export type McpSyncResult = {
server: McpServer;
created: number;
updated: number;
tools: Tool[];
};
export const mcpServersApi = {
list: () => request<McpServer[]>("/api/mcp-servers"),
create: (body: McpServerUpsert) =>
request<McpServer>("/api/mcp-servers", {
method: "POST",
body: JSON.stringify(body),
}),
update: (id: string, body: McpServerUpsert) =>
request<McpServer>(`/api/mcp-servers/${id}`, {
method: "PUT",
body: JSON.stringify(body),
}),
sync: (id: string) =>
request<McpSyncResult>(`/api/mcp-servers/${id}/sync`, {
method: "POST",
}),
remove: (id: string) =>
request<{ ok: boolean }>(`/api/mcp-servers/${id}`, {
method: "DELETE",
}),
};
// ---------- 知识库 ----------
export type KnowledgeBase = {
id: string;