Skip to content

RuntimeError: "Attempted to exit cancel scope in a different task" during FastMCPClient teardown in pytest-asyncio tests #348

@CaliLuke

Description

@CaliLuke

Description

A RuntimeError: Attempted to exit cancel scope in a different task than it was entered in occurs consistently during the teardown phase of FastMCPClient instances. This issue is observed when FastMCPClient is used with an in-memory FastMCP server within pytest-asyncio test fixtures. Specifically, the error arises when the client's asynchronous context manager (async with FastMCPClient(...)) exits and its __aexit__ method is called as part of the fixture cleanup.

Steps to Reproduce (Conceptual):

  1. Set up a pytest-asyncio test environment with the specified library versions.
  2. Define a function-scoped pytest fixture that provides an FastMCPClient instance connected to an in-memory FastMCP server.
    import pytest
    import pytest_asyncio
    from fastmcp import FastMCP
    from fastmcp.client import Client as FastMCPClient
    
    @pytest_asyncio.fixture(scope="function")
    async def mcp_server_instance():
        # A basic FastMCP server instance for the test
        server = FastMCP(name="test-server-for-bug-report")
        # Potentially register a dummy tool or resource if needed for client init
        @server.tool()
        async def dummy_tool():
            return "dummy"
        return server
    
    @pytest_asyncio.fixture(scope="function")
    async def mcp_test_client(mcp_server_instance: FastMCP):
        # FastMCPClient uses FastMCPTransport for in-memory connections
        async with FastMCPClient(mcp_server_instance) as client:
            yield client
        # The RuntimeError occurs after this yield, when __aexit__ is implicitly called
        # on the FastMCPClient instance during fixture teardown.
  3. Write a simple asynchronous test function that uses the mcp_test_client fixture.
    async def test_example_using_client(mcp_test_client: FastMCPClient):
        # Perform some basic operation or just pass
        assert mcp_test_client is not None
        # Example: await mcp_test_client.list_tools()
        pass
  4. Execute the test using pytest.
  5. The RuntimeError is expected during the teardown of the mcp_test_client fixture, not during the execution of test_example_using_client itself.

Detailed Explanation of Suspected Cause:

The RuntimeError appears to stem from how anyio TaskGroups and CancelScopes are managed within the mcp and fastmcp libraries during the shutdown of an in-memory client-server session. The sequence leading to the error is as follows:

  1. Client Teardown Initiation: The error originates when the FastMCPClient's __aexit__() method is invoked as the async with block for the client concludes during fixture teardown.
  2. Transport Layer: For in-memory connections, FastMCPClient.__aexit__() delegates to its transport's context management. The relevant transport is fastmcp.client.transports.FastMCPTransport. Its connect_session() method (specifically, the __aexit__ part of the asynccontextmanager) relies on mcp.shared.memory.create_connected_server_and_client_session().
  3. Outer TaskGroup (tg_memory) in mcp.shared.memory:
    • The function mcp.shared.memory.create_connected_server_and_client_session() (around L61 in mcp/shared/memory.py) creates an anyio.TaskGroup (referred to here as tg_memory).
    • It starts the MCPServer.run() method (from mcp.server.lowlevel.server.Server) as a background task within tg_memory using tg_memory.start_soon(server.run, ...) (around L80 in mcp/shared/memory.py).
    • Crucially, a finally block within create_connected_server_and_client_session() (around L102 in mcp/shared/memory.py) calls tg_memory.cancel_scope.cancel(). This call is intended to signal the server.run() task to shut down.
    • The RuntimeError occurs when this outer tg_memory itself attempts to exit (i.e., its __aexit__ method is called by the unwinding async with stack originating from the client's __aexit__).
  4. Nested TaskGroup (tg_server_run) in MCPServer.run():
    • The MCPServer.run() method (around L472 in mcp/server/lowlevel/server.py) contains the main operational loop for the server.
    • Internally, it creates its own nested anyio.TaskGroup (referred to here as tg_server_run) (around L489 in mcp/server/lowlevel/server.py).
    • For each incoming message, it spawns a new task using tg_server_run.start_soon(self._handle_message, ...) (around L493 in mcp/server/lowlevel/server.py).
  5. Hypothesized Core Issue:
    The fundamental problem is believed to be that the MCPServer.run() task (and by extension, tasks managed within its tg_server_run) does not terminate cleanly or swiftly enough when tg_memory's cancel scope is cancelled from the outside (by the client closing).
    When tg_memory subsequently attempts to finalize its own exit (its __aexit__ is invoked), anyio's strict rule—that a cancel scope must be exited by the same task that initially entered it—is violated. This violation likely occurs because the MCPServer.run() task, or one of the tasks it spawned within tg_server_run, is still running, attempting cleanup, or has not fully relinquished control in a way that respects anyio's task ownership rules for cancel scopes. The MCPServer.run() loop may not be adequately responsive to the cancellation signal from tg_memory, failing to ensure its own tg_server_run and all its child tasks are robustly and promptly terminated. The pytest-asyncio environment, with its per-test event loop management, likely makes this race condition or improper teardown sequence more apparent.

Impact:

  • Tests that use FastMCPClient with in-memory servers within a pytest-asyncio setup consistently fail during their teardown phase due to this RuntimeError.
  • This masks the actual success or failure of the test assertions themselves, as the error occurs after the primary test logic has completed.
  • It significantly hinders automated testing and Continuous Integration/Continuous Deployment (CI/CD) processes by generating false negatives, making it difficult to ascertain the true state of the codebase and complicating debugging efforts.

Suggested Area for Investigation (in fastmcp/mcp):

The investigation should primarily focus on the mcp library's handling of task cancellation and shutdown, specifically:

  • MCPServer.run() Cancellation Handling: Examine how mcp.server.lowlevel.server.Server.run() responds to cancellation signals propagated from its parent TaskGroup (i.e., tg_memory created in mcp.shared.memory.py). It needs to reliably detect cancellation (e.g., by checking cancel_scope.cancel_called or handling anyio.CancelledError within its main loop).
  • Nested TaskGroup Shutdown (tg_server_run): Ensure that upon receiving a cancellation signal, Server.run() robustly and promptly terminates its own nested TaskGroup (tg_server_run) and all tasks managed by it (e.g., _handle_message tasks). This includes breaking its main message processing loop and allowing for a clean and complete shutdown of tg_server_run before Server.run() itself exits.
  • Synchronization in mcp.shared.memory: Review the interaction within mcp.shared.memory.create_connected_server_and_client_session(). After tg_memory.cancel_scope.cancel() is called, there might be a need for a more explicit synchronization mechanism to await the full termination of the server.run() task (including the complete shutdown of its internal tg_server_run) before the async with tg_memory: block is allowed to exit. This would ensure all server-side activity related to that session is finalized before the task group that spawned it attempts to clean up its own cancel scope.

Addressing these areas should lead to more robust teardown behavior when using FastMCPClient with in-memory FastMCP servers in anyio-based applications, particularly within testing frameworks like pytest-asyncio.

Version Information

*   **`fastmcp` version:** 2.2.6
*   **`mcp` version:** 1.6.0
*   **Python version(s):** 3.11, 3.12, 3.13 (Observed in an environment using Python 3.13)
*   **`pytest` version:** e.g., 7.x, 8.x (Observed with pytest 8.3.5)
*   **`pytest-asyncio` version:** e.g., 0.21.x, 0.23.x (Observed with pytest-asyncio 0.26.0)
*   **`anyio` version:** e.g., 3.x, 4.x (Observed with anyio 4.9.0)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions