agent-Specialization/modules/mcp_client_manager/manager.py

523 lines
21 KiB
Python

"""MCP 客户端管理:工具发现、调用与 OpenAI 工具映射。"""
from __future__ import annotations
import hashlib
import json
import os
import select
import shutil
import subprocess
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING
import httpx
from config import MCP_PROTOCOL_VERSION, MCP_DEFAULT_TIMEOUT_SECONDS
from modules.mcp_client_manager.exceptions import MCPClientError
from modules.mcp_client_manager.models import MCPClientPoolEntry, MCPToolBinding
from modules.mcp_server_registry import MCPServerRegistry
if TYPE_CHECKING:
from modules.user_container_manager import ContainerHandle
class MCPClientManager:
"""MCP 客户端主协调器。"""
def __init__(
self,
registry: MCPServerRegistry,
protocol_version: str = MCP_PROTOCOL_VERSION,
container_session: Optional["ContainerHandle"] = None,
):
self.registry = registry
self.protocol_version = str(protocol_version or MCP_PROTOCOL_VERSION)
self.container_session = container_session
self._container_session_signature = self._compute_session_signature(container_session)
self._latest_alias_map: Dict[str, MCPToolBinding] = {}
self._client_pool: Dict[str, MCPClientPoolEntry] = {}
self._pool_lock = threading.RLock()
def __del__(self):
try:
self.close_all_clients()
except Exception:
pass
@staticmethod
def _compute_session_signature(session: Optional["ContainerHandle"]) -> Optional[Tuple[str, str, str, str, str]]:
if not session:
return None
return (
str(getattr(session, "mode", "") or ""),
str(getattr(session, "workspace_path", "") or ""),
str(getattr(session, "mount_path", "") or ""),
str(getattr(session, "container_name", "") or ""),
str(getattr(session, "sandbox_bin", "") or ""),
)
def set_container_session(self, session: Optional["ContainerHandle"]) -> None:
next_signature = self._compute_session_signature(session)
if next_signature == self._container_session_signature:
# 引用对象可能相同(甚至被原地修改),这里仍刷新引用,便于后续读取最新字段。
self.container_session = session
return
self.close_all_clients()
self.container_session = session
self._container_session_signature = next_signature
@staticmethod
def _server_signature(server: Dict[str, Any]) -> str:
payload = {
"id": server.get("id"),
"transport": server.get("transport"),
"command": server.get("command"),
"args": server.get("args") or [],
"cwd": server.get("cwd"),
"env": server.get("env") or {},
"url": server.get("url"),
"headers": server.get("headers") or {},
"timeout_seconds": server.get("timeout_seconds"),
}
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True)
return hashlib.sha1(raw.encode("utf-8")).hexdigest()
@staticmethod
def _is_client_alive(client: Any) -> bool:
process = getattr(client, "process", None)
if process is not None:
return bool(process.poll() is None)
# HTTP 客户端无法直接探活,按可用处理,失败时走重连兜底
return True
@staticmethod
def _safe_close_client(client: Any) -> None:
try:
if client:
client.close()
except Exception:
pass
def _close_pool_entry_locked(self, server_id: str) -> None:
entry = self._client_pool.pop(server_id, None)
if entry:
self._safe_close_client(entry.client)
def _ensure_client_locked(self, server: Dict[str, Any], timeout_seconds: int, *, force_recreate: bool = False):
sid = str(server.get("id") or "").strip()
if not sid:
raise MCPClientError("MCP 服务缺少 id")
signature = self._server_signature(server)
entry = self._client_pool.get(sid)
needs_recreate = force_recreate or (entry is None)
if entry and not needs_recreate:
if entry.signature != signature:
needs_recreate = True
elif not self._is_client_alive(entry.client):
needs_recreate = True
if needs_recreate:
if entry:
self._safe_close_client(entry.client)
client = self._make_client(server, timeout_seconds)
if hasattr(client, "start"):
client.start()
init_result = client.initialize()
now = time.time()
entry = MCPClientPoolEntry(
server_id=sid,
signature=signature,
client=client,
timeout_seconds=timeout_seconds,
created_at=now,
last_used_at=now,
)
self._client_pool[sid] = entry
return client, init_result
# 复用连接:动态刷新超时,并确保处于 initialized 状态
entry.timeout_seconds = timeout_seconds
try:
if hasattr(entry.client, "timeout_seconds"):
entry.client.timeout_seconds = max(3, int(timeout_seconds))
if hasattr(entry.client, "_client"):
entry.client._client.timeout = httpx.Timeout(max(3, int(timeout_seconds)))
except Exception:
pass
init_result = {}
if hasattr(entry.client, "initialize"):
init_result = entry.client.initialize() or {}
entry.last_used_at = time.time()
return entry.client, init_result
def _run_with_client(
self,
server: Dict[str, Any],
timeout_seconds: int,
operation: Callable[[Any], Any],
) -> Tuple[Any, Dict[str, Any]]:
last_exc: Optional[Exception] = None
for attempt in range(2):
with self._pool_lock:
client, init_result = self._ensure_client_locked(
server,
timeout_seconds,
force_recreate=attempt > 0,
)
sid = str(server.get("id") or "").strip()
try:
output = operation(client)
entry = self._client_pool.get(sid)
if entry:
entry.last_used_at = time.time()
return output, init_result
except Exception as exc: # pragma: no cover - 仅异常链路
last_exc = exc
self._close_pool_entry_locked(sid)
continue
if last_exc:
raise last_exc
raise MCPClientError("MCP 客户端执行失败")
def close_all_clients(self) -> None:
with self._pool_lock:
entries = list(self._client_pool.values())
self._client_pool.clear()
for entry in entries:
self._safe_close_client(entry.client)
@staticmethod
def _sanitize_input_schema(schema: Any) -> Dict[str, Any]:
if not isinstance(schema, dict):
return {"type": "object", "properties": {}}
output = dict(schema)
if output.get("type") != "object":
output["type"] = "object"
if not isinstance(output.get("properties"), dict):
output["properties"] = {}
if "required" in output and not isinstance(output.get("required"), list):
output.pop("required", None)
return output
@staticmethod
def _compact_tool_entry(tool: Dict[str, Any]) -> Dict[str, Any]:
return {
"name": str(tool.get("name") or "").strip(),
"description": str(tool.get("description") or ""),
"title": str(tool.get("title") or ""),
"inputSchema": tool.get("inputSchema") if isinstance(tool.get("inputSchema"), dict) else {"type": "object", "properties": {}},
}
@staticmethod
def _build_call_message(result: Dict[str, Any]) -> str:
content = result.get("content")
if isinstance(content, list):
lines: List[str] = []
for idx, item in enumerate(content, start=1):
if isinstance(item, dict) and item.get("type") == "text":
text = str(item.get("text") or "").strip()
if text:
lines.append(text)
continue
if not isinstance(item, dict):
continue
ctype = str(item.get("type") or "").strip().lower()
if ctype in {"image", "audio"}:
mime = str(item.get("mimeType") or item.get("mime_type") or "").strip()
if not mime:
mime = "image/png" if ctype == "image" else "audio/wav"
data_len = len(str(item.get("data") or ""))
lines.append(f"[MCP {ctype} #{idx}] {mime} ({data_len} base64 chars)")
continue
if ctype in {"resource_link", "resourcelink"}:
uri = str(item.get("uri") or item.get("url") or "").strip()
name = str(item.get("name") or item.get("title") or "").strip()
if uri and name:
lines.append(f"[MCP 资源] {name} -> {uri}")
elif uri:
lines.append(f"[MCP 资源] {uri}")
continue
if ctype == "resource":
resource = item.get("resource")
if isinstance(resource, dict):
text = str(resource.get("text") or "").strip()
if text:
lines.append(text)
continue
uri = str(resource.get("uri") or "").strip()
if uri:
lines.append(f"[MCP 资源] {uri}")
continue
uri = str(item.get("uri") or item.get("url") or "").strip()
if uri:
lines.append(f"[MCP 资源] {uri}")
if lines:
return "\n".join(lines)[:500]
structured = result.get("structuredContent")
if structured is not None:
try:
return json.dumps(structured, ensure_ascii=False)[:500]
except Exception:
return str(structured)[:500]
return ""
@staticmethod
def _content_preview(content_items: Any) -> str:
if not isinstance(content_items, list):
return ""
previews: List[str] = []
for item in content_items:
if not isinstance(item, dict):
continue
ctype = item.get("type")
if ctype == "text":
text = str(item.get("text") or "").strip()
if text:
previews.append(text[:300])
elif ctype:
previews.append(f"[{ctype}]")
if len(previews) >= 4:
break
return "\n".join(previews).strip()
def _make_client(self, server: Dict[str, Any], timeout_seconds: int):
transport = server.get("transport")
if transport == "stdio":
return _StdioMCPClient(
server,
timeout_seconds,
self.protocol_version,
container_session=self.container_session,
)
if transport == "streamable_http":
return _StreamableHTTPMCPClient(server, timeout_seconds, self.protocol_version)
raise MCPClientError(f"不支持的 MCP transport: {transport}")
def _paged_list_tools(self, client, max_pages: int = 20) -> List[Dict[str, Any]]:
cursor: Optional[str] = None
tools: List[Dict[str, Any]] = []
for _ in range(max_pages):
params = {"cursor": cursor} if cursor else {}
result = client.request("tools/list", params)
page_tools = result.get("tools") if isinstance(result, dict) else []
if isinstance(page_tools, list):
for item in page_tools:
if isinstance(item, dict):
compact = self._compact_tool_entry(item)
if compact.get("name"):
tools.append(compact)
cursor = (result or {}).get("nextCursor") if isinstance(result, dict) else None
if not cursor:
break
return tools
def discover_tools_for_server(self, server_id: str, *, timeout_seconds: Optional[int] = None) -> Dict[str, Any]:
server = self.registry.get_server(server_id)
if not server:
return {"success": False, "error": f"未找到 MCP 服务: {server_id}"}
timeout = int(timeout_seconds or server.get("timeout_seconds") or MCP_DEFAULT_TIMEOUT_SECONDS)
started = time.time()
try:
tools, init_result = self._run_with_client(
server,
timeout,
lambda client: self._paged_list_tools(client),
)
self.registry.update_tools_cache(server_id, tools=tools, error="", touched=True)
elapsed_ms = int((time.time() - started) * 1000)
return {
"success": True,
"server_id": server_id,
"protocol_version": init_result.get("protocolVersion") if isinstance(init_result, dict) else None,
"tools_count": len(tools),
"tools": tools,
"elapsed_ms": elapsed_ms,
}
except Exception as exc:
self.registry.update_tools_cache(server_id, error=str(exc), touched=True)
return {"success": False, "server_id": server_id, "error": str(exc)}
def sync_servers(self, server_id: Optional[str] = None) -> Dict[str, Any]:
# 先从磁盘重载一次,避免「文件已改但进程内缓存未刷新」导致同步遗漏,
# 以及后续 update_tools_cache 把旧缓存反写覆盖新配置。
self.registry.reload()
with self._pool_lock:
enabled_ids = {
str(item.get("id") or "").strip()
for item in self.registry.list_enabled_servers()
if str(item.get("id") or "").strip()
}
stale_ids = [sid for sid in list(self._client_pool.keys()) if sid not in enabled_ids]
for sid in stale_ids:
self._close_pool_entry_locked(sid)
if server_id:
return self.discover_tools_for_server(server_id)
results = []
for server in self.registry.list_enabled_servers():
sid = server.get("id")
if not sid:
continue
results.append(self.discover_tools_for_server(sid))
ok_count = sum(1 for item in results if item.get("success"))
overall_success = (not results) or (ok_count == len(results))
return {
"success": overall_success,
"servers_total": len(results),
"servers_success": ok_count,
"servers_failed": len(results) - ok_count,
"results": results,
}
def _iter_visible_tools(self, server: Dict[str, Any]) -> Iterable[Dict[str, Any]]:
include_tools = set(server.get("include_tools") or [])
exclude_tools = set(server.get("exclude_tools") or [])
for item in server.get("tools_cache") or []:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()
if not name:
continue
if include_tools and name not in include_tools:
continue
if name in exclude_tools:
continue
yield item
def build_openai_tools(
self,
*,
ensure_discovery: bool = True,
discovery_timeout_seconds: int = 12,
) -> Tuple[List[Dict[str, Any]], Dict[str, MCPToolBinding]]:
tools: List[Dict[str, Any]] = []
alias_map: Dict[str, MCPToolBinding] = {}
# 防止“外部直接改配置文件但进程内缓存没刷新”:
# 这里在常规构建阶段主动重载一次,避免后续 discover/update 把旧缓存反写回磁盘。
if ensure_discovery:
self.registry.reload()
servers = self.registry.list_enabled_servers()
for server in servers:
sid = str(server.get("id") or "").strip()
if not sid:
continue
if ensure_discovery and not (server.get("tools_cache") or []):
self.discover_tools_for_server(sid, timeout_seconds=discovery_timeout_seconds)
server = self.registry.get_server(sid) or server
for item in self._iter_visible_tools(server):
remote_name = str(item.get("name") or "").strip()
alias_base = self.registry.make_tool_alias(sid, remote_name)
alias = alias_base
if alias in alias_map:
suffix = hashlib.sha1(f"{sid}:{remote_name}".encode("utf-8")).hexdigest()[:6]
max_len = max(8, 64 - len(suffix) - 2)
alias = f"{alias_base[:max_len]}__{suffix}"
description = str(item.get("description") or "").strip()
if description:
description = f"[MCP:{sid}] {description}"
else:
description = f"[MCP:{sid}] 通过 MCP 服务调用远程工具 {remote_name}"
schema = self._sanitize_input_schema(item.get("inputSchema"))
tools.append(
{
"type": "function",
"function": {
"name": alias,
"description": description,
"parameters": schema,
},
}
)
alias_map[alias] = MCPToolBinding(
alias=alias,
server_id=sid,
server_name=str(server.get("name") or sid),
remote_name=remote_name,
transport=str(server.get("transport") or ""),
timeout_seconds=int(server.get("timeout_seconds") or MCP_DEFAULT_TIMEOUT_SECONDS),
)
self._latest_alias_map = alias_map
return tools, alias_map
def _resolve_binding(
self,
tool_alias: str,
alias_map: Optional[Dict[str, MCPToolBinding]] = None,
) -> Optional[MCPToolBinding]:
if alias_map and tool_alias in alias_map:
return alias_map[tool_alias]
if tool_alias in self._latest_alias_map:
return self._latest_alias_map[tool_alias]
# 容错:从缓存动态重建一遍
_, rebuilt = self.build_openai_tools(ensure_discovery=False)
return rebuilt.get(tool_alias)
def call_tool_by_alias(
self,
tool_alias: str,
arguments: Dict[str, Any],
*,
alias_map: Optional[Dict[str, MCPToolBinding]] = None,
) -> Dict[str, Any]:
binding = self._resolve_binding(tool_alias, alias_map)
if not binding:
return {"success": False, "error": f"未找到 MCP 工具映射: {tool_alias}"}
server = self.registry.get_server(binding.server_id)
if not server:
return {"success": False, "error": f"未找到 MCP 服务: {binding.server_id}"}
if not server.get("enabled", True):
return {"success": False, "error": f"MCP 服务已禁用: {binding.server_id}"}
timeout = int(server.get("timeout_seconds") or binding.timeout_seconds or MCP_DEFAULT_TIMEOUT_SECONDS)
started = time.time()
try:
call_result, _ = self._run_with_client(
server,
timeout,
lambda client: client.request(
"tools/call",
{"name": binding.remote_name, "arguments": arguments or {}},
),
)
is_error = bool((call_result or {}).get("isError"))
content_items = (call_result or {}).get("content") or []
preview = self._content_preview(content_items)
elapsed_ms = int((time.time() - started) * 1000)
message = self._build_call_message(call_result)
return {
"success": not is_error,
"server_id": binding.server_id,
"server_name": binding.server_name,
"tool_alias": binding.alias,
"tool_name": binding.remote_name,
"transport": binding.transport,
"elapsed_ms": elapsed_ms,
"is_error": is_error,
"message": message,
"preview": preview,
"content": content_items,
"structured_content": (call_result or {}).get("structuredContent"),
"raw_result": call_result,
}
except Exception as exc:
return {
"success": False,
"server_id": binding.server_id,
"server_name": binding.server_name,
"tool_alias": binding.alias,
"tool_name": binding.remote_name,
"transport": binding.transport,
"error": str(exc),
}