Skip to content

Commit 47f076b

Browse files
committed
feat: add configurable timeout for tool execution
- Add REQUEST_TIMEOUT error code (-32001) to types.py - Add tool_timeout_seconds setting to FastMCP (default: 300s) - Wrap tool execution in anyio.fail_after() for timeout enforcement - Raise McpError with REQUEST_TIMEOUT code when timeout is exceeded - Add comprehensive tests for timeout behavior - Update README with timeout configuration documentation Implements baseline timeout behavior for MCP tool calls as described in the PR requirements. Server-side timeout is configurable via tool_timeout_seconds parameter or FASTMCP_TOOL_TIMEOUT_SECONDS env var. Client-side timeout behavior already exists in BaseSession.
1 parent 5983a65 commit 47f076b

File tree

6 files changed

+269
-16
lines changed

6 files changed

+269
-16
lines changed

README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,57 @@ async def long_running_task(task_name: str, ctx: Context[ServerSession, None], s
364364
_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/tool_progress.py)_
365365
<!-- /snippet-source -->
366366

367+
#### Tool Timeouts
368+
369+
FastMCP provides configurable timeouts for tool execution to prevent long-running tools from blocking indefinitely. By default, tools have a 300-second (5 minute) timeout, but this can be customized:
370+
371+
```python
372+
from mcp.server.fastmcp import FastMCP
373+
374+
# Set a custom timeout for all tools (in seconds)
375+
mcp = FastMCP("My Server", tool_timeout_seconds=60.0) # 1 minute timeout
376+
377+
# Disable timeout entirely (use with caution)
378+
mcp = FastMCP("My Server", tool_timeout_seconds=None)
379+
380+
# Use the default 300 second timeout
381+
mcp = FastMCP("My Server") # 5 minute default
382+
```
383+
384+
When a tool exceeds its timeout, an `McpError` with error code `REQUEST_TIMEOUT` (-32001) is raised:
385+
386+
```python
387+
from mcp.shared.exceptions import McpError
388+
from mcp.types import REQUEST_TIMEOUT
389+
390+
@mcp.tool()
391+
async def slow_operation(data: str) -> str:
392+
"""A potentially slow operation."""
393+
# If this takes longer than tool_timeout_seconds, it will be cancelled
394+
result = await process_large_dataset(data)
395+
return result
396+
397+
# Clients can catch timeout errors
398+
try:
399+
result = await session.call_tool("slow_operation", {"data": "..."})
400+
except McpError as e:
401+
if e.error.code == REQUEST_TIMEOUT:
402+
print("Tool execution timed out")
403+
```
404+
405+
**Configuration via environment variables:**
406+
407+
```bash
408+
# Set timeout via environment variable
409+
FASTMCP_TOOL_TIMEOUT_SECONDS=120 python server.py
410+
```
411+
412+
**Best practices:**
413+
- Choose timeouts based on expected tool execution time
414+
- Consider client-side timeouts as well for end-to-end timeout control
415+
- Log or monitor timeout occurrences to identify problematic tools
416+
- For truly long-running operations, consider using progress updates or async patterns
417+
367418
#### Structured Output
368419

369420
Tools will return structured results by default, if their return type
@@ -1072,6 +1123,7 @@ The FastMCP server instance accessible via `ctx.fastmcp` provides access to serv
10721123
- `host` and `port` - Server network configuration
10731124
- `mount_path`, `sse_path`, `streamable_http_path` - Transport paths
10741125
- `stateless_http` - Whether the server operates in stateless mode
1126+
- `tool_timeout_seconds` - Maximum execution time for tools (default: 300 seconds)
10751127
- And other configuration options
10761128

