-
Notifications
You must be signed in to change notification settings - Fork 2.5k
Open
Labels
P1Significant bug affecting many users, highly requested featureSignificant bug affecting many users, highly requested featurebugSomething isn't workingSomething isn't workingready for workEnough information for someone to start working onEnough information for someone to start working on
Description
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) |
pitta-bread, CuriousTank, vincent-pli, morigs and lcy0321
Metadata
Metadata
Assignees
Labels
P1Significant bug affecting many users, highly requested featureSignificant bug affecting many users, highly requested featurebugSomething isn't workingSomething isn't workingready for workEnough information for someone to start working onEnough information for someone to start working on