fastmcp 2.10.2__py3-none-any.whl → 2.10.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fastmcp/client/client.py CHANGED
@@ -1,11 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import copy
4
5
  import datetime
5
6
  from contextlib import AsyncExitStack, asynccontextmanager
6
- from dataclasses import dataclass
7
+ from dataclasses import dataclass, field
7
8
  from pathlib import Path
8
- from typing import Any, Generic, Literal, cast, overload
9
+ from typing import Any, Generic, Literal, TypeVar, cast, overload
9
10
 
10
11
  import anyio
11
12
  import httpx
@@ -31,14 +32,15 @@ from fastmcp.client.roots import (
31
32
  )
32
33
  from fastmcp.client.sampling import SamplingHandler, create_sampling_callback
33
34
  from fastmcp.exceptions import ToolError
35
+ from fastmcp.mcp_config import MCPConfig
34
36
  from fastmcp.server import FastMCP
35
37
  from fastmcp.utilities.exceptions import get_catch_handlers
36
38
  from fastmcp.utilities.json_schema_type import json_schema_to_type
37
39
  from fastmcp.utilities.logging import get_logger
38
- from fastmcp.utilities.mcp_config import MCPConfig
39
40
  from fastmcp.utilities.types import get_cached_typeadapter
40
41
 
41
42
  from .transports import (
43
+ ClientTransport,
42
44
  ClientTransportT,
43
45
  FastMCP1Server,
44
46
  FastMCPTransport,
@@ -65,6 +67,25 @@ __all__ = [
65
67
 
66
68
  logger = get_logger(__name__)
67
69
 
70
+ T = TypeVar("T", bound="ClientTransport")
71
+
72
+
73
+ @dataclass
74
+ class ClientSessionState:
75
+ """Holds all session-related state for a Client instance.
76
+
77
+ This allows clean separation of configuration (which is copied) from
78
+ session state (which should be fresh for each new client instance).
79
+ """
80
+
81
+ session: ClientSession | None = None
82
+ nesting_counter: int = 0
83
+ lock: anyio.Lock = field(default_factory=anyio.Lock)
84
+ session_task: asyncio.Task | None = None
85
+ ready_event: anyio.Event = field(default_factory=anyio.Event)
86
+ stop_event: anyio.Event = field(default_factory=anyio.Event)
87
+ initialize_result: mcp.types.InitializeResult | None = None
88
+
68
89
 
69
90
  class Client(Generic[ClientTransportT]):
70
91
  """
@@ -74,14 +95,35 @@ class Client(Generic[ClientTransportT]):
74
95
  handles connection establishment and management. Client provides methods for
75
96
  working with resources, prompts, tools and other MCP capabilities.
76
97
 
98
+ This client supports reentrant context managers (multiple concurrent
99
+ `async with client:` blocks) using reference counting and background session
100
+ management. This allows efficient session reuse in any scenario with
101
+ nested or concurrent client usage.
102
+
103
+ MCP SDK 1.10 introduced automatic list_tools() calls during call_tool()
104
+ execution. This created a race condition where events could be reset while
105
+ other tasks were waiting on them, causing deadlocks. The issue was exposed
106
+ in proxy scenarios but affects any reentrant usage.
107
+
108
+ The solution uses reference counting to track active context managers,
109
+ a background task to manage the session lifecycle, events to coordinate
110
+ between tasks, and ensures all session state changes happen within a lock.
111
+ Events are only created when needed, never reset outside locks.
112
+
113
+ This design prevents race conditions where tasks wait on events that get
114
+ replaced by other tasks, ensuring reliable coordination in concurrent scenarios.
115
+
77
116
  Args:
78
- transport: Connection source specification, which can be:
79
- - ClientTransport: Direct transport instance
80
- - FastMCP: In-process FastMCP server
81
- - AnyUrl | str: URL to connect to
82
- - Path: File path for local socket
83
- - MCPConfig: MCP server configuration
84
- - dict: Transport configuration
117
+ transport:
118
+ Connection source specification, which can be:
119
+
120
+ - ClientTransport: Direct transport instance
121
+ - FastMCP: In-process FastMCP server
122
+ - AnyUrl or str: URL to connect to
123
+ - Path: File path for local socket
124
+ - MCPConfig: MCP server configuration
125
+ - dict: Transport configuration
126
+
85
127
  roots: Optional RootsList or RootsHandler for filesystem access
86
128
  sampling_handler: Optional handler for sampling requests
87
129
  log_handler: Optional handler for log messages
@@ -92,68 +134,79 @@ class Client(Generic[ClientTransportT]):
92
134
  Set to 0 to disable. If None, uses the value in the FastMCP global settings.
93
135
 
94
136
  Examples:
95
- ```python # Connect to FastMCP server client =
96
- Client("http://localhost:8080")
137
+ ```python
138
+ # Connect to FastMCP server
139
+ client = Client("http://localhost:8080")
97
140
 
98
141
  async with client:
99
- # List available resources resources = await client.list_resources()
142
+ # List available resources
143
+ resources = await client.list_resources()
100
144
 
101
- # Call a tool result = await client.call_tool("my_tool", {"param":
102
- "value"})
145
+ # Call a tool
146
+ result = await client.call_tool("my_tool", {"param": "value"})
103
147
  ```
104
148
  """
105
149
 
106
150
  @overload
107
- def __new__(
108
- cls,
109
- transport: ClientTransportT,
110
- **kwargs: Any,
111
- ) -> Client[ClientTransportT]: ...
151
+ def __init__(self: Client[T], transport: T, *args, **kwargs) -> None: ...
112
152
 
113
153
  @overload
114
- def __new__(
115
- cls, transport: AnyUrl, **kwargs
116
- ) -> Client[SSETransport | StreamableHttpTransport]: ...
154
+ def __init__(
155
+ self: Client[SSETransport | StreamableHttpTransport],
156
+ transport: AnyUrl,
157
+ *args,
158
+ **kwargs,
159
+ ) -> None: ...
117
160
 
118
161
  @overload
119
- def __new__(
120
- cls, transport: FastMCP | FastMCP1Server, **kwargs
121
- ) -> Client[FastMCPTransport]: ...
162
+ def __init__(
163
+ self: Client[FastMCPTransport],
164
+ transport: FastMCP | FastMCP1Server,
165
+ *args,
166
+ **kwargs,
167
+ ) -> None: ...
122
168
 
123
169
  @overload
124
- def __new__(
125
- cls, transport: Path, **kwargs
126
- ) -> Client[PythonStdioTransport | NodeStdioTransport]: ...
170
+ def __init__(
171
+ self: Client[PythonStdioTransport | NodeStdioTransport],
172
+ transport: Path,
173
+ *args,
174
+ **kwargs,
175
+ ) -> None: ...
127
176
 
128
177
  @overload
129
- def __new__(
130
- cls, transport: MCPConfig | dict[str, Any], **kwargs
131
- ) -> Client[MCPConfigTransport]: ...
178
+ def __init__(
179
+ self: Client[MCPConfigTransport],
180
+ transport: MCPConfig | dict[str, Any],
181
+ *args,
182
+ **kwargs,
183
+ ) -> None: ...
132
184
 
133
185
  @overload
134
- def __new__(
135
- cls, transport: str, **kwargs
136
- ) -> Client[
137
- PythonStdioTransport
138
- | NodeStdioTransport
139
- | SSETransport
140
- | StreamableHttpTransport
141
- ]: ...
142
-
143
- def __new__(cls, transport, **kwargs) -> Client:
144
- instance = super().__new__(cls)
145
- return instance
186
+ def __init__(
187
+ self: Client[
188
+ PythonStdioTransport
189
+ | NodeStdioTransport
190
+ | SSETransport
191
+ | StreamableHttpTransport
192
+ ],
193
+ transport: str,
194
+ *args,
195
+ **kwargs,
196
+ ) -> None: ...
146
197
 
147
198
  def __init__(
148
199
  self,
149
- transport: ClientTransportT
150
- | FastMCP
151
- | AnyUrl
152
- | Path
153
- | MCPConfig
154
- | dict[str, Any]
155
- | str,
156
- # Common args
200
+ transport: (
201
+ ClientTransportT
202
+ | FastMCP
203
+ | FastMCP1Server
204
+ | AnyUrl
205
+ | Path
206
+ | MCPConfig
207
+ | dict[str, Any]
208
+ | str
209
+ ),
157
210
  roots: RootsList | RootsHandler | None = None,
158
211
  sampling_handler: SamplingHandler | None = None,
159
212
  elicitation_handler: ElicitationHandler | None = None,
@@ -164,11 +217,10 @@ class Client(Generic[ClientTransportT]):
164
217
  init_timeout: datetime.timedelta | float | int | None = None,
165
218
  client_info: mcp.types.Implementation | None = None,
166
219
  auth: httpx.Auth | Literal["oauth"] | str | None = None,
167
- ):
220
+ ) -> None:
168
221
  self.transport = cast(ClientTransportT, infer_transport(transport))
169
222
  if auth is not None:
170
223
  self.transport._set_auth(auth)
171
- self._initialize_result: mcp.types.InitializeResult | None = None
172
224
 
173
225
  if log_handler is None:
174
226
  log_handler = default_log_handler
@@ -214,33 +266,27 @@ class Client(Generic[ClientTransportT]):
214
266
  elicitation_handler
215
267
  )
