""" MCP 客户端桥接 ============== 连接外部 MCP Server,把它们的 tools 提供给自动回复链路调用, 让客服 AI 能查文档/挂号/业务系统等,回复更精准。 服务器列表配置在 ai_config.AI_MCP_SERVERS,例如: [ { "name": "filesystem", "enabled": true, "transport": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "D:/docs"], "env": {}, "cwd": null } ] """ from __future__ import annotations import asyncio import json import os import re from contextlib import AsyncExitStack from typing import Any, Optional import ai_config # OpenAI / DeepSeek function name 只允许 [a-zA-Z0-9_-] _SAFE = re.compile(r"[^a-zA-Z0-9_-]+") def _safe_name(server: str, tool: str) -> str: s = _SAFE.sub("_", server.strip()) or "srv" t = _SAFE.sub("_", tool.strip()) or "tool" return f"{s}__{t}" def _parse_name(namespaced: str) -> tuple[str, str]: if "__" not in namespaced: raise ValueError(f"invalid tool name (expect server__tool): {namespaced}") server, tool = namespaced.split("__", 1) return server, tool def _enabled_servers() -> list[dict]: raw = getattr(ai_config, "AI_MCP_SERVERS", None) or [] out = [] for i, s in enumerate(raw): if not isinstance(s, dict): continue if s.get("enabled", True) is False: continue name = (s.get("name") or f"server{i}").strip() transport = (s.get("transport") or "stdio").strip().lower() if transport == "stdio" and not s.get("command"): continue if transport in ("sse", "http", "streamable_http") and not s.get("url"): continue cfg = dict(s) cfg["name"] = name cfg["transport"] = transport out.append(cfg) return out def _tool_result_to_text(result: Any) -> str: """把 MCP CallToolResult 转成发给模型的纯文本。""" try: if result is None: return "" # CallToolResult: content list of TextContent / ImageContent / ... parts = [] content = getattr(result, "content", None) if content is None and isinstance(result, dict): content = result.get("content") if not content: # 兜底序列化 return json.dumps(result, ensure_ascii=False, default=str)[:8000] for block in content: btype = getattr(block, "type", None) or (block.get("type") if isinstance(block, dict) else None) if btype == "text": text = getattr(block, "text", None) or (block.get("text") if isinstance(block, dict) else "") parts.append(text or "") else: parts.append(json.dumps(block, ensure_ascii=False, default=str)) text = "\n".join(p for p in parts if p) is_err = getattr(result, "isError", False) if is_err: return f"[tool error]\n{text}"[:8000] return text[:8000] except Exception as e: return f"[tool result parse error] {e}" class McpHub: """ 一次回复周期内的 MCP 连接池。 用法: async with McpHub() as hub: tools = hub.openai_tools() text = await hub.call_tool("srv__search", {"q": "..."}) """ def __init__(self, servers: list[dict] | None = None): self._cfgs = servers if servers is not None else _enabled_servers() self._stack: Optional[AsyncExitStack] = None self._sessions: dict[str, Any] = {} # server_name -> ClientSession self._tool_meta: dict[str, dict] = {} # namespaced -> {server, tool, schema...} self._openai_tools: list[dict] = [] @property def server_names(self) -> list[str]: return list(self._sessions.keys()) @property def tool_count(self) -> int: return len(self._openai_tools) def openai_tools(self) -> list[dict]: return list(self._openai_tools) async def __aenter__(self) -> "McpHub": self._stack = AsyncExitStack() await self._stack.__aenter__() for cfg in self._cfgs: try: await self._connect(cfg) except Exception as e: print(f" [MCP] 连接失败 [{cfg.get('name')}]: {e}") return self async def __aexit__(self, *exc): if self._stack is not None: await self._stack.__aexit__(*exc) self._sessions.clear() self._tool_meta.clear() self._openai_tools.clear() self._stack = None async def _connect(self, cfg: dict): name = cfg["name"] transport = cfg["transport"] if transport == "stdio": from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client env = os.environ.copy() extra = cfg.get("env") or {} env.update({str(k): str(v) for k, v in extra.items()}) params = StdioServerParameters( command=cfg["command"], args=list(cfg.get("args") or []), env=env, cwd=cfg.get("cwd") or None, ) read, write = await self._stack.enter_async_context(stdio_client(params)) session = await self._stack.enter_async_context(ClientSession(read, write)) elif transport in ("sse", "http", "streamable_http"): # 优先 streamable HTTP,其次 SSE(视 SDK 版本而定) url = cfg["url"] headers = cfg.get("headers") or {} session = None try: from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client read, write, _ = await self._stack.enter_async_context( streamablehttp_client(url, headers=headers) ) session = await self._stack.enter_async_context(ClientSession(read, write)) except Exception: from mcp import ClientSession from mcp.client.sse import sse_client read, write = await self._stack.enter_async_context( sse_client(url, headers=headers) ) session = await self._stack.enter_async_context(ClientSession(read, write)) else: raise ValueError(f"unsupported transport: {transport}") await session.initialize() self._sessions[name] = session listed = await session.list_tools() tools = getattr(listed, "tools", None) or [] for tool in tools: tname = getattr(tool, "name", "") or "" if not tname: continue ns = _safe_name(name, tname) desc = getattr(tool, "description", None) or f"MCP tool {tname} from {name}" schema = getattr(tool, "inputSchema", None) or {"type": "object", "properties": {}} if isinstance(schema, dict): params = schema else: # pydantic model try: params = schema if isinstance(schema, dict) else dict(schema) except Exception: params = {"type": "object", "properties": {}} self._tool_meta[ns] = { "server": name, "tool": tname, "description": desc, } self._openai_tools.append({ "type": "function", "function": { "name": ns, "description": f"[{name}] {desc}", "parameters": params, }, }) print(f" [MCP] 已连接 [{name}],工具 {len(tools)} 个") async def call_tool(self, namespaced: str, arguments: dict | None = None) -> str: server, tool = _parse_name(namespaced) # 若命名被 safe 化,用 meta 反查真实 tool 名 meta = self._tool_meta.get(namespaced) if meta: server = meta["server"] tool = meta["tool"] session = self._sessions.get(server) if session is None: return f"[MCP] server not connected: {server}" try: result = await session.call_tool(tool, arguments=arguments or {}) return _tool_result_to_text(result) except Exception as e: return f"[MCP] call_tool failed: {e}" def run_coro(coro): """在同步代码里跑一段 async(回复线程用)。""" try: loop = asyncio.get_running_loop() except RuntimeError: loop = None if loop and loop.is_running(): # 极少见:已在事件循环中 → 新开线程跑 import concurrent.futures with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: return pool.submit(asyncio.run, coro).result() return asyncio.run(coro) async def probe_servers(servers: list[dict] | None = None) -> dict: """探测 MCP 服务器连通性与工具列表(供 GUI 测试)。""" async with McpHub(servers) as hub: tools = [ {"name": n, "server": m["server"], "tool": m["tool"], "description": m["description"]} for n, m in hub._tool_meta.items() ] return { "ok": True, "servers": hub.server_names, "tool_count": hub.tool_count, "tools": tools, }