mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-09 05:16:56 +08:00
601 lines
24 KiB
Python
601 lines
24 KiB
Python
"""Agent 外部 MCP 客户端与配置管理。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import uuid
|
||
from dataclasses import dataclass
|
||
from typing import Any, Optional
|
||
from urllib.parse import urljoin
|
||
|
||
from app.db.systemconfig_oper import SystemConfigOper
|
||
from app.log import logger
|
||
from app.schemas.agent import (
|
||
AgentMcpServerConfig,
|
||
AgentMcpServerTestResult,
|
||
AgentMcpServerToolInfo,
|
||
)
|
||
from app.schemas.types import SystemConfigKey
|
||
from app.utils.http import AsyncRequestUtils
|
||
|
||
MCP_PROTOCOL_VERSION = "2025-11-25"
|
||
MCP_CLIENT_NAME = "MoviePilot Agent"
|
||
DEFAULT_MCP_TIMEOUT = 30
|
||
MCP_TOOL_NAME_PATTERN = re.compile(r"[^a-zA-Z0-9_]+")
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class AgentMcpToolSpec:
|
||
"""已发现的外部 MCP 工具定义。"""
|
||
|
||
server: AgentMcpServerConfig
|
||
name: str
|
||
agent_tool_name: str
|
||
description: str
|
||
input_schema: dict[str, Any]
|
||
|
||
|
||
def _normalize_identifier(value: str, fallback: str = "mcp") -> str:
|
||
"""把服务器或工具名称转换为 Agent 工具可用的标识片段。"""
|
||
normalized = MCP_TOOL_NAME_PATTERN.sub("_", str(value or "").strip())
|
||
normalized = re.sub(r"_+", "_", normalized).strip("_").lower()
|
||
if not normalized:
|
||
normalized = fallback
|
||
if normalized[0].isdigit():
|
||
normalized = f"{fallback}_{normalized}"
|
||
return normalized[:64]
|
||
|
||
|
||
def _normalize_timeout(value: Any) -> int:
|
||
"""规范化 MCP 连接和调用超时时间。"""
|
||
try:
|
||
timeout = int(value or DEFAULT_MCP_TIMEOUT)
|
||
except (TypeError, ValueError):
|
||
timeout = DEFAULT_MCP_TIMEOUT
|
||
return min(max(timeout, 1), 600)
|
||
|
||
|
||
def _normalize_string_dict(value: Any) -> dict[str, str]:
|
||
"""规范化请求头和环境变量字典,移除空键。"""
|
||
if not isinstance(value, dict):
|
||
return {}
|
||
normalized: dict[str, str] = {}
|
||
for key, item in value.items():
|
||
normalized_key = str(key or "").strip()
|
||
if not normalized_key:
|
||
continue
|
||
normalized[normalized_key] = str(item or "")
|
||
return normalized
|
||
|
||
|
||
def _normalize_input_schema(value: Any) -> dict[str, Any]:
|
||
"""规范化 MCP 工具参数 Schema,保证至少是 object schema。"""
|
||
if not isinstance(value, dict):
|
||
return {"type": "object", "properties": {}, "required": []}
|
||
schema = dict(value)
|
||
schema.setdefault("type", "object")
|
||
schema.setdefault("properties", {})
|
||
schema.setdefault("required", [])
|
||
return schema
|
||
|
||
|
||
def _build_agent_tool_name(server: AgentMcpServerConfig, tool_name: str) -> str:
|
||
"""构造注入 Agent 的外部 MCP 工具名。"""
|
||
prefix = server.tool_prefix or f"mcp_{server.name or server.id}"
|
||
normalized_prefix = _normalize_identifier(prefix, fallback="mcp")
|
||
normalized_tool_name = _normalize_identifier(tool_name, fallback="tool")
|
||
if normalized_tool_name.startswith(f"{normalized_prefix}_"):
|
||
return normalized_tool_name
|
||
return f"{normalized_prefix}_{normalized_tool_name}"[:128]
|
||
|
||
|
||
def _jsonrpc_message(method: str, params: Optional[dict[str, Any]] = None, *, request_id: Optional[str] = None) -> dict:
|
||
"""构造 JSON-RPC 2.0 消息。"""
|
||
payload = {"jsonrpc": "2.0", "method": method}
|
||
if request_id is not None:
|
||
payload["id"] = request_id
|
||
if params is not None:
|
||
payload["params"] = params
|
||
return payload
|
||
|
||
|
||
def _raise_for_jsonrpc_error(payload: Any) -> None:
|
||
"""检查 JSON-RPC 响应错误并转换为运行时异常。"""
|
||
if isinstance(payload, dict) and payload.get("error"):
|
||
error = payload["error"]
|
||
if isinstance(error, dict):
|
||
message = error.get("message") or error
|
||
else:
|
||
message = error
|
||
raise RuntimeError(f"MCP JSON-RPC 错误: {message}")
|
||
|
||
|
||
def _extract_jsonrpc_result(payload: Any, request_id: str) -> Any:
|
||
"""从 JSON-RPC 响应中提取 result 字段。"""
|
||
if not isinstance(payload, dict):
|
||
raise RuntimeError("MCP 响应不是有效 JSON 对象")
|
||
if payload.get("id") != request_id:
|
||
raise RuntimeError("MCP 响应 ID 与请求不匹配")
|
||
_raise_for_jsonrpc_error(payload)
|
||
return payload.get("result")
|
||
|
||
|
||
async def _iter_sse_events(response) -> Any:
|
||
"""按 SSE 事件格式迭代响应流。"""
|
||
event_name = "message"
|
||
data_lines: list[str] = []
|
||
async for raw_line in response.aiter_lines():
|
||
line = raw_line.rstrip("\r")
|
||
if not line:
|
||
if data_lines:
|
||
yield {"event": event_name, "data": "\n".join(data_lines)}
|
||
event_name = "message"
|
||
data_lines = []
|
||
continue
|
||
if line.startswith(":"):
|
||
continue
|
||
field, _, value = line.partition(":")
|
||
if value.startswith(" "):
|
||
value = value[1:]
|
||
if field == "event":
|
||
event_name = value or "message"
|
||
elif field == "data":
|
||
data_lines.append(value)
|
||
if data_lines:
|
||
yield {"event": event_name, "data": "\n".join(data_lines)}
|
||
|
||
|
||
def _parse_sse_text_response(text: str, request_id: str) -> Any:
|
||
"""从非流式 SSE 文本响应中提取匹配请求的 JSON-RPC 结果。"""
|
||
event_name = "message"
|
||
data_lines: list[str] = []
|
||
for raw_line in str(text or "").splitlines():
|
||
line = raw_line.rstrip("\r")
|
||
if not line:
|
||
if data_lines:
|
||
payload = _load_sse_json_payload(event_name, "\n".join(data_lines))
|
||
if isinstance(payload, dict) and payload.get("id") == request_id:
|
||
return _extract_jsonrpc_result(payload, request_id)
|
||
event_name = "message"
|
||
data_lines = []
|
||
continue
|
||
if line.startswith(":"):
|
||
continue
|
||
field, _, value = line.partition(":")
|
||
if value.startswith(" "):
|
||
value = value[1:]
|
||
if field == "event":
|
||
event_name = value or "message"
|
||
elif field == "data":
|
||
data_lines.append(value)
|
||
if data_lines:
|
||
payload = _load_sse_json_payload(event_name, "\n".join(data_lines))
|
||
if isinstance(payload, dict) and payload.get("id") == request_id:
|
||
return _extract_jsonrpc_result(payload, request_id)
|
||
raise RuntimeError("MCP SSE 响应中未找到匹配请求")
|
||
|
||
|
||
def _load_sse_json_payload(event_name: str, data: str) -> Optional[dict]:
|
||
"""解析 SSE data 中的 JSON-RPC 消息。"""
|
||
if event_name not in {"message", "messages"}:
|
||
return None
|
||
try:
|
||
payload = json.loads(data)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
return payload if isinstance(payload, dict) else None
|
||
|
||
|
||
class _StdioMcpSession:
|
||
"""stdio MCP 会话,按一次操作生命周期启动外部进程。"""
|
||
|
||
def __init__(self, server: AgentMcpServerConfig) -> None:
|
||
self.server = server
|
||
self.process: Optional[asyncio.subprocess.Process] = None
|
||
self.stderr_task: Optional[asyncio.Task] = None
|
||
|
||
async def __aenter__(self) -> "_StdioMcpSession":
|
||
"""启动 stdio MCP 子进程。"""
|
||
if not self.server.command:
|
||
raise RuntimeError("stdio MCP 服务器缺少启动命令")
|
||
env = os.environ.copy()
|
||
env.update(self.server.env or {})
|
||
self.process = await asyncio.create_subprocess_exec(
|
||
self.server.command,
|
||
*(self.server.args or []),
|
||
stdin=asyncio.subprocess.PIPE,
|
||
stdout=asyncio.subprocess.PIPE,
|
||
stderr=asyncio.subprocess.PIPE,
|
||
env=env,
|
||
)
|
||
self.stderr_task = asyncio.create_task(self._drain_stderr())
|
||
return self
|
||
|
||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||
"""结束 stdio MCP 子进程。"""
|
||
if self.stderr_task:
|
||
self.stderr_task.cancel()
|
||
if not self.process:
|
||
return
|
||
if self.process.returncode is None:
|
||
self.process.terminate()
|
||
try:
|
||
await asyncio.wait_for(self.process.wait(), timeout=2)
|
||
except asyncio.TimeoutError:
|
||
self.process.kill()
|
||
await self.process.wait()
|
||
|
||
async def _drain_stderr(self) -> None:
|
||
"""持续读取子进程 stderr,避免缓冲区阻塞。"""
|
||
if not self.process or not self.process.stderr:
|
||
return
|
||
try:
|
||
while True:
|
||
line = await self.process.stderr.readline()
|
||
if not line:
|
||
break
|
||
logger.debug(f"MCP stdio[{self.server.name}] stderr: {line.decode(errors='replace').strip()}")
|
||
except asyncio.CancelledError:
|
||
return
|
||
|
||
async def notify(self, method: str, params: Optional[dict[str, Any]] = None) -> None:
|
||
"""发送不需要响应的 JSON-RPC 通知。"""
|
||
await self._write_json(_jsonrpc_message(method, params))
|
||
|
||
async def request(self, method: str, params: Optional[dict[str, Any]] = None) -> Any:
|
||
"""发送 JSON-RPC 请求并等待响应。"""
|
||
request_id = uuid.uuid4().hex
|
||
await self._write_json(_jsonrpc_message(method, params, request_id=request_id))
|
||
while True:
|
||
payload = await self._read_json()
|
||
if payload.get("id") == request_id:
|
||
return _extract_jsonrpc_result(payload, request_id)
|
||
|
||
async def _write_json(self, payload: dict) -> None:
|
||
"""写入一行 JSON-RPC 消息。"""
|
||
if not self.process or not self.process.stdin:
|
||
raise RuntimeError("stdio MCP 进程未启动")
|
||
data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||
self.process.stdin.write(data.encode("utf-8"))
|
||
await self.process.stdin.drain()
|
||
|
||
async def _read_json(self) -> dict:
|
||
"""从 stdout 读取一行 JSON-RPC 消息。"""
|
||
if not self.process or not self.process.stdout:
|
||
raise RuntimeError("stdio MCP 进程未启动")
|
||
timeout = _normalize_timeout(self.server.timeout)
|
||
while True:
|
||
line = await asyncio.wait_for(self.process.stdout.readline(), timeout=timeout)
|
||
if not line:
|
||
raise RuntimeError("stdio MCP 进程已退出")
|
||
try:
|
||
payload = json.loads(line.decode("utf-8"))
|
||
except ValueError:
|
||
logger.debug(f"忽略非 JSON MCP stdout 行: {line!r}")
|
||
continue
|
||
if isinstance(payload, dict):
|
||
return payload
|
||
|
||
|
||
class _HttpMcpSession:
|
||
"""Streamable HTTP MCP 会话。"""
|
||
|
||
def __init__(self, server: AgentMcpServerConfig) -> None:
|
||
self.server = server
|
||
self.session_id: Optional[str] = None
|
||
|
||
async def __aenter__(self) -> "_HttpMcpSession":
|
||
"""进入 HTTP MCP 会话。"""
|
||
return self
|
||
|
||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||
"""退出 HTTP MCP 会话。"""
|
||
return None
|
||
|
||
async def notify(self, method: str, params: Optional[dict[str, Any]] = None) -> None:
|
||
"""发送不需要响应的 JSON-RPC 通知。"""
|
||
await self._post(_jsonrpc_message(method, params), expect_response=False)
|
||
|
||
async def request(self, method: str, params: Optional[dict[str, Any]] = None) -> Any:
|
||
"""发送 JSON-RPC 请求并等待响应。"""
|
||
request_id = uuid.uuid4().hex
|
||
return await self._post(
|
||
_jsonrpc_message(method, params, request_id=request_id),
|
||
expect_response=True,
|
||
request_id=request_id,
|
||
)
|
||
|
||
async def _post(
|
||
self,
|
||
payload: dict,
|
||
*,
|
||
expect_response: bool,
|
||
request_id: Optional[str] = None,
|
||
) -> Any:
|
||
"""向 Streamable HTTP MCP 服务发送一条 JSON-RPC 消息。"""
|
||
if not self.server.url:
|
||
raise RuntimeError("HTTP MCP 服务器缺少 URL")
|
||
headers = {
|
||
"Accept": "application/json, text/event-stream",
|
||
"Content-Type": "application/json",
|
||
**(self.server.headers or {}),
|
||
}
|
||
if self.session_id:
|
||
headers["Mcp-Session-Id"] = self.session_id
|
||
response = await AsyncRequestUtils(
|
||
headers=headers,
|
||
timeout=_normalize_timeout(self.server.timeout),
|
||
content_type="application/json",
|
||
accept_type="application/json, text/event-stream",
|
||
http2=False,
|
||
).post_res(self.server.url, json=payload, raise_exception=True)
|
||
try:
|
||
if not response:
|
||
raise RuntimeError("HTTP MCP 请求无响应")
|
||
response.raise_for_status()
|
||
session_id = response.headers.get("Mcp-Session-Id")
|
||
if session_id:
|
||
self.session_id = session_id
|
||
if not expect_response:
|
||
return None
|
||
content_type = response.headers.get("content-type", "").lower()
|
||
if "text/event-stream" in content_type:
|
||
return _parse_sse_text_response(response.text, request_id or "")
|
||
data = response.json()
|
||
return _extract_jsonrpc_result(data, request_id or "")
|
||
finally:
|
||
if response is not None:
|
||
await response.aclose()
|
||
|
||
|
||
class _SseMcpSession:
|
||
"""旧版 HTTP+SSE MCP 会话。"""
|
||
|
||
def __init__(self, server: AgentMcpServerConfig) -> None:
|
||
self.server = server
|
||
self.response = None
|
||
self.endpoint: Optional[str] = None
|
||
self._stream_manager = None
|
||
self._event_iterator = None
|
||
|
||
async def __aenter__(self) -> "_SseMcpSession":
|
||
"""打开 SSE 流并读取服务端回传的 POST endpoint。"""
|
||
if not self.server.url:
|
||
raise RuntimeError("SSE MCP 服务器缺少 URL")
|
||
self._stream_manager = AsyncRequestUtils(
|
||
headers={"Accept": "text/event-stream", **(self.server.headers or {})},
|
||
timeout=_normalize_timeout(self.server.timeout),
|
||
accept_type="text/event-stream",
|
||
http2=False,
|
||
).get_stream(self.server.url, raise_exception=True)
|
||
self.response = await self._stream_manager.__aenter__()
|
||
if not self.response:
|
||
raise RuntimeError("SSE MCP 连接无响应")
|
||
self.response.raise_for_status()
|
||
self._event_iterator = _iter_sse_events(self.response).__aiter__()
|
||
self.endpoint = await self._read_endpoint()
|
||
return self
|
||
|
||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||
"""关闭 SSE 流。"""
|
||
if self._stream_manager:
|
||
await self._stream_manager.__aexit__(exc_type, exc, tb)
|
||
|
||
async def notify(self, method: str, params: Optional[dict[str, Any]] = None) -> None:
|
||
"""发送不需要响应的 JSON-RPC 通知。"""
|
||
await self._post(_jsonrpc_message(method, params))
|
||
|
||
async def request(self, method: str, params: Optional[dict[str, Any]] = None) -> Any:
|
||
"""发送 JSON-RPC 请求并等待 SSE 流上的响应。"""
|
||
request_id = uuid.uuid4().hex
|
||
await self._post(_jsonrpc_message(method, params, request_id=request_id))
|
||
timeout = _normalize_timeout(self.server.timeout)
|
||
while True:
|
||
event = await asyncio.wait_for(self._event_iterator.__anext__(), timeout=timeout)
|
||
payload = _load_sse_json_payload(event.get("event", ""), event.get("data", ""))
|
||
if isinstance(payload, dict) and payload.get("id") == request_id:
|
||
return _extract_jsonrpc_result(payload, request_id)
|
||
|
||
async def _read_endpoint(self) -> str:
|
||
"""读取 SSE endpoint 事件中的 POST 地址。"""
|
||
timeout = _normalize_timeout(self.server.timeout)
|
||
while True:
|
||
event = await asyncio.wait_for(self._event_iterator.__anext__(), timeout=timeout)
|
||
if event.get("event") != "endpoint":
|
||
continue
|
||
endpoint = str(event.get("data") or "").strip()
|
||
if not endpoint:
|
||
continue
|
||
return urljoin(self.server.url, endpoint)
|
||
|
||
async def _post(self, payload: dict) -> None:
|
||
"""向 SSE 握手返回的 endpoint 发送 JSON-RPC 消息。"""
|
||
if not self.endpoint:
|
||
raise RuntimeError("SSE MCP endpoint 未初始化")
|
||
response = await AsyncRequestUtils(
|
||
headers={
|
||
"Accept": "application/json",
|
||
"Content-Type": "application/json",
|
||
**(self.server.headers or {}),
|
||
},
|
||
timeout=_normalize_timeout(self.server.timeout),
|
||
content_type="application/json",
|
||
accept_type="application/json",
|
||
http2=False,
|
||
).post_res(self.endpoint, json=payload, raise_exception=True)
|
||
try:
|
||
if not response:
|
||
raise RuntimeError("SSE MCP POST 请求无响应")
|
||
response.raise_for_status()
|
||
finally:
|
||
if response is not None:
|
||
await response.aclose()
|
||
|
||
|
||
async def _open_mcp_session(server: AgentMcpServerConfig):
|
||
"""根据配置创建对应的 MCP 传输会话。"""
|
||
transport = "http" if server.transport == "streamable_http" else server.transport
|
||
if transport == "stdio":
|
||
return _StdioMcpSession(server)
|
||
if transport == "sse":
|
||
return _SseMcpSession(server)
|
||
if transport == "http":
|
||
return _HttpMcpSession(server)
|
||
raise RuntimeError(f"不支持的 MCP 传输协议: {server.transport}")
|
||
|
||
|
||
class AgentMcpManager:
|
||
"""管理 Agent 外部 MCP 服务器配置、工具发现和工具调用。"""
|
||
|
||
def get_servers(self) -> list[AgentMcpServerConfig]:
|
||
"""读取已保存的外部 MCP 服务器配置。"""
|
||
raw_servers = SystemConfigOper().get(SystemConfigKey.AIAgentMcpServers) or []
|
||
if not isinstance(raw_servers, list):
|
||
return []
|
||
servers: list[AgentMcpServerConfig] = []
|
||
for raw_server in raw_servers:
|
||
try:
|
||
servers.append(self.normalize_server(raw_server))
|
||
except Exception as err:
|
||
logger.warning(f"忽略无效的 Agent MCP 配置: {err}")
|
||
return servers
|
||
|
||
async def save_servers(self, servers: list[AgentMcpServerConfig]) -> bool:
|
||
"""保存外部 MCP 服务器配置。"""
|
||
normalized_servers = [self.normalize_server(server).model_dump() for server in servers]
|
||
return await SystemConfigOper().async_set(
|
||
SystemConfigKey.AIAgentMcpServers,
|
||
normalized_servers or None,
|
||
)
|
||
|
||
def normalize_server(self, value: Any) -> AgentMcpServerConfig:
|
||
"""规范化单个 MCP 服务器配置。"""
|
||
if isinstance(value, AgentMcpServerConfig):
|
||
raw_server = value.model_dump()
|
||
elif isinstance(value, dict):
|
||
raw_server = dict(value)
|
||
else:
|
||
raise ValueError("MCP 服务器配置必须是对象")
|
||
|
||
raw_server["id"] = str(raw_server.get("id") or uuid.uuid4().hex[:12]).strip()
|
||
raw_server["name"] = str(raw_server.get("name") or raw_server["id"]).strip()
|
||
raw_server["transport"] = str(raw_server.get("transport") or "stdio").strip()
|
||
raw_server["description"] = str(raw_server.get("description") or "").strip() or None
|
||
raw_server["command"] = str(raw_server.get("command") or "").strip() or None
|
||
raw_server["args"] = [str(item) for item in raw_server.get("args") or []]
|
||
raw_server["env"] = _normalize_string_dict(raw_server.get("env"))
|
||
raw_server["url"] = str(raw_server.get("url") or "").strip() or None
|
||
raw_server["headers"] = _normalize_string_dict(raw_server.get("headers"))
|
||
raw_server["timeout"] = _normalize_timeout(raw_server.get("timeout"))
|
||
raw_server["tool_prefix"] = str(raw_server.get("tool_prefix") or "").strip() or None
|
||
raw_server["require_admin"] = bool(raw_server.get("require_admin", True))
|
||
return AgentMcpServerConfig.model_validate(raw_server)
|
||
|
||
def config_signature(self) -> str:
|
||
"""生成外部 MCP 配置签名,用于 Agent 图缓存失效。"""
|
||
payload = [server.model_dump() for server in self.get_servers()]
|
||
raw_text = json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str)
|
||
return hashlib.sha256(raw_text.encode("utf-8")).hexdigest()
|
||
|
||
async def initialize_session(self, session) -> None:
|
||
"""完成 MCP initialize 和 initialized 通知流程。"""
|
||
await session.request(
|
||
"initialize",
|
||
{
|
||
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||
"capabilities": {},
|
||
"clientInfo": {
|
||
"name": MCP_CLIENT_NAME,
|
||
"version": "1.0.0",
|
||
},
|
||
},
|
||
)
|
||
await session.notify("notifications/initialized")
|
||
|
||
async def list_server_tools(self, server: AgentMcpServerConfig) -> list[AgentMcpToolSpec]:
|
||
"""连接单个 MCP 服务器并读取工具列表。"""
|
||
normalized_server = self.normalize_server(server)
|
||
session_manager = await _open_mcp_session(normalized_server)
|
||
async with session_manager as session:
|
||
await self.initialize_session(session)
|
||
result = await session.request("tools/list")
|
||
tools_payload = result.get("tools", []) if isinstance(result, dict) else []
|
||
tool_specs: list[AgentMcpToolSpec] = []
|
||
for item in tools_payload:
|
||
if not isinstance(item, dict) or not item.get("name"):
|
||
continue
|
||
tool_name = str(item["name"])
|
||
tool_specs.append(
|
||
AgentMcpToolSpec(
|
||
server=normalized_server,
|
||
name=tool_name,
|
||
agent_tool_name=_build_agent_tool_name(normalized_server, tool_name),
|
||
description=str(item.get("description") or ""),
|
||
input_schema=_normalize_input_schema(item.get("inputSchema")),
|
||
)
|
||
)
|
||
return tool_specs
|
||
|
||
async def list_enabled_tool_specs(self) -> list[AgentMcpToolSpec]:
|
||
"""读取所有启用 MCP 服务器暴露的工具定义。"""
|
||
tool_specs: list[AgentMcpToolSpec] = []
|
||
seen_names: set[str] = set()
|
||
for server in self.get_servers():
|
||
if not server.enabled:
|
||
continue
|
||
try:
|
||
for spec in await self.list_server_tools(server):
|
||
if spec.agent_tool_name in seen_names:
|
||
logger.warning(f"跳过重复的 MCP Agent 工具名: {spec.agent_tool_name}")
|
||
continue
|
||
tool_specs.append(spec)
|
||
seen_names.add(spec.agent_tool_name)
|
||
except Exception as err:
|
||
logger.warning(f"读取 MCP 服务器 {server.name} 工具失败: {err}")
|
||
return tool_specs
|
||
|
||
async def call_server_tool(
|
||
self,
|
||
server: AgentMcpServerConfig,
|
||
tool_name: str,
|
||
arguments: Optional[dict[str, Any]] = None,
|
||
) -> Any:
|
||
"""调用单个 MCP 服务器上的指定工具。"""
|
||
normalized_server = self.normalize_server(server)
|
||
session_manager = await _open_mcp_session(normalized_server)
|
||
async with session_manager as session:
|
||
await self.initialize_session(session)
|
||
return await session.request(
|
||
"tools/call",
|
||
{
|
||
"name": tool_name,
|
||
"arguments": arguments or {},
|
||
},
|
||
)
|
||
|
||
async def test_server(self, server: AgentMcpServerConfig) -> AgentMcpServerTestResult:
|
||
"""测试 MCP 服务器连接并返回工具列表。"""
|
||
tool_specs = await self.list_server_tools(server)
|
||
tools = [
|
||
AgentMcpServerToolInfo(
|
||
name=spec.name,
|
||
agent_tool_name=spec.agent_tool_name,
|
||
description=spec.description,
|
||
input_schema=spec.input_schema,
|
||
)
|
||
for spec in tool_specs
|
||
]
|
||
return AgentMcpServerTestResult(
|
||
success=True,
|
||
message=f"连接成功,发现 {len(tools)} 个工具",
|
||
tools=tools,
|
||
tool_count=len(tools),
|
||
)
|
||
|
||
|
||
agent_mcp_manager = AgentMcpManager()
|