Spaces:
Running
Running
Merge pull request #1057 from jlowin/concurrency-fix-1054
Browse files- src/fastmcp/client/client.py +73 -10
src/fastmcp/client/client.py
CHANGED
|
@@ -74,6 +74,24 @@ class Client(Generic[ClientTransportT]):
|
|
| 74 |
handles connection establishment and management. Client provides methods for
|
| 75 |
working with resources, prompts, tools and other MCP capabilities.
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
Args:
|
| 78 |
transport: Connection source specification, which can be:
|
| 79 |
- ClientTransport: Direct transport instance
|
|
@@ -214,14 +232,15 @@ class Client(Generic[ClientTransportT]):
|
|
| 214 |
elicitation_handler
|
| 215 |
)
|
| 216 |
|
| 217 |
-
#
|
| 218 |
-
self._session: ClientSession | None = None
|
| 219 |
-
self.
|
| 220 |
-
self.
|
| 221 |
-
self.
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
self.
|
|
|
|
| 225 |
|
| 226 |
@property
|
| 227 |
def session(self) -> ClientSession:
|
|
@@ -291,11 +310,26 @@ class Client(Generic[ClientTransportT]):
|
|
| 291 |
await self._disconnect()
|
| 292 |
|
| 293 |
async def _connect(self):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
# ensure only one session is running at a time to avoid race conditions
|
| 295 |
async with self._context_lock:
|
| 296 |
need_to_start = self._session_task is None or self._session_task.done()
|
| 297 |
if need_to_start:
|
| 298 |
-
|
|
|
|
|
|
|
|
|
|
| 299 |
self._stop_event = anyio.Event()
|
| 300 |
self._ready_event = anyio.Event()
|
| 301 |
self._session_task = asyncio.create_task(self._session_runner())
|
|
@@ -303,7 +337,10 @@ class Client(Generic[ClientTransportT]):
|
|
| 303 |
|
| 304 |
if self._session_task.done():
|
| 305 |
exception = self._session_task.exception()
|
| 306 |
-
|
|
|
|
|
|
|
|
|
|
| 307 |
if isinstance(exception, httpx.HTTPStatusError):
|
| 308 |
raise exception
|
| 309 |
raise RuntimeError(
|
|
@@ -314,6 +351,19 @@ class Client(Generic[ClientTransportT]):
|
|
| 314 |
return self
|
| 315 |
|
| 316 |
async def _disconnect(self, force: bool = False):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
# ensure only one session is running at a time to avoid race conditions
|
| 318 |
async with self._context_lock:
|
| 319 |
# if we are forcing a disconnect, reset the nesting counter
|
|
@@ -337,6 +387,19 @@ class Client(Generic[ClientTransportT]):
|
|
| 337 |
self._session_task = None
|
| 338 |
|
| 339 |
async def _session_runner(self):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
try:
|
| 341 |
async with AsyncExitStack() as stack:
|
| 342 |
await stack.enter_async_context(self._context_manager())
|
|
|
|
| 74 |
handles connection establishment and management. Client provides methods for
|
| 75 |
working with resources, prompts, tools and other MCP capabilities.
|
| 76 |
|
| 77 |
+
This client supports reentrant context managers (multiple concurrent
|
| 78 |
+
`async with client:` blocks) using reference counting and background session
|
| 79 |
+
management. This allows efficient session reuse in any scenario with
|
| 80 |
+
nested or concurrent client usage.
|
| 81 |
+
|
| 82 |
+
MCP SDK 1.10 introduced automatic list_tools() calls during call_tool()
|
| 83 |
+
execution. This created a race condition where events could be reset while
|
| 84 |
+
other tasks were waiting on them, causing deadlocks. The issue was exposed
|
| 85 |
+
in proxy scenarios but affects any reentrant usage.
|
| 86 |
+
|
| 87 |
+
The solution uses reference counting to track active context managers,
|
| 88 |
+
a background task to manage the session lifecycle, events to coordinate
|
| 89 |
+
between tasks, and ensures all session state changes happen within a lock.
|
| 90 |
+
Events are only created when needed, never reset outside locks.
|
| 91 |
+
|
| 92 |
+
See: https://github.com/jlowin/fastmcp/issues/1051
|
| 93 |
+
https://github.com/jlowin/fastmcp/pull/1054
|
| 94 |
+
|
| 95 |
Args:
|
| 96 |
transport: Connection source specification, which can be:
|
| 97 |
- ClientTransport: Direct transport instance
|
|
|
|
| 232 |
elicitation_handler
|
| 233 |
)
|
| 234 |
|
| 235 |
+
# Session context management - see class docstring for detailed explanation
|
| 236 |
+
self._session: ClientSession | None = None # Active MCP session
|
| 237 |
+
self._nesting_counter: int = 0 # Reference count for active context managers
|
| 238 |
+
self._context_lock = anyio.Lock() # Protects all session state changes
|
| 239 |
+
self._session_task: asyncio.Task | None = (
|
| 240 |
+
None # Background session manager task
|
| 241 |
+
)
|
| 242 |
+
self._ready_event = anyio.Event() # Signals when session is ready for use
|
| 243 |
+
self._stop_event = anyio.Event() # Signals when session should stop
|
| 244 |
|
| 245 |
@property
|
| 246 |
def session(self) -> ClientSession:
|
|
|
|
| 310 |
await self._disconnect()
|
| 311 |
|
| 312 |
async def _connect(self):
|
| 313 |
+
"""
|
| 314 |
+
Establish or reuse a session connection.
|
| 315 |
+
|
| 316 |
+
This method implements the reentrant context manager pattern:
|
| 317 |
+
- First call: Creates background session task and waits for it to be ready
|
| 318 |
+
- Subsequent calls: Increments reference counter and reuses existing session
|
| 319 |
+
- All operations protected by _context_lock to prevent race conditions
|
| 320 |
+
|
| 321 |
+
The critical fix: Events are only created when starting a new session,
|
| 322 |
+
never reset outside the lock, preventing the deadlock scenario where
|
| 323 |
+
tasks wait on events that get replaced by other tasks.
|
| 324 |
+
"""
|
| 325 |
# ensure only one session is running at a time to avoid race conditions
|
| 326 |
async with self._context_lock:
|
| 327 |
need_to_start = self._session_task is None or self._session_task.done()
|
| 328 |
if need_to_start:
|
| 329 |
+
if self._nesting_counter != 0:
|
| 330 |
+
raise RuntimeError(
|
| 331 |
+
f"Internal error: nesting counter should be 0 when starting new session, got {self._nesting_counter}"
|
| 332 |
+
)
|
| 333 |
self._stop_event = anyio.Event()
|
| 334 |
self._ready_event = anyio.Event()
|
| 335 |
self._session_task = asyncio.create_task(self._session_runner())
|
|
|
|
| 337 |
|
| 338 |
if self._session_task.done():
|
| 339 |
exception = self._session_task.exception()
|
| 340 |
+
if exception is None:
|
| 341 |
+
raise RuntimeError(
|
| 342 |
+
"Session task completed without exception but connection failed"
|
| 343 |
+
)
|
| 344 |
if isinstance(exception, httpx.HTTPStatusError):
|
| 345 |
raise exception
|
| 346 |
raise RuntimeError(
|
|
|
|
| 351 |
return self
|
| 352 |
|
| 353 |
async def _disconnect(self, force: bool = False):
|
| 354 |
+
"""
|
| 355 |
+
Disconnect from session using reference counting.
|
| 356 |
+
|
| 357 |
+
This method implements proper cleanup for reentrant context managers:
|
| 358 |
+
- Decrements reference counter for normal exits
|
| 359 |
+
- Only stops session when counter reaches 0 (no more active contexts)
|
| 360 |
+
- Force flag bypasses reference counting for immediate shutdown
|
| 361 |
+
- Session cleanup happens inside the lock to ensure atomicity
|
| 362 |
+
|
| 363 |
+
Key fix: Removed the problematic "Reset for future reconnects" logic
|
| 364 |
+
that was resetting events outside the lock, causing race conditions.
|
| 365 |
+
Event recreation now happens only in _connect() when actually needed.
|
| 366 |
+
"""
|
| 367 |
# ensure only one session is running at a time to avoid race conditions
|
| 368 |
async with self._context_lock:
|
| 369 |
# if we are forcing a disconnect, reset the nesting counter
|
|
|
|
| 387 |
self._session_task = None
|
| 388 |
|
| 389 |
async def _session_runner(self):
|
| 390 |
+
"""
|
| 391 |
+
Background task that manages the actual session lifecycle.
|
| 392 |
+
|
| 393 |
+
This task runs in the background and:
|
| 394 |
+
1. Establishes the transport connection via _context_manager()
|
| 395 |
+
2. Signals that the session is ready via _ready_event.set()
|
| 396 |
+
3. Waits for disconnect signal via _stop_event.wait()
|
| 397 |
+
4. Ensures _ready_event is always set, even on failures
|
| 398 |
+
|
| 399 |
+
The simplified error handling (compared to the original) removes
|
| 400 |
+
redundant exception re-raising while ensuring waiting tasks are
|
| 401 |
+
always unblocked via the finally block.
|
| 402 |
+
"""
|
| 403 |
try:
|
| 404 |
async with AsyncExitStack() as stack:
|
| 405 |
await stack.enter_async_context(self._context_manager())
|