Skip to content

Commit 1ed976b

Browse files
authored
refactor: move task attribute to function-based variants only [SEP-1686] (#2560)
* refactor: move task attribute to function-based variants only * fix: update snapshot test for ResourceTemplate without task field
1 parent 960ce77 commit 1ed976b

File tree

7 files changed

+102
-91
lines changed

7 files changed

+102
-91
lines changed

src/fastmcp/prompts/prompt.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,6 @@ class Prompt(FastMCPComponent):
6666
arguments: list[PromptArgument] | None = Field(
6767
default=None, description="Arguments that can be passed to the prompt"
6868
)
69-
task: Annotated[
70-
bool,
71-
Field(
72-
description="Whether this prompt supports background task execution (SEP-1686)"
73-
),
74-
] = False
7569

7670
def enable(self) -> None:
7771
super().enable()
@@ -164,6 +158,12 @@ class FunctionPrompt(Prompt):
164158
"""A prompt that is a function."""
165159

166160
fn: Callable[..., PromptResult | Awaitable[PromptResult]]
161+
task: Annotated[
162+
bool,
163+
Field(
164+
description="Whether this prompt supports background task execution (SEP-1686)"
165+
),
166+
] = False
167167

168168
@classmethod
169169
def from_function(

src/fastmcp/resources/resource.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,6 @@ class Resource(FastMCPComponent):
4747
Annotations | None,
4848
Field(description="Optional annotations about the resource's behavior"),
4949
] = None
50-
task: Annotated[
51-
bool,
52-
Field(
53-
description="Whether this resource supports background task execution (SEP-1686)"
54-
),
55-
] = False
5650

5751
def enable(self) -> None:
5852
super().enable()
@@ -176,6 +170,12 @@ class FunctionResource(Resource):
176170
"""
177171

178172
fn: Callable[..., Any]
173+
task: Annotated[
174+
bool,
175+
Field(
176+
description="Whether this resource supports background task execution (SEP-1686)"
177+
),
178+
] = False
179179

180180
@classmethod
181181
def from_function(

src/fastmcp/resources/template.py

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -103,12 +103,6 @@ class ResourceTemplate(FastMCPComponent):
103103
annotations: Annotations | None = Field(
104104
default=None, description="Optional annotations about the resource's behavior"
105105
)
106-
task: Annotated[
107-
bool,
108-
Field(
109-
description="Whether this resource template supports background task execution (SEP-1686)"
110-
),
111-
] = False
112106

113107
def __repr__(self) -> str:
114108
return f"{self.__class__.__name__}(uri_template={self.uri_template!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})"
@@ -178,22 +172,14 @@ async def read(self, arguments: dict[str, Any]) -> str | bytes:
178172
)
179173

180174
async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource:
181-
"""Create a resource from the template with the given parameters."""
182-
183-
async def resource_read_fn() -> str | bytes:
184-
# Call function and check if result is a coroutine
185-
result = await self.read(arguments=params)
186-
return result
175+
"""Create a resource from the template with the given parameters.
187176
188-
return Resource.from_function(
189-
fn=resource_read_fn,
190-
uri=uri,
191-
name=self.name,
192-
description=self.description,
193-
mime_type=self.mime_type,
194-
tags=self.tags,
195-
enabled=self.enabled,
196-
task=self.task,
177+
The base implementation does not support background tasks.
178+
Use FunctionResourceTemplate for task support.
179+
"""
180+
raise NotImplementedError(
181+
"Subclasses must implement create_resource(). "
182+
"Use FunctionResourceTemplate for task support."
197183
)
198184

199185
def to_mcp_template(
@@ -245,6 +231,31 @@ class FunctionResourceTemplate(ResourceTemplate):
245231
"""A template for dynamically creating resources."""
246232

247233
fn: Callable[..., Any]
234+
task: Annotated[
235+
bool,
236+
Field(
237+
description="Whether this resource template supports background task execution (SEP-1686)"
238+
),
239+
] = False
240+
241+
async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource:
242+
"""Create a resource from the template with the given parameters."""
243+
244+
async def resource_read_fn() -> str | bytes:
245+
# Call function and check if result is a coroutine
246+
result = await self.read(arguments=params)
247+
return result
248+
249+
return Resource.from_function(
250+
fn=resource_read_fn,
251+
uri=uri,
252+
name=self.name,
253+
description=self.description,
254+
mime_type=self.mime_type,
255+
tags=self.tags,
256+
enabled=self.enabled,
257+
task=self.task,
258+
)
248259

249260
async def read(self, arguments: dict[str, Any]) -> str | bytes:
250261
"""Read the resource content."""

src/fastmcp/server/server.py

Lines changed: 14 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,9 @@
6060
from fastmcp.prompts import Prompt
6161
from fastmcp.prompts.prompt import FunctionPrompt
6262
from fastmcp.prompts.prompt_manager import PromptManager
63-
from fastmcp.resources.resource import Resource
63+
from fastmcp.resources.resource import FunctionResource, Resource
6464
from fastmcp.resources.resource_manager import ResourceManager
65-
from fastmcp.resources.template import ResourceTemplate
65+
from fastmcp.resources.template import FunctionResourceTemplate, ResourceTemplate
6666
from fastmcp.server.auth import AuthProvider
6767
from fastmcp.server.http import (
6868
StarletteWithLifespan,
@@ -400,48 +400,21 @@ async def _docket_lifespan(self) -> AsyncIterator[None]:
400400
self._docket = docket
401401

402402
# Register local task-enabled tools/prompts/resources with Docket
403+
# Only function-based variants support background tasks
403404
for tool in self._tool_manager._tools.values():
404-
if not hasattr(tool, "fn"):
405-
continue
406-
supports_task = (
407-
tool.task
408-
if tool.task is not None
409-
else self._support_tasks_by_default
410-
)
411-
if supports_task:
405+
if isinstance(tool, FunctionTool) and tool.task:
412406
docket.register(tool.fn)
413407

414408
for prompt in self._prompt_manager._prompts.values():
415-
if not hasattr(prompt, "fn"):
416-
continue
417-
supports_task = (
418-
prompt.task
419-
if prompt.task is not None
420-
else self._support_tasks_by_default
421-
)
422-
if supports_task:
409+
if isinstance(prompt, FunctionPrompt) and prompt.task:
423410
docket.register(prompt.fn)
424411

425412
for resource in self._resource_manager._resources.values():
426-
if not hasattr(resource, "fn"):
427-
continue
428-
supports_task = (
429-
resource.task
430-
if resource.task is not None
431-
else self._support_tasks_by_default
432-
)
433-
if supports_task:
413+
if isinstance(resource, FunctionResource) and resource.task:
434414
docket.register(resource.fn)
435415

436416
for template in self._resource_manager._templates.values():
437-
if not hasattr(template, "fn"):
438-
continue
439-
supports_task = (
440-
template.task
441-
if template.task is not None
442-
else self._support_tasks_by_default
443-
)
444-
if supports_task:
417+
if isinstance(template, FunctionResourceTemplate) and template.task:
445418
docket.register(template.fn)
446419

447420
# Set Docket in ContextVar so CurrentDocket can access it
@@ -602,7 +575,11 @@ async def handler(req: mcp.types.ReadResourceRequest) -> mcp.types.ServerResult:
602575
async with fastmcp.server.context.Context(fastmcp=self):
603576
try:
604577
resource = await self._resource_manager.get_resource(uri)
605-
if resource and resource.task:
578+
if (
579+
resource
580+
and isinstance(resource, FunctionResource)
581+
and resource.task
582+
):
606583
# Convert TaskMetadata to dict for handler
607584
task_meta_dict = task_meta.model_dump(exclude_none=True)
608585
return await handle_resource_as_task(
@@ -671,7 +648,7 @@ async def handler(req: mcp.types.GetPromptRequest) -> mcp.types.ServerResult:
671648
async with fastmcp.server.context.Context(fastmcp=self):
672649
prompts = await self.get_prompts()
673650
prompt = prompts.get(name)
674-
if prompt and prompt.task:
651+
if prompt and isinstance(prompt, FunctionPrompt) and prompt.task:
675652
# Convert TaskMetadata to dict for handler
676653
task_meta_dict = task_meta.model_dump(exclude_none=True)
677654
result = await handle_prompt_as_task(
@@ -1349,7 +1326,7 @@ async def _call_tool_mcp(
13491326
if task_meta and fastmcp.settings.enable_tasks:
13501327
# Task metadata present - check if tool supports background execution
13511328
tool = self._tool_manager._tools.get(key)
1352-
if tool and tool.task:
1329+
if tool and isinstance(tool, FunctionTool) and tool.task:
13531330
# Route to background execution
13541331
# Convert TaskMetadata to dict for handler
13551332
task_meta_dict = task_meta.model_dump(exclude_none=True)

src/fastmcp/tools/tool.py

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -136,12 +136,6 @@ class Tool(FastMCPComponent):
136136
ToolResultSerializerType | None,
137137
Field(description="Optional custom serializer for tool results"),
138138
] = None
139-
task: Annotated[
140-
bool,
141-
Field(
142-
description="Whether this tool supports background task execution (SEP-1686)"
143-
),
144-
] = False
145139

146140
@model_validator(mode="after")
147141
def _validate_tool_name(self) -> Tool:
@@ -179,24 +173,15 @@ def to_mcp_tool(
179173
elif self.annotations and self.annotations.title:
180174
title = self.annotations.title
181175

182-
# Auto-populate task execution mode based on tool.task flag if not explicitly set
183-
# Per SEP-1686: tools declare task support via execution.task
184-
# task values: "never" (no task support), "optional" (supports both), "always" (requires task)
185-
annotations = self.annotations
186-
execution = None
187-
if self.task:
188-
# Tool supports background execution - use "optional" to allow both immediate and task execution
189-
execution = ToolExecution(task="optional")
190-
191176
return MCPTool(
192177
name=overrides.get("name", self.name),
193178
title=overrides.get("title", title),
194179
description=overrides.get("description", self.description),
195180
inputSchema=overrides.get("inputSchema", self.parameters),
196181
outputSchema=overrides.get("outputSchema", self.output_schema),
197182
icons=overrides.get("icons", self.icons),
198-
annotations=overrides.get("annotations", annotations),
199-
execution=overrides.get("execution", execution),
183+
annotations=overrides.get("annotations", self.annotations),
184+
execution=overrides.get("execution"),
200185
_meta=overrides.get(
201186
"_meta", self.get_meta(include_fastmcp_meta=include_fastmcp_meta)
202187
),
@@ -284,6 +269,35 @@ def from_tool(
284269

285270
class FunctionTool(Tool):
286271
fn: Callable[..., Any]
272+
task: Annotated[
273+
bool,
274+
Field(
275+
description="Whether this tool supports background task execution (SEP-1686)"
276+
),
277+
] = False
278+
279+
def to_mcp_tool(
280+
self,
281+
*,
282+
include_fastmcp_meta: bool | None = None,
283+
**overrides: Any,
284+
) -> MCPTool:
285+
"""Convert the FastMCP tool to an MCP tool.
286+
287+
Extends the base implementation to add task execution mode if enabled.
288+
"""
289+
# Get base MCP tool from parent
290+
mcp_tool = super().to_mcp_tool(
291+
include_fastmcp_meta=include_fastmcp_meta, **overrides
292+
)
293+
294+
# Add task execution mode if this tool supports background tasks
295+
# Per SEP-1686: tools declare task support via execution.task
296+
# task values: "never" (no task support), "optional" (supports both), "always" (requires task)
297+
if self.task and "execution" not in overrides:
298+
mcp_tool.execution = ToolExecution(task="optional")
299+
300+
return mcp_tool
287301

288302
@classmethod
289303
def from_function(

tests/server/middleware/test_logging.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,7 @@ async def test_on_message_with_resource_template_in_payload(
332332

333333
assert get_log_lines(caplog) == snapshot(
334334
[
335-
'{"event": "request_start", "method": "test_method", "source": "client", "payload": "{\\"name\\":\\"tmpl\\",\\"title\\":null,\\"description\\":null,\\"icons\\":null,\\"tags\\":[],\\"meta\\":null,\\"enabled\\":true,\\"uri_template\\":\\"tmpl://{id}\\",\\"mime_type\\":\\"text/plain\\",\\"parameters\\":{\\"id\\":{\\"type\\":\\"string\\"}},\\"annotations\\":null,\\"task\\":false}", "payload_type": "ResourceTemplate"}',
335+
'{"event": "request_start", "method": "test_method", "source": "client", "payload": "{\\"name\\":\\"tmpl\\",\\"title\\":null,\\"description\\":null,\\"icons\\":null,\\"tags\\":[],\\"meta\\":null,\\"enabled\\":true,\\"uri_template\\":\\"tmpl://{id}\\",\\"mime_type\\":\\"text/plain\\",\\"parameters\\":{\\"id\\":{\\"type\\":\\"string\\"}},\\"annotations\\":null}", "payload_type": "ResourceTemplate"}',
336336
'{"event": "request_success", "method": "test_method", "source": "client", "duration_ms": 0.02}',
337337
]
338338
)

tests/server/tasks/test_sync_function_task_disabled.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
import pytest
99

1010
from fastmcp import FastMCP
11+
from fastmcp.prompts.prompt import FunctionPrompt
12+
from fastmcp.resources.resource import FunctionResource
13+
from fastmcp.tools.tool import FunctionTool
1114

1215

1316
async def test_sync_tool_with_explicit_task_true_raises():
@@ -91,8 +94,9 @@ async def async_tool(x: int) -> int:
9194
"""An async tool."""
9295
return x * 2
9396

94-
# Tool should have task=True
97+
# Tool should have task=True and be a FunctionTool
9598
tool = await mcp.get_tool("async_tool")
99+
assert isinstance(tool, FunctionTool)
96100
assert tool.task is True
97101

98102

@@ -105,8 +109,9 @@ async def async_prompt() -> str:
105109
"""An async prompt."""
106110
return "Hello"
107111

108-
# Prompt should have task=True
112+
# Prompt should have task=True and be a FunctionPrompt
109113
prompt = await mcp.get_prompt("async_prompt")
114+
assert isinstance(prompt, FunctionPrompt)
110115
assert prompt.task is True
111116

112117

@@ -119,8 +124,9 @@ async def async_resource() -> str:
119124
"""An async resource."""
120125
return "data"
121126

122-
# Resource should have task=True
127+
# Resource should have task=True and be a FunctionResource
123128
resource = await mcp._resource_manager.get_resource("test://async")
129+
assert isinstance(resource, FunctionResource)
124130
assert resource.task is True
125131

126132

@@ -134,6 +140,7 @@ def sync_tool(x: int) -> int:
134140
return x * 2
135141

136142
tool = await mcp.get_tool("sync_tool")
143+
assert isinstance(tool, FunctionTool)
137144
assert tool.task is False
138145

139146

@@ -147,6 +154,7 @@ def sync_prompt() -> str:
147154
return "Hello"
148155

149156
prompt = await mcp.get_prompt("sync_prompt")
157+
assert isinstance(prompt, FunctionPrompt)
150158
assert prompt.task is False
151159

152160

@@ -160,6 +168,7 @@ def sync_resource() -> str:
160168
return "data"
161169

162170
resource = await mcp._resource_manager.get_resource("test://sync")
171+
assert isinstance(resource, FunctionResource)
163172
assert resource.task is False
164173

165174

0 commit comments

Comments
 (0)