mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-22 07:03:53 +08:00
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:
@@ -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 = []
|
||||
|
||||
# Back‑worker 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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user