DEBUG.WIKI
V.PS:让项目靠近用户,亚太、欧洲、北美 KVM VPS
IPPeak:从目标地区访问网页,住宅代理、SEO 监测、广告验证
ZgoCloud:多地区部署,按需选节点;日本、美国、香港、德国

Agent 关闭多个 MCP 连接,报 cancel scope 跨 Task 退出

多个 MCP 连接退出时报 cancel scope 跨 Task 错误,检查上下文的任务归属和反向关闭顺序。用同一 Task 的 AsyncExitStack 管理 transport 与 session。

Python Agent 已经收到模型回复,退出时却报:

Attempted to exit cancel scope in a different task than it was entered in
an error occurred during closing of asynchronous generator

如果堆栈落在 ClientSession、stdio_client 或多服务器的 cleanup() 中,检查连接上下文是谁打开、谁关闭,以及关闭顺序。工具调用成功并不证明清理流程正确。

下面用 Python mcp 1.22.0 的上下文接口说明组织方式。这类错误也可能来自应用自己封装的连接清理,升级 SDK 不会替应用改变 Task 归属。不能仅凭异常就认定所有当前 SDK 都存在同一个缺陷。

同一个 Task 打开,也由它按反向顺序关闭

stdio_client 在内部使用 AnyIO task group,ClientSession 也在进入时创建任务组。任务组包含 cancel scope,这些作用域需要在同一个 Task 中按后进先出的顺序退出。

假设同一个 Task 依次打开服务器 A 和 B:

进入 A 的 stdio
  进入 A 的 session
    进入 B 的 stdio
      进入 B 的 session

退出顺序应为 B 的 session、B 的 stdio、A 的 session、A 的 stdio。只改成“逐个关闭”还不够,正序关闭 A 再关闭 B 也会破坏这份嵌套关系。

下面这种清理结构会新建 Task。即使 gather(*jobs) 的参数写法没有问题,也改变了上下文退出的任务归属:

# 错误结构示例:server.initialize() 曾在另一个 Task 中进入上下文。
jobs = [asyncio.create_task(server.cleanup()) for server in servers]
await asyncio.gather(*jobs, return_exceptions=True)

若上下文都由当前 Task 顺序进入,就应让当前 Task 反向退出它们。不要仅捕获 RuntimeError 后继续显示“已关闭”,也不要通过 return_exceptions=True 把失败的清理结果全部丢掉。

用一份 AsyncExitStack 管理连接

下面把两台工具服务器的 transport 与 session 注册到同一个 AsyncExitStack。进入、使用和退出都留在 main() 所属 Task 中;第二台初始化失败时,栈也会对已经进入的上下文发起反向退出。

import asyncio
import sys
from contextlib import AsyncExitStack

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client


async def main() -> None:
    server_paths = [
        "/absolute/path/to/server_a.py",
        "/absolute/path/to/server_b.py",
    ]
    sessions: list[ClientSession] = []

    async with AsyncExitStack() as stack:
        for path in server_paths:
            params = StdioServerParameters(
                command=sys.executable,
                args=[path],
            )
            read_stream, write_stream = await stack.enter_async_context(
                stdio_client(params)
            )
            session = await stack.enter_async_context(
                ClientSession(read_stream, write_stream)
            )
            await session.initialize()
            sessions.append(session)

        for index, session in enumerate(sessions):
            result = await session.list_tools()
            print(index, [tool.name for tool in result.tools])


if __name__ == "__main__":
    asyncio.run(main())

两个路径是占位值,需要替换为本机已有、可信且支持 stdio 的 Python MCP 服务器。sys.executable 使用当前 Python 环境;服务器还需要其他环境变量或工作目录时,按它自己的运行条件显式配置。示例会启动两个子进程并读取工具列表,不调用模型,也不执行工具。

AsyncExitStack 只记录进入和退出关系,不会把跨 Task 的使用自动变正确。不要在一个请求 Task 中 enter_async_context(),随后把 stack.aclose() 交给另一个请求 Task、后台清理 Task 或事件循环退出钩子。手动调用 __aenter__() 与 __aexit__() 时也必须遵守同样条件。

这种写法不要求所有业务工具调用都串行。连接生命周期的拥有者需要固定;要在连接存活期间并发调用工具,还应按客户端能力、服务器约束和业务副作用另外设计,并等待相关任务结束后再关闭连接。

接入 Web 服务后,按生命周期选择连接拥有者

如果连接只供一次请求使用,可以让同一个请求协程用完整的 async with 打开、使用并退出。不要把异步生成器里 yield 出来的连接留给另一个 Task 关闭。

如果连接要跨请求复用,应由持续运行的连接拥有者管理它的完整上下文,通过受控通道接收请求和关闭指令。仅把 session 存进全局变量,或加一把 asyncio.Lock,都不能保证进入和退出发生在同一个 Task;锁解决的是访问协调,任务归属仍需单独确认。

定位时可以在进入、退出位置记录 asyncio.current_task() 及连接名,并列出实际的嵌套顺序。不要只比较可重复的 Task 名字,也不要把客户端任务和 SDK 内部读写任务混为一谈。前者拥有上下文,后者由内部任务组管理。

外层超时或用户取消还可能暴露其他清理问题。保留完整异常堆栈,检查当前 SDK 的关闭条件和应用的取消顺序;不能简单在退出栈顶再套一个 cancel scope,然后先关闭它下面的旧作用域。取消需要继续交给上层处理,不能为了隐藏日志而吞掉所有异常。

改动后检查正常退出、第二台服务器初始化失败,以及运行中收到取消这几种路径。进入和退出应由同一个拥有者按反向顺序完成,子进程应按预期结束,日志不再出现跨 Task 或错误嵌套的清理异常。回退生命周期改动时保留原配置与诊断记录,不把“强制结束所有工具进程”当成正常恢复办法。