mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 13:34:17 +02:00
Merge branch 'main' into sse-bugfix
This commit is contained in:
commit
86821dc548
26 changed files with 788 additions and 284 deletions
9
.github/workflows/run-static.yml
vendored
9
.github/workflows/run-static.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
22
.github/workflows/run-tests.yml
vendored
22
.github/workflows/run-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
89
examples/in_memory_proxy_example.py
Normal file
89
examples/in_memory_proxy_example.py
Normal 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())
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"""Custom exceptions for FastMCP."""
|
||||
|
||||
from mcp import McpError # noqa: F401
|
||||
|
||||
|
||||
class FastMCPError(Exception):
|
||||
"""Base error for FastMCP."""
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
49
src/fastmcp/utilities/exceptions.py
Normal file
49
src/fastmcp/utilities/exceptions.py
Normal 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 {}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
8
uv.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue