modelcontextprotocol / modelcontextprotocol/python-sdk
`RuntimeError: Attempted to exit cancel scope in a different task` when cleaning up multiple MCPClient instances out-of-order
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 24.3k
- Forks
- 4k
- Avg merge
- 1d 1h
- Merged PRs (30d)
- 31
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) |
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by running the minimal reproduction in the issue, then inspect MCPClient.cleanup(), AsyncExitStack, stdio_client, and ClientSession teardown behavior. The fix is done when independently created clients can be cleaned up in FIFO order without RuntimeError or CancelledError, while FILO cleanup continues to work.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100