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.
Files changed (85) 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 +739 -136
  9. fastmcp/client/elicitation.py +11 -5
  10. fastmcp/client/messages.py +7 -5
  11. fastmcp/client/roots.py +2 -1
  12. fastmcp/client/sampling/__init__.py +69 -0
  13. fastmcp/client/sampling/handlers/__init__.py +0 -0
  14. fastmcp/client/sampling/handlers/anthropic.py +387 -0
  15. fastmcp/client/sampling/handlers/openai.py +399 -0
  16. fastmcp/client/tasks.py +551 -0
  17. fastmcp/client/transports.py +72 -21
  18. fastmcp/contrib/component_manager/component_service.py +4 -20
  19. fastmcp/dependencies.py +25 -0
  20. fastmcp/experimental/sampling/handlers/__init__.py +5 -0
  21. fastmcp/experimental/sampling/handlers/openai.py +4 -169
  22. fastmcp/experimental/server/openapi/__init__.py +15 -13
  23. fastmcp/experimental/utilities/openapi/__init__.py +12 -38
  24. fastmcp/prompts/prompt.py +38 -38
  25. fastmcp/resources/resource.py +33 -16
  26. fastmcp/resources/template.py +69 -59
  27. fastmcp/server/auth/__init__.py +0 -9
  28. fastmcp/server/auth/auth.py +127 -3
  29. fastmcp/server/auth/oauth_proxy.py +47 -97
  30. fastmcp/server/auth/oidc_proxy.py +7 -0
  31. fastmcp/server/auth/providers/in_memory.py +2 -2
  32. fastmcp/server/auth/providers/oci.py +2 -2
  33. fastmcp/server/context.py +509 -180
  34. fastmcp/server/dependencies.py +464 -6
  35. fastmcp/server/elicitation.py +285 -47
  36. fastmcp/server/event_store.py +177 -0
  37. fastmcp/server/http.py +15 -3
  38. fastmcp/server/low_level.py +56 -12
  39. fastmcp/server/middleware/middleware.py +2 -2
  40. fastmcp/server/openapi/__init__.py +35 -0
  41. fastmcp/{experimental/server → server}/openapi/components.py +4 -3
  42. fastmcp/{experimental/server → server}/openapi/routing.py +1 -1
  43. fastmcp/{experimental/server → server}/openapi/server.py +6 -5
  44. fastmcp/server/proxy.py +53 -40
  45. fastmcp/server/sampling/__init__.py +10 -0
  46. fastmcp/server/sampling/run.py +301 -0
  47. fastmcp/server/sampling/sampling_tool.py +108 -0
  48. fastmcp/server/server.py +793 -552
  49. fastmcp/server/tasks/__init__.py +21 -0
  50. fastmcp/server/tasks/capabilities.py +22 -0
  51. fastmcp/server/tasks/config.py +89 -0
  52. fastmcp/server/tasks/converters.py +206 -0
  53. fastmcp/server/tasks/handlers.py +356 -0
  54. fastmcp/server/tasks/keys.py +93 -0
  55. fastmcp/server/tasks/protocol.py +355 -0
  56. fastmcp/server/tasks/subscriptions.py +205 -0
  57. fastmcp/settings.py +101 -103
  58. fastmcp/tools/tool.py +83 -49
  59. fastmcp/tools/tool_transform.py +1 -12
  60. fastmcp/utilities/components.py +3 -3
  61. fastmcp/utilities/json_schema_type.py +4 -4
  62. fastmcp/utilities/mcp_config.py +1 -2
  63. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +1 -1
  64. fastmcp/{experimental/utilities → utilities}/openapi/README.md +7 -35
  65. fastmcp/utilities/openapi/__init__.py +63 -0
  66. fastmcp/{experimental/utilities → utilities}/openapi/formatters.py +5 -5
  67. fastmcp/{experimental/utilities → utilities}/openapi/json_schema_converter.py +1 -1
  68. fastmcp/utilities/tests.py +11 -5
  69. fastmcp/utilities/types.py +8 -0
  70. {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/METADATA +7 -4
  71. {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/RECORD +79 -63
  72. fastmcp/client/sampling.py +0 -56
  73. fastmcp/experimental/sampling/handlers/base.py +0 -21
  74. fastmcp/server/auth/providers/bearer.py +0 -25
  75. fastmcp/server/openapi.py +0 -1087
  76. fastmcp/server/sampling/handler.py +0 -19
  77. fastmcp/utilities/openapi.py +0 -1568
  78. /fastmcp/{experimental/server → server}/openapi/README.md +0 -0
  79. /fastmcp/{experimental/utilities → utilities}/openapi/director.py +0 -0
  80. /fastmcp/{experimental/utilities → utilities}/openapi/models.py +0 -0
  81. /fastmcp/{experimental/utilities → utilities}/openapi/parser.py +0 -0
  82. /fastmcp/{experimental/utilities → utilities}/openapi/schemas.py +0 -0
  83. {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/WHEEL +0 -0
  84. {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/entry_points.txt +0 -0
  85. {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: ClientSamplingHandler | None = None,
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
- self._init_timeout = _timeout_to_seconds(init_timeout)
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(self, sampling_callback: ClientSamplingHandler) -> None:
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._session_state.session = None
371
- self._session_state.initialize_result = None
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
- await self._disconnect()
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
- await self._session_state.ready_event.wait()
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
- if isinstance(exception, httpx.HTTPStatusError):
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
- result = await self.session.read_resource(uri)
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, uri: AnyUrl | str
671
- ) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]:
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]: A list of content
679
- objects, typically containing either text or binary data.
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, name: str, arguments: dict[str, Any] | None = None
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
- result = await self.session.get_prompt(
768
- name=name, arguments=serialized_arguments
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, name: str, arguments: dict[str, Any] | None = None
774
- ) -> mcp.types.GetPromptResult:
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 from the protocol,
783
- containing the prompt messages and any additional metadata.
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
- 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
- )
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
- ) -> CallToolResult:
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 a ToolError if the tool call results in an error. Defaults to True.
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
- The content returned by the tool. If the tool returns structured
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
- 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:
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
- 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}")
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 CallToolResult(
993
- content=result.content,
994
- structured_content=result.structuredContent,
995
- meta=result.meta,
996
- data=data,
997
- is_error=result.isError,
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