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.
Files changed (175) hide show
  1. fastmcp/_vendor/__init__.py +1 -0
  2. fastmcp/_vendor/docket_di/README.md +7 -0
  3. fastmcp/_vendor/docket_di/__init__.py +163 -0
  4. fastmcp/cli/cli.py +112 -28
  5. fastmcp/cli/install/claude_code.py +1 -5
  6. fastmcp/cli/install/claude_desktop.py +1 -5
  7. fastmcp/cli/install/cursor.py +1 -5
  8. fastmcp/cli/install/gemini_cli.py +1 -5
  9. fastmcp/cli/install/mcp_json.py +1 -6
  10. fastmcp/cli/run.py +146 -5
  11. fastmcp/client/__init__.py +7 -9
  12. fastmcp/client/auth/oauth.py +18 -17
  13. fastmcp/client/client.py +100 -870
  14. fastmcp/client/elicitation.py +1 -1
  15. fastmcp/client/mixins/__init__.py +13 -0
  16. fastmcp/client/mixins/prompts.py +295 -0
  17. fastmcp/client/mixins/resources.py +325 -0
  18. fastmcp/client/mixins/task_management.py +157 -0
  19. fastmcp/client/mixins/tools.py +397 -0
  20. fastmcp/client/sampling/handlers/anthropic.py +2 -2
  21. fastmcp/client/sampling/handlers/openai.py +1 -1
  22. fastmcp/client/tasks.py +3 -3
  23. fastmcp/client/telemetry.py +47 -0
  24. fastmcp/client/transports/__init__.py +38 -0
  25. fastmcp/client/transports/base.py +82 -0
  26. fastmcp/client/transports/config.py +170 -0
  27. fastmcp/client/transports/http.py +145 -0
  28. fastmcp/client/transports/inference.py +154 -0
  29. fastmcp/client/transports/memory.py +90 -0
  30. fastmcp/client/transports/sse.py +89 -0
  31. fastmcp/client/transports/stdio.py +543 -0
  32. fastmcp/contrib/component_manager/README.md +4 -10
  33. fastmcp/contrib/component_manager/__init__.py +1 -2
  34. fastmcp/contrib/component_manager/component_manager.py +95 -160
  35. fastmcp/contrib/component_manager/example.py +1 -1
  36. fastmcp/contrib/mcp_mixin/example.py +4 -4
  37. fastmcp/contrib/mcp_mixin/mcp_mixin.py +11 -4
  38. fastmcp/decorators.py +41 -0
  39. fastmcp/dependencies.py +12 -1
  40. fastmcp/exceptions.py +4 -0
  41. fastmcp/experimental/server/openapi/__init__.py +18 -15
  42. fastmcp/mcp_config.py +13 -4
  43. fastmcp/prompts/__init__.py +6 -3
  44. fastmcp/prompts/function_prompt.py +465 -0
  45. fastmcp/prompts/prompt.py +321 -271
  46. fastmcp/resources/__init__.py +5 -3
  47. fastmcp/resources/function_resource.py +335 -0
  48. fastmcp/resources/resource.py +325 -115
  49. fastmcp/resources/template.py +215 -43
  50. fastmcp/resources/types.py +27 -12
  51. fastmcp/server/__init__.py +2 -2
  52. fastmcp/server/auth/__init__.py +14 -0
  53. fastmcp/server/auth/auth.py +30 -10
  54. fastmcp/server/auth/authorization.py +190 -0
  55. fastmcp/server/auth/oauth_proxy/__init__.py +14 -0
  56. fastmcp/server/auth/oauth_proxy/consent.py +361 -0
  57. fastmcp/server/auth/oauth_proxy/models.py +178 -0
  58. fastmcp/server/auth/{oauth_proxy.py → oauth_proxy/proxy.py} +24 -778
  59. fastmcp/server/auth/oauth_proxy/ui.py +277 -0
  60. fastmcp/server/auth/oidc_proxy.py +2 -2
  61. fastmcp/server/auth/providers/auth0.py +24 -94
  62. fastmcp/server/auth/providers/aws.py +26 -95
  63. fastmcp/server/auth/providers/azure.py +41 -129
  64. fastmcp/server/auth/providers/descope.py +18 -49
  65. fastmcp/server/auth/providers/discord.py +25 -86
  66. fastmcp/server/auth/providers/github.py +23 -87
  67. fastmcp/server/auth/providers/google.py +24 -87
  68. fastmcp/server/auth/providers/introspection.py +60 -79
  69. fastmcp/server/auth/providers/jwt.py +30 -67
  70. fastmcp/server/auth/providers/oci.py +47 -110
  71. fastmcp/server/auth/providers/scalekit.py +23 -61
  72. fastmcp/server/auth/providers/supabase.py +18 -47
  73. fastmcp/server/auth/providers/workos.py +34 -127
  74. fastmcp/server/context.py +372 -419
  75. fastmcp/server/dependencies.py +541 -251
  76. fastmcp/server/elicitation.py +20 -18
  77. fastmcp/server/event_store.py +3 -3
  78. fastmcp/server/http.py +16 -6
  79. fastmcp/server/lifespan.py +198 -0
  80. fastmcp/server/low_level.py +92 -2
  81. fastmcp/server/middleware/__init__.py +5 -1
  82. fastmcp/server/middleware/authorization.py +312 -0
  83. fastmcp/server/middleware/caching.py +101 -54
  84. fastmcp/server/middleware/middleware.py +6 -9
  85. fastmcp/server/middleware/ping.py +70 -0
  86. fastmcp/server/middleware/tool_injection.py +2 -2
  87. fastmcp/server/mixins/__init__.py +7 -0
  88. fastmcp/server/mixins/lifespan.py +217 -0
  89. fastmcp/server/mixins/mcp_operations.py +392 -0
  90. fastmcp/server/mixins/transport.py +342 -0
  91. fastmcp/server/openapi/__init__.py +41 -21
  92. fastmcp/server/openapi/components.py +16 -339
  93. fastmcp/server/openapi/routing.py +34 -118
  94. fastmcp/server/openapi/server.py +67 -392
  95. fastmcp/server/providers/__init__.py +71 -0
  96. fastmcp/server/providers/aggregate.py +261 -0
  97. fastmcp/server/providers/base.py +578 -0
  98. fastmcp/server/providers/fastmcp_provider.py +674 -0
  99. fastmcp/server/providers/filesystem.py +226 -0
  100. fastmcp/server/providers/filesystem_discovery.py +327 -0
  101. fastmcp/server/providers/local_provider/__init__.py +11 -0
  102. fastmcp/server/providers/local_provider/decorators/__init__.py +15 -0
  103. fastmcp/server/providers/local_provider/decorators/prompts.py +256 -0
  104. fastmcp/server/providers/local_provider/decorators/resources.py +240 -0
  105. fastmcp/server/providers/local_provider/decorators/tools.py +315 -0
  106. fastmcp/server/providers/local_provider/local_provider.py +465 -0
  107. fastmcp/server/providers/openapi/__init__.py +39 -0
  108. fastmcp/server/providers/openapi/components.py +332 -0
  109. fastmcp/server/providers/openapi/provider.py +405 -0
  110. fastmcp/server/providers/openapi/routing.py +109 -0
  111. fastmcp/server/providers/proxy.py +867 -0
  112. fastmcp/server/providers/skills/__init__.py +59 -0
  113. fastmcp/server/providers/skills/_common.py +101 -0
  114. fastmcp/server/providers/skills/claude_provider.py +44 -0
  115. fastmcp/server/providers/skills/directory_provider.py +153 -0
  116. fastmcp/server/providers/skills/skill_provider.py +432 -0
  117. fastmcp/server/providers/skills/vendor_providers.py +142 -0
  118. fastmcp/server/providers/wrapped_provider.py +140 -0
  119. fastmcp/server/proxy.py +34 -700
  120. fastmcp/server/sampling/run.py +341 -2
  121. fastmcp/server/sampling/sampling_tool.py +4 -3
  122. fastmcp/server/server.py +1214 -2171
  123. fastmcp/server/tasks/__init__.py +2 -1
  124. fastmcp/server/tasks/capabilities.py +13 -1
  125. fastmcp/server/tasks/config.py +66 -3
  126. fastmcp/server/tasks/handlers.py +65 -273
  127. fastmcp/server/tasks/keys.py +4 -6
  128. fastmcp/server/tasks/requests.py +474 -0
  129. fastmcp/server/tasks/routing.py +76 -0
  130. fastmcp/server/tasks/subscriptions.py +20 -11
  131. fastmcp/server/telemetry.py +131 -0
  132. fastmcp/server/transforms/__init__.py +244 -0
  133. fastmcp/server/transforms/namespace.py +193 -0
  134. fastmcp/server/transforms/prompts_as_tools.py +175 -0
  135. fastmcp/server/transforms/resources_as_tools.py +190 -0
  136. fastmcp/server/transforms/tool_transform.py +96 -0
  137. fastmcp/server/transforms/version_filter.py +124 -0
  138. fastmcp/server/transforms/visibility.py +526 -0
  139. fastmcp/settings.py +34 -96
  140. fastmcp/telemetry.py +122 -0
  141. fastmcp/tools/__init__.py +10 -3
  142. fastmcp/tools/function_parsing.py +201 -0
  143. fastmcp/tools/function_tool.py +467 -0
  144. fastmcp/tools/tool.py +215 -362
  145. fastmcp/tools/tool_transform.py +38 -21
  146. fastmcp/utilities/async_utils.py +69 -0
  147. fastmcp/utilities/components.py +152 -91
  148. fastmcp/utilities/inspect.py +8 -20
  149. fastmcp/utilities/json_schema.py +12 -5
  150. fastmcp/utilities/json_schema_type.py +17 -15
  151. fastmcp/utilities/lifespan.py +56 -0
  152. fastmcp/utilities/logging.py +12 -4
  153. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +3 -3
  154. fastmcp/utilities/openapi/parser.py +3 -3
  155. fastmcp/utilities/pagination.py +80 -0
  156. fastmcp/utilities/skills.py +253 -0
  157. fastmcp/utilities/tests.py +0 -16
  158. fastmcp/utilities/timeout.py +47 -0
  159. fastmcp/utilities/types.py +1 -1
  160. fastmcp/utilities/versions.py +285 -0
  161. {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/METADATA +8 -5
  162. fastmcp-3.0.0b1.dist-info/RECORD +228 -0
  163. fastmcp/client/transports.py +0 -1170
  164. fastmcp/contrib/component_manager/component_service.py +0 -209
  165. fastmcp/prompts/prompt_manager.py +0 -117
  166. fastmcp/resources/resource_manager.py +0 -338
  167. fastmcp/server/tasks/converters.py +0 -206
  168. fastmcp/server/tasks/protocol.py +0 -359
  169. fastmcp/tools/tool_manager.py +0 -170
  170. fastmcp/utilities/mcp_config.py +0 -56
  171. fastmcp-2.14.4.dist-info/RECORD +0 -161
  172. /fastmcp/server/{openapi → providers/openapi}/README.md +0 -0
  173. {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/WHEEL +0 -0
  174. {fastmcp-2.14.4.dist-info → fastmcp-3.0.0b1.dist-info}/entry_points.txt +0 -0
  175. {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.protocol import DOCKET_TO_MCP_STATE
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"], # type: ignore[typeddict-item]
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.error(f"subscribe_to_task_updates failed for {task_id}: {e}")
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
- mcp_status = DOCKET_TO_MCP_STATE.get(state, "failed")
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": 1000,
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
- mcp_status = DOCKET_TO_MCP_STATE.get(execution.state, "failed")
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": 1000,
202
+ "pollInterval": poll_interval_ms,
194
203
  "statusMessage": execution.progress.message,
195
204
  }
196
205