Skip to content

RuntimeError: Attempted to exit cancel scope in a different task when cleaning up multiple MCPClient instances out-of-order #577

Open

Description

@HMJiangGatech

Describe the bug
If two MCPClient objects are instantiated and cleaned up in non-FILO order (i.e., the first-created client is cleaned up before the second), teardown fails with a cascade of RuntimeError/CancelledError exceptions coming from anyio and mcp.client.stdio.

To Reproduce
Minimal repro:

import os, asyncio, json
from typing import Optional
from contextlib import AsyncExitStack
from mcp import ClientSession, StdioServerParameters
from mcp.types import TextContent
from mcp.client.stdio import stdio_client

class MCPClient:
    def __init__(self, command: str, args: list[str], env: Optional[dict] = None):
        self.session: Optional[ClientSession] = None
        self.command, self.args, self.env = command, args, env
        self._cleanup_lock = asyncio.Lock()
        self.exit_stack: Optional[AsyncExitStack] = None

    async def connect_to_server(self):
        await self.cleanup()
        self.exit_stack = AsyncExitStack()

        server_params = StdioServerParameters(
            command=self.command, args=self.args, env=self.env
        )
        stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params))
        self.stdio, self.write = stdio_transport
        self.session = await self.exit_stack.enter_async_context(
            ClientSession(self.stdio, self.write)
        )
        await self.session.initialize()

    async def cleanup(self):
        if self.exit_stack:
            async with self._cleanup_lock:
                await self.exit_stack.aclose()
                self.session = None
            self.exit_stack = None

async def main():
    cfg = {
        "command": "npx",
        "args": ["-y", "@adenot/mcp-google-search"],
        "env": {
            "GOOGLE_API_KEY": os.environ["GOOGLE_API_KEY"],
            "GOOGLE_SEARCH_ENGINE_ID": os.environ["GOOGLE_SEARCH_ENGINE_ID"],
        },
    }

    c1, c2 = MCPClient(**cfg), MCPClient(**cfg)
    await c1.connect_to_server()
    await c2.connect_to_server()

    # Works (FILO)
    # await c2.cleanup()
    # await c1.cleanup()

    # Fails (FIFO)
    await c1.cleanup()      # <-- boom
    await c2.cleanup()

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

Expected behavior

cleanup() should succeed regardless of the order in which multiple MCPClient instances are closed, as long as each instance’s own exit_stack is intact. A single client ought to manage its own lifetime without depending on external FILO discipline.

Actual Traceback

RuntimeError: Attempted to exit cancel scope in a different task than it was entered in
...
asyncio.exceptions.CancelledError: Cancelled by cancel scope ...
...
RuntimeError: Attempted to exit a cancel scope that isn't the current task's current cancel scope

Environment

Item Version
mcp 1.6.0
Python 3.12.10
anyio 4.9.0
OS macOS 14.4 (Apple Silicon)

