2025-11-08 12:32:32 +08:00
|
|
|
|
"""HTTP MCP客户端 - 使用官方 MCP Python SDK 实现"""
|
|
|
|
|
|
import asyncio
|
2025-11-07 22:14:20 +08:00
|
|
|
|
from typing import Dict, Any, List, Optional
|
2025-11-08 12:32:32 +08:00
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
|
|
|
|
|
|
|
|
from mcp import ClientSession, types
|
|
|
|
|
|
from mcp.client.streamable_http import streamablehttp_client
|
|
|
|
|
|
from pydantic import AnyUrl
|
|
|
|
|
|
|
2025-11-07 22:14:20 +08:00
|
|
|
|
from app.logger import get_logger
|
|
|
|
|
|
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MCPError(Exception):
|
|
|
|
|
|
"""MCP错误"""
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class HTTPMCPClient:
|
2025-11-08 12:32:32 +08:00
|
|
|
|
"""HTTP模式MCP客户端(基于官方 MCP Python SDK)"""
|
2025-11-07 22:14:20 +08:00
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
url: str,
|
|
|
|
|
|
headers: Optional[Dict[str, str]] = None,
|
|
|
|
|
|
env: Optional[Dict[str, str]] = None,
|
2025-11-08 12:32:32 +08:00
|
|
|
|
timeout: float = 60.0
|
2025-11-07 22:14:20 +08:00
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
初始化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"]}'
|
|
|
|
|
|
|
2025-11-08 12:32:32 +08:00
|
|
|
|
self._session: Optional[ClientSession] = None
|
|
|
|
|
|
self._context_stack = [] # 保存上下文管理器栈
|
|
|
|
|
|
self._initialized = False
|
|
|
|
|
|
self._lock = asyncio.Lock()
|
2025-11-07 22:14:20 +08:00
|
|
|
|
|
2025-11-08 12:32:32 +08:00
|
|
|
|
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)}")
|
2025-11-07 22:14:20 +08:00
|
|
|
|
|
2025-11-08 12:32:32 +08:00
|
|
|
|
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}")
|
2025-11-07 22:14:20 +08:00
|
|
|
|
|
2025-11-08 12:32:32 +08:00
|
|
|
|
self._session = None
|
|
|
|
|
|
self._initialized = False
|
2025-11-07 22:14:20 +08:00
|
|
|
|
|
2025-11-08 12:32:32 +08:00
|
|
|
|
async def initialize(self) -> Dict[str, Any]:
|
2025-11-07 22:14:20 +08:00
|
|
|
|
"""
|
2025-11-08 12:32:32 +08:00
|
|
|
|
初始化MCP会话
|
2025-11-07 22:14:20 +08:00
|
|
|
|
|
|
|
|
|
|
Returns:
|
2025-11-08 12:32:32 +08:00
|
|
|
|
初始化响应
|
2025-11-07 22:14:20 +08:00
|
|
|
|
"""
|
2025-11-08 12:32:32 +08:00
|
|
|
|
await self._ensure_connected()
|
|
|
|
|
|
return {"status": "initialized"}
|
2025-11-07 22:14:20 +08:00
|
|
|
|
|
|
|
|
|
|
async def list_tools(self) -> List[Dict[str, Any]]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
列举可用工具
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
工具列表
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
2025-11-08 12:32:32 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2025-11-07 22:14:20 +08:00
|
|
|
|
logger.info(f"获取到 {len(tools)} 个工具")
|
|
|
|
|
|
return tools
|
2025-11-08 12:32:32 +08:00
|
|
|
|
|
2025-11-07 22:14:20 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"获取工具列表失败: {e}")
|
2025-11-08 12:32:32 +08:00
|
|
|
|
raise MCPError(f"获取工具列表失败: {str(e)}")
|
2025-11-07 22:14:20 +08:00
|
|
|
|
|
|
|
|
|
|
async def call_tool(
|
|
|
|
|
|
self,
|
|
|
|
|
|
tool_name: str,
|
|
|
|
|
|
arguments: Dict[str, Any]
|
|
|
|
|
|
) -> Any:
|
|
|
|
|
|
"""
|
|
|
|
|
|
调用工具
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
tool_name: 工具名称
|
|
|
|
|
|
arguments: 工具参数
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
工具执行结果
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
2025-11-08 12:32:32 +08:00
|
|
|
|
await self._ensure_connected()
|
|
|
|
|
|
|
2025-11-07 22:14:20 +08:00
|
|
|
|
logger.info(f"调用工具: {tool_name}")
|
|
|
|
|
|
logger.debug(f"参数: {arguments}")
|
|
|
|
|
|
|
2025-11-08 12:32:32 +08:00
|
|
|
|
result = await self._session.call_tool(tool_name, arguments)
|
2025-11-07 22:14:20 +08:00
|
|
|
|
|
2025-11-08 12:32:32 +08:00
|
|
|
|
# 处理返回结果
|
|
|
|
|
|
# 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-11-07 22:14:20 +08:00
|
|
|
|
|
2025-11-08 12:32:32 +08:00
|
|
|
|
# 如果有结构化内容(2025-06-18规范)
|
|
|
|
|
|
if hasattr(result, 'structuredContent') and result.structuredContent:
|
|
|
|
|
|
return result.structuredContent
|
|
|
|
|
|
|
|
|
|
|
|
return None
|
2025-11-07 22:14:20 +08:00
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"调用工具失败: {tool_name}, 错误: {e}")
|
2025-11-08 12:32:32 +08:00
|
|
|
|
raise MCPError(f"调用工具失败: {str(e)}")
|
2025-11-07 22:14:20 +08:00
|
|
|
|
|
|
|
|
|
|
async def list_resources(self) -> List[Dict[str, Any]]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
列举可用资源
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
资源列表
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
2025-11-08 12:32:32 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2025-11-07 22:14:20 +08:00
|
|
|
|
logger.info(f"获取到 {len(resources)} 个资源")
|
|
|
|
|
|
return resources
|
2025-11-08 12:32:32 +08:00
|
|
|
|
|
2025-11-07 22:14:20 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"获取资源列表失败: {e}")
|
2025-11-08 12:32:32 +08:00
|
|
|
|
raise MCPError(f"获取资源列表失败: {str(e)}")
|
2025-11-07 22:14:20 +08:00
|
|
|
|
|
|
|
|
|
|
async def read_resource(self, uri: str) -> Any:
|
|
|
|
|
|
"""
|
|
|
|
|
|
读取资源
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
uri: 资源URI
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
资源内容
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
2025-11-08 12:32:32 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2025-11-07 22:14:20 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"读取资源失败: {uri}, 错误: {e}")
|
2025-11-08 12:32:32 +08:00
|
|
|
|
raise MCPError(f"读取资源失败: {str(e)}")
|
2025-11-07 22:14:20 +08:00
|
|
|
|
|
|
|
|
|
|
async def test_connection(self) -> Dict[str, Any]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
测试连接
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
测试结果
|
|
|
|
|
|
"""
|
2025-11-08 12:32:32 +08:00
|
|
|
|
import time
|
2025-11-07 22:14:20 +08:00
|
|
|
|
start_time = time.time()
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2025-11-08 12:32:32 +08:00
|
|
|
|
# 尝试连接并列举工具
|
|
|
|
|
|
await self._ensure_connected()
|
2025-11-07 22:14:20 +08:00
|
|
|
|
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": [
|
2025-11-08 12:32:32 +08:00
|
|
|
|
"请检查服务器URL是否正确",
|
|
|
|
|
|
"请确认API Key是否有效",
|
|
|
|
|
|
"请检查网络连接",
|
|
|
|
|
|
"请确认MCP服务器是否在线"
|
2025-11-07 22:14:20 +08:00
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async def close(self):
|
2025-11-08 12:32:32 +08:00
|
|
|
|
"""关闭客户端连接"""
|
|
|
|
|
|
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()
|