fastmcp 2.13.3__py3-none-any.whl → 2.14.1__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/__init__.py +0 -21
- fastmcp/cli/__init__.py +0 -3
- fastmcp/cli/__main__.py +5 -0
- fastmcp/cli/cli.py +8 -22
- fastmcp/cli/install/shared.py +0 -15
- fastmcp/cli/tasks.py +110 -0
- fastmcp/client/auth/oauth.py +9 -9
- fastmcp/client/client.py +739 -136
- fastmcp/client/elicitation.py +11 -5
- fastmcp/client/messages.py +7 -5
- fastmcp/client/roots.py +2 -1
- fastmcp/client/sampling/__init__.py +69 -0
- fastmcp/client/sampling/handlers/__init__.py +0 -0
- fastmcp/client/sampling/handlers/anthropic.py +387 -0
- fastmcp/client/sampling/handlers/openai.py +399 -0
- fastmcp/client/tasks.py +551 -0
- fastmcp/client/transports.py +72 -21
- fastmcp/contrib/component_manager/component_service.py +4 -20
- fastmcp/dependencies.py +25 -0
- fastmcp/experimental/sampling/handlers/__init__.py +5 -0
- fastmcp/experimental/sampling/handlers/openai.py +4 -169
- fastmcp/experimental/server/openapi/__init__.py +15 -13
- fastmcp/experimental/utilities/openapi/__init__.py +12 -38
- fastmcp/prompts/prompt.py +38 -38
- fastmcp/resources/resource.py +33 -16
- fastmcp/resources/template.py +69 -59
- fastmcp/server/auth/__init__.py +0 -9
- fastmcp/server/auth/auth.py +127 -3
- fastmcp/server/auth/oauth_proxy.py +47 -97
- fastmcp/server/auth/oidc_proxy.py +7 -0
- fastmcp/server/auth/providers/in_memory.py +2 -2
- fastmcp/server/auth/providers/oci.py +2 -2
- fastmcp/server/context.py +509 -180
- fastmcp/server/dependencies.py +464 -6
- fastmcp/server/elicitation.py +285 -47
- fastmcp/server/event_store.py +177 -0
- fastmcp/server/http.py +15 -3
- fastmcp/server/low_level.py +56 -12
- fastmcp/server/middleware/middleware.py +2 -2
- fastmcp/server/openapi/__init__.py +35 -0
- fastmcp/{experimental/server → server}/openapi/components.py +4 -3
- fastmcp/{experimental/server → server}/openapi/routing.py +1 -1
- fastmcp/{experimental/server → server}/openapi/server.py +6 -5
- fastmcp/server/proxy.py +53 -40
- fastmcp/server/sampling/__init__.py +10 -0
- fastmcp/server/sampling/run.py +301 -0
- fastmcp/server/sampling/sampling_tool.py +108 -0
- fastmcp/server/server.py +793 -552
- fastmcp/server/tasks/__init__.py +21 -0
- fastmcp/server/tasks/capabilities.py +22 -0
- fastmcp/server/tasks/config.py +89 -0
- fastmcp/server/tasks/converters.py +206 -0
- fastmcp/server/tasks/handlers.py +356 -0
- fastmcp/server/tasks/keys.py +93 -0
- fastmcp/server/tasks/protocol.py +355 -0
- fastmcp/server/tasks/subscriptions.py +205 -0
- fastmcp/settings.py +101 -103
- fastmcp/tools/tool.py +83 -49
- fastmcp/tools/tool_transform.py +1 -12
- fastmcp/utilities/components.py +3 -3
- fastmcp/utilities/json_schema_type.py +4 -4
- fastmcp/utilities/mcp_config.py +1 -2
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +1 -1
- fastmcp/{experimental/utilities → utilities}/openapi/README.md +7 -35
- fastmcp/utilities/openapi/__init__.py +63 -0
- fastmcp/{experimental/utilities → utilities}/openapi/formatters.py +5 -5
- fastmcp/{experimental/utilities → utilities}/openapi/json_schema_converter.py +1 -1
- fastmcp/utilities/tests.py +11 -5
- fastmcp/utilities/types.py +8 -0
- {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/METADATA +7 -4
- {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/RECORD +79 -63
- fastmcp/client/sampling.py +0 -56
- fastmcp/experimental/sampling/handlers/base.py +0 -21
- fastmcp/server/auth/providers/bearer.py +0 -25
- fastmcp/server/openapi.py +0 -1087
- fastmcp/server/sampling/handler.py +0 -19
- fastmcp/utilities/openapi.py +0 -1568
- /fastmcp/{experimental/server → server}/openapi/README.md +0 -0
- /fastmcp/{experimental/utilities → utilities}/openapi/director.py +0 -0
- /fastmcp/{experimental/utilities → utilities}/openapi/models.py +0 -0
- /fastmcp/{experimental/utilities → utilities}/openapi/parser.py +0 -0
- /fastmcp/{experimental/utilities → utilities}/openapi/schemas.py +0 -0
- {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/WHEEL +0 -0
- {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/licenses/LICENSE +0 -0
fastmcp/client/client.py
CHANGED
|
@@ -4,6 +4,8 @@ import asyncio
|
|
|
4
4
|
import copy
|
|
5
5
|
import datetime
|
|
6
6
|
import secrets
|
|
7
|
+
import uuid
|
|
8
|
+
import weakref
|
|
7
9
|
from contextlib import AsyncExitStack, asynccontextmanager
|
|
8
10
|
from dataclasses import dataclass, field
|
|
9
11
|
from pathlib import Path
|
|
@@ -14,7 +16,20 @@ import httpx
|
|
|
14
16
|
import mcp.types
|
|
15
17
|
import pydantic_core
|
|
16
18
|
from exceptiongroup import catch
|
|
17
|
-
from mcp import ClientSession
|
|
19
|
+
from mcp import ClientSession, McpError
|
|
20
|
+
from mcp.types import (
|
|
21
|
+
CancelTaskRequest,
|
|
22
|
+
CancelTaskRequestParams,
|
|
23
|
+
GetTaskPayloadRequest,
|
|
24
|
+
GetTaskPayloadRequestParams,
|
|
25
|
+
GetTaskPayloadResult,
|
|
26
|
+
GetTaskRequest,
|
|
27
|
+
GetTaskRequestParams,
|
|
28
|
+
GetTaskResult,
|
|
29
|
+
ListTasksRequest,
|
|
30
|
+
PaginatedRequestParams,
|
|
31
|
+
TaskStatusNotification,
|
|
32
|
+
)
|
|
18
33
|
from pydantic import AnyUrl
|
|
19
34
|
|
|
20
35
|
import fastmcp
|
|
@@ -32,10 +47,15 @@ from fastmcp.client.roots import (
|
|
|
32
47
|
create_roots_callback,
|
|
33
48
|
)
|
|
34
49
|
from fastmcp.client.sampling import (
|
|
35
|
-
ClientSamplingHandler,
|
|
36
50
|
SamplingHandler,
|
|
37
51
|
create_sampling_callback,
|
|
38
52
|
)
|
|
53
|
+
from fastmcp.client.tasks import (
|
|
54
|
+
PromptTask,
|
|
55
|
+
ResourceTask,
|
|
56
|
+
TaskNotificationHandler,
|
|
57
|
+
ToolTask,
|
|
58
|
+
)
|
|
39
59
|
from fastmcp.exceptions import ToolError
|
|
40
60
|
from fastmcp.mcp_config import MCPConfig
|
|
41
61
|
from fastmcp.server import FastMCP
|
|
@@ -61,7 +81,6 @@ from .transports import (
|
|
|
61
81
|
|
|
62
82
|
__all__ = [
|
|
63
83
|
"Client",
|
|
64
|
-
"ClientSamplingHandler",
|
|
65
84
|
"ElicitationHandler",
|
|
66
85
|
"LogHandler",
|
|
67
86
|
"MessageHandler",
|
|
@@ -77,16 +96,6 @@ logger = get_logger(__name__)
|
|
|
77
96
|
T = TypeVar("T", bound="ClientTransport")
|
|
78
97
|
|
|
79
98
|
|
|
80
|
-
def _timeout_to_seconds(
|
|
81
|
-
timeout: datetime.timedelta | float | int | None,
|
|
82
|
-
) -> float | None:
|
|
83
|
-
if timeout is None:
|
|
84
|
-
return None
|
|
85
|
-
if isinstance(timeout, datetime.timedelta):
|
|
86
|
-
return timeout.total_seconds()
|
|
87
|
-
return float(timeout)
|
|
88
|
-
|
|
89
|
-
|
|
90
99
|
@dataclass
|
|
91
100
|
class ClientSessionState:
|
|
92
101
|
"""Holds all session-related state for a Client instance.
|
|
@@ -104,6 +113,17 @@ class ClientSessionState:
|
|
|
104
113
|
initialize_result: mcp.types.InitializeResult | None = None
|
|
105
114
|
|
|
106
115
|
|
|
116
|
+
@dataclass
|
|
117
|
+
class CallToolResult:
|
|
118
|
+
"""Parsed result from a tool call."""
|
|
119
|
+
|
|
120
|
+
content: list[mcp.types.ContentBlock]
|
|
121
|
+
structured_content: dict[str, Any] | None
|
|
122
|
+
meta: dict[str, Any] | None
|
|
123
|
+
data: Any = None
|
|
124
|
+
is_error: bool = False
|
|
125
|
+
|
|
126
|
+
|
|
107
127
|
class Client(Generic[ClientTransportT]):
|
|
108
128
|
"""
|
|
109
129
|
MCP client that delegates connection management to a Transport instance.
|
|
@@ -226,7 +246,8 @@ class Client(Generic[ClientTransportT]):
|
|
|
226
246
|
),
|
|
227
247
|
name: str | None = None,
|
|
228
248
|
roots: RootsList | RootsHandler | None = None,
|
|
229
|
-
sampling_handler:
|
|
249
|
+
sampling_handler: SamplingHandler | None = None,
|
|
250
|
+
sampling_capabilities: mcp.types.SamplingCapability | None = None,
|
|
230
251
|
elicitation_handler: ElicitationHandler | None = None,
|
|
231
252
|
log_handler: LogHandler | None = None,
|
|
232
253
|
message_handler: MessageHandlerT | MessageHandler | None = None,
|
|
@@ -258,7 +279,13 @@ class Client(Generic[ClientTransportT]):
|
|
|
258
279
|
# handle init handshake timeout
|
|
259
280
|
if init_timeout is None:
|
|
260
281
|
init_timeout = fastmcp.settings.client_init_timeout
|
|
261
|
-
|
|
282
|
+
if isinstance(init_timeout, datetime.timedelta):
|
|
283
|
+
init_timeout = init_timeout.total_seconds()
|
|
284
|
+
elif not init_timeout:
|
|
285
|
+
init_timeout = None
|
|
286
|
+
else:
|
|
287
|
+
init_timeout = float(init_timeout)
|
|
288
|
+
self._init_timeout = init_timeout
|
|
262
289
|
|
|
263
290
|
self.auto_initialize = auto_initialize
|
|
264
291
|
|
|
@@ -266,7 +293,7 @@ class Client(Generic[ClientTransportT]):
|
|
|
266
293
|
"sampling_callback": None,
|
|
267
294
|
"list_roots_callback": None,
|
|
268
295
|
"logging_callback": create_log_callback(log_handler),
|
|
269
|
-
"message_handler": message_handler,
|
|
296
|
+
"message_handler": message_handler or TaskNotificationHandler(self),
|
|
270
297
|
"read_timeout_seconds": timeout, # ty: ignore[invalid-argument-type]
|
|
271
298
|
"client_info": client_info,
|
|
272
299
|
}
|
|
@@ -278,6 +305,14 @@ class Client(Generic[ClientTransportT]):
|
|
|
278
305
|
self._session_kwargs["sampling_callback"] = create_sampling_callback(
|
|
279
306
|
sampling_handler
|
|
280
307
|
)
|
|
308
|
+
# Default to tools-enabled capabilities unless explicitly overridden
|
|
309
|
+
self._session_kwargs["sampling_capabilities"] = (
|
|
310
|
+
sampling_capabilities
|
|
311
|
+
if sampling_capabilities is not None
|
|
312
|
+
else mcp.types.SamplingCapability(
|
|
313
|
+
tools=mcp.types.SamplingToolsCapability()
|
|
314
|
+
)
|
|
315
|
+
)
|
|
281
316
|
|
|
282
317
|
if elicitation_handler is not None:
|
|
283
318
|
self._session_kwargs["elicitation_callback"] = create_elicitation_callback(
|
|
@@ -287,6 +322,29 @@ class Client(Generic[ClientTransportT]):
|
|
|
287
322
|
# Session context management - see class docstring for detailed explanation
|
|
288
323
|
self._session_state = ClientSessionState()
|
|
289
324
|
|
|
325
|
+
# Track task IDs submitted by this client (for list_tasks support)
|
|
326
|
+
self._submitted_task_ids: set[str] = set()
|
|
327
|
+
|
|
328
|
+
# Registry for routing notifications/tasks/status to Task objects
|
|
329
|
+
|
|
330
|
+
self._task_registry: dict[
|
|
331
|
+
str, weakref.ref[ToolTask | PromptTask | ResourceTask]
|
|
332
|
+
] = {}
|
|
333
|
+
|
|
334
|
+
def _reset_session_state(self, full: bool = False) -> None:
|
|
335
|
+
"""Reset session state after disconnect or cancellation.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
full: If True, also resets session_task and nesting_counter.
|
|
339
|
+
Use full=True for cancellation cleanup where the session
|
|
340
|
+
task was started but never completed normally.
|
|
341
|
+
"""
|
|
342
|
+
self._session_state.session = None
|
|
343
|
+
self._session_state.initialize_result = None
|
|
344
|
+
if full:
|
|
345
|
+
self._session_state.session_task = None
|
|
346
|
+
self._session_state.nesting_counter = 0
|
|
347
|
+
|
|
290
348
|
@property
|
|
291
349
|
def session(self) -> ClientSession:
|
|
292
350
|
"""Get the current active session. Raises RuntimeError if not connected."""
|
|
@@ -306,11 +364,21 @@ class Client(Generic[ClientTransportT]):
|
|
|
306
364
|
"""Set the roots for the client. This does not automatically call `send_roots_list_changed`."""
|
|
307
365
|
self._session_kwargs["list_roots_callback"] = create_roots_callback(roots)
|
|
308
366
|
|
|
309
|
-
def set_sampling_callback(
|
|
367
|
+
def set_sampling_callback(
|
|
368
|
+
self,
|
|
369
|
+
sampling_callback: SamplingHandler,
|
|
370
|
+
sampling_capabilities: mcp.types.SamplingCapability | None = None,
|
|
371
|
+
) -> None:
|
|
310
372
|
"""Set the sampling callback for the client."""
|
|
311
373
|
self._session_kwargs["sampling_callback"] = create_sampling_callback(
|
|
312
374
|
sampling_callback
|
|
313
375
|
)
|
|
376
|
+
# Default to tools-enabled capabilities unless explicitly overridden
|
|
377
|
+
self._session_kwargs["sampling_capabilities"] = (
|
|
378
|
+
sampling_capabilities
|
|
379
|
+
if sampling_capabilities is not None
|
|
380
|
+
else mcp.types.SamplingCapability(tools=mcp.types.SamplingToolsCapability())
|
|
381
|
+
)
|
|
314
382
|
|
|
315
383
|
def set_elicitation_callback(
|
|
316
384
|
self, elicitation_callback: ElicitationHandler
|
|
@@ -359,7 +427,7 @@ class Client(Generic[ClientTransportT]):
|
|
|
359
427
|
**self._session_kwargs
|
|
360
428
|
) as session:
|
|
361
429
|
self._session_state.session = session
|
|
362
|
-
# Initialize the session
|
|
430
|
+
# Initialize the session if auto_initialize is enabled
|
|
363
431
|
try:
|
|
364
432
|
if self.auto_initialize:
|
|
365
433
|
await self.initialize()
|
|
@@ -367,14 +435,72 @@ class Client(Generic[ClientTransportT]):
|
|
|
367
435
|
except anyio.ClosedResourceError as e:
|
|
368
436
|
raise RuntimeError("Server session was closed unexpectedly") from e
|
|
369
437
|
finally:
|
|
370
|
-
self.
|
|
371
|
-
|
|
438
|
+
self._reset_session_state()
|
|
439
|
+
|
|
440
|
+
async def initialize(
|
|
441
|
+
self,
|
|
442
|
+
timeout: datetime.timedelta | float | int | None = None,
|
|
443
|
+
) -> mcp.types.InitializeResult:
|
|
444
|
+
"""Send an initialize request to the server.
|
|
445
|
+
|
|
446
|
+
This method performs the MCP initialization handshake with the server,
|
|
447
|
+
exchanging capabilities and server information. It is idempotent - calling
|
|
448
|
+
it multiple times returns the cached result from the first call.
|
|
449
|
+
|
|
450
|
+
The initialization happens automatically when entering the client context
|
|
451
|
+
manager unless `auto_initialize=False` was set during client construction.
|
|
452
|
+
Manual calls to this method are only needed when auto-initialization is disabled.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
timeout: Optional timeout for the initialization request (seconds or timedelta).
|
|
456
|
+
If None, uses the client's init_timeout setting.
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
InitializeResult: The server's initialization response containing server info,
|
|
460
|
+
capabilities, protocol version, and optional instructions.
|
|
461
|
+
|
|
462
|
+
Raises:
|
|
463
|
+
RuntimeError: If the client is not connected or initialization times out.
|
|
464
|
+
|
|
465
|
+
Example:
|
|
466
|
+
```python
|
|
467
|
+
# With auto-initialization disabled
|
|
468
|
+
client = Client(server, auto_initialize=False)
|
|
469
|
+
async with client:
|
|
470
|
+
result = await client.initialize()
|
|
471
|
+
print(f"Server: {result.serverInfo.name}")
|
|
472
|
+
print(f"Instructions: {result.instructions}")
|
|
473
|
+
```
|
|
474
|
+
"""
|
|
475
|
+
|
|
476
|
+
if self.initialize_result is not None:
|
|
477
|
+
return self.initialize_result
|
|
478
|
+
|
|
479
|
+
if timeout is None:
|
|
480
|
+
timeout = self._init_timeout
|
|
481
|
+
|
|
482
|
+
# Convert timeout if needed
|
|
483
|
+
if isinstance(timeout, datetime.timedelta):
|
|
484
|
+
timeout = timeout.total_seconds()
|
|
485
|
+
elif timeout is not None:
|
|
486
|
+
timeout = float(timeout)
|
|
487
|
+
|
|
488
|
+
try:
|
|
489
|
+
with anyio.fail_after(timeout):
|
|
490
|
+
self._session_state.initialize_result = await self.session.initialize()
|
|
491
|
+
return self._session_state.initialize_result
|
|
492
|
+
except TimeoutError as e:
|
|
493
|
+
raise RuntimeError("Failed to initialize server session") from e
|
|
372
494
|
|
|
373
495
|
async def __aenter__(self):
|
|
374
496
|
return await self._connect()
|
|
375
497
|
|
|
376
498
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
377
|
-
|
|
499
|
+
# Use a timeout to prevent hanging during cleanup if the connection is in a bad
|
|
500
|
+
# state (e.g., rate-limited). The MCP SDK's transport may try to terminate the
|
|
501
|
+
# session which can hang if the server is unresponsive.
|
|
502
|
+
with anyio.move_on_after(5):
|
|
503
|
+
await self._disconnect()
|
|
378
504
|
|
|
379
505
|
async def _connect(self):
|
|
380
506
|
"""
|
|
@@ -406,7 +532,48 @@ class Client(Generic[ClientTransportT]):
|
|
|
406
532
|
self._session_state.session_task = asyncio.create_task(
|
|
407
533
|
self._session_runner()
|
|
408
534
|
)
|
|
409
|
-
|
|
535
|
+
try:
|
|
536
|
+
await self._session_state.ready_event.wait()
|
|
537
|
+
except asyncio.CancelledError:
|
|
538
|
+
# Cancellation during initial connection startup can leave the
|
|
539
|
+
# background session task running because __aexit__ is never invoked
|
|
540
|
+
# when __aenter__ is cancelled. Since we hold the session lock here
|
|
541
|
+
# and we know we started the session task, it's safe to tear it down
|
|
542
|
+
# without impacting other active contexts.
|
|
543
|
+
#
|
|
544
|
+
# Note: session_task is an asyncio.Task (not anyio) because it needs
|
|
545
|
+
# to outlive individual context manager scopes - anyio's structured
|
|
546
|
+
# concurrency doesn't allow tasks to escape their task group.
|
|
547
|
+
session_task = self._session_state.session_task
|
|
548
|
+
if session_task is not None:
|
|
549
|
+
# Request a graceful stop if the runner has already reached
|
|
550
|
+
# its stop_event wait.
|
|
551
|
+
self._session_state.stop_event.set()
|
|
552
|
+
session_task.cancel()
|
|
553
|
+
with anyio.CancelScope(shield=True):
|
|
554
|
+
with anyio.move_on_after(3):
|
|
555
|
+
try:
|
|
556
|
+
await session_task
|
|
557
|
+
except asyncio.CancelledError:
|
|
558
|
+
pass
|
|
559
|
+
except Exception as e:
|
|
560
|
+
logger.debug(
|
|
561
|
+
f"Error during cancelled session cleanup: {e}"
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
# Reset session state so future callers can reconnect cleanly.
|
|
565
|
+
self._reset_session_state(full=True)
|
|
566
|
+
|
|
567
|
+
with anyio.CancelScope(shield=True):
|
|
568
|
+
with anyio.move_on_after(3):
|
|
569
|
+
try:
|
|
570
|
+
await self.transport.close()
|
|
571
|
+
except Exception as e:
|
|
572
|
+
logger.debug(
|
|
573
|
+
f"Error closing transport after cancellation: {e}"
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
raise
|
|
410
577
|
|
|
411
578
|
if self._session_state.session_task.done():
|
|
412
579
|
exception = self._session_state.session_task.exception()
|
|
@@ -414,7 +581,8 @@ class Client(Generic[ClientTransportT]):
|
|
|
414
581
|
raise RuntimeError(
|
|
415
582
|
"Session task completed without exception but connection failed"
|
|
416
583
|
)
|
|
417
|
-
|
|
584
|
+
# Preserve specific exception types that clients may want to handle
|
|
585
|
+
if isinstance(exception, httpx.HTTPStatusError | McpError):
|
|
418
586
|
raise exception
|
|
419
587
|
raise RuntimeError(
|
|
420
588
|
f"Client failed to connect: {exception}"
|
|
@@ -487,61 +655,34 @@ class Client(Generic[ClientTransportT]):
|
|
|
487
655
|
# Ensure ready event is set even if context manager entry fails
|
|
488
656
|
self._session_state.ready_event.set()
|
|
489
657
|
|
|
658
|
+
def _handle_task_status_notification(
|
|
659
|
+
self, notification: TaskStatusNotification
|
|
660
|
+
) -> None:
|
|
661
|
+
"""Route task status notification to appropriate Task object.
|
|
662
|
+
|
|
663
|
+
Called when notifications/tasks/status is received from server.
|
|
664
|
+
Updates Task object's cache and triggers events/callbacks.
|
|
665
|
+
"""
|
|
666
|
+
# Extract task ID from notification params
|
|
667
|
+
task_id = notification.params.taskId
|
|
668
|
+
if not task_id:
|
|
669
|
+
return
|
|
670
|
+
|
|
671
|
+
# Look up task in registry (weakref)
|
|
672
|
+
task_ref = self._task_registry.get(task_id)
|
|
673
|
+
if task_ref:
|
|
674
|
+
task = task_ref() # Dereference weakref
|
|
675
|
+
if task:
|
|
676
|
+
# Convert notification params to GetTaskResult (they share the same fields via Task)
|
|
677
|
+
status = GetTaskResult.model_validate(notification.params.model_dump())
|
|
678
|
+
task._handle_status_notification(status)
|
|
679
|
+
|
|
490
680
|
async def close(self):
|
|
491
681
|
await self._disconnect(force=True)
|
|
492
682
|
await self.transport.close()
|
|
493
683
|
|
|
494
684
|
# --- MCP Client Methods ---
|
|
495
685
|
|
|
496
|
-
async def initialize(
|
|
497
|
-
self,
|
|
498
|
-
timeout: datetime.timedelta | float | int | None = None,
|
|
499
|
-
) -> mcp.types.InitializeResult:
|
|
500
|
-
"""Send an initialize request to the server.
|
|
501
|
-
|
|
502
|
-
This method performs the MCP initialization handshake with the server,
|
|
503
|
-
exchanging capabilities and server information. It is idempotent - calling
|
|
504
|
-
it multiple times returns the cached result from the first call.
|
|
505
|
-
|
|
506
|
-
The initialization happens automatically when entering the client context
|
|
507
|
-
manager unless `auto_initialize=False` was set during client construction.
|
|
508
|
-
Manual calls to this method are only needed when auto-initialization is disabled.
|
|
509
|
-
|
|
510
|
-
Args:
|
|
511
|
-
timeout: Optional timeout for the initialization request (seconds or timedelta).
|
|
512
|
-
If None, uses the client's init_timeout setting.
|
|
513
|
-
|
|
514
|
-
Returns:
|
|
515
|
-
InitializeResult: The server's initialization response containing server info,
|
|
516
|
-
capabilities, protocol version, and optional instructions.
|
|
517
|
-
|
|
518
|
-
Raises:
|
|
519
|
-
RuntimeError: If the client is not connected or initialization times out.
|
|
520
|
-
|
|
521
|
-
Example:
|
|
522
|
-
```python
|
|
523
|
-
# With auto-initialization disabled
|
|
524
|
-
client = Client(server, auto_initialize=False)
|
|
525
|
-
async with client:
|
|
526
|
-
result = await client.initialize()
|
|
527
|
-
print(f"Server: {result.serverInfo.name}")
|
|
528
|
-
print(f"Instructions: {result.instructions}")
|
|
529
|
-
```
|
|
530
|
-
"""
|
|
531
|
-
|
|
532
|
-
if self.initialize_result is not None:
|
|
533
|
-
return self.initialize_result
|
|
534
|
-
|
|
535
|
-
if timeout is None:
|
|
536
|
-
timeout = self._init_timeout
|
|
537
|
-
try:
|
|
538
|
-
with anyio.fail_after(_timeout_to_seconds(timeout)):
|
|
539
|
-
initialize_result = await self.session.initialize()
|
|
540
|
-
self._session_state.initialize_result = initialize_result
|
|
541
|
-
return initialize_result
|
|
542
|
-
except TimeoutError as e:
|
|
543
|
-
raise RuntimeError("Failed to initialize server session") from e
|
|
544
|
-
|
|
545
686
|
async def ping(self) -> bool:
|
|
546
687
|
"""Send a ping request."""
|
|
547
688
|
result = await self.session.send_ping()
|
|
@@ -645,12 +786,13 @@ class Client(Generic[ClientTransportT]):
|
|
|
645
786
|
return result.resourceTemplates
|
|
646
787
|
|
|
647
788
|
async def read_resource_mcp(
|
|
648
|
-
self, uri: AnyUrl | str
|
|
789
|
+
self, uri: AnyUrl | str, meta: dict[str, Any] | None = None
|
|
649
790
|
) -> mcp.types.ReadResourceResult:
|
|
650
791
|
"""Send a resources/read request and return the complete MCP protocol result.
|
|
651
792
|
|
|
652
793
|
Args:
|
|
653
794
|
uri (AnyUrl | str): The URI of the resource to read. Can be a string or an AnyUrl object.
|
|
795
|
+
meta (dict[str, Any] | None, optional): Request metadata (e.g., for SEP-1686 tasks). Defaults to None.
|
|
654
796
|
|
|
655
797
|
Returns:
|
|
656
798
|
mcp.types.ReadResourceResult: The complete response object from the protocol,
|
|
@@ -663,24 +805,73 @@ class Client(Generic[ClientTransportT]):
|
|
|
663
805
|
|
|
664
806
|
if isinstance(uri, str):
|
|
665
807
|
uri = AnyUrl(uri) # Ensure AnyUrl
|
|
666
|
-
|
|
808
|
+
|
|
809
|
+
# If meta provided, use send_request for SEP-1686 task support
|
|
810
|
+
if meta:
|
|
811
|
+
task_dict = meta.get("modelcontextprotocol.io/task")
|
|
812
|
+
request = mcp.types.ReadResourceRequest(
|
|
813
|
+
params=mcp.types.ReadResourceRequestParams(
|
|
814
|
+
uri=uri,
|
|
815
|
+
task=mcp.types.TaskMetadata(**task_dict)
|
|
816
|
+
if task_dict
|
|
817
|
+
else None, # SEP-1686: task as direct param (spec-compliant)
|
|
818
|
+
)
|
|
819
|
+
)
|
|
820
|
+
result = await self.session.send_request(
|
|
821
|
+
request=request, # type: ignore[arg-type]
|
|
822
|
+
result_type=mcp.types.ReadResourceResult,
|
|
823
|
+
)
|
|
824
|
+
else:
|
|
825
|
+
result = await self.session.read_resource(uri)
|
|
667
826
|
return result
|
|
668
827
|
|
|
828
|
+
@overload
|
|
829
|
+
async def read_resource(
|
|
830
|
+
self,
|
|
831
|
+
uri: AnyUrl | str,
|
|
832
|
+
*,
|
|
833
|
+
task: Literal[False] = False,
|
|
834
|
+
) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]: ...
|
|
835
|
+
|
|
836
|
+
@overload
|
|
669
837
|
async def read_resource(
|
|
670
|
-
self,
|
|
671
|
-
|
|
838
|
+
self,
|
|
839
|
+
uri: AnyUrl | str,
|
|
840
|
+
*,
|
|
841
|
+
task: Literal[True],
|
|
842
|
+
task_id: str | None = None,
|
|
843
|
+
ttl: int = 60000,
|
|
844
|
+
) -> ResourceTask: ...
|
|
845
|
+
|
|
846
|
+
async def read_resource(
|
|
847
|
+
self,
|
|
848
|
+
uri: AnyUrl | str,
|
|
849
|
+
*,
|
|
850
|
+
task: bool = False,
|
|
851
|
+
task_id: str | None = None,
|
|
852
|
+
ttl: int = 60000,
|
|
853
|
+
) -> (
|
|
854
|
+
list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]
|
|
855
|
+
| ResourceTask
|
|
856
|
+
):
|
|
672
857
|
"""Read the contents of a resource or resolved template.
|
|
673
858
|
|
|
674
859
|
Args:
|
|
675
860
|
uri (AnyUrl | str): The URI of the resource to read. Can be a string or an AnyUrl object.
|
|
861
|
+
task (bool): If True, execute as background task (SEP-1686). Defaults to False.
|
|
862
|
+
task_id (str | None): Optional client-provided task ID (auto-generated if not provided).
|
|
863
|
+
ttl (int): Time to keep results available in milliseconds (default 60s).
|
|
676
864
|
|
|
677
865
|
Returns:
|
|
678
|
-
list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]
|
|
679
|
-
objects,
|
|
866
|
+
list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents] | ResourceTask:
|
|
867
|
+
A list of content objects if task=False, or a ResourceTask object if task=True.
|
|
680
868
|
|
|
681
869
|
Raises:
|
|
682
870
|
RuntimeError: If called while the client is not connected.
|
|
683
871
|
"""
|
|
872
|
+
if task:
|
|
873
|
+
return await self._read_resource_as_task(uri, task_id, ttl)
|
|
874
|
+
|
|
684
875
|
if isinstance(uri, str):
|
|
685
876
|
try:
|
|
686
877
|
uri = AnyUrl(uri) # Ensure AnyUrl
|
|
@@ -691,6 +882,62 @@ class Client(Generic[ClientTransportT]):
|
|
|
691
882
|
result = await self.read_resource_mcp(uri)
|
|
692
883
|
return result.contents
|
|
693
884
|
|
|
885
|
+
async def _read_resource_as_task(
|
|
886
|
+
self,
|
|
887
|
+
uri: AnyUrl | str,
|
|
888
|
+
task_id: str | None = None,
|
|
889
|
+
ttl: int = 60000,
|
|
890
|
+
) -> ResourceTask:
|
|
891
|
+
"""Read a resource for background execution (SEP-1686).
|
|
892
|
+
|
|
893
|
+
Returns a ResourceTask object that handles both background and immediate execution.
|
|
894
|
+
|
|
895
|
+
Args:
|
|
896
|
+
uri: Resource URI to read
|
|
897
|
+
task_id: Optional client-provided task ID (ignored, for backward compatibility)
|
|
898
|
+
ttl: Time to keep results available in milliseconds (default 60s)
|
|
899
|
+
|
|
900
|
+
Returns:
|
|
901
|
+
ResourceTask: Future-like object for accessing task status and results
|
|
902
|
+
"""
|
|
903
|
+
# Per SEP-1686 final spec: client sends only ttl, server generates taskId
|
|
904
|
+
# Read resource with task metadata (no taskId sent)
|
|
905
|
+
result = await self.read_resource_mcp(
|
|
906
|
+
uri=uri,
|
|
907
|
+
meta={
|
|
908
|
+
"modelcontextprotocol.io/task": {
|
|
909
|
+
"ttl": ttl,
|
|
910
|
+
}
|
|
911
|
+
},
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
# Check if server accepted background execution
|
|
915
|
+
# If response includes task metadata with taskId, server accepted background mode
|
|
916
|
+
# If response includes returned_immediately=True, server declined and executed sync
|
|
917
|
+
task_meta = (result.meta or {}).get("modelcontextprotocol.io/task", {})
|
|
918
|
+
if task_meta.get("taskId"):
|
|
919
|
+
# Background execution accepted - extract server-generated taskId
|
|
920
|
+
server_task_id = task_meta["taskId"]
|
|
921
|
+
# Track this task ID for list_tasks()
|
|
922
|
+
self._submitted_task_ids.add(server_task_id)
|
|
923
|
+
|
|
924
|
+
# Create task object
|
|
925
|
+
task_obj = ResourceTask(
|
|
926
|
+
self, server_task_id, uri=str(uri), immediate_result=None
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
# Register for notification routing
|
|
930
|
+
self._task_registry[server_task_id] = weakref.ref(task_obj) # type: ignore[assignment]
|
|
931
|
+
|
|
932
|
+
return task_obj
|
|
933
|
+
else:
|
|
934
|
+
# Server declined background execution (graceful degradation)
|
|
935
|
+
# Use a synthetic task ID for the immediate result
|
|
936
|
+
synthetic_task_id = task_id or str(uuid.uuid4())
|
|
937
|
+
return ResourceTask(
|
|
938
|
+
self, synthetic_task_id, uri=str(uri), immediate_result=result.contents
|
|
939
|
+
)
|
|
940
|
+
|
|
694
941
|
# async def subscribe_resource(self, uri: AnyUrl | str) -> None:
|
|
695
942
|
# """Send a resources/subscribe request."""
|
|
696
943
|
# if isinstance(uri, str):
|
|
@@ -734,13 +981,17 @@ class Client(Generic[ClientTransportT]):
|
|
|
734
981
|
|
|
735
982
|
# --- Prompt ---
|
|
736
983
|
async def get_prompt_mcp(
|
|
737
|
-
self,
|
|
984
|
+
self,
|
|
985
|
+
name: str,
|
|
986
|
+
arguments: dict[str, Any] | None = None,
|
|
987
|
+
meta: dict[str, Any] | None = None,
|
|
738
988
|
) -> mcp.types.GetPromptResult:
|
|
739
989
|
"""Send a prompts/get request and return the complete MCP protocol result.
|
|
740
990
|
|
|
741
991
|
Args:
|
|
742
992
|
name (str): The name of the prompt to retrieve.
|
|
743
993
|
arguments (dict[str, Any] | None, optional): Arguments to pass to the prompt. Defaults to None.
|
|
994
|
+
meta (dict[str, Any] | None, optional): Request metadata (e.g., for SEP-1686 tasks). Defaults to None.
|
|
744
995
|
|
|
745
996
|
Returns:
|
|
746
997
|
mcp.types.GetPromptResult: The complete response object from the protocol,
|
|
@@ -764,30 +1015,138 @@ class Client(Generic[ClientTransportT]):
|
|
|
764
1015
|
"utf-8"
|
|
765
1016
|
)
|
|
766
1017
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1018
|
+
# If meta provided, use send_request for SEP-1686 task support
|
|
1019
|
+
if meta:
|
|
1020
|
+
task_dict = meta.get("modelcontextprotocol.io/task")
|
|
1021
|
+
request = mcp.types.GetPromptRequest(
|
|
1022
|
+
params=mcp.types.GetPromptRequestParams(
|
|
1023
|
+
name=name,
|
|
1024
|
+
arguments=serialized_arguments,
|
|
1025
|
+
task=mcp.types.TaskMetadata(**task_dict)
|
|
1026
|
+
if task_dict
|
|
1027
|
+
else None, # SEP-1686: task as direct param (spec-compliant)
|
|
1028
|
+
)
|
|
1029
|
+
)
|
|
1030
|
+
result = await self.session.send_request(
|
|
1031
|
+
request=request, # type: ignore[arg-type]
|
|
1032
|
+
result_type=mcp.types.GetPromptResult,
|
|
1033
|
+
)
|
|
1034
|
+
else:
|
|
1035
|
+
result = await self.session.get_prompt(
|
|
1036
|
+
name=name, arguments=serialized_arguments
|
|
1037
|
+
)
|
|
770
1038
|
return result
|
|
771
1039
|
|
|
1040
|
+
@overload
|
|
772
1041
|
async def get_prompt(
|
|
773
|
-
self,
|
|
774
|
-
|
|
1042
|
+
self,
|
|
1043
|
+
name: str,
|
|
1044
|
+
arguments: dict[str, Any] | None = None,
|
|
1045
|
+
*,
|
|
1046
|
+
task: Literal[False] = False,
|
|
1047
|
+
) -> mcp.types.GetPromptResult: ...
|
|
1048
|
+
|
|
1049
|
+
@overload
|
|
1050
|
+
async def get_prompt(
|
|
1051
|
+
self,
|
|
1052
|
+
name: str,
|
|
1053
|
+
arguments: dict[str, Any] | None = None,
|
|
1054
|
+
*,
|
|
1055
|
+
task: Literal[True],
|
|
1056
|
+
task_id: str | None = None,
|
|
1057
|
+
ttl: int = 60000,
|
|
1058
|
+
) -> PromptTask: ...
|
|
1059
|
+
|
|
1060
|
+
async def get_prompt(
|
|
1061
|
+
self,
|
|
1062
|
+
name: str,
|
|
1063
|
+
arguments: dict[str, Any] | None = None,
|
|
1064
|
+
*,
|
|
1065
|
+
task: bool = False,
|
|
1066
|
+
task_id: str | None = None,
|
|
1067
|
+
ttl: int = 60000,
|
|
1068
|
+
) -> mcp.types.GetPromptResult | PromptTask:
|
|
775
1069
|
"""Retrieve a rendered prompt message list from the server.
|
|
776
1070
|
|
|
777
1071
|
Args:
|
|
778
1072
|
name (str): The name of the prompt to retrieve.
|
|
779
1073
|
arguments (dict[str, Any] | None, optional): Arguments to pass to the prompt. Defaults to None.
|
|
1074
|
+
task (bool): If True, execute as background task (SEP-1686). Defaults to False.
|
|
1075
|
+
task_id (str | None): Optional client-provided task ID (auto-generated if not provided).
|
|
1076
|
+
ttl (int): Time to keep results available in milliseconds (default 60s).
|
|
780
1077
|
|
|
781
1078
|
Returns:
|
|
782
|
-
mcp.types.GetPromptResult: The complete response object
|
|
783
|
-
|
|
1079
|
+
mcp.types.GetPromptResult | PromptTask: The complete response object if task=False,
|
|
1080
|
+
or a PromptTask object if task=True.
|
|
784
1081
|
|
|
785
1082
|
Raises:
|
|
786
1083
|
RuntimeError: If called while the client is not connected.
|
|
787
1084
|
"""
|
|
1085
|
+
if task:
|
|
1086
|
+
return await self._get_prompt_as_task(name, arguments, task_id, ttl)
|
|
1087
|
+
|
|
788
1088
|
result = await self.get_prompt_mcp(name=name, arguments=arguments)
|
|
789
1089
|
return result
|
|
790
1090
|
|
|
1091
|
+
async def _get_prompt_as_task(
|
|
1092
|
+
self,
|
|
1093
|
+
name: str,
|
|
1094
|
+
arguments: dict[str, Any] | None = None,
|
|
1095
|
+
task_id: str | None = None,
|
|
1096
|
+
ttl: int = 60000,
|
|
1097
|
+
) -> PromptTask:
|
|
1098
|
+
"""Get a prompt for background execution (SEP-1686).
|
|
1099
|
+
|
|
1100
|
+
Returns a PromptTask object that handles both background and immediate execution.
|
|
1101
|
+
|
|
1102
|
+
Args:
|
|
1103
|
+
name: Prompt name to get
|
|
1104
|
+
arguments: Prompt arguments
|
|
1105
|
+
task_id: Optional client-provided task ID (ignored, for backward compatibility)
|
|
1106
|
+
ttl: Time to keep results available in milliseconds (default 60s)
|
|
1107
|
+
|
|
1108
|
+
Returns:
|
|
1109
|
+
PromptTask: Future-like object for accessing task status and results
|
|
1110
|
+
"""
|
|
1111
|
+
# Per SEP-1686 final spec: client sends only ttl, server generates taskId
|
|
1112
|
+
# Call prompt with task metadata (no taskId sent)
|
|
1113
|
+
result = await self.get_prompt_mcp(
|
|
1114
|
+
name=name,
|
|
1115
|
+
arguments=arguments or {},
|
|
1116
|
+
meta={
|
|
1117
|
+
"modelcontextprotocol.io/task": {
|
|
1118
|
+
"ttl": ttl,
|
|
1119
|
+
}
|
|
1120
|
+
},
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
# Check if server accepted background execution
|
|
1124
|
+
# If response includes task metadata with taskId, server accepted background mode
|
|
1125
|
+
# If response includes returned_immediately=True, server declined and executed sync
|
|
1126
|
+
task_meta = (result.meta or {}).get("modelcontextprotocol.io/task", {})
|
|
1127
|
+
if task_meta.get("taskId"):
|
|
1128
|
+
# Background execution accepted - extract server-generated taskId
|
|
1129
|
+
server_task_id = task_meta["taskId"]
|
|
1130
|
+
# Track this task ID for list_tasks()
|
|
1131
|
+
self._submitted_task_ids.add(server_task_id)
|
|
1132
|
+
|
|
1133
|
+
# Create task object
|
|
1134
|
+
task_obj = PromptTask(
|
|
1135
|
+
self, server_task_id, prompt_name=name, immediate_result=None
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
# Register for notification routing
|
|
1139
|
+
self._task_registry[server_task_id] = weakref.ref(task_obj) # type: ignore[assignment]
|
|
1140
|
+
|
|
1141
|
+
return task_obj
|
|
1142
|
+
else:
|
|
1143
|
+
# Server declined background execution (graceful degradation)
|
|
1144
|
+
# Use a synthetic task ID for the immediate result
|
|
1145
|
+
synthetic_task_id = task_id or str(uuid.uuid4())
|
|
1146
|
+
return PromptTask(
|
|
1147
|
+
self, synthetic_task_id, prompt_name=name, immediate_result=result
|
|
1148
|
+
)
|
|
1149
|
+
|
|
791
1150
|
# --- Completion ---
|
|
792
1151
|
|
|
793
1152
|
async def complete_mcp(
|
|
@@ -910,24 +1269,123 @@ class Client(Generic[ClientTransportT]):
|
|
|
910
1269
|
if isinstance(timeout, int | float):
|
|
911
1270
|
timeout = datetime.timedelta(seconds=float(timeout))
|
|
912
1271
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
1272
|
+
# For task submissions, use send_request to bypass SDK validation
|
|
1273
|
+
# Task acknowledgments don't have structured content, which would fail validation
|
|
1274
|
+
if meta and "modelcontextprotocol.io/task" in meta:
|
|
1275
|
+
task_dict = meta.get("modelcontextprotocol.io/task")
|
|
1276
|
+
request = mcp.types.CallToolRequest(
|
|
1277
|
+
params=mcp.types.CallToolRequestParams(
|
|
1278
|
+
name=name,
|
|
1279
|
+
arguments=arguments,
|
|
1280
|
+
task=mcp.types.TaskMetadata(**task_dict)
|
|
1281
|
+
if task_dict
|
|
1282
|
+
else None, # SEP-1686: task as direct param (spec-compliant)
|
|
1283
|
+
)
|
|
1284
|
+
)
|
|
1285
|
+
result = await self.session.send_request(
|
|
1286
|
+
request=request, # type: ignore[arg-type]
|
|
1287
|
+
result_type=mcp.types.CallToolResult,
|
|
1288
|
+
request_read_timeout_seconds=timeout, # type: ignore[arg-type]
|
|
1289
|
+
progress_callback=progress_handler or self._progress_handler,
|
|
1290
|
+
)
|
|
1291
|
+
else:
|
|
1292
|
+
result = await self.session.call_tool(
|
|
1293
|
+
name=name,
|
|
1294
|
+
arguments=arguments,
|
|
1295
|
+
read_timeout_seconds=timeout, # ty: ignore[invalid-argument-type]
|
|
1296
|
+
progress_callback=progress_handler or self._progress_handler,
|
|
1297
|
+
meta=meta,
|
|
1298
|
+
)
|
|
920
1299
|
return result
|
|
921
1300
|
|
|
1301
|
+
async def _parse_call_tool_result(
|
|
1302
|
+
self, name: str, result: mcp.types.CallToolResult, raise_on_error: bool = False
|
|
1303
|
+
) -> CallToolResult:
|
|
1304
|
+
"""Parse an mcp.types.CallToolResult into our CallToolResult dataclass.
|
|
1305
|
+
|
|
1306
|
+
Args:
|
|
1307
|
+
name: Tool name (for schema lookup)
|
|
1308
|
+
result: Raw MCP protocol result
|
|
1309
|
+
raise_on_error: Whether to raise ToolError on errors
|
|
1310
|
+
|
|
1311
|
+
Returns:
|
|
1312
|
+
CallToolResult: Parsed result with structured data
|
|
1313
|
+
"""
|
|
1314
|
+
data = None
|
|
1315
|
+
if result.isError and raise_on_error:
|
|
1316
|
+
msg = cast(mcp.types.TextContent, result.content[0]).text
|
|
1317
|
+
raise ToolError(msg)
|
|
1318
|
+
elif result.structuredContent:
|
|
1319
|
+
try:
|
|
1320
|
+
if name not in self.session._tool_output_schemas:
|
|
1321
|
+
await self.session.list_tools()
|
|
1322
|
+
if name in self.session._tool_output_schemas:
|
|
1323
|
+
output_schema = self.session._tool_output_schemas.get(name)
|
|
1324
|
+
if output_schema:
|
|
1325
|
+
if output_schema.get("x-fastmcp-wrap-result"):
|
|
1326
|
+
output_schema = output_schema.get("properties", {}).get(
|
|
1327
|
+
"result"
|
|
1328
|
+
)
|
|
1329
|
+
structured_content = result.structuredContent.get("result")
|
|
1330
|
+
else:
|
|
1331
|
+
structured_content = result.structuredContent
|
|
1332
|
+
output_type = json_schema_to_type(output_schema)
|
|
1333
|
+
type_adapter = get_cached_typeadapter(output_type)
|
|
1334
|
+
data = type_adapter.validate_python(structured_content)
|
|
1335
|
+
else:
|
|
1336
|
+
data = result.structuredContent
|
|
1337
|
+
except Exception as e:
|
|
1338
|
+
logger.error(f"[{self.name}] Error parsing structured content: {e}")
|
|
1339
|
+
|
|
1340
|
+
return CallToolResult(
|
|
1341
|
+
content=result.content,
|
|
1342
|
+
structured_content=result.structuredContent,
|
|
1343
|
+
meta=result.meta,
|
|
1344
|
+
data=data,
|
|
1345
|
+
is_error=result.isError,
|
|
1346
|
+
)
|
|
1347
|
+
|
|
1348
|
+
@overload
|
|
922
1349
|
async def call_tool(
|
|
923
1350
|
self,
|
|
924
1351
|
name: str,
|
|
925
1352
|
arguments: dict[str, Any] | None = None,
|
|
1353
|
+
*,
|
|
926
1354
|
timeout: datetime.timedelta | float | int | None = None,
|
|
927
1355
|
progress_handler: ProgressHandler | None = None,
|
|
928
1356
|
raise_on_error: bool = True,
|
|
929
1357
|
meta: dict[str, Any] | None = None,
|
|
930
|
-
|
|
1358
|
+
task: Literal[False] = False,
|
|
1359
|
+
) -> CallToolResult: ...
|
|
1360
|
+
|
|
1361
|
+
@overload
|
|
1362
|
+
async def call_tool(
|
|
1363
|
+
self,
|
|
1364
|
+
name: str,
|
|
1365
|
+
arguments: dict[str, Any] | None = None,
|
|
1366
|
+
*,
|
|
1367
|
+
timeout: datetime.timedelta | float | int | None = None,
|
|
1368
|
+
progress_handler: ProgressHandler | None = None,
|
|
1369
|
+
raise_on_error: bool = True,
|
|
1370
|
+
meta: dict[str, Any] | None = None,
|
|
1371
|
+
task: Literal[True],
|
|
1372
|
+
task_id: str | None = None,
|
|
1373
|
+
ttl: int = 60000,
|
|
1374
|
+
) -> ToolTask: ...
|
|
1375
|
+
|
|
1376
|
+
async def call_tool(
|
|
1377
|
+
self,
|
|
1378
|
+
name: str,
|
|
1379
|
+
arguments: dict[str, Any] | None = None,
|
|
1380
|
+
*,
|
|
1381
|
+
timeout: datetime.timedelta | float | int | None = None,
|
|
1382
|
+
progress_handler: ProgressHandler | None = None,
|
|
1383
|
+
raise_on_error: bool = True,
|
|
1384
|
+
meta: dict[str, Any] | None = None,
|
|
1385
|
+
task: bool = False,
|
|
1386
|
+
task_id: str | None = None,
|
|
1387
|
+
ttl: int = 60000,
|
|
1388
|
+
) -> CallToolResult | ToolTask:
|
|
931
1389
|
"""Call a tool on the server.
|
|
932
1390
|
|
|
933
1391
|
Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error.
|
|
@@ -937,15 +1395,18 @@ class Client(Generic[ClientTransportT]):
|
|
|
937
1395
|
arguments (dict[str, Any] | None, optional): Arguments to pass to the tool. Defaults to None.
|
|
938
1396
|
timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
|
|
939
1397
|
progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
|
|
940
|
-
raise_on_error (bool, optional): Whether to raise
|
|
1398
|
+
raise_on_error (bool, optional): Whether to raise an exception if the tool call results in an error. Defaults to True.
|
|
941
1399
|
meta (dict[str, Any] | None, optional): Additional metadata to include with the request.
|
|
942
1400
|
This is useful for passing contextual information (like user IDs, trace IDs, or preferences)
|
|
943
1401
|
that shouldn't be tool arguments but may influence server-side processing. The server
|
|
944
1402
|
can access this via `context.request_context.meta`. Defaults to None.
|
|
1403
|
+
task (bool): If True, execute as background task (SEP-1686). Defaults to False.
|
|
1404
|
+
task_id (str | None): Optional client-provided task ID (auto-generated if not provided).
|
|
1405
|
+
ttl (int): Time to keep results available in milliseconds (default 60s).
|
|
945
1406
|
|
|
946
1407
|
Returns:
|
|
947
|
-
CallToolResult:
|
|
948
|
-
|
|
1408
|
+
CallToolResult | ToolTask: The content returned by the tool if task=False,
|
|
1409
|
+
or a ToolTask object if task=True. If the tool returns structured
|
|
949
1410
|
outputs, they are returned as a dataclass (if an output schema
|
|
950
1411
|
is available) or a dictionary; otherwise, a list of content
|
|
951
1412
|
blocks is returned. Note: to receive both structured and
|
|
@@ -956,6 +1417,9 @@ class Client(Generic[ClientTransportT]):
|
|
|
956
1417
|
ToolError: If the tool call results in an error.
|
|
957
1418
|
RuntimeError: If called while the client is not connected.
|
|
958
1419
|
"""
|
|
1420
|
+
if task:
|
|
1421
|
+
return await self._call_tool_as_task(name, arguments, task_id, ttl)
|
|
1422
|
+
|
|
959
1423
|
result = await self.call_tool_mcp(
|
|
960
1424
|
name=name,
|
|
961
1425
|
arguments=arguments or {},
|
|
@@ -963,38 +1427,186 @@ class Client(Generic[ClientTransportT]):
|
|
|
963
1427
|
progress_handler=progress_handler,
|
|
964
1428
|
meta=meta,
|
|
965
1429
|
)
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1430
|
+
return await self._parse_call_tool_result(
|
|
1431
|
+
name, result, raise_on_error=raise_on_error
|
|
1432
|
+
)
|
|
1433
|
+
|
|
1434
|
+
async def _call_tool_as_task(
|
|
1435
|
+
self,
|
|
1436
|
+
name: str,
|
|
1437
|
+
arguments: dict[str, Any] | None = None,
|
|
1438
|
+
task_id: str | None = None,
|
|
1439
|
+
ttl: int = 60000,
|
|
1440
|
+
) -> ToolTask:
|
|
1441
|
+
"""Call a tool for background execution (SEP-1686).
|
|
1442
|
+
|
|
1443
|
+
Returns a ToolTask object that handles both background and immediate execution.
|
|
1444
|
+
If the server accepts background execution, ToolTask will poll for results.
|
|
1445
|
+
If the server declines (graceful degradation), ToolTask wraps the immediate result.
|
|
1446
|
+
|
|
1447
|
+
Args:
|
|
1448
|
+
name: Tool name to call
|
|
1449
|
+
arguments: Tool arguments
|
|
1450
|
+
task_id: Optional client-provided task ID (ignored, for backward compatibility)
|
|
1451
|
+
ttl: Time to keep results available in milliseconds (default 60s)
|
|
1452
|
+
|
|
1453
|
+
Returns:
|
|
1454
|
+
ToolTask: Future-like object for accessing task status and results
|
|
1455
|
+
"""
|
|
1456
|
+
# Per SEP-1686 final spec: client sends only ttl, server generates taskId
|
|
1457
|
+
# Call tool with task metadata (no taskId sent)
|
|
1458
|
+
result = await self.call_tool_mcp(
|
|
1459
|
+
name=name,
|
|
1460
|
+
arguments=arguments or {},
|
|
1461
|
+
meta={
|
|
1462
|
+
"modelcontextprotocol.io/task": {
|
|
1463
|
+
"ttl": ttl,
|
|
1464
|
+
}
|
|
1465
|
+
},
|
|
1466
|
+
)
|
|
1467
|
+
|
|
1468
|
+
# Check if server accepted background execution
|
|
1469
|
+
# If response includes task metadata with taskId, server accepted background mode
|
|
1470
|
+
# If response includes returned_immediately=True, server declined and executed sync
|
|
1471
|
+
task_meta = (result.meta or {}).get("modelcontextprotocol.io/task", {})
|
|
1472
|
+
if task_meta.get("taskId"):
|
|
1473
|
+
# Background execution accepted - extract server-generated taskId
|
|
1474
|
+
server_task_id = task_meta["taskId"]
|
|
1475
|
+
# Track this task ID for list_tasks()
|
|
1476
|
+
self._submitted_task_ids.add(server_task_id)
|
|
1477
|
+
|
|
1478
|
+
# Create task object
|
|
1479
|
+
task_obj = ToolTask(
|
|
1480
|
+
self, server_task_id, tool_name=name, immediate_result=None
|
|
1481
|
+
)
|
|
1482
|
+
|
|
1483
|
+
# Register for notification routing
|
|
1484
|
+
self._task_registry[server_task_id] = weakref.ref(task_obj) # type: ignore[assignment]
|
|
1485
|
+
|
|
1486
|
+
return task_obj
|
|
1487
|
+
else:
|
|
1488
|
+
# Server declined background execution (graceful degradation)
|
|
1489
|
+
# or returned_immediately=True - executed synchronously
|
|
1490
|
+
# Wrap the immediate result
|
|
1491
|
+
parsed_result = await self._parse_call_tool_result(name, result)
|
|
1492
|
+
# Use a synthetic task ID for the immediate result
|
|
1493
|
+
synthetic_task_id = task_id or str(uuid.uuid4())
|
|
1494
|
+
return ToolTask(
|
|
1495
|
+
self, synthetic_task_id, tool_name=name, immediate_result=parsed_result
|
|
1496
|
+
)
|
|
1497
|
+
|
|
1498
|
+
async def get_task_status(self, task_id: str) -> GetTaskResult:
|
|
1499
|
+
"""Query the status of a background task.
|
|
1500
|
+
|
|
1501
|
+
Sends a 'tasks/get' MCP protocol request over the existing transport.
|
|
1502
|
+
|
|
1503
|
+
Args:
|
|
1504
|
+
task_id: The task ID returned from call_tool_as_task
|
|
1505
|
+
|
|
1506
|
+
Returns:
|
|
1507
|
+
GetTaskResult: Status information including taskId, status, pollInterval, etc.
|
|
1508
|
+
|
|
1509
|
+
Raises:
|
|
1510
|
+
RuntimeError: If client not connected
|
|
1511
|
+
"""
|
|
1512
|
+
request = GetTaskRequest(params=GetTaskRequestParams(taskId=task_id))
|
|
1513
|
+
return await self.session.send_request(
|
|
1514
|
+
request=request, # type: ignore[arg-type]
|
|
1515
|
+
result_type=GetTaskResult, # type: ignore[arg-type]
|
|
1516
|
+
)
|
|
1517
|
+
|
|
1518
|
+
async def get_task_result(self, task_id: str) -> Any:
|
|
1519
|
+
"""Retrieve the raw result of a completed background task.
|
|
1520
|
+
|
|
1521
|
+
Sends a 'tasks/result' MCP protocol request over the existing transport.
|
|
1522
|
+
Returns the raw result - callers should parse it appropriately.
|
|
1523
|
+
|
|
1524
|
+
Args:
|
|
1525
|
+
task_id: The task ID returned from call_tool_as_task
|
|
1526
|
+
|
|
1527
|
+
Returns:
|
|
1528
|
+
Any: The raw result (could be tool, prompt, or resource result)
|
|
1529
|
+
|
|
1530
|
+
Raises:
|
|
1531
|
+
RuntimeError: If client not connected, task not found, or task failed
|
|
1532
|
+
"""
|
|
1533
|
+
request = GetTaskPayloadRequest(
|
|
1534
|
+
params=GetTaskPayloadRequestParams(taskId=task_id)
|
|
1535
|
+
)
|
|
1536
|
+
# Return raw result - Task classes handle type-specific parsing
|
|
1537
|
+
result = await self.session.send_request(
|
|
1538
|
+
request=request, # type: ignore[arg-type]
|
|
1539
|
+
result_type=GetTaskPayloadResult, # type: ignore[arg-type]
|
|
1540
|
+
)
|
|
1541
|
+
# Return as dict for compatibility with Task class parsing
|
|
1542
|
+
return result.model_dump(exclude_none=True, by_alias=True)
|
|
1543
|
+
|
|
1544
|
+
async def list_tasks(
|
|
1545
|
+
self,
|
|
1546
|
+
cursor: str | None = None,
|
|
1547
|
+
limit: int = 50,
|
|
1548
|
+
) -> dict[str, Any]:
|
|
1549
|
+
"""List background tasks.
|
|
1550
|
+
|
|
1551
|
+
Sends a 'tasks/list' MCP protocol request to the server. If the server
|
|
1552
|
+
returns an empty list (indicating client-side tracking), falls back to
|
|
1553
|
+
querying status for locally tracked task IDs.
|
|
1554
|
+
|
|
1555
|
+
Args:
|
|
1556
|
+
cursor: Optional pagination cursor
|
|
1557
|
+
limit: Maximum number of tasks to return (default 50)
|
|
1558
|
+
|
|
1559
|
+
Returns:
|
|
1560
|
+
dict: Response with structure:
|
|
1561
|
+
- tasks: List of task status dicts with taskId, status, etc.
|
|
1562
|
+
- nextCursor: Optional cursor for next page
|
|
1563
|
+
|
|
1564
|
+
Raises:
|
|
1565
|
+
RuntimeError: If client not connected
|
|
1566
|
+
"""
|
|
1567
|
+
# Send protocol request
|
|
1568
|
+
params = PaginatedRequestParams(cursor=cursor, limit=limit)
|
|
1569
|
+
request = ListTasksRequest(params=params)
|
|
1570
|
+
server_response = await self.session.send_request(
|
|
1571
|
+
request=request, # type: ignore[invalid-argument-type]
|
|
1572
|
+
result_type=mcp.types.ListTasksResult,
|
|
1573
|
+
)
|
|
1574
|
+
|
|
1575
|
+
# If server returned tasks, use those
|
|
1576
|
+
if server_response.tasks:
|
|
1577
|
+
return server_response.model_dump(by_alias=True)
|
|
1578
|
+
|
|
1579
|
+
# Server returned empty - fall back to client-side tracking
|
|
1580
|
+
tasks = []
|
|
1581
|
+
for task_id in list(self._submitted_task_ids)[:limit]:
|
|
971
1582
|
try:
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
if output_schema.get("x-fastmcp-wrap-result"):
|
|
978
|
-
output_schema = output_schema.get("properties", {}).get(
|
|
979
|
-
"result"
|
|
980
|
-
)
|
|
981
|
-
structured_content = result.structuredContent.get("result")
|
|
982
|
-
else:
|
|
983
|
-
structured_content = result.structuredContent
|
|
984
|
-
output_type = json_schema_to_type(output_schema)
|
|
985
|
-
type_adapter = get_cached_typeadapter(output_type)
|
|
986
|
-
data = type_adapter.validate_python(structured_content)
|
|
987
|
-
else:
|
|
988
|
-
data = result.structuredContent
|
|
989
|
-
except Exception as e:
|
|
990
|
-
logger.error(f"[{self.name}] Error parsing structured content: {e}")
|
|
1583
|
+
status = await self.get_task_status(task_id)
|
|
1584
|
+
tasks.append(status.model_dump(by_alias=True))
|
|
1585
|
+
except Exception:
|
|
1586
|
+
# Task may have expired or been deleted, skip it
|
|
1587
|
+
continue
|
|
991
1588
|
|
|
992
|
-
return
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1589
|
+
return {"tasks": tasks, "nextCursor": None}
|
|
1590
|
+
|
|
1591
|
+
async def cancel_task(self, task_id: str) -> mcp.types.CancelTaskResult:
|
|
1592
|
+
"""Cancel a task, transitioning it to cancelled state.
|
|
1593
|
+
|
|
1594
|
+
Sends a 'tasks/cancel' MCP protocol request. Task will halt execution
|
|
1595
|
+
and transition to cancelled state.
|
|
1596
|
+
|
|
1597
|
+
Args:
|
|
1598
|
+
task_id: The task ID to cancel
|
|
1599
|
+
|
|
1600
|
+
Returns:
|
|
1601
|
+
CancelTaskResult: The task status showing cancelled state
|
|
1602
|
+
|
|
1603
|
+
Raises:
|
|
1604
|
+
RuntimeError: If task doesn't exist
|
|
1605
|
+
"""
|
|
1606
|
+
request = CancelTaskRequest(params=CancelTaskRequestParams(taskId=task_id))
|
|
1607
|
+
return await self.session.send_request(
|
|
1608
|
+
request=request, # type: ignore[invalid-argument-type]
|
|
1609
|
+
result_type=mcp.types.CancelTaskResult,
|
|
998
1610
|
)
|
|
999
1611
|
|
|
1000
1612
|
@classmethod
|
|
@@ -1004,12 +1616,3 @@ class Client(Generic[ClientTransportT]):
|
|
|
1004
1616
|
return f"{class_name}-{secrets.token_hex(2)}"
|
|
1005
1617
|
else:
|
|
1006
1618
|
return f"{class_name}-{name}-{secrets.token_hex(2)}"
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
@dataclass
|
|
1010
|
-
class CallToolResult:
|
|
1011
|
-
content: list[mcp.types.ContentBlock]
|
|
1012
|
-
structured_content: dict[str, Any] | None
|
|
1013
|
-
meta: dict[str, Any] | None
|
|
1014
|
-
data: Any = None
|
|
1015
|
-
is_error: bool = False
|