216
268
 
217
- # session context management
218
- self._session: ClientSession | None = None
219
- self._exit_stack: AsyncExitStack | None = None
220
- self._nesting_counter: int = 0
221
- self._context_lock = anyio.Lock()
222
- self._session_task: asyncio.Task | None = None
223
- self._ready_event = anyio.Event()
224
- self._stop_event = anyio.Event()
269
+ # Session context management - see class docstring for detailed explanation
270
+ self._session_state = ClientSessionState()
225
271
 
226
272
  @property
227
273
  def session(self) -> ClientSession:
228
274
  """Get the current active session. Raises RuntimeError if not connected."""
229
- if self._session is None:
275
+ if self._session_state.session is None:
230
276
  raise RuntimeError(
231
277
  "Client is not connected. Use the 'async with client:' context manager first."
232
278
  )
233
279
 
234
- return self._session
280
+ return self._session_state.session
235
281
 
236
282
  @property
237
283
  def initialize_result(self) -> mcp.types.InitializeResult:
238
284
  """Get the result of the initialization request."""
239
- if self._initialize_result is None:
285
+ if self._session_state.initialize_result is None:
240
286
  raise RuntimeError(
241
287
  "Client is not connected. Use the 'async with client:' context manager first."
242
288
  )
243
- return self._initialize_result
289
+ return self._session_state.initialize_result
244
290
 
