825 lines
30 KiB
Python
825 lines
30 KiB
Python
"""MCP 客户端管理:工具发现、调用与 OpenAI 工具映射。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import select
|
||
import shutil
|
||
import subprocess
|
||
import time
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Any, Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING
|
||
|
||
import httpx
|
||
|
||
from config import MCP_PROTOCOL_VERSION, MCP_DEFAULT_TIMEOUT_SECONDS
|
||
from modules.mcp_server_registry import MCPServerRegistry
|
||
|
||
if TYPE_CHECKING:
|
||
from modules.user_container_manager import ContainerHandle
|
||
|
||
|
||
class MCPClientError(Exception):
|
||
"""MCP 客户端异常。"""
|
||
|
||
|
||
@dataclass
|
||
class MCPToolBinding:
|
||
alias: str
|
||
server_id: str
|
||
server_name: str
|
||
remote_name: str
|
||
transport: str
|
||
timeout_seconds: int
|
||
|
||
|
||
class _StdioMCPClient:
|
||
"""最小可用 stdio MCP 客户端(JSON-RPC over newline-delimited JSON)。"""
|
||
|
||
CONTAINER_PREFIXES = (
|
||
"/workspace",
|
||
"/usr/",
|
||
"/bin/",
|
||
"/sbin/",
|
||
"/lib/",
|
||
"/lib64/",
|
||
"/opt/",
|
||
"/etc/",
|
||
"/tmp/",
|
||
"/var/",
|
||
"/home/",
|
||
"/root/",
|
||
)
|
||
PATH_OPTION_KEYS = {
|
||
"--path",
|
||
"--dir",
|
||
"--directory",
|
||
"--root",
|
||
"--cwd",
|
||
"--workspace",
|
||
}
|
||
|
||
def __init__(
|
||
self,
|
||
server: Dict[str, Any],
|
||
timeout_seconds: int,
|
||
protocol_version: str,
|
||
container_session: Optional["ContainerHandle"] = None,
|
||
):
|
||
self.server = server
|
||
self.timeout_seconds = max(3, int(timeout_seconds or MCP_DEFAULT_TIMEOUT_SECONDS))
|
||
self.protocol_version = protocol_version
|
||
self.container_session = container_session
|
||
self.process: Optional[subprocess.Popen] = None
|
||
self._seq = 0
|
||
|
||
def _next_id(self) -> int:
|
||
self._seq += 1
|
||
return self._seq
|
||
|
||
@staticmethod
|
||
def _is_windows_abs_path(value: str) -> bool:
|
||
return len(value) > 2 and value[1] == ":" and value[0].isalpha()
|
||
|
||
@staticmethod
|
||
def _normalize_mount_path(value: str) -> str:
|
||
mount = str(value or "/workspace").strip()
|
||
mount = mount.rstrip("/")
|
||
return mount or "/workspace"
|
||
|
||
def _normalize_workspace_path(self, value: Any) -> str:
|
||
raw = str(value or "").strip()
|
||
if not raw:
|
||
return ""
|
||
try:
|
||
return str(Path(raw).expanduser().resolve())
|
||
except Exception:
|
||
return raw
|
||
|
||
def _map_workspace_path(self, raw: str, workspace_host: str, mount_path: str) -> str:
|
||
if not raw or not workspace_host:
|
||
return raw
|
||
if raw.startswith(mount_path):
|
||
return raw
|
||
if self._is_windows_abs_path(raw):
|
||
return raw
|
||
if not raw.startswith("/"):
|
||
return raw
|
||
try:
|
||
normalized = str(Path(raw).expanduser().resolve())
|
||
except Exception:
|
||
normalized = raw
|
||
if normalized == workspace_host:
|
||
return mount_path
|
||
prefix = workspace_host + os.sep
|
||
if normalized.startswith(prefix):
|
||
rel = normalized[len(prefix):].replace("\\", "/")
|
||
return f"{mount_path}/{rel}" if rel else mount_path
|
||
return raw
|
||
|
||
def _rewrite_path_like_value_for_container(
|
||
self,
|
||
value: str,
|
||
workspace_host: str,
|
||
mount_path: str,
|
||
) -> str:
|
||
raw = str(value or "").strip()
|
||
if not raw:
|
||
return raw
|
||
mapped = self._map_workspace_path(raw, workspace_host, mount_path)
|
||
return mapped if mapped != raw else raw
|
||
|
||
@staticmethod
|
||
def _command_basename(value: str) -> str:
|
||
text = str(value or "").strip().replace("\\", "/").rstrip("/")
|
||
if not text:
|
||
return ""
|
||
if "/" in text:
|
||
return text.split("/")[-1]
|
||
return text
|
||
|
||
def _rewrite_command_for_container(self, command: str, workspace_host: str, mount_path: str) -> str:
|
||
raw = str(command or "").strip()
|
||
if not raw:
|
||
return raw
|
||
mapped = self._map_workspace_path(raw, workspace_host, mount_path)
|
||
if mapped != raw:
|
||
return mapped
|
||
# command 若是宿主机绝对路径(如 /opt/homebrew/bin/npx),
|
||
# 在容器内通常应退化为命令名(npx/python3),避免无意义路径校验阻断。
|
||
if raw.startswith("/") or self._is_windows_abs_path(raw) or "\\" in raw:
|
||
candidate = self._command_basename(raw)
|
||
return candidate or raw
|
||
return raw
|
||
|
||
def _rewrite_arg_for_container(
|
||
self,
|
||
arg: str,
|
||
*,
|
||
index: int,
|
||
workspace_host: str,
|
||
mount_path: str,
|
||
) -> str:
|
||
raw = str(arg or "")
|
||
stripped = raw.strip()
|
||
if not stripped:
|
||
return raw
|
||
if "=" in stripped:
|
||
left, right = stripped.split("=", 1)
|
||
if left in self.PATH_OPTION_KEYS and right:
|
||
rewritten = self._rewrite_path_like_value_for_container(
|
||
right,
|
||
workspace_host=workspace_host,
|
||
mount_path=mount_path,
|
||
)
|
||
return f"{left}={rewritten}"
|
||
return self._rewrite_path_like_value_for_container(
|
||
stripped,
|
||
workspace_host=workspace_host,
|
||
mount_path=mount_path,
|
||
)
|
||
|
||
def _prepare_launch(self) -> Tuple[List[str], Optional[str], Dict[str, str]]:
|
||
command_raw = str(self.server.get("command") or "").strip()
|
||
args_raw = [str(item) for item in (self.server.get("args") or [])]
|
||
env_raw = dict(self.server.get("env") or {})
|
||
cwd_raw = str(self.server.get("cwd") or "").strip() or None
|
||
if not command_raw:
|
||
raise MCPClientError("stdio 服务缺少 command")
|
||
|
||
session = self.container_session
|
||
use_container = bool(session and getattr(session, "mode", None) == "docker")
|
||
if not use_container:
|
||
env = dict(os.environ)
|
||
env.update(env_raw)
|
||
return [command_raw, *args_raw], cwd_raw, env
|
||
|
||
container_name = str(getattr(session, "container_name", "") or "").strip()
|
||
if not container_name:
|
||
raise MCPClientError("docker 模式下 stdio MCP 缺少 container_name")
|
||
docker_bin = str(getattr(session, "sandbox_bin", "") or "docker").strip() or "docker"
|
||
docker_path = shutil.which(docker_bin) or docker_bin
|
||
mount_path = self._normalize_mount_path(getattr(session, "mount_path", "/workspace"))
|
||
workspace_host = self._normalize_workspace_path(getattr(session, "workspace_path", ""))
|
||
|
||
command = self._rewrite_command_for_container(
|
||
command_raw,
|
||
workspace_host=workspace_host,
|
||
mount_path=mount_path,
|
||
)
|
||
args: List[str] = []
|
||
for idx, item in enumerate(args_raw):
|
||
args.append(
|
||
self._rewrite_arg_for_container(
|
||
item,
|
||
index=idx,
|
||
workspace_host=workspace_host,
|
||
mount_path=mount_path,
|
||
)
|
||
)
|
||
cwd = (
|
||
self._rewrite_path_like_value_for_container(
|
||
cwd_raw,
|
||
workspace_host=workspace_host,
|
||
mount_path=mount_path,
|
||
)
|
||
if cwd_raw
|
||
else None
|
||
)
|
||
|
||
cmd: List[str] = [docker_path, "exec", "-i"]
|
||
if cwd:
|
||
cmd += ["-w", cwd]
|
||
|
||
exec_env = {
|
||
"PYTHONIOENCODING": "utf-8",
|
||
"TERM": "xterm-256color",
|
||
}
|
||
for key, value in env_raw.items():
|
||
if value is not None:
|
||
exec_env[str(key)] = str(value)
|
||
for key, value in exec_env.items():
|
||
cmd += ["-e", f"{key}={value}"]
|
||
cmd += [container_name, command, *args]
|
||
|
||
return cmd, None, dict(os.environ)
|
||
|
||
def start(self) -> None:
|
||
launch_cmd, cwd, env = self._prepare_launch()
|
||
try:
|
||
self.process = subprocess.Popen(
|
||
launch_cmd,
|
||
stdin=subprocess.PIPE,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
text=True,
|
||
bufsize=1,
|
||
cwd=cwd or None,
|
||
env=env,
|
||
)
|
||
except Exception as exc:
|
||
raise MCPClientError(f"启动 stdio MCP 服务失败: {exc}") from exc
|
||
|
||
def _send(self, payload: Dict[str, Any]) -> None:
|
||
if not self.process or not self.process.stdin:
|
||
raise MCPClientError("stdio 进程未启动")
|
||
raw = json.dumps(payload, ensure_ascii=False)
|
||
try:
|
||
self.process.stdin.write(raw + "\n")
|
||
self.process.stdin.flush()
|
||
except Exception as exc:
|
||
raise MCPClientError(f"写入 stdio 消息失败: {exc}") from exc
|
||
|
||
def _read_message(self, timeout_seconds: Optional[float] = None) -> Dict[str, Any]:
|
||
if not self.process or not self.process.stdout:
|
||
raise MCPClientError("stdio 进程未启动")
|
||
timeout = self.timeout_seconds if timeout_seconds is None else max(0.1, float(timeout_seconds))
|
||
deadline = time.monotonic() + timeout
|
||
stdout = self.process.stdout
|
||
last_text_line = ""
|
||
while True:
|
||
if self.process.poll() is not None:
|
||
stderr_text = ""
|
||
stdout_text = ""
|
||
try:
|
||
if self.process.stderr:
|
||
stderr_text = self.process.stderr.read() or ""
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if self.process.stdout:
|
||
stdout_text = self.process.stdout.read() or ""
|
||
except Exception:
|
||
pass
|
||
detail = (stderr_text or stdout_text or last_text_line or "")[:400]
|
||
raise MCPClientError(f"stdio 进程已退出(code={self.process.returncode}){detail}")
|
||
|
||
remain = deadline - time.monotonic()
|
||
if remain <= 0:
|
||
raise MCPClientError("读取 stdio MCP 响应超时")
|
||
|
||
readable, _, _ = select.select([stdout], [], [], min(0.3, remain))
|
||
if not readable:
|
||
continue
|
||
line = stdout.readline()
|
||
if not line:
|
||
continue
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
return json.loads(line)
|
||
except Exception:
|
||
last_text_line = line
|
||
continue
|
||
|
||
def request(self, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||
req_id = self._next_id()
|
||
payload = {"jsonrpc": "2.0", "id": req_id, "method": method, "params": params or {}}
|
||
self._send(payload)
|
||
while True:
|
||
msg = self._read_message()
|
||
if msg.get("id") == req_id:
|
||
if "error" in msg:
|
||
error = msg.get("error") or {}
|
||
raise MCPClientError(f"{method} 调用失败: {error}")
|
||
return msg.get("result") or {}
|
||
# 服务器主动向客户端发起请求时,返回 method not found,避免阻塞
|
||
if "id" in msg and "method" in msg:
|
||
self._send(
|
||
{
|
||
"jsonrpc": "2.0",
|
||
"id": msg.get("id"),
|
||
"error": {"code": -32601, "message": "Client method not implemented"},
|
||
}
|
||
)
|
||
|
||
def notify(self, method: str, params: Optional[Dict[str, Any]] = None) -> None:
|
||
self._send({"jsonrpc": "2.0", "method": method, "params": params or {}})
|
||
|
||
def initialize(self) -> Dict[str, Any]:
|
||
result = self.request(
|
||
"initialize",
|
||
{
|
||
"protocolVersion": self.protocol_version,
|
||
"capabilities": {},
|
||
"clientInfo": {"name": "agents-mcp-bridge", "version": "1.0.0"},
|
||
},
|
||
)
|
||
self.notify("notifications/initialized")
|
||
return result
|
||
|
||
def close(self) -> None:
|
||
if not self.process:
|
||
return
|
||
try:
|
||
if self.process.stdin:
|
||
self.process.stdin.close()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if self.process.poll() is None:
|
||
self.process.terminate()
|
||
self.process.wait(timeout=1.5)
|
||
except Exception:
|
||
try:
|
||
self.process.kill()
|
||
except Exception:
|
||
pass
|
||
finally:
|
||
try:
|
||
if self.process.stdout:
|
||
self.process.stdout.close()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if self.process.stderr:
|
||
self.process.stderr.close()
|
||
except Exception:
|
||
pass
|
||
self.process = None
|
||
|
||
|
||
class _StreamableHTTPMCPClient:
|
||
"""最小可用 streamable HTTP MCP 客户端。"""
|
||
|
||
def __init__(self, server: Dict[str, Any], timeout_seconds: int, protocol_version: str):
|
||
self.server = server
|
||
self.url = str(server.get("url") or "").strip()
|
||
self.timeout_seconds = max(3, int(timeout_seconds or MCP_DEFAULT_TIMEOUT_SECONDS))
|
||
self.protocol_version = protocol_version
|
||
self._session_id: Optional[str] = None
|
||
self._seq = 0
|
||
self._client = httpx.Client(timeout=self.timeout_seconds)
|
||
|
||
def _next_id(self) -> int:
|
||
self._seq += 1
|
||
return self._seq
|
||
|
||
@staticmethod
|
||
def _header_pick(headers: httpx.Headers, key: str) -> Optional[str]:
|
||
for hk, hv in headers.items():
|
||
if hk.lower() == key.lower():
|
||
return hv
|
||
return None
|
||
|
||
def _build_headers(self) -> Dict[str, str]:
|
||
headers = {
|
||
"Accept": "application/json, text/event-stream",
|
||
"Content-Type": "application/json",
|
||
"MCP-Protocol-Version": self.protocol_version,
|
||
}
|
||
if self._session_id:
|
||
headers["Mcp-Session-Id"] = self._session_id
|
||
headers.update(self.server.get("headers") or {})
|
||
return headers
|
||
|
||
@staticmethod
|
||
def _parse_sse_json_events(raw_text: str) -> List[Dict[str, Any]]:
|
||
events: List[Dict[str, Any]] = []
|
||
blocks = raw_text.split("\n\n")
|
||
for block in blocks:
|
||
data_lines: List[str] = []
|
||
for line in block.splitlines():
|
||
if line.startswith("data:"):
|
||
data_lines.append(line[5:].lstrip())
|
||
if not data_lines:
|
||
continue
|
||
payload = "\n".join(data_lines).strip()
|
||
if not payload or payload == "[DONE]":
|
||
continue
|
||
try:
|
||
parsed = json.loads(payload)
|
||
except Exception:
|
||
continue
|
||
if isinstance(parsed, dict):
|
||
events.append(parsed)
|
||
return events
|
||
|
||
def _request(self, method: str, params: Optional[Dict[str, Any]] = None, notification: bool = False) -> Dict[str, Any]:
|
||
if not self.url:
|
||
raise MCPClientError("streamable_http 服务缺少 url")
|
||
req_id = None if notification else self._next_id()
|
||
payload: Dict[str, Any] = {"jsonrpc": "2.0", "method": method, "params": params or {}}
|
||
if req_id is not None:
|
||
payload["id"] = req_id
|
||
try:
|
||
resp = self._client.post(self.url, headers=self._build_headers(), json=payload)
|
||
except Exception as exc:
|
||
raise MCPClientError(f"HTTP MCP 请求失败: {exc}") from exc
|
||
|
||
session_id = self._header_pick(resp.headers, "Mcp-Session-Id")
|
||
if session_id:
|
||
self._session_id = session_id
|
||
|
||
if resp.status_code >= 400:
|
||
raise MCPClientError(f"HTTP MCP 状态异常: {resp.status_code} {resp.text[:400]}")
|
||
|
||
if notification:
|
||
return {}
|
||
|
||
content_type = (resp.headers.get("content-type") or "").lower()
|
||
message: Optional[Dict[str, Any]] = None
|
||
if "application/json" in content_type:
|
||
parsed = resp.json()
|
||
if isinstance(parsed, dict):
|
||
message = parsed
|
||
elif "text/event-stream" in content_type:
|
||
for event in self._parse_sse_json_events(resp.text):
|
||
if event.get("id") == req_id:
|
||
message = event
|
||
break
|
||
else:
|
||
try:
|
||
parsed = resp.json()
|
||
if isinstance(parsed, dict):
|
||
message = parsed
|
||
except Exception:
|
||
pass
|
||
|
||
if not message:
|
||
raise MCPClientError(f"HTTP MCP 未返回可解析响应(method={method})")
|
||
if "error" in message:
|
||
raise MCPClientError(f"{method} 调用失败: {message.get('error')}")
|
||
return message.get("result") or {}
|
||
|
||
def initialize(self) -> Dict[str, Any]:
|
||
result = self._request(
|
||
"initialize",
|
||
{
|
||
"protocolVersion": self.protocol_version,
|
||
"capabilities": {},
|
||
"clientInfo": {"name": "agents-mcp-bridge", "version": "1.0.0"},
|
||
},
|
||
)
|
||
self._request("notifications/initialized", {}, notification=True)
|
||
return result
|
||
|
||
def request(self, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||
return self._request(method, params, notification=False)
|
||
|
||
def close(self) -> None:
|
||
try:
|
||
if self._session_id and self.url:
|
||
self._client.delete(self.url, headers=self._build_headers())
|
||
except Exception:
|
||
pass
|
||
try:
|
||
self._client.close()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
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._latest_alias_map: Dict[str, MCPToolBinding] = {}
|
||
|
||
def set_container_session(self, session: Optional["ContainerHandle"]) -> None:
|
||
self.container_session = session
|
||
|
||
@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):
|
||
for item in content:
|
||
if isinstance(item, dict) and item.get("type") == "text":
|
||
text = str(item.get("text") or "").strip()
|
||
if text:
|
||
return text[: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 "MCP 工具调用完成"
|
||
|
||
@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()
|
||
client = None
|
||
try:
|
||
client = self._make_client(server, timeout)
|
||
client.start() if hasattr(client, "start") else None
|
||
init_result = client.initialize()
|
||
tools = 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)}
|
||
finally:
|
||
try:
|
||
if client:
|
||
client.close()
|
||
except Exception:
|
||
pass
|
||
|
||
def sync_servers(self, server_id: Optional[str] = None) -> Dict[str, Any]:
|
||
# 先从磁盘重载一次,避免「文件已改但进程内缓存未刷新」导致同步遗漏,
|
||
# 以及后续 update_tools_cache 把旧缓存反写覆盖新配置。
|
||
self.registry.reload()
|
||
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] = {}
|
||
|
||
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)
|
||
client = None
|
||
started = time.time()
|
||
try:
|
||
client = self._make_client(server, timeout)
|
||
client.start() if hasattr(client, "start") else None
|
||
client.initialize()
|
||
call_result = 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),
|
||
}
|
||
finally:
|
||
try:
|
||
if client:
|
||
client.close()
|
||
except Exception:
|
||
pass
|