mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 22:14:18 +02:00
Add timeout parameter for tool foreground execution (#2872)
This commit is contained in:
parent
7ee3bec727
commit
2d838315f0
6 changed files with 322 additions and 12 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
192
tests/tools/test_tool_timeout.py
Normal file
192
tests/tools/test_tool_timeout.py
Normal 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue