fastmcp 2.13.2__py3-none-any.whl → 2.14.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastmcp/__init__.py +0 -21
- fastmcp/cli/__init__.py +0 -3
- fastmcp/cli/__main__.py +5 -0
- fastmcp/cli/cli.py +8 -22
- fastmcp/cli/install/shared.py +0 -15
- fastmcp/cli/tasks.py +110 -0
- fastmcp/client/auth/oauth.py +9 -9
- fastmcp/client/client.py +665 -129
- fastmcp/client/elicitation.py +11 -5
- fastmcp/client/messages.py +7 -5
- fastmcp/client/roots.py +2 -1
- fastmcp/client/tasks.py +614 -0
- fastmcp/client/transports.py +37 -5
- fastmcp/contrib/component_manager/component_service.py +4 -20
- fastmcp/dependencies.py +25 -0
- fastmcp/experimental/sampling/handlers/openai.py +1 -1
- fastmcp/experimental/server/openapi/__init__.py +15 -13
- fastmcp/experimental/utilities/openapi/__init__.py +12 -38
- fastmcp/prompts/prompt.py +33 -33
- fastmcp/resources/resource.py +29 -12
- fastmcp/resources/template.py +64 -54
- fastmcp/server/auth/__init__.py +0 -9
- fastmcp/server/auth/auth.py +127 -3
- fastmcp/server/auth/oauth_proxy.py +47 -97
- fastmcp/server/auth/oidc_proxy.py +7 -0
- fastmcp/server/auth/providers/in_memory.py +2 -2
- fastmcp/server/auth/providers/oci.py +2 -2
- fastmcp/server/context.py +66 -72
- fastmcp/server/dependencies.py +464 -6
- fastmcp/server/elicitation.py +285 -47
- fastmcp/server/event_store.py +177 -0
- fastmcp/server/http.py +15 -3
- fastmcp/server/low_level.py +56 -12
- fastmcp/server/middleware/middleware.py +2 -2
- fastmcp/server/openapi/__init__.py +35 -0
- fastmcp/{experimental/server → server}/openapi/components.py +4 -3
- fastmcp/{experimental/server → server}/openapi/routing.py +1 -1
- fastmcp/{experimental/server → server}/openapi/server.py +6 -5
- fastmcp/server/proxy.py +50 -37
- fastmcp/server/server.py +731 -532
- fastmcp/server/tasks/__init__.py +21 -0
- fastmcp/server/tasks/capabilities.py +22 -0
- fastmcp/server/tasks/config.py +89 -0
- fastmcp/server/tasks/converters.py +205 -0
- fastmcp/server/tasks/handlers.py +356 -0
- fastmcp/server/tasks/keys.py +93 -0
- fastmcp/server/tasks/protocol.py +355 -0
- fastmcp/server/tasks/subscriptions.py +205 -0
- fastmcp/settings.py +101 -103
- fastmcp/tools/tool.py +80 -44
- fastmcp/tools/tool_transform.py +1 -12
- fastmcp/utilities/components.py +3 -3
- fastmcp/utilities/json_schema_type.py +4 -4
- fastmcp/utilities/mcp_config.py +1 -2
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +1 -1
- fastmcp/{experimental/utilities → utilities}/openapi/README.md +7 -35
- fastmcp/utilities/openapi/__init__.py +63 -0
- fastmcp/{experimental/utilities → utilities}/openapi/formatters.py +5 -5
- fastmcp/{experimental/utilities → utilities}/openapi/json_schema_converter.py +1 -1
- fastmcp/utilities/tests.py +11 -5
- fastmcp/utilities/types.py +8 -0
- {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/METADATA +5 -4
- {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/RECORD +71 -59
- fastmcp/server/auth/providers/bearer.py +0 -25
- fastmcp/server/openapi.py +0 -1087
- fastmcp/utilities/openapi.py +0 -1568
- /fastmcp/{experimental/server → server}/openapi/README.md +0 -0
- /fastmcp/{experimental/utilities → utilities}/openapi/director.py +0 -0
- /fastmcp/{experimental/utilities → utilities}/openapi/models.py +0 -0
- /fastmcp/{experimental/utilities → utilities}/openapi/parser.py +0 -0
- /fastmcp/{experimental/utilities → utilities}/openapi/schemas.py +0 -0
- {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/WHEEL +0 -0
- {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.13.2.dist-info → fastmcp-2.14.0.dist-info}/licenses/LICENSE +0 -0
fastmcp/client/elicitation.py
CHANGED
|
@@ -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]
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
fastmcp/client/messages.py
CHANGED
|
@@ -35,16 +35,18 @@ class MessageHandler:
|
|
|
35
35
|
# requests
|
|
36
36
|
case RequestResponder():
|
|
37
37
|
# handle all requests
|
|
38
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
fastmcp/client/tasks.py
ADDED
|
@@ -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
|