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/cli/cli.py +102 -225
- fastmcp/cli/install/__init__.py +20 -0
- fastmcp/cli/install/claude_code.py +186 -0
- fastmcp/cli/install/claude_desktop.py +186 -0
- fastmcp/cli/install/cursor.py +196 -0
- fastmcp/cli/install/mcp_config.py +165 -0
- fastmcp/cli/install/shared.py +85 -0
- fastmcp/cli/run.py +13 -4
- fastmcp/client/client.py +230 -124
- fastmcp/client/transports.py +1 -1
- fastmcp/mcp_config.py +282 -0
- fastmcp/prompts/prompt.py +2 -4
- fastmcp/resources/resource.py +2 -2
- fastmcp/resources/template.py +1 -1
- fastmcp/server/openapi.py +40 -9
- fastmcp/server/proxy.py +101 -48
- fastmcp/server/server.py +32 -3
- fastmcp/tools/tool.py +3 -2
- fastmcp/tools/tool_transform.py +5 -6
- fastmcp/utilities/json_schema.py +14 -3
- fastmcp/utilities/openapi.py +92 -0
- {fastmcp-2.10.2.dist-info → fastmcp-2.10.4.dist-info}/METADATA +4 -3
- {fastmcp-2.10.2.dist-info → fastmcp-2.10.4.dist-info}/RECORD +26 -20
- fastmcp/utilities/mcp_config.py +0 -103
- {fastmcp-2.10.2.dist-info → fastmcp-2.10.4.dist-info}/WHEEL +0 -0
- {fastmcp-2.10.2.dist-info → fastmcp-2.10.4.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.10.2.dist-info → fastmcp-2.10.4.dist-info}/licenses/LICENSE +0 -0
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:
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
96
|
-
|
|
137
|
+
```python
|
|
138
|
+
# Connect to FastMCP server
|
|
139
|
+
client = Client("http://localhost:8080")
|
|
97
140
|
|
|
98
141
|
async with client:
|
|
99
|
-
# List available resources
|
|
142
|
+
# List available resources
|
|
143
|
+
resources = await client.list_resources()
|
|
100
144
|
|
|
101
|
-
# Call a tool
|
|
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
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
154
|
+
def __init__(
|
|
155
|
+
self: Client[SSETransport | StreamableHttpTransport],
|
|
156
|
+
transport: AnyUrl,
|
|
157
|
+
*args,
|
|
158
|
+
**kwargs,
|
|
159
|
+
) -> None: ...
|
|
117
160
|
|
|
118
161
|
@overload
|
|
119
|
-
def
|
|
120
|
-
|
|
121
|
-
|
|
162
|
+
def __init__(
|
|
163
|
+
self: Client[FastMCPTransport],
|
|
164
|
+
transport: FastMCP | FastMCP1Server,
|
|
165
|
+
*args,
|
|
166
|
+
**kwargs,
|
|
167
|
+
) -> None: ...
|
|
122
168
|
|
|
123
169
|
@overload
|
|
124
|
-
def
|
|
125
|
-
|
|
126
|
-
|
|
170
|
+
def __init__(
|
|
171
|
+
self: Client[PythonStdioTransport | NodeStdioTransport],
|
|
172
|
+
transport: Path,
|
|
173
|
+
*args,
|
|
174
|
+
**kwargs,
|
|
175
|
+
) -> None: ...
|
|
127
176
|
|
|
128
177
|
@overload
|
|
129
|
-
def
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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:
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
#
|
|
218
|
-
self.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
285
|
-
self.
|
|
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.
|
|
312
|
-
need_to_start =
|
|
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.
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
440
|
+
if self._session_state.nesting_counter > 0:
|
|
334
441
|
return
|
|
335
442
|
|
|
336
443
|
# stop the active seesion
|
|
337
|
-
if self.
|
|
444
|
+
if self._session_state.session_task is None:
|
|
338
445
|
return
|
|
339
|
-
self.
|
|
340
|
-
|
|
341
|
-
self.
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
451
|
+
async def _session_runner(self):
|
|
452
|
+
"""
|
|
453
|
+
Background task that manages the actual session lifecycle.
|
|
346
454
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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.
|
|
368
|
-
raise
|
|
474
|
+
self._session_state.ready_event.set()
|
|
369
475
|
|
|
370
476
|
async def close(self):
|
|
371
477
|
await self._disconnect(force=True)
|
fastmcp/client/transports.py
CHANGED
|
@@ -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
|
|