245
291
  def set_roots(self, roots: RootsList | RootsHandler) -> None:
246
292
  """Set the roots for the client. This does not automatically call `send_roots_list_changed`."""
@@ -262,7 +308,33 @@ class Client(Generic[ClientTransportT]):
262
308
 
263
309
  def is_connected(self) -> bool:
264
310
  """Check if the client is currently connected."""
265
- return self._session is not None
311
+ return self._session_state.session is not None
312
+
313
+ def new(self) -> Client[ClientTransportT]:
314
+ """Create a new client instance with the same configuration but fresh session state.
315
+
316
+ This creates a new client with the same transport, handlers, and configuration,
317
+ but with no active session. Useful for creating independent sessions that don't
318
+ share state with the original client.
319
+
320
+ Returns:
321
+ A new Client instance with the same configuration but disconnected state.
322
+
323
+ Example:
324
+ ```python
325
+ # Create a fresh client for each concurrent operation
326
+ fresh_client = client.new()
327
+ async with fresh_client:
328
+ await fresh_client.call_tool("some_tool", {})
329
+ ```
330
+ """
331
+
332
+ new_client = copy.copy(self)
333
+
334
+ # Reset session state to fresh state
335
+ new_client._session_state = ClientSessionState()
336
+
337
+ return new_client
266
338
 
267
339
  @asynccontextmanager
268
340
  async def _context_manager(self):
