Skip to content

Commit 837d4c4

Browse files
authored
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
1 parent 5b05e80 commit 837d4c4

File tree

9 files changed

+305
-20
lines changed

9 files changed

+305
-20
lines changed

.github/workflows/run-tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ jobs:
4646
enable-cache: true
4747
cache-dependency-glob: "uv.lock"
4848
python-version: ${{ matrix.python-version }}
49+
4950
- name: Install FastMCP
5051
run: uv sync --dev --locked
5152

docs/clients/client.mdx

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,14 +115,18 @@ The standard client methods return user-friendly representations that may change
115115
tools = await client.list_tools()
116116
# tools -> list[mcp.types.Tool]
117117
```
118-
* **`call_tool(name: str, arguments: dict[str, Any] | None = None)`**: Executes a tool on the server.
118+
* **`call_tool(name: str, arguments: dict[str, Any] | None = None, timeout: float | None = None)`**: Executes a tool on the server.
119119
```python
120120
result = await client.call_tool("add", {"a": 5, "b": 3})
121121
# result -> list[mcp.types.TextContent | mcp.types.ImageContent | ...]
122122
print(result[0].text) # Assuming TextContent, e.g., '8'
123+
124+
# With timeout (aborts if execution takes longer than 2 seconds)
125+
result = await client.call_tool("long_running_task", {"param": "value"}, timeout=2.0)
123126
```
124127
* Arguments are passed as a dictionary. FastMCP servers automatically handle JSON string parsing for complex types if needed.
125128
* Returns a list of content objects (usually `TextContent` or `ImageContent`).
129+
* The optional `timeout` parameter limits the maximum execution time (in seconds) for this specific call, overriding any client-level timeout.
126130

127131
#### Resource Operations
128132

@@ -191,6 +195,45 @@ These methods are especially useful for debugging or when you need to access met
191195

192196
MCP allows servers to interact with clients in order to provide additional capabilities. The `Client` constructor accepts additional configuration to handle these server requests.
193197

198+
#### Timeout Control
199+
200+
<VersionBadge version="2.3.4" />
201+
202+
You can control request timeouts at both the client level and individual request level:
203+
204+
```python
205+
from fastmcp import Client
206+
from fastmcp.exceptions import McpError
207+
208+
# Client with a global 5-second timeout for all requests
209+
client = Client(
210+
my_mcp_server,
211+
timeout=5.0 # Default timeout in seconds
212+
)
213+
214+
async with client:
215+
# This uses the global 5-second timeout
216+
result1 = await client.call_tool("quick_task", {"param": "value"})
217+
218+
# This specifies a 10-second timeout for this specific call
219+
result2 = await client.call_tool("slow_task", {"param": "value"}, timeout=10.0)
220+
221+
try:
222+
# This will likely timeout
223+
result3 = await client.call_tool("medium_task", {"param": "value"}, timeout=0.01)
224+
except McpError as e:
225+
# Handle timeout error
226+
print(f"The task timed out: {e}")
227+
```
228+
229+
<Warning>
230+
Timeout behavior varies between transport types:
231+
232+
- With **SSE** transport, the per-request (tool call) timeout **always** takes precedence, regardless of which is lower.
233+
- With **HTTP** transport, the **lower** of the two timeouts (client or tool call) takes precedence.
234+
235+
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.
236+
</Warning>
194237

195238
#### LLM Sampling
196239

src/fastmcp/client/client.py

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,35 @@ class Client:
3535
"""
3636
MCP client that delegates connection management to a Transport instance.
3737
38-
The Client class is primarily concerned with MCP protocol logic,
39-
while the Transport handles connection establishment and management.
38+
The Client class is responsible for MCP protocol logic, while the Transport
39+
handles connection establishment and management. Client provides methods
40+
for working with resources, prompts, tools and other MCP capabilities.
41+
42+
Args:
43+
transport: Connection source specification, which can be:
44+
- ClientTransport: Direct transport instance
45+
- FastMCP: In-process FastMCP server
46+
- AnyUrl | str: URL to connect to
47+
- Path: File path for local socket
48+
- dict: Transport configuration
49+
roots: Optional RootsList or RootsHandler for filesystem access
50+
sampling_handler: Optional handler for sampling requests
51+
log_handler: Optional handler for log messages
52+
message_handler: Optional handler for protocol messages
53+
timeout: Optional timeout for requests (seconds or timedelta)
54+
55+
Examples:
56+
```python
57+
# Connect to FastMCP server
58+
client = Client("http://localhost:8080")
59+
60+
async with client:
61+
# List available resources
62+
resources = await client.list_resources()
63+
64+
# Call a tool
65+
result = await client.call_tool("my_tool", {"param": "value"})
66+
```
4067
"""
4168

4269
def __init__(
@@ -47,19 +74,22 @@ def __init__(
4774
sampling_handler: SamplingHandler | None = None,
4875
log_handler: LogHandler | None = None,
4976
message_handler: MessageHandler | None = None,
50-
read_timeout_seconds: datetime.timedelta | None = None,
77+
timeout: datetime.timedelta | float | int | None = None,
5178
):
5279
self.transport = infer_transport(transport)
5380
self._session: ClientSession | None = None
5481
self._exit_stack: AsyncExitStack | None = None
5582
self._nesting_counter: int = 0
5683

84+
if isinstance(timeout, int | float):
85+
timeout = datetime.timedelta(seconds=timeout)
86+
5787
self._session_kwargs: SessionKwargs = {
5888
"sampling_callback": None,
5989
"list_roots_callback": None,
6090
"logging_callback": log_handler,
6191
"message_handler": message_handler,
62-
"read_timeout_seconds": read_timeout_seconds,
92+
"read_timeout_seconds": timeout,
6393
}
6494

6595
if roots is not None:
@@ -397,7 +427,10 @@ async def list_tools(self) -> list[mcp.types.Tool]:
397427
# --- Call Tool ---
398428

399429
async def call_tool_mcp(
400-
self, name: str, arguments: dict[str, Any]
430+
self,
431+
name: str,
432+
arguments: dict[str, Any],
433+
timeout: datetime.timedelta | float | int | None = None,
401434
) -> mcp.types.CallToolResult:
402435
"""Send a tools/call request and return the complete MCP protocol result.
403436
@@ -407,21 +440,27 @@ async def call_tool_mcp(
407440
Args:
408441
name (str): The name of the tool to call.
409442
arguments (dict[str, Any]): Arguments to pass to the tool.
410-
443+
timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
411444
Returns:
412445
mcp.types.CallToolResult: The complete response object from the protocol,
413446
containing the tool result and any additional metadata.
414447
415448
Raises:
416449
RuntimeError: If called while the client is not connected.
417450
"""
418-
result = await self.session.call_tool(name=name, arguments=arguments)
451+
452+
if isinstance(timeout, int | float):
453+
timeout = datetime.timedelta(seconds=timeout)
454+
result = await self.session.call_tool(
455+
name=name, arguments=arguments, read_timeout_seconds=timeout
456+
)
419457
return result
420458

421459
async def call_tool(
422460
self,
423461
name: str,
424462
arguments: dict[str, Any] | None = None,
463+
timeout: datetime.timedelta | float | int | None = None,
425464
) -> list[
426465
mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource
427466
]:
@@ -441,7 +480,11 @@ async def call_tool(
441480
ToolError: If the tool call results in an error.
442481
RuntimeError: If called while the client is not connected.
443482
"""
444-
result = await self.call_tool_mcp(name=name, arguments=arguments or {})
483+
result = await self.call_tool_mcp(
484+
name=name,
485+
arguments=arguments or {},
486+
timeout=timeout,
487+
)
445488
if result.isError:
446489
msg = cast(mcp.types.TextContent, result.content[0]).text
447490
raise ToolError(msg)

src/fastmcp/client/transports.py

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import warnings
99
from collections.abc import AsyncIterator
1010
from pathlib import Path
11-
from typing import Any, TypedDict
11+
from typing import Any, TypedDict, cast
1212

1313
from mcp import ClientSession, StdioServerParameters
1414
from mcp.client.session import (
@@ -102,19 +102,41 @@ def __repr__(self) -> str:
102102
class SSETransport(ClientTransport):
103103
"""Transport implementation that connects to an MCP server via Server-Sent Events."""
104104

105-
def __init__(self, url: str | AnyUrl, headers: dict[str, str] | None = None):
105+
def __init__(
106+
self,
107+
url: str | AnyUrl,
108+
headers: dict[str, str] | None = None,
109+
sse_read_timeout: datetime.timedelta | float | int | None = None,
110+
):
106111
if isinstance(url, AnyUrl):
107112
url = str(url)
108113
if not isinstance(url, str) or not url.startswith("http"):
109114
raise ValueError("Invalid HTTP/S URL provided for SSE.")
110115
self.url = url
111116
self.headers = headers or {}
112117

118+
if isinstance(sse_read_timeout, int | float):
119+
sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
120+
self.sse_read_timeout = sse_read_timeout
121+
113122
@contextlib.asynccontextmanager
114123
async def connect_session(
115124
self, **session_kwargs: Unpack[SessionKwargs]
116125
) -> AsyncIterator[ClientSession]:
117-
async with sse_client(self.url, headers=self.headers) as transport:
126+
client_kwargs = {}
127+
# sse_read_timeout has a default value set, so we can't pass None without overriding it
128+
# instead we simply leave the kwarg out if it's not provided
129+
if self.sse_read_timeout is not None:
130+
client_kwargs["sse_read_timeout"] = self.sse_read_timeout.total_seconds()
131+
if session_kwargs.get("read_timeout_seconds", None) is not None:
132+
read_timeout_seconds = cast(
133+
datetime.timedelta, session_kwargs.get("read_timeout_seconds")
134+
)
135+
client_kwargs["timeout"] = read_timeout_seconds.total_seconds()
136+
137+
async with sse_client(
138+
self.url, headers=self.headers, **client_kwargs
139+
) as transport:
118140
read_stream, write_stream = transport
119141
async with ClientSession(
120142
read_stream, write_stream, **session_kwargs
@@ -129,19 +151,38 @@ def __repr__(self) -> str:
129151
class StreamableHttpTransport(ClientTransport):
130152
"""Transport implementation that connects to an MCP server via Streamable HTTP Requests."""
131153

132-
def __init__(self, url: str | AnyUrl, headers: dict[str, str] | None = None):
154+
def __init__(
155+
self,
156+
url: str | AnyUrl,
157+
headers: dict[str, str] | None = None,
158+
sse_read_timeout: datetime.timedelta | float | int | None = None,
159+
):
133160
if isinstance(url, AnyUrl):
134161
url = str(url)
135162
if not isinstance(url, str) or not url.startswith("http"):
136163
raise ValueError("Invalid HTTP/S URL provided for Streamable HTTP.")
137164
self.url = url
138165
self.headers = headers or {}
139166

167+
if isinstance(sse_read_timeout, int | float):
168+
sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
169+
self.sse_read_timeout = sse_read_timeout
170+
140171
@contextlib.asynccontextmanager
141172
async def connect_session(
142173
self, **session_kwargs: Unpack[SessionKwargs]
143174
) -> AsyncIterator[ClientSession]:
144-
async with streamablehttp_client(self.url, headers=self.headers) as transport:
175+
client_kwargs = {}
176+
# sse_read_timeout has a default value set, so we can't pass None without overriding it
177+
# instead we simply leave the kwarg out if it's not provided
178+
if self.sse_read_timeout is not None:
179+
client_kwargs["sse_read_timeout"] = self.sse_read_timeout
180+
if session_kwargs.get("read_timeout_seconds", None) is not None:
181+
client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds")
182+
183+
async with streamablehttp_client(
184+
self.url, headers=self.headers, **client_kwargs
185+
) as transport:
145186
read_stream, write_stream, _ = transport
146187
async with ClientSession(
147188
read_stream, write_stream, **session_kwargs

src/fastmcp/exceptions.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Custom exceptions for FastMCP."""
22

3+
from mcp import McpError # noqa: F401
4+
35

46
class FastMCPError(Exception):
57
"""Base error for FastMCP."""

src/fastmcp/utilities/exceptions.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from collections.abc import Callable, Iterable, Mapping
22
from typing import Any
33

4+
import httpx
5+
import mcp.types
46
from exceptiongroup import BaseExceptionGroup
7+
from mcp import McpError
58

69
import fastmcp
710

@@ -16,12 +19,19 @@ def iter_exc(group: BaseExceptionGroup):
1619

1720
def _exception_handler(group: BaseExceptionGroup):
1821
for leaf in iter_exc(group):
22+
if isinstance(leaf, httpx.ConnectTimeout):
23+
raise McpError(
24+
error=mcp.types.ErrorData(
25+
code=httpx.codes.REQUEST_TIMEOUT,
26+
message="Timed out while waiting for response.",
27+
)
28+
)
1929
raise leaf
2030

2131

2232
# this catch handler is used to catch taskgroup exception groups and raise the
2333
# first exception. This allows more sane debugging.
24-
catch_handlers: Mapping[
34+
_catch_handlers: Mapping[
2535
type[BaseException] | Iterable[type[BaseException]],
2636
Callable[[BaseExceptionGroup[Any]], Any],
2737
] = {
@@ -34,6 +44,6 @@ def get_catch_handlers() -> Mapping[
3444
Callable[[BaseExceptionGroup[Any]], Any],
3545
]:
3646
if fastmcp.settings.settings.client_raise_first_exceptiongroup_error:
37-
return catch_handlers
47+
return _catch_handlers
3848
else:
3949
return {}

0 commit comments

Comments
 (0)