fastmcp 2.13.2__py3-none-any.whl → 2.14.0__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 +665 -129
- fastmcp/client/elicitation.py +11 -5
- fastmcp/client/messages.py +7 -5
- fastmcp/client/roots.py +2 -1
- fastmcp/client/tasks.py +614 -0
- fastmcp/client/transports.py +37 -5
- fastmcp/contrib/component_manager/component_service.py +4 -20
- fastmcp/dependencies.py +25 -0
- fastmcp/experimental/sampling/handlers/openai.py +1 -1
- fastmcp/experimental/server/openapi/__init__.py +15 -13
- fastmcp/experimental/utilities/openapi/__init__.py +12 -38
- fastmcp/prompts/prompt.py +33 -33
- fastmcp/resources/resource.py +29 -12
- fastmcp/resources/template.py +64 -54
- 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 +66 -72
- 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 +50 -37
- fastmcp/server/server.py +731 -532
- 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 +205 -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 +80 -44
- 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.2.dist-info → fastmcp-2.14.0.dist-info}/METADATA +5 -4
- {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/RECORD +71 -59
- fastmcp/server/auth/providers/bearer.py +0 -25
- fastmcp/server/openapi.py +0 -1087
- 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.2.dist-info → fastmcp-2.14.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.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
|
|
@@ -36,6 +51,13 @@ from fastmcp.client.sampling import (
|
|
|
36
51
|
SamplingHandler,
|
|
37
52
|
create_sampling_callback,
|
|
38
53
|
)
|
|
54
|
+
from fastmcp.client.tasks import (
|
|
55
|
+
PromptTask,
|
|
56
|
+
ResourceTask,
|
|
57
|
+
TaskNotificationHandler,
|
|
58
|
+
ToolTask,
|
|
59
|
+
_task_capable_initialize,
|
|
60
|
+
)
|
|
39
61
|
from fastmcp.exceptions import ToolError
|
|
40
62
|
from fastmcp.mcp_config import MCPConfig
|
|
41
63
|
from fastmcp.server import FastMCP
|
|
@@ -77,16 +99,6 @@ logger = get_logger(__name__)
|
|
|
77
99
|
T = TypeVar("T", bound="ClientTransport")
|
|
78
100
|
|
|
79
101
|
|
|
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
102
|
@dataclass
|
|
91
103
|
class ClientSessionState:
|
|
92
104
|
"""Holds all session-related state for a Client instance.
|
|
@@ -104,6 +116,17 @@ class ClientSessionState:
|
|
|
104
116
|
initialize_result: mcp.types.InitializeResult | None = None
|
|
105
117
|
|
|
106
118
|
|
|
119
|
+
@dataclass
|
|
120
|
+
class CallToolResult:
|
|
121
|
+
"""Parsed result from a tool call."""
|
|
122
|
+
|
|
123
|
+
content: list[mcp.types.ContentBlock]
|
|
124
|
+
structured_content: dict[str, Any] | None
|
|
125
|
+
meta: dict[str, Any] | None
|
|
126
|
+
data: Any = None
|
|
127
|
+
is_error: bool = False
|
|
128
|
+
|
|
129
|
+
|
|
107
130
|
class Client(Generic[ClientTransportT]):
|
|
108
131
|
"""
|
|
109
132
|
MCP client that delegates connection management to a Transport instance.
|
|
@@ -258,7 +281,13 @@ class Client(Generic[ClientTransportT]):
|
|
|
258
281
|
# handle init handshake timeout
|
|
259
282
|
if init_timeout is None:
|
|
260
283
|
init_timeout = fastmcp.settings.client_init_timeout
|
|
261
|
-
|
|
284
|
+
if isinstance(init_timeout, datetime.timedelta):
|
|
285
|
+
init_timeout = init_timeout.total_seconds()
|
|
286
|
+
elif not init_timeout:
|
|
287
|
+
init_timeout = None
|
|
288
|
+
else:
|
|
289
|
+
init_timeout = float(init_timeout)
|
|
290
|
+
self._init_timeout = init_timeout
|
|
262
291
|
|
|
263
292
|
self.auto_initialize = auto_initialize
|
|
264
293
|
|
|
@@ -266,7 +295,7 @@ class Client(Generic[ClientTransportT]):
|
|
|
266
295
|
"sampling_callback": None,
|
|
267
296
|
"list_roots_callback": None,
|
|
268
297
|
"logging_callback": create_log_callback(log_handler),
|
|
269
|
-
"message_handler": message_handler,
|
|
298
|
+
"message_handler": message_handler or TaskNotificationHandler(self),
|
|
270
299
|
"read_timeout_seconds": timeout, # ty: ignore[invalid-argument-type]
|
|
271
300
|
"client_info": client_info,
|
|
272
301
|
}
|
|
@@ -287,6 +316,15 @@ class Client(Generic[ClientTransportT]):
|
|
|
287
316
|
# Session context management - see class docstring for detailed explanation
|
|
288
317
|
self._session_state = ClientSessionState()
|
|
289
318
|
|
|
319
|
+
# Track task IDs submitted by this client (for list_tasks support)
|
|
320
|
+
self._submitted_task_ids: set[str] = set()
|
|
321
|
+
|
|
322
|
+
# Registry for routing notifications/tasks/status to Task objects
|
|
323
|
+
|
|
324
|
+
self._task_registry: dict[
|
|
325
|
+
str, weakref.ref[ToolTask | PromptTask | ResourceTask]
|
|
326
|
+
] = {}
|
|
327
|
+
|
|
290
328
|
@property
|
|
291
329
|
def session(self) -> ClientSession:
|
|
292
330
|
"""Get the current active session. Raises RuntimeError if not connected."""
|
|
@@ -359,7 +397,7 @@ class Client(Generic[ClientTransportT]):
|
|
|
359
397
|
**self._session_kwargs
|
|
360
398
|
) as session:
|
|
361
399
|
self._session_state.session = session
|
|
362
|
-
# Initialize the session
|
|
400
|
+
# Initialize the session if auto_initialize is enabled
|
|
363
401
|
try:
|
|
364
402
|
if self.auto_initialize:
|
|
365
403
|
await self.initialize()
|
|
@@ -370,11 +408,73 @@ class Client(Generic[ClientTransportT]):
|
|
|
370
408
|
self._session_state.session = None
|
|
371
409
|
self._session_state.initialize_result = None
|
|
372
410
|
|
|
411
|
+
async def initialize(
|
|
412
|
+
self,
|
|
413
|
+
timeout: datetime.timedelta | float | int | None = None,
|
|
414
|
+
) -> mcp.types.InitializeResult:
|
|
415
|
+
"""Send an initialize request to the server.
|
|
416
|
+
|
|
417
|
+
This method performs the MCP initialization handshake with the server,
|
|
418
|
+
exchanging capabilities and server information. It is idempotent - calling
|
|
419
|
+
it multiple times returns the cached result from the first call.
|
|
420
|
+
|
|
421
|
+
The initialization happens automatically when entering the client context
|
|
422
|
+
manager unless `auto_initialize=False` was set during client construction.
|
|
423
|
+
Manual calls to this method are only needed when auto-initialization is disabled.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
timeout: Optional timeout for the initialization request (seconds or timedelta).
|
|
427
|
+
If None, uses the client's init_timeout setting.
|
|
428
|
+
|
|
429
|
+
Returns:
|
|
430
|
+
InitializeResult: The server's initialization response containing server info,
|
|
431
|
+
capabilities, protocol version, and optional instructions.
|
|
432
|
+
|
|
433
|
+
Raises:
|
|
434
|
+
RuntimeError: If the client is not connected or initialization times out.
|
|
435
|
+
|
|
436
|
+
Example:
|
|
437
|
+
```python
|
|
438
|
+
# With auto-initialization disabled
|
|
439
|
+
client = Client(server, auto_initialize=False)
|
|
440
|
+
async with client:
|
|
441
|
+
result = await client.initialize()
|
|
442
|
+
print(f"Server: {result.serverInfo.name}")
|
|
443
|
+
print(f"Instructions: {result.instructions}")
|
|
444
|
+
```
|
|
445
|
+
"""
|
|
446
|
+
|
|
447
|
+
if self.initialize_result is not None:
|
|
448
|
+
return self.initialize_result
|
|
449
|
+
|
|
450
|
+
if timeout is None:
|
|
451
|
+
timeout = self._init_timeout
|
|
452
|
+
|
|
453
|
+
# Convert timeout if needed
|
|
454
|
+
if isinstance(timeout, datetime.timedelta):
|
|
455
|
+
timeout = timeout.total_seconds()
|
|
456
|
+
elif timeout is not None:
|
|
457
|
+
timeout = float(timeout)
|
|
458
|
+
|
|
459
|
+
try:
|
|
460
|
+
with anyio.fail_after(timeout):
|
|
461
|
+
self._session_state.initialize_result = await _task_capable_initialize(
|
|
462
|
+
self.session
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
return self._session_state.initialize_result
|
|
466
|
+
except TimeoutError as e:
|
|
467
|
+
raise RuntimeError("Failed to initialize server session") from e
|
|
468
|
+
|
|
373
469
|
async def __aenter__(self):
|
|
374
470
|
return await self._connect()
|
|
375
471
|
|
|
376
472
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
377
|
-
|
|
473
|
+
# Use a timeout to prevent hanging during cleanup if the connection is in a bad
|
|
474
|
+
# state (e.g., rate-limited). The MCP SDK's transport may try to terminate the
|
|
475
|
+
# session which can hang if the server is unresponsive.
|
|
476
|
+
with anyio.move_on_after(5):
|
|
477
|
+
await self._disconnect()
|
|
378
478
|
|
|
379
479
|
async def _connect(self):
|
|
380
480
|
"""
|
|
@@ -414,7 +514,8 @@ class Client(Generic[ClientTransportT]):
|
|
|
414
514
|
raise RuntimeError(
|
|
415
515
|
"Session task completed without exception but connection failed"
|
|
416
516
|
)
|
|
417
|
-
|
|
517
|
+
# Preserve specific exception types that clients may want to handle
|
|
518
|
+
if isinstance(exception, httpx.HTTPStatusError | McpError):
|
|
418
519
|
raise exception
|
|
419
520
|
raise RuntimeError(
|
|
420
521
|
f"Client failed to connect: {exception}"
|
|
@@ -487,61 +588,34 @@ class Client(Generic[ClientTransportT]):
|
|
|
487
588
|
# Ensure ready event is set even if context manager entry fails
|
|
488
589
|
self._session_state.ready_event.set()
|
|
489
590
|
|
|
591
|
+
def _handle_task_status_notification(
|
|
592
|
+
self, notification: TaskStatusNotification
|
|
593
|
+
) -> None:
|
|
594
|
+
"""Route task status notification to appropriate Task object.
|
|
595
|
+
|
|
596
|
+
Called when notifications/tasks/status is received from server.
|
|
597
|
+
Updates Task object's cache and triggers events/callbacks.
|
|
598
|
+
"""
|
|
599
|
+
# Extract task ID from notification params
|
|
600
|
+
task_id = notification.params.taskId
|
|
601
|
+
if not task_id:
|
|
602
|
+
return
|
|
603
|
+
|
|
604
|
+
# Look up task in registry (weakref)
|
|
605
|
+
task_ref = self._task_registry.get(task_id)
|
|
606
|
+
if task_ref:
|
|
607
|
+
task = task_ref() # Dereference weakref
|
|
608
|
+
if task:
|
|
609
|
+
# Convert notification params to GetTaskResult (they share the same fields via Task)
|
|
610
|
+
status = GetTaskResult.model_validate(notification.params.model_dump())
|
|
611
|
+
task._handle_status_notification(status)
|
|
612
|
+
|
|
490
613
|
async def close(self):
|
|
491
614
|
await self._disconnect(force=True)
|
|
492
615
|
await self.transport.close()
|
|
493
616
|
|
|
494
617
|
# --- MCP Client Methods ---
|
|
495
618
|
|
|
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
619
|
async def ping(self) -> bool:
|
|
546
620
|
"""Send a ping request."""
|
|
547
621
|
result = await self.session.send_ping()
|
|
@@ -645,12 +719,13 @@ class Client(Generic[ClientTransportT]):
|
|
|
645
719
|
return result.resourceTemplates
|
|
646
720
|
|
|
647
721
|
async def read_resource_mcp(
|
|
648
|
-
self, uri: AnyUrl | str
|
|
722
|
+
self, uri: AnyUrl | str, meta: dict[str, Any] | None = None
|
|
649
723
|
) -> mcp.types.ReadResourceResult:
|
|
650
724
|
"""Send a resources/read request and return the complete MCP protocol result.
|
|
651
725
|
|
|
652
726
|
Args:
|
|
653
727
|
uri (AnyUrl | str): The URI of the resource to read. Can be a string or an AnyUrl object.
|
|
728
|
+
meta (dict[str, Any] | None, optional): Request metadata (e.g., for SEP-1686 tasks). Defaults to None.
|
|
654
729
|
|
|
655
730
|
Returns:
|
|
656
731
|
mcp.types.ReadResourceResult: The complete response object from the protocol,
|
|
@@ -663,24 +738,73 @@ class Client(Generic[ClientTransportT]):
|
|
|
663
738
|
|
|
664
739
|
if isinstance(uri, str):
|
|
665
740
|
uri = AnyUrl(uri) # Ensure AnyUrl
|
|
666
|
-
|
|
741
|
+
|
|
742
|
+
# If meta provided, use send_request for SEP-1686 task support
|
|
743
|
+
if meta:
|
|
744
|
+
task_dict = meta.get("modelcontextprotocol.io/task")
|
|
745
|
+
request = mcp.types.ReadResourceRequest(
|
|
746
|
+
params=mcp.types.ReadResourceRequestParams(
|
|
747
|
+
uri=uri,
|
|
748
|
+
task=mcp.types.TaskMetadata(**task_dict)
|
|
749
|
+
if task_dict
|
|
750
|
+
else None, # SEP-1686: task as direct param (spec-compliant)
|
|
751
|
+
)
|
|
752
|
+
)
|
|
753
|
+
result = await self.session.send_request(
|
|
754
|
+
request=request, # type: ignore[arg-type]
|
|
755
|
+
result_type=mcp.types.ReadResourceResult,
|
|
756
|
+
)
|
|
757
|
+
else:
|
|
758
|
+
result = await self.session.read_resource(uri)
|
|
667
759
|
return result
|
|
668
760
|
|
|
761
|
+
@overload
|
|
762
|
+
async def read_resource(
|
|
763
|
+
self,
|
|
764
|
+
uri: AnyUrl | str,
|
|
765
|
+
*,
|
|
766
|
+
task: Literal[False] = False,
|
|
767
|
+
) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]: ...
|
|
768
|
+
|
|
769
|
+
@overload
|
|
669
770
|
async def read_resource(
|
|
670
|
-
self,
|
|
671
|
-
|
|
771
|
+
self,
|
|
772
|
+
uri: AnyUrl | str,
|
|
773
|
+
*,
|
|
774
|
+
task: Literal[True],
|
|
775
|
+
task_id: str | None = None,
|
|
776
|
+
ttl: int = 60000,
|
|
777
|
+
) -> ResourceTask: ...
|
|
778
|
+
|
|
779
|
+
async def read_resource(
|
|
780
|
+
self,
|
|
781
|
+
uri: AnyUrl | str,
|
|
782
|
+
*,
|
|
783
|
+
task: bool = False,
|
|
784
|
+
task_id: str | None = None,
|
|
785
|
+
ttl: int = 60000,
|
|
786
|
+
) -> (
|
|
787
|
+
list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]
|
|
788
|
+
| ResourceTask
|
|
789
|
+
):
|
|
672
790
|
"""Read the contents of a resource or resolved template.
|
|
673
791
|
|
|
674
792
|
Args:
|
|
675
793
|
uri (AnyUrl | str): The URI of the resource to read. Can be a string or an AnyUrl object.
|
|
794
|
+
task (bool): If True, execute as background task (SEP-1686). Defaults to False.
|
|
795
|
+
task_id (str | None): Optional client-provided task ID (auto-generated if not provided).
|
|
796
|
+
ttl (int): Time to keep results available in milliseconds (default 60s).
|
|
676
797
|
|
|
677
798
|
Returns:
|
|
678
|
-
list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]
|
|
679
|
-
objects,
|
|
799
|
+
list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents] | ResourceTask:
|
|
800
|
+
A list of content objects if task=False, or a ResourceTask object if task=True.
|
|
680
801
|
|
|
681
802
|
Raises:
|
|
682
803
|
RuntimeError: If called while the client is not connected.
|
|
683
804
|
"""
|
|
805
|
+
if task:
|
|
806
|
+
return await self._read_resource_as_task(uri, task_id, ttl)
|
|
807
|
+
|
|
684
808
|
if isinstance(uri, str):
|
|
685
809
|
try:
|
|
686
810
|
uri = AnyUrl(uri) # Ensure AnyUrl
|
|
@@ -691,6 +815,62 @@ class Client(Generic[ClientTransportT]):
|
|
|
691
815
|
result = await self.read_resource_mcp(uri)
|
|
692
816
|
return result.contents
|
|
693
817
|
|
|
818
|
+
async def _read_resource_as_task(
|
|
819
|
+
self,
|
|
820
|
+
uri: AnyUrl | str,
|
|
821
|
+
task_id: str | None = None,
|
|
822
|
+
ttl: int = 60000,
|
|
823
|
+
) -> ResourceTask:
|
|
824
|
+
"""Read a resource for background execution (SEP-1686).
|
|
825
|
+
|
|
826
|
+
Returns a ResourceTask object that handles both background and immediate execution.
|
|
827
|
+
|
|
828
|
+
Args:
|
|
829
|
+
uri: Resource URI to read
|
|
830
|
+
task_id: Optional client-provided task ID (ignored, for backward compatibility)
|
|
831
|
+
ttl: Time to keep results available in milliseconds (default 60s)
|
|
832
|
+
|
|
833
|
+
Returns:
|
|
834
|
+
ResourceTask: Future-like object for accessing task status and results
|
|
835
|
+
"""
|
|
836
|
+
# Per SEP-1686 final spec: client sends only ttl, server generates taskId
|
|
837
|
+
# Read resource with task metadata (no taskId sent)
|
|
838
|
+
result = await self.read_resource_mcp(
|
|
839
|
+
uri=uri,
|
|
840
|
+
meta={
|
|
841
|
+
"modelcontextprotocol.io/task": {
|
|
842
|
+
"ttl": ttl,
|
|
843
|
+
}
|
|
844
|
+
},
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
# Check if server accepted background execution
|
|
848
|
+
# If response includes task metadata with taskId, server accepted background mode
|
|
849
|
+
# If response includes returned_immediately=True, server declined and executed sync
|
|
850
|
+
task_meta = (result.meta or {}).get("modelcontextprotocol.io/task", {})
|
|
851
|
+
if task_meta.get("taskId"):
|
|
852
|
+
# Background execution accepted - extract server-generated taskId
|
|
853
|
+
server_task_id = task_meta["taskId"]
|
|
854
|
+
# Track this task ID for list_tasks()
|
|
855
|
+
self._submitted_task_ids.add(server_task_id)
|
|
856
|
+
|
|
857
|
+
# Create task object
|
|
858
|
+
task_obj = ResourceTask(
|
|
859
|
+
self, server_task_id, uri=str(uri), immediate_result=None
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
# Register for notification routing
|
|
863
|
+
self._task_registry[server_task_id] = weakref.ref(task_obj) # type: ignore[assignment]
|
|
864
|
+
|
|
865
|
+
return task_obj
|
|
866
|
+
else:
|
|
867
|
+
# Server declined background execution (graceful degradation)
|
|
868
|
+
# Use a synthetic task ID for the immediate result
|
|
869
|
+
synthetic_task_id = task_id or str(uuid.uuid4())
|
|
870
|
+
return ResourceTask(
|
|
871
|
+
self, synthetic_task_id, uri=str(uri), immediate_result=result.contents
|
|
872
|
+
)
|
|
873
|
+
|
|
694
874
|
# async def subscribe_resource(self, uri: AnyUrl | str) -> None:
|
|
695
875
|
# """Send a resources/subscribe request."""
|
|
696
876
|
# if isinstance(uri, str):
|
|
@@ -734,13 +914,17 @@ class Client(Generic[ClientTransportT]):
|
|
|
734
914
|
|
|
735
915
|
# --- Prompt ---
|
|
736
916
|
async def get_prompt_mcp(
|
|
737
|
-
self,
|
|
917
|
+
self,
|
|
918
|
+
name: str,
|
|
919
|
+
arguments: dict[str, Any] | None = None,
|
|
920
|
+
meta: dict[str, Any] | None = None,
|
|
738
921
|
) -> mcp.types.GetPromptResult:
|
|
739
922
|
"""Send a prompts/get request and return the complete MCP protocol result.
|
|
740
923
|
|
|
741
924
|
Args:
|
|
742
925
|
name (str): The name of the prompt to retrieve.
|
|
743
926
|
arguments (dict[str, Any] | None, optional): Arguments to pass to the prompt. Defaults to None.
|
|
927
|
+
meta (dict[str, Any] | None, optional): Request metadata (e.g., for SEP-1686 tasks). Defaults to None.
|
|
744
928
|
|
|
745
929
|
Returns:
|
|
746
930
|
mcp.types.GetPromptResult: The complete response object from the protocol,
|
|
@@ -764,30 +948,138 @@ class Client(Generic[ClientTransportT]):
|
|
|
764
948
|
"utf-8"
|
|
765
949
|
)
|
|
766
950
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
951
|
+
# If meta provided, use send_request for SEP-1686 task support
|
|
952
|
+
if meta:
|
|
953
|
+
task_dict = meta.get("modelcontextprotocol.io/task")
|
|
954
|
+
request = mcp.types.GetPromptRequest(
|
|
955
|
+
params=mcp.types.GetPromptRequestParams(
|
|
956
|
+
name=name,
|
|
957
|
+
arguments=serialized_arguments,
|
|
958
|
+
task=mcp.types.TaskMetadata(**task_dict)
|
|
959
|
+
if task_dict
|
|
960
|
+
else None, # SEP-1686: task as direct param (spec-compliant)
|
|
961
|
+
)
|
|
962
|
+
)
|
|
963
|
+
result = await self.session.send_request(
|
|
964
|
+
request=request, # type: ignore[arg-type]
|
|
965
|
+
result_type=mcp.types.GetPromptResult,
|
|
966
|
+
)
|
|
967
|
+
else:
|
|
968
|
+
result = await self.session.get_prompt(
|
|
969
|
+
name=name, arguments=serialized_arguments
|
|
970
|
+
)
|
|
770
971
|
return result
|
|
771
972
|
|
|
973
|
+
@overload
|
|
772
974
|
async def get_prompt(
|
|
773
|
-
self,
|
|
774
|
-
|
|
975
|
+
self,
|
|
976
|
+
name: str,
|
|
977
|
+
arguments: dict[str, Any] | None = None,
|
|
978
|
+
*,
|
|
979
|
+
task: Literal[False] = False,
|
|
980
|
+
) -> mcp.types.GetPromptResult: ...
|
|
981
|
+
|
|
982
|
+
@overload
|
|
983
|
+
async def get_prompt(
|
|
984
|
+
self,
|
|
985
|
+
name: str,
|
|
986
|
+
arguments: dict[str, Any] | None = None,
|
|
987
|
+
*,
|
|
988
|
+
task: Literal[True],
|
|
989
|
+
task_id: str | None = None,
|
|
990
|
+
ttl: int = 60000,
|
|
991
|
+
) -> PromptTask: ...
|
|
992
|
+
|
|
993
|
+
async def get_prompt(
|
|
994
|
+
self,
|
|
995
|
+
name: str,
|
|
996
|
+
arguments: dict[str, Any] | None = None,
|
|
997
|
+
*,
|
|
998
|
+
task: bool = False,
|
|
999
|
+
task_id: str | None = None,
|
|
1000
|
+
ttl: int = 60000,
|
|
1001
|
+
) -> mcp.types.GetPromptResult | PromptTask:
|
|
775
1002
|
"""Retrieve a rendered prompt message list from the server.
|
|
776
1003
|
|
|
777
1004
|
Args:
|
|
778
1005
|
name (str): The name of the prompt to retrieve.
|
|
779
1006
|
arguments (dict[str, Any] | None, optional): Arguments to pass to the prompt. Defaults to None.
|
|
1007
|
+
task (bool): If True, execute as background task (SEP-1686). Defaults to False.
|
|
1008
|
+
task_id (str | None): Optional client-provided task ID (auto-generated if not provided).
|
|
1009
|
+
ttl (int): Time to keep results available in milliseconds (default 60s).
|
|
780
1010
|
|
|
781
1011
|
Returns:
|
|
782
|
-
mcp.types.GetPromptResult: The complete response object
|
|
783
|
-
|
|
1012
|
+
mcp.types.GetPromptResult | PromptTask: The complete response object if task=False,
|
|
1013
|
+
or a PromptTask object if task=True.
|
|
784
1014
|
|
|
785
1015
|
Raises:
|
|
786
1016
|
RuntimeError: If called while the client is not connected.
|
|
787
1017
|
"""
|
|
1018
|
+
if task:
|
|
1019
|
+
return await self._get_prompt_as_task(name, arguments, task_id, ttl)
|
|
1020
|
+
|
|
788
1021
|
result = await self.get_prompt_mcp(name=name, arguments=arguments)
|
|
789
1022
|
return result
|
|
790
1023
|
|
|
1024
|
+
async def _get_prompt_as_task(
|
|
1025
|
+
self,
|
|
1026
|
+
name: str,
|
|
1027
|
+
arguments: dict[str, Any] | None = None,
|
|
1028
|
+
task_id: str | None = None,
|
|
1029
|
+
ttl: int = 60000,
|
|
1030
|
+
) -> PromptTask:
|
|
1031
|
+
"""Get a prompt for background execution (SEP-1686).
|
|
1032
|
+
|
|
1033
|
+
Returns a PromptTask object that handles both background and immediate execution.
|
|
1034
|
+
|
|
1035
|
+
Args:
|
|
1036
|
+
name: Prompt name to get
|
|
1037
|
+
arguments: Prompt arguments
|
|
1038
|
+
task_id: Optional client-provided task ID (ignored, for backward compatibility)
|
|
1039
|
+
ttl: Time to keep results available in milliseconds (default 60s)
|
|
1040
|
+
|
|
1041
|
+
Returns:
|
|
1042
|
+
PromptTask: Future-like object for accessing task status and results
|
|
1043
|
+
"""
|
|
1044
|
+
# Per SEP-1686 final spec: client sends only ttl, server generates taskId
|
|
1045
|
+
# Call prompt with task metadata (no taskId sent)
|
|
1046
|
+
result = await self.get_prompt_mcp(
|
|
1047
|
+
name=name,
|
|
1048
|
+
arguments=arguments or {},
|
|
1049
|
+
meta={
|
|
1050
|
+
"modelcontextprotocol.io/task": {
|
|
1051
|
+
"ttl": ttl,
|
|
1052
|
+
}
|
|
1053
|
+
},
|
|
1054
|
+
)
|
|
1055
|
+
|
|
1056
|
+
# Check if server accepted background execution
|
|
1057
|
+
# If response includes task metadata with taskId, server accepted background mode
|
|
1058
|
+
# If response includes returned_immediately=True, server declined and executed sync
|
|
1059
|
+
task_meta = (result.meta or {}).get("modelcontextprotocol.io/task", {})
|
|
1060
|
+
if task_meta.get("taskId"):
|
|
1061
|
+
# Background execution accepted - extract server-generated taskId
|
|
1062
|
+
server_task_id = task_meta["taskId"]
|
|
1063
|
+
# Track this task ID for list_tasks()
|
|
1064
|
+
self._submitted_task_ids.add(server_task_id)
|
|
1065
|
+
|
|
1066
|
+
# Create task object
|
|
1067
|
+
task_obj = PromptTask(
|
|
1068
|
+
self, server_task_id, prompt_name=name, immediate_result=None
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
# Register for notification routing
|
|
1072
|
+
self._task_registry[server_task_id] = weakref.ref(task_obj) # type: ignore[assignment]
|
|
1073
|
+
|
|
1074
|
+
return task_obj
|
|
1075
|
+
else:
|
|
1076
|
+
# Server declined background execution (graceful degradation)
|
|
1077
|
+
# Use a synthetic task ID for the immediate result
|
|
1078
|
+
synthetic_task_id = task_id or str(uuid.uuid4())
|
|
1079
|
+
return PromptTask(
|
|
1080
|
+
self, synthetic_task_id, prompt_name=name, immediate_result=result
|
|
1081
|
+
)
|
|
1082
|
+
|
|
791
1083
|
# --- Completion ---
|
|
792
1084
|
|
|
793
1085
|
async def complete_mcp(
|
|
@@ -910,24 +1202,123 @@ class Client(Generic[ClientTransportT]):
|
|
|
910
1202
|
if isinstance(timeout, int | float):
|
|
911
1203
|
timeout = datetime.timedelta(seconds=float(timeout))
|
|
912
1204
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
1205
|
+
# For task submissions, use send_request to bypass SDK validation
|
|
1206
|
+
# Task acknowledgments don't have structured content, which would fail validation
|
|
1207
|
+
if meta and "modelcontextprotocol.io/task" in meta:
|
|
1208
|
+
task_dict = meta.get("modelcontextprotocol.io/task")
|
|
1209
|
+
request = mcp.types.CallToolRequest(
|
|
1210
|
+
params=mcp.types.CallToolRequestParams(
|
|
1211
|
+
name=name,
|
|
1212
|
+
arguments=arguments,
|
|
1213
|
+
task=mcp.types.TaskMetadata(**task_dict)
|
|
1214
|
+
if task_dict
|
|
1215
|
+
else None, # SEP-1686: task as direct param (spec-compliant)
|
|
1216
|
+
)
|
|
1217
|
+
)
|
|
1218
|
+
result = await self.session.send_request(
|
|
1219
|
+
request=request, # type: ignore[arg-type]
|
|
1220
|
+
result_type=mcp.types.CallToolResult,
|
|
1221
|
+
request_read_timeout_seconds=timeout, # type: ignore[arg-type]
|
|
1222
|
+
progress_callback=progress_handler or self._progress_handler,
|
|
1223
|
+
)
|
|
1224
|
+
else:
|
|
1225
|
+
result = await self.session.call_tool(
|
|
1226
|
+
name=name,
|
|
1227
|
+
arguments=arguments,
|
|
1228
|
+
read_timeout_seconds=timeout, # ty: ignore[invalid-argument-type]
|
|
1229
|
+
progress_callback=progress_handler or self._progress_handler,
|
|
1230
|
+
meta=meta,
|
|
1231
|
+
)
|
|
920
1232
|
return result
|
|
921
1233
|
|
|
1234
|
+
async def _parse_call_tool_result(
|
|
1235
|
+
self, name: str, result: mcp.types.CallToolResult, raise_on_error: bool = False
|
|
1236
|
+
) -> CallToolResult:
|
|
1237
|
+
"""Parse an mcp.types.CallToolResult into our CallToolResult dataclass.
|
|
1238
|
+
|
|
1239
|
+
Args:
|
|
1240
|
+
name: Tool name (for schema lookup)
|
|
1241
|
+
result: Raw MCP protocol result
|
|
1242
|
+
raise_on_error: Whether to raise ToolError on errors
|
|
1243
|
+
|
|
1244
|
+
Returns:
|
|
1245
|
+
CallToolResult: Parsed result with structured data
|
|
1246
|
+
"""
|
|
1247
|
+
data = None
|
|
1248
|
+
if result.isError and raise_on_error:
|
|
1249
|
+
msg = cast(mcp.types.TextContent, result.content[0]).text
|
|
1250
|
+
raise ToolError(msg)
|
|
1251
|
+
elif result.structuredContent:
|
|
1252
|
+
try:
|
|
1253
|
+
if name not in self.session._tool_output_schemas:
|
|
1254
|
+
await self.session.list_tools()
|
|
1255
|
+
if name in self.session._tool_output_schemas:
|
|
1256
|
+
output_schema = self.session._tool_output_schemas.get(name)
|
|
1257
|
+
if output_schema:
|
|
1258
|
+
if output_schema.get("x-fastmcp-wrap-result"):
|
|
1259
|
+
output_schema = output_schema.get("properties", {}).get(
|
|
1260
|
+
"result"
|
|
1261
|
+
)
|
|
1262
|
+
structured_content = result.structuredContent.get("result")
|
|
1263
|
+
else:
|
|
1264
|
+
structured_content = result.structuredContent
|
|
1265
|
+
output_type = json_schema_to_type(output_schema)
|
|
1266
|
+
type_adapter = get_cached_typeadapter(output_type)
|
|
1267
|
+
data = type_adapter.validate_python(structured_content)
|
|
1268
|
+
else:
|
|
1269
|
+
data = result.structuredContent
|
|
1270
|
+
except Exception as e:
|
|
1271
|
+
logger.error(f"[{self.name}] Error parsing structured content: {e}")
|
|
1272
|
+
|
|
1273
|
+
return CallToolResult(
|
|
1274
|
+
content=result.content,
|
|
1275
|
+
structured_content=result.structuredContent,
|
|
1276
|
+
meta=result.meta,
|
|
1277
|
+
data=data,
|
|
1278
|
+
is_error=result.isError,
|
|
1279
|
+
)
|
|
1280
|
+
|
|
1281
|
+
@overload
|
|
922
1282
|
async def call_tool(
|
|
923
1283
|
self,
|
|
924
1284
|
name: str,
|
|
925
1285
|
arguments: dict[str, Any] | None = None,
|
|
1286
|
+
*,
|
|
926
1287
|
timeout: datetime.timedelta | float | int | None = None,
|
|
927
1288
|
progress_handler: ProgressHandler | None = None,
|
|
928
1289
|
raise_on_error: bool = True,
|
|
929
1290
|
meta: dict[str, Any] | None = None,
|
|
930
|
-
|
|
1291
|
+
task: Literal[False] = False,
|
|
1292
|
+
) -> CallToolResult: ...
|
|
1293
|
+
|
|
1294
|
+
@overload
|
|
1295
|
+
async def call_tool(
|
|
1296
|
+
self,
|
|
1297
|
+
name: str,
|
|
1298
|
+
arguments: dict[str, Any] | None = None,
|
|
1299
|
+
*,
|
|
1300
|
+
timeout: datetime.timedelta | float | int | None = None,
|
|
1301
|
+
progress_handler: ProgressHandler | None = None,
|
|
1302
|
+
raise_on_error: bool = True,
|
|
1303
|
+
meta: dict[str, Any] | None = None,
|
|
1304
|
+
task: Literal[True],
|
|
1305
|
+
task_id: str | None = None,
|
|
1306
|
+
ttl: int = 60000,
|
|
1307
|
+
) -> ToolTask: ...
|
|
1308
|
+
|
|
1309
|
+
async def call_tool(
|
|
1310
|
+
self,
|
|
1311
|
+
name: str,
|
|
1312
|
+
arguments: dict[str, Any] | None = None,
|
|
1313
|
+
*,
|
|
1314
|
+
timeout: datetime.timedelta | float | int | None = None,
|
|
1315
|
+
progress_handler: ProgressHandler | None = None,
|
|
1316
|
+
raise_on_error: bool = True,
|
|
1317
|
+
meta: dict[str, Any] | None = None,
|
|
1318
|
+
task: bool = False,
|
|
1319
|
+
task_id: str | None = None,
|
|
1320
|
+
ttl: int = 60000,
|
|
1321
|
+
) -> CallToolResult | ToolTask:
|
|
931
1322
|
"""Call a tool on the server.
|
|
932
1323
|
|
|
933
1324
|
Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error.
|
|
@@ -937,15 +1328,18 @@ class Client(Generic[ClientTransportT]):
|
|
|
937
1328
|
arguments (dict[str, Any] | None, optional): Arguments to pass to the tool. Defaults to None.
|
|
938
1329
|
timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
|
|
939
1330
|
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
|
|
1331
|
+
raise_on_error (bool, optional): Whether to raise an exception if the tool call results in an error. Defaults to True.
|
|
941
1332
|
meta (dict[str, Any] | None, optional): Additional metadata to include with the request.
|
|
942
1333
|
This is useful for passing contextual information (like user IDs, trace IDs, or preferences)
|
|
943
1334
|
that shouldn't be tool arguments but may influence server-side processing. The server
|
|
944
1335
|
can access this via `context.request_context.meta`. Defaults to None.
|
|
1336
|
+
task (bool): If True, execute as background task (SEP-1686). Defaults to False.
|
|
1337
|
+
task_id (str | None): Optional client-provided task ID (auto-generated if not provided).
|
|
1338
|
+
ttl (int): Time to keep results available in milliseconds (default 60s).
|
|
945
1339
|
|
|
946
1340
|
Returns:
|
|
947
|
-
CallToolResult:
|
|
948
|
-
|
|
1341
|
+
CallToolResult | ToolTask: The content returned by the tool if task=False,
|
|
1342
|
+
or a ToolTask object if task=True. If the tool returns structured
|
|
949
1343
|
outputs, they are returned as a dataclass (if an output schema
|
|
950
1344
|
is available) or a dictionary; otherwise, a list of content
|
|
951
1345
|
blocks is returned. Note: to receive both structured and
|
|
@@ -956,6 +1350,9 @@ class Client(Generic[ClientTransportT]):
|
|
|
956
1350
|
ToolError: If the tool call results in an error.
|
|
957
1351
|
RuntimeError: If called while the client is not connected.
|
|
958
1352
|
"""
|
|
1353
|
+
if task:
|
|
1354
|
+
return await self._call_tool_as_task(name, arguments, task_id, ttl)
|
|
1355
|
+
|
|
959
1356
|
result = await self.call_tool_mcp(
|
|
960
1357
|
name=name,
|
|
961
1358
|
arguments=arguments or {},
|
|
@@ -963,38 +1360,186 @@ class Client(Generic[ClientTransportT]):
|
|
|
963
1360
|
progress_handler=progress_handler,
|
|
964
1361
|
meta=meta,
|
|
965
1362
|
)
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1363
|
+
return await self._parse_call_tool_result(
|
|
1364
|
+
name, result, raise_on_error=raise_on_error
|
|
1365
|
+
)
|
|
1366
|
+
|
|
1367
|
+
async def _call_tool_as_task(
|
|
1368
|
+
self,
|
|
1369
|
+
name: str,
|
|
1370
|
+
arguments: dict[str, Any] | None = None,
|
|
1371
|
+
task_id: str | None = None,
|
|
1372
|
+
ttl: int = 60000,
|
|
1373
|
+
) -> ToolTask:
|
|
1374
|
+
"""Call a tool for background execution (SEP-1686).
|
|
1375
|
+
|
|
1376
|
+
Returns a ToolTask object that handles both background and immediate execution.
|
|
1377
|
+
If the server accepts background execution, ToolTask will poll for results.
|
|
1378
|
+
If the server declines (graceful degradation), ToolTask wraps the immediate result.
|
|
1379
|
+
|
|
1380
|
+
Args:
|
|
1381
|
+
name: Tool name to call
|
|
1382
|
+
arguments: Tool arguments
|
|
1383
|
+
task_id: Optional client-provided task ID (ignored, for backward compatibility)
|
|
1384
|
+
ttl: Time to keep results available in milliseconds (default 60s)
|
|
1385
|
+
|
|
1386
|
+
Returns:
|
|
1387
|
+
ToolTask: Future-like object for accessing task status and results
|
|
1388
|
+
"""
|
|
1389
|
+
# Per SEP-1686 final spec: client sends only ttl, server generates taskId
|
|
1390
|
+
# Call tool with task metadata (no taskId sent)
|
|
1391
|
+
result = await self.call_tool_mcp(
|
|
1392
|
+
name=name,
|
|
1393
|
+
arguments=arguments or {},
|
|
1394
|
+
meta={
|
|
1395
|
+
"modelcontextprotocol.io/task": {
|
|
1396
|
+
"ttl": ttl,
|
|
1397
|
+
}
|
|
1398
|
+
},
|
|
1399
|
+
)
|
|
1400
|
+
|
|
1401
|
+
# Check if server accepted background execution
|
|
1402
|
+
# If response includes task metadata with taskId, server accepted background mode
|
|
1403
|
+
# If response includes returned_immediately=True, server declined and executed sync
|
|
1404
|
+
task_meta = (result.meta or {}).get("modelcontextprotocol.io/task", {})
|
|
1405
|
+
if task_meta.get("taskId"):
|
|
1406
|
+
# Background execution accepted - extract server-generated taskId
|
|
1407
|
+
server_task_id = task_meta["taskId"]
|
|
1408
|
+
# Track this task ID for list_tasks()
|
|
1409
|
+
self._submitted_task_ids.add(server_task_id)
|
|
1410
|
+
|
|
1411
|
+
# Create task object
|
|
1412
|
+
task_obj = ToolTask(
|
|
1413
|
+
self, server_task_id, tool_name=name, immediate_result=None
|
|
1414
|
+
)
|
|
1415
|
+
|
|
1416
|
+
# Register for notification routing
|
|
1417
|
+
self._task_registry[server_task_id] = weakref.ref(task_obj) # type: ignore[assignment]
|
|
1418
|
+
|
|
1419
|
+
return task_obj
|
|
1420
|
+
else:
|
|
1421
|
+
# Server declined background execution (graceful degradation)
|
|
1422
|
+
# or returned_immediately=True - executed synchronously
|
|
1423
|
+
# Wrap the immediate result
|
|
1424
|
+
parsed_result = await self._parse_call_tool_result(name, result)
|
|
1425
|
+
# Use a synthetic task ID for the immediate result
|
|
1426
|
+
synthetic_task_id = task_id or str(uuid.uuid4())
|
|
1427
|
+
return ToolTask(
|
|
1428
|
+
self, synthetic_task_id, tool_name=name, immediate_result=parsed_result
|
|
1429
|
+
)
|
|
1430
|
+
|
|
1431
|
+
async def get_task_status(self, task_id: str) -> GetTaskResult:
|
|
1432
|
+
"""Query the status of a background task.
|
|
1433
|
+
|
|
1434
|
+
Sends a 'tasks/get' MCP protocol request over the existing transport.
|
|
1435
|
+
|
|
1436
|
+
Args:
|
|
1437
|
+
task_id: The task ID returned from call_tool_as_task
|
|
1438
|
+
|
|
1439
|
+
Returns:
|
|
1440
|
+
GetTaskResult: Status information including taskId, status, pollInterval, etc.
|
|
1441
|
+
|
|
1442
|
+
Raises:
|
|
1443
|
+
RuntimeError: If client not connected
|
|
1444
|
+
"""
|
|
1445
|
+
request = GetTaskRequest(params=GetTaskRequestParams(taskId=task_id))
|
|
1446
|
+
return await self.session.send_request(
|
|
1447
|
+
request=request, # type: ignore[arg-type]
|
|
1448
|
+
result_type=GetTaskResult, # type: ignore[arg-type]
|
|
1449
|
+
)
|
|
1450
|
+
|
|
1451
|
+
async def get_task_result(self, task_id: str) -> Any:
|
|
1452
|
+
"""Retrieve the raw result of a completed background task.
|
|
1453
|
+
|
|
1454
|
+
Sends a 'tasks/result' MCP protocol request over the existing transport.
|
|
1455
|
+
Returns the raw result - callers should parse it appropriately.
|
|
1456
|
+
|
|
1457
|
+
Args:
|
|
1458
|
+
task_id: The task ID returned from call_tool_as_task
|
|
1459
|
+
|
|
1460
|
+
Returns:
|
|
1461
|
+
Any: The raw result (could be tool, prompt, or resource result)
|
|
1462
|
+
|
|
1463
|
+
Raises:
|
|
1464
|
+
RuntimeError: If client not connected, task not found, or task failed
|
|
1465
|
+
"""
|
|
1466
|
+
request = GetTaskPayloadRequest(
|
|
1467
|
+
params=GetTaskPayloadRequestParams(taskId=task_id)
|
|
1468
|
+
)
|
|
1469
|
+
# Return raw result - Task classes handle type-specific parsing
|
|
1470
|
+
result = await self.session.send_request(
|
|
1471
|
+
request=request, # type: ignore[arg-type]
|
|
1472
|
+
result_type=GetTaskPayloadResult, # type: ignore[arg-type]
|
|
1473
|
+
)
|
|
1474
|
+
# Return as dict for compatibility with Task class parsing
|
|
1475
|
+
return result.model_dump(exclude_none=True, by_alias=True)
|
|
1476
|
+
|
|
1477
|
+
async def list_tasks(
|
|
1478
|
+
self,
|
|
1479
|
+
cursor: str | None = None,
|
|
1480
|
+
limit: int = 50,
|
|
1481
|
+
) -> dict[str, Any]:
|
|
1482
|
+
"""List background tasks.
|
|
1483
|
+
|
|
1484
|
+
Sends a 'tasks/list' MCP protocol request to the server. If the server
|
|
1485
|
+
returns an empty list (indicating client-side tracking), falls back to
|
|
1486
|
+
querying status for locally tracked task IDs.
|
|
1487
|
+
|
|
1488
|
+
Args:
|
|
1489
|
+
cursor: Optional pagination cursor
|
|
1490
|
+
limit: Maximum number of tasks to return (default 50)
|
|
1491
|
+
|
|
1492
|
+
Returns:
|
|
1493
|
+
dict: Response with structure:
|
|
1494
|
+
- tasks: List of task status dicts with taskId, status, etc.
|
|
1495
|
+
- nextCursor: Optional cursor for next page
|
|
1496
|
+
|
|
1497
|
+
Raises:
|
|
1498
|
+
RuntimeError: If client not connected
|
|
1499
|
+
"""
|
|
1500
|
+
# Send protocol request
|
|
1501
|
+
params = PaginatedRequestParams(cursor=cursor, limit=limit)
|
|
1502
|
+
request = ListTasksRequest(params=params)
|
|
1503
|
+
server_response = await self.session.send_request(
|
|
1504
|
+
request=request, # type: ignore[invalid-argument-type]
|
|
1505
|
+
result_type=mcp.types.ListTasksResult,
|
|
1506
|
+
)
|
|
1507
|
+
|
|
1508
|
+
# If server returned tasks, use those
|
|
1509
|
+
if server_response.tasks:
|
|
1510
|
+
return server_response.model_dump(by_alias=True)
|
|
1511
|
+
|
|
1512
|
+
# Server returned empty - fall back to client-side tracking
|
|
1513
|
+
tasks = []
|
|
1514
|
+
for task_id in list(self._submitted_task_ids)[:limit]:
|
|
971
1515
|
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}")
|
|
1516
|
+
status = await self.get_task_status(task_id)
|
|
1517
|
+
tasks.append(status.model_dump(by_alias=True))
|
|
1518
|
+
except Exception:
|
|
1519
|
+
# Task may have expired or been deleted, skip it
|
|
1520
|
+
continue
|
|
991
1521
|
|
|
992
|
-
return
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1522
|
+
return {"tasks": tasks, "nextCursor": None}
|
|
1523
|
+
|
|
1524
|
+
async def cancel_task(self, task_id: str) -> mcp.types.CancelTaskResult:
|
|
1525
|
+
"""Cancel a task, transitioning it to cancelled state.
|
|
1526
|
+
|
|
1527
|
+
Sends a 'tasks/cancel' MCP protocol request. Task will halt execution
|
|
1528
|
+
and transition to cancelled state.
|
|
1529
|
+
|
|
1530
|
+
Args:
|
|
1531
|
+
task_id: The task ID to cancel
|
|
1532
|
+
|
|
1533
|
+
Returns:
|
|
1534
|
+
CancelTaskResult: The task status showing cancelled state
|
|
1535
|
+
|
|
1536
|
+
Raises:
|
|
1537
|
+
RuntimeError: If task doesn't exist
|
|
1538
|
+
"""
|
|
1539
|
+
request = CancelTaskRequest(params=CancelTaskRequestParams(taskId=task_id))
|
|
1540
|
+
return await self.session.send_request(
|
|
1541
|
+
request=request, # type: ignore[invalid-argument-type]
|
|
1542
|
+
result_type=mcp.types.CancelTaskResult,
|
|
998
1543
|
)
|
|
999
1544
|
|
|
1000
1545
|
@classmethod
|
|
@@ -1004,12 +1549,3 @@ class Client(Generic[ClientTransportT]):
|
|
|
1004
1549
|
return f"{class_name}-{secrets.token_hex(2)}"
|
|
1005
1550
|
else:
|
|
1006
1551
|
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
|