@@ -270,102 +342,136 @@ class Client(Generic[ClientTransportT]):
270
342
  async with self.transport.connect_session(
271
343
  **self._session_kwargs
272
344
  ) as session:
273
- self._session = session
345
+ self._session_state.session = session
274
346
  # Initialize the session
275
347
  try:
276
348
  with anyio.fail_after(self._init_timeout):
277
- self._initialize_result = await self._session.initialize()
349
+ self._session_state.initialize_result = (
350
+ await self._session_state.session.initialize()
351
+ )
278
352
  yield
279
353
  except anyio.ClosedResourceError:
280
354
  raise RuntimeError("Server session was closed unexpectedly")
281
355
  except TimeoutError:
282
356
  raise RuntimeError("Failed to initialize server session")
283
357
  finally:
284
- self._session = None
285
- self._initialize_result = None
358
+ self._session_state.session = None
359
+ self._session_state.initialize_result = None
286
360
 
287
361
  async def __aenter__(self):
288
- await self._connect()
289
-
290
- # Check if session task failed and raise error immediately
291
- if (
292
- self._session_task is not None
293
- and self._session_task.done()
294
- and not self._session_task.cancelled()
295
- ):
296
- exception = self._session_task.exception()
297
- if isinstance(exception, httpx.HTTPStatusError):
298
- raise exception
299
- elif exception is not None:
300
- raise RuntimeError(
301
- f"Client failed to connect: {exception}"
302
- ) from exception
303
-
304
- return self
362
+ return await self._connect()
305
363
 
306
364
  async def __aexit__(self, exc_type, exc_val, exc_tb):
307
365
  await self._disconnect()
308
366
 
309
367
  async def _connect(self):
368
+ """
369
+ Establish or reuse a session connection.
370
+
371
+ This method implements the reentrant context manager pattern:
372
+ - First call: Creates background session task and waits for it to be ready
373
+ - Subsequent calls: Increments reference counter and reuses existing session
374
+ - All operations protected by _context_lock to prevent race conditions
375
+
376
+ The critical fix: Events are only created when starting a new session,
377
+ never reset outside the lock, preventing the deadlock scenario where
378
+ tasks wait on events that get replaced by other tasks.
379
+ """
310
380
  # ensure only one session is running at a time to avoid race conditions
311
- async with self._context_lock:
312
- need_to_start = self._session_task is None or self._session_task.done()
381
+ async with self._session_state.lock:
382
+ need_to_start = (
383
+ self._session_state.session_task is None
384
+ or self._session_state.session_task.done()
385
+ )
313
386
  if need_to_start:
314
- self._stop_event = anyio.Event()
315
- self._ready_event = anyio.Event()
316
- self._session_task = asyncio.create_task(self._session_runner())
317
- await self._ready_event.wait()
318
- self._nesting_counter += 1
387
+ if self._session_state.nesting_counter != 0:
388
+ raise RuntimeError(
389
+ f"Internal error: nesting counter should be 0 when starting new session, got {self._session_state.nesting_counter}"
390
+ )
391
+ self._session_state.stop_event = anyio.Event()
392
+ self._session_state.ready_event = anyio.Event()
393
+ self._session_state.session_task = asyncio.create_task(
394
+ self._session_runner()
395
+ )
396
+ await self._session_state.ready_event.wait()
397
+
398
+ if self._session_state.session_task.done():
399
+ exception = self._session_state.session_task.exception()
400
+ if exception is None:
401
+ raise RuntimeError(
402
+ "Session task completed without exception but connection failed"
403
+ )
404
+ if isinstance(exception, httpx.HTTPStatusError):
405
+ raise exception
406
+ raise RuntimeError(
407
+ f"Client failed to connect: {exception}"
408
+ ) from exception
409
+
410
+ self._session_state.nesting_counter += 1
319
411
  return self
320
412
 
321
413
  async def _disconnect(self, force: bool = False):
