Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion docs/servers/tasks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,41 @@ When a client requests background execution, the call returns immediately with a
Background tasks require async functions. Attempting to use `task=True` with a sync function raises a `ValueError` at registration time.
</Warning>

## Execution Modes

For fine-grained control over task execution behavior, use `TaskConfig` instead of the boolean shorthand. The MCP task protocol defines three execution modes:

| Mode | Client calls without task | Client calls with task |
|------|--------------------------|------------------------|
| `"forbidden"` | Executes synchronously | Error: task not supported |
| `"optional"` | Executes synchronously | Executes as background task |
| `"required"` | Error: task required | Executes as background task |

```python
from fastmcp import FastMCP, TaskConfig

mcp = FastMCP("MyServer")

# Supports both sync and background execution (default when task=True)
@mcp.tool(task=TaskConfig(mode="optional"))
async def flexible_task() -> str:
return "Works either way"

# Requires background execution - errors if client doesn't request task
@mcp.tool(task=TaskConfig(mode="required"))
async def must_be_background() -> str:
return "Only runs as a background task"

# No task support (default when task=False or omitted)
@mcp.tool(task=TaskConfig(mode="forbidden"))
async def sync_only() -> str:
return "Never runs as background task"
```

The boolean shortcuts map to these modes:
- `task=True` → `TaskConfig(mode="optional")`
- `task=False` → `TaskConfig(mode="forbidden")`

### Server-Wide Default

To enable background task support for all components by default, pass `tasks=True` to the constructor. Individual decorators can still override this with `task=False`.
Expand All @@ -75,7 +110,9 @@ If your server defines any synchronous tools, resources, or prompts, you will ne

### Graceful Degradation

When a client requests background execution (`task=True` in the request) but the component doesn't support it (`task=False` on the decorator), FastMCP executes synchronously and returns the result inline. This follows the SEP-1686 specification for graceful degradation—clients can always request background execution without worrying about server capabilities.
When a client requests background execution but the component has `mode="forbidden"`, FastMCP executes synchronously and returns the result inline. This follows the SEP-1686 specification for graceful degradation—clients can always request background execution without worrying about server capabilities.

Conversely, when a component has `mode="required"` but the client doesn't request background execution, FastMCP returns an error indicating that task execution is required.

### Configuration

Expand Down
2 changes: 2 additions & 0 deletions src/fastmcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from fastmcp.server.server import FastMCP
from fastmcp.server.context import Context
from fastmcp.server.tasks.config import TaskConfig
import fastmcp.server

from fastmcp.client import Client
Expand All @@ -31,6 +32,7 @@
"Client",
"Context",
"FastMCP",
"TaskConfig",
"client",
"settings",
]
33 changes: 17 additions & 16 deletions src/fastmcp/prompts/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from fastmcp.exceptions import PromptError
from fastmcp.server.dependencies import get_context, without_injected_parameters
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.logging import get_logger
Expand Down Expand Up @@ -120,7 +121,7 @@ def from_function(
tags: set[str] | None = None,
enabled: bool | None = None,
meta: dict[str, Any] | None = None,
task: bool | None = None,
task: bool | TaskConfig | None = None,
) -> FunctionPrompt:
"""Create a Prompt from a function.

Expand Down Expand Up @@ -158,12 +159,10 @@ class FunctionPrompt(Prompt):
"""A prompt that is a function."""

fn: Callable[..., PromptResult | Awaitable[PromptResult]]
task: Annotated[
bool,
Field(
description="Whether this prompt supports background task execution (SEP-1686)"
),
] = False
task_config: Annotated[
TaskConfig,
Field(description="Background task execution configuration (SEP-1686)."),
] = Field(default_factory=lambda: TaskConfig(mode="forbidden"))

@classmethod
def from_function(
Expand All @@ -176,7 +175,7 @@ def from_function(
tags: set[str] | None = None,
enabled: bool | None = None,
meta: dict[str, Any] | None = None,
task: bool | None = None,
task: bool | TaskConfig | None = None,
) -> FunctionPrompt:
"""Create a Prompt from a function.

