"""服务端MCP客户端"""
|
|
from __future__ import annotations
|
|
from datetime import timedelta
|
import asyncio
|
import os
|
import shutil
|
import concurrent.futures
|
from contextlib import AsyncExitStack
|
from typing import Optional, List, Dict, Any
|
|
from mcp import ClientSession, StdioServerParameters, Implementation
|
from mcp.client.session import SamplingFnT, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT
|
from mcp.client.stdio import stdio_client
|
from mcp.client.sse import sse_client
|
from mcp.client.streamable_http import streamablehttp_client
|
from mcp.shared.session import ProgressFnT
|
|
from config.logger import setup_logging
|
from core.utils.util import sanitize_tool_name
|
|
TAG = __name__
|
|
|
class ServerMCPClient:
|
"""服务端MCP客户端,用于连接和管理MCP服务"""
|
|
def __init__(self, config: Dict[str, Any]):
|
"""初始化服务端MCP客户端
|
|
Args:
|
config: MCP服务配置字典
|
"""
|
self.logger = setup_logging()
|
self.config = config
|
|
self._worker_task: Optional[asyncio.Task] = None
|
self._ready_evt = asyncio.Event()
|
self._shutdown_evt = asyncio.Event()
|
|
self.session: Optional[ClientSession] = None
|
self.tools: List = [] # 原始工具对象
|
self.tools_dict: Dict[str, Any] = {}
|
self.name_mapping: Dict[str, str] = {}
|
|
async def initialize(self, read_timeout_seconds: timedelta | None = None,
|
sampling_callback: SamplingFnT | None = None,
|
elicitation_callback: ElicitationFnT | None = None,
|
list_roots_callback: ListRootsFnT | None = None,
|
logging_callback: LoggingFnT | None = None,
|
message_handler: MessageHandlerFnT | None = None,
|
client_info: Implementation | None = None):
|
"""初始化MCP客户端连接"""
|
if self._worker_task:
|
return
|
|
self._worker_task = asyncio.create_task(
|
self._worker(read_timeout_seconds=read_timeout_seconds,
|
sampling_callback=sampling_callback,
|
elicitation_callback=elicitation_callback,
|
list_roots_callback=list_roots_callback,
|
logging_callback=logging_callback,
|
message_handler=message_handler,
|
client_info=client_info), name="ServerMCPClientWorker"
|
)
|
await self._ready_evt.wait()
|
|
self.logger.bind(tag=TAG).info(
|
f"服务端MCP客户端已连接,可用工具: {[name for name in self.name_mapping.values()]}"
|
)
|
|
async def cleanup(self):
|
"""清理MCP客户端资源"""
|
if not self._worker_task:
|
return
|
|
self._shutdown_evt.set()
|
try:
|
await asyncio.wait_for(self._worker_task, timeout=20)
|
except (asyncio.TimeoutError, Exception) as e:
|
self.logger.bind(tag=TAG).error(f"服务端MCP客户端关闭错误: {e}")
|
finally:
|
self._worker_task = None
|
|
def has_tool(self, name: str) -> bool:
|
"""检查是否包含指定工具
|
|
Args:
|
name: 工具名称
|
|
Returns:
|
bool: 是否包含该工具
|
"""
|
return name in self.tools_dict
|
|
def get_available_tools(self) -> List[Dict[str, Any]]:
|
"""获取所有可用工具的定义
|
|
Returns:
|
List[Dict[str, Any]]: 工具定义列表
|
"""
|
return [
|
{
|
"type": "function",
|
"function": {
|
"name": name,
|
"description": tool.description,
|
"parameters": tool.inputSchema,
|
},
|
}
|
for name, tool in self.tools_dict.items()
|
]
|
|
async def call_tool(self, name: str, arguments: dict, read_timeout_seconds: timedelta | None = None, progress_callback: ProgressFnT | None = None, *, meta: dict[str, Any] | None = None) -> Any:
|
"""调用指定工具
|
|
Args:
|
name: 工具名称
|
arguments: 工具参数
|
read_timeout_seconds:
|
progress_callback: 进度回调函数
|
meta:
|
|
Returns:
|
Any: 工具执行结果
|
|
Raises:
|
RuntimeError: 客户端未初始化时抛出
|
"""
|
if not self.session:
|
raise RuntimeError("服务端MCP客户端未初始化")
|
|
real_name = self.name_mapping.get(name, name)
|
loop = self._worker_task.get_loop()
|
coro = self.session.call_tool(real_name, arguments=arguments, read_timeout_seconds=read_timeout_seconds, progress_callback=progress_callback, meta=meta)
|
|
if loop is asyncio.get_running_loop():
|
return await coro
|
|
fut: concurrent.futures.Future = asyncio.run_coroutine_threadsafe(coro, loop)
|
return await asyncio.wrap_future(fut)
|
|
def is_connected(self) -> bool:
|
"""检查MCP客户端是否连接正常
|
|
Returns:
|
bool: 如果客户端已连接并正常工作,返回True,否则返回False
|
"""
|
# 检查工作任务是否存在
|
if self._worker_task is None:
|
return False
|
|
# 检查工作任务是否已经完成或取消
|
if self._worker_task.done():
|
return False
|
|
# 检查会话是否存在
|
if self.session is None:
|
return False
|
|
# 所有检查都通过,连接正常
|
return True
|
|
async def _worker(self, read_timeout_seconds: timedelta | None = None,
|
sampling_callback: SamplingFnT | None = None,
|
elicitation_callback: ElicitationFnT | None = None,
|
list_roots_callback: ListRootsFnT | None = None,
|
logging_callback: LoggingFnT | None = None,
|
message_handler: MessageHandlerFnT | None = None,
|
client_info: Implementation | None = None):
|
"""MCP客户端工作协程"""
|
async with AsyncExitStack() as stack:
|
try:
|
# 建立 StdioClient
|
if "command" in self.config:
|
cmd = (
|
shutil.which("npx")
|
if self.config["command"] == "npx"
|
else self.config["command"]
|
)
|
env = {**os.environ, **self.config.get("env", {})}
|
params = StdioServerParameters(
|
command=cmd,
|
args=self.config.get("args", []),
|
env=env,
|
)
|
stdio_r, stdio_w = await stack.enter_async_context(
|
stdio_client(params)
|
)
|
read_stream, write_stream = stdio_r, stdio_w
|
|
# 建立SSEClient
|
elif "url" in self.config:
|
headers = dict(self.config.get("headers", {}))
|
# TODO 兼容旧版本
|
if "API_ACCESS_TOKEN" in self.config:
|
headers["Authorization"] = f"Bearer {self.config['API_ACCESS_TOKEN']}"
|
self.logger.bind(tag=TAG).warning(f"你正在使用旧过时的配置 API_ACCESS_TOKEN ,请在.mcp_server_settings.json中将API_ACCESS_TOKEN直接设置在headers中,例如 'Authorization': 'Bearer API_ACCESS_TOKEN'")
|
|
# 根据transport类型选择不同的客户端,默认为SSE
|
transport_type = self.config.get("transport", "sse")
|
|
if transport_type == "streamable-http" or transport_type == "http":
|
# 使用 Streamable HTTP 传输
|
http_r, http_w, get_session_id = await stack.enter_async_context(
|
streamablehttp_client(
|
url=self.config["url"],
|
headers=headers,
|
timeout=self.config.get("timeout", 30),
|
sse_read_timeout=self.config.get("sse_read_timeout", 60 * 5),
|
terminate_on_close=self.config.get("terminate_on_close", True)
|
)
|
)
|
read_stream, write_stream = http_r, http_w
|
else:
|
# 使用传统的 SSE 传输
|
sse_r, sse_w = await stack.enter_async_context(
|
sse_client(
|
url=self.config["url"],
|
headers=headers,
|
timeout=self.config.get("timeout", 5),
|
sse_read_timeout=self.config.get("sse_read_timeout", 60 * 5)
|
)
|
)
|
read_stream, write_stream = sse_r, sse_w
|
|
else:
|
raise ValueError("MCP客户端配置必须包含'command'或'url'")
|
|
self.session = await stack.enter_async_context(
|
ClientSession(
|
read_stream=read_stream,
|
write_stream=write_stream,
|
read_timeout_seconds=read_timeout_seconds,
|
sampling_callback=sampling_callback,
|
elicitation_callback=elicitation_callback,
|
list_roots_callback=list_roots_callback,
|
logging_callback=logging_callback,
|
message_handler=message_handler,
|
client_info=client_info
|
)
|
)
|
await self.session.initialize()
|
|
# 获取工具
|
self.tools = (await self.session.list_tools()).tools
|
for t in self.tools:
|
sanitized = sanitize_tool_name(t.name)
|
self.tools_dict[sanitized] = t
|
self.name_mapping[sanitized] = t.name
|
|
self._ready_evt.set()
|
|
# 挂起等待关闭
|
await self._shutdown_evt.wait()
|
|
except Exception as e:
|
self.logger.bind(tag=TAG).error(f"服务端MCP客户端工作协程错误: {e}")
|
self._ready_evt.set()
|
raise
|