Auto-route Docket-dependent components through Docket execution

Replace tool timeout parameter with Docket's native Timeout dependency.
Components declaring Docket deps (Timeout, Retry, etc.) are now:
- Registered with Docket at startup
- Auto-routed through Docket even for foreground calls

Known limitations to discuss:
- Context not available in Docket workers
- Registration bug: register_with_docket still checks supports_tasks()
- Error propagation: ToolError wrapped in McpError
- Performance: every call goes through Redis+worker
This commit is contained in:
Jeremiah Lowin 2026-01-22 09:42:37 -05:00
commit 78cc19b029
19 changed files with 521 additions and 275 deletions

View file

@ -174,6 +174,40 @@ from fastmcp.server import create_proxy
proxy = create_proxy("http://example.com/mcp")
```
## v3.0.0b2 (from v3.0.0b1)
### Breaking Changes
#### Tool `timeout` Parameter Removed
The `timeout` parameter on `@mcp.tool()` has been removed. Use Docket's `Timeout` dependency instead:
```python
# Before (v3.0.0b1)
@mcp.tool(timeout=30.0)
async def fetch_data(url: str) -> dict:
...
# After (v3.0.0b2)
from datetime import timedelta
from docket import Timeout
@mcp.tool
async def fetch_data(
url: str,
timeout: Timeout = Timeout(timedelta(seconds=30)),
) -> dict:
...
```
Benefits of the new approach:
- **Unified execution**: Timeouts work identically for foreground and background tasks
- **Additional capabilities**: Access to `Retry`, `ExponentialRetry`, `ConcurrencyLimit`
- **Auto-routing**: Components with Docket dependencies automatically route through Docket
Note: This is only breaking from beta 1 → beta 2. The `timeout` parameter didn't exist before beta 1.
## v2.14.0
### OpenAPI Parser Promotion

View file

@ -810,20 +810,53 @@ Documentation: [Lifespan](/servers/lifespan)
---
## Tool Timeout
The features above were released in **v3.0.0b1**.
Tools can limit foreground execution time with a `timeout` parameter ([#2872](https://github.com/jlowin/fastmcp/pull/2872)):
---
## v3.0 Beta 2 Features
---
## Auto-Docket Execution
Components with Docket dependencies (like `Timeout`, `Retry`, `ExponentialRetry`) automatically route through Docket, ensuring consistent behavior for both foreground and background execution.
```python
@mcp.tool(timeout=30.0)
async def fetch_data(url: str) -> dict:
"""Fetch with 30-second timeout."""
from datetime import timedelta
from docket import Timeout, ExponentialRetry
from fastmcp import FastMCP
mcp = FastMCP("Server")
@mcp.tool
async def fetch_data(
url: str,
timeout: Timeout = Timeout(timedelta(seconds=30)),
retry: ExponentialRetry = ExponentialRetry(max_attempts=3),
) -> dict:
"""Fetch with timeout and automatic retry."""
...
```
When exceeded, clients receive MCP error code `-32000`. Both sync and async tools are supported—sync functions run in thread pools so the timeout applies regardless of execution model.
When FastMCP detects these Docket dependencies in a component's function signature, it:
Note: This timeout applies to foreground execution only. Background tasks (`task=True`) execute in Docket workers where this timeout isn't enforced.
1. **Registers the component with Docket at startup** - even if `task=True` wasn't specified
2. **Routes foreground calls through Docket** - ensuring timeouts/retries work correctly
3. **Uses the same code path for background tasks** - unified execution model
This replaces the previous `timeout` parameter on `@mcp.tool()`. The Docket approach has several advantages:
- **Unified behavior**: Timeouts and retries work identically for foreground and background execution
- **Battle-tested implementation**: Docket's timeout/retry logic handles edge cases properly
- **Composable**: Multiple dependencies can be combined (timeout + retry + concurrency limits)
- **Declarative**: Dependencies in the function signature clearly document requirements
Supported Docket dependencies:
- `Timeout` - Cancel execution after a duration
- `Retry` / `ExponentialRetry` - Retry on failure with configurable backoff
- `ConcurrencyLimit` - Limit concurrent executions
- `CurrentDocket` / `CurrentWorker` / `CurrentExecution` - Access Docket runtime context
---

View file

@ -115,12 +115,6 @@ def search_products_implementation(query: str, category: str | None = None) -> l
Optional meta information about the tool. This data is passed through to the MCP client as the `meta` field of the client-side tool object and can be used for custom metadata, versioning, or other application-specific purposes.
</ParamField>
<ParamField body="timeout" type="float | None">
<VersionBadge version="3.0.0" />
Execution timeout in seconds. If the tool takes longer than this to complete, an MCP error is returned to the client. See [Timeouts](#timeouts) for details.
</ParamField>
<ParamField body="version" type="str | int | None">
<VersionBadge version="3.0.0" />
@ -779,57 +773,51 @@ def divide(a: float, b: float) -> float:
When `mask_error_details=True`, only error messages from `ToolError` will include details, other exceptions will be converted to a generic message.
## Timeouts
## Timeouts and Retries
<VersionBadge version="3.0.0" />
Tools can specify a `timeout` parameter to limit how long execution can take. When the timeout is exceeded, the client receives an MCP error and the tool stops processing. This protects your server from unexpectedly slow operations that could block resources or leave clients waiting indefinitely.
FastMCP supports timeouts and retries through Docket's dependency injection system. When you use Docket dependencies like `Timeout` or `Retry` in your tool functions, FastMCP automatically routes execution through Docket to honor them.
```python
from datetime import timedelta
from docket import Timeout, ExponentialRetry
from fastmcp import FastMCP
mcp = FastMCP()
@mcp.tool(timeout=30.0)
async def fetch_data(url: str) -> dict:
"""Fetch data with a 30-second timeout."""
# If this takes longer than 30 seconds,
# the client receives an MCP error
@mcp.tool
async def fetch_data(
url: str,
timeout: Timeout = Timeout(timedelta(seconds=30)),
retry: ExponentialRetry = ExponentialRetry(max_attempts=3)
) -> dict:
"""Fetch data with a 30-second timeout and automatic retries."""
...
```
Timeouts are specified in seconds as a float. When a tool exceeds its timeout, FastMCP returns an MCP error with code `-32000` and a message indicating which tool timed out and how long it ran. Both sync and async tools support timeouts—sync functions run in thread pools, so the timeout applies to the entire operation regardless of execution model.
When FastMCP detects Docket dependencies in your function signature, it runs the tool through Docket's execution engine even for foreground (non-background) calls. This gives you:
- **Unified behavior**: Timeouts and retries work the same whether the tool runs as a foreground call or a background task
- **Battle-tested implementation**: Docket's execution model handles cancellation, retry scheduling, and failure tracking
- **Additional features**: Access to concurrency limits, progress reporting, and more
<Note>
Tools must explicitly opt-in to timeouts. There is no server-level default timeout setting.
Using Docket dependencies requires installing FastMCP with the tasks extra: `pip install 'fastmcp[tasks]'`
</Note>
### Timeouts vs Background Tasks
### Available Dependencies
Timeouts apply to **foreground execution**—when a tool runs directly in response to a client request. They protect your server from tools that unexpectedly hang due to network issues, resource contention, or other transient problems.
Docket provides several dependencies you can use in your tools:
<Warning>
The `timeout` parameter does **not** apply to background tasks. When a tool runs as a background task (`task=True`), execution happens in a Docket worker where the FastMCP timeout is not enforced.
| Dependency | Purpose |
|------------|---------|
| `Timeout(timedelta)` | Cancel execution if it exceeds the duration |
| `Retry` / `ExponentialRetry` | Automatically retry on failure |
| `ConcurrencyLimit(n)` | Limit concurrent executions |
| `Progress()` | Report progress during execution |
For task timeouts, use Docket's `Timeout` dependency directly in your function signature:
```python
from datetime import timedelta
from docket import Timeout
@mcp.tool(task=True)
async def long_running_task(
data: str,
timeout: Timeout = Timeout(timedelta(minutes=10))
) -> str:
"""Task with a 10-minute timeout enforced by Docket."""
...
```
See the [Docket documentation](https://chrisguidry.github.io/docket/dependencies/#task-timeouts) for more on task timeouts and retries.
</Warning>
When a tool times out, FastMCP logs a warning suggesting task mode. For operations you know will be long-running, use `task=True` instead—background tasks offload work to distributed workers and let clients poll for progress.
See the [Docket documentation](https://chrisguidry.github.io/docket/dependencies/) for full details on each dependency.
## Component Visibility

View file

@ -313,6 +313,15 @@ class FunctionPrompt(Prompt):
logger.exception(f"Error rendering prompt {self.name}")
raise PromptError(f"Error rendering prompt {self.name}.") from e
@property
def docket_callable(self) -> Callable[..., Any]:
"""Return the callable that would be registered with Docket.
FunctionPrompt returns self.fn (the underlying function with user's
Depends parameters for Docket to resolve).
"""
return self.fn
def register_with_docket(self, docket: Docket) -> None:
"""Register this prompt with docket for background execution.
@ -321,7 +330,7 @@ class FunctionPrompt(Prompt):
"""
if not self.task_config.supports_tasks():
return
docket.register(self.fn, names=[self.key])
docket.register(self.docket_callable, names=[self.key])
async def add_to_docket(
self,

View file

@ -357,17 +357,25 @@ class Prompt(FastMCPComponent):
task_meta=task_meta,
)
if task_result:
return task_result
return task_result # type: ignore[return-value]
# Synchronous execution
result = await self.render(arguments)
return self.convert_result(result)
@property
def docket_callable(self) -> Callable[..., Any]:
"""Return the callable that would be registered with Docket.
For base Prompt, this is self.render. FunctionPrompt overrides to return self.fn.
"""
return self.render
def register_with_docket(self, docket: Docket) -> None:
"""Register this prompt with docket for background execution."""
if not self.task_config.supports_tasks():
return
docket.register(self.render, names=[self.key])
docket.register(self.docket_callable, names=[self.key])
async def add_to_docket( # type: ignore[override]
self,

View file

@ -217,6 +217,15 @@ class FunctionResource(Resource):
return result
@property
def docket_callable(self) -> Callable[..., Any]:
"""Return the callable that would be registered with Docket.
FunctionResource returns self.fn (the underlying function with user's
Depends parameters for Docket to resolve).
"""
return self.fn
def register_with_docket(self, docket: Docket) -> None:
"""Register this resource with docket for background execution.
@ -225,7 +234,7 @@ class FunctionResource(Resource):
"""
if not self.task_config.supports_tasks():
return
docket.register(self.fn, names=[self.key])
docket.register(self.docket_callable, names=[self.key])
def resource(

View file

@ -349,7 +349,7 @@ class Resource(FastMCPComponent):
component=self, task_type="resource", arguments=None, task_meta=task_meta
)
if task_result:
return task_result
return task_result # type: ignore[return-value]
# Synchronous execution - convert result to ResourceResult
result = await self.read()
@ -383,11 +383,19 @@ class Resource(FastMCPComponent):
base_key = self.make_key(str(self.uri))
return f"{base_key}@{self.version or ''}"
@property
def docket_callable(self) -> Callable[..., Any]:
"""Return the callable that would be registered with Docket.
For base Resource, this is self.read. FunctionResource overrides to return self.fn.
"""
return self.read
def register_with_docket(self, docket: Docket) -> None:
"""Register this resource with docket for background execution."""
if not self.task_config.supports_tasks():
return
docket.register(self.read, names=[self.key])
docket.register(self.docket_callable, names=[self.key])
async def add_to_docket( # type: ignore[override]
self,

View file

@ -230,7 +230,7 @@ class ResourceTemplate(FastMCPComponent):
component=self, task_type="template", arguments=params, task_meta=task_meta
)
if task_result:
return task_result
return task_result # type: ignore[return-value]
# Synchronous execution - create resource and read directly
# Call resource.read() not resource._read() to avoid task routing on ephemeral resource
@ -287,11 +287,19 @@ class ResourceTemplate(FastMCPComponent):
base_key = self.make_key(self.uri_template)
return f"{base_key}@{self.version or ''}"
@property
def docket_callable(self) -> Callable[..., Any]:
"""Return the callable that would be registered with Docket.
For base ResourceTemplate, this is self.read. FunctionResourceTemplate overrides to return self.fn.
"""
return self.read
def register_with_docket(self, docket: Docket) -> None:
"""Register this template with docket for background execution."""
if not self.task_config.supports_tasks():
return
docket.register(self.read, names=[self.key])
docket.register(self.docket_callable, names=[self.key])
async def add_to_docket( # type: ignore[override]
self,
@ -363,7 +371,7 @@ class FunctionResourceTemplate(ResourceTemplate):
component=self, task_type="template", arguments=params, task_meta=task_meta
)
if task_result:
return task_result
return task_result # type: ignore[return-value]
# Synchronous execution - call read() directly, skip resource creation
result = await self.read(arguments=params)
@ -419,6 +427,15 @@ class FunctionResourceTemplate(ResourceTemplate):
return result
@property
def docket_callable(self) -> Callable[..., Any]:
"""Return the callable that would be registered with Docket.
FunctionResourceTemplate returns self.fn (the underlying function with user's
Depends parameters for Docket to resolve).
"""
return self.fn
def register_with_docket(self, docket: Docket) -> None:
"""Register this template with docket for background execution.
@ -427,7 +444,7 @@ class FunctionResourceTemplate(ResourceTemplate):
"""
if not self.task_config.supports_tasks():
return
docket.register(self.fn, names=[self.key])
docket.register(self.docket_callable, names=[self.key])
async def add_to_docket(
self,

View file

@ -54,6 +54,7 @@ __all__ = [
"get_server",
"is_docket_available",
"require_docket",
"requires_docket_execution",
"resolve_dependencies",
"transform_context_annotations",
"without_injected_parameters",
@ -102,6 +103,61 @@ def require_docket(feature: str) -> None:
)
def requires_docket_execution(component: Any) -> bool:
"""Check if a component has Docket-specific dependencies requiring Docket execution.
Docket provides dependencies like Timeout, Retry, ExponentialRetry that
integrate with its task lifecycle. If a component's callable uses these,
it must be executed through Docket for them to function properly.
Args:
component: A FastMCP component (Tool, Resource, Prompt, etc.) with a
docket_callable property.
Returns:
True if the component's docket_callable uses Docket-specific dependencies.
"""
callable_fn = getattr(component, "docket_callable", None)
if callable_fn is None:
return False
if not is_docket_available():
return False # Can't have Docket deps without Docket installed
try:
from docket.dependencies import (
ConcurrencyLimit,
CurrentDocket,
CurrentExecution,
CurrentWorker,
ExponentialRetry,
Retry,
Timeout,
)
from docket.dependencies import (
get_dependency_parameters as _get_dep_params,
)
except ImportError:
return False
# Docket dependency types that require Docket execution
DOCKET_TYPES = (
Timeout,
Retry,
ExponentialRetry,
ConcurrencyLimit,
CurrentDocket,
CurrentWorker,
CurrentExecution,
)
# Use the DI system to find dependencies
deps = _get_dep_params(callable_fn)
# Check if any dependency is a Docket type
return any(isinstance(d, DOCKET_TYPES) for d in deps.values())
# --- Dependency injection imports ---
# Try docket first for isinstance compatibility in worker context,
# fall back to vendored DI engine when docket is not installed.

View file

@ -416,14 +416,18 @@ class Provider:
# -------------------------------------------------------------------------
async def get_tasks(self) -> Sequence[FastMCPComponent]:
"""Return components that should be registered as background tasks.
"""Return components that should be registered with Docket.
Override to customize which components are task-eligible.
Default calls list_* methods, applies provider transforms, and filters
for components with task_config.mode != 'forbidden'.
for components that either:
- Have task_config.mode != 'forbidden' (explicit task support), OR
- Have Docket-specific dependencies (Timeout, Retry, etc.)
Used by the server during startup to register functions with Docket.
"""
from fastmcp.server.dependencies import requires_docket_execution
# Fetch all component types in parallel
results = await gather(
self._list_tools(),
@ -452,7 +456,7 @@ class Provider:
*templates,
*prompts,
]
if c.task_config.supports_tasks()
if c.task_config.supports_tasks() or requires_docket_execution(c)
]
# -------------------------------------------------------------------------

View file

@ -64,7 +64,6 @@ class ToolDecoratorMixin:
task=resolved_task,
exclude_args=meta.exclude_args,
serializer=meta.serializer,
timeout=meta.timeout,
auth=meta.auth,
)
else:
@ -114,7 +113,6 @@ class ToolDecoratorMixin:
enabled: bool = True,
task: bool | TaskConfig | None = None,
serializer: ToolResultSerializerType | None = None, # Deprecated
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
) -> Callable[[AnyFunction], FunctionTool]: ...
@ -139,7 +137,6 @@ class ToolDecoratorMixin:
enabled: bool = True,
task: bool | TaskConfig | None = None,
serializer: ToolResultSerializerType | None = None, # Deprecated
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
) -> (
Callable[[AnyFunction], FunctionTool]
@ -243,7 +240,6 @@ class ToolDecoratorMixin:
meta=meta,
serializer=serializer,
task=resolved_task,
timeout=timeout,
auth=auth,
)
self._add_component(tool_obj)
@ -266,7 +262,6 @@ class ToolDecoratorMixin:
task=task,
exclude_args=exclude_args,
serializer=serializer,
timeout=timeout,
auth=auth,
enabled=enabled,
)
@ -310,6 +305,5 @@ class ToolDecoratorMixin:
enabled=enabled,
task=task,
serializer=serializer,
timeout=timeout,
auth=auth,
)

View file

@ -447,13 +447,22 @@ class LocalProvider(
# =========================================================================
async def get_tasks(self) -> Sequence[FastMCPComponent]:
"""Return components eligible for background task execution.
"""Return components eligible for Docket registration.
Returns components that:
- Have task_config.mode != 'forbidden' (explicit task support), OR
- Have Docket-specific dependencies (Timeout, Retry, etc.)
Returns components that have task_config.mode != 'forbidden'.
This includes both FunctionTool/Resource/Prompt instances created via
decorators and custom Tool/Resource/Prompt subclasses.
"""
return [c for c in self._components.values() if c.task_config.supports_tasks()]
from fastmcp.server.dependencies import requires_docket_execution
return [
c
for c in self._components.values()
if c.task_config.supports_tasks() or requires_docket_execution(c)
]
# =========================================================================
# Decorator methods

View file

@ -1371,7 +1371,6 @@ class FastMCP(
exclude_args: list[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
) -> FunctionTool: ...
@ -1391,7 +1390,6 @@ class FastMCP(
exclude_args: list[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
) -> Callable[[AnyFunction], FunctionTool]: ...
@ -1410,7 +1408,6 @@ class FastMCP(
exclude_args: list[str] | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
) -> (
Callable[[AnyFunction], FunctionTool]
@ -1479,7 +1476,6 @@ class FastMCP(
exclude_args=exclude_args,
meta=meta,
task=task if task is not None else self._support_tasks_by_default,
timeout=timeout,
serializer=self._tool_serializer,
auth=auth,
)

View file

@ -8,21 +8,23 @@ from __future__ import annotations
import uuid
from contextlib import suppress
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Literal
from typing import Any, Literal
import mcp.types
from docket.execution import ExecutionState
from mcp.shared.exceptions import McpError
from mcp.types import INTERNAL_ERROR, ErrorData
from fastmcp.prompts.prompt import Prompt, PromptResult
from fastmcp.resources.resource import Resource, ResourceResult
from fastmcp.resources.template import ResourceTemplate
from fastmcp.server.dependencies import _current_docket, get_context
from fastmcp.server.tasks.config import TaskMeta
from fastmcp.server.tasks.keys import build_task_key
from fastmcp.tools.tool import Tool, ToolResult
from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
from fastmcp.prompts.prompt import Prompt
from fastmcp.resources.resource import Resource
from fastmcp.resources.template import ResourceTemplate
from fastmcp.tools.tool import Tool
logger = get_logger(__name__)
# Redis mapping TTL buffer: Add 15 minutes to Docket's execution_ttl
TASK_MAPPING_TTL_BUFFER_SECONDS = 15 * 60
@ -155,3 +157,86 @@ async def submit_to_docket(
pollInterval=poll_interval_ms,
)
)
async def run_in_docket_sync(
task_type: Literal["tool", "resource", "template", "prompt"],
component: Tool | Resource | ResourceTemplate | Prompt,
arguments: dict[str, Any] | None = None,
) -> ToolResult | ResourceResult | PromptResult:
"""Submit to Docket and wait for result (foreground semantics).
This is used when a component has Docket-specific dependencies (Timeout,
Retry, etc.) but the caller didn't request background execution. We run
through Docket to honor the dependencies, but wait for the result.
Args:
task_type: Component type ("tool", "resource", "template", "prompt")
component: The component instance
arguments: Arguments for tool/prompt/template execution
Returns:
The component's result (ToolResult, ResourceResult, or PromptResult)
Raises:
McpError: If Docket is not available or execution fails
"""
docket = _current_docket.get()
if docket is None:
raise McpError(
ErrorData(
code=INTERNAL_ERROR,
message="Docket dependencies require a running FastMCP server with Docket enabled",
)
)
# Generate a unique task key for this sync execution
task_key = f"fastmcp:sync:{uuid.uuid4()}"
fn_key = component.key
# Queue the function to Docket
# Component types have different add_to_docket signatures
if isinstance(component, Resource):
execution = await component.add_to_docket(
docket, fn_key=fn_key, task_key=task_key
)
elif isinstance(component, (Tool, ResourceTemplate)):
args = arguments if arguments is not None else {}
execution = await component.add_to_docket(
docket, args, fn_key=fn_key, task_key=task_key
)
else: # Prompt
execution = await component.add_to_docket(
docket, arguments, fn_key=fn_key, task_key=task_key
)
# Wait for execution to complete
terminal_states: set[ExecutionState] = {
ExecutionState.COMPLETED,
ExecutionState.FAILED,
ExecutionState.CANCELLED,
}
async for event in execution.subscribe():
if hasattr(event, "state") and event.state in terminal_states:
break
# Get the result - execution is complete so this returns immediately.
# If the task failed, get_result re-raises the stored exception (which could
# be any exception type from the user's function), so we catch broadly.
try:
raw_result = await execution.get_result()
except McpError:
raise # Don't wrap MCP errors
except Exception as e:
logger.error(f"Docket execution failed for {component.key}: {e}")
raise McpError(
ErrorData(
code=INTERNAL_ERROR,
message=f"Docket execution failed: {e}",
)
) from e
# Convert raw result to appropriate MCP type
# The convert_result method handles the transformation
return component.convert_result(raw_result)

View file

@ -11,14 +11,15 @@ import mcp.types
from mcp.shared.exceptions import McpError
from mcp.types import METHOD_NOT_FOUND, ErrorData
from fastmcp.server.dependencies import requires_docket_execution
from fastmcp.server.tasks.config import TaskMeta
from fastmcp.server.tasks.handlers import submit_to_docket
from fastmcp.server.tasks.handlers import run_in_docket_sync, submit_to_docket
if TYPE_CHECKING:
from fastmcp.prompts.prompt import Prompt
from fastmcp.resources.resource import Resource
from fastmcp.prompts.prompt import Prompt, PromptResult
from fastmcp.resources.resource import Resource, ResourceResult
from fastmcp.resources.template import ResourceTemplate
from fastmcp.tools.tool import Tool
from fastmcp.tools.tool import Tool, ToolResult
TaskType = Literal["tool", "resource", "template", "prompt"]
@ -28,9 +29,13 @@ async def check_background_task(
task_type: TaskType,
arguments: dict[str, Any] | None = None,
task_meta: TaskMeta | None = None,
) -> mcp.types.CreateTaskResult | None:
) -> mcp.types.CreateTaskResult | ToolResult | ResourceResult | PromptResult | None:
"""Check task mode and submit to background if requested.
Also handles auto-routing for components with Docket dependencies:
if a component uses Timeout, Retry, etc. and wasn't explicitly requested
as a background task, we run it through Docket with sync-wait semantics.
Args:
component: The MCP component
task_type: Type of task ("tool", "resource", "template", "prompt")
@ -38,7 +43,9 @@ async def check_background_task(
task_meta: Task execution metadata. If provided, execute as background task.
Returns:
CreateTaskResult if submitted to docket, None for sync execution
CreateTaskResult if submitted to docket as background task,
ToolResult/ResourceResult/PromptResult if auto-routed through Docket sync,
None for regular sync execution
Raises:
McpError: If mode="required" but no task metadata, or mode="forbidden"
@ -67,7 +74,12 @@ async def check_background_task(
)
)
# No task metadata - synchronous execution
# Auto-route through Docket if component has Docket dependencies
# This ensures Timeout, Retry, etc. work even for foreground calls
if not task_meta and requires_docket_execution(component):
return await run_in_docket_sync(task_type, component, arguments)
# No task metadata - regular synchronous execution
if not task_meta:
return None

View file

@ -16,10 +16,8 @@ from typing import (
runtime_checkable,
)
import anyio
import mcp.types
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData, Icon, ToolAnnotations, ToolExecution
from mcp.types import Icon, ToolAnnotations, ToolExecution
import fastmcp
from fastmcp.decorators import resolve_task_config
@ -75,7 +73,6 @@ class ToolMeta:
task: bool | TaskConfig | None = None
exclude_args: list[str] | None = None
serializer: Any | None = None
timeout: float | None = None
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None
enabled: bool = True
@ -120,7 +117,6 @@ class FunctionTool(Tool):
serializer: ToolResultSerializerType | None = None,
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
) -> FunctionTool:
"""Create a FunctionTool from a function.
@ -147,7 +143,6 @@ class FunctionTool(Tool):
meta,
task,
serializer,
timeout,
auth,
]
)
@ -176,7 +171,6 @@ class FunctionTool(Tool):
task=task,
exclude_args=exclude_args,
serializer=serializer,
timeout=timeout,
auth=auth,
)
@ -240,7 +234,6 @@ class FunctionTool(Tool):
serializer=metadata.serializer,
meta=metadata.meta,
task_config=task_config,
timeout=metadata.timeout,
auth=metadata.auth,
)
@ -249,46 +242,26 @@ class FunctionTool(Tool):
wrapper_fn = without_injected_parameters(self.fn)
type_adapter = get_cached_typeadapter(wrapper_fn)
# Apply timeout if configured
if self.timeout is not None:
try:
with anyio.fail_after(self.timeout):
# Thread pool execution for sync functions, direct await for async
if inspect.iscoroutinefunction(wrapper_fn):
result = await type_adapter.validate_python(arguments)
else:
# Sync function: run in threadpool to avoid blocking
result = await call_sync_fn_in_threadpool(
type_adapter.validate_python, arguments
)
# Handle sync wrappers that return awaitables
if inspect.isawaitable(result):
result = await result
except TimeoutError:
logger.warning(
f"Tool '{self.name}' timed out after {self.timeout}s. "
f"Consider using task=True for long-running operations. "
f"See https://gofastmcp.com/servers/tasks"
)
raise McpError(
ErrorData(
code=-32000,
message=f"Tool '{self.name}' execution timed out after {self.timeout}s",
)
) from None
if inspect.iscoroutinefunction(wrapper_fn):
result = await type_adapter.validate_python(arguments)
else:
# No timeout: use existing execution path
if inspect.iscoroutinefunction(wrapper_fn):
result = await type_adapter.validate_python(arguments)
else:
result = await call_sync_fn_in_threadpool(
type_adapter.validate_python, arguments
)
if inspect.isawaitable(result):
result = await result
result = await call_sync_fn_in_threadpool(
type_adapter.validate_python, arguments
)
if inspect.isawaitable(result):
result = await result
return self.convert_result(result)
@property
def docket_callable(self) -> Callable[..., Any]:
"""Return the callable that would be registered with Docket.
FunctionTool returns self.fn (the underlying function with user's
Depends parameters for Docket to resolve).
"""
return self.fn
def register_with_docket(self, docket: Docket) -> None:
"""Register this tool with docket for background execution.
@ -297,7 +270,7 @@ class FunctionTool(Tool):
"""
if not self.task_config.supports_tasks():
return
docket.register(self.fn, names=[self.key])
docket.register(self.docket_callable, names=[self.key])
async def add_to_docket(
self,
@ -342,7 +315,6 @@ def tool(
task: bool | TaskConfig | None = None,
exclude_args: list[str] | None = None,
serializer: Any | None = None,
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
) -> Callable[[F], F]: ...
@overload
@ -361,7 +333,6 @@ def tool(
task: bool | TaskConfig | None = None,
exclude_args: list[str] | None = None,
serializer: Any | None = None,
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
) -> Callable[[F], F]: ...
@ -381,7 +352,6 @@ def tool(
task: bool | TaskConfig | None = None,
exclude_args: list[str] | None = None,
serializer: Any | None = None,
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
) -> Any:
"""Standalone decorator to mark a function as an MCP tool.
@ -413,7 +383,6 @@ def tool(
task=resolve_task_config(task),
exclude_args=exclude_args,
serializer=serializer,
timeout=timeout,
auth=auth,
)
return FunctionTool.from_function(fn, metadata=tool_meta)
@ -432,7 +401,6 @@ def tool(
task=task,
exclude_args=exclude_args,
serializer=serializer,
timeout=timeout,
auth=auth,
)
target = fn.__func__ if hasattr(fn, "__func__") else fn

View file

@ -149,12 +149,6 @@ class Tool(FastMCPComponent):
AuthCheckCallable | list[AuthCheckCallable] | None,
Field(description="Authorization checks for this tool", exclude=True),
] = None
timeout: Annotated[
float | None,
Field(
description="Execution timeout in seconds. If None, no timeout is applied."
),
] = None
@model_validator(mode="after")
def _validate_tool_name(self) -> Tool:
@ -205,7 +199,6 @@ class Tool(FastMCPComponent):
serializer: ToolResultSerializerType | None = None, # Deprecated
meta: dict[str, Any] | None = None,
task: bool | TaskConfig | None = None,
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
) -> FunctionTool:
"""Create a Tool from a function."""
@ -225,7 +218,6 @@ class Tool(FastMCPComponent):
serializer=serializer,
meta=meta,
task=task,
timeout=timeout,
auth=auth,
)
@ -329,15 +321,23 @@ class Tool(FastMCPComponent):
task_meta=task_meta,
)
if task_result:
return task_result
return task_result # type: ignore[return-value]
return await self.run(arguments)
@property
def docket_callable(self) -> Callable[..., Any]:
"""Return the callable that would be registered with Docket.
For base Tool, this is self.run. FunctionTool overrides to return self.fn.
"""
return self.run
def register_with_docket(self, docket: Docket) -> None:
"""Register this tool with docket for background execution."""
if not self.task_config.supports_tasks():
return
docket.register(self.run, names=[self.key])
docket.register(self.docket_callable, names=[self.key])
async def add_to_docket( # type: ignore[override]
self,

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Sequence
from collections.abc import Callable, Sequence
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, TypedDict, cast
from mcp.types import Icon
@ -204,6 +204,18 @@ class FastMCPComponent(FastMCPBaseModel):
"""Create a copy of the component."""
return self.model_copy()
@property
def docket_callable(self) -> Callable[..., Any] | None:
"""Return the callable that would be registered with Docket.
Subclasses override to return their specific callable:
- Tool: self.run (or self.fn for FunctionTool)
- Resource: self.read (or self.fn for FunctionResource)
- Prompt: self.render (or self.fn for FunctionPrompt)
- ResourceTemplate: self.read (or self.fn for FunctionResourceTemplate)
"""
return None # Base implementation; subclasses override
def register_with_docket(self, docket: Docket) -> None:
"""Register this component with docket for background execution.

View file

@ -1,21 +1,29 @@
"""Tests for tool timeout functionality."""
"""Tests for tool timeout and Docket dependency detection."""
import time
import anyio
import pytest
from mcp.shared.exceptions import McpError
from mcp.types import TextContent
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
class TestToolTimeout:
"""Test tool timeout behavior."""
def _docket_available() -> bool:
"""Check if Docket is available."""
try:
import docket # noqa: F401
async def test_no_timeout_completes_normally_async(self):
"""Tool without timeout completes normally (async)."""
return True
except ImportError:
return False
class TestToolExecution:
"""Test basic tool execution (timeout parameter removed in v3.0.0b2)."""
async def test_async_tool_completes_normally(self):
"""Async tool completes normally."""
mcp = FastMCP()
@mcp.tool
@ -27,8 +35,8 @@ class TestToolTimeout:
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "completed"
async def test_no_timeout_completes_normally_sync(self):
"""Tool without timeout completes normally (sync)."""
async def test_sync_tool_completes_normally(self):
"""Sync tool completes normally."""
mcp = FastMCP()
@mcp.tool
@ -40,153 +48,149 @@ class TestToolTimeout:
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "completed"
async def test_timeout_not_reached_async(self):
"""Async tool with timeout completes before timeout."""
async def test_multiple_tools_complete_normally(self):
"""Multiple tools can run and complete."""
mcp = FastMCP()
@mcp.tool(timeout=5.0)
async def fast_async_tool() -> str:
await anyio.sleep(0.1)
return "completed"
result = await mcp.call_tool("fast_async_tool")
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "completed"
async def test_timeout_not_reached_sync(self):
"""Sync tool with timeout completes before timeout."""
mcp = FastMCP()
@mcp.tool(timeout=5.0)
def fast_sync_tool() -> str:
time.sleep(0.1)
return "completed"
result = await mcp.call_tool("fast_sync_tool")
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "completed"
async def test_async_timeout_exceeded(self):
"""Async tool exceeds timeout and raises TimeoutError."""
mcp = FastMCP()
@mcp.tool(timeout=0.2)
async def slow_async_tool() -> str:
await anyio.sleep(2.0)
return "should not reach"
# TimeoutError is caught and converted to ToolError by FastMCP
with pytest.raises(ToolError) as exc_info:
await mcp.call_tool("slow_async_tool")
# Verify the tool raised an error (error message may be masked)
assert exc_info.value is not None
async def test_sync_timeout_exceeded(self):
"""Sync tool timeout works with CPU-bound operations."""
pytest.skip(
"Sync timeouts require thread pool execution (coming in future commit)"
)
# Note: time.sleep() blocks the event loop and cannot be interrupted
# by anyio.fail_after(). This will work once sync functions run in
# thread pools (commit 8c471a49).
async def test_timeout_error_raises_tool_error(self):
"""Timeout error is converted to ToolError and logs warning."""
mcp = FastMCP()
@mcp.tool(timeout=0.1)
async def slow_tool() -> str:
await anyio.sleep(1.0)
return "never"
# Verify that ToolError is raised (timeout warning is logged to stderr)
with pytest.raises(ToolError):
await mcp.call_tool("slow_tool")
async def test_timeout_from_tool_from_function(self):
"""Timeout works when using Tool.from_function()."""
from fastmcp.tools import Tool
async def my_slow_tool() -> str:
await anyio.sleep(1.0)
return "never"
tool = Tool.from_function(my_slow_tool, timeout=0.1)
mcp = FastMCP()
mcp.add_tool(tool)
with pytest.raises(ToolError):
await mcp.call_tool("my_slow_tool")
async def test_timeout_zero_times_out_immediately(self):
"""Timeout of 0 times out immediately."""
mcp = FastMCP()
@mcp.tool(timeout=0.0)
async def instant_timeout() -> str:
await anyio.sleep(0) # Give the event loop a chance to check timeout
return "never"
with pytest.raises(ToolError):
await mcp.call_tool("instant_timeout")
async def test_timeout_with_task_mode(self):
"""Tool with timeout and task mode can be configured together."""
mcp = FastMCP(tasks=True)
@mcp.tool(task=True, timeout=1.0)
async def task_with_timeout() -> str:
await anyio.sleep(0.1)
return "completed"
# Tool should be registered successfully
tools = await mcp.list_tools()
tool = next((t for t in tools if t.name == "task_with_timeout"), None)
assert tool is not None
assert tool.timeout == 1.0
assert tool.task_config.supports_tasks()
async def test_multiple_tools_with_different_timeouts(self):
"""Multiple tools can have different timeout values."""
mcp = FastMCP()
@mcp.tool(timeout=1.0)
async def short_timeout() -> str:
await anyio.sleep(0.1)
return "short"
@mcp.tool(timeout=5.0)
async def long_timeout() -> str:
await anyio.sleep(0.1)
return "long"
@mcp.tool
async def no_timeout() -> str:
await anyio.sleep(0.1)
return "none"
async def tool_one() -> str:
await anyio.sleep(0.01)
return "one"
# All should complete successfully
result1 = await mcp.call_tool("short_timeout")
result2 = await mcp.call_tool("long_timeout")
result3 = await mcp.call_tool("no_timeout")
@mcp.tool
async def tool_two() -> str:
await anyio.sleep(0.01)
return "two"
@mcp.tool
def tool_three() -> str:
time.sleep(0.01)
return "three"
result1 = await mcp.call_tool("tool_one")
result2 = await mcp.call_tool("tool_two")
result3 = await mcp.call_tool("tool_three")
assert isinstance(result1.content[0], TextContent)
assert isinstance(result2.content[0], TextContent)
assert isinstance(result3.content[0], TextContent)
assert result1.content[0].text == "short"
assert result2.content[0].text == "long"
assert result3.content[0].text == "none"
assert result1.content[0].text == "one"
assert result2.content[0].text == "two"
assert result3.content[0].text == "three"
async def test_timeout_error_converted_to_tool_error(self):
"""Timeout errors are converted to ToolError by FastMCP."""
mcp = FastMCP()
@mcp.tool(timeout=0.1)
async def times_out() -> str:
await anyio.sleep(1.0)
return "never"
class TestDocketCallableProperty:
"""Test the docket_callable property on components."""
# TimeoutError should be caught and converted to ToolError
with pytest.raises((ToolError, McpError)):
await mcp.call_tool("times_out")
async def test_function_tool_docket_callable_returns_fn(self):
"""FunctionTool.docket_callable returns the underlying function."""
from fastmcp.tools import Tool
async def my_tool() -> str:
return "result"
tool = Tool.from_function(my_tool)
# FunctionTool should return self.fn
assert tool.docket_callable is not None
# It should be callable
assert callable(tool.docket_callable)
async def test_function_resource_docket_callable_returns_fn(self):
"""FunctionResource.docket_callable returns the underlying function."""
from fastmcp.resources import Resource
def my_resource() -> str:
return "data"
resource = Resource.from_function(my_resource, uri="data://test")
assert resource.docket_callable is not None
assert callable(resource.docket_callable)
async def test_function_prompt_docket_callable_returns_fn(self):
"""FunctionPrompt.docket_callable returns the underlying function."""
from fastmcp.prompts import Prompt
def my_prompt(topic: str) -> str:
return f"Write about {topic}"
prompt = Prompt.from_function(my_prompt)
assert prompt.docket_callable is not None
assert callable(prompt.docket_callable)
class TestRequiresDocketExecution:
"""Test the requires_docket_execution() function."""
def test_tool_without_docket_deps_returns_false(self):
"""Tool without Docket dependencies returns False."""
from fastmcp.server.dependencies import requires_docket_execution
from fastmcp.tools import Tool
async def simple_tool(x: int) -> int:
return x * 2
tool = Tool.from_function(simple_tool)
assert requires_docket_execution(tool) is False
def test_tool_with_regular_deps_returns_false(self):
"""Tool with regular (non-Docket) dependencies returns False."""
from fastmcp.server.context import Context
from fastmcp.server.dependencies import requires_docket_execution
from fastmcp.tools import Tool
async def tool_with_context(x: int, ctx: Context) -> int:
return x * 2
tool = Tool.from_function(tool_with_context)
assert requires_docket_execution(tool) is False
def test_component_without_docket_callable_returns_false(self):
"""Component without docket_callable returns False."""
from fastmcp.server.dependencies import requires_docket_execution
class FakeComponent:
pass
assert requires_docket_execution(FakeComponent()) is False
@pytest.mark.skipif(
not _docket_available(),
reason="Docket not installed",
)
def test_tool_with_timeout_dep_returns_true(self):
"""Tool with Docket Timeout dependency returns True."""
from datetime import timedelta
from docket import Timeout
from fastmcp.server.dependencies import requires_docket_execution
from fastmcp.tools import Tool
async def tool_with_timeout(
x: int,
timeout: Timeout = Timeout(timedelta(seconds=30)),
) -> int:
return x * 2
tool = Tool.from_function(tool_with_timeout)
assert requires_docket_execution(tool) is True
@pytest.mark.skipif(
not _docket_available(),
reason="Docket not installed",
)
def test_tool_with_retry_dep_returns_true(self):
"""Tool with Docket Retry dependency returns True."""
from docket import ExponentialRetry
from fastmcp.server.dependencies import requires_docket_execution
from fastmcp.tools import Tool
async def tool_with_retry(
x: int,
retry: ExponentialRetry = ExponentialRetry(attempts=3),
) -> int:
return x * 2
tool = Tool.from_function(tool_with_retry)
assert requires_docket_execution(tool) is True