Add timeout parameter for tool foreground execution (#2872)

This commit is contained in:
Jeremiah Lowin 2026-01-13 21:05:34 -05:00 committed by GitHub
commit 2d838315f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 322 additions and 12 deletions

View file

@ -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
@ -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" />

View file

@ -204,6 +204,7 @@ class LocalProvider(Provider):
task=resolved_task,
exclude_args=meta.exclude_args,
serializer=meta.serializer,
timeout=meta.timeout,
auth=meta.auth,
)
else:
@ -434,6 +435,7 @@ class LocalProvider(Provider):
enabled: bool = True,
task: bool | TaskConfig | None = None,
serializer: ToolResultSerializerType | None = None, # Deprecated
timeout: float | None = None,
auth: AuthCheckCallable | list[AuthCheckCallable] | None = None,
) -> FunctionTool: ...
@ -454,6 +456,7 @@ class LocalProvider(Provider):
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]: ...
@ -477,6 +480,7 @@ class LocalProvider(Provider):
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]
@ -579,6 +583,7 @@ class LocalProvider(Provider):
meta=meta,
serializer=serializer,
task=resolved_task,
timeout=timeout,
auth=auth,
)
self._add_component(tool_obj)
@ -600,6 +605,7 @@ class LocalProvider(Provider):
task=task,
exclude_args=exclude_args,
serializer=serializer,
timeout=timeout,
auth=auth,
)
target = fn.__func__ if hasattr(fn, "__func__") else fn
@ -643,6 +649,7 @@ class LocalProvider(Provider):
enabled=enabled,
task=task,
serializer=serializer,
timeout=timeout,
auth=auth,
)

View file

@ -1993,6 +1993,7 @@ class FastMCP(Generic[LifespanResultT]):
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: ...
@ -2011,6 +2012,7 @@ class FastMCP(Generic[LifespanResultT]):
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]: ...
@ -2028,6 +2030,7 @@ class FastMCP(Generic[LifespanResultT]):
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]
@ -2095,6 +2098,7 @@ class FastMCP(Generic[LifespanResultT]):
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

@ -16,8 +16,10 @@ from typing import (
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
@ -31,12 +33,15 @@ from fastmcp.tools.tool import (
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
@ -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
@ -115,6 +121,7 @@ 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.
@ -140,6 +147,7 @@ class FunctionTool(Tool):
meta,
task,
serializer,
timeout,
auth,
]
)
@ -167,6 +175,7 @@ class FunctionTool(Tool):
task=task,
exclude_args=exclude_args,
serializer=serializer,
timeout=timeout,
auth=auth,
)
@ -229,6 +238,7 @@ class FunctionTool(Tool):
serializer=metadata.serializer,
meta=metadata.meta,
task_config=task_config,
timeout=metadata.timeout,
auth=metadata.auth,
)
@ -237,17 +247,43 @@ class FunctionTool(Tool):
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)
@ -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
@ -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]: ...
@ -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.
@ -368,6 +407,7 @@ 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)
@ -385,6 +425,7 @@ 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,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:
@ -200,6 +206,7 @@ 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."""
@ -218,6 +225,7 @@ class Tool(FastMCPComponent):
serializer=serializer,
meta=meta,
task=task,
timeout=timeout,
auth=auth,
)

View file

@ -0,0 +1,192 @@
"""Tests for tool timeout functionality."""
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."""
async def test_no_timeout_completes_normally_async(self):
"""Tool without timeout completes normally (async)."""
mcp = FastMCP()
@mcp.tool
async def quick_async_tool() -> str:
await anyio.sleep(0.01)
return "completed"
result = await mcp.call_tool("quick_async_tool")
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)."""
mcp = FastMCP()
@mcp.tool
def quick_sync_tool() -> str:
time.sleep(0.01)
return "completed"
result = await mcp.call_tool("quick_sync_tool")
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."""
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.get_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"
# 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")
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"
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"
# TimeoutError should be caught and converted to ToolError
with pytest.raises((ToolError, McpError)):
await mcp.call_tool("times_out")