RuntimeError: "Attempted to exit cancel scope in a different task" during `FastMCPClient` teardown in `pytest-asyncio` tests
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):
- Set up a
pytest-asynciotest environment with the specified library versions. - Define a function-scoped
pytestfixture that provides anFastMCPClientinstance connected to an in-memoryFastMCPserver.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. - Write a simple asynchronous test function that uses the
mcp_test_clientfixture.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 - Execute the test using
pytest. - The
RuntimeErroris expected during the teardown of themcp_test_clientfixture, not during the execution oftest_example_using_clientitself.
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:
- Client Teardown Initiation: The error originates when the
FastMCPClient's__aexit__()method is invoked as theasync withblock for the client concludes during fixture teardown. - Transport Layer: For in-memory connections,
FastMCPClient.__aexit__()delegates to its transport's context management. The relevant transport isfastmcp.client.transports.FastMCPTransport. Itsconnect_session()method (specifically, the__aexit__part of theasynccontextmanager) relies onmcp.shared.memory.create_connected_server_and_client_session(). - Outer TaskGroup (
tg_memory) inmcp.shared.memory:- The function
mcp.shared.memory.create_connected_server_and_client_session()(around L61 inmcp/shared/memory.py) creates ananyio.TaskGroup(referred to here astg_memory). - It starts the
MCPServer.run()method (frommcp.server.lowlevel.server.Server) as a background task withintg_memoryusingtg_memory.start_soon(server.run, ...)(around L80 inmcp/shared/memory.py). - Crucially, a
finallyblock withincreate_connected_server_and_client_session()(around L102 inmcp/shared/memory.py) callstg_memory.cancel_scope.cancel(). This call is intended to signal theserver.run()task to shut down. - The
RuntimeErroroccurs when this outertg_memoryitself attempts to exit (i.e., its__aexit__method is called by the unwindingasync withstack originating from the client's__aexit__).
- The function
- Nested TaskGroup (
tg_server_run) inMCPServer.run():- The
MCPServer.run()method (around L472 inmcp/server/lowlevel/server.py) contains the main operational loop for the server. - Internally, it creates its own nested
anyio.TaskGroup(referred to here astg_server_run) (around L489 inmcp/server/lowlevel/server.py). - For each incoming message, it spawns a new task using
tg_server_run.start_soon(self._handle_message, ...)(around L493 inmcp/server/lowlevel/server.py).
- The
- Hypothesized Core Issue:
The fundamental problem is believed to be that the
MCPServer.run()task (and by extension, tasks managed within itstg_server_run) does not terminate cleanly or swiftly enough whentg_memory's cancel scope is cancelled from the outside (by the client closing). Whentg_memorysubsequently 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 theMCPServer.run()task, or one of the tasks it spawned withintg_server_run, is still running, attempting cleanup, or has not fully relinquished control in a way that respectsanyio's task ownership rules for cancel scopes. TheMCPServer.run()loop may not be adequately responsive to the cancellation signal fromtg_memory, failing to ensure its owntg_server_runand all its child tasks are robustly and promptly terminated. Thepytest-asyncioenvironment, with its per-test event loop management, likely makes this race condition or improper teardown sequence more apparent.
Impact:
- Tests that use
FastMCPClientwith in-memory servers within apytest-asynciosetup consistently fail during their teardown phase due to thisRuntimeError. - 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 howmcp.server.lowlevel.server.Server.run()responds to cancellation signals propagated from its parentTaskGroup(i.e.,tg_memorycreated inmcp.shared.memory.py). It needs to reliably detect cancellation (e.g., by checkingcancel_scope.cancel_calledor handlinganyio.CancelledErrorwithin its main loop).- Nested TaskGroup Shutdown (
tg_server_run): Ensure that upon receiving a cancellation signal,Server.run()robustly and promptly terminates its own nestedTaskGroup(tg_server_run) and all tasks managed by it (e.g.,_handle_messagetasks). This includes breaking its main message processing loop and allowing for a clean and complete shutdown oftg_server_runbeforeServer.run()itself exits. - Synchronization in
mcp.shared.memory: Review the interaction withinmcp.shared.memory.create_connected_server_and_client_session(). Aftertg_memory.cancel_scope.cancel()is called, there might be a need for a more explicit synchronization mechanism to await the full termination of theserver.run()task (including the complete shutdown of its internaltg_server_run) before theasync 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)
The core issue is that pytest-asyncio fixtures generally do not run in the same event loop as tests (or even each other). See https://github.com/pytest-dev/pytest-asyncio/issues/947. There are two settings that can mitigate this -- asyncio_default_fixture_loop_scope and asyncio_default_test_loop_scope. FastMCP historically set the fixture scope to "session" as a mitigant, I've opened #350 to ensure the test scope is the same (though generally it has not been an issue).
If you use these settings, it may resolve this common issue. If you would like to implement the suggestions you've made regarding the in-memory transport, you'll need to PR to the official SDK which contains that logic.
This might be addressed (or at least improved) by #635 / #643