fastmcp 2.14.4__py3-none-any.whl → 3.0.0b1__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/_vendor/__init__.py +1 -0
- fastmcp/_vendor/docket_di/README.md +7 -0
- fastmcp/_vendor/docket_di/__init__.py +163 -0
- fastmcp/cli/cli.py +112 -28
- fastmcp/cli/install/claude_code.py +1 -5
- fastmcp/cli/install/claude_desktop.py +1 -5
- fastmcp/cli/install/cursor.py +1 -5
- fastmcp/cli/install/gemini_cli.py +1 -5
- fastmcp/cli/install/mcp_json.py +1 -6
- fastmcp/cli/run.py +146 -5
- fastmcp/client/__init__.py +7 -9
- fastmcp/client/auth/oauth.py +18 -17
- fastmcp/client/client.py +100 -870
- fastmcp/client/elicitation.py +1 -1
- fastmcp/client/mixins/__init__.py +13 -0
- fastmcp/client/mixins/prompts.py +295 -0
- fastmcp/client/mixins/resources.py +325 -0
- fastmcp/client/mixins/task_management.py +157 -0
- fastmcp/client/mixins/tools.py +397 -0
- fastmcp/client/sampling/handlers/anthropic.py +2 -2
- fastmcp/client/sampling/handlers/openai.py +1 -1
- fastmcp/client/tasks.py +3 -3
- fastmcp/client/telemetry.py +47 -0
- fastmcp/client/transports/__init__.py +38 -0
- fastmcp/client/transports/base.py +82 -0
- fastmcp/client/transports/config.py +170 -0
- fastmcp/client/transports/http.py +145 -0
- fastmcp/client/transports/inference.py +154 -0
- fastmcp/client/transports/memory.py +90 -0
- fastmcp/client/transports/sse.py +89 -0
- fastmcp/client/transports/stdio.py +543 -0
- fastmcp/contrib/component_manager/README.md +4 -10
- fastmcp/contrib/component_manager/__init__.py +1 -2
- fastmcp/contrib/component_manager/component_manager.py +95 -160
- fastmcp/contrib/component_manager/example.py +1 -1
- fastmcp/contrib/mcp_mixin/example.py +4 -4
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +11 -4
- fastmcp/decorators.py +41 -0
- fastmcp/dependencies.py +12 -1
- fastmcp/exceptions.py +4 -0
- fastmcp/experimental/server/openapi/__init__.py +18 -15
- fastmcp/mcp_config.py +13 -4
- fastmcp/prompts/__init__.py +6 -3
- fastmcp/prompts/function_prompt.py +465 -0
- fastmcp/prompts/prompt.py +321 -271
- fastmcp/resources/__init__.py +5 -3
- fastmcp/resources/function_resource.py +335 -0
- fastmcp/resources/resource.py +325 -115
- fastmcp/resources/template.py +215 -43
- fastmcp/resources/types.py +27 -12
- fastmcp/server/__init__.py +2 -2
- fastmcp/server/auth/__init__.py +14 -0
- fastmcp/server/auth/auth.py +30 -10
- fastmcp/server/auth/authorization.py +190 -0
- fastmcp/server/auth/oauth_proxy/__init__.py +14 -0
- fastmcp/server/auth/oauth_proxy/consent.py +361 -0
- fastmcp/server/auth/oauth_proxy/models.py +178 -0
- fastmcp/server/auth/{oauth_proxy.py → oauth_proxy/proxy.py} +24 -778
- fastmcp/server/auth/oauth_proxy/ui.py +277 -0
- fastmcp/server/auth/oidc_proxy.py +2 -2
- fastmcp/server/auth/providers/auth0.py +24 -94
- fastmcp/server/auth/providers/aws.py +26 -95
- fastmcp/server/auth/providers/azure.py +41 -129
- fastmcp/server/auth/providers/descope.py +18 -49
- fastmcp/server/auth/providers/discord.py +25 -86
- fastmcp/server/auth/providers/github.py +23 -87
- fastmcp/server/auth/providers/google.py +24 -87
- fastmcp/server/auth/providers/introspection.py +60 -79
- fastmcp/server/auth/providers/jwt.py +30 -67
- fastmcp/server/auth/providers/oci.py +47 -110
- fastmcp/server/auth/providers/scalekit.py +23 -61
- fastmcp/server/auth/providers/supabase.py +18 -47
- fastmcp/server/auth/providers/workos.py +34 -127
- fastmcp/server/context.py +372 -419
- fastmcp/server/dependencies.py +541 -251
- fastmcp/server/elicitation.py +20 -18
- fastmcp/server/event_store.py +3 -3
- fastmcp/server/http.py +16 -6
- fastmcp/server/lifespan.py +198 -0
- fastmcp/server/low_level.py +92 -2
- fastmcp/server/middleware/__init__.py +5 -1
- fastmcp/server/middleware/authorization.py +312 -0
- fastmcp/server/middleware/caching.py +101 -54
- fastmcp/server/middleware/middleware.py +6 -9
- fastmcp/server/middleware/ping.py +70 -0
- fastmcp/server/middleware/tool_injection.py +2 -2
- fastmcp/server/mixins/__init__.py +7 -0
- fastmcp/server/mixins/lifespan.py +217 -0
- fastmcp/server/mixins/mcp_operations.py +392 -0
- fastmcp/server/mixins/transport.py +342 -0
- fastmcp/server/openapi/__init__.py +41 -21
- fastmcp/server/openapi/components.py +16 -339
- fastmcp/server/openapi/routing.py +34 -118
- fastmcp/server/openapi/server.py +67 -392
- fastmcp/server/providers/__init__.py +71 -0
- fastmcp/server/providers/aggregate.py +261 -0
- fastmcp/server/providers/base.py +578 -0
- fastmcp/server/providers/fastmcp_provider.py +674 -0
- fastmcp/server/providers/filesystem.py +226 -0
- fastmcp/server/providers/filesystem_discovery.py +327 -0
- fastmcp/server/providers/local_provider/__init__.py +11 -0
- fastmcp/server/providers/local_provider/decorators/__init__.py +15 -0
- fastmcp/server/providers/local_provider/decorators/prompts.py +256 -0
- fastmcp/server/providers/local_provider/decorators/resources.py +240 -0
- fastmcp/server/providers/local_provider/decorators/tools.py +315 -0
- fastmcp/server/providers/local_provider/local_provider.py +465 -0
- fastmcp/server/providers/openapi/__init__.py +39 -0
- fastmcp/server/providers/openapi/components.py +332 -0
- fastmcp/server/providers/openapi/provider.py +405 -0
- fastmcp/server/providers/openapi/routing.py +109 -0
- fastmcp/server/providers/proxy.py +867 -0
- fastmcp/server/providers/skills/__init__.py +59 -0
- fastmcp/server/providers/skills/_common.py +101 -0
- fastmcp/server/providers/skills/claude_provider.py +44 -0
- fastmcp/server/providers/skills/directory_provider.py +153 -0
- fastmcp/server/providers/skills/skill_provider.py +432 -0
- fastmcp/server/providers/skills/vendor_providers.py +142 -0
- fastmcp/server/providers/wrapped_provider.py +140 -0
- fastmcp/server/proxy.py +34 -700
- fastmcp/server/sampling/run.py +341 -2
- fastmcp/server/sampling/sampling_tool.py +4 -3
- fastmcp/server/server.py +1214 -2171
- fastmcp/server/tasks/__init__.py +2 -1
- fastmcp/server/tasks/capabilities.py +13 -1
- fastmcp/server/tasks/config.py +66 -3
- fastmcp/server/tasks/handlers.py +65 -273
- fastmcp/server/tasks/keys.py +4 -6
- fastmcp/server/tasks/requests.py +474 -0
- fastmcp/server/tasks/routing.py +76 -0
- fastmcp/server/tasks/subscriptions.py +20 -11
- fastmcp/server/telemetry.py +131 -0
- fastmcp/server/transforms/__init__.py +244 -0
- fastmcp/server/transforms/namespace.py +193 -0
- fastmcp/server/transforms/prompts_as_tools.py +175 -0
- fastmcp/server/transforms/resources_as_tools.py +190 -0
- fastmcp/server/transforms/tool_transform.py +96 -0
- fastmcp/server/transforms/version_filter.py +124 -0
- fastmcp/server/transforms/visibility.py +526 -0
- fastmcp/settings.py +34 -96
- fastmcp/telemetry.py +122 -0
- fastmcp/tools/__init__.py +10 -3
- fastmcp/tools/function_parsing.py +201 -0
- fastmcp/tools/function_tool.py +467 -0
- fastmcp/tools/tool.py +215 -362
- fastmcp/tools/tool_transform.py +38 -21
- fastmcp/utilities/async_utils.py +69 -0
- fastmcp/utilities/components.py +152 -91
- fastmcp/utilities/inspect.py +8 -20
- fastmcp/utilities/json_schema.py +12 -5
- fastmcp/utilities/json_schema_type.py +17 -15
- fastmcp/utilities/lifespan.py +56 -0
- fastmcp/utilities/logging.py +12 -4
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -3
- fastmcp/utilities/openapi/parser.py +3 -3
- fastmcp/utilities/pagination.py +80 -0
- fastmcp/utilities/skills.py +253 -0
- fastmcp/utilities/tests.py +0 -16
- fastmcp/utilities/timeout.py +47 -0
- fastmcp/utilities/types.py +1 -1
- fastmcp/utilities/versions.py +285 -0
- {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/METADATA +8 -5
- fastmcp-3.0.0b1.dist-info/RECORD +228 -0
- fastmcp/client/transports.py +0 -1170
- fastmcp/contrib/component_manager/component_service.py +0 -209
- fastmcp/prompts/prompt_manager.py +0 -117
- fastmcp/resources/resource_manager.py +0 -338
- fastmcp/server/tasks/converters.py +0 -206
- fastmcp/server/tasks/protocol.py +0 -359
- fastmcp/tools/tool_manager.py +0 -170
- fastmcp/utilities/mcp_config.py +0 -56
- fastmcp-2.14.4.dist-info/RECORD +0 -161
- /fastmcp/server/{openapi → providers/openapi}/README.md +0 -0
- {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/WHEEL +0 -0
- {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""SEP-1686 task request handlers.
|
|
2
|
+
|
|
3
|
+
Handles MCP task protocol requests: tasks/get, tasks/result, tasks/list, tasks/cancel.
|
|
4
|
+
These handlers query and manage existing tasks (contrast with handlers.py which creates tasks).
|
|
5
|
+
|
|
6
|
+
This module requires fastmcp[tasks] (pydocket). It is only imported when docket is available.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import datetime, timedelta, timezone
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
13
|
+
|
|
14
|
+
import mcp.types
|
|
15
|
+
from docket.execution import ExecutionState
|
|
16
|
+
from mcp.shared.exceptions import McpError
|
|
17
|
+
from mcp.types import (
|
|
18
|
+
INTERNAL_ERROR,
|
|
19
|
+
INVALID_PARAMS,
|
|
20
|
+
CancelTaskResult,
|
|
21
|
+
ErrorData,
|
|
22
|
+
GetTaskResult,
|
|
23
|
+
ListTasksResult,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
import fastmcp.server.context
|
|
27
|
+
from fastmcp.exceptions import NotFoundError
|
|
28
|
+
from fastmcp.prompts.prompt import Prompt
|
|
29
|
+
from fastmcp.resources.resource import Resource
|
|
30
|
+
from fastmcp.resources.template import ResourceTemplate
|
|
31
|
+
from fastmcp.server.tasks.config import DEFAULT_POLL_INTERVAL_MS, DEFAULT_TTL_MS
|
|
32
|
+
from fastmcp.server.tasks.keys import parse_task_key
|
|
33
|
+
from fastmcp.tools.tool import Tool
|
|
34
|
+
from fastmcp.utilities.versions import VersionSpec
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from fastmcp.server.server import FastMCP
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Map Docket execution states to MCP task status strings
|
|
41
|
+
# Per SEP-1686 final spec (line 381): tasks MUST begin in "working" status
|
|
42
|
+
DOCKET_TO_MCP_STATE: dict[ExecutionState, str] = {
|
|
43
|
+
ExecutionState.SCHEDULED: "working", # Initial state per spec
|
|
44
|
+
ExecutionState.QUEUED: "working", # Initial state per spec
|
|
45
|
+
ExecutionState.RUNNING: "working",
|
|
46
|
+
ExecutionState.COMPLETED: "completed",
|
|
47
|
+
ExecutionState.FAILED: "failed",
|
|
48
|
+
ExecutionState.CANCELLED: "cancelled",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_key_version(key_suffix: str) -> tuple[str, str | None]:
|
|
53
|
+
"""Parse a key suffix into (name_or_uri, version).
|
|
54
|
+
|
|
55
|
+
Keys always contain @ as a version delimiter (sentinel pattern):
|
|
56
|
+
- "add@1.0" → ("add", "1.0") # versioned
|
|
57
|
+
- "add@" → ("add", None) # unversioned
|
|
58
|
+
- "user@example.com@1.0" → ("user@example.com", "1.0") # @ in URI
|
|
59
|
+
|
|
60
|
+
Uses rsplit to split on the LAST @ which is always the version delimiter.
|
|
61
|
+
Falls back to treating the whole string as the name if @ is not present
|
|
62
|
+
(for backwards compatibility with legacy task keys).
|
|
63
|
+
"""
|
|
64
|
+
if "@" not in key_suffix:
|
|
65
|
+
# Legacy key without version sentinel - treat as unversioned
|
|
66
|
+
return key_suffix, None
|
|
67
|
+
name_or_uri, version = key_suffix.rsplit("@", 1)
|
|
68
|
+
return name_or_uri, version if version else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _lookup_task_execution(
|
|
72
|
+
docket: Any,
|
|
73
|
+
session_id: str,
|
|
74
|
+
client_task_id: str,
|
|
75
|
+
) -> tuple[Any, str | None, int]:
|
|
76
|
+
"""Look up task execution and metadata from Redis.
|
|
77
|
+
|
|
78
|
+
Consolidates the common pattern of fetching task metadata from Redis,
|
|
79
|
+
validating it exists, and retrieving the Docket execution.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
docket: Docket instance
|
|
83
|
+
session_id: Session ID
|
|
84
|
+
client_task_id: Client-provided task ID
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Tuple of (execution, created_at, poll_interval_ms)
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
McpError: If task not found or execution not found
|
|
91
|
+
"""
|
|
92
|
+
task_meta_key = docket.key(f"fastmcp:task:{session_id}:{client_task_id}")
|
|
93
|
+
created_at_key = docket.key(
|
|
94
|
+
f"fastmcp:task:{session_id}:{client_task_id}:created_at"
|
|
95
|
+
)
|
|
96
|
+
poll_interval_key = docket.key(
|
|
97
|
+
f"fastmcp:task:{session_id}:{client_task_id}:poll_interval"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Fetch metadata (single round-trip with mget)
|
|
101
|
+
async with docket.redis() as redis:
|
|
102
|
+
task_key_bytes, created_at_bytes, poll_interval_bytes = await redis.mget(
|
|
103
|
+
task_meta_key, created_at_key, poll_interval_key
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Decode and validate task_key
|
|
107
|
+
task_key = task_key_bytes.decode("utf-8") if task_key_bytes else None
|
|
108
|
+
if not task_key:
|
|
109
|
+
raise McpError(
|
|
110
|
+
ErrorData(code=INVALID_PARAMS, message=f"Task {client_task_id} not found")
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Get execution
|
|
114
|
+
execution = await docket.get_execution(task_key)
|
|
115
|
+
if not execution:
|
|
116
|
+
raise McpError(
|
|
117
|
+
ErrorData(
|
|
118
|
+
code=INVALID_PARAMS,
|
|
119
|
+
message=f"Task {client_task_id} execution not found",
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Parse metadata with defaults
|
|
124
|
+
created_at = created_at_bytes.decode("utf-8") if created_at_bytes else None
|
|
125
|
+
try:
|
|
126
|
+
poll_interval_ms = (
|
|
127
|
+
int(poll_interval_bytes.decode("utf-8"))
|
|
128
|
+
if poll_interval_bytes
|
|
129
|
+
else DEFAULT_POLL_INTERVAL_MS
|
|
130
|
+
)
|
|
131
|
+
except (ValueError, UnicodeDecodeError):
|
|
132
|
+
poll_interval_ms = DEFAULT_POLL_INTERVAL_MS
|
|
133
|
+
|
|
134
|
+
return execution, created_at, poll_interval_ms
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def tasks_get_handler(server: FastMCP, params: dict[str, Any]) -> GetTaskResult:
|
|
138
|
+
"""Handle MCP 'tasks/get' request (SEP-1686).
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
server: FastMCP server instance
|
|
142
|
+
params: Request params containing taskId
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
GetTaskResult: Task status response with spec-compliant fields
|
|
146
|
+
"""
|
|
147
|
+
async with fastmcp.server.context.Context(fastmcp=server) as ctx:
|
|
148
|
+
client_task_id = params.get("taskId")
|
|
149
|
+
if not client_task_id:
|
|
150
|
+
raise McpError(
|
|
151
|
+
ErrorData(
|
|
152
|
+
code=INVALID_PARAMS, message="Missing required parameter: taskId"
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Get session ID from Context
|
|
157
|
+
session_id = ctx.session_id
|
|
158
|
+
|
|
159
|
+
# Get Docket instance
|
|
160
|
+
docket = server._docket
|
|
161
|
+
if docket is None:
|
|
162
|
+
raise McpError(
|
|
163
|
+
ErrorData(
|
|
164
|
+
code=INTERNAL_ERROR,
|
|
165
|
+
message="Background tasks require Docket",
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Look up task execution and metadata
|
|
170
|
+
execution, created_at, poll_interval_ms = await _lookup_task_execution(
|
|
171
|
+
docket, session_id, client_task_id
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Sync state from Redis
|
|
175
|
+
await execution.sync()
|
|
176
|
+
|
|
177
|
+
# Map Docket state to MCP state
|
|
178
|
+
state_map = DOCKET_TO_MCP_STATE
|
|
179
|
+
mcp_state: Literal[
|
|
180
|
+
"working", "input_required", "completed", "failed", "cancelled"
|
|
181
|
+
] = state_map.get(execution.state, "failed") # type: ignore[assignment]
|
|
182
|
+
|
|
183
|
+
# Build response (use default ttl since we don't track per-task values)
|
|
184
|
+
# createdAt is REQUIRED per SEP-1686 final spec (line 430)
|
|
185
|
+
# Per spec lines 447-448: SHOULD NOT include related-task metadata in tasks/get
|
|
186
|
+
error_message = None
|
|
187
|
+
status_message = None
|
|
188
|
+
|
|
189
|
+
if execution.state == ExecutionState.FAILED:
|
|
190
|
+
try:
|
|
191
|
+
await execution.get_result(timeout=timedelta(seconds=0))
|
|
192
|
+
except Exception as error:
|
|
193
|
+
error_message = str(error)
|
|
194
|
+
status_message = f"Task failed: {error_message}"
|
|
195
|
+
elif execution.progress and execution.progress.message:
|
|
196
|
+
# Extract progress message from Docket if available (spec line 403)
|
|
197
|
+
status_message = execution.progress.message
|
|
198
|
+
|
|
199
|
+
# createdAt is required per spec, but can be None from Redis
|
|
200
|
+
# Parse ISO string to datetime, or use current time as fallback
|
|
201
|
+
if created_at:
|
|
202
|
+
try:
|
|
203
|
+
created_at_dt = datetime.fromisoformat(
|
|
204
|
+
created_at.replace("Z", "+00:00")
|
|
205
|
+
)
|
|
206
|
+
except (ValueError, AttributeError):
|
|
207
|
+
created_at_dt = datetime.now(timezone.utc)
|
|
208
|
+
else:
|
|
209
|
+
created_at_dt = datetime.now(timezone.utc)
|
|
210
|
+
|
|
211
|
+
return GetTaskResult(
|
|
212
|
+
taskId=client_task_id,
|
|
213
|
+
status=mcp_state,
|
|
214
|
+
createdAt=created_at_dt,
|
|
215
|
+
lastUpdatedAt=datetime.now(timezone.utc),
|
|
216
|
+
ttl=DEFAULT_TTL_MS,
|
|
217
|
+
pollInterval=poll_interval_ms,
|
|
218
|
+
statusMessage=status_message,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
|
|
223
|
+
"""Handle MCP 'tasks/result' request (SEP-1686).
|
|
224
|
+
|
|
225
|
+
Converts raw task return values to MCP types based on task type.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
server: FastMCP server instance
|
|
229
|
+
params: Request params containing taskId
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
MCP result (CallToolResult, GetPromptResult, or ReadResourceResult)
|
|
233
|
+
"""
|
|
234
|
+
async with fastmcp.server.context.Context(fastmcp=server) as ctx:
|
|
235
|
+
client_task_id = params.get("taskId")
|
|
236
|
+
if not client_task_id:
|
|
237
|
+
raise McpError(
|
|
238
|
+
ErrorData(
|
|
239
|
+
code=INVALID_PARAMS, message="Missing required parameter: taskId"
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Get session ID from Context
|
|
244
|
+
session_id = ctx.session_id
|
|
245
|
+
|
|
246
|
+
# Get execution from Docket (use instance attribute for cross-task access)
|
|
247
|
+
docket = server._docket
|
|
248
|
+
if docket is None:
|
|
249
|
+
raise McpError(
|
|
250
|
+
ErrorData(
|
|
251
|
+
code=INTERNAL_ERROR,
|
|
252
|
+
message="Background tasks require Docket",
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# Look up full task key from Redis
|
|
257
|
+
task_meta_key = docket.key(f"fastmcp:task:{session_id}:{client_task_id}")
|
|
258
|
+
async with docket.redis() as redis:
|
|
259
|
+
task_key_bytes = await redis.get(task_meta_key)
|
|
260
|
+
|
|
261
|
+
task_key = None if task_key_bytes is None else task_key_bytes.decode("utf-8")
|
|
262
|
+
|
|
263
|
+
if task_key is None:
|
|
264
|
+
raise McpError(
|
|
265
|
+
ErrorData(
|
|
266
|
+
code=INVALID_PARAMS,
|
|
267
|
+
message=f"Invalid taskId: {client_task_id} not found",
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
execution = await docket.get_execution(task_key)
|
|
272
|
+
if execution is None:
|
|
273
|
+
raise McpError(
|
|
274
|
+
ErrorData(
|
|
275
|
+
code=INVALID_PARAMS,
|
|
276
|
+
message=f"Invalid taskId: {client_task_id} not found",
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Sync state from Redis
|
|
281
|
+
await execution.sync()
|
|
282
|
+
|
|
283
|
+
# Check if completed
|
|
284
|
+
state_map = DOCKET_TO_MCP_STATE
|
|
285
|
+
if execution.state not in (ExecutionState.COMPLETED, ExecutionState.FAILED):
|
|
286
|
+
mcp_state = state_map.get(execution.state, "failed")
|
|
287
|
+
raise McpError(
|
|
288
|
+
ErrorData(
|
|
289
|
+
code=INVALID_PARAMS,
|
|
290
|
+
message=f"Task not completed yet (current state: {mcp_state})",
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# Get result from Docket
|
|
295
|
+
try:
|
|
296
|
+
raw_value = await execution.get_result(timeout=timedelta(seconds=0))
|
|
297
|
+
except Exception as error:
|
|
298
|
+
# Task failed - return error result
|
|
299
|
+
return mcp.types.CallToolResult(
|
|
300
|
+
content=[mcp.types.TextContent(type="text", text=str(error))],
|
|
301
|
+
isError=True,
|
|
302
|
+
_meta={ # type: ignore[call-arg] # _meta is Pydantic alias for meta field
|
|
303
|
+
"modelcontextprotocol.io/related-task": {
|
|
304
|
+
"taskId": client_task_id,
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# Parse task key to get component key
|
|
310
|
+
key_parts = parse_task_key(task_key)
|
|
311
|
+
component_key = key_parts["component_identifier"]
|
|
312
|
+
|
|
313
|
+
# Look up component by its prefixed key (inlined from deleted get_component)
|
|
314
|
+
component: Tool | Resource | ResourceTemplate | Prompt | None = None
|
|
315
|
+
try:
|
|
316
|
+
if component_key.startswith("tool:"):
|
|
317
|
+
name, version_str = _parse_key_version(component_key[5:])
|
|
318
|
+
version = VersionSpec(eq=version_str) if version_str else None
|
|
319
|
+
component = await server.get_tool(name, version)
|
|
320
|
+
elif component_key.startswith("resource:"):
|
|
321
|
+
uri, version_str = _parse_key_version(component_key[9:])
|
|
322
|
+
version = VersionSpec(eq=version_str) if version_str else None
|
|
323
|
+
component = await server.get_resource(uri, version)
|
|
324
|
+
elif component_key.startswith("template:"):
|
|
325
|
+
uri, version_str = _parse_key_version(component_key[9:])
|
|
326
|
+
version = VersionSpec(eq=version_str) if version_str else None
|
|
327
|
+
component = await server.get_resource_template(uri, version)
|
|
328
|
+
elif component_key.startswith("prompt:"):
|
|
329
|
+
name, version_str = _parse_key_version(component_key[7:])
|
|
330
|
+
version = VersionSpec(eq=version_str) if version_str else None
|
|
331
|
+
component = await server.get_prompt(name, version)
|
|
332
|
+
except NotFoundError:
|
|
333
|
+
component = None
|
|
334
|
+
|
|
335
|
+
if component is None:
|
|
336
|
+
raise McpError(
|
|
337
|
+
ErrorData(
|
|
338
|
+
code=INTERNAL_ERROR,
|
|
339
|
+
message=f"Component not found for task: {component_key}",
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# Build related-task metadata
|
|
344
|
+
related_task_meta = {
|
|
345
|
+
"modelcontextprotocol.io/related-task": {
|
|
346
|
+
"taskId": client_task_id,
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
# Convert based on component type
|
|
351
|
+
if isinstance(component, Tool):
|
|
352
|
+
fastmcp_result = component.convert_result(raw_value)
|
|
353
|
+
mcp_result = fastmcp_result.to_mcp_result()
|
|
354
|
+
# Ensure we have a CallToolResult and add metadata
|
|
355
|
+
if isinstance(mcp_result, mcp.types.CallToolResult):
|
|
356
|
+
mcp_result._meta = related_task_meta # type: ignore[attr-defined]
|
|
357
|
+
elif isinstance(mcp_result, tuple):
|
|
358
|
+
content, structured_content = mcp_result
|
|
359
|
+
mcp_result = mcp.types.CallToolResult(
|
|
360
|
+
content=content,
|
|
361
|
+
structuredContent=structured_content,
|
|
362
|
+
_meta=related_task_meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field
|
|
363
|
+
)
|
|
364
|
+
else:
|
|
365
|
+
mcp_result = mcp.types.CallToolResult(
|
|
366
|
+
content=mcp_result,
|
|
367
|
+
_meta=related_task_meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field
|
|
368
|
+
)
|
|
369
|
+
return mcp_result
|
|
370
|
+
|
|
371
|
+
elif isinstance(component, Prompt):
|
|
372
|
+
fastmcp_result = component.convert_result(raw_value)
|
|
373
|
+
mcp_result = fastmcp_result.to_mcp_prompt_result()
|
|
374
|
+
mcp_result._meta = related_task_meta # type: ignore[attr-defined]
|
|
375
|
+
return mcp_result
|
|
376
|
+
|
|
377
|
+
elif isinstance(component, ResourceTemplate):
|
|
378
|
+
fastmcp_result = component.convert_result(raw_value)
|
|
379
|
+
mcp_result = fastmcp_result.to_mcp_result(component.uri_template)
|
|
380
|
+
mcp_result._meta = related_task_meta # type: ignore[attr-defined]
|
|
381
|
+
return mcp_result
|
|
382
|
+
|
|
383
|
+
elif isinstance(component, Resource):
|
|
384
|
+
fastmcp_result = component.convert_result(raw_value)
|
|
385
|
+
mcp_result = fastmcp_result.to_mcp_result(str(component.uri))
|
|
386
|
+
mcp_result._meta = related_task_meta # type: ignore[attr-defined]
|
|
387
|
+
return mcp_result
|
|
388
|
+
|
|
389
|
+
else:
|
|
390
|
+
raise McpError(
|
|
391
|
+
ErrorData(
|
|
392
|
+
code=INTERNAL_ERROR,
|
|
393
|
+
message=f"Internal error: Unknown component type: {type(component).__name__}",
|
|
394
|
+
)
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
async def tasks_list_handler(
|
|
399
|
+
server: FastMCP, params: dict[str, Any]
|
|
400
|
+
) -> ListTasksResult:
|
|
401
|
+
"""Handle MCP 'tasks/list' request (SEP-1686).
|
|
402
|
+
|
|
403
|
+
Note: With client-side tracking, this returns minimal info.
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
server: FastMCP server instance
|
|
407
|
+
params: Request params (cursor, limit)
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
ListTasksResult: Response with tasks list and pagination
|
|
411
|
+
"""
|
|
412
|
+
# Return empty list - client tracks tasks locally
|
|
413
|
+
return ListTasksResult(tasks=[], nextCursor=None)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
async def tasks_cancel_handler(
|
|
417
|
+
server: FastMCP, params: dict[str, Any]
|
|
418
|
+
) -> CancelTaskResult:
|
|
419
|
+
"""Handle MCP 'tasks/cancel' request (SEP-1686).
|
|
420
|
+
|
|
421
|
+
Cancels a running task, transitioning it to cancelled state.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
server: FastMCP server instance
|
|
425
|
+
params: Request params containing taskId
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
CancelTaskResult: Task status response showing cancelled state
|
|
429
|
+
"""
|
|
430
|
+
async with fastmcp.server.context.Context(fastmcp=server) as ctx:
|
|
431
|
+
client_task_id = params.get("taskId")
|
|
432
|
+
if not client_task_id:
|
|
433
|
+
raise McpError(
|
|
434
|
+
ErrorData(
|
|
435
|
+
code=INVALID_PARAMS, message="Missing required parameter: taskId"
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
# Get session ID from Context
|
|
440
|
+
session_id = ctx.session_id
|
|
441
|
+
|
|
442
|
+
# Get Docket instance
|
|
443
|
+
docket = server._docket
|
|
444
|
+
if docket is None:
|
|
445
|
+
raise McpError(
|
|
446
|
+
ErrorData(
|
|
447
|
+
code=INTERNAL_ERROR,
|
|
448
|
+
message="Background tasks require Docket",
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
# Look up task execution and metadata
|
|
453
|
+
execution, created_at, poll_interval_ms = await _lookup_task_execution(
|
|
454
|
+
docket, session_id, client_task_id
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
# Cancel via Docket (now sets CANCELLED state natively)
|
|
458
|
+
# Note: We need to get task_key from execution.key for cancellation
|
|
459
|
+
await docket.cancel(execution.key)
|
|
460
|
+
|
|
461
|
+
# Return task status with cancelled state
|
|
462
|
+
# createdAt is REQUIRED per SEP-1686 final spec (line 430)
|
|
463
|
+
# Per spec lines 447-448: SHOULD NOT include related-task metadata in tasks/cancel
|
|
464
|
+
return CancelTaskResult(
|
|
465
|
+
taskId=client_task_id,
|
|
466
|
+
status="cancelled",
|
|
467
|
+
createdAt=datetime.fromisoformat(created_at)
|
|
468
|
+
if created_at
|
|
469
|
+
else datetime.now(timezone.utc),
|
|
470
|
+
lastUpdatedAt=datetime.now(timezone.utc),
|
|
471
|
+
ttl=DEFAULT_TTL_MS,
|
|
472
|
+
pollInterval=poll_interval_ms,
|
|
473
|
+
statusMessage="Task cancelled",
|
|
474
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Task routing helper for MCP components.
|
|
2
|
+
|
|
3
|
+
Provides unified task mode enforcement and docket routing logic.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
9
|
+
|
|
10
|
+
import mcp.types
|
|
11
|
+
from mcp.shared.exceptions import McpError
|
|
12
|
+
from mcp.types import METHOD_NOT_FOUND, ErrorData
|
|
13
|
+
|
|
14
|
+
from fastmcp.server.tasks.config import TaskMeta
|
|
15
|
+
from fastmcp.server.tasks.handlers import submit_to_docket
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from fastmcp.prompts.prompt import Prompt
|
|
19
|
+
from fastmcp.resources.resource import Resource
|
|
20
|
+
from fastmcp.resources.template import ResourceTemplate
|
|
21
|
+
from fastmcp.tools.tool import Tool
|
|
22
|
+
|
|
23
|
+
TaskType = Literal["tool", "resource", "template", "prompt"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def check_background_task(
|
|
27
|
+
component: Tool | Resource | ResourceTemplate | Prompt,
|
|
28
|
+
task_type: TaskType,
|
|
29
|
+
arguments: dict[str, Any] | None = None,
|
|
30
|
+
task_meta: TaskMeta | None = None,
|
|
31
|
+
) -> mcp.types.CreateTaskResult | None:
|
|
32
|
+
"""Check task mode and submit to background if requested.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
component: The MCP component
|
|
36
|
+
task_type: Type of task ("tool", "resource", "template", "prompt")
|
|
37
|
+
arguments: Arguments for tool/prompt/template execution
|
|
38
|
+
task_meta: Task execution metadata. If provided, execute as background task.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
CreateTaskResult if submitted to docket, None for sync execution
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
McpError: If mode="required" but no task metadata, or mode="forbidden"
|
|
45
|
+
but task metadata is present
|
|
46
|
+
"""
|
|
47
|
+
task_config = component.task_config
|
|
48
|
+
|
|
49
|
+
# Infer label from component
|
|
50
|
+
entity_label = f"{type(component).__name__} '{component.title or component.key}'"
|
|
51
|
+
|
|
52
|
+
# Enforce mode="required" - must have task metadata
|
|
53
|
+
if task_config.mode == "required" and not task_meta:
|
|
54
|
+
raise McpError(
|
|
55
|
+
ErrorData(
|
|
56
|
+
code=METHOD_NOT_FOUND,
|
|
57
|
+
message=f"{entity_label} requires task-augmented execution",
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Enforce mode="forbidden" - cannot be called with task metadata
|
|
62
|
+
if not task_config.supports_tasks() and task_meta:
|
|
63
|
+
raise McpError(
|
|
64
|
+
ErrorData(
|
|
65
|
+
code=METHOD_NOT_FOUND,
|
|
66
|
+
message=f"{entity_label} does not support task-augmented execution",
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# No task metadata - synchronous execution
|
|
71
|
+
if not task_meta:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
# fn_key is expected to be set; fall back to component.key for direct calls
|
|
75
|
+
fn_key = task_meta.fn_key or component.key
|
|
76
|
+
return await submit_to_docket(task_type, fn_key, component, arguments, task_meta)
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Subscribes to Docket execution state changes and sends notifications/tasks/status
|
|
4
4
|
to clients when their tasks change state.
|
|
5
|
+
|
|
6
|
+
This module requires fastmcp[tasks] (pydocket). It is only imported when docket is available.
|
|
5
7
|
"""
|
|
6
8
|
|
|
7
9
|
from __future__ import annotations
|
|
@@ -13,7 +15,8 @@ from typing import TYPE_CHECKING
|
|
|
13
15
|
from docket.execution import ExecutionState
|
|
14
16
|
from mcp.types import TaskStatusNotification, TaskStatusNotificationParams
|
|
15
17
|
|
|
16
|
-
from fastmcp.server.tasks.
|
|
18
|
+
from fastmcp.server.tasks.keys import parse_task_key
|
|
19
|
+
from fastmcp.server.tasks.requests import DOCKET_TO_MCP_STATE
|
|
17
20
|
from fastmcp.utilities.logging import get_logger
|
|
18
21
|
|
|
19
22
|
if TYPE_CHECKING:
|
|
@@ -29,6 +32,7 @@ async def subscribe_to_task_updates(
|
|
|
29
32
|
task_key: str,
|
|
30
33
|
session: ServerSession,
|
|
31
34
|
docket: Docket,
|
|
35
|
+
poll_interval_ms: int = 5000,
|
|
32
36
|
) -> None:
|
|
33
37
|
"""Subscribe to Docket execution events and send MCP notifications.
|
|
34
38
|
|
|
@@ -41,6 +45,7 @@ async def subscribe_to_task_updates(
|
|
|
41
45
|
task_key: Internal Docket execution key (includes session, type, component)
|
|
42
46
|
session: MCP ServerSession for sending notifications
|
|
43
47
|
docket: Docket instance for subscribing to execution events
|
|
48
|
+
poll_interval_ms: Poll interval in milliseconds to include in notifications
|
|
44
49
|
"""
|
|
45
50
|
try:
|
|
46
51
|
execution = await docket.get_execution(task_key)
|
|
@@ -57,7 +62,8 @@ async def subscribe_to_task_updates(
|
|
|
57
62
|
task_id=task_id,
|
|
58
63
|
task_key=task_key,
|
|
59
64
|
docket=docket,
|
|
60
|
-
state=event["state"],
|
|
65
|
+
state=event["state"],
|
|
66
|
+
poll_interval_ms=poll_interval_ms,
|
|
61
67
|
)
|
|
62
68
|
elif event["type"] == "progress":
|
|
63
69
|
# Send notification when progress message changes
|
|
@@ -67,10 +73,11 @@ async def subscribe_to_task_updates(
|
|
|
67
73
|
task_key=task_key,
|
|
68
74
|
docket=docket,
|
|
69
75
|
execution=execution,
|
|
76
|
+
poll_interval_ms=poll_interval_ms,
|
|
70
77
|
)
|
|
71
78
|
|
|
72
79
|
except Exception as e:
|
|
73
|
-
logger.
|
|
80
|
+
logger.warning(f"Subscription task failed for {task_id}: {e}", exc_info=True)
|
|
74
81
|
|
|
75
82
|
|
|
76
83
|
async def _send_status_notification(
|
|
@@ -79,6 +86,7 @@ async def _send_status_notification(
|
|
|
79
86
|
task_key: str,
|
|
80
87
|
docket: Docket,
|
|
81
88
|
state: ExecutionState,
|
|
89
|
+
poll_interval_ms: int = 5000,
|
|
82
90
|
) -> None:
|
|
83
91
|
"""Send notifications/tasks/status to client.
|
|
84
92
|
|
|
@@ -91,13 +99,13 @@ async def _send_status_notification(
|
|
|
91
99
|
task_key: Internal task key (for metadata lookup)
|
|
92
100
|
docket: Docket instance
|
|
93
101
|
state: Docket execution state (enum)
|
|
102
|
+
poll_interval_ms: Poll interval in milliseconds
|
|
94
103
|
"""
|
|
95
104
|
# Map Docket state to MCP status
|
|
96
|
-
|
|
105
|
+
state_map = DOCKET_TO_MCP_STATE
|
|
106
|
+
mcp_status = state_map.get(state, "failed")
|
|
97
107
|
|
|
98
108
|
# Extract session_id from task_key for Redis lookup
|
|
99
|
-
from fastmcp.server.tasks.keys import parse_task_key
|
|
100
|
-
|
|
101
109
|
key_parts = parse_task_key(task_key)
|
|
102
110
|
session_id = key_parts["session_id"]
|
|
103
111
|
|
|
@@ -126,7 +134,7 @@ async def _send_status_notification(
|
|
|
126
134
|
"createdAt": created_at,
|
|
127
135
|
"lastUpdatedAt": datetime.now(timezone.utc).isoformat(),
|
|
128
136
|
"ttl": 60000,
|
|
129
|
-
"pollInterval":
|
|
137
|
+
"pollInterval": poll_interval_ms,
|
|
130
138
|
}
|
|
131
139
|
|
|
132
140
|
if status_message:
|
|
@@ -148,6 +156,7 @@ async def _send_progress_notification(
|
|
|
148
156
|
task_key: str,
|
|
149
157
|
docket: Docket,
|
|
150
158
|
execution: Execution,
|
|
159
|
+
poll_interval_ms: int = 5000,
|
|
151
160
|
) -> None:
|
|
152
161
|
"""Send notifications/tasks/status when progress updates.
|
|
153
162
|
|
|
@@ -157,6 +166,7 @@ async def _send_progress_notification(
|
|
|
157
166
|
task_key: Internal task key
|
|
158
167
|
docket: Docket instance
|
|
159
168
|
execution: Execution object with current progress
|
|
169
|
+
poll_interval_ms: Poll interval in milliseconds
|
|
160
170
|
"""
|
|
161
171
|
# Sync execution to get latest progress
|
|
162
172
|
await execution.sync()
|
|
@@ -166,11 +176,10 @@ async def _send_progress_notification(
|
|
|
166
176
|
return
|
|
167
177
|
|
|
168
178
|
# Map Docket state to MCP status
|
|
169
|
-
|
|
179
|
+
state_map = DOCKET_TO_MCP_STATE
|
|
180
|
+
mcp_status = state_map.get(execution.state, "failed")
|
|
170
181
|
|
|
171
182
|
# Extract session_id from task_key for Redis lookup
|
|
172
|
-
from fastmcp.server.tasks.keys import parse_task_key
|
|
173
|
-
|
|
174
183
|
key_parts = parse_task_key(task_key)
|
|
175
184
|
session_id = key_parts["session_id"]
|
|
176
185
|
|
|
@@ -190,7 +199,7 @@ async def _send_progress_notification(
|
|
|
190
199
|
"createdAt": created_at,
|
|
191
200
|
"lastUpdatedAt": datetime.now(timezone.utc).isoformat(),
|
|
192
201
|
"ttl": 60000,
|
|
193
|
-
"pollInterval":
|
|
202
|
+
"pollInterval": poll_interval_ms,
|
|
194
203
|
"statusMessage": execution.progress.message,
|
|
195
204
|
}
|
|
196
205
|
|