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.
Files changed (74) hide show
  1. fastmcp/__init__.py +0 -21
  2. fastmcp/cli/__init__.py +0 -3
  3. fastmcp/cli/__main__.py +5 -0
  4. fastmcp/cli/cli.py +8 -22
  5. fastmcp/cli/install/shared.py +0 -15
  6. fastmcp/cli/tasks.py +110 -0
  7. fastmcp/client/auth/oauth.py +9 -9
  8. fastmcp/client/client.py +665 -129
  9. fastmcp/client/elicitation.py +11 -5
  10. fastmcp/client/messages.py +7 -5
  11. fastmcp/client/roots.py +2 -1
  12. fastmcp/client/tasks.py +614 -0
  13. fastmcp/client/transports.py +37 -5
  14. fastmcp/contrib/component_manager/component_service.py +4 -20
  15. fastmcp/dependencies.py +25 -0
  16. fastmcp/experimental/sampling/handlers/openai.py +1 -1
  17. fastmcp/experimental/server/openapi/__init__.py +15 -13
  18. fastmcp/experimental/utilities/openapi/__init__.py +12 -38
  19. fastmcp/prompts/prompt.py +33 -33
  20. fastmcp/resources/resource.py +29 -12
  21. fastmcp/resources/template.py +64 -54
  22. fastmcp/server/auth/__init__.py +0 -9
  23. fastmcp/server/auth/auth.py +127 -3
  24. fastmcp/server/auth/oauth_proxy.py +47 -97
  25. fastmcp/server/auth/oidc_proxy.py +7 -0
  26. fastmcp/server/auth/providers/in_memory.py +2 -2
  27. fastmcp/server/auth/providers/oci.py +2 -2
  28. fastmcp/server/context.py +66 -72
  29. fastmcp/server/dependencies.py +464 -6
  30. fastmcp/server/elicitation.py +285 -47
  31. fastmcp/server/event_store.py +177 -0
  32. fastmcp/server/http.py +15 -3
  33. fastmcp/server/low_level.py +56 -12
  34. fastmcp/server/middleware/middleware.py +2 -2
  35. fastmcp/server/openapi/__init__.py +35 -0
  36. fastmcp/{experimental/server → server}/openapi/components.py +4 -3
  37. fastmcp/{experimental/server → server}/openapi/routing.py +1 -1
  38. fastmcp/{experimental/server → server}/openapi/server.py +6 -5
  39. fastmcp/server/proxy.py +50 -37
  40. fastmcp/server/server.py +731 -532
  41. fastmcp/server/tasks/__init__.py +21 -0
  42. fastmcp/server/tasks/capabilities.py +22 -0
  43. fastmcp/server/tasks/config.py +89 -0
  44. fastmcp/server/tasks/converters.py +205 -0
  45. fastmcp/server/tasks/handlers.py +356 -0
  46. fastmcp/server/tasks/keys.py +93 -0
  47. fastmcp/server/tasks/protocol.py +355 -0
  48. fastmcp/server/tasks/subscriptions.py +205 -0
  49. fastmcp/settings.py +101 -103
  50. fastmcp/tools/tool.py +80 -44
  51. fastmcp/tools/tool_transform.py +1 -12
  52. fastmcp/utilities/components.py +3 -3
  53. fastmcp/utilities/json_schema_type.py +4 -4
  54. fastmcp/utilities/mcp_config.py +1 -2
  55. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +1 -1
  56. fastmcp/{experimental/utilities → utilities}/openapi/README.md +7 -35
  57. fastmcp/utilities/openapi/__init__.py +63 -0
  58. fastmcp/{experimental/utilities → utilities}/openapi/formatters.py +5 -5
  59. fastmcp/{experimental/utilities → utilities}/openapi/json_schema_converter.py +1 -1
  60. fastmcp/utilities/tests.py +11 -5
  61. fastmcp/utilities/types.py +8 -0
  62. {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/METADATA +5 -4
  63. {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/RECORD +71 -59
  64. fastmcp/server/auth/providers/bearer.py +0 -25
  65. fastmcp/server/openapi.py +0 -1087
  66. fastmcp/utilities/openapi.py +0 -1568
  67. /fastmcp/{experimental/server → server}/openapi/README.md +0 -0
  68. /fastmcp/{experimental/utilities → utilities}/openapi/director.py +0 -0
  69. /fastmcp/{experimental/utilities → utilities}/openapi/models.py +0 -0
  70. /fastmcp/{experimental/utilities → utilities}/openapi/parser.py +0 -0
  71. /fastmcp/{experimental/utilities → utilities}/openapi/schemas.py +0 -0
  72. {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/WHEEL +0 -0
  73. {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/entry_points.txt +0 -0
  74. {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
- self._init_timeout = _timeout_to_seconds(init_timeout)
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
- await self._disconnect()
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
- if isinstance(exception, httpx.HTTPStatusError):
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
- result = await self.session.read_resource(uri)
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, uri: AnyUrl | str
671
- ) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]:
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]: A list of content
679
- objects, typically containing either text or binary data.
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, name: str, arguments: dict[str, Any] | None = None
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
- result = await self.session.get_prompt(
768
- name=name, arguments=serialized_arguments
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, name: str, arguments: dict[str, Any] | None = None
774
- ) -> mcp.types.GetPromptResult:
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 from the protocol,
783
- containing the prompt messages and any additional metadata.
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
- result = await self.session.call_tool(
914
- name=name,
915
- arguments=arguments,
916
- read_timeout_seconds=timeout, # ty: ignore[invalid-argument-type]
917
- progress_callback=progress_handler or self._progress_handler,
918
- meta=meta,
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
- ) -> CallToolResult:
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 a ToolError if the tool call results in an error. Defaults to True.
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
- The content returned by the tool. If the tool returns structured
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
- data = None
967
- if result.isError and raise_on_error:
968
- msg = cast(mcp.types.TextContent, result.content[0]).text
969
- raise ToolError(msg)
970
- elif result.structuredContent:
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
- if name not in self.session._tool_output_schemas:
973
- await self.session.list_tools()
974
- if name in self.session._tool_output_schemas:
975
- output_schema = self.session._tool_output_schemas.get(name)
976
- if output_schema:
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 CallToolResult(
993
- content=result.content,
994
- structured_content=result.structuredContent,
995
- meta=result.meta,
996
- data=data,
997
- is_error=result.isError,
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