Merge branch 'main' into sse-bugfix

This commit is contained in:
Jeremiah Lowin 2025-05-15 21:28:19 -04:00
commit 86821dc548
26 changed files with 788 additions and 284 deletions

View file

@ -14,13 +14,10 @@ on:
- "uv.lock"
- "pyproject.toml"
- ".github/workflows/**"
# run on all pull requests because these checks are required and will block merges otherwise
pull_request:
paths:
- "src/**"
- "tests/**"
- "uv.lock"
- "pyproject.toml"
- ".github/workflows/**"
workflow_dispatch:
permissions:

View file

@ -13,13 +13,9 @@ on:
- "uv.lock"
- "pyproject.toml"
- ".github/workflows/**"
# run on all pull requests because these checks are required and will block merges otherwise
pull_request:
paths:
- "src/**"
- "tests/**"
- "uv.lock"
- "pyproject.toml"
- ".github/workflows/**"
workflow_dispatch:
@ -45,18 +41,10 @@ jobs:
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
python-version: ${{ matrix.python-version }}
- name: Install FastMCP
run: uv sync --dev
- name: Fix pyreadline on Windows
if: matrix.os == 'windows-latest'
run: |
uv pip uninstall -y pyreadline
uv pip install pyreadline3
run: uv sync --dev --locked
- name: Run tests
run: uv run --frozen pytest
run: uv run pytest

View file

@ -30,9 +30,8 @@ The following inference rules are used to determine the appropriate `ClientTrans
3. **`Path` or `str` pointing to an existing file**:
* If it ends with `.py`: Creates a `PythonStdioTransport` to run the script using `python`.
* If it ends with `.js`: Creates a `NodeStdioTransport` to run the script using `node`.
4. **`AnyUrl` or `str` pointing to a URL**:
* If it starts with `http://` or `https://`: Creates an `SSETransport`.
* If it starts with `ws://` or `wss://`: Creates a `WSTransport`.
4. **`AnyUrl` or `str` pointing to a URL that begins with `http://` or `https://`**:
* Creates a `StreamableHttpTransport`
5. **Other**: Raises a `ValueError` if the type cannot be inferred.
```python
@ -41,24 +40,24 @@ from fastmcp import Client, FastMCP
# Example transports (more details in Transports page)
server_instance = FastMCP(name="TestServer") # In-memory server
sse_url = "http://localhost:8000/sse" # SSE server URL
http_url = "https://example.com/mcp" # HTTP server URL
ws_url = "ws://localhost:9000" # WebSocket server URL
server_script = "my_mcp_server.py" # Path to a Python server file
# Client automatically infers the transport type
client_in_memory = Client(server_instance)
client_sse = Client(sse_url)
client_http = Client(http_url)
client_ws = Client(ws_url)
client_stdio = Client(server_script)
print(client_in_memory.transport)
print(client_sse.transport)
print(client_http.transport)
print(client_ws.transport)
print(client_stdio.transport)
# Expected Output (types may vary slightly based on environment):
# <FastMCP(server='TestServer')>
# <SSE(url='http://localhost:8000/sse')>
# <StreamableHttp(url='https://example.com/mcp')>
# <WebSocket(url='ws://localhost:9000')>
# <PythonStdioTransport(command='python', args=['/path/to/your/my_mcp_server.py'])>
```
@ -115,14 +114,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 +194,45 @@ These methods are especially useful for debugging or when you need to access met
MCP allows servers to interact with clients in order to provide additional capabilities. The `Client` constructor accepts additional configuration to handle these server requests.
#### Timeout Control
<VersionBadge version="2.3.4" />
You can control request timeouts at both the client level and individual request level:
```python
from fastmcp import Client
from fastmcp.exceptions import McpError
# Client with a global 5-second timeout for all requests
client = Client(
my_mcp_server,
timeout=5.0 # Default timeout in seconds
)
async with client:
# This uses the global 5-second timeout
result1 = await client.call_tool("quick_task", {"param": "value"})
# This specifies a 10-second timeout for this specific call
result2 = await client.call_tool("slow_task", {"param": "value"}, timeout=10.0)
try:
# This will likely timeout
result3 = await client.call_tool("medium_task", {"param": "value"}, timeout=0.01)
except McpError as e:
# Handle timeout error
print(f"The task timed out: {e}")
```
<Warning>
Timeout behavior varies between transport types:
- With **SSE** transport, the per-request (tool call) timeout **always** takes precedence, regardless of which is lower.
- With **HTTP** transport, the **lower** of the two timeouts (client or tool call) takes precedence.
For consistent behavior across all transports, we recommend explicitly setting timeouts at the individual tool call level when needed, rather than relying on client-level timeouts.
</Warning>
#### LLM Sampling

View file