10771129
```python

src/mcp/server/fastmcp/server.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
112112

113113
# tool settings
114114
warn_on_duplicate_tools: bool
115+
tool_timeout_seconds: float | None
116+
"""Maximum time in seconds for tool execution. None means no timeout. Default is 300 seconds (5 minutes)."""
115117

116118
# prompt settings
117119
warn_on_duplicate_prompts: bool
@@ -168,6 +170,7 @@ def __init__( # noqa: PLR0913
168170
warn_on_duplicate_resources: bool = True,
169171
warn_on_duplicate_tools: bool = True,
170172
warn_on_duplicate_prompts: bool = True,
173+
tool_timeout_seconds: float | None = 300.0,
171174
dependencies: Collection[str] = (),
172175
lifespan: (Callable[[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None) = None,
173176
auth: AuthSettings | None = None,
@@ -187,6 +190,7 @@ def __init__( # noqa: PLR0913
187190
warn_on_duplicate_resources=warn_on_duplicate_resources,
188191
warn_on_duplicate_tools=warn_on_duplicate_tools,
189192
warn_on_duplicate_prompts=warn_on_duplicate_prompts,
193+
tool_timeout_seconds=tool_timeout_seconds,
190194
dependencies=list(dependencies),
191195
lifespan=lifespan,
192196
auth=auth,
@@ -202,7 +206,11 @@ def __init__( # noqa: PLR0913
202206
# We need to create a Lifespan type that is a generic on the server type, like Starlette does.
203207
lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan), # type: ignore
204208
)
205-
self._tool_manager = ToolManager(tools=tools, warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools)
209+
self._tool_manager = ToolManager(
210+
tools=tools,
211+
warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools,
212+
timeout_seconds=self.settings.tool_timeout_seconds,
213+
)
206214
self._resource_manager = ResourceManager(warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources)
207215
self._prompt_manager = PromptManager(warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts)
208216
# Validate auth configuration

src/mcp/server/fastmcp/tools/base.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@
66
from functools import cached_property
77
from typing import TYPE_CHECKING, Any
88

9+
import anyio
910
from pydantic import BaseModel, Field
1011

1112
from mcp.server.fastmcp.exceptions import ToolError
1213
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
1314
from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
15+
from mcp.shared.exceptions import McpError
1416
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
15-
from mcp.types import Icon, ToolAnnotations
17+
from mcp.types import REQUEST_TIMEOUT, ErrorData, Icon, ToolAnnotations
1618

1719
if TYPE_CHECKING:
1820
from mcp.server.fastmcp.server import Context
@@ -94,20 +96,45 @@ async def run(
9496
arguments: dict[str, Any],
9597
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None,
9698
convert_result: bool = False,
99+
timeout_seconds: float | None = None,
97100
) -> Any:
98-
"""Run the tool with arguments."""
99-
try:
100-
result = await self.fn_metadata.call_fn_with_arg_validation(
101-
self.fn,
102-
self.is_async,
103-
arguments,
104-
{self.context_kwarg: context} if self.context_kwarg is not None else None,
105-
)
101+
"""Run the tool with arguments.
102+
103+
Args:
104+
arguments: The arguments to pass to the tool function
105+
context: Optional context to inject into the tool function
106+
convert_result: Whether to convert the result to MCP types
107+
timeout_seconds: Maximum execution time in seconds. None means no timeout.
106108
107-
if convert_result:
108-
result = self.fn_metadata.convert_result(result)
109+
Returns:
110+
The result of the tool execution
109111
110-
return result
112+
Raises:
113+
McpError: If the tool execution times out (REQUEST_TIMEOUT error code)
114+
ToolError: If the tool execution fails for other reasons
115+
"""
116+
try:
117+
# Wrap execution in timeout if configured
118+
with anyio.fail_after(timeout_seconds):
119+
result = await self.fn_metadata.call_fn_with_arg_validation(
120+
self.fn,
121+
self.is_async,
122+
arguments,
123+
{self.context_kwarg: context} if self.context_kwarg is not None else None,
124+
)
125+
126+
if convert_result:
127+
result = self.fn_metadata.convert_result(result)
128+
129+
return result
130+
except TimeoutError as e:
131+
# Convert timeout to MCP error with REQUEST_TIMEOUT code
132+
raise McpError(
133+
ErrorData(
134+
code=REQUEST_TIMEOUT,
135+
message=f"Tool '{self.name}' execution exceeded timeout of {timeout_seconds} seconds",
136+
)
137+
) from e
111138
except Exception as e:
112139
raise ToolError(f"Error executing tool {self.name}: {e}") from e
113140

src/mcp/server/fastmcp/tools/tool_manager.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class ToolManager:
2222
def __init__(
2323
self,
2424
warn_on_duplicate_tools: bool = True,
25+
timeout_seconds: float | None = None,
2526
*,
2627
tools: list[Tool] | None = None,
2728
):
@@ -33,6 +34,7 @@ def __init__(
3334
self._tools[tool.name] = tool
3435

3536
self.warn_on_duplicate_tools = warn_on_duplicate_tools
37+
self.timeout_seconds = timeout_seconds
3638

3739
def get_tool(self, name: str) -> Tool | None:
3840
"""Get tool by name."""
@@ -90,4 +92,9 @@ async def call_tool(
9092
if not tool:
9193
raise ToolError(f"Unknown tool: {name}")
9294

93-
return await tool.run(arguments, context=context, convert_result=convert_result)
95+
return await tool.run(
96+
arguments,
97+
context=context,
98+
convert_result=convert_result,
99+
timeout_seconds=self.timeout_seconds,
100+
)

src/mcp/types.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,8 @@ class JSONRPCResponse(BaseModel):
152152

153153
# SDK error codes
154154
CONNECTION_CLOSED = -32000
155-
# REQUEST_TIMEOUT = -32001 # the typescript sdk uses this
155+
REQUEST_TIMEOUT = -32001
156+
"""Error code indicating that a request exceeded the configured timeout period."""
156157

157158
# Standard JSON-RPC error codes
158159
PARSE_ERROR = -32700

tests/server/fastmcp/test_tool_manager.py

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import json
23
import logging
34
from dataclasses import dataclass
@@ -12,7 +13,8 @@
1213
from mcp.server.fastmcp.utilities.func_metadata import ArgModelBase, FuncMetadata
1314
from mcp.server.session import ServerSessionT
1415
from mcp.shared.context import LifespanContextT, RequestT
15-
from mcp.types import TextContent, ToolAnnotations
16+
from mcp.shared.exceptions import McpError
17+
from mcp.types import REQUEST_TIMEOUT, TextContent, ToolAnnotations
1618

1719

1820
class TestAddTools:
@@ -920,3 +922,159 @@ def test_func() -> str: # pragma: no cover
920922
# Remove with correct case
921923
manager.remove_tool("test_func")
922924
assert manager.get_tool("test_func") is None
925+
926+
927+
class TestToolTimeout:
928+
"""Test timeout behavior for tool execution."""
929+
930+
@pytest.mark.anyio
931+
async def test_tool_timeout_exceeded(self):
932+
"""Test that a slow tool times out and raises McpError with REQUEST_TIMEOUT code."""
933+
934+
async def slow_tool(duration: float) -> str: # pragma: no cover
935+
"""A tool that sleeps for the specified duration."""
936+
await asyncio.sleep(duration)
937+
return "completed"
938+
939+
manager = ToolManager(timeout_seconds=0.1) # 100ms timeout
940+
manager.add_tool(slow_tool)
941+
942+
# Tool should timeout after 100ms
943+
with pytest.raises(McpError) as exc_info:
944+
await manager.call_tool("slow_tool", {"duration": 1.0}) # Try to sleep for 1 second
945+
946+
# Verify the error code is REQUEST_TIMEOUT
947+
assert exc_info.value.error.code == REQUEST_TIMEOUT
948+
assert "slow_tool" in exc_info.value.error.message
949+
assert "exceeded timeout" in exc_info.value.error.message
950+
951+
@pytest.mark.anyio
952+
async def test_tool_completes_before_timeout(self):
953+
"""Test that a fast tool completes successfully before timeout."""
954+
955+
async def fast_tool(value: str) -> str:
956+
"""A tool that completes quickly."""
957+
await asyncio.sleep(0.01) # 10ms
958+
return f"processed: {value}"
959+
960+
manager = ToolManager(timeout_seconds=1.0) # 1 second timeout
961+
manager.add_tool(fast_tool)
962+
963+
# Tool should complete successfully
964+
result = await manager.call_tool("fast_tool", {"value": "test"})
965+
assert result == "processed: test"
966+
967+
@pytest.mark.anyio
968+
async def test_tool_without_timeout(self):
969+
"""Test that tools work normally when timeout is None."""
970+
971+
async def slow_tool(duration: float) -> str:
972+
"""A tool that can take any amount of time."""
973+
await asyncio.sleep(duration)
974+
return "completed"
975+
976+
manager = ToolManager(timeout_seconds=None) # No timeout
977+
manager.add_tool(slow_tool)
978+
979+
# Tool should complete without timeout even if slow
980+
result = await manager.call_tool("slow_tool", {"duration": 0.2})
981+
assert result == "completed"
982+
983+
@pytest.mark.anyio
984+
async def test_sync_tool_timeout(self):
985+
"""Test that synchronous tools also respect timeout."""
986+
import time
987+
988+
def slow_sync_tool(duration: float) -> str: # pragma: no cover
989+
"""A synchronous tool that sleeps."""
990+
time.sleep(duration)
991+
return "completed"
992+
993+
manager = ToolManager(timeout_seconds=0.1) # 100ms timeout
994+
manager.add_tool(slow_sync_tool)
995+
996+
# Sync tool should also timeout
997+
with pytest.raises(McpError) as exc_info:
998+
await manager.call_tool("slow_sync_tool", {"duration": 1.0})
999+
1000+
assert exc_info.value.error.code == REQUEST_TIMEOUT
1001+
1002+
@pytest.mark.anyio
1003+
async def test_timeout_with_context_injection(self):
1004+
"""Test that timeout works correctly with context injection."""
1005+
1006+
async def slow_tool_with_context(
1007+
duration: float, ctx: Context[ServerSessionT, None]
1008+
) -> str: # pragma: no cover
1009+
"""A tool with context that times out."""
1010+
await asyncio.sleep(duration)
1011+
return "completed"
1012+
1013+
manager = ToolManager(timeout_seconds=0.1)
1014+
manager.add_tool(slow_tool_with_context)
1015+
1016+
mcp = FastMCP()
1017+
ctx = mcp.get_context()
1018+
1019+
# Tool should timeout even with context injection
1020+
with pytest.raises(McpError) as exc_info:
1021+
await manager.call_tool("slow_tool_with_context", {"duration": 1.0}, context=ctx)
1022+
1023+
assert exc_info.value.error.code == REQUEST_TIMEOUT
1024+
1025+
@pytest.mark.anyio
1026+
async def test_tool_error_not_confused_with_timeout(self):
1027+
"""Test that regular tool errors are not confused with timeout errors."""
1028+
1029+
async def failing_tool(should_fail: bool) -> str:
1030+
"""A tool that raises an error."""
1031+
if should_fail:
1032+
raise ValueError("Tool failed intentionally")
1033+
return "success"
1034+
1035+
manager = ToolManager(timeout_seconds=1.0)
1036+
manager.add_tool(failing_tool)
1037+
1038+
# Regular errors should still be ToolError, not timeout
1039+
with pytest.raises(ToolError, match="Error executing tool failing_tool"):
1040+
await manager.call_tool("failing_tool", {"should_fail": True})
1041+
1042+
@pytest.mark.anyio
1043+
async def test_fastmcp_timeout_setting(self):
1044+
"""Test that FastMCP passes timeout setting to ToolManager."""
1045+
1046+
async def slow_tool() -> str: # pragma: no cover
1047+
"""A slow tool."""
1048+
await asyncio.sleep(1.0)
1049+
return "completed"
1050+
1051+
# Create FastMCP with custom timeout
1052+
app = FastMCP(tool_timeout_seconds=0.1)
1053+
1054+
@app.tool()
1055+
async def test_tool() -> str: # pragma: no cover
1056+
"""Test tool."""
1057+
await asyncio.sleep(1.0)
1058+
return "completed"
1059+
1060+
# Tool should timeout based on FastMCP setting
1061+
with pytest.raises(McpError) as exc_info:
1062+
await app._tool_manager.call_tool("test_tool", {})
1063+
1064+
assert exc_info.value.error.code == REQUEST_TIMEOUT
1065+
1066+
@pytest.mark.anyio
1067+
async def test_fastmcp_no_timeout(self):
1068+
"""Test that FastMCP works with timeout disabled."""
1069+
1070+
app = FastMCP(tool_timeout_seconds=None)
1071+
1072+
@app.tool()
1073+
async def slow_tool() -> str:
1074+
"""A slow tool."""
1075+
await asyncio.sleep(0.2)
1076+
return "completed"
1077+
1078+
# Tool should complete without timeout
1079+
result = await app._tool_manager.call_tool("slow_tool", {})
1080+
assert result == "completed"

0 commit comments

Comments
 (0)