Spaces:
Running
Running
Jeremiah Lowin commited on
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 +1 -0
- docs/clients/client.mdx +44 -1
- src/fastmcp/client/client.py +51 -8
- src/fastmcp/client/transports.py +46 -5
- src/fastmcp/exceptions.py +2 -0
- src/fastmcp/utilities/exceptions.py +12 -2
- tests/client/test_client.py +48 -4
- tests/client/test_sse.py +54 -0
- tests/client/test_streamable_http.py +47 -0
.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
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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":
|
| 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,
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 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) ==
|
| 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) ==
|
| 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)
|