fastmcp 2.12.5__py3-none-any.whl → 2.14.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastmcp/__init__.py +2 -23
- fastmcp/cli/__init__.py +0 -3
- fastmcp/cli/__main__.py +5 -0
- fastmcp/cli/cli.py +19 -33
- fastmcp/cli/install/claude_code.py +6 -6
- fastmcp/cli/install/claude_desktop.py +3 -3
- fastmcp/cli/install/cursor.py +18 -12
- fastmcp/cli/install/gemini_cli.py +3 -3
- fastmcp/cli/install/mcp_json.py +3 -3
- fastmcp/cli/install/shared.py +0 -15
- fastmcp/cli/run.py +13 -8
- fastmcp/cli/tasks.py +110 -0
- fastmcp/client/__init__.py +9 -9
- fastmcp/client/auth/oauth.py +123 -225
- fastmcp/client/client.py +697 -95
- fastmcp/client/elicitation.py +11 -5
- fastmcp/client/logging.py +18 -14
- fastmcp/client/messages.py +7 -5
- fastmcp/client/oauth_callback.py +85 -171
- fastmcp/client/roots.py +2 -1
- fastmcp/client/sampling.py +1 -1
- fastmcp/client/tasks.py +614 -0
- fastmcp/client/transports.py +117 -30
- fastmcp/contrib/component_manager/__init__.py +1 -1
- fastmcp/contrib/component_manager/component_manager.py +2 -2
- fastmcp/contrib/component_manager/component_service.py +10 -26
- fastmcp/contrib/mcp_mixin/README.md +32 -1
- fastmcp/contrib/mcp_mixin/__init__.py +2 -2
- fastmcp/contrib/mcp_mixin/mcp_mixin.py +14 -2
- fastmcp/dependencies.py +25 -0
- fastmcp/experimental/sampling/handlers/openai.py +3 -3
- fastmcp/experimental/server/openapi/__init__.py +20 -21
- fastmcp/experimental/utilities/openapi/__init__.py +16 -47
- fastmcp/mcp_config.py +3 -4
- fastmcp/prompts/__init__.py +1 -1
- fastmcp/prompts/prompt.py +54 -51
- fastmcp/prompts/prompt_manager.py +16 -101
- fastmcp/resources/__init__.py +5 -5
- fastmcp/resources/resource.py +43 -21
- fastmcp/resources/resource_manager.py +9 -168
- fastmcp/resources/template.py +161 -61
- fastmcp/resources/types.py +30 -24
- fastmcp/server/__init__.py +1 -1
- fastmcp/server/auth/__init__.py +9 -14
- fastmcp/server/auth/auth.py +197 -46
- fastmcp/server/auth/handlers/authorize.py +326 -0
- fastmcp/server/auth/jwt_issuer.py +236 -0
- fastmcp/server/auth/middleware.py +96 -0
- fastmcp/server/auth/oauth_proxy.py +1469 -298
- fastmcp/server/auth/oidc_proxy.py +91 -20
- fastmcp/server/auth/providers/auth0.py +40 -21
- fastmcp/server/auth/providers/aws.py +29 -3
- fastmcp/server/auth/providers/azure.py +312 -131
- fastmcp/server/auth/providers/debug.py +114 -0
- fastmcp/server/auth/providers/descope.py +86 -29
- fastmcp/server/auth/providers/discord.py +308 -0
- fastmcp/server/auth/providers/github.py +29 -8
- fastmcp/server/auth/providers/google.py +48 -9
- fastmcp/server/auth/providers/in_memory.py +29 -5
- fastmcp/server/auth/providers/introspection.py +281 -0
- fastmcp/server/auth/providers/jwt.py +48 -31
- fastmcp/server/auth/providers/oci.py +233 -0
- fastmcp/server/auth/providers/scalekit.py +238 -0
- fastmcp/server/auth/providers/supabase.py +188 -0
- fastmcp/server/auth/providers/workos.py +35 -17
- fastmcp/server/context.py +236 -116
- fastmcp/server/dependencies.py +503 -18
- fastmcp/server/elicitation.py +286 -48
- fastmcp/server/event_store.py +177 -0
- fastmcp/server/http.py +71 -20
- fastmcp/server/low_level.py +165 -2
- fastmcp/server/middleware/__init__.py +1 -1
- fastmcp/server/middleware/caching.py +476 -0
- fastmcp/server/middleware/error_handling.py +14 -10
- fastmcp/server/middleware/logging.py +50 -39
- fastmcp/server/middleware/middleware.py +29 -16
- fastmcp/server/middleware/rate_limiting.py +3 -3
- fastmcp/server/middleware/tool_injection.py +116 -0
- fastmcp/server/openapi/__init__.py +35 -0
- fastmcp/{experimental/server → server}/openapi/components.py +15 -10
- fastmcp/{experimental/server → server}/openapi/routing.py +3 -3
- fastmcp/{experimental/server → server}/openapi/server.py +6 -5
- fastmcp/server/proxy.py +72 -48
- fastmcp/server/server.py +1415 -733
- fastmcp/server/tasks/__init__.py +21 -0
- fastmcp/server/tasks/capabilities.py +22 -0
- fastmcp/server/tasks/config.py +89 -0
- fastmcp/server/tasks/converters.py +205 -0
- fastmcp/server/tasks/handlers.py +356 -0
- fastmcp/server/tasks/keys.py +93 -0
- fastmcp/server/tasks/protocol.py +355 -0
- fastmcp/server/tasks/subscriptions.py +205 -0
- fastmcp/settings.py +125 -113
- fastmcp/tools/__init__.py +1 -1
- fastmcp/tools/tool.py +138 -55
- fastmcp/tools/tool_manager.py +30 -112
- fastmcp/tools/tool_transform.py +12 -21
- fastmcp/utilities/cli.py +67 -28
- fastmcp/utilities/components.py +10 -5
- fastmcp/utilities/inspect.py +79 -23
- fastmcp/utilities/json_schema.py +4 -4
- fastmcp/utilities/json_schema_type.py +8 -8
- fastmcp/utilities/logging.py +118 -8
- fastmcp/utilities/mcp_config.py +1 -2
- fastmcp/utilities/mcp_server_config/__init__.py +3 -3
- fastmcp/utilities/mcp_server_config/v1/environments/base.py +1 -2
- fastmcp/utilities/mcp_server_config/v1/environments/uv.py +6 -6
- fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +5 -5
- fastmcp/utilities/mcp_server_config/v1/schema.json +3 -0
- fastmcp/utilities/mcp_server_config/v1/sources/base.py +0 -1
- fastmcp/{experimental/utilities → utilities}/openapi/README.md +7 -35
- fastmcp/utilities/openapi/__init__.py +63 -0
- fastmcp/{experimental/utilities → utilities}/openapi/director.py +14 -15
- fastmcp/{experimental/utilities → utilities}/openapi/formatters.py +5 -5
- fastmcp/{experimental/utilities → utilities}/openapi/json_schema_converter.py +7 -3
- fastmcp/{experimental/utilities → utilities}/openapi/parser.py +37 -16
- fastmcp/utilities/tests.py +92 -5
- fastmcp/utilities/types.py +86 -16
- fastmcp/utilities/ui.py +626 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/METADATA +24 -15
- fastmcp-2.14.0.dist-info/RECORD +156 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/WHEEL +1 -1
- fastmcp/cli/claude.py +0 -135
- fastmcp/server/auth/providers/bearer.py +0 -25
- fastmcp/server/openapi.py +0 -1083
- fastmcp/utilities/openapi.py +0 -1568
- fastmcp/utilities/storage.py +0 -204
- fastmcp-2.12.5.dist-info/RECORD +0 -134
- fastmcp/{experimental/server → server}/openapi/README.md +0 -0
- fastmcp/{experimental/utilities → utilities}/openapi/models.py +3 -3
- fastmcp/{experimental/utilities → utilities}/openapi/schemas.py +2 -2
- {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.12.5.dist-info → fastmcp-2.14.0.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]
|