From 837d4c407d781e88fd5b72da6676e2a57db03ba4 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 14 May 2025 20:50:24 -0400 Subject: [PATCH] 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 --- .github/workflows/run-tests.yml | 1 + docs/clients/client.mdx | 45 ++++++++++++++++++++- src/fastmcp/client/client.py | 59 ++++++++++++++++++++++++---- src/fastmcp/client/transports.py | 51 +++++++++++++++++++++--- src/fastmcp/exceptions.py | 2 + src/fastmcp/utilities/exceptions.py | 14 ++++++- tests/client/test_client.py | 52 ++++++++++++++++++++++-- tests/client/test_sse.py | 54 +++++++++++++++++++++++++ tests/client/test_streamable_http.py | 47 ++++++++++++++++++++++ 9 files changed, 305 insertions(+), 20 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index dd6af5154..ec4457e24 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -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 diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx index 1f1e6aa76..4b77c5008 100644 --- a/docs/clients/client.mdx +++ b/docs/clients/client.mdx @@ -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 + + + +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}") +``` + + +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. + #### LLM Sampling diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 6c56893a0..9f5a111f9 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -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) diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index f201a1c6b..1b559a7c4 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -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 diff --git a/src/fastmcp/exceptions.py b/src/fastmcp/exceptions.py index c105cce8e..24864d8a0 100644 --- a/src/fastmcp/exceptions.py +++ b/src/fastmcp/exceptions.py @@ -1,5 +1,7 @@ """Custom exceptions for FastMCP.""" +from mcp import McpError # noqa: F401 + class FastMCPError(Exception): """Base error for FastMCP.""" diff --git a/src/fastmcp/utilities/exceptions.py b/src/fastmcp/utilities/exceptions.py index eaba30ee6..e50dc57b0 100644 --- a/src/fastmcp/utilities/exceptions.py +++ b/src/fastmcp/utilities/exceptions.py @@ -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 {} diff --git a/tests/client/test_client.py b/tests/client/test_client.py index ff460b295..1d8c3187d 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -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) diff --git a/tests/client/test_sse.py b/tests/client/test_sse.py index 3259fb7d5..f70395550 100644 --- a/tests/client/test_sse.py +++ b/tests/client/test_sse.py @@ -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) diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index a388cc3cb..9b1528c5d 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -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)