feat: add MCP tool integration
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
21
backend/services/tool_resources.py
Normal file
21
backend/services/tool_resources.py
Normal 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,
|
||||
)
|
||||
5
backend/services/tools/__init__.py
Normal file
5
backend/services/tools/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Small protocol adapters used by the shared ToolExecutor."""
|
||||
|
||||
from services.tools.mcp_client import McpClientError, McpToolClient
|
||||
|
||||
__all__ = ["McpClientError", "McpToolClient"]
|
||||
152
backend/services/tools/mcp_client.py
Normal file
152
backend/services/tools/mcp_client.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user