Activity

  1. mashriram commented on May 2, 2025

    @mashriram

    Facing the same issue when connectng trying to use FastAPI and async methods with mcp to work with frontend
    The error simply like this and it starts at mcp libraries sse.py and flows down to below
    ``

    ~~~~~~~~~~~~~~~~~~~~~~~^^
    File "/home/mukundan/projects/mcp-sse/.venv/lib/python3.13/site-packages/anyio/_backends/_asyncio.py", line 773, in aexit
    if self.cancel_scope.exit(type(exc), exc, exc.traceback):
    ~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    File "/home/mukundan/projects/mcp-sse/.venv/lib/python3.13/site-packages/anyio/_backends/_asyncio.py", line 456, in exit
    raise RuntimeError(
    ...<2 lines>...
    )
    RuntimeError: Attempted to exit cancel scope in a different task than it was entered in```
    
  2. pitta-bread commented on May 12, 2025

    @pitta-bread

    Faced the same issue. My custom context manager class was doing simple iteration of the clients list to attempt cleanup (FIFO). In line with the workaround detailed above, fixed with FILO:

    e.g.

    finally:
        # Clean up in the same task context
        # MUST BE FILO, FIFO fails
        # https://github.com/modelcontextprotocol/python-sdk/issues/577
        for client in reversed(self.clients):
            try:
                await client.cleanup()
  3. CuriousTank commented on May 21, 2025

    @CuriousTank

    How to achieve free management of multiple MCPClient instances under such conditions, enabling instant exit and connection establishment

  4. jhaoming-oai commented on May 21, 2025

    @jhaoming-oai

    Trying OpenAI Codex to fix the issue:
    https://chatgpt.com/s/cd_682dec27cf788191b2892b8097d45248

  5. vincent-pli commented on Jun 20, 2025

    @vincent-pli
    Contributor

    any progress? why must follow the FIFO

  6. added
    bugSomething isn't working
    ready for workEnough information for someone to start working on
    P2Moderate issues affecting some users, edge cases, potentially valuable feature
    P1Significant bug affecting many users, highly requested feature
    and removed
    P2Moderate issues affecting some users, edge cases, potentially valuable feature
    on Sep 26, 2025
  7. felixweinberger commented on Sep 26, 2025

    @felixweinberger
    Contributor

    Hi @HMJiangGatech thanks for reporting this and providing a detailed repro.

  8. Ti1228 commented on Oct 11, 2025

    @Ti1228

    I think mcp.client.stdio.stdio_client should explicitly cancel its TaskGroup inside the context manager’s aexit (i.e., in the finally block) before shutting down the subprocess. That way, the background reader/writer tasks are cancelled by the same task that created the scope, avoiding cross-task scope exit.

    Proposed diff:

     @asynccontextmanager
     async def stdio_client(server: StdioServerParameters, ...):
         ...
    -    async with (anyio.create_task_group() as tg, process):
    +    async with anyio.create_task_group() as tg, process:
             tg.start_soon(stdout_reader)
             tg.start_soon(stdin_writer)
             try:
                 yield read_stream, write_stream
             finally:
    -            # [Existing cleanup: close stdin, wait for process, kill if needed]
    +            # Cancel background tasks before closing process to avoid cross-task cancellation
    +            tg.cancel_scope.cancel()
    +            # Proceed with process shutdown
                 if process.stdin:
                     try:
                         await process.stdin.aclose()
                     except Exception:
                         pass
                 try:
                     with anyio.fail_after(PROCESS_TERMINATION_TIMEOUT):
                         await process.wait()
                 except TimeoutError:
                     await _terminate_process_tree(process)
                 except ProcessLookupError:
                     pass

    Since this is my first time leaving a comment on GitHub, I apologize in advance if I said anything awkward or inappropriate.
    Thanks

  9. cbcoutinho commented on Oct 18, 2025

    @cbcoutinho
    Contributor

    After running into the same problem, I did a deep investigation into this issue and wanted to share what I found.

    TL;DR

    This is a fundamental limitation of anyio's structured concurrency model when combined with AsyncExitStack. The error occurs because anyio enforces strict LIFO (Last-In-First-Out) cleanup order for cancel scopes, but AsyncExitStack allows arbitrary cleanup order.

    Root Cause

    When you create multiple MCP clients using AsyncExitStack:

    1. Each client creates a task group with an associated cancel scope
    2. These cancel scopes form a stack that anyio expects to be exited in LIFO order
    3. When you clean up clients in FIFO order (first-created first), you're trying to exit an "inner" cancel scope while an "outer" one is still active
    4. AnyIO detects this as "exiting a cancel scope in a different task than it was entered in" because the scope stack is out of order

    Why the Suggested Fix Doesn't Work

    @Ti1228's suggestion to add tg.cancel_scope.cancel() at the start of the finally block is good practice, but unfortunately doesn't solve this specific problem because:

    • The error occurs during the task group's __aexit__ method before our finally block completes
    • The issue is with the order of exiting the context managers themselves, not just the background tasks

    I tested this approach extensively and the error persists.

    This is By Design

    After researching anyio's issue tracker, I found that this behavior is intentional. From the anyio maintainer (issue agronholm/anyio#345):

    "Yield in general cannot be used in a task group context, which is a fundamental limitation of structured concurrency frameworks like AnyIO and Trio."

    The combination of async generators (which stdio_client uses) and arbitrary cleanup order violates anyio's structured concurrency guarantees.

    Workarounds

    Since this is a limitation of the anyio framework rather than a bug in the MCP SDK, here are the recommended approaches:

    Option 1: Ensure LIFO Cleanup (Recommended)

    class MCPClient:
        # ... your existing code ...
    
    async def main():
        cfg = {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]}
    
        clients = []
        c1 = MCPClient(**cfg)
        await c1.connect_to_server()
        clients.append(c1)
    
        c2 = MCPClient(**cfg)
        await c2.connect_to_server()
        clients.append(c2)
    
        # Do work...
    
        # Clean up in LIFO order (last-created first)
        for client in reversed(clients):
            await client.cleanup()

    Option 2: Use Context Managers Directly

    Instead of AsyncExitStack, use nested context managers which guarantee LIFO cleanup:

    async def main():
        server_params1 = StdioServerParameters(...)
        server_params2 = StdioServerParameters(...)
    
        async with stdio_client(server_params1) as (read1, write1):
            async with ClientSession(read1, write1) as session1:
                await session1.initialize()
    
                async with stdio_client(server_params2) as (read2, write2):
                    async with ClientSession(read2, write2) as session2:
                        await session2.initialize()
    
                        # Both clients available here
                        # They will automatically clean up in LIFO order

    Option 3: Use Separate Event Loops

    If you truly need independent cleanup order, run each client in its own async context:

    async def run_client(cfg):
        async with AsyncExitStack() as stack:
            # Each client in its own context
            ...
    
    await asyncio.gather(
        run_client(cfg1),
        run_client(cfg2)
    )

    Should This Be Fixed in the SDK?

    The MCP SDK is working correctly within anyio's constraints. This is a documentation issue rather than a code bug. We should:

    1. Document this limitation in the SDK documentation
    2. Provide examples of proper multi-client management
    3. Consider adding a ClientManager helper class that enforces LIFO cleanup

    Related Issues

    Hope this helps clarify what's happening!

  10. DouweM commented on Oct 24, 2025

    @DouweM

    @cbcoutinho Thanks for looking into this! However, doesn't AsyncExitStack already clean up in LIFO order? ExitStack is explicitly documented at doing so, so I'd expect AsyncExitStack do be the same:

    https://docs.python.org/3/library/contextlib.html#contextlib.ExitStack

    Since registered callbacks are invoked in the reverse order of registration, this ends up behaving as if multiple nested with statements had been used with the registered set of callbacks.

    In other words, it's (meant to be) equivalent to your "Option 2: Use Context Managers Directly".

  11. cbcoutinho commented on Oct 24, 2025

    @cbcoutinho
    Contributor

    Howdy @DouweM,

    I'm seeing this error specifically when generating multiple streamable_http_clients within pytest fixtures. These fixtures are not related and do not follow strictly LIFO - that's my best guess as to why this error crops up intermittently.

    Indeed, you could nest all the clients manually one by one to enforce LIFO, but this was undesirable in my case. I've since switched to anyio and don't see the same problem when creating multiple clients concurrently in pytest fixtures.

  12. IgnazioDS commented on Jun 8, 2026

    @IgnazioDS
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Significant bug affecting many users, highly requested featurebugSomething isn't workingready for workEnough information for someone to start working on

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions