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
60 changes: 59 additions & 1 deletion docs/servers/tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,15 @@ def search_products_implementation(query: str, category: str | None = None) -> l

<ParamField body="meta" type="dict[str, Any] | None">
<VersionBadge version="2.11.0" />

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>
</Card>

### Using with Methods
Expand Down Expand Up @@ -767,6 +773,58 @@ 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

<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.

```python
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
...
```

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.

<Note>
Tools must explicitly opt-in to timeouts. There is no server-level default timeout setting.
</Note>

### Timeouts vs Background Tasks

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.

<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.

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.

## Visibility Control

<VersionBadge version="3.0.0" />
Expand Down
7 changes: 7 additions & 0 deletions src/fastmcp/server/providers/local_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ def add_tool(self, tool: Tool | Callable[..., Any]) -> Tool:
task=resolved_task,
exclude_args=meta.exclude_args,
serializer=meta.serializer,
timeout=meta.timeout,
auth=meta.auth,
)
else:
Expand Down Expand Up @@ -434,6 +435,7 @@ def tool(
enabled: bool = True,
task: bool | TaskConfig | None = None,
serializer: ToolResultSerializerType | None = None, # Deprecated
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
) -> FunctionTool: ...

Expand All @@ -454,6 +456,7 @@ def tool(
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]: ...

Expand All @@ -477,6 +480,7 @@ def tool(
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]
Expand Down Expand Up @@ -579,6 +583,7 @@ def decorate_and_register(
meta=meta,
serializer=serializer,
task=resolved_task,
timeout=timeout,
auth=auth,
)
self._add_component(tool_obj)
Expand All @@ -600,6 +605,7 @@ def decorate_and_register(
task=task,
exclude_args=exclude_args,
serializer=serializer,
timeout=timeout,
auth=auth,
)
target = fn.__func__ if hasattr(fn, "__func__") else fn
Expand Down Expand Up @@ -643,6 +649,7 @@ def decorate_and_register(
enabled=enabled,
task=task,
serializer=serializer,
timeout=timeout,
auth=auth,
)

Expand Down
4 changes: 4 additions & 0 deletions src/fastmcp/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1993,6 +1993,7 @@ def tool(
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: ...

Expand All @@ -2011,6 +2012,7 @@ def tool(
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]: ...

Expand All @@ -2028,6 +2030,7 @@ def tool(
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]
Expand Down Expand Up @@ -2095,6 +2098,7 @@ def my_tool(x: int) -> str:
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,
)
Expand Down
63 changes: 52 additions & 11 deletions src/fastmcp/tools/function_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
runtime_checkable,
)

import anyio
import mcp.types
from mcp.types import Icon, ToolAnnotations, ToolExecution
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData, Icon, ToolAnnotations, ToolExecution

import fastmcp
from fastmcp.decorators import resolve_task_config
Expand All @@ -31,12 +33,15 @@
ToolResultSerializerType,
)
from fastmcp.utilities.async_utils import call_sync_fn_in_threadpool
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.types import (
NotSet,
NotSetT,
get_cached_typeadapter,
)

logger = get_logger(__name__)

if TYPE_CHECKING:
from docket import Docket
from docket.execution import Execution
Expand Down Expand Up @@ -69,6 +74,7 @@ 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


Expand Down Expand Up @@ -115,6 +121,7 @@ def from_function(
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.
Expand All @@ -140,6 +147,7 @@ def from_function(
meta,
task,
serializer,
timeout,
auth,
]
)
Expand Down Expand Up @@ -167,6 +175,7 @@ def from_function(
task=task,
exclude_args=exclude_args,
serializer=serializer,
timeout=timeout,
auth=auth,
)

Expand Down Expand Up @@ -229,6 +238,7 @@ def from_function(
serializer=metadata.serializer,
meta=metadata.meta,
task_config=task_config,
timeout=metadata.timeout,
auth=metadata.auth,
)

Expand All @@ -237,17 +247,43 @@ async def run(self, arguments: dict[str, Any]) -> ToolResult:
wrapper_fn = without_injected_parameters(self.fn)
type_adapter = get_cached_typeadapter(wrapper_fn)

if inspect.iscoroutinefunction(wrapper_fn):
# Async function: validate_python returns a coroutine
result = await type_adapter.validate_python(arguments)
# 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
else:
# Sync function: run in threadpool to avoid blocking the event loop
result = await call_sync_fn_in_threadpool(
type_adapter.validate_python, arguments
)
# Handle sync wrappers that return awaitables (e.g., partial(async_fn))
if inspect.isawaitable(result):
result = await result
# 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

return self.convert_result(result)

Expand Down Expand Up @@ -303,6 +339,7 @@ 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
Expand All @@ -320,6 +357,7 @@ 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]: ...

Expand All @@ -338,6 +376,7 @@ 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.
Expand Down Expand Up @@ -368,6 +407,7 @@ def create_tool(fn: Callable[..., Any], tool_name: str | None) -> FunctionTool:
task=resolve_task_config(task),
exclude_args=exclude_args,
serializer=serializer,
timeout=timeout,
auth=auth,
)
return FunctionTool.from_function(fn, metadata=tool_meta)
Expand All @@ -385,6 +425,7 @@ def attach_metadata(fn: F, tool_name: str | None) -> F:
task=task,
exclude_args=exclude_args,
serializer=serializer,
timeout=timeout,
auth=auth,
)
target = fn.__func__ if hasattr(fn, "__func__") else fn
Expand Down
8 changes: 8 additions & 0 deletions src/fastmcp/tools/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@ 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:
Expand Down Expand Up @@ -200,6 +206,7 @@ def from_function(
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."""
Expand All @@ -218,6 +225,7 @@ def from_function(
serializer=serializer,
meta=meta,
task=task,
timeout=timeout,
auth=auth,
)

Expand Down
Loading