Files
AgentLab/backend/app/tools/mcp_manager.py
T
ThearchyHelios 964eeaa829 AgentLab:可视化 Agent 编排实验台
在画布上拖拽编排 agent 工作流,并实时看着它执行。

后端 FastAPI + LangGraph:把画布 JSON 编译成 StateGraph,15 种节点
(模型/Agent/多Agent、工具/沙箱、分支/循环/子图、记忆/检索/整形、
人工介入/结构校验)。选 LangGraph 是因为可视化编排真正难的是暂停、
恢复和回放,而 checkpointer + interrupt() 正好把这几件事做在了底层。

前端 React + React Flow:节点元数据驱动属性面板与连接桩,运行时
节点高亮、边流动画、卡片内流式 token 全部由同一份事件流驱动。

关键实现:
- Agent 内工具审批用 @task 缓存模型调用,interrupt 重放时不重复计费
- 沙箱保持只读根文件系统,代码经 exec+base64 写入(put_archive 会被
  只读容器拒绝),断网、非 root、内存/CPU/超时限额
- 检索为向量与 BM25 混合,本地哈希 embedding 零配置可用
- 分支条件走 AST 白名单求值器;HTTP 工具逐 IP 拦截内网与元数据地址
- Mock provider 让无 API Key 也能跑通完整链路

7 个内置模板全部冒烟通过,含真实模型端到端验证。
2026-09-13 17:27:00 +08:00

134 lines
4.9 KiB
Python

from __future__ import annotations
import asyncio
import logging
from typing import Any
from langchain_core.tools import BaseTool
from sqlalchemy import select
from app.db.base import SessionLocal
from app.db.models import McpServer
logger = logging.getLogger(__name__)
def _server_config(row: McpServer) -> dict[str, Any]:
if row.transport == "http":
return {"transport": "streamable_http", "url": row.url or ""}
return {
"transport": "stdio",
"command": row.command or "",
"args": list(row.args or []),
"env": dict(row.env or {}),
}
class McpManager:
"""管理外部 MCP server 的连接与工具缓存。
工具在图里用 `mcp:<server>/<tool>` 引用。连接是懒建立的,
并且按 server 缓存,避免每个节点都去重新拉起一次 stdio 子进程。
"""
def __init__(self) -> None:
self._tools: dict[str, list[BaseTool]] = {} # server name -> tools
self._lock = asyncio.Lock()
async def _load_servers(self) -> list[McpServer]:
async with SessionLocal() as session:
rows = await session.execute(select(McpServer).where(McpServer.enabled.is_(True)))
return list(rows.scalars())
async def _connect(self, rows: list[McpServer]) -> dict[str, list[BaseTool]]:
from langchain_mcp_adapters.client import MultiServerMCPClient
out: dict[str, list[BaseTool]] = {}
for row in rows:
try:
client = MultiServerMCPClient({row.name: _server_config(row)})
tools = await asyncio.wait_for(client.get_tools(), timeout=30)
out[row.name] = list(tools)
await self._mark(row.id, "ok", None, [t.name for t in tools])
except Exception as e: # noqa: BLE001 - 一个 server 挂了不影响其他的
logger.warning("MCP server %s 连接失败: %s", row.name, e)
out[row.name] = []
await self._mark(row.id, "error", f"{type(e).__name__}: {e}", [])
return out
async def _mark(
self, server_id: str, status: str, error: str | None, tool_names: list[str]
) -> None:
async with SessionLocal() as session:
row = await session.get(McpServer, server_id)
if row:
row.status = status
row.last_error = error
row.tools_cache = tool_names
await session.commit()
async def refresh(self) -> dict[str, list[str]]:
async with self._lock:
rows = await self._load_servers()
self._tools = await self._connect(rows)
return {name: [t.name for t in tools] for name, tools in self._tools.items()}
async def ensure_loaded(self) -> None:
if not self._tools:
await self.refresh()
async def list_tools(self) -> list[dict[str, Any]]:
await self.ensure_loaded()
out: list[dict[str, Any]] = []
for server, tools in self._tools.items():
for tool in tools:
out.append(
{
"id": f"mcp:{server}/{tool.name}",
"name": tool.name,
"server": server,
"description": tool.description or "",
"category": f"MCP · {server}",
"schema": tool.args_schema.model_json_schema()
if getattr(tool, "args_schema", None)
else {},
}
)
return out
async def get_tools(self, refs: list[str]) -> list[BaseTool]:
"""按 `mcp:<server>/<tool>` 取工具;`mcp:<server>/*` 表示该 server 全部工具。"""
await self.ensure_loaded()
wanted: list[BaseTool] = []
for ref in refs:
body = ref[4:] if ref.startswith("mcp:") else ref
server, _, tool_name = body.partition("/")
tools = self._tools.get(server, [])
if tool_name in ("", "*"):
wanted.extend(tools)
continue
for tool in tools:
if tool.name == tool_name:
wanted.append(tool)
break
return wanted
async def probe(self, row: McpServer) -> dict[str, Any]:
"""连一次看看,用于设置页的"测试连接"。"""
from langchain_mcp_adapters.client import MultiServerMCPClient
try:
client = MultiServerMCPClient({row.name: _server_config(row)})
tools = await asyncio.wait_for(client.get_tools(), timeout=30)
return {
"ok": True,
"tools": [
{"name": t.name, "description": t.description or ""} for t in tools
],
}
except Exception as e: # noqa: BLE001
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
mcp_manager = McpManager()