Add timeout support to client (#455)

* Add timeouts kwargs

* Add timeout support

* Fix windows handling and docs

* Update client.mdx

* Windows tests

* Update test_sse.py

* Update test_sse.py

* Update test_sse.py

* Update test_sse.py

* Fix windows
This commit is contained in:
Jeremiah Lowin 2025-05-14 20:50:24 -04:00 committed by GitHub
commit 837d4c407d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 305 additions and 20 deletions

View file

@ -46,6 +46,7 @@ jobs:
enable-cache: true
cache-dependency-glob: "uv.lock"
python-version: ${{ matrix.python-version }}
- name: Install FastMCP
run: uv sync --dev --locked

View file

@ -115,14 +115,18 @@ The standard client methods return user-friendly representations that may change
tools = await client.list_tools()
# tools -> list[mcp.types.Tool]
```
* **`call_tool(name: str, arguments: dict[str, Any] | None = None)`**: Executes a tool on the server.
* **`call_tool(name: str, arguments: dict[str, Any] | None = None, timeout: float | None = None)`**: Executes a tool on the server.
```python
result = await client.call_tool("add", {"a": 5, "b": 3})
# result -> list[mcp.types.TextContent | mcp.types.ImageContent | ...]
print(result[0].text) # Assuming TextContent, e.g., '8'
# With timeout (aborts if execution takes longer than 2 seconds)
result = await client.call_tool("long_running_task", {"param": "value"}, timeout=2.0)
```
* Arguments are passed as a dictionary. FastMCP servers automatically handle JSON string parsing for complex types if needed.
* Returns a list of content objects (usually `TextContent` or `ImageContent`).
* The optional `timeout` parameter limits the maximum execution time (in seconds) for this specific call, overriding any client-level timeout.
#### Resource Operations
@ -191,6 +195,45 @@ These methods are especially useful for debugging or when you need to access met
MCP allows servers to interact with clients in order to provide additional capabilities. The `Client` constructor accepts additional configuration to handle these server requests.
#### Timeout Control
<VersionBadge version="2.3.4" />
You can control request timeouts at both the client level and individual request level:
```python
from fastmcp import Client
from fastmcp.exceptions import McpError
# Client with a global 5-second timeout for all requests
client = Client(
my_mcp_server,
timeout=5.0 # Default timeout in seconds
)
async with client:
# This uses the global 5-second timeout
result1 = await client.call_tool("quick_task", {"param": "value"})
# This specifies a 10-second timeout for this specific call
result2 = await client.call_tool("slow_task", {"param": "value"}, timeout=10.0)
try:
# This will likely timeout
result3 = await client.call_tool("medium_task", {"param": "value"}, timeout=0.01)
except McpError as e:
# Handle timeout error
print(f"The task timed out: {e}")
```
<Warning>
Timeout behavior varies between transport types:
- With **SSE** transport, the per-request (tool call) timeout **always** takes precedence, regardless of which is lower.
- With **HTTP** transport, the **lower** of the two timeouts (client or tool call) takes precedence.
For consistent behavior across all transports, we recommend explicitly setting timeouts at the individual tool call level when needed, rather than relying on client-level timeouts.
</Warning>
#### LLM Sampling

View file

@ -35,8 +35,35 @@ class Client:
"""
MCP client that delegates connection management to a Transport instance.
The Client class is primarily concerned with MCP protocol logic,
while the Transport handles connection establishment and management.
The Client class is responsible for MCP protocol logic, while the Transport
handles connection establishment and management. Client provides methods
for working with resources, prompts, tools and other MCP capabilities.
Args:
transport: Connection source specification, which can be:
- ClientTransport: Direct transport instance
- FastMCP: In-process FastMCP server
- AnyUrl | str: URL to connect to
- Path: File path for local socket
- dict: Transport configuration
roots: Optional RootsList or RootsHandler for filesystem access
sampling_handler: Optional handler for sampling requests
log_handler: Optional handler for log messages
message_handler: Optional handler for protocol messages
timeout: Optional timeout for requests (seconds or timedelta)
Examples:
```python
# Connect to FastMCP server
client = Client("http://localhost:8080")
async with client:
# List available resources
resources = await client.list_resources()
# Call a tool
result = await client.call_tool("my_tool", {"param": "value"})
```
"""
def __init__(
@ -47,19 +74,22 @@ class Client:
sampling_handler: SamplingHandler | None = None,
log_handler: LogHandler | None = None,
message_handler: MessageHandler | None = None,
read_timeout_seconds: datetime.timedelta | None = None,
timeout: datetime.timedelta | float | int | None = None,
):
self.transport = infer_transport(transport)
self._session: ClientSession | None = None
self._exit_stack: AsyncExitStack | None = None
self._nesting_counter: int = 0
if isinstance(timeout, int | float):
timeout = datetime.timedelta(seconds=timeout)
self._session_kwargs: SessionKwargs = {
"sampling_callback": None,
"list_roots_callback": None,
"logging_callback": log_handler,
"message_handler": message_handler,
"read_timeout_seconds": read_timeout_seconds,
"read_timeout_seconds": timeout,
}
if roots is not None:
@ -397,7 +427,10 @@ class Client:
# --- Call Tool ---
async def call_tool_mcp(
self, name: str, arguments: dict[str, Any]
self,
name: str,
arguments: dict[str, Any],
timeout: datetime.timedelta | float | int | None = None,
) -> mcp.types.CallToolResult:
"""Send a tools/call request and return the complete MCP protocol result.
@ -407,7 +440,7 @@ class Client:
Args:
name (str): The name of the tool to call.
arguments (dict[str, Any]): Arguments to pass to the tool.
timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
Returns:
mcp.types.CallToolResult: The complete response object from the protocol,
containing the tool result and any additional metadata.
@ -415,13 +448,19 @@ class Client:
Raises:
RuntimeError: If called while the client is not connected.
"""
result = await self.session.call_tool(name=name, arguments=arguments)
if isinstance(timeout, int | float):
timeout = datetime.timedelta(seconds=timeout)
result = await self.session.call_tool(
name=name, arguments=arguments, read_timeout_seconds=timeout
)
return result
async def call_tool(
self,
name: str,
arguments: dict[str, Any] | None = None,
timeout: datetime.timedelta | float | int | None = None,
) -> list[
mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource
]:
@ -441,7 +480,11 @@ class Client:
ToolError: If the tool call results in an error.
RuntimeError: If called while the client is not connected.
"""
result = await self.call_tool_mcp(name=name, arguments=arguments or {})
result = await self.call_tool_mcp(
name=name,
arguments=arguments or {},
timeout=timeout,
)
if result.isError:
msg = cast(mcp.types.TextContent, result.content[0]).text
raise ToolError(msg)

View file

@ -8,7 +8,7 @@ import sys
import warnings
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any, TypedDict
from typing import Any, TypedDict, cast
from mcp import ClientSession, StdioServerParameters
from mcp.client.session import (
@ -102,7 +102,12 @@ class WSTransport(ClientTransport):
class SSETransport(ClientTransport):
"""Transport implementation that connects to an MCP server via Server-Sent Events."""
def __init__(self, url: str | AnyUrl, headers: dict[str, str] | None = None):
def __init__(
self,
url: str | AnyUrl,
headers: dict[str, str] | None = None,
sse_read_timeout: datetime.timedelta | float | int | None = None,
):
if isinstance(url, AnyUrl):
url = str(url)
if not isinstance(url, str) or not url.startswith("http"):
@ -110,11 +115,28 @@ class SSETransport(ClientTransport):
self.url = url
self.headers = headers or {}
if isinstance(sse_read_timeout, int | float):
sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
self.sse_read_timeout = sse_read_timeout
@contextlib.asynccontextmanager
async def connect_session(
self, **session_kwargs: Unpack[SessionKwargs]
) -> AsyncIterator[ClientSession]:
async with sse_client(self.url, headers=self.headers) as transport:
client_kwargs = {}
# sse_read_timeout has a default value set, so we can't pass None without overriding it
# instead we simply leave the kwarg out if it's not provided
if self.sse_read_timeout is not None:
client_kwargs["sse_read_timeout"] = self.sse_read_timeout.total_seconds()
if session_kwargs.get("read_timeout_seconds", None) is not None:
read_timeout_seconds = cast(
datetime.timedelta, session_kwargs.get("read_timeout_seconds")
)
client_kwargs["timeout"] = read_timeout_seconds.total_seconds()
async with sse_client(
self.url, headers=self.headers, **client_kwargs
) as transport:
read_stream, write_stream = transport
async with ClientSession(
read_stream, write_stream, **session_kwargs
@ -129,7 +151,12 @@ class SSETransport(ClientTransport):
class StreamableHttpTransport(ClientTransport):
"""Transport implementation that connects to an MCP server via Streamable HTTP Requests."""
def __init__(self, url: str | AnyUrl, headers: dict[str, str] | None = None):
def __init__(
self,
url: str | AnyUrl,
headers: dict[str, str] | None = None,
sse_read_timeout: datetime.timedelta | float | int | None = None,
):
if isinstance(url, AnyUrl):
url = str(url)
if not isinstance(url, str) or not url.startswith("http"):
@ -137,11 +164,25 @@ class StreamableHttpTransport(ClientTransport):
self.url = url
self.headers = headers or {}
if isinstance(sse_read_timeout, int | float):
sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
self.sse_read_timeout = sse_read_timeout
@contextlib.asynccontextmanager
async def connect_session(
self, **session_kwargs: Unpack[SessionKwargs]
) -> AsyncIterator[ClientSession]:
async with streamablehttp_client(self.url, headers=self.headers) as transport:
client_kwargs = {}
# sse_read_timeout has a default value set, so we can't pass None without overriding it
# instead we simply leave the kwarg out if it's not provided
if self.sse_read_timeout is not None:
client_kwargs["sse_read_timeout"] = self.sse_read_timeout
if session_kwargs.get("read_timeout_seconds", None) is not None:
client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds")
async with streamablehttp_client(
self.url, headers=self.headers, **client_kwargs
) as transport:
read_stream, write_stream, _ = transport
async with ClientSession(
read_stream, write_stream, **session_kwargs

View file

@ -1,5 +1,7 @@
"""Custom exceptions for FastMCP."""
from mcp import McpError # noqa: F401
class FastMCPError(Exception):
"""Base error for FastMCP."""

View file

@ -1,7 +1,10 @@
from collections.abc import Callable, Iterable, Mapping
from typing import Any
import httpx
import mcp.types
from exceptiongroup import BaseExceptionGroup
from mcp import McpError
import fastmcp
@ -16,12 +19,19 @@ def iter_exc(group: BaseExceptionGroup):
def _exception_handler(group: BaseExceptionGroup):
for leaf in iter_exc(group):
if isinstance(leaf, httpx.ConnectTimeout):
raise McpError(
error=mcp.types.ErrorData(
code=httpx.codes.REQUEST_TIMEOUT,
message="Timed out while waiting for response.",
)
)
raise leaf
# this catch handler is used to catch taskgroup exception groups and raise the
# first exception. This allows more sane debugging.
catch_handlers: Mapping[
_catch_handlers: Mapping[
type[BaseException] | Iterable[type[BaseException]],
Callable[[BaseExceptionGroup[Any]], Any],
] = {
@ -34,6 +44,6 @@ def get_catch_handlers() -> Mapping[
Callable[[BaseExceptionGroup[Any]], Any],
]:
if fastmcp.settings.settings.client_raise_first_exceptiongroup_error:
return catch_handlers
return _catch_handlers
else:
return {}

View file

@ -1,6 +1,8 @@
import asyncio
from typing import cast
import pytest
from mcp import McpError
from pydantic import AnyUrl
from fastmcp.client import Client
@ -27,6 +29,12 @@ def fastmcp_server():
"""Add two numbers together."""
return a + b
@server.tool()
async def sleep(seconds: float) -> str:
"""Sleep for a given number of seconds."""
await asyncio.sleep(seconds)
return f"Slept for {seconds} seconds"
# Add a resource
@server.resource(uri="data://users")
async def get_users():
@ -78,8 +86,8 @@ async def test_list_tools(fastmcp_server):
result = await client.list_tools()
# Check that our tools are available
assert len(result) == 2
assert set(tool.name for tool in result) == {"greet", "add"}
assert len(result) == 3
assert set(tool.name for tool in result) == {"greet", "add", "sleep"}
async def test_list_tools_mcp(fastmcp_server):
@ -91,8 +99,8 @@ async def test_list_tools_mcp(fastmcp_server):
# Check that we got the raw MCP ListToolsResult object
assert hasattr(result, "tools")
assert len(result.tools) == 2
assert set(tool.name for tool in result.tools) == {"greet", "add"}
assert len(result.tools) == 3
assert set(tool.name for tool in result.tools) == {"greet", "add", "sleep"}
async def test_call_tool(fastmcp_server):
@ -499,3 +507,39 @@ class TestErrorHandling:
with pytest.raises(Exception) as excinfo:
await client.read_resource(AnyUrl("error://resource/123"))
assert "This is a resource error (xyz)" in str(excinfo.value)
class TestTimeout:
async def test_timeout(self, fastmcp_server: FastMCP):
async with Client(
transport=FastMCPTransport(fastmcp_server), timeout=0.01
) as client:
with pytest.raises(
McpError,
match="Timed out while waiting for response to ClientRequest. Waited 0.01 seconds",
):
await client.call_tool("sleep", {"seconds": 0.1})
async def test_timeout_tool_call(self, fastmcp_server: FastMCP):
async with Client(transport=FastMCPTransport(fastmcp_server)) as client:
with pytest.raises(McpError):
await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
async def test_timeout_tool_call_overrides_client_timeout(
self, fastmcp_server: FastMCP
):
async with Client(
transport=FastMCPTransport(fastmcp_server),
timeout=2,
) as client:
with pytest.raises(McpError):
await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
async def test_timeout_tool_call_overrides_client_timeout_even_if_lower(
self, fastmcp_server: FastMCP
):
async with Client(
transport=FastMCPTransport(fastmcp_server),
timeout=0.01,
) as client:
await client.call_tool("sleep", {"seconds": 0.1}, timeout=2)

View file

@ -1,9 +1,11 @@
import asyncio
import json
import sys
from collections.abc import Generator
import pytest
import uvicorn
from mcp import McpError
from mcp.types import TextResourceContents
from starlette.applications import Starlette
from starlette.routing import Mount
@ -31,6 +33,12 @@ def fastmcp_server():
"""Add two numbers together."""
return a + b
@server.tool()
async def sleep(seconds: float) -> str:
"""Sleep for a given number of seconds."""
await asyncio.sleep(seconds)
return f"Slept for {seconds} seconds"
# Add a resource
@server.resource(uri="data://users")
async def get_users():
@ -126,3 +134,49 @@ async def test_nested_sse_server_resolves_correctly():
) as client:
result = await client.ping()
assert result is True
class TestTimeout:
async def test_timeout(self, sse_server: str):
with pytest.raises(
McpError,
match="Timed out while waiting for response to ClientRequest. Waited 0.01 seconds",
):
async with Client(
transport=SSETransport(sse_server),
timeout=0.01,
) as client:
await client.call_tool("sleep", {"seconds": 0.1})
async def test_timeout_tool_call(self, sse_server: str):
async with Client(transport=SSETransport(sse_server)) as client:
with pytest.raises(McpError, match="Timed out"):
await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
async def test_timeout_tool_call_overrides_client_timeout_if_lower(
self, sse_server: str
):
async with Client(
transport=SSETransport(sse_server),
timeout=2,
) as client:
with pytest.raises(McpError, match="Timed out"):
await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
@pytest.mark.skipif(
sys.platform == "win32",
reason="This test is flaky on Windows. Sometimes the client timeout is respected and sometimes it is not.",
)
async def test_timeout_client_timeout_does_not_override_tool_call_timeout_if_lower(
self, sse_server: str
):
"""
With SSE, the tool call timeout always takes precedence over the client.
Note: on Windows, the behavior appears unpredictable.
"""
async with Client(
transport=SSETransport(sse_server),
timeout=0.01,
) as client:
await client.call_tool("sleep", {"seconds": 0.1}, timeout=2)

View file

@ -1,9 +1,11 @@
import asyncio
import json
import sys
from collections.abc import Generator
import pytest
import uvicorn
from mcp import McpError
from mcp.types import TextResourceContents
from starlette.applications import Starlette
from starlette.routing import Mount
@ -31,6 +33,12 @@ def fastmcp_server():
"""Add two numbers together."""
return a + b
@server.tool()
async def sleep(seconds: float) -> str:
"""Sleep for a given number of seconds."""
await asyncio.sleep(seconds)
return f"Slept for {seconds} seconds"
# Add a resource
@server.resource(uri="data://users")
async def get_users():
@ -139,3 +147,42 @@ async def test_nested_streamable_http_server_resolves_correctly():
) as client:
result = await client.ping()
assert result is True
class TestTimeout:
async def test_timeout(self, streamable_http_server: str):
# note this transport behaves differently than others and raises
# McpError from the *client* context
with pytest.raises(McpError, match="Timed out"):
async with Client(
transport=StreamableHttpTransport(streamable_http_server),
timeout=0.01,
) as client:
await client.call_tool("sleep", {"seconds": 0.1})
async def test_timeout_tool_call(self, streamable_http_server: str):
async with Client(
transport=StreamableHttpTransport(streamable_http_server),
) as client:
with pytest.raises(McpError):
await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
async def test_timeout_tool_call_overrides_client_timeout(
self, streamable_http_server: str
):
async with Client(
transport=StreamableHttpTransport(streamable_http_server),
timeout=2,
) as client:
with pytest.raises(McpError):
await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
async def test_timeout_client_timeout_overrides_tool_call_timeout_if_lower(
self, streamable_http_server: str
):
with pytest.raises(McpError):
async with Client(
transport=StreamableHttpTransport(streamable_http_server),
timeout=0.01,
) as client:
await client.call_tool("sleep", {"seconds": 0.1}, timeout=2)