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

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