"""HTTP MCP客户端 - 使用官方 MCP Python SDK 实现""" import asyncio from typing import Dict, Any, List, Optional from contextlib import asynccontextmanager from mcp import ClientSession, types from mcp.client.streamable_http import streamablehttp_client from pydantic import AnyUrl from app.logger import get_logger logger = get_logger(__name__) class MCPError(Exception): """MCP错误""" pass class HTTPMCPClient: """HTTP模式MCP客户端(基于官方 MCP Python SDK)""" def __init__( self, url: str, headers: Optional[Dict[str, str]] = None, env: Optional[Dict[str, str]] = None, timeout: float = 60.0 ): """ 初始化HTTP MCP客户端 Args: url: MCP服务器URL headers: HTTP请求头 env: 环境变量(用于API Key等) timeout: 超时时间(秒) """ self.url = url.rstrip('/') self.headers = headers or {} self.env = env or {} self.timeout = timeout # 如果env中有API Key,添加到headers if 'API_KEY' in self.env: self.headers['Authorization'] = f'Bearer {self.env["API_KEY"]}' self._session: Optional[ClientSession] = None self._context_stack = [] # 保存上下文管理器栈 self._initialized = False self._lock = asyncio.Lock() async def _ensure_connected(self): """确保连接已建立""" async with self._lock: if self._session is None: try: logger.info(f"🔗 连接到MCP服务器: {self.url}") # 使用官方 SDK 的 streamable_http_client # 保存上下文管理器以便后续正确清理 stream_context = streamablehttp_client(self.url) read_stream, write_stream, _ = await stream_context.__aenter__() self._context_stack.append(('stream', stream_context)) # 创建客户端会话 self._session = ClientSession(read_stream, write_stream) session_context = self._session await session_context.__aenter__() self._context_stack.append(('session', session_context)) # 初始化会话 await self._session.initialize() self._initialized = True logger.info(f"✅ MCP会话初始化成功") except Exception as e: logger.error(f"❌ MCP连接失败: {e}") await self._cleanup() raise MCPError(f"连接MCP服务器失败: {str(e)}") async def _cleanup(self): """清理连接资源(按照进入的相反顺序退出)""" # 按照LIFO顺序清理上下文 while self._context_stack: ctx_type, ctx = self._context_stack.pop() try: await ctx.__aexit__(None, None, None) except RuntimeError as e: # 忽略 anyio 的任务上下文错误(在关闭时可能发生) if "cancel scope" in str(e).lower() or "different task" in str(e).lower(): logger.debug(f"忽略{ctx_type}上下文清理的任务切换警告: {e}") else: logger.error(f"清理{ctx_type}上下文失败: {e}") except Exception as e: logger.error(f"清理{ctx_type}上下文失败: {e}") self._session = None self._initialized = False async def initialize(self) -> Dict[str, Any]: """ 初始化MCP会话 Returns: 初始化响应 """ await self._ensure_connected() return {"status": "initialized"} async def list_tools(self) -> List[Dict[str, Any]]: """ 列举可用工具 Returns: 工具列表 """ try: await self._ensure_connected() result = await self._session.list_tools() # 转换为字典格式 tools = [] for tool in result.tools: tool_dict = { "name": tool.name, "description": tool.description or "", "inputSchema": tool.inputSchema } tools.append(tool_dict) logger.info(f"获取到 {len(tools)} 个工具") return tools except Exception as e: logger.error(f"获取工具列表失败: {e}") raise MCPError(f"获取工具列表失败: {str(e)}") async def call_tool( self, tool_name: str, arguments: Dict[str, Any] ) -> Any: """ 调用工具 Args: tool_name: 工具名称 arguments: 工具参数 Returns: 工具执行结果 """ try: await self._ensure_connected() logger.info(f"调用工具: {tool_name}") logger.debug(f"参数: {arguments}") result = await self._session.call_tool(tool_name, arguments) # 处理返回结果 # MCP SDK 返回 CallToolResult 对象 if result.content: # 提取第一个content的文本 for content in result.content: if isinstance(content, types.TextContent): return content.text elif isinstance(content, types.ImageContent): return { "type": "image", "data": content.data, "mimeType": content.mimeType } # 如果没有文本内容,返回原始内容 return result.content[0] if result.content else None # 如果有结构化内容(2025-06-18规范) if hasattr(result, 'structuredContent') and result.structuredContent: return result.structuredContent return None except Exception as e: logger.error(f"调用工具失败: {tool_name}, 错误: {e}") raise MCPError(f"调用工具失败: {str(e)}") async def list_resources(self) -> List[Dict[str, Any]]: """ 列举可用资源 Returns: 资源列表 """ try: await self._ensure_connected() result = await self._session.list_resources() # 转换为字典格式 resources = [] for resource in result.resources: resource_dict = { "uri": str(resource.uri), "name": resource.name, "description": resource.description or "", "mimeType": resource.mimeType or "" } resources.append(resource_dict) logger.info(f"获取到 {len(resources)} 个资源") return resources except Exception as e: logger.error(f"获取资源列表失败: {e}") raise MCPError(f"获取资源列表失败: {str(e)}") async def read_resource(self, uri: str) -> Any: """ 读取资源 Args: uri: 资源URI Returns: 资源内容 """ try: await self._ensure_connected() result = await self._session.read_resource(AnyUrl(uri)) # 提取资源内容 if result.contents: content = result.contents[0] if isinstance(content, types.TextContent): return content.text elif isinstance(content, types.ImageContent): return { "type": "image", "data": content.data, "mimeType": content.mimeType } elif isinstance(content, types.BlobResourceContents): return { "type": "blob", "blob": content.blob, "mimeType": content.mimeType } return None except Exception as e: logger.error(f"读取资源失败: {uri}, 错误: {e}") raise MCPError(f"读取资源失败: {str(e)}") async def test_connection(self) -> Dict[str, Any]: """ 测试连接 Returns: 测试结果 """ import time start_time = time.time() try: # 尝试连接并列举工具 await self._ensure_connected() tools = await self.list_tools() end_time = time.time() response_time = round((end_time - start_time) * 1000, 2) return { "success": True, "message": "连接测试成功", "response_time_ms": response_time, "tools_count": len(tools), "tools": tools } except Exception as e: end_time = time.time() response_time = round((end_time - start_time) * 1000, 2) return { "success": False, "message": "连接测试失败", "response_time_ms": response_time, "error": str(e), "error_type": type(e).__name__, "suggestions": [ "请检查服务器URL是否正确", "请确认API Key是否有效", "请检查网络连接", "请确认MCP服务器是否在线" ] } async def close(self): """关闭客户端连接""" logger.info(f"关闭MCP客户端: {self.url}") await self._cleanup() @asynccontextmanager async def create_mcp_client( url: str, headers: Optional[Dict[str, str]] = None, env: Optional[Dict[str, str]] = None, timeout: float = 60.0 ): """ 创建MCP客户端的上下文管理器 Args: url: MCP服务器URL headers: HTTP请求头 env: 环境变量 timeout: 超时时间 Yields: HTTPMCPClient实例 """ client = HTTPMCPClient(url, headers, env, timeout) try: await client.initialize() yield client finally: await client.close()