@ -13,6 +13,19 @@ The FastMCP `Client` relies on a `ClientTransport` object to handle the specific
While the `Client` often infers the correct transport automatically (see [Client Overview](/clients/client#transport-inference)), you can also instantiate transports explicitly for more control.
<Tip>
Clients are lightweight objects, so don't hesitate to create new ones as needed. However, be mindful of the context management - each time you open a client context (`async with client:`), a new connection or process starts. For best performance, keep client contexts open while performing multiple operations rather than repeatedly opening and closing them.
</Tip>
## Choosing a Transport
Choose the transport that best fits your use case:
- **Connecting to Remote/Persistent Servers:** Use `StreamableHttpTransport` (recommended, default for HTTP URLs) or `SSETransport` (legacy option) for web-based deployments.
- **Local Development/Testing:** Use `FastMCPTransport` for in-memory, same-process testing of your FastMCP servers.
- **Running Local Servers:** Use `UvxStdioTransport` (Python/uv) or `NpxStdioTransport` (Node/npm) if you need to run MCP servers as packaged tools.
## Network Transports
@ -22,70 +35,122 @@ These transports connect to servers running over a network, typically long-runni
<VersionBadge version="2.3.0" />
* **Class:** `fastmcp.client.transports.StreamableHttpTransport`
* **Inferred From:** `http://` or `https://` URLs (default for HTTP URLs as of v2.3.0)
* **Use Case:** Connecting to persistent MCP servers exposed over HTTP/S using FastMCP's `mcp.run(transport="streamable-http")` mode.
Streamable HTTP is the recommended transport for web-based deployments, providing efficient bidirectional communication over HTTP.
#### Overview
- **Class:** `fastmcp.client.transports.StreamableHttpTransport`
- **Inferred From:** URLs starting with `http://` or `https://` (default for HTTP URLs since v2.3.0)
- **Server Compatibility:** Works with FastMCP servers running in `streamable-http` mode
#### Basic Usage
The simplest way to use Streamable HTTP is to let the transport be inferred from a URL:
```python
from fastmcp import Client
import asyncio
# The Client automatically uses StreamableHttpTransport for HTTP URLs
client = Client("https://example.com/mcp")
async def main():
async with client:
tools = await client.list_tools()
print(f"Available tools: {tools}")
asyncio.run(main())
```
#### Authentication with Headers
For servers requiring authentication:
```python
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport
http_url = "http://localhost:8000/mcp"
# Create transport with authentication headers
transport = StreamableHttpTransport(
url="https://example.com/mcp",
headers={"Authorization": "Bearer your-token-here"}
)
# Option 1: Inferred transport (default for HTTP URLs)
client_inferred = Client(http_url)
# Option 2: Explicit transport (e.g., to add custom headers)
headers = {"Authorization": "Bearer mytoken"}
transport_explicit = StreamableHttpTransport(url=http_url, headers=headers)
client_explicit = Client(transport_explicit)
async def use_streamable_http_client(client):
async with client:
tools = await client.list_tools()
print(f"Connected via Streamable HTTP, found tools: {tools}")
# asyncio.run(use_streamable_http_client(client_inferred))
# asyncio.run(use_streamable_http_client(client_explicit))
client = Client(transport)
```
### SSE (Server-Sent Events)
* **Class:** `fastmcp.client.transports.SSETransport`
* **Inferred From:** Not automatically inferred for most HTTP URLs (as of v2.3.0)
* **Use Case:** Connecting to MCP servers using Server-Sent Events, often using FastMCP's `mcp.run(transport="sse")` mode.
<VersionBadge version="2.0.0" />
While SSE is still supported, Streamable HTTP is the recommended transport for new web-based deployments.
Server-Sent Events (SSE) is a transport that allows servers to push data to clients over HTTP connections. While still supported, Streamable HTTP is now the recommended transport for new web-based deployments.
#### Overview
- **Class:** `fastmcp.client.transports.SSETransport`
- **Inferred From:** Not automatically inferred for HTTP URLs since v2.3.0 (must be explicitly specified)
- **Server Compatibility:** Works with FastMCP servers running in `sse` mode
#### Basic Usage
Since v2.3.0, you must explicitly create an `SSETransport` for SSE connections:
```python
from fastmcp import Client
from fastmcp.client.transports import SSETransport
import asyncio
# Create an SSE transport
transport = SSETransport(url="https://example.com/sse")
# Pass the transport to the client
client = Client(transport)
async def main():
async with client:
tools = await client.list_tools()
print(f"Available tools: {tools}")
asyncio.run(main())
```
#### Authentication with Headers
SSE transport also supports custom headers for authentication:
```python
from fastmcp import Client
from fastmcp.client.transports import SSETransport
sse_url = "http://localhost:8000/sse"
# Create SSE transport with authentication headers
transport = SSETransport(
url="https://example.com/sse",
headers={"Authorization": "Bearer your-token-here"}
)
# Since v2.3.0, HTTP URLs default to StreamableHttpTransport,
# so you must explicitly use SSETransport for SSE connections
transport_explicit = SSETransport(url=sse_url)
client_explicit = Client(transport_explicit)
async def use_sse_client(client):
async with client:
tools = await client.list_tools()
print(f"Connected via SSE, found tools: {tools}")
# asyncio.run(use_sse_client(client_explicit))
client = Client(transport)
```
## Stdio Transports
#### When to Use SSE vs. Streamable HTTP
- **Use Streamable HTTP when:**
- Setting up new deployments (recommended default)
- You need bidirectional streaming
- You're connecting to FastMCP servers running in `streamable-http` mode
- **Use SSE when:**
- Connecting to legacy FastMCP servers running in `sse` mode
- Working with infrastructure optimized for Server-Sent Events
## Local Transports
These transports manage an MCP server running as a subprocess, communicating with it via standard input (stdin) and standard output (stdout). This is the standard mechanism used by clients like Claude Desktop.
### Python Stdio
* **Class:** `fastmcp.client.transports.PythonStdioTransport`
* **Inferred From:** Paths to `.py` files.
* **Use Case:** Running a Python-based MCP server script (like one using FastMCP or the base `mcp` library) in a subprocess.
- **Class:** `fastmcp.client.transports.PythonStdioTransport`
- **Inferred From:** Paths to `.py` files
- **Use Case:** Running a Python-based MCP server script in a subprocess
This is the most common way to interact with local FastMCP servers during development or when integrating with tools that expect to launch a server script.
@ -93,39 +158,37 @@ This is the most common way to interact with local FastMCP servers during develo
from fastmcp import Client
from fastmcp.client.transports import PythonStdioTransport
server_script = "my_mcp_server.py" # Assumes this file exists and runs mcp.run()
server_script = "my_mcp_server.py" # Path to your server script
# Option 1: Inferred transport
client_inferred = Client(server_script)
client = Client(server_script)
# Option 2: Explicit transport (e.g., to use a specific python executable or add args)
transport_explicit = PythonStdioTransport(
# Option 2: Explicit transport with custom configuration
transport = PythonStdioTransport(
script_path=server_script,
python_cmd="/usr/bin/python3.11", # Specify python version
# args=["--some-server-arg"], # Pass args to the script
# env={"MY_VAR": "value"}, # Set environment variables
# cwd="/path/to/run/in" # Set working directory
python_cmd="/usr/bin/python3.11", # Optional: specify Python interpreter
# args=["--some-server-arg"], # Optional: pass arguments to the script
# env={"MY_VAR": "value"}, # Optional: set environment variables
)
client_explicit = Client(transport_explicit)
client = Client(transport)
async def use_stdio_client(client):
async def main():
async with client:
tools = await client.list_tools()
print(f"Connected via Python Stdio, found tools: {tools}")
# asyncio.run(use_stdio_client(client_inferred))
# asyncio.run(use_stdio_client(client_explicit))
asyncio.run(main())
```
<Warning>
The server script (`my_mcp_server.py` in the example) *must* include logic to start the MCP server and listen on stdio, typically via `mcp.run()` or `fastmcp.server.run()`. The `Client` only launches the script; it doesn't inject the server logic.
The server script must include logic to start the MCP server and listen on stdio, typically via `mcp.run()` or `fastmcp.server.run()`. The Client only launches the script; it doesn't inject the server logic.
</Warning>
### Node.js Stdio
* **Class:** `fastmcp.client.transports.NodeStdioTransport`
* **Inferred From:** Paths to `.js` files.
* **Use Case:** Running a Node.js-based MCP server script in a subprocess.
- **Class:** `fastmcp.client.transports.NodeStdioTransport`
- **Inferred From:** Paths to `.js` files
- **Use Case:** Running a Node.js-based MCP server script in a subprocess
Similar to the Python transport, but for JavaScript servers.
@ -133,112 +196,111 @@ Similar to the Python transport, but for JavaScript servers.
from fastmcp import Client
from fastmcp.client.transports import NodeStdioTransport
node_server_script = "my_mcp_server.js" # Assumes this JS file starts an MCP server on stdio
node_server_script = "my_mcp_server.js" # Path to your Node.js server script
# Option 1: Inferred transport
client_inferred = Client(node_server_script)
client = Client(node_server_script)
# Option 2: Explicit transport
transport_explicit = NodeStdioTransport(
transport = NodeStdioTransport(
script_path=node_server_script,
node_cmd="node" # Or specify path to Node executable
node_cmd="node" # Optional: specify path to Node executable
)
client_explicit = Client(transport_explicit)
client = Client(transport)
# Usage is the same as other clients
# async with client_explicit:
# tools = await client_explicit.list_tools()
async def main():
async with client:
tools = await client.list_tools()
print(f"Connected via Node.js Stdio, found tools: {tools}")
asyncio.run(main())
```
### UVX Stdio (Experimental)
* **Class:** `fastmcp.client.transports.UvxStdioTransport`
* **Inferred From:** Not automatically inferred. Must be instantiated explicitly.
* **Use Case:** Running an MCP server packaged as a Python tool using [`uvx`](https://docs.astral.sh/uv/reference/cli/#uvx) (part of the `uv` toolchain). This allows running tools without explicitly installing them into the current environment.
- **Class:** `fastmcp.client.transports.UvxStdioTransport`
- **Inferred From:** Not automatically inferred
- **Use Case:** Running an MCP server packaged as a Python tool using [`uvx`](https://docs.astral.sh/uv/reference/cli/#uvx)
This is useful for executing MCP servers distributed as command-line tools or packages.
This is useful for executing MCP servers distributed as command-line tools or packages without installing them into your environment.
```python
from fastmcp import Client
from fastmcp.client.transports import UvxStdioTransport
# Example: Run a hypothetical 'cloud-analyzer-mcp' tool via uvx
# Assume this tool, when run, starts an MCP server on stdio
# Run a hypothetical 'cloud-analyzer-mcp' tool via uvx
transport = UvxStdioTransport(
tool_name="cloud-analyzer-mcp",
# from_package="cloud-analyzer-cli", # Optionally specify package if tool name differs
# with_packages=["boto3", "requests"], # Add dependencies if needed
# tool_args=["--config", "prod.yaml"] # Pass args to the tool itself
# from_package="cloud-analyzer-cli", # Optional: specify package if tool name differs
# with_packages=["boto3", "requests"] # Optional: add dependencies
)
client = Client(transport)
# async with client:
# analysis = await client.call_tool("analyze_bucket", {"name": "my-data"})
async def main():
async with client:
result = await client.call_tool("analyze_bucket", {"name": "my-data"})
print(f"Analysis result: {result}")
asyncio.run(main())
```
### NPX Stdio (Experimental)
* **Class:** `fastmcp.client.transports.NpxStdioTransport`
* **Inferred From:** Not automatically inferred. Must be instantiated explicitly.
* **Use Case:** Running an MCP server packaged as an NPM package using `npx`.
- **Class:** `fastmcp.client.transports.NpxStdioTransport`
- **Inferred From:** Not automatically inferred
- **Use Case:** Running an MCP server packaged as an NPM package using `npx`
Similar to `UvxStdioTransport`, but for the Node.js ecosystem.
```python
from fastmcp import Client
from fastmcp.client.transports import NpxStdioTransport
# Example: Run a hypothetical 'npm-mcp-server-package' via npx
# Run an MCP server from an NPM package
transport = NpxStdioTransport(
package="npm-mcp-server-package",
# args=["--port", "stdio"] # Args passed to the package script
package="mcp-server-package",
# args=["--port", "stdio"] # Optional: pass arguments to the package
)
client = Client(transport)
# async with client:
# response = await client.call_tool("get_npm_data", {})
async def main():
async with client:
result = await client.call_tool("get_npm_data", {})
print(f"Result: {result}")
asyncio.run(main())
```
## In-Memory Transports
### FastMCP Transport
* **Class:** `fastmcp.client.transports.FastMCPTransport`
* **Inferred From:** An instance of `fastmcp.server.FastMCP`.
* **Use Case:** Connecting directly to a `FastMCP` server instance running in the *same Python process*.
- **Class:** `fastmcp.client.transports.FastMCPTransport`
- **Inferred From:** An instance of `fastmcp.server.FastMCP`
- **Use Case:** Connecting directly to a `FastMCP` server instance in the same Python process
This is extremely useful for:
* **Testing:** Writing unit or integration tests for your FastMCP server without needing subprocesses or network connections.
* **Embedding:** Using an MCP server as a component within a larger application.
This is extremely useful for testing your FastMCP servers.
```python
from fastmcp import FastMCP, Client
from fastmcp.client.transports import FastMCPTransport
import asyncio
# 1. Create your FastMCP server instance
server = FastMCP(name="InMemoryServer")
@server.tool()
def ping(): return "pong"
def ping():
return "pong"
# 2. Create a client pointing directly to the server instance
# Option A: Inferred
client_inferred = Client(server)
client = Client(server) # Transport is automatically inferred
# Option B: Explicit
transport_explicit = FastMCPTransport(mcp=server)
client_explicit = Client(transport_explicit)
async def main():
async with client:
result = await client.call_tool("ping")
print(f"In-memory call result: {result}")
# 3. Use the client (no subprocess or network involved)
async def test_in_memory():
async with client_inferred: # Or client_explicit
result = await client_inferred.call_tool("ping")
print(f"In-memory call result: {result[0].text}") # Output: pong
# asyncio.run(test_in_memory())
asyncio.run(main())
```
Communication happens through efficient in-memory queues, making it very fast.
## Choosing a Transport
* **Local Development/Testing:** Use `PythonStdioTransport` (inferred from `.py` files) or `FastMCPTransport` (for same-process testing).
* **Connecting to Remote/Persistent Servers:** Use `StreamableHttpTransport` (recommended, default for HTTP URLs) or `SSETransport` (legacy option).
* **Running Packaged Tools:** Use `UvxStdioTransport` (Python/uv) or `NpxStdioTransport` (Node/npm) if you need to run MCP servers without local installation.
* **Integrating with Claude Desktop (or similar):** These tools typically expect to run a Python script, so your server should be runnable via `python your_server.py`, making `PythonStdioTransport` the relevant mechanism on the client side.
Communication happens through efficient in-memory queues, making it very fast and ideal for unit testing.

View file

@ -32,7 +32,7 @@ async def test_tool_functionality(mcp_server):
# Pass the server directly to the Client constructor
async with Client(mcp_server) as client:
result = await client.call_tool("greet", {"name": "World"})
assert "Hello, World!" in str(result[0])
assert result[0].text == "Hello, World!"
```
This pattern creates a direct connection between the client and server, allowing you to test your server's functionality efficiently.

View file

@ -89,7 +89,7 @@ proxy = FastMCP.from_client(client, name="SSE to Stdio Proxy")
You can also proxy an in-memory `FastMCP` instance, which is useful for adjusting the configuration or behavior of a server you don't completely control.
```python
from fastmcp import FastMCP
from fastmcp import FastMCP, Client
# Original server
original_server = FastMCP(name="Original")
@ -98,9 +98,12 @@ original_server = FastMCP(name="Original")
def tool_a() -> str:
return "A"
# Create a proxy of the original server
# To proxy an in-memory server, first create a Client to it.
client_to_original = Client(original_server)
# Create a proxy of the original server using the client.
proxy = FastMCP.from_client(
original_server,
client_to_original,
name="Proxy Server"
)

View file

@ -0,0 +1,89 @@
"""
This example demonstrates how to set up and use an in-memory FastMCP proxy.
It illustrates the pattern:
1. Create an original FastMCP server with some tools.
2. Create a Client that connects to this original server (in-memory).
3. Create a proxy FastMCP server using FastMCP.from_client(), passing it the client from step 2.
4. Use another Client to connect to the proxy server (in-memory) and interact with the original server's tools through the proxy.
"""
import asyncio
from mcp.types import TextContent
from fastmcp import FastMCP
from fastmcp.client import Client
class EchoService:
"""A simple service to demonstrate with"""
def echo(self, message: str) -> str:
return f"Original server echoes: {message}"
async def main():
print("--- In-Memory FastMCP Proxy Example ---")
print("This example will walk through setting up an in-memory proxy.")
print("-----------------------------------------")
# 1. Original Server Setup
print(
"\nStep 1: Setting up the Original Server (OriginalEchoServer) with an 'echo' tool..."
)
original_server = FastMCP("OriginalEchoServer")
original_server.add_tool(EchoService().echo)
print(f" -> Original Server '{original_server.name}' created.")
# 2. Client for Proxy
print("\nStep 2: Creating a Client to connect to the Original Server...")
print(" (This client will be used internally by the proxy server)")
client_to_original = Client(original_server)
print(f" -> Client for proxy created, targeting '{original_server.name}'.")
# 3. Proxy Server Creation
print("\nStep 3: Creating the Proxy Server (InMemoryProxy)...")
print(
f" (Using FastMCP.from_client, passing it the client from Step 2 that targets '{original_server.name}')"
)
proxy_server = FastMCP.from_client(client_to_original, name="InMemoryProxy")
print(
f" -> Proxy Server '{proxy_server.name}' created, proxying '{original_server.name}'."
)
# 4. Interacting via Proxy
print("\nStep 4: Using a new Client to connect to the Proxy Server and interact...")
async with Client(proxy_server) as final_client:
print(f" -> Successfully connected to proxy '{proxy_server.name}'.")
print("\n Listing tools available via proxy...")
tools = await final_client.list_tools()
if tools:
print(" Available Tools:")
for tool in tools:
print(
f" - {tool.name} (Description: {tool.description or 'N/A'})"
)
else:
print(" No tools found via proxy.")
message_to_echo = "Hello, simplified proxied world!"
print(f"\n Calling 'echo' tool via proxy with message: '{message_to_echo}'")
try:
result = await final_client.call_tool("echo", {"message": message_to_echo})
if result and isinstance(result[0], TextContent):
print(f" Result from proxied 'echo' call: '{result[0].text}'")
else:
print(
f" Error: Unexpected result format from proxied 'echo' call: {result}"
)
except Exception as e:
print(f" Error calling 'echo' tool via proxy: {e}")
print("\n-----------------------------------------")
print("--- In-Memory Proxy Example Finished ---")
if __name__ == "__main__":
asyncio.run(main())

View file

@ -7,7 +7,7 @@ dependencies = [
"python-dotenv>=1.1.0",
"exceptiongroup>=1.2.2",
"httpx>=0.28.1",
"mcp>=1.8.2,<2.0.0",
"mcp>=1.9.0,<2.0.0",
"openapi-pydantic>=0.5.1",
"rich>=13.9.4",
"typer>=0.15.2",
@ -96,6 +96,7 @@ reportMissingTypeStubs = false
useLibraryCodeForTypes = true
venvPath = "."
venv = ".venv"
strict = ["src/fastmcp/server/server.py"]
[tool.ruff.lint]
extend-select = ["I", "UP"]

View file

@ -263,7 +263,7 @@ def dev(
try:
# Import server to get dependencies
server = _import_server(file, server_object)
if hasattr(server, "dependencies"):
if hasattr(server, "dependencies") and server.dependencies is not None:
with_packages = list(set(with_packages + server.dependencies))
env_vars = {}

View file

@ -1,9 +1,10 @@
import datetime
from contextlib import AbstractAsyncContextManager
from contextlib import AsyncExitStack
from pathlib import Path
from typing import Any, cast
import mcp.types
from exceptiongroup import catch
from mcp import ClientSession
from pydantic import AnyUrl
@ -14,8 +15,9 @@ from fastmcp.client.roots import (
create_roots_callback,
)
from fastmcp.client.sampling import SamplingHandler, create_sampling_callback
from fastmcp.exceptions import ClientError
from fastmcp.exceptions import ToolError
from fastmcp.server import FastMCP
from fastmcp.utilities.exceptions import get_catch_handlers
from .transports import ClientTransport, SessionKwargs, infer_transport
@ -33,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__(
@ -45,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._session_cm: AbstractAsyncContextManager[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:
@ -91,9 +123,23 @@ class Client:
async def __aenter__(self):
if self._nesting_counter == 0:
# create new session
self._session_cm = self.transport.connect_session(**self._session_kwargs)
self._session = await self._session_cm.__aenter__()
# Create exit stack to manage both context managers
stack = AsyncExitStack()
await stack.__aenter__()
# Add the exception handling context
stack.enter_context(catch(get_catch_handlers()))
# the above catch will only apply once this __aenter__ finishes so
# we need to wrap the session creation in a new context in case it
# raises errors itself
with catch(get_catch_handlers()):
# Create and enter the transport session using the exit stack
session_cm = self.transport.connect_session(**self._session_kwargs)
self._session = await stack.enter_async_context(session_cm)
# Store the stack for cleanup in __aexit__
self._exit_stack = stack
self._nesting_counter += 1
return self
@ -101,10 +147,14 @@ class Client:
async def __aexit__(self, exc_type, exc_val, exc_tb):
self._nesting_counter -= 1
if self._nesting_counter == 0 and self._session_cm is not None:
await self._session_cm.__aexit__(exc_type, exc_val, exc_tb)
self._session_cm = None
self._session = None
if self._nesting_counter == 0:
# Exit the stack which will handle cleaning up the session
if self._exit_stack is not None:
try:
await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
finally:
self._exit_stack = None
self._session = None
# --- MCP Client Methods ---
@ -118,9 +168,12 @@ class Client:
progress_token: str | int,
progress: float,
total: float | None = None,
message: str | None = None,
) -> None:
"""Send a progress notification."""
await self.session.send_progress_notification(progress_token, progress, total)
await self.session.send_progress_notification(
progress_token, progress, total, message
)
async def set_logging_level(self, level: mcp.types.LoggingLevel) -> None:
"""Send a logging/setLevel request."""
@ -377,7 +430,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.
@ -387,7 +443,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.
@ -395,19 +451,25 @@ 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
]:
"""Call a tool on the server.
Unlike call_tool_mcp, this method raises a ClientError if the tool call results in an error.
Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error.
Args:
name (str): The name of the tool to call.
@ -418,11 +480,15 @@ class Client:
The content returned by the tool.
Raises:
ClientError: If the tool call results in an error.
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 ClientError(msg)
raise ToolError(msg)
return result.content

View file

@ -8,10 +8,9 @@ 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 exceptiongroup import BaseExceptionGroup, catch
from mcp import ClientSession, McpError, StdioServerParameters
from mcp import ClientSession, StdioServerParameters
from mcp.client.session import (
ListRootsFnT,
LoggingFnT,
@ -26,7 +25,6 @@ from mcp.shared.memory import create_connected_server_and_client_session
from pydantic import AnyUrl
from typing_extensions import Unpack
from fastmcp.exceptions import ClientError
from fastmcp.server import FastMCP as FastMCPServer
@ -104,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"):
@ -112,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
@ -131,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"):
@ -139,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
@ -418,26 +457,12 @@ class FastMCPTransport(ClientTransport):
async def connect_session(
self, **session_kwargs: Unpack[SessionKwargs]
) -> AsyncIterator[ClientSession]:
def exception_handler(excgroup: BaseExceptionGroup):
for exc in excgroup.exceptions:
if isinstance(exc, BaseExceptionGroup):
exception_handler(exc)
raise exc
def mcperror_handler(excgroup: BaseExceptionGroup):
for exc in excgroup.exceptions:
if isinstance(exc, BaseExceptionGroup):
mcperror_handler(exc)
raise ClientError(exc)
# backport of 3.11's except* syntax
with catch({McpError: mcperror_handler, Exception: exception_handler}):
# create_connected_server_and_client_session manages the session lifecycle itself
async with create_connected_server_and_client_session(
server=self._fastmcp._mcp_server,
**session_kwargs,
) as session:
yield session
# create_connected_server_and_client_session manages the session lifecycle itself
async with create_connected_server_and_client_session(
server=self._fastmcp._mcp_server,
**session_kwargs,
) as session:
yield session
def __repr__(self) -> str:
return f"<FastMCP(server='{self._fastmcp.name}')>"
@ -519,12 +544,6 @@ def infer_transport(
headers=server.get("headers", None),
)
# WebSocket transport
elif "ws_url" in server:
return WSTransport(
url=server["ws_url"],
)
raise ValueError("Cannot determine transport type from dictionary")
# the transport is an unknown type

View file

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

View file

@ -56,7 +56,7 @@ class Context:
ctx.error("Error message")
# Report progress
ctx.report_progress(50, 100)
ctx.report_progress(50, 100, "Processing")
# Access resources
data = ctx.read_resource("resource://data")
@ -96,7 +96,7 @@ class Context:
return self.fastmcp._mcp_server.request_context
async def report_progress(
self, progress: float, total: float | None = None
self, progress: float, total: float | None = None, message: str | None = None
) -> None:
"""Report progress for the current operation.
@ -115,7 +115,10 @@ class Context:
return
await self.request_context.session.send_progress_notification(
progress_token=progress_token, progress=progress, total=total
progress_token=progress_token,
progress=progress,
total=total,
message=message,
)
async def read_resource(self, uri: str | AnyUrl) -> list[ReadResourceContents]:

View file

@ -10,9 +10,15 @@ from mcp.server.auth.middleware.bearer_auth import (
BearerAuthBackend,
RequireAuthMiddleware,
)
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
from mcp.server.auth.provider import (
AccessTokenT,
AuthorizationCodeT,
OAuthAuthorizationServerProvider,
RefreshTokenT,
)
from mcp.server.auth.routes import create_auth_routes
from mcp.server.auth.settings import AuthSettings
from mcp.server.lowlevel.server import LifespanResultT
from mcp.server.sse import SseServerTransport
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from starlette.applications import Starlette
@ -30,6 +36,7 @@ if TYPE_CHECKING:
logger = get_logger(__name__)
_current_http_request: ContextVar[Request | None] = ContextVar(
"http_request",
default=None,
@ -62,7 +69,10 @@ class RequestContextMiddleware:
def setup_auth_middleware_and_routes(
auth_server_provider: OAuthAuthorizationServerProvider | None,
auth_server_provider: OAuthAuthorizationServerProvider[
AuthorizationCodeT, RefreshTokenT, AccessTokenT
]
| None,
auth_settings: AuthSettings | None,
) -> tuple[list[Middleware], list[BaseRoute], list[str]]:
"""Set up authentication middleware and routes if auth is enabled.
@ -136,10 +146,13 @@ def create_base_app(
def create_sse_app(
server: FastMCP,
server: FastMCP[LifespanResultT],
message_path: str,
sse_path: str,
auth_server_provider: OAuthAuthorizationServerProvider | None = None,
auth_server_provider: OAuthAuthorizationServerProvider[
AuthorizationCodeT, RefreshTokenT, AccessTokenT
]
| None = None,
auth_settings: AuthSettings | None = None,
debug: bool = False,
routes: list[BaseRoute] | None = None,
@ -236,10 +249,13 @@ def create_sse_app(
def create_streamable_http_app(
server: FastMCP,
server: FastMCP[LifespanResultT],
streamable_http_path: str,
event_store: None = None,
auth_server_provider: OAuthAuthorizationServerProvider | None = None,
auth_server_provider: OAuthAuthorizationServerProvider[
AuthorizationCodeT, RefreshTokenT, AccessTokenT
]
| None = None,
auth_settings: AuthSettings | None = None,
json_response: bool = False,
stateless_http: bool = False,

View file

@ -66,7 +66,7 @@ DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
@asynccontextmanager
async def default_lifespan(server: FastMCP) -> AsyncIterator[Any]:
async def default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any]:
"""Default lifespan context manager that does nothing.
Args:
@ -79,8 +79,10 @@ async def default_lifespan(server: FastMCP) -> AsyncIterator[Any]:
def _lifespan_wrapper(
app: FastMCP,
lifespan: Callable[[FastMCP], AbstractAsyncContextManager[LifespanResultT]],
app: FastMCP[LifespanResultT],
lifespan: Callable[
[FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]
],
) -> Callable[
[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]
]:
@ -189,15 +191,13 @@ class FastMCP(Generic[LifespanResultT]):
"""
if transport is None:
transport = "stdio"
if transport not in ["stdio", "streamable-http", "sse"]:
if transport not in {"stdio", "streamable-http", "sse"}:
raise ValueError(f"Unknown transport: {transport}")
if transport == "stdio":
await self.run_stdio_async(**transport_kwargs)
elif transport == "streamable-http":
await self.run_http_async(transport="streamable-http", **transport_kwargs)
elif transport == "sse":
await self.run_http_async(transport="sse", **transport_kwargs)
elif transport in {"streamable-http", "sse"}:
await self.run_http_async(transport=transport, **transport_kwargs)
else:
raise ValueError(f"Unknown transport: {transport}")
@ -228,7 +228,7 @@ class FastMCP(Generic[LifespanResultT]):
async def get_tools(self) -> dict[str, Tool]:
"""Get all registered tools, indexed by registered key."""
if (tools := self._cache.get("tools")) is self._cache.NOT_FOUND:
tools = {}
tools: dict[str, Tool] = {}
for server in self._mounted_servers.values():
server_tools = await server.get_tools()
tools.update(server_tools)
@ -239,7 +239,7 @@ class FastMCP(Generic[LifespanResultT]):
async def get_resources(self) -> dict[str, Resource]:
"""Get all registered resources, indexed by registered key."""
if (resources := self._cache.get("resources")) is self._cache.NOT_FOUND:
resources = {}
resources: dict[str, Resource] = {}
for server in self._mounted_servers.values():
server_resources = await server.get_resources()
resources.update(server_resources)
@ -252,7 +252,7 @@ class FastMCP(Generic[LifespanResultT]):
if (
templates := self._cache.get("resource_templates")
) is self._cache.NOT_FOUND:
templates = {}
templates: dict[str, ResourceTemplate] = {}
for server in self._mounted_servers.values():
server_templates = await server.get_resource_templates()
templates.update(server_templates)
@ -265,7 +265,7 @@ class FastMCP(Generic[LifespanResultT]):
List all available prompts.
"""
if (prompts := self._cache.get("prompts")) is self._cache.NOT_FOUND:
prompts = {}
prompts: dict[str, Prompt] = {}
for server in self._mounted_servers.values():
server_prompts = await server.get_prompts()
prompts.update(server_prompts)
@ -418,7 +418,7 @@ class FastMCP(Generic[LifespanResultT]):
for server in self._mounted_servers.values():
if server.match_prompt(name):
new_key = server.strip_prompt_prefix(name)
return await server.server._mcp_get_prompt(new_key, arguments)
return await server.server._mcp_get_prompt(new_key, arguments)
else:
raise NotFoundError(f"Unknown prompt: {name}")
@ -743,7 +743,8 @@ class FastMCP(Generic[LifespanResultT]):
port: int | None = None,
log_level: str | None = None,
path: str | None = None,
uvicorn_config: dict | None = None,
uvicorn_config: dict[str, Any] | None = None,
middleware: list[Middleware] | None = None,
) -> None:
"""Run the server using HTTP transport.
@ -760,7 +761,7 @@ class FastMCP(Generic[LifespanResultT]):
# lifespan is required for streamable http
uvicorn_config["lifespan"] = "on"
app = self.http_app(path=path, transport=transport)
app = self.http_app(path=path, transport=transport, middleware=middleware)
config = uvicorn.Config(
app,
@ -779,7 +780,7 @@ class FastMCP(Generic[LifespanResultT]):
log_level: str | None = None,
path: str | None = None,
message_path: str | None = None,
uvicorn_config: dict | None = None,
uvicorn_config: dict[str, Any] | None = None,
) -> None:
"""Run the server using SSE transport."""
@ -901,7 +902,7 @@ class FastMCP(Generic[LifespanResultT]):
port: int | None = None,
log_level: str | None = None,
path: str | None = None,
uvicorn_config: dict | None = None,
uvicorn_config: dict[str, Any] | None = None,
) -> None:
# Deprecated since 2.3.2
warnings.warn(
@ -1128,7 +1129,7 @@ class MountedServer:
def __init__(
self,
prefix: str,
server: FastMCP,
server: FastMCP[LifespanResultT],
tool_separator: str | None = None,
resource_separator: str | None = None,
prompt_separator: str | None = None,

View file

@ -1,6 +1,7 @@
from __future__ import annotations as _annotations
from typing import TYPE_CHECKING, Literal
import inspect
from typing import TYPE_CHECKING, Annotated, Literal
from mcp.server.auth.settings import AuthSettings
from pydantic import Field, model_validator
@ -28,16 +29,37 @@ class Settings(BaseSettings):
test_mode: bool = False
log_level: LOG_LEVEL = "INFO"
tool_attempt_parse_json_args: bool = Field(
default=False,
description="""
Note: this enables a legacy behavior. If True, will attempt to parse
stringified JSON lists and objects strings in tool arguments before
passing them to the tool. This is an old behavior that can create
unexpected type coercion issues, but may be helpful for less powerful
LLMs that stringify JSON instead of passing actual lists and objects.
Defaults to False.""",
)
client_raise_first_exceptiongroup_error: Annotated[
bool,
Field(
default=True,
description=inspect.cleandoc(
"""
Many MCP components operate in anyio taskgroups, and raise
ExceptionGroups instead of exceptions. If this setting is True, FastMCP Clients
will `raise` the first error in any ExceptionGroup instead of raising
the ExceptionGroup as a whole. This is useful for debugging, but may
mask other errors.
"""
),
),
] = True
tool_attempt_parse_json_args: Annotated[
bool,
Field(
default=False,
description=inspect.cleandoc(
"""
Note: this enables a legacy behavior. If True, will attempt to parse
stringified JSON lists and objects strings in tool arguments before
passing them to the tool. This is an old behavior that can create
unexpected type coercion issues, but may be helpful for less powerful
LLMs that stringify JSON instead of passing actual lists and objects.
Defaults to False.
"""
),
),
] = False
@model_validator(mode="after")
def setup_logging(self) -> Self:
@ -64,7 +86,10 @@ class ServerSettings(BaseSettings):
nested_model_default_partial_update=True,
)
log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level)
log_level: Annotated[
LOG_LEVEL,
Field(default_factory=lambda: Settings().log_level),
]
# HTTP settings
host: str = "127.0.0.1"
@ -83,10 +108,13 @@ class ServerSettings(BaseSettings):
# prompt settings
on_duplicate_prompts: DuplicateBehavior = "warn"
dependencies: list[str] = Field(
default_factory=list,
description="List of dependencies to install in the server environment",
)
dependencies: Annotated[
list[str],
Field(
default_factory=list,
description="List of dependencies to install in the server environment",
),
] = []
# cache settings (for checking mounted servers)
cache_expiration_seconds: float = 0
@ -100,16 +128,4 @@ class ServerSettings(BaseSettings):
)
class ClientSettings(BaseSettings):
"""FastMCP client settings."""
model_config = SettingsConfigDict(
env_prefix="FASTMCP_CLIENT_",
env_file=".env",
extra="ignore",
)
log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level)
settings = Settings()

View file

@ -0,0 +1,49 @@
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
def iter_exc(group: BaseExceptionGroup):
for exc in group.exceptions:
if isinstance(exc, BaseExceptionGroup):
yield from iter_exc(exc)
else:
yield exc
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[
type[BaseException] | Iterable[type[BaseException]],
Callable[[BaseExceptionGroup[Any]], Any],
] = {
Exception: _exception_handler,
}
def get_catch_handlers() -> Mapping[
type[BaseException] | Iterable[type[BaseException]],
Callable[[BaseExceptionGroup[Any]], Any],
]:
if fastmcp.settings.settings.client_raise_first_exceptiongroup_error:
return _catch_handlers
else:
return {}

View file

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

View file

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

View file

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

View file

@ -15,7 +15,7 @@ from pydantic.networks import AnyUrl
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.exceptions import ClientError
from fastmcp.exceptions import ToolError
from fastmcp.server.openapi import (
FastMCPOpenAPI,
OpenAPIResource,
@ -1029,7 +1029,7 @@ async def test_none_path_parameters_rejected(
# Create a client and try to call a tool with a None path parameter
async with Client(mcp_server) as client:
# get_user has a required path parameter user_id
with pytest.raises(ClientError, match="Missing required path parameters"):
with pytest.raises(ToolError, match="Missing required path parameters"):
await client.call_tool(
"update_user_name_users__user_id__name_patch",
{

View file

@ -4,11 +4,12 @@ from typing import Any
import mcp.types
import pytest
from dirty_equals import Contains
from mcp import McpError
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.client.transports import FastMCPTransport
from fastmcp.exceptions import ClientError
from fastmcp.exceptions import ToolError
from fastmcp.server.proxy import FastMCPProxy
USERS = [
@ -109,7 +110,7 @@ class TestTools:
assert proxy_result[0].text == "3"
async def test_error_tool_raises_error(self, proxy_server):
with pytest.raises(ClientError, match=""):
with pytest.raises(ToolError, match=""):
async with Client(proxy_server) as client:
await client.call_tool("error_tool", {})
@ -147,9 +148,7 @@ class TestResources:
assert json.loads(result[0].text) == USERS
async def test_read_resource_returns_none_if_not_found(self, proxy_server):
with pytest.raises(
ClientError, match="Unknown resource: resource://nonexistent"
):
with pytest.raises(McpError, match="Unknown resource: resource://nonexistent"):
async with Client(proxy_server) as client:
await client.read_resource("resource://nonexistent")

View file

@ -1,6 +1,7 @@
from typing import Annotated
import pytest
from mcp import McpError
from mcp.types import (
TextContent,
TextResourceContents,
@ -8,7 +9,7 @@ from mcp.types import (
from pydantic import Field
from fastmcp import Client, FastMCP
from fastmcp.exceptions import ClientError, NotFoundError
from fastmcp.exceptions import NotFoundError
class TestCreateServer:
@ -296,7 +297,7 @@ class TestResourceDecorator:
async def test_no_resources_before_decorator(self):
mcp = FastMCP()
with pytest.raises(ClientError, match="Unknown resource"):
with pytest.raises(McpError, match="Unknown resource"):
async with Client(mcp) as client:
await client.read_resource("resource://data")

View file

@ -8,6 +8,7 @@ from typing import Annotated, Literal
import pydantic_core
import pytest
from mcp import McpError
from mcp.types import (
BlobResourceContents,
ImageContent,
@ -18,7 +19,7 @@ from pydantic import AnyUrl, Field
from fastmcp import Client, Context, FastMCP
from fastmcp.client.transports import FastMCPTransport
from fastmcp.exceptions import ClientError
from fastmcp.exceptions import ToolError
from fastmcp.prompts.prompt import EmbeddedResource, PromptMessage
from fastmcp.resources import FileResource, FunctionResource
from fastmcp.utilities.types import Image
@ -320,7 +321,7 @@ class TestToolParameters:
async with Client(mcp) as client:
with pytest.raises(
ClientError,
ToolError,
match="Error calling tool 'my_tool'",
):
await client.call_tool("my_tool", {"x": "not an int"})
@ -365,7 +366,7 @@ class TestToolParameters:
pass
async with Client(mcp) as client:
with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
with pytest.raises(ToolError, match="Error calling tool 'analyze'"):
await client.call_tool("analyze", {"x": 0})
async def test_default_field_validation(self):
@ -376,7 +377,7 @@ class TestToolParameters:
pass
async with Client(mcp) as client:
with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
with pytest.raises(ToolError, match="Error calling tool 'analyze'"):
await client.call_tool("analyze", {"x": 0})
async def test_default_field_is_still_required_if_no_default_specified(self):
@ -387,7 +388,7 @@ class TestToolParameters:
pass
async with Client(mcp) as client:
with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
with pytest.raises(ToolError, match="Error calling tool 'analyze'"):
await client.call_tool("analyze", {})
async def test_literal_type_validation_error(self):
@ -398,7 +399,7 @@ class TestToolParameters:
pass
async with Client(mcp) as client:
with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
with pytest.raises(ToolError, match="Error calling tool 'analyze'"):
await client.call_tool("analyze", {"x": "c"})
async def test_literal_type_validation_success(self):
@ -426,7 +427,7 @@ class TestToolParameters:
return x.value
async with Client(mcp) as client:
with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
with pytest.raises(ToolError, match="Error calling tool 'analyze'"):
await client.call_tool("analyze", {"x": "some-color"})
async def test_enum_type_validation_success(self):
@ -462,7 +463,7 @@ class TestToolParameters:
assert isinstance(result[0], TextContent)
assert result[0].text == "1.0"
with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
with pytest.raises(ToolError, match="Error calling tool 'analyze'"):
await client.call_tool("analyze", {"x": "not a number"})
async def test_path_type(self):
@ -489,7 +490,7 @@ class TestToolParameters:
return str(path)
async with Client(mcp) as client:
with pytest.raises(ClientError, match="Error calling tool 'send_path'"):
with pytest.raises(ToolError, match="Error calling tool 'send_path'"):
await client.call_tool("send_path", {"path": 1})
async def test_uuid_type(self):
@ -515,7 +516,7 @@ class TestToolParameters:
return str(x)
async with Client(mcp) as client:
with pytest.raises(ClientError, match="Error calling tool 'send_uuid'"):
with pytest.raises(ToolError, match="Error calling tool 'send_uuid'"):
await client.call_tool("send_uuid", {"x": "not a uuid"})
async def test_datetime_type(self):
@ -554,7 +555,7 @@ class TestToolParameters:
return x.isoformat()
async with Client(mcp) as client:
with pytest.raises(ClientError, match="Error calling tool 'send_datetime'"):
with pytest.raises(ToolError, match="Error calling tool 'send_datetime'"):
await client.call_tool("send_datetime", {"x": "not a datetime"})
async def test_date_type(self):
@ -1230,7 +1231,7 @@ class TestPrompts:
async def test_get_unknown_prompt(self):
"""Test error when getting unknown prompt."""
mcp = FastMCP()
with pytest.raises(ClientError, match="Unknown prompt"):
with pytest.raises(McpError, match="Unknown prompt"):
async with Client(mcp) as client:
await client.get_prompt("unknown")
@ -1242,7 +1243,7 @@ class TestPrompts:
def prompt_fn(name: str) -> str:
return f"Hello, {name}!"
with pytest.raises(ClientError, match="Missing required arguments"):
with pytest.raises(McpError, match="Missing required arguments"):
async with Client(mcp) as client:
await client.get_prompt("prompt_fn")

View file

@ -4,7 +4,7 @@ from pydantic import BaseModel
from fastmcp import FastMCP, Image
from fastmcp.client import Client
from fastmcp.exceptions import ClientError
from fastmcp.exceptions import ToolError
from fastmcp.tools.tool import Tool
from fastmcp.utilities.tests import temporary_settings
@ -299,7 +299,7 @@ class TestLegacyToolJsonParsing:
async with Client(mcp) as client:
with pytest.raises(
ClientError,
ToolError,
match="Error calling tool 'process_list'",
):
await client.call_tool("process_list", {"items": "['a', 'b', 3]"})

8
uv.lock generated
View file

@ -340,7 +340,7 @@ dev = [
requires-dist = [
{ name = "exceptiongroup", specifier = ">=1.2.2" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "mcp", specifier = ">=1.8.0,<2.0.0" },
{ name = "mcp", specifier = ">=1.9.0,<2.0.0" },
{ name = "openapi-pydantic", specifier = ">=0.5.1" },
{ name = "python-dotenv", specifier = ">=1.1.0" },
{ name = "rich", specifier = ">=13.9.4" },
@ -573,7 +573,7 @@ wheels = [
[[package]]
name = "mcp"
version = "1.8.0"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -586,9 +586,9 @@ dependencies = [
{ name = "starlette" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/97/0a3e08559557b0ac5799f9fb535fbe5a4e4dcdd66ce9d32e7a74b4d0534d/mcp-1.8.0.tar.gz", hash = "sha256:263dfb700540b726c093f0c3e043f66aded0730d0b51f04eb0a3eb90055fe49b", size = 264641, upload-time = "2025-05-08T20:09:06.255Z" }
sdist = { url = "https://files.pythonhosted.org/packages/bc/8d/0f4468582e9e97b0a24604b585c651dfd2144300ecffd1c06a680f5c8861/mcp-1.9.0.tar.gz", hash = "sha256:905d8d208baf7e3e71d70c82803b89112e321581bcd2530f9de0fe4103d28749", size = 281432 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/b2/4ac3bd17b1fdd65658f18de4eb0c703517ee0b483dc5f56467802a9197e0/mcp-1.8.0-py3-none-any.whl", hash = "sha256:889d9d3b4f12b7da59e7a3933a0acadae1fce498bfcd220defb590aa291a1334", size = 119544, upload-time = "2025-05-08T20:09:04.458Z" },
{ url = "https://files.pythonhosted.org/packages/a5/d5/22e36c95c83c80eb47c83f231095419cf57cf5cca5416f1c960032074c78/mcp-1.9.0-py3-none-any.whl", hash = "sha256:9dfb89c8c56f742da10a5910a1f64b0d2ac2c3ed2bd572ddb1cfab7f35957178", size = 125082 },
]
[[package]]