Jeremiah Lowin commited on
Commit
ea9de1e
·
unverified ·
1 Parent(s): 758a503

Add timeout support to client (#455)

Browse files

* 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

.github/workflows/run-tests.yml CHANGED
@@ -46,6 +46,7 @@ jobs:
46
  enable-cache: true
47
  cache-dependency-glob: "uv.lock"
48
  python-version: ${{ matrix.python-version }}
 
49
  - name: Install FastMCP
50
  run: uv sync --dev --locked
51
 
 
46
  enable-cache: true
47
  cache-dependency-glob: "uv.lock"
48
  python-version: ${{ matrix.python-version }}
49
+
50
  - name: Install FastMCP
51
  run: uv sync --dev --locked
52
 
docs/clients/client.mdx CHANGED
@@ -115,14 +115,18 @@ The standard client methods return user-friendly representations that may change
115
  tools = await client.list_tools()
116
  # tools -> list[mcp.types.Tool]
117
  ```
118
- * **`call_tool(name: str, arguments: dict[str, Any] | None = None)`**: Executes a tool on the server.
119
  ```python
120
  result = await client.call_tool("add", {"a": 5, "b": 3})
121
  # result -> list[mcp.types.TextContent | mcp.types.ImageContent | ...]
122
  print(result[0].text) # Assuming TextContent, e.g., '8'
 
 
 
123
  ```
124
  * Arguments are passed as a dictionary. FastMCP servers automatically handle JSON string parsing for complex types if needed.
125
  * Returns a list of content objects (usually `TextContent` or `ImageContent`).
 
126
 
127
  #### Resource Operations
128
 
@@ -191,6 +195,45 @@ These methods are especially useful for debugging or when you need to access met
191
 
192
  MCP allows servers to interact with clients in order to provide additional capabilities. The `Client` constructor accepts additional configuration to handle these server requests.
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
195
  #### LLM Sampling
196
 
 
115
  tools = await client.list_tools()
116
  # tools -> list[mcp.types.Tool]
117
  ```
118
+ * **`call_tool(name: str, arguments: dict[str, Any] | None = None, timeout: float | None = None)`**: Executes a tool on the server.
119
  ```python
120
  result = await client.call_tool("add", {"a": 5, "b": 3})
121
  # result -> list[mcp.types.TextContent | mcp.types.ImageContent | ...]
122
  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)
126
  ```
127
  * Arguments are passed as a dictionary. FastMCP servers automatically handle JSON string parsing for complex types if needed.
128
  * 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.
130
 
131
  #### Resource Operations
132
 
 
195
 
196
  MCP allows servers to interact with clients in order to provide additional capabilities. The `Client` constructor accepts additional configuration to handle these server requests.
197
 
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>
237
 
238
  #### LLM Sampling
239
 
src/fastmcp/client/client.py CHANGED
@@ -35,8 +35,35 @@ class Client:
35
  """
36
  MCP client that delegates connection management to a Transport instance.
37
 
38
- The Client class is primarily concerned with MCP protocol logic,
39
- while the Transport handles connection establishment and management.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  """
41
 
42
  def __init__(
@@ -47,19 +74,22 @@ class Client:
47
  sampling_handler: SamplingHandler | None = None,
48
  log_handler: LogHandler | None = None,
49
  message_handler: MessageHandler | None = None,
50
- read_timeout_seconds: datetime.timedelta | None = None,
51
  ):
52
  self.transport = infer_transport(transport)
53
  self._session: ClientSession | None = None
54
  self._exit_stack: AsyncExitStack | None = None
55
  self._nesting_counter: int = 0
56
 
 
 
 
57
  self._session_kwargs: SessionKwargs = {
58
  "sampling_callback": None,
59
  "list_roots_callback": None,
60
  "logging_callback": log_handler,
61
  "message_handler": message_handler,
62
- "read_timeout_seconds": read_timeout_seconds,
63
  }
64
 
65
  if roots is not None:
@@ -397,7 +427,10 @@ class Client:
397
  # --- Call Tool ---
398
 
399
  async def call_tool_mcp(
400
- self, name: str, arguments: dict[str, Any]
 
 
 
401
  ) -> mcp.types.CallToolResult:
402
  """Send a tools/call request and return the complete MCP protocol result.
403
 
@@ -407,7 +440,7 @@ class Client:
407
  Args:
408
  name (str): The name of the tool to call.
409
  arguments (dict[str, Any]): Arguments to pass to the tool.
410
-
411
  Returns:
412
  mcp.types.CallToolResult: The complete response object from the protocol,
413
  containing the tool result and any additional metadata.
@@ -415,13 +448,19 @@ class Client:
415
  Raises:
416
  RuntimeError: If called while the client is not connected.
417
  """
418
- result = await self.session.call_tool(name=name, arguments=arguments)
 
 
 
 
 
419
  return result
420
 
421
  async def call_tool(
422
  self,
423
  name: str,
424
  arguments: dict[str, Any] | None = None,
 
425
  ) -> list[
426
  mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource
427
  ]:
@@ -441,7 +480,11 @@ class Client:
441
  ToolError: If the tool call results in an error.
442
  RuntimeError: If called while the client is not connected.
443
  """
444
- result = await self.call_tool_mcp(name=name, arguments=arguments or {})
 
 
 
 
445
  if result.isError:
446
  msg = cast(mcp.types.TextContent, result.content[0]).text
447
  raise ToolError(msg)
 
35
  """
36
  MCP client that delegates connection management to a Transport instance.
37
 
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
+ ```
67
  """
68
 
69
  def __init__(
 
74
  sampling_handler: SamplingHandler | None = None,
75
  log_handler: LogHandler | None = None,
76
  message_handler: MessageHandler | None = None,
77
+ timeout: datetime.timedelta | float | int | None = None,
78
  ):
79
  self.transport = infer_transport(transport)
80
  self._session: ClientSession | None = None
81
  self._exit_stack: AsyncExitStack | None = None
82
  self._nesting_counter: int = 0
83
 
84
+ if isinstance(timeout, int | float):
85
+ timeout = datetime.timedelta(seconds=timeout)
86
+
87
  self._session_kwargs: SessionKwargs = {
88
  "sampling_callback": None,
89
  "list_roots_callback": None,
90
  "logging_callback": log_handler,
91
  "message_handler": message_handler,
92
+ "read_timeout_seconds": timeout,
93
  }
94
 
95
  if roots is not None:
 
427
  # --- Call Tool ---
428
 
429
  async def call_tool_mcp(
430
+ self,
431
+ name: str,
432
+ arguments: dict[str, Any],
433
+ timeout: datetime.timedelta | float | int | None = None,
434
  ) -> mcp.types.CallToolResult:
435
  """Send a tools/call request and return the complete MCP protocol result.
436
 
 
440
  Args:
441
  name (str): The name of the tool to call.
442
  arguments (dict[str, Any]): Arguments to pass to the tool.
443
+ timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
444
  Returns:
445
  mcp.types.CallToolResult: The complete response object from the protocol,
446
  containing the tool result and any additional metadata.
 
448
  Raises:
449
  RuntimeError: If called while the client is not connected.
450
  """
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
+ )
457
  return result
458
 
459
  async def call_tool(
460
  self,
461
  name: str,
462
  arguments: dict[str, Any] | None = None,
463
+ timeout: datetime.timedelta | float | int | None = None,
464
  ) -> list[
465
  mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource
466
  ]:
 
480
  ToolError: If the tool call results in an error.
481
  RuntimeError: If called while the client is not connected.
482
  """
483
+ result = await self.call_tool_mcp(
484
+ name=name,
485
+ arguments=arguments or {},
486
+ timeout=timeout,
487
+ )
488
  if result.isError:
489
  msg = cast(mcp.types.TextContent, result.content[0]).text
490
  raise ToolError(msg)
src/fastmcp/client/transports.py CHANGED
@@ -8,7 +8,7 @@ import sys
8
  import warnings
9
  from collections.abc import AsyncIterator
10
  from pathlib import Path
11
- from typing import Any, TypedDict
12
 
13
  from mcp import ClientSession, StdioServerParameters
14
  from mcp.client.session import (
@@ -102,7 +102,12 @@ class WSTransport(ClientTransport):
102
  class SSETransport(ClientTransport):
103
  """Transport implementation that connects to an MCP server via Server-Sent Events."""
104
 
105
- def __init__(self, url: str | AnyUrl, headers: dict[str, str] | None = None):
 
 
 
 
 
106
  if isinstance(url, AnyUrl):
107
  url = str(url)
108
  if not isinstance(url, str) or not url.startswith("http"):
@@ -110,11 +115,28 @@ class SSETransport(ClientTransport):
110
  self.url = url
111
  self.headers = headers or {}
112
 
 
 
 
 
113
  @contextlib.asynccontextmanager
114
  async def connect_session(
115
  self, **session_kwargs: Unpack[SessionKwargs]
116
  ) -> AsyncIterator[ClientSession]:
117
- async with sse_client(self.url, headers=self.headers) as transport:
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  read_stream, write_stream = transport
119
  async with ClientSession(
120
  read_stream, write_stream, **session_kwargs
@@ -129,7 +151,12 @@ class SSETransport(ClientTransport):
129
  class StreamableHttpTransport(ClientTransport):
130
  """Transport implementation that connects to an MCP server via Streamable HTTP Requests."""
131
 
132
- def __init__(self, url: str | AnyUrl, headers: dict[str, str] | None = None):
 
 
 
 
 
133
  if isinstance(url, AnyUrl):
134
  url = str(url)
135
  if not isinstance(url, str) or not url.startswith("http"):
@@ -137,11 +164,25 @@ class StreamableHttpTransport(ClientTransport):
137
  self.url = url
138
  self.headers = headers or {}
139
 
 
 
 
 
140
  @contextlib.asynccontextmanager
141
  async def connect_session(
142
  self, **session_kwargs: Unpack[SessionKwargs]
143
  ) -> AsyncIterator[ClientSession]:
144
- async with streamablehttp_client(self.url, headers=self.headers) as transport:
 
 
 
 
 
 
 
 
 
 
145
  read_stream, write_stream, _ = transport
146
  async with ClientSession(
147
  read_stream, write_stream, **session_kwargs
 
8
  import warnings
9
  from collections.abc import AsyncIterator
10
  from pathlib import Path
11
+ from typing import Any, TypedDict, cast
12
 
13
  from mcp import ClientSession, StdioServerParameters
14
  from mcp.client.session import (
 
102
  class SSETransport(ClientTransport):
103
  """Transport implementation that connects to an MCP server via Server-Sent Events."""
104
 
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
+ ):
111
  if isinstance(url, AnyUrl):
112
  url = str(url)
113
  if not isinstance(url, str) or not url.startswith("http"):
 
115
  self.url = url
116
  self.headers = headers or {}
117
 
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
+
122
  @contextlib.asynccontextmanager
123
  async def connect_session(
124
  self, **session_kwargs: Unpack[SessionKwargs]
125
  ) -> AsyncIterator[ClientSession]:
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:
140
  read_stream, write_stream = transport
141
  async with ClientSession(
142
  read_stream, write_stream, **session_kwargs
 
151
  class StreamableHttpTransport(ClientTransport):
152
  """Transport implementation that connects to an MCP server via Streamable HTTP Requests."""
153
 
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
+ ):
160
  if isinstance(url, AnyUrl):
161
  url = str(url)
162
  if not isinstance(url, str) or not url.startswith("http"):
 
164
  self.url = url
165
  self.headers = headers or {}
166
 
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
+
171
  @contextlib.asynccontextmanager
172
  async def connect_session(
173
  self, **session_kwargs: Unpack[SessionKwargs]
174
  ) -> AsyncIterator[ClientSession]:
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:
186
  read_stream, write_stream, _ = transport
187
  async with ClientSession(
188
  read_stream, write_stream, **session_kwargs
src/fastmcp/exceptions.py CHANGED
@@ -1,5 +1,7 @@
1
  """Custom exceptions for FastMCP."""
2
 
 
 
3
 
4
  class FastMCPError(Exception):
5
  """Base error for FastMCP."""
 
1
  """Custom exceptions for FastMCP."""
2
 
3
+ from mcp import McpError # noqa: F401
4
+
5
 
6
  class FastMCPError(Exception):
7
  """Base error for FastMCP."""
src/fastmcp/utilities/exceptions.py CHANGED
@@ -1,7 +1,10 @@
1
  from collections.abc import Callable, Iterable, Mapping
2
  from typing import Any
3
 
 
 
4
  from exceptiongroup import BaseExceptionGroup
 
5
 
6
  import fastmcp
7
 
@@ -16,12 +19,19 @@ def iter_exc(group: BaseExceptionGroup):
16
 
17
  def _exception_handler(group: BaseExceptionGroup):
18
  for leaf in iter_exc(group):
 
 
 
 
 
 
 
19
  raise leaf
20
 
21
 
22
  # this catch handler is used to catch taskgroup exception groups and raise the
23
  # first exception. This allows more sane debugging.
24
- catch_handlers: Mapping[
25
  type[BaseException] | Iterable[type[BaseException]],
26
  Callable[[BaseExceptionGroup[Any]], Any],
27
  ] = {
@@ -34,6 +44,6 @@ def get_catch_handlers() -> Mapping[
34
  Callable[[BaseExceptionGroup[Any]], Any],
35
  ]:
36
  if fastmcp.settings.settings.client_raise_first_exceptiongroup_error:
37
- return catch_handlers
38
  else:
39
  return {}
 
1
  from collections.abc import Callable, Iterable, Mapping
2
  from typing import Any
3
 
4
+ import httpx
5
+ import mcp.types
6
  from exceptiongroup import BaseExceptionGroup
7
+ from mcp import McpError
8
 
9
  import fastmcp
10
 
 
19
 
20
  def _exception_handler(group: BaseExceptionGroup):
21
  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
+ )
29
  raise leaf
30
 
31
 
32
  # this catch handler is used to catch taskgroup exception groups and raise the
33
  # first exception. This allows more sane debugging.
34
+ _catch_handlers: Mapping[
35
  type[BaseException] | Iterable[type[BaseException]],
36
  Callable[[BaseExceptionGroup[Any]], Any],
37
  ] = {
 
44
  Callable[[BaseExceptionGroup[Any]], Any],
45
  ]:
46
  if fastmcp.settings.settings.client_raise_first_exceptiongroup_error:
47
+ return _catch_handlers
48
  else:
49
  return {}
tests/client/test_client.py CHANGED
@@ -1,6 +1,8 @@
 
1
  from typing import cast
2
 
3
  import pytest
 
4
  from pydantic import AnyUrl
5
 
6
  from fastmcp.client import Client
@@ -27,6 +29,12 @@ def fastmcp_server():
27
  """Add two numbers together."""
28
  return a + b
29
 
 
 
 
 
 
 
30
  # Add a resource
31
  @server.resource(uri="data://users")
32
  async def get_users():
@@ -78,8 +86,8 @@ async def test_list_tools(fastmcp_server):
78
  result = await client.list_tools()
79
 
80
  # Check that our tools are available
81
- assert len(result) == 2
82
- assert set(tool.name for tool in result) == {"greet", "add"}
83
 
84
 
85
  async def test_list_tools_mcp(fastmcp_server):
@@ -91,8 +99,8 @@ async def test_list_tools_mcp(fastmcp_server):
91
 
92
  # Check that we got the raw MCP ListToolsResult object
93
  assert hasattr(result, "tools")
94
- assert len(result.tools) == 2
95
- assert set(tool.name for tool in result.tools) == {"greet", "add"}
96
 
97
 
98
  async def test_call_tool(fastmcp_server):
@@ -499,3 +507,39 @@ class TestErrorHandling:
499
  with pytest.raises(Exception) as excinfo:
500
  await client.read_resource(AnyUrl("error://resource/123"))
501
  assert "This is a resource error (xyz)" in str(excinfo.value)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
  from typing import cast
3
 
4
  import pytest
5
+ from mcp import McpError
6
  from pydantic import AnyUrl
7
 
8
  from fastmcp.client import Client
 
29
  """Add two numbers together."""
30
  return a + b
31
 
32
+ @server.tool()
33
+ async def sleep(seconds: float) -> str:
34
+ """Sleep for a given number of seconds."""
35
+ await asyncio.sleep(seconds)
36
+ return f"Slept for {seconds} seconds"
37
+
38
  # Add a resource
39
  @server.resource(uri="data://users")
40
  async def get_users():
 
86
  result = await client.list_tools()
87
 
88
  # Check that our tools are available
89
+ assert len(result) == 3
90
+ assert set(tool.name for tool in result) == {"greet", "add", "sleep"}
91
 
92
 
93
  async def test_list_tools_mcp(fastmcp_server):
 
99
 
100
  # Check that we got the raw MCP ListToolsResult object
101
  assert hasattr(result, "tools")
102
+ assert len(result.tools) == 3
103
+ assert set(tool.name for tool in result.tools) == {"greet", "add", "sleep"}
104
 
105
 
106
  async def test_call_tool(fastmcp_server):
 
507
  with pytest.raises(Exception) as excinfo:
508
  await client.read_resource(AnyUrl("error://resource/123"))
509
  assert "This is a resource error (xyz)" in str(excinfo.value)
510
+
511
+
512
+ class TestTimeout:
513
+ async def test_timeout(self, fastmcp_server: FastMCP):
514
+ async with Client(
515
+ transport=FastMCPTransport(fastmcp_server), timeout=0.01
516
+ ) as client:
517
+ with pytest.raises(
518
+ McpError,
519
+ match="Timed out while waiting for response to ClientRequest. Waited 0.01 seconds",
520
+ ):
521
+ await client.call_tool("sleep", {"seconds": 0.1})
522
+
523
+ async def test_timeout_tool_call(self, fastmcp_server: FastMCP):
524
+ async with Client(transport=FastMCPTransport(fastmcp_server)) as client:
525
+ with pytest.raises(McpError):
526
+ await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
527
+
528
+ async def test_timeout_tool_call_overrides_client_timeout(
529
+ self, fastmcp_server: FastMCP
530
+ ):
531
+ async with Client(
532
+ transport=FastMCPTransport(fastmcp_server),
533
+ timeout=2,
534
+ ) as client:
535
+ with pytest.raises(McpError):
536
+ await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
537
+
538
+ async def test_timeout_tool_call_overrides_client_timeout_even_if_lower(
539
+ self, fastmcp_server: FastMCP
540
+ ):
541
+ async with Client(
542
+ transport=FastMCPTransport(fastmcp_server),
543
+ timeout=0.01,
544
+ ) as client:
545
+ await client.call_tool("sleep", {"seconds": 0.1}, timeout=2)
tests/client/test_sse.py CHANGED
@@ -1,9 +1,11 @@
 
1
  import json
2
  import sys
3
  from collections.abc import Generator
4
 
5
  import pytest
6
  import uvicorn
 
7
  from mcp.types import TextResourceContents
8
  from starlette.applications import Starlette
9
  from starlette.routing import Mount
@@ -31,6 +33,12 @@ def fastmcp_server():
31
  """Add two numbers together."""
32
  return a + b
33
 
 
 
 
 
 
 
34
  # Add a resource
35
  @server.resource(uri="data://users")
36
  async def get_users():
@@ -126,3 +134,49 @@ async def test_nested_sse_server_resolves_correctly():
126
  ) as client:
127
  result = await client.ping()
128
  assert result is True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
  import json
3
  import sys
4
  from collections.abc import Generator
5
 
6
  import pytest
7
  import uvicorn
8
+ from mcp import McpError
9
  from mcp.types import TextResourceContents
10
  from starlette.applications import Starlette
11
  from starlette.routing import Mount
 
33
  """Add two numbers together."""
34
  return a + b
35
 
36
+ @server.tool()
37
+ async def sleep(seconds: float) -> str:
38
+ """Sleep for a given number of seconds."""
39
+ await asyncio.sleep(seconds)
40
+ return f"Slept for {seconds} seconds"
41
+
42
  # Add a resource
43
  @server.resource(uri="data://users")
44
  async def get_users():
 
134
  ) as client:
135
  result = await client.ping()
136
  assert result is True
137
+
138
+
139
+ class TestTimeout:
140
+ async def test_timeout(self, sse_server: str):
141
+ with pytest.raises(
142
+ McpError,
143
+ match="Timed out while waiting for response to ClientRequest. Waited 0.01 seconds",
144
+ ):
145
+ async with Client(
146
+ transport=SSETransport(sse_server),
147
+ timeout=0.01,
148
+ ) as client:
149
+ await client.call_tool("sleep", {"seconds": 0.1})
150
+
151
+ async def test_timeout_tool_call(self, sse_server: str):
152
+ async with Client(transport=SSETransport(sse_server)) as client:
153
+ with pytest.raises(McpError, match="Timed out"):
154
+ await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
155
+
156
+ async def test_timeout_tool_call_overrides_client_timeout_if_lower(
157
+ self, sse_server: str
158
+ ):
159
+ async with Client(
160
+ transport=SSETransport(sse_server),
161
+ timeout=2,
162
+ ) as client:
163
+ with pytest.raises(McpError, match="Timed out"):
164
+ await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
165
+
166
+ @pytest.mark.skipif(
167
+ sys.platform == "win32",
168
+ reason="This test is flaky on Windows. Sometimes the client timeout is respected and sometimes it is not.",
169
+ )
170
+ async def test_timeout_client_timeout_does_not_override_tool_call_timeout_if_lower(
171
+ self, sse_server: str
172
+ ):
173
+ """
174
+ With SSE, the tool call timeout always takes precedence over the client.
175
+
176
+ Note: on Windows, the behavior appears unpredictable.
177
+ """
178
+ async with Client(
179
+ transport=SSETransport(sse_server),
180
+ timeout=0.01,
181
+ ) as client:
182
+ await client.call_tool("sleep", {"seconds": 0.1}, timeout=2)
tests/client/test_streamable_http.py CHANGED
@@ -1,9 +1,11 @@
 
1
  import json
2
  import sys
3
  from collections.abc import Generator
4
 
5
  import pytest
6
  import uvicorn
 
7
  from mcp.types import TextResourceContents
8
  from starlette.applications import Starlette
9
  from starlette.routing import Mount
@@ -31,6 +33,12 @@ def fastmcp_server():
31
  """Add two numbers together."""
32
  return a + b
33
 
 
 
 
 
 
 
34
  # Add a resource
35
  @server.resource(uri="data://users")
36
  async def get_users():
@@ -139,3 +147,42 @@ async def test_nested_streamable_http_server_resolves_correctly():
139
  ) as client:
140
  result = await client.ping()
141
  assert result is True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
  import json
3
  import sys
4
  from collections.abc import Generator
5
 
6
  import pytest
7
  import uvicorn
8
+ from mcp import McpError
9
  from mcp.types import TextResourceContents
10
  from starlette.applications import Starlette
11
  from starlette.routing import Mount
 
33
  """Add two numbers together."""
34
  return a + b
35
 
36
+ @server.tool()
37
+ async def sleep(seconds: float) -> str:
38
+ """Sleep for a given number of seconds."""
39
+ await asyncio.sleep(seconds)
40
+ return f"Slept for {seconds} seconds"
41
+
42
  # Add a resource
43
  @server.resource(uri="data://users")
44
  async def get_users():
 
147
  ) as client:
148
  result = await client.ping()
149
  assert result is True
150
+
151
+
152
+ class TestTimeout:
153
+ async def test_timeout(self, streamable_http_server: str):
154
+ # note this transport behaves differently than others and raises
155
+ # McpError from the *client* context
156
+ with pytest.raises(McpError, match="Timed out"):
157
+ async with Client(
158
+ transport=StreamableHttpTransport(streamable_http_server),
159
+ timeout=0.01,
160
+ ) as client:
161
+ await client.call_tool("sleep", {"seconds": 0.1})
162
+
163
+ async def test_timeout_tool_call(self, streamable_http_server: str):
164
+ async with Client(
165
+ transport=StreamableHttpTransport(streamable_http_server),
166
+ ) as client:
167
+ with pytest.raises(McpError):
168
+ await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
169
+
170
+ async def test_timeout_tool_call_overrides_client_timeout(
171
+ self, streamable_http_server: str
172
+ ):
173
+ async with Client(
174
+ transport=StreamableHttpTransport(streamable_http_server),
175
+ timeout=2,
176
+ ) as client:
177
+ with pytest.raises(McpError):
178
+ await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
179
+
180
+ async def test_timeout_client_timeout_overrides_tool_call_timeout_if_lower(
181
+ self, streamable_http_server: str
182
+ ):
183
+ with pytest.raises(McpError):
184
+ async with Client(
185
+ transport=StreamableHttpTransport(streamable_http_server),
186
+ timeout=0.01,
187
+ ) as client:
188
+ await client.call_tool("sleep", {"seconds": 0.1}, timeout=2)