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
@@ -7,7 +7,7 @@ import mcp.types
7
7
  from mcp import ClientSession
8
8
  from mcp.client.session import ElicitationFnT
9
9
  from mcp.shared.context import LifespanContextT, RequestContext
10
- from mcp.types import ElicitRequestParams
10
+ from mcp.types import ElicitRequestFormParams, ElicitRequestParams
11
11
  from mcp.types import ElicitResult as MCPElicitResult
12
12
  from pydantic_core import to_jsonable_python
13
13
  from typing_extensions import TypeVar
@@ -26,7 +26,8 @@ class ElicitResult(MCPElicitResult, Generic[T]):
26
26
  ElicitationHandler: TypeAlias = Callable[
27
27
  [
28
28
  str, # message
29
- type[T], # a class for creating a structured response
29
+ type[T]
30
+ | None, # a class for creating a structured response (None for URL elicitation)
30
31
  ElicitRequestParams,
31
32
  RequestContext[ClientSession, LifespanContextT],
32
33
  ],
@@ -42,10 +43,15 @@ def create_elicitation_callback(
42
43
  params: ElicitRequestParams,
43
44
  ) -> MCPElicitResult | mcp.types.ErrorData:
44
45
  try:
45
- if params.requestedSchema == {"type": "object", "properties": {}}:
46
- response_type = None
46
+ # requestedSchema only exists on ElicitRequestFormParams, not ElicitRequestURLParams
47
+ if isinstance(params, ElicitRequestFormParams):
48
+ if params.requestedSchema == {"type": "object", "properties": {}}:
49
+ response_type = None
50
+ else:
51
+ response_type = json_schema_to_type(params.requestedSchema)
47
52
  else:
48
- response_type = json_schema_to_type(params.requestedSchema)
53
+ # URL-based elicitation doesn't have a schema
54
+ response_type = None
49
55
 
50
56
  result = await elicitation_handler(
51
57
  params.message, response_type, params, context
@@ -35,16 +35,18 @@ class MessageHandler:
35
35
  # requests
36
36
  case RequestResponder():
37
37
  # handle all requests
38
- await self.on_request(message)
38
+ # TODO(ty): remove when ty supports match statement narrowing
39
+ await self.on_request(message) # type: ignore[arg-type]
39
40
 
40
41
  # handle specific requests
41
- match message.request.root:
42
+ # TODO(ty): remove type ignores when ty supports match statement narrowing
43
+ match message.request.root: # type: ignore[union-attr]
42
44
  case mcp.types.PingRequest():
43
- await self.on_ping(message.request.root)
45
+ await self.on_ping(message.request.root) # type: ignore[union-attr]
44
46
  case mcp.types.ListRootsRequest():
45
- await self.on_list_roots(message.request.root)
47
+ await self.on_list_roots(message.request.root) # type: ignore[union-attr]
46
48
  case mcp.types.CreateMessageRequest():
47
- await self.on_create_message(message.request.root)
49
+ await self.on_create_message(message.request.root) # type: ignore[union-attr]
48
50
 
49
51
  # notifications
50
52
  case mcp.types.ServerNotification():
fastmcp/client/roots.py CHANGED
@@ -34,7 +34,8 @@ def create_roots_callback(
34
34
  handler: RootsList | RootsHandler,
35
35
  ) -> ListRootsFnT:
36
36
  if isinstance(handler, list):
37
- return _create_roots_callback_from_roots(handler)
37
+ # TODO(ty): remove when ty supports isinstance union narrowing
38
+ return _create_roots_callback_from_roots(handler) # type: ignore[arg-type]
38
39
  elif inspect.isfunction(handler):
39
40
  return _create_roots_callback_from_fn(handler)
40
41
  else:
@@ -0,0 +1,614 @@
1
+ """SEP-1686 client Task classes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import abc
6
+ import asyncio
7
+ import inspect
8
+ import time
9
+ import weakref
10
+ from collections.abc import Awaitable, Callable
11
+ from datetime import datetime, timezone
12
+ from typing import TYPE_CHECKING, Generic, TypeVar
13
+
14
+ import mcp.types
15
+ from mcp import ClientSession
16
+ from mcp.client.session import (
17
+ SUPPORTED_PROTOCOL_VERSIONS,
18
+ _default_elicitation_callback,
19
+ _default_list_roots_callback,
20
+ _default_sampling_callback,
21
+ )
22
+ from mcp.types import GetTaskResult, TaskStatusNotification
23
+
24
+ from fastmcp.client.messages import Message, MessageHandler
25
+ from fastmcp.utilities.logging import get_logger
26
+
27
+ logger = get_logger(__name__)
28
+
29
+ if TYPE_CHECKING:
30
+ from fastmcp.client.client import CallToolResult, Client
31
+
32
+
33
+ # TODO(SEP-1686): Remove this function when the MCP SDK adds an
34
+ # `experimental_capabilities` parameter to ClientSession (the server side
35
+ # already has this via `create_initialization_options(experimental_capabilities={})`).
36
+ # The SDK currently hardcodes `experimental=None` in ClientSession.initialize().
37
+ async def _task_capable_initialize(
38
+ session: ClientSession,
39
+ ) -> mcp.types.InitializeResult:
40
+ """Initialize a session with task capabilities declared."""
41
+ sampling = (
42
+ mcp.types.SamplingCapability()
43
+ if session._sampling_callback != _default_sampling_callback
44
+ else None
45
+ )
46
+ elicitation = (
47
+ mcp.types.ElicitationCapability()
48
+ if session._elicitation_callback != _default_elicitation_callback
49
+ else None
50
+ )
51
+ roots = (
52
+ mcp.types.RootsCapability(listChanged=True)
53
+ if session._list_roots_callback != _default_list_roots_callback
54
+ else None
55
+ )
56
+
57
+ result = await session.send_request(
58
+ mcp.types.ClientRequest(
59
+ mcp.types.InitializeRequest(
60
+ params=mcp.types.InitializeRequestParams(
61
+ protocolVersion=mcp.types.LATEST_PROTOCOL_VERSION,
62
+ capabilities=mcp.types.ClientCapabilities(
63
+ sampling=sampling,
64
+ elicitation=elicitation,
65
+ experimental={"tasks": {}},
66
+ roots=roots,
67
+ ),
68
+ clientInfo=session._client_info,
69
+ ),
70
+ )
71
+ ),
72
+ mcp.types.InitializeResult,
73
+ )
74
+
75
+ if result.protocolVersion not in SUPPORTED_PROTOCOL_VERSIONS:
76
+ raise RuntimeError(
77
+ f"Unsupported protocol version from the server: {result.protocolVersion}"
78
+ )
79
+
80
+ session._server_capabilities = result.capabilities
81
+
82
+ await session.send_notification(
83
+ mcp.types.ClientNotification(mcp.types.InitializedNotification())
84
+ )
85
+
86
+ return result
87
+
88
+
89
+ class TaskNotificationHandler(MessageHandler):
90
+ """MessageHandler that routes task status notifications to Task objects."""
91
+
92
+ def __init__(self, client: Client):
93
+ super().__init__()
94
+ self._client_ref: weakref.ref[Client] = weakref.ref(client)
95
+
96
+ async def dispatch(self, message: Message) -> None:
97
+ """Dispatch messages, including task status notifications."""
98
+ if isinstance(message, mcp.types.ServerNotification):
99
+ if isinstance(message.root, TaskStatusNotification):
100
+ client = self._client_ref()
101
+ if client:
102
+ client._handle_task_status_notification(message.root)
103
+
104
+ await super().dispatch(message)
105
+
106
+
107
+ TaskResultT = TypeVar("TaskResultT")
108
+
109
+
110
+ class Task(abc.ABC, Generic[TaskResultT]):
111
+ """
112
+ Abstract base class for MCP background tasks (SEP-1686).
113
+
114
+ Provides a uniform API whether the server accepts background execution
115
+ or executes synchronously (graceful degradation per SEP-1686).
116
+
117
+ Subclasses:
118
+ - ToolTask: For tool calls (result type: CallToolResult)
119
+ - PromptTask: For prompts (future, result type: GetPromptResult)
120
+ - ResourceTask: For resources (future, result type: ReadResourceResult)
121
+ """
122
+
123
+ def __init__(
124
+ self,
125
+ client: Client,
126
+ task_id: str,
127
+ immediate_result: TaskResultT | None = None,
128
+ ):
129
+ """
130
+ Create a Task wrapper.
131
+
132
+ Args:
133
+ client: The FastMCP client
134
+ task_id: The task identifier
135
+ immediate_result: If server executed synchronously, the immediate result
136
+ """
137
+ self._client = client
138
+ self._task_id = task_id
139
+ self._immediate_result = immediate_result
140
+ self._is_immediate = immediate_result is not None
141
+
142
+ # Notification-based optimization (SEP-1686 notifications/tasks/status)
143
+ self._status_cache: GetTaskResult | None = None
144
+ self._status_event: asyncio.Event | None = None # Lazy init
145
+ self._status_callbacks: list[
146
+ Callable[[GetTaskResult], None | Awaitable[None]]
147
+ ] = []
148
+ self._cached_result: TaskResultT | None = None
149
+
150
+ def _check_client_connected(self) -> None:
151
+ """Validate that client context is still active.
152
+
153
+ Raises:
154
+ RuntimeError: If accessed outside client context (unless immediate)
155
+ """
156
+ if self._is_immediate:
157
+ return # Already resolved, no client needed
158
+
159
+ try:
160
+ _ = self._client.session
161
+ except RuntimeError as e:
162
+ raise RuntimeError(
163
+ "Cannot access task results outside client context. "
164
+ "Task futures must be used within 'async with client:' block."
165
+ ) from e
166
+
167
+ @property
168
+ def task_id(self) -> str:
169
+ """Get the task ID."""
170
+ return self._task_id
171
+
172
+ @property
173
+ def returned_immediately(self) -> bool:
174
+ """Check if server executed the task immediately.
175
+
176
+ Returns:
177
+ True if server executed synchronously (graceful degradation or no task support)
178
+ False if server accepted background execution
179
+ """
180
+ return self._is_immediate
181
+
182
+ def _handle_status_notification(self, status: GetTaskResult) -> None:
183
+ """Process incoming notifications/tasks/status (internal).
184
+
185
+ Called by Client when a notification is received for this task.
186
+ Updates cache, triggers events, and invokes user callbacks.
187
+
188
+ Args:
189
+ status: Task status from notification
190
+ """
191
+ # Update cache for next status() call
192
+ self._status_cache = status
193
+
194
+ # Wake up any wait() calls
195
+ if self._status_event is not None:
196
+ self._status_event.set()
197
+
198
+ # Invoke user callbacks
199
+ for callback in self._status_callbacks:
200
+ try:
201
+ result = callback(status)
202
+ if inspect.isawaitable(result):
203
+ # Fire and forget async callbacks
204
+ asyncio.create_task(result) # type: ignore[arg-type] # noqa: RUF006
205
+ except Exception as e:
206
+ logger.warning(f"Task callback error: {e}", exc_info=True)
207
+
208
+ def on_status_change(
209
+ self,
210
+ callback: Callable[[GetTaskResult], None | Awaitable[None]],
211
+ ) -> None:
212
+ """Register callback for status change notifications.
213
+
214
+ The callback will be invoked when a notifications/tasks/status is received
215
+ for this task (optional server feature per SEP-1686 lines 436-444).
216
+
217
+ Supports both sync and async callbacks (auto-detected).
218
+
219
+ Args:
220
+ callback: Function to call with GetTaskResult when status changes.
221
+ Can return None (sync) or Awaitable[None] (async).
222
+
223
+ Example:
224
+ >>> task = await client.call_tool("slow_operation", {}, task=True)
225
+ >>>
226
+ >>> def on_update(status: GetTaskResult):
227
+ ... print(f"Task {status.taskId} is now {status.status}")
228
+ >>>
229
+ >>> task.on_status_change(on_update)
230
+ >>> result = await task # Callback fires when status changes
231
+ """
232
+ self._status_callbacks.append(callback)
233
+
234
+ async def status(self) -> GetTaskResult:
235
+ """Get current task status.
236
+
237
+ If server executed immediately, returns synthetic completed status.
238
+ Otherwise queries the server for current status.
239
+ """
240
+ self._check_client_connected()
241
+
242
+ if self._is_immediate:
243
+ # Return synthetic completed status
244
+ now = datetime.now(timezone.utc)
245
+ return GetTaskResult(
246
+ taskId=self._task_id,
247
+ status="completed",
248
+ createdAt=now,
249
+ lastUpdatedAt=now,
250
+ ttl=None,
251
+ pollInterval=1000,
252
+ )
253
+
254
+ # Return cached status if available (from notification)
255
+ if self._status_cache is not None:
256
+ cached = self._status_cache
257
+ # Don't clear cache - keep it for next call
258
+ return cached
259
+
260
+ # Query server and cache the result
261
+ self._status_cache = await self._client.get_task_status(self._task_id)
262
+ return self._status_cache
263
+
264
+ @abc.abstractmethod
265
+ async def result(self) -> TaskResultT:
266
+ """Wait for and return the task result.
267
+
268
+ Must be implemented by subclasses to return the appropriate result type.
269
+ """
270
+ ...
271
+
272
+ async def wait(
273
+ self, *, state: str | None = None, timeout: float = 300.0
274
+ ) -> GetTaskResult:
275
+ """Wait for task to reach a specific state or complete.
276
+
277
+ Uses event-based waiting when notifications are available (fast),
278
+ with fallback to polling (reliable). Optimally wakes up immediately
279
+ on status changes when server sends notifications/tasks/status.
280
+
281
+ Args:
282
+ state: Desired state ('submitted', 'working', 'completed', 'failed').
283
+ If None, waits for any terminal state (completed/failed)
284
+ timeout: Maximum time to wait in seconds
285
+
286
+ Returns:
287
+ GetTaskResult: Final task status
288
+
289
+ Raises:
290
+ TimeoutError: If desired state not reached within timeout
291
+ """
292
+ self._check_client_connected()
293
+
294
+ if self._is_immediate:
295
+ # Already done
296
+ return await self.status()
297
+
298
+ # Initialize event for notification wake-ups
299
+ if self._status_event is None:
300
+ self._status_event = asyncio.Event()
301
+
302
+ start = time.time()
303
+ terminal_states = {"completed", "failed", "cancelled"}
304
+ poll_interval = 0.5 # Fallback polling interval (500ms)
305
+
306
+ while True:
307
+ # Check cached status first (updated by notifications)
308
+ if self._status_cache:
309
+ current = self._status_cache.status
310
+ if state is None:
311
+ if current in terminal_states:
312
+ return self._status_cache
313
+ elif current == state:
314
+ return self._status_cache
315
+
316
+ # Check timeout
317
+ elapsed = time.time() - start
318
+ if elapsed >= timeout:
319
+ raise TimeoutError(
320
+ f"Task {self._task_id} did not reach {state or 'terminal state'} within {timeout}s"
321
+ )
322
+
323
+ remaining = timeout - elapsed
324
+
325
+ # Wait for notification event OR poll timeout
326
+ try:
327
+ await asyncio.wait_for(
328
+ self._status_event.wait(), timeout=min(poll_interval, remaining)
329
+ )
330
+ self._status_event.clear()
331
+ except asyncio.TimeoutError:
332
+ # Fallback: poll server (notification didn't arrive in time)
333
+ self._status_cache = await self._client.get_task_status(self._task_id)
334
+
335
+ async def cancel(self) -> None:
336
+ """Cancel this task, transitioning it to cancelled state.
337
+
338
+ Sends a tasks/cancel protocol request. The server will attempt to halt
339
+ execution and move the task to cancelled state.
340
+
341
+ Note: If server executed immediately (graceful degradation), this is a no-op
342
+ as there's no server-side task to cancel.
343
+ """
344
+ if self._is_immediate:
345
+ # No server-side task to cancel
346
+ return
347
+ self._check_client_connected()
348
+ await self._client.cancel_task(self._task_id)
349
+ # Invalidate cache to force fresh status fetch
350
+ self._status_cache = None
351
+
352
+ def __await__(self):
353
+ """Allow 'await task' to get result."""
354
+ return self.result().__await__()
355
+
356
+
357
+ class ToolTask(Task["CallToolResult"]):
358
+ """
359
+ Represents a tool call that may execute in background or immediately.
360
+
361
+ Provides a uniform API whether the server accepts background execution
362
+ or executes synchronously (graceful degradation per SEP-1686).
363
+
364
+ Usage:
365
+ task = await client.call_tool_as_task("analyze", args)
366
+
367
+ # Check status
368
+ status = await task.status()
369
+
370
+ # Wait for completion
371
+ await task.wait()
372
+
373
+ # Get result (waits if needed)
374
+ result = await task.result() # Returns CallToolResult
375
+
376
+ # Or just await the task directly
377
+ result = await task
378
+ """
379
+
380
+ def __init__(
381
+ self,
382
+ client: Client,
383
+ task_id: str,
384
+ tool_name: str,
385
+ immediate_result: CallToolResult | None = None,
386
+ ):
387
+ """
388
+ Create a ToolTask wrapper.
389
+
390
+ Args:
391
+ client: The FastMCP client
392
+ task_id: The task identifier
393
+ tool_name: Name of the tool being executed
394
+ immediate_result: If server executed synchronously, the immediate result
395
+ """
396
+ super().__init__(client, task_id, immediate_result)
397
+ self._tool_name = tool_name
398
+
399
+ async def result(self) -> CallToolResult:
400
+ """Wait for and return the tool result.
401
+
402
+ If server executed immediately, returns the immediate result.
403
+ Otherwise waits for background task to complete and retrieves result.
404
+
405
+ Returns:
406
+ CallToolResult: The parsed tool result (same as call_tool returns)
407
+ """
408
+ # Check cache first
409
+ if self._cached_result is not None:
410
+ return self._cached_result
411
+
412
+ if self._is_immediate:
413
+ assert self._immediate_result is not None # Type narrowing
414
+ result = self._immediate_result
415
+ else:
416
+ # Check client connected
417
+ self._check_client_connected()
418
+
419
+ # Wait for completion using event-based wait (respects notifications)
420
+ await self.wait()
421
+
422
+ # Get the raw result (dict or CallToolResult)
423
+ raw_result = await self._client.get_task_result(self._task_id)
424
+
425
+ # Convert to CallToolResult if needed and parse
426
+ if isinstance(raw_result, dict):
427
+ # Raw dict from get_task_result - parse as CallToolResult
428
+ mcp_result = mcp.types.CallToolResult.model_validate(raw_result)
429
+ result = await self._client._parse_call_tool_result(
430
+ self._tool_name, mcp_result, raise_on_error=True
431
+ )
432
+ elif isinstance(raw_result, mcp.types.CallToolResult):
433
+ # Already a CallToolResult from MCP protocol - parse it
434
+ result = await self._client._parse_call_tool_result(
435
+ self._tool_name, raw_result, raise_on_error=True
436
+ )
437
+ else:
438
+ # Legacy ToolResult format - convert to MCP type
439
+ if hasattr(raw_result, "content") and hasattr(
440
+ raw_result, "structured_content"
441
+ ):
442
+ mcp_result = mcp.types.CallToolResult(
443
+ content=raw_result.content,
444
+ structuredContent=raw_result.structured_content, # type: ignore[arg-type]
445
+ _meta=raw_result.meta,
446
+ )
447
+ result = await self._client._parse_call_tool_result(
448
+ self._tool_name, mcp_result, raise_on_error=True
449
+ )
450
+ else:
451
+ # Unknown type - just return it
452
+ result = raw_result # type: ignore[assignment]
453
+
454
+ # Cache before returning
455
+ self._cached_result = result
456
+ return result
457
+
458
+
459
+ class PromptTask(Task[mcp.types.GetPromptResult]):
460
+ """
461
+ Represents a prompt call that may execute in background or immediately.
462
+
463
+ Provides a uniform API whether the server accepts background execution
464
+ or executes synchronously (graceful degradation per SEP-1686).
465
+
466
+ Usage:
467
+ task = await client.get_prompt_as_task("analyze", args)
468
+ result = await task # Returns GetPromptResult
469
+ """
470
+
471
+ def __init__(
472
+ self,
473
+ client: Client,
474
+ task_id: str,
475
+ prompt_name: str,
476
+ immediate_result: mcp.types.GetPromptResult | None = None,
477
+ ):
478
+ """
479
+ Create a PromptTask wrapper.
480
+
481
+ Args:
482
+ client: The FastMCP client
483
+ task_id: The task identifier
484
+ prompt_name: Name of the prompt being executed
485
+ immediate_result: If server executed synchronously, the immediate result
486
+ """
487
+ super().__init__(client, task_id, immediate_result)
488
+ self._prompt_name = prompt_name
489
+
490
+ async def result(self) -> mcp.types.GetPromptResult:
491
+ """Wait for and return the prompt result.
492
+
493
+ If server executed immediately, returns the immediate result.
494
+ Otherwise waits for background task to complete and retrieves result.
495
+
496
+ Returns:
497
+ GetPromptResult: The prompt result with messages and description
498
+ """
499
+ # Check cache first
500
+ if self._cached_result is not None:
501
+ return self._cached_result
502
+
503
+ if self._is_immediate:
504
+ assert self._immediate_result is not None
505
+ result = self._immediate_result
506
+ else:
507
+ # Check client connected
508
+ self._check_client_connected()
509
+
510
+ # Wait for completion using event-based wait (respects notifications)
511
+ await self.wait()
512
+
513
+ # Get the raw MCP result
514
+ mcp_result = await self._client.get_task_result(self._task_id)
515
+
516
+ # Parse as GetPromptResult
517
+ result = mcp.types.GetPromptResult.model_validate(mcp_result)
518
+
519
+ # Cache before returning
520
+ self._cached_result = result
521
+ return result
522
+
523
+
524
+ class ResourceTask(
525
+ Task[list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]]
526
+ ):
527
+ """
528
+ Represents a resource read that may execute in background or immediately.
529
+
530
+ Provides a uniform API whether the server accepts background execution
531
+ or executes synchronously (graceful degradation per SEP-1686).
532
+
533
+ Usage:
534
+ task = await client.read_resource_as_task("file://data.txt")
535
+ contents = await task # Returns list[ReadResourceContents]
536
+ """
537
+
538
+ def __init__(
539
+ self,
540
+ client: Client,
541
+ task_id: str,
542
+ uri: str,
543
+ immediate_result: list[
544
+ mcp.types.TextResourceContents | mcp.types.BlobResourceContents
545
+ ]
546
+ | None = None,
547
+ ):
548
+ """
549
+ Create a ResourceTask wrapper.
550
+
551
+ Args:
552
+ client: The FastMCP client
553
+ task_id: The task identifier
554
+ uri: URI of the resource being read
555
+ immediate_result: If server executed synchronously, the immediate result
556
+ """
557
+ super().__init__(client, task_id, immediate_result)
558
+ self._uri = uri
559
+
560
+ async def result(
561
+ self,
562
+ ) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]:
563
+ """Wait for and return the resource contents.
564
+
565
+ If server executed immediately, returns the immediate result.
566
+ Otherwise waits for background task to complete and retrieves result.
567
+
568
+ Returns:
569
+ list[ReadResourceContents]: The resource contents
570
+ """
571
+ # Check cache first
572
+ if self._cached_result is not None:
573
+ return self._cached_result
574
+
575
+ if self._is_immediate:
576
+ assert self._immediate_result is not None
577
+ result = self._immediate_result
578
+ else:
579
+ # Check client connected
580
+ self._check_client_connected()
581
+
582
+ # Wait for completion using event-based wait (respects notifications)
583
+ await self.wait()
584
+
585
+ # Get the raw MCP result
586
+ mcp_result = await self._client.get_task_result(self._task_id)
587
+
588
+ # Parse as ReadResourceResult or extract contents
589
+ if isinstance(mcp_result, mcp.types.ReadResourceResult):
590
+ # Already parsed by TasksResponse - extract contents
591
+ result = list(mcp_result.contents)
592
+ elif isinstance(mcp_result, dict) and "contents" in mcp_result:
593
+ # Dict format - parse each content item
594
+ parsed_contents = []
595
+ for item in mcp_result["contents"]:
596
+ if isinstance(item, dict):
597
+ if "blob" in item:
598
+ parsed_contents.append(
599
+ mcp.types.BlobResourceContents.model_validate(item)
600
+ )
601
+ else:
602
+ parsed_contents.append(
603
+ mcp.types.TextResourceContents.model_validate(item)
604
+ )
605
+ else:
606
+ parsed_contents.append(item)
607
+ result = parsed_contents
608
+ else:
609
+ # Fallback - might be the list directly
610
+ result = mcp_result if isinstance(mcp_result, list) else [mcp_result]
611
+
612
+ # Cache before returning
613
+ self._cached_result = result
614
+ return result