414
+ """
415
+ Disconnect from session using reference counting.
416
+
417
+ This method implements proper cleanup for reentrant context managers:
418
+ - Decrements reference counter for normal exits
419
+ - Only stops session when counter reaches 0 (no more active contexts)
420
+ - Force flag bypasses reference counting for immediate shutdown
421
+ - Session cleanup happens inside the lock to ensure atomicity
422
+
423
+ Key fix: Removed the problematic "Reset for future reconnects" logic
424
+ that was resetting events outside the lock, causing race conditions.
425
+ Event recreation now happens only in _connect() when actually needed.
426
+ """
322
427
  # ensure only one session is running at a time to avoid race conditions
323
- async with self._context_lock:
428
+ async with self._session_state.lock:
324
429
  # if we are forcing a disconnect, reset the nesting counter
325
430
  if force:
326
- self._nesting_counter = 0
431
+ self._session_state.nesting_counter = 0
327
432
 
328
433
  # otherwise decrement to check if we are done nesting
329
434
  else:
330
- self._nesting_counter = max(0, self._nesting_counter - 1)
435
+ self._session_state.nesting_counter = max(
436
+ 0, self._session_state.nesting_counter - 1
437
+ )
331
438
 
332
439
  # if we are still nested, return
333
- if self._nesting_counter > 0:
440
+ if self._session_state.nesting_counter > 0:
334
441
  return
335
442
 
336
443
  # stop the active seesion
337
- if self._session_task is None:
444
+ if self._session_state.session_task is None:
338
445
  return
339
- self._stop_event.set()
340
- runner_task = self._session_task
341
- self._session_task = None
446
+ self._session_state.stop_event.set()
447
+ # wait for session to finish to ensure state has been reset
448
+ await self._session_state.session_task
449
+ self._session_state.session_task = None
342
450
 
343
- # wait for the session to finish
344
- if runner_task:
345
- await runner_task
451
+ async def _session_runner(self):
452
+ """
453
+ Background task that manages the actual session lifecycle.
346
454
 
347
- # Reset for future reconnects
348
- self._stop_event = anyio.Event()
349
- self._ready_event = anyio.Event()
350
- self._session = None
351
- self._initialize_result = None
455
+ This task runs in the background and:
456
+ 1. Establishes the transport connection via _context_manager()
457
+ 2. Signals that the session is ready via _ready_event.set()
458
+ 3. Waits for disconnect signal via _stop_event.wait()
459
+ 4. Ensures _ready_event is always set, even on failures
352
460
 
353
- async def _session_runner(self):
461
+ The simplified error handling (compared to the original) removes
462
+ redundant exception re-raising while ensuring waiting tasks are
463
+ always unblocked via the finally block.
464
+ """
354
465
  try:
355
466
  async with AsyncExitStack() as stack:
356
- try:
357
- await stack.enter_async_context(self._context_manager())
358
- # Session/context is now ready
359
- self._ready_event.set()
360
- # Wait until disconnect/stop is requested
361
- await self._stop_event.wait()
362
- finally:
363
- # On exit, ensure ready event is set (idempotent)
364
- self._ready_event.set()
365
- except Exception:
467
+ await stack.enter_async_context(self._context_manager())
468
+ # Session/context is now ready
469
+ self._session_state.ready_event.set()
470
+ # Wait until disconnect/stop is requested
471
+ await self._session_state.stop_event.wait()
472
+ finally:
366
473
  # Ensure ready event is set even if context manager entry fails
367
- self._ready_event.set()
368
- raise
474
+ self._session_state.ready_event.set()
369
475
 
370
476
  async def close(self):
371
477
  await self._disconnect(force=True)
@@ -29,10 +29,10 @@ from typing_extensions import TypedDict, Unpack
29
29
  import fastmcp
30
30
  from fastmcp.client.auth.bearer import BearerAuth
31
31
  from fastmcp.client.auth.oauth import OAuth
32
+ from fastmcp.mcp_config import MCPConfig, infer_transport_type_from_url
32
33
  from fastmcp.server.dependencies import get_http_headers
33
34
  from fastmcp.server.server import FastMCP
34
35
  from fastmcp.utilities.logging import get_logger
35
- from fastmcp.utilities.mcp_config import MCPConfig, infer_transport_type_from_url
36
36
 
37
37
  logger = get_logger(__name__)
38
38