-
Notifications
You must be signed in to change notification settings - Fork 998
Description
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-asyncio
test environment with the specified library versions. - Define a function-scoped
pytest
fixture that provides anFastMCPClient
instance connected to an in-memoryFastMCP
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.
- 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
- Execute the test using
pytest
. - The
RuntimeError
is expected during the teardown of themcp_test_client
fixture, not during the execution oftest_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:
- Client Teardown Initiation: The error originates when the
FastMCPClient
's__aexit__()
method is invoked as theasync with
block 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_memory
usingtg_memory.start_soon(server.run, ...)
(around L80 inmcp/shared/memory.py
). - Crucially, a
finally
block 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
RuntimeError
occurs when this outertg_memory
itself attempts to exit (i.e., its__aexit__
method is called by the unwindingasync with
stack 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 theMCPServer.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_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 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_run
and all its child tasks are robustly and promptly terminated. Thepytest-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 apytest-asyncio
setup 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_memory
created inmcp.shared.memory.py
). It needs to reliably detect cancellation (e.g., by checkingcancel_scope.cancel_called
or handlinganyio.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 nestedTaskGroup
(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 oftg_server_run
beforeServer.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)