fix(MCP): 重构MCPClient为后台协程 + AsyncExitStack管理,解决进程退出时的“Attempted to exit cancel scope in a different task”错误

# 变更
----
- 将所有stdio_client与ClientSession的创建/销毁都放到同一个后台 task 中
- 使用AsyncExitStack托管异步资源,cleanup时在同一task内执行exit_stack.aclose()
- 外部只通过事件通知后台task退出,避免跨协程调用cancel-scope异常
This commit is contained in:
caixypromise
2025-05-04 23:27:47 +08:00
parent 9fc1285c09
commit 0a765f4aac
2 changed files with 137 additions and 77 deletions
+128 -69
View File
@@ -1,86 +1,145 @@
from __future__ import annotations
import asyncio, os, shutil, concurrent.futures
from datetime import timedelta
from typing import Optional
from contextlib import AsyncExitStack
import os, shutil
from typing import Optional, List
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from config.logger import setup_logging
TAG = __name__
class MCPClient:
def __init__(self, config):
# Initialize session and client objects
self.session: Optional[ClientSession] = None
self.exit_stack = AsyncExitStack()
def __init__(self, config: dict):
self.logger = setup_logging()
self.config = config
self.tolls = []
# Backworker task & 状态同步
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 = []
async def initialize(self):
args = self.config.get("args", [])
"""
启动后台 task,并等待其就绪(拿到 `tools`)。
"""
if self._worker_task:
return # 已经 init 过
command = (
shutil.which("npx")
if self.config["command"] == "npx"
else self.config["command"]
)
env={**os.environ}
if self.config.get("env"):
env.update(self.config["env"])
server_params = StdioServerParameters(
command=command,
args=args,
env=env
)
stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params))
self.stdio, self.write = stdio_transport
time_out_delta = timedelta(seconds=15)
self.session = await self.exit_stack.enter_async_context(ClientSession(read_stream=self.stdio, write_stream=self.write, read_timeout_seconds=time_out_delta))
await self.session.initialize()
# List available tools
response = await self.session.list_tools()
tools = response.tools
self.tools = tools
self.logger.bind(tag=TAG).info(f"Connected to server with tools:{[tool.name for tool in tools]}")
def has_tool(self, tool_name):
return any(tool.name == tool_name for tool in self.tools)
def get_available_tools(self):
available_tools = [{"type": "function", "function":{
"name": tool.name,
"description": tool.description,
"parameters": tool.inputSchema
} } for tool in self.tools]
# 在当前 loop 创建后台 task
self._worker_task = asyncio.create_task(self._worker(), name="MCPClientWorker")
await self._ready_evt.wait() # 等待 worker 初始化完成
return available_tools
async def call_tool(self, tool_name: str, tool_args: dict):
self.logger.bind(tag=TAG).info(f"MCPClient Calling tool {tool_name} with args: {tool_args}")
try:
response = await self.session.call_tool(tool_name, tool_args)
except Exception as e:
self.logger.bind(tag=TAG).error(f"Error calling tool {tool_name}: {e}")
from types import SimpleNamespace
error_content = SimpleNamespace(
type='text',
text=f"Error calling tool {tool_name}: {e}"
)
error_response = SimpleNamespace(
content=[error_content],
isError=True
)
return error_response
self.logger.bind(tag=TAG).info(f"MCPClient Response from tool {tool_name}: {response}")
return response
# 此时 tools 已填充
self.logger.bind(tag=TAG).info(
f"Connected, tools = {[t.name for t in self.tools]}"
)
async def cleanup(self):
"""Clean up resources"""
await self.exit_stack.aclose()
"""
对外关闭接口:
· 只负责发出 “关机信号”
· 等待后台 task 正常退出
在任何 loop / task 调用都安全。
"""
if not self._worker_task:
return
self._shutdown_evt.set() # 发信号
try:
await asyncio.wait_for(self._worker_task, timeout=15)
except (asyncio.TimeoutError, Exception) as e:
self.logger.bind(tag=TAG).error(f"worker shutdown err: {e}")
finally:
self._worker_task = None
# ----------------------------- 工具接口 -----------------------------
def has_tool(self, name: str) -> bool:
return any(t.name == name for t in self.tools)
def get_available_tools(self):
return [
{
"type": "function",
"function": {
"name": t.name,
"description": t.description,
"parameters": t.inputSchema,
},
}
for t in self.tools
]
async def call_tool(self, name: str, args: dict):
"""
转发到 session.call_tool。
若在 worker 之外的 task 调用,会通过 run_coroutine_threadsafe
投递到 worker 所在 loop 中执行,保证线程安全。
"""
if not self.session: # 尚未就绪
raise RuntimeError("MCPClient not initialized")
loop = self._worker_task.get_loop()
coro = self.session.call_tool(name, args)
# 在同一个 loop ➜ 直接 await
if loop is asyncio.get_running_loop():
return await coro
# 跨 loop ➜ run_coroutine_threadsafe
fut: concurrent.futures.Future = asyncio.run_coroutine_threadsafe(coro, loop)
return await asyncio.wrap_future(fut)
# ----------------------------- 后台 task -----------------------------
async def _worker(self):
"""
单线程协程:
1. 创建所有异步资源
2. set_ready → 供外部使用
3. 等待 shutdown_evt
4. 自动随 AsyncExitStack 退出而清理资源
"""
async with AsyncExitStack() as stack:
try:
# ---------- 启动后端进程 ----------
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))
# ---------- 会话 ----------
self.session = await stack.enter_async_context(
ClientSession(
read_stream=stdio_r,
write_stream=stdio_w,
read_timeout_seconds=timedelta(seconds=15),
)
)
await self.session.initialize()
# ---------- 工具 ----------
self.tools = (await self.session.list_tools()).tools
# 初始化完成,放行外部
self._ready_evt.set()
# ---------- 挂起等待关闭 ----------
await self._shutdown_evt.wait()
except Exception as e:
self.logger.bind(tag=TAG).error(f"worker error: {e}")
self._ready_evt.set() # 确保外部不会卡死
raise
+9 -8
View File
@@ -1,5 +1,5 @@
"""MCP服务管理器"""
import asyncio
import os, json
from typing import Dict, Any, List
from .MCPClient import MCPClient
@@ -94,8 +94,8 @@ class MCPManager:
"""
for tool in self.tools:
if (
tool.get("function") != None
and tool["function"].get("name") == tool_name
tool.get("function") != None
and tool["function"].get("name") == tool_name
):
return True
return False
@@ -120,12 +120,13 @@ class MCPManager:
raise ValueError(f"Tool {tool_name} not found in any MCP server")
async def cleanup_all(self) -> None:
for name, client in self.client.items():
"""依次关闭所有 MCPClient,不让异常阻断整体流程。"""
for name, client in list(self.client.items()):
try:
await client.cleanup()
self.logger.bind(tag=TAG).info(f"Cleaned up MCP client: {name}")
except Exception as e:
await asyncio.wait_for(client.cleanup(), timeout=20)
self.logger.bind(tag=TAG).info(f"MCP client closed: {name}")
except (asyncio.TimeoutError, Exception) as e:
self.logger.bind(tag=TAG).error(
f"Error cleaning up MCP client {name}: {e}"
f"Error closing MCP client {name}: {e}"
)
self.client.clear()