Expand All @@ -201,20 +200,22 @@ def from_function(

description = description or inspect.getdoc(fn)

# Normalize task to TaskConfig and validate
if task is None:
task_config = TaskConfig(mode="forbidden")
elif isinstance(task, bool):
task_config = TaskConfig.from_bool(task)
else:
task_config = task
task_config.validate_function(fn, func_name)

# if the fn is a callable class, we need to get the __call__ method from here out
if not inspect.isroutine(fn):
fn = fn.__call__
# if the fn is a staticmethod, we need to work with the underlying function
if isinstance(fn, staticmethod):
fn = fn.__func__ # type: ignore[assignment]

# Validate that task=True requires async functions (after unwrapping)
if task and not inspect.iscoroutinefunction(fn):
raise ValueError(
f"Prompt '{func_name}' uses a sync function but has task=True. "
"Background tasks require async functions. Set task=False to disable."
)

# Wrap fn to handle dependency resolution internally
wrapped_fn = without_injected_parameters(fn)
type_adapter = get_cached_typeadapter(wrapped_fn)
Expand Down Expand Up @@ -271,7 +272,7 @@ def from_function(
enabled=enabled if enabled is not None else True,
fn=wrapped_fn,
meta=meta,
task=task if task is not None else False,
task_config=task_config,
)

def _convert_string_arguments(self, kwargs: dict[str, Any]) -> dict[str, Any]:
Expand Down
39 changes: 18 additions & 21 deletions src/fastmcp/resources/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from typing_extensions import Self

from fastmcp.server.dependencies import get_context, without_injected_parameters
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.types import (
get_fn_name,
Expand Down Expand Up @@ -77,7 +78,7 @@ def from_function(
enabled: bool | None = None,
annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
task: bool | None = None,
task: bool | TaskConfig | None = None,
) -> FunctionResource:
return FunctionResource.from_function(
fn=fn,
Expand Down Expand Up @@ -170,12 +171,10 @@ class FunctionResource(Resource):
"""

fn: Callable[..., Any]
task: Annotated[
bool,
Field(
description="Whether this resource supports background task execution (SEP-1686)"
),
] = False
task_config: Annotated[
TaskConfig,
Field(description="Background task execution configuration (SEP-1686)."),
] = Field(default_factory=lambda: TaskConfig(mode="forbidden"))

@classmethod
def from_function(
Expand All @@ -191,24 +190,22 @@ def from_function(
enabled: bool | None = None,
annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
task: bool | None = None,
task: bool | TaskConfig | None = None,
) -> FunctionResource:
"""Create a FunctionResource from a function."""
if isinstance(uri, str):
uri = AnyUrl(uri)

# Validate that task=True requires async functions
# Handle callable classes and staticmethods before checking
fn_to_check = fn
if not inspect.isroutine(fn) and callable(fn):
fn_to_check = fn.__call__
if isinstance(fn_to_check, staticmethod):
fn_to_check = fn_to_check.__func__
if task and not inspect.iscoroutinefunction(fn_to_check):
raise ValueError(
f"Resource '{name or get_fn_name(fn)}' uses a sync function but has task=True. "
"Background tasks require async functions. Set task=False to disable."
)
func_name = name or get_fn_name(fn)

# Normalize task to TaskConfig and validate
if task is None:
task_config = TaskConfig(mode="forbidden")
elif isinstance(task, bool):
task_config = TaskConfig.from_bool(task)
else:
task_config = task
task_config.validate_function(fn, func_name)

# Wrap fn to handle dependency resolution internally
wrapped_fn = without_injected_parameters(fn)
Expand All @@ -225,7 +222,7 @@ def from_function(
enabled=enabled if enabled is not None else True,
annotations=annotations,
meta=meta,
task=task if task is not None else False,
task_config=task_config,
)

async def read(self) -> str | bytes:
Expand Down
35 changes: 18 additions & 17 deletions src/fastmcp/resources/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

from fastmcp.resources.resource import Resource
from fastmcp.server.dependencies import get_context, without_injected_parameters
from fastmcp.server.tasks.config import TaskConfig
from fastmcp.utilities.components import FastMCPComponent
from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.types import get_cached_typeadapter
Expand Down Expand Up @@ -136,7 +137,7 @@ def from_function(
enabled: bool | None = None,
annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
task: bool | None = None,
task: bool | TaskConfig | None = None,
) -> FunctionResourceTemplate:
return FunctionResourceTemplate.from_function(
fn=fn,
Expand Down Expand Up @@ -231,12 +232,10 @@ class FunctionResourceTemplate(ResourceTemplate):
"""A template for dynamically creating resources."""

fn: Callable[..., Any]
task: Annotated[
bool,
Field(
description="Whether this resource template supports background task execution (SEP-1686)"
),
] = False
task_config: Annotated[
TaskConfig,
Field(description="Background task execution configuration (SEP-1686)."),
] = Field(default_factory=lambda: TaskConfig(mode="forbidden"))

async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource:
"""Create a resource from the template with the given parameters."""
Expand All @@ -254,7 +253,7 @@ async def resource_read_fn() -> str | bytes:
mime_type=self.mime_type,
tags=self.tags,
enabled=self.enabled,
task=self.task,
task=self.task_config,
)

async def read(self, arguments: dict[str, Any]) -> str | bytes:
Expand Down Expand Up @@ -302,7 +301,7 @@ def from_function(
enabled: bool | None = None,
annotations: Annotations | None = None,
meta: dict[str, Any] | None = None,
task: bool | None = None,
task: bool | TaskConfig | None = None,
) -> FunctionResourceTemplate:
"""Create a template from a function."""

Expand Down Expand Up @@ -373,20 +372,22 @@ def from_function(

description = description or inspect.getdoc(fn)

# Normalize task to TaskConfig and validate
if task is None:
task_config = TaskConfig(mode="forbidden")
elif isinstance(task, bool):
task_config = TaskConfig.from_bool(task)
else:
task_config = task
task_config.validate_function(fn, func_name)

# if the fn is a callable class, we need to get the __call__ method from here out
if not inspect.isroutine(fn):
fn = fn.__call__
# if the fn is a staticmethod, we need to work with the underlying function
if isinstance(fn, staticmethod):
fn = fn.__func__

# Validate that task=True requires async functions (after unwrapping)
if task and not inspect.iscoroutinefunction(fn):
raise ValueError(
f"Resource template '{func_name}' uses a sync function but has task=True. "
"Background tasks require async functions. Set task=False to disable."
)

wrapper_fn = without_injected_parameters(fn)
type_adapter = get_cached_typeadapter(wrapper_fn)
parameters = type_adapter.json_schema()
Expand All @@ -408,5 +409,5 @@ def from_function(
enabled=enabled if enabled is not None else True,
annotations=annotations,
meta=meta,
task=task if task is not None else False,
task_config=task_config,
)
Loading
Loading