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