fastmcp 2.13.3__py3-none-any.whl → 2.14.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. fastmcp/__init__.py +0 -21
  2. fastmcp/cli/__init__.py +0 -3
  3. fastmcp/cli/__main__.py +5 -0
  4. fastmcp/cli/cli.py +8 -22
  5. fastmcp/cli/install/shared.py +0 -15
  6. fastmcp/cli/tasks.py +110 -0
  7. fastmcp/client/auth/oauth.py +9 -9
  8. fastmcp/client/client.py +739 -136
  9. fastmcp/client/elicitation.py +11 -5
  10. fastmcp/client/messages.py +7 -5
  11. fastmcp/client/roots.py +2 -1
  12. fastmcp/client/sampling/__init__.py +69 -0
  13. fastmcp/client/sampling/handlers/__init__.py +0 -0
  14. fastmcp/client/sampling/handlers/anthropic.py +387 -0
  15. fastmcp/client/sampling/handlers/openai.py +399 -0
  16. fastmcp/client/tasks.py +551 -0
  17. fastmcp/client/transports.py +72 -21
  18. fastmcp/contrib/component_manager/component_service.py +4 -20
  19. fastmcp/dependencies.py +25 -0
  20. fastmcp/experimental/sampling/handlers/__init__.py +5 -0
  21. fastmcp/experimental/sampling/handlers/openai.py +4 -169
  22. fastmcp/experimental/server/openapi/__init__.py +15 -13
  23. fastmcp/experimental/utilities/openapi/__init__.py +12 -38
  24. fastmcp/prompts/prompt.py +38 -38
  25. fastmcp/resources/resource.py +33 -16
  26. fastmcp/resources/template.py +69 -59
  27. fastmcp/server/auth/__init__.py +0 -9
  28. fastmcp/server/auth/auth.py +127 -3
  29. fastmcp/server/auth/oauth_proxy.py +47 -97
  30. fastmcp/server/auth/oidc_proxy.py +7 -0
  31. fastmcp/server/auth/providers/in_memory.py +2 -2
  32. fastmcp/server/auth/providers/oci.py +2 -2
  33. fastmcp/server/context.py +509 -180
  34. fastmcp/server/dependencies.py +464 -6
  35. fastmcp/server/elicitation.py +285 -47
  36. fastmcp/server/event_store.py +177 -0
  37. fastmcp/server/http.py +15 -3
  38. fastmcp/server/low_level.py +56 -12
  39. fastmcp/server/middleware/middleware.py +2 -2
  40. fastmcp/server/openapi/__init__.py +35 -0
  41. fastmcp/{experimental/server → server}/openapi/components.py +4 -3
  42. fastmcp/{experimental/server → server}/openapi/routing.py +1 -1
  43. fastmcp/{experimental/server → server}/openapi/server.py +6 -5
  44. fastmcp/server/proxy.py +53 -40
  45. fastmcp/server/sampling/__init__.py +10 -0
  46. fastmcp/server/sampling/run.py +301 -0
  47. fastmcp/server/sampling/sampling_tool.py +108 -0
  48. fastmcp/server/server.py +793 -552
  49. fastmcp/server/tasks/__init__.py +21 -0
  50. fastmcp/server/tasks/capabilities.py +22 -0
  51. fastmcp/server/tasks/config.py +89 -0
  52. fastmcp/server/tasks/converters.py +206 -0
  53. fastmcp/server/tasks/handlers.py +356 -0
  54. fastmcp/server/tasks/keys.py +93 -0
  55. fastmcp/server/tasks/protocol.py +355 -0
  56. fastmcp/server/tasks/subscriptions.py +205 -0
  57. fastmcp/settings.py +101 -103
  58. fastmcp/tools/tool.py +83 -49
  59. fastmcp/tools/tool_transform.py +1 -12
  60. fastmcp/utilities/components.py +3 -3
  61. fastmcp/utilities/json_schema_type.py +4 -4
  62. fastmcp/utilities/mcp_config.py +1 -2
  63. fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +1 -1
  64. fastmcp/{experimental/utilities → utilities}/openapi/README.md +7 -35
  65. fastmcp/utilities/openapi/__init__.py +63 -0
  66. fastmcp/{experimental/utilities → utilities}/openapi/formatters.py +5 -5
  67. fastmcp/{experimental/utilities → utilities}/openapi/json_schema_converter.py +1 -1
  68. fastmcp/utilities/tests.py +11 -5
  69. fastmcp/utilities/types.py +8 -0
  70. {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/METADATA +7 -4
  71. {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/RECORD +79 -63
  72. fastmcp/client/sampling.py +0 -56
  73. fastmcp/experimental/sampling/handlers/base.py +0 -21
  74. fastmcp/server/auth/providers/bearer.py +0 -25
  75. fastmcp/server/openapi.py +0 -1087
  76. fastmcp/server/sampling/handler.py +0 -19
  77. fastmcp/utilities/openapi.py +0 -1568
  78. /fastmcp/{experimental/server → server}/openapi/README.md +0 -0
  79. /fastmcp/{experimental/utilities → utilities}/openapi/director.py +0 -0
  80. /fastmcp/{experimental/utilities → utilities}/openapi/models.py +0 -0
  81. /fastmcp/{experimental/utilities → utilities}/openapi/parser.py +0 -0
  82. /fastmcp/{experimental/utilities → utilities}/openapi/schemas.py +0 -0
  83. {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/WHEEL +0 -0
  84. {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/entry_points.txt +0 -0
  85. {fastmcp-2.13.3.dist-info → fastmcp-2.14.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,93 @@
1
+ """Task key management for SEP-1686 background tasks.
2
+
3
+ Task keys encode security scoping and metadata in the Docket key format:
4
+ {session_id}:{client_task_id}:{task_type}:{component_identifier}
5
+
6
+ This format provides:
7
+ - Session-based security scoping (prevents cross-session access)
8
+ - Task type identification (tool/prompt/resource)
9
+ - Component identification (name or URI for result conversion)
10
+ """
11
+
12
+ from urllib.parse import quote, unquote
13
+
14
+
15
+ def build_task_key(
16
+ session_id: str,
17
+ client_task_id: str,
18
+ task_type: str,
19
+ component_identifier: str,
20
+ ) -> str:
21
+ """Build Docket task key with embedded metadata.
22
+
23
+ Format: {session_id}:{client_task_id}:{task_type}:{component_identifier}
24
+
25
+ The component_identifier is URI-encoded to handle special characters (colons, slashes, etc.).
26
+
27
+ Args:
28
+ session_id: Session ID for security scoping
29
+ client_task_id: Client-provided task ID
30
+ task_type: Type of task ("tool", "prompt", "resource")
31
+ component_identifier: Tool name, prompt name, or resource URI
32
+
33
+ Returns:
34
+ Encoded task key for Docket
35
+
36
+ Examples:
37
+ >>> build_task_key("session123", "task456", "tool", "my_tool")
38
+ 'session123:task456:tool:my_tool'
39
+
40
+ >>> build_task_key("session123", "task456", "resource", "file://data.txt")
41
+ 'session123:task456:resource:file%3A%2F%2Fdata.txt'
42
+ """
43
+ encoded_identifier = quote(component_identifier, safe="")
44
+ return f"{session_id}:{client_task_id}:{task_type}:{encoded_identifier}"
45
+
46
+
47
+ def parse_task_key(task_key: str) -> dict[str, str]:
48
+ """Parse Docket task key to extract metadata.
49
+
50
+ Args:
51
+ task_key: Encoded task key from Docket
52
+
53
+ Returns:
54
+ Dict with keys: session_id, client_task_id, task_type, component_identifier
55
+
56
+ Examples:
57
+ >>> parse_task_key("session123:task456:tool:my_tool")
58
+ {'session_id': 'session123', 'client_task_id': 'task456',
59
+ 'task_type': 'tool', 'component_identifier': 'my_tool'}
60
+
61
+ >>> parse_task_key("session123:task456:resource:file%3A%2F%2Fdata.txt")
62
+ {'session_id': 'session123', 'client_task_id': 'task456',
63
+ 'task_type': 'resource', 'component_identifier': 'file://data.txt'}
64
+ """
65
+ parts = task_key.split(":", 3)
66
+ if len(parts) != 4:
67
+ raise ValueError(
68
+ f"Invalid task key format: {task_key}. "
69
+ f"Expected: {{session_id}}:{{client_task_id}}:{{task_type}}:{{component_identifier}}"
70
+ )
71
+
72
+ return {
73
+ "session_id": parts[0],
74
+ "client_task_id": parts[1],
75
+ "task_type": parts[2],
76
+ "component_identifier": unquote(parts[3]),
77
+ }
78
+
79
+
80
+ def get_client_task_id_from_key(task_key: str) -> str:
81
+ """Extract just the client task ID from a task key.
82
+
83
+ Args:
84
+ task_key: Full encoded task key
85
+
86
+ Returns:
87
+ Client-provided task ID (second segment)
88
+
89
+ Example:
90
+ >>> get_client_task_id_from_key("session123:task456:tool:my_tool")
91
+ 'task456'
92
+ """
93
+ return task_key.split(":", 3)[1]
@@ -0,0 +1,355 @@
1
+ """SEP-1686 task protocol handlers.
2
+
3
+ Implements MCP task protocol methods: tasks/get, tasks/result, tasks/list, tasks/cancel, tasks/delete.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from datetime import datetime, timedelta, timezone
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ import mcp.types
12
+ from docket.execution import ExecutionState
13
+ from mcp.shared.exceptions import McpError
14
+ from mcp.types import (
15
+ INTERNAL_ERROR,
16
+ INVALID_PARAMS,
17
+ CancelTaskResult,
18
+ ErrorData,
19
+ GetTaskResult,
20
+ ListTasksResult,
21
+ )
22
+
23
+ from fastmcp.server.tasks.converters import (
24
+ convert_prompt_result,
25
+ convert_resource_result,
26
+ convert_tool_result,
27
+ )
28
+ from fastmcp.server.tasks.keys import parse_task_key
29
+
30
+ if TYPE_CHECKING:
31
+ from fastmcp.server.server import FastMCP
32
+
33
+ # Map Docket execution states to MCP task status strings
34
+ # Per SEP-1686 final spec (line 381): tasks MUST begin in "working" status
35
+ DOCKET_TO_MCP_STATE: dict[ExecutionState, str] = {
36
+ ExecutionState.SCHEDULED: "working", # Initial state per spec
37
+ ExecutionState.QUEUED: "working", # Initial state per spec
38
+ ExecutionState.RUNNING: "working",
39
+ ExecutionState.COMPLETED: "completed",
40
+ ExecutionState.FAILED: "failed",
41
+ ExecutionState.CANCELLED: "cancelled",
42
+ }
43
+
44
+
45
+ async def tasks_get_handler(server: FastMCP, params: dict[str, Any]) -> GetTaskResult:
46
+ """Handle MCP 'tasks/get' request (SEP-1686).
47
+
48
+ Args:
49
+ server: FastMCP server instance
50
+ params: Request params containing taskId
51
+
52
+ Returns:
53
+ GetTaskResult: Task status response with spec-compliant fields
54
+ """
55
+ import fastmcp.server.context
56
+
57
+ async with fastmcp.server.context.Context(fastmcp=server) as ctx:
58
+ client_task_id = params.get("taskId")
59
+ if not client_task_id:
60
+ raise McpError(
61
+ ErrorData(
62
+ code=INVALID_PARAMS, message="Missing required parameter: taskId"
63
+ )
64
+ )
65
+
66
+ # Get session ID from Context
67
+ session_id = ctx.session_id
68
+
69
+ # Get execution from Docket (use instance attribute for cross-task access)
70
+ docket = server._docket
71
+ if docket is None:
72
+ raise McpError(
73
+ ErrorData(
74
+ code=INTERNAL_ERROR,
75
+ message="Background tasks require Docket",
76
+ )
77
+ )
78
+
79
+ # Look up full task key and creation timestamp from Redis
80
+ redis_key = f"fastmcp:task:{session_id}:{client_task_id}"
81
+ created_at_key = f"fastmcp:task:{session_id}:{client_task_id}:created_at"
82
+ async with docket.redis() as redis:
83
+ task_key_bytes = await redis.get(redis_key)
84
+ created_at_bytes = await redis.get(created_at_key)
85
+
86
+ task_key = None if task_key_bytes is None else task_key_bytes.decode("utf-8")
87
+ created_at = (
88
+ None if created_at_bytes is None else created_at_bytes.decode("utf-8")
89
+ )
90
+
91
+ if task_key is None:
92
+ # Task not found - raise error per MCP protocol
93
+ raise McpError(
94
+ ErrorData(
95
+ code=INVALID_PARAMS, message=f"Task {client_task_id} not found"
96
+ )
97
+ )
98
+
99
+ execution = await docket.get_execution(task_key)
100
+ if execution is None:
101
+ # Task key exists but no execution - raise error
102
+ raise McpError(
103
+ ErrorData(
104
+ code=INVALID_PARAMS,
105
+ message=f"Task {client_task_id} execution not found",
106
+ )
107
+ )
108
+
109
+ # Sync state from Redis
110
+ await execution.sync()
111
+
112
+ # Map Docket state to MCP state
113
+ mcp_state = DOCKET_TO_MCP_STATE.get(execution.state, "failed")
114
+
115
+ # Build response (use default ttl since we don't track per-task values)
116
+ # createdAt is REQUIRED per SEP-1686 final spec (line 430)
117
+ # Per spec lines 447-448: SHOULD NOT include related-task metadata in tasks/get
118
+ error_message = None
119
+ status_message = None
120
+
121
+ if execution.state == ExecutionState.FAILED:
122
+ try:
123
+ await execution.get_result(timeout=timedelta(seconds=0))
124
+ except Exception as error:
125
+ error_message = str(error)
126
+ status_message = f"Task failed: {error_message}"
127
+ elif execution.progress and execution.progress.message:
128
+ # Extract progress message from Docket if available (spec line 403)
129
+ status_message = execution.progress.message
130
+
131
+ return GetTaskResult(
132
+ taskId=client_task_id,
133
+ status=mcp_state, # type: ignore[arg-type]
134
+ createdAt=created_at, # type: ignore[arg-type]
135
+ lastUpdatedAt=datetime.now(timezone.utc),
136
+ ttl=60000,
137
+ pollInterval=1000,
138
+ statusMessage=status_message,
139
+ )
140
+
141
+
142
+ async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any:
143
+ """Handle MCP 'tasks/result' request (SEP-1686).
144
+
145
+ Converts raw task return values to MCP types based on task type.
146
+
147
+ Args:
148
+ server: FastMCP server instance
149
+ params: Request params containing taskId
150
+
151
+ Returns:
152
+ MCP result (CallToolResult, GetPromptResult, or ReadResourceResult)
153
+ """
154
+ import fastmcp.server.context
155
+
156
+ async with fastmcp.server.context.Context(fastmcp=server) as ctx:
157
+ client_task_id = params.get("taskId")
158
+ if not client_task_id:
159
+ raise McpError(
160
+ ErrorData(
161
+ code=INVALID_PARAMS, message="Missing required parameter: taskId"
162
+ )
163
+ )
164
+
165
+ # Get session ID from Context
166
+ session_id = ctx.session_id
167
+
168
+ # Get execution from Docket (use instance attribute for cross-task access)
169
+ docket = server._docket
170
+ if docket is None:
171
+ raise McpError(
172
+ ErrorData(
173
+ code=INTERNAL_ERROR,
174
+ message="Background tasks require Docket",
175
+ )
176
+ )
177
+
178
+ # Look up full task key from Redis
179
+ redis_key = f"fastmcp:task:{session_id}:{client_task_id}"
180
+ async with docket.redis() as redis:
181
+ task_key_bytes = await redis.get(redis_key)
182
+
183
+ task_key = None if task_key_bytes is None else task_key_bytes.decode("utf-8")
184
+
185
+ if task_key is None:
186
+ raise McpError(
187
+ ErrorData(
188
+ code=INVALID_PARAMS,
189
+ message=f"Invalid taskId: {client_task_id} not found",
190
+ )
191
+ )
192
+
193
+ execution = await docket.get_execution(task_key)
194
+ if execution is None:
195
+ raise McpError(
196
+ ErrorData(
197
+ code=INVALID_PARAMS,
198
+ message=f"Invalid taskId: {client_task_id} not found",
199
+ )
200
+ )
201
+
202
+ # Sync state from Redis
203
+ await execution.sync()
204
+
205
+ # Check if completed
206
+ if execution.state not in (ExecutionState.COMPLETED, ExecutionState.FAILED):
207
+ mcp_state = DOCKET_TO_MCP_STATE.get(execution.state, "failed")
208
+ raise McpError(
209
+ ErrorData(
210
+ code=INVALID_PARAMS,
211
+ message=f"Task not completed yet (current state: {mcp_state})",
212
+ )
213
+ )
214
+
215
+ # Get result from Docket
216
+ try:
217
+ raw_value = await execution.get_result(timeout=timedelta(seconds=0))
218
+ except Exception as error:
219
+ # Task failed - return error result
220
+ return mcp.types.CallToolResult(
221
+ content=[mcp.types.TextContent(type="text", text=str(error))],
222
+ isError=True,
223
+ _meta={
224
+ "modelcontextprotocol.io/related-task": {
225
+ "taskId": client_task_id,
226
+ }
227
+ },
228
+ )
229
+
230
+ # Parse task key to get type and component info
231
+ key_parts = parse_task_key(task_key)
232
+ task_type = key_parts["task_type"]
233
+
234
+ # Convert based on task type (pass client_task_id for metadata)
235
+ if task_type == "tool":
236
+ return await convert_tool_result(
237
+ server, raw_value, key_parts["component_identifier"], client_task_id
238
+ )
239
+ elif task_type == "prompt":
240
+ return await convert_prompt_result(
241
+ server, raw_value, key_parts["component_identifier"], client_task_id
242
+ )
243
+ elif task_type == "resource":
244
+ return await convert_resource_result(
245
+ server, raw_value, key_parts["component_identifier"], client_task_id
246
+ )
247
+ else:
248
+ raise McpError(
249
+ ErrorData(
250
+ code=INTERNAL_ERROR,
251
+ message=f"Internal error: Unknown task type: {task_type}",
252
+ )
253
+ )
254
+
255
+
256
+ async def tasks_list_handler(
257
+ server: FastMCP, params: dict[str, Any]
258
+ ) -> ListTasksResult:
259
+ """Handle MCP 'tasks/list' request (SEP-1686).
260
+
261
+ Note: With client-side tracking, this returns minimal info.
262
+
263
+ Args:
264
+ server: FastMCP server instance
265
+ params: Request params (cursor, limit)
266
+
267
+ Returns:
268
+ ListTasksResult: Response with tasks list and pagination
269
+ """
270
+ # Return empty list - client tracks tasks locally
271
+ return ListTasksResult(tasks=[], nextCursor=None)
272
+
273
+
274
+ async def tasks_cancel_handler(
275
+ server: FastMCP, params: dict[str, Any]
276
+ ) -> CancelTaskResult:
277
+ """Handle MCP 'tasks/cancel' request (SEP-1686).
278
+
279
+ Cancels a running task, transitioning it to cancelled state.
280
+
281
+ Args:
282
+ server: FastMCP server instance
283
+ params: Request params containing taskId
284
+
285
+ Returns:
286
+ CancelTaskResult: Task status response showing cancelled state
287
+ """
288
+ import fastmcp.server.context
289
+
290
+ async with fastmcp.server.context.Context(fastmcp=server) as ctx:
291
+ client_task_id = params.get("taskId")
292
+ if not client_task_id:
293
+ raise McpError(
294
+ ErrorData(
295
+ code=INVALID_PARAMS, message="Missing required parameter: taskId"
296
+ )
297
+ )
298
+
299
+ # Get session ID from Context
300
+ session_id = ctx.session_id
301
+
302
+ docket = server._docket
303
+ if docket is None:
304
+ raise McpError(
305
+ ErrorData(
306
+ code=INTERNAL_ERROR,
307
+ message="Background tasks require Docket",
308
+ )
309
+ )
310
+
311
+ # Look up full task key and creation timestamp from Redis
312
+ redis_key = f"fastmcp:task:{session_id}:{client_task_id}"
313
+ created_at_key = f"fastmcp:task:{session_id}:{client_task_id}:created_at"
314
+ async with docket.redis() as redis:
315
+ task_key_bytes = await redis.get(redis_key)
316
+ created_at_bytes = await redis.get(created_at_key)
317
+
318
+ task_key = None if task_key_bytes is None else task_key_bytes.decode("utf-8")
319
+ created_at = (
320
+ None if created_at_bytes is None else created_at_bytes.decode("utf-8")
321
+ )
322
+
323
+ if task_key is None:
324
+ raise McpError(
325
+ ErrorData(
326
+ code=INVALID_PARAMS,
327
+ message=f"Invalid taskId: {client_task_id} not found",
328
+ )
329
+ )
330
+
331
+ # Check if task exists
332
+ execution = await docket.get_execution(task_key)
333
+ if execution is None:
334
+ raise McpError(
335
+ ErrorData(
336
+ code=INVALID_PARAMS,
337
+ message=f"Invalid taskId: {client_task_id} not found",
338
+ )
339
+ )
340
+
341
+ # Cancel via Docket (now sets CANCELLED state natively)
342
+ await docket.cancel(task_key)
343
+
344
+ # Return task status with cancelled state
345
+ # createdAt is REQUIRED per SEP-1686 final spec (line 430)
346
+ # Per spec lines 447-448: SHOULD NOT include related-task metadata in tasks/cancel
347
+ return CancelTaskResult(
348
+ taskId=client_task_id,
349
+ status="cancelled",
350
+ createdAt=created_at or datetime.now(timezone.utc).isoformat(),
351
+ lastUpdatedAt=datetime.now(timezone.utc),
352
+ ttl=60_000,
353
+ pollInterval=1000,
354
+ statusMessage="Task cancelled",
355
+ )
@@ -0,0 +1,205 @@
1
+ """Task subscription helpers for sending MCP notifications (SEP-1686).
2
+
3
+ Subscribes to Docket execution state changes and sends notifications/tasks/status
4
+ to clients when their tasks change state.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from contextlib import suppress
10
+ from datetime import datetime, timezone
11
+ from typing import TYPE_CHECKING
12
+
13
+ from docket.execution import ExecutionState
14
+ from mcp.types import TaskStatusNotification, TaskStatusNotificationParams
15
+
16
+ from fastmcp.server.tasks.protocol import DOCKET_TO_MCP_STATE
17
+ from fastmcp.utilities.logging import get_logger
18
+
19
+ if TYPE_CHECKING:
20
+ from docket import Docket
21
+ from docket.execution import Execution
22
+ from mcp.server.session import ServerSession
23
+
24
+ logger = get_logger(__name__)
25
+
26
+
27
+ async def subscribe_to_task_updates(
28
+ task_id: str,
29
+ task_key: str,
30
+ session: ServerSession,
31
+ docket: Docket,
32
+ ) -> None:
33
+ """Subscribe to Docket execution events and send MCP notifications.
34
+
35
+ Per SEP-1686 lines 436-444, servers MAY send notifications/tasks/status
36
+ when task state changes. This is an optional optimization that reduces
37
+ client polling frequency.
38
+
39
+ Args:
40
+ task_id: Client-visible task ID (server-generated UUID)
41
+ task_key: Internal Docket execution key (includes session, type, component)
42
+ session: MCP ServerSession for sending notifications
43
+ docket: Docket instance for subscribing to execution events
44
+ """
45
+ try:
46
+ execution = await docket.get_execution(task_key)
47
+ if execution is None:
48
+ logger.warning(f"No execution found for task {task_id}")
49
+ return
50
+
51
+ # Subscribe to state and progress events from Docket
52
+ async for event in execution.subscribe():
53
+ if event["type"] == "state":
54
+ # Send notifications/tasks/status when state changes
55
+ await _send_status_notification(
56
+ session=session,
57
+ task_id=task_id,
58
+ task_key=task_key,
59
+ docket=docket,
60
+ state=event["state"], # type: ignore[typeddict-item]
61
+ )
62
+ elif event["type"] == "progress":
63
+ # Send notification when progress message changes
64
+ await _send_progress_notification(
65
+ session=session,
66
+ task_id=task_id,
67
+ task_key=task_key,
68
+ docket=docket,
69
+ execution=execution,
70
+ )
71
+
72
+ except Exception as e:
73
+ logger.warning(f"Subscription task failed for {task_id}: {e}", exc_info=True)
74
+
75
+
76
+ async def _send_status_notification(
77
+ session: ServerSession,
78
+ task_id: str,
79
+ task_key: str,
80
+ docket: Docket,
81
+ state: ExecutionState,
82
+ ) -> None:
83
+ """Send notifications/tasks/status to client.
84
+
85
+ Per SEP-1686 line 454: notification SHOULD NOT include related-task metadata
86
+ (taskId is already in params).
87
+
88
+ Args:
89
+ session: MCP ServerSession
90
+ task_id: Client-visible task ID
91
+ task_key: Internal task key (for metadata lookup)
92
+ docket: Docket instance
93
+ state: Docket execution state (enum)
94
+ """
95
+ # Map Docket state to MCP status
96
+ mcp_status = DOCKET_TO_MCP_STATE.get(state, "failed")
97
+
98
+ # Extract session_id from task_key for Redis lookup
99
+ from fastmcp.server.tasks.keys import parse_task_key
100
+
101
+ key_parts = parse_task_key(task_key)
102
+ session_id = key_parts["session_id"]
103
+
104
+ # Retrieve createdAt timestamp from Redis
105
+ created_at_key = f"fastmcp:task:{session_id}:{task_id}:created_at"
106
+ async with docket.redis() as redis:
107
+ created_at_bytes = await redis.get(created_at_key)
108
+
109
+ created_at = (
110
+ created_at_bytes.decode("utf-8")
111
+ if created_at_bytes
112
+ else datetime.now(timezone.utc).isoformat()
113
+ )
114
+
115
+ # Build status message
116
+ status_message = None
117
+ if state == ExecutionState.COMPLETED:
118
+ status_message = "Task completed successfully"
119
+ elif state == ExecutionState.FAILED:
120
+ status_message = "Task failed"
121
+ elif state == ExecutionState.CANCELLED:
122
+ status_message = "Task cancelled"
123
+
124
+ params_dict = {
125
+ "taskId": task_id,
126
+ "status": mcp_status,
127
+ "createdAt": created_at,
128
+ "lastUpdatedAt": datetime.now(timezone.utc).isoformat(),
129
+ "ttl": 60000,
130
+ "pollInterval": 1000,
131
+ }
132
+
133
+ if status_message:
134
+ params_dict["statusMessage"] = status_message
135
+
136
+ # Create notification (no related-task metadata per spec line 454)
137
+ notification = TaskStatusNotification(
138
+ params=TaskStatusNotificationParams.model_validate(params_dict),
139
+ )
140
+
141
+ # Send notification (don't let failures break the subscription)
142
+ with suppress(Exception):
143
+ await session.send_notification(notification) # type: ignore[arg-type]
144
+
145
+
146
+ async def _send_progress_notification(
147
+ session: ServerSession,
148
+ task_id: str,
149
+ task_key: str,
150
+ docket: Docket,
151
+ execution: Execution,
152
+ ) -> None:
153
+ """Send notifications/tasks/status when progress updates.
154
+
155
+ Args:
156
+ session: MCP ServerSession
157
+ task_id: Client-visible task ID
158
+ task_key: Internal task key
159
+ docket: Docket instance
160
+ execution: Execution object with current progress
161
+ """
162
+ # Sync execution to get latest progress
163
+ await execution.sync()
164
+
165
+ # Only send if there's a progress message
166
+ if not execution.progress or not execution.progress.message:
167
+ return
168
+
169
+ # Map Docket state to MCP status
170
+ mcp_status = DOCKET_TO_MCP_STATE.get(execution.state, "failed")
171
+
172
+ # Extract session_id from task_key for Redis lookup
173
+ from fastmcp.server.tasks.keys import parse_task_key
174
+
175
+ key_parts = parse_task_key(task_key)
176
+ session_id = key_parts["session_id"]
177
+
178
+ # Retrieve createdAt timestamp from Redis
179
+ created_at_key = f"fastmcp:task:{session_id}:{task_id}:created_at"
180
+ async with docket.redis() as redis:
181
+ created_at_bytes = await redis.get(created_at_key)
182
+
183
+ created_at = (
184
+ created_at_bytes.decode("utf-8")
185
+ if created_at_bytes
186
+ else datetime.now(timezone.utc).isoformat()
187
+ )
188
+
189
+ params_dict = {
190
+ "taskId": task_id,
191
+ "status": mcp_status,
192
+ "createdAt": created_at,
193
+ "lastUpdatedAt": datetime.now(timezone.utc).isoformat(),
194
+ "ttl": 60000,
195
+ "pollInterval": 1000,
196
+ "statusMessage": execution.progress.message,
197
+ }
198
+
199
+ # Create and send notification
200
+ notification = TaskStatusNotification(
201
+ params=TaskStatusNotificationParams.model_validate(params_dict),
202
+ )
203
+
204
+ with suppress(Exception):
205
+ await session.send_notification(notification) # type: ignore[arg-type]