Merge branch 'main' into oauthclient

This commit is contained in:
Jeremiah Lowin 2025-05-29 17:40:26 -04:00
commit 0c8469ea4c
89 changed files with 8268 additions and 2260 deletions

View file

@ -44,7 +44,7 @@ jobs:
python-version: ${{ matrix.python-version }}
- name: Install FastMCP
run: uv sync --dev --locked
run: uv sync --locked
- name: Run tests
run: uv run pytest
run: uv run pytest tests

67
AGENTS.md Normal file
View file

@ -0,0 +1,67 @@
# AGENTS
> **Audience**: LLM-driven engineering agents
This file provides guidance for autonomous coding agents working inside the **FastMCP** repository.
---
## Repository map
| Path | Purpose |
| ---------------- | ---------------------------------------------------------------------------------------- |
| `src/fastmcp/` | Library source code (Python ≥ 3.10) |
| ` └─server/` | Server implementation, `FastMCP`, auth, networking |
| ` └─client/` | Highlevel client SDK + helpers |
| ` └─resources/` | MCP resources and resource templates |
| ` └─prompts/` | Prompt templates |
| ` └─tools/` | Tool implementations |
| `tests/` | Pytest testsuite |
| `docs/` | Mintlifyflavoured Markdown, published to [https://gofastmcp.com](https://gofastmcp.com) |
| `examples/` | Minimal runnable demos |
---
## Mandatory dev workflow
```bash
uv sync # install dependencies
uv run pre-commit run --all-files # Ruff + Prettier + Pyright
uv run pytest # run full test suite
```
*Tests must pass* and *lint/typing must be clean* before committing.
### Core MCP objects
There are four major MCP object types:
- Tools (`src/tools/`)
- Resources (`src/resources/`)
- Resource Templates (`src/resources/`)
- Prompts (`src/prompts`)
While these have slightly different semantics and implementations, in general changes that affect interactions with any one (like adding tags, importing, etc.) will need to be adopted, applied, and tested on all others. Be sure to look at not only the object definition but also the related `Manager` (e.g. `ToolManager`, `ResourceManager`, and `PromptManager`). Also note that while resources and resource templates are different objects, they both are handled by the `ResourceManager`.
---
## Code conventions
* **Language:** Python ≥ 3.10
* **Style:** Enforced through pre-commit hooks
* **Type-checking:** Fully typed codebase
* **Tests:** Each feature should have corresponding tests
---
## Development guidelines
1. **Set up** the environment:
```bash
uv sync && uv run pre-commit run --all-files
```
2. **Run tests**: `uv run pytest` until they pass.
3. **Iterate**: if a command fails, read the output, fix the code, retry.
4. Make the smallest set of changes that achieve the desired outcome.
5. Always read code before modifying it blindly.
6. Follow established patterns and maintain consistency.

View file

@ -15,11 +15,11 @@
> [!NOTE]
> #### FastMCP 2.0 & The Official MCP SDK
>
> Recognize the `FastMCP` name? You might have seen the version that was contributed to the [official MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk), which was based on **FastMCP 1.0**.
> FastMCP is the standard framework for building MCP servers and clients. FastMCP 1.0 was incorporated into the [official MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk).
>
> **Welcome to FastMCP 2.0!** This is the actively developed successor, and it significantly expands on 1.0 by introducing powerful client capabilities, server proxying & composition, OpenAPI/FastAPI integration, and more advanced features.
> **This is FastMCP 2.0,** the actively maintained version that significantly expands on 1.0's basic server-building capabilities by introducing full client support, server composition, OpenAPI/FastAPI integration, remote server proxying, built-in testing tools, and more.
>
> FastMCP 2.0 is the recommended path for building modern, powerful MCP applications. Ready to upgrade or get started? Follow the [installation instructions](https://gofastmcp.com/getting-started/installation), which include specific steps for upgrading from the official MCP SDK.
> FastMCP 2.0 is the complete toolkit for modern AI applications. Ready to upgrade or get started? Follow the [installation instructions](https://gofastmcp.com/getting-started/installation), which include specific steps for upgrading from the official MCP SDK.
---
@ -253,6 +253,29 @@ async def main():
# ... use the client
```
FastMCP also supports connecting to multiple servers through a single unified client using the standard MCP configuration format:
```python
from fastmcp import Client
# Standard MCP configuration with multiple servers
config = {
"mcpServers": {
"weather": {"url": "https://weather-api.example.com/mcp"},
"assistant": {"command": "python", "args": ["./assistant_server.py"]}
}
}
# Create a client that connects to all servers
client = Client(config)
async def main():
async with client:
# Access tools and resources with server prefixes
forecast = await client.call_tool("weather_get_forecast", {"city": "London"})
answer = await client.call_tool("assistant_answer_question", {"query": "What is MCP?"})
```
Learn more in the [**Client Documentation**](https://gofastmcp.com/clients/client) and [**Transports Documentation**](https://gofastmcp.com/clients/transports).
## Advanced Features
@ -261,7 +284,7 @@ FastMCP introduces powerful ways to structure and deploy your MCP applications.
### Proxy Servers
Create a FastMCP server that acts as an intermediary for another local or remote MCP server using `FastMCP.from_client()`. This is especially useful for bridging transports (e.g., remote SSE to local Stdio) or adding a layer of logic to a server you don't control.
Create a FastMCP server that acts as an intermediary for another local or remote MCP server using `FastMCP.as_proxy()`. This is especially useful for bridging transports (e.g., remote SSE to local Stdio) or adding a layer of logic to a server you don't control.
Learn more in the [**Proxying Documentation**](https://gofastmcp.com/patterns/proxy).

View file

@ -0,0 +1,152 @@
---
title: Advanced Features
sidebarTitle: Advanced Features
description: Learn about the advanced features of the FastMCP Client.
icon: stars
---
import { VersionBadge } from '/snippets/version-badge.mdx'
In addition to basic server interaction, FastMCP clients can also handle more advanced features and server interaction patterns. The `Client` constructor accepts additional configuration to handle these server requests.
<Tip>
To enable many of these features, you must provide an appropriate handler or callback function. For example. In most cases, if you do not provide a handler, FastMCP's default handler will emit a `DEBUG` level log.
</Tip>
## Logging and Notifications
<VersionBadge version="2.0.0" />
MCP servers can emit logs to clients. To process these logs, you can provide a `log_handler` to the client.
The `log_handler` must be an async function that accepts a single argument, which is an instance of `fastmcp.client.logging.LogMessage`. This has attributes like `level`, `logger`, and `data`.
```python {2, 12}
from fastmcp import Client
from fastmcp.client.logging import LogMessage
async def log_handler(message: LogMessage):
level = message.level.upper()
logger = message.logger or 'default'
data = message.data
print(f"[Server Log - {level}] {logger}: {data}")
client_with_logging = Client(
...,
log_handler=log_handler,
)
```
## Progress Monitoring
<VersionBadge version="2.3.5" />
MCP servers can report progress during long-running operations. The client can set a progress handler to receive and process these updates.
```python {2, 13}
from fastmcp import Client
from fastmcp.client.progress import ProgressHandler
async def my_progress_handler(
progress: float,
total: float | None,
message: str | None
) -> None:
print(f"Progress: {progress} / {total} ({message})")
client = Client(
...,
progress_handler=my_progress_handler
)
```
By default, FastMCP uses a handler that logs progress updates at the debug level. This default handler properly handles cases where `total` or `message` might be None.
You can override the progress handler for specific tool calls:
```python
# Client uses the default debug logger for progress
client = Client(...)
async with client:
# Use default progress handler (debug logging)
result1 = await client.call_tool("long_task", {"param": "value"})
# Override with custom progress handler just for this call
result2 = await client.call_tool(
"another_task",
{"param": "value"},
progress_handler=my_progress_handler
)
```
A typical progress update includes:
- Current progress value (e.g., 2 of 5 steps completed)
- Total expected value (may be None)
- Status message (may be None)
## LLM Sampling
<VersionBadge version="2.0.0" />
MCP Servers can request LLM completions from clients. The client can provide a `sampling_handler` to handle these requests. The sampling handler receives a list of messages and other parameters from the server, and should return a string completion.
The following example uses the `marvin` library to generate a completion:
```python {8-17, 21}
import marvin
from fastmcp import Client
from fastmcp.client.sampling import (
SamplingMessage,
SamplingParams,
RequestContext,
)
async def sampling_handler(
messages: list[SamplingMessage],
params: SamplingParams,
context: RequestContext
) -> str:
return await marvin.say_async(
message=[m.content.text for m in messages],
instructions=params.systemPrompt,
)
client = Client(
...,
sampling_handler=sampling_handler,
)
```
## Roots
<VersionBadge version="2.0.0" />
Roots are a way for clients to inform servers about the resources they have access to or certain boundaries on their access. The server can use this information to adjust behavior or provide more accurate responses.
Servers can request roots from clients, and clients can notify servers when their roots change.
To set the roots when creating a client, users can either provide a list of roots (which can be a list of strings) or an async function that returns a list of roots.
<CodeGroup>
```python Static Roots {5}
from fastmcp import Client
client = Client(
...,
roots=["/path/to/root1", "/path/to/root2"],
)
```
```python Dynamic Roots Callback {4-6, 10}
from fastmcp import Client
from fastmcp.client.roots import RequestContext
async def roots_callback(context: RequestContext) -> list[str]:
print(f"Server requested roots (Request ID: {context.request_id})")
return ["/path/to/root1", "/path/to/root2"]
client = Client(
...,
roots=roots_callback,
)
```
</CodeGroup>

View file

@ -18,7 +18,6 @@ The FastMCP Client architecture separates the protocol logic (`Client`) from the
- **`Client`**: Handles sending MCP requests (like `tools/call`, `resources/read`), receiving responses, and managing callbacks.
- **`Transport`**: Responsible for establishing and maintaining the connection to the server (e.g., via WebSockets, SSE, Stdio, or in-memory).
### Transports
Clients must be initialized with a `transport`. You can either provide an already instantiated transport object, or provide a transport source and let FastMCP attempt to infer the correct transport to use.
@ -26,13 +25,14 @@ Clients must be initialized with a `transport`. You can either provide an alread
The following inference rules are used to determine the appropriate `ClientTransport` based on the input type:
1. **`ClientTransport` Instance**: If you provide an already instantiated transport object, it's used directly.
2. **`FastMCP` Instance**: Creates a `FastMCPTransport` for efficient in-memory communication (ideal for testing).
2. **`FastMCP` Instance**: Creates a `FastMCPTransport` for efficient in-memory communication (ideal for testing). This also works with a **FastMCP 1.0 server** created via `mcp.server.fastmcp.FastMCP`.
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 that begins with `http://` or `https://`**:
* Creates a `StreamableHttpTransport`
5. **Other**: Raises a `ValueError` if the type cannot be inferred.
5. **`MCPConfig` or dictionary matching MCPConfig schema**: Creates a client that connects to one or more MCP servers specified in the config.
6. **Other**: Raises a `ValueError` if the type cannot be inferred.
```python
import asyncio
@ -41,30 +41,100 @@ from fastmcp import Client, FastMCP
# Example transports (more details in Transports page)
server_instance = FastMCP(name="TestServer") # In-memory server
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_http = Client(http_url)
client_ws = Client(ws_url)
client_stdio = Client(server_script)
print(client_in_memory.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')>
# <StreamableHttp(url='https://example.com/mcp')>
# <WebSocket(url='ws://localhost:9000')>
# <PythonStdioTransport(command='python', args=['/path/to/your/my_mcp_server.py'])>
```
You can also initialize a client from an MCP configuration dictionary or `MCPConfig` file:
```python
from fastmcp import Client
config = {
"mcpServers": {
"local": {"command": "python", "args": ["local_server.py"]},
"remote": {"url": "https://example.com/mcp"},
}
}
client_config = Client(config)
```
<Tip>
For more control over connection details (like headers for SSE, environment variables for Stdio), you can instantiate the specific `ClientTransport` class yourself and pass it to the `Client`. See the [Transports](/clients/transports) page for details.
</Tip>
### Multi-Server Clients
<VersionBadge version="2.4.0" />
FastMCP supports creating clients that connect to multiple MCP servers through a single client interface using a standard MCP configuration format (`MCPConfig`). This configuration approach makes it easy to connect to multiple specialized servers or create composable systems with a simple, declarative syntax.
<Note>
The MCP configuration format follows an emerging standard and may evolve as the specification matures. FastMCP will strive to maintain compatibility with future versions, but be aware that field names or structure might change.
</Note>
When you create a client with an `MCPConfig` containing multiple servers:
1. FastMCP creates a composite client that internally mounts all servers using their config names as prefixes
2. Tools and resources from each server are accessible with appropriate prefixes in the format `servername_toolname` and `protocol://servername/resource/path`
3. You interact with this as a single unified client, with requests automatically routed to the appropriate server
```python
from fastmcp import Client
# Create a standard MCP configuration with multiple servers
config = {
"mcpServers": {
# A remote HTTP server
"weather": {
"url": "https://weather-api.example.com/mcp",
"transport": "streamable-http"
},
# A local server running via stdio
"assistant": {
"command": "python",
"args": ["./my_assistant_server.py"],
"env": {"DEBUG": "true"}
}
}
}
# Create a client that connects to both servers
client = Client(config)
async def main():
async with client:
# Access tools from different servers with prefixes
weather_data = await client.call_tool("weather_get_forecast", {"city": "London"})
response = await client.call_tool("assistant_answer_question", {"question": "What's the capital of France?"})
# Access resources with prefixed URIs
weather_icons = await client.read_resource("weather://weather/icons/sunny")
templates = await client.read_resource("resource://assistant/templates/list")
print(f"Weather: {weather_data}")
print(f"Assistant: {response}")
if __name__ == "__main__":
asyncio.run(main())
```
If your configuration has only a single server, FastMCP will create a direct client to that server without any prefixing.
## Client Usage
### Connection Lifecycle
@ -114,7 +184,7 @@ 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, timeout: float | None = None)`**: Executes a tool on the server.
* **`call_tool(name: str, arguments: dict[str, Any] | None = None, timeout: float | None = None, progress_handler: ProgressHandler | 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 | ...]
@ -122,10 +192,18 @@ The standard client methods return user-friendly representations that may change
# With timeout (aborts if execution takes longer than 2 seconds)
result = await client.call_tool("long_running_task", {"param": "value"}, timeout=2.0)
# With progress handler (to track execution progress)
result = await client.call_tool(
"long_running_task",
{"param": "value"},
progress_handler=my_progress_handler
)
```
* 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.
* The optional `progress_handler` parameter receives progress updates during execution, overriding any client-level progress handler.
#### Resource Operations
@ -190,11 +268,42 @@ Available raw MCP methods:
These methods are especially useful for debugging or when you need to access metadata or fields that aren't exposed by the simplified methods.
### Advanced Features
### Additional Features
MCP allows servers to interact with clients in order to provide additional capabilities. The `Client` constructor accepts additional configuration to handle these server requests.
#### Pinging the Server
#### Timeout Control
The client can be used to ping the server to verify connectivity.
```python
async with client:
await client.ping()
print("Server is reachable")
```
#### Session Management
When using stdio transports, clients support a `keep_alive` feature (enabled by default) that maintains subprocess sessions between connection contexts. You can manually control this behavior using the client's `close()` method.
When `keep_alive=False`, the client will automatically close the session when the context manager exits.
```python
from fastmcp import Client
client = Client("my_mcp_server.py") # keep_alive=True by default
async def example():
async with client:
await client.ping()
async with client:
await client.ping() # Same subprocess as above
```
<Note>
For detailed examples and configuration options, see [Session Management in Transports](/clients/transports#session-management).
</Note>
#### Timeouts
<VersionBadge version="2.3.4" />
@ -234,96 +343,7 @@ Timeout behavior varies between transport types:
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
MCP Servers can request LLM completions from clients. The client can provide a `sampling_handler` to handle these requests. The sampling handler receives a list of messages and other parameters from the server, and should return a string completion.
The following example uses the `marvin` library to generate a completion:
```python {8-17, 21}
import marvin
from fastmcp import Client
from fastmcp.client.sampling import (
SamplingMessage,
SamplingParams,
RequestContext,
)
async def sampling_handler(
messages: list[SamplingMessage],
params: SamplingParams,
context: RequestContext
) -> str:
return await marvin.say_async(
message=[m.content.text for m in messages],
instructions=params.systemPrompt,
)
client = Client(
...,
sampling_handler=sampling_handler,
)
```
#### Logging
MCP servers can emit logs to clients. The client can set a logging callback to receive these logs.
```python {4-5, 9}
from fastmcp import Client
from fastmcp.client.logging import LogHandler, LogMessage
async def my_log_handler(params: LogMessage):
print(f"[Server Log - {params.level.upper()}] {params.logger or 'default'}: {params.data}")
client_with_logging = Client(
...,
log_handler=my_log_handler,
)
```
#### Roots
Roots are a way for clients to inform servers about the resources they have access to or certain boundaries on their access. The server can use this information to adjust behavior or provide more accurate responses.
Servers can request roots from clients, and clients can notify servers when their roots change.
To set the roots when creating a client, users can either provide a list of roots (which can be a list of strings) or an async function that returns a list of roots.
<CodeGroup>
```python Static Roots {5}
from fastmcp import Client
client = Client(
...,
roots=["/path/to/root1", "/path/to/root2"],
)
```
```python Dynamic Roots Callback {4-6, 10}
from fastmcp import Client
from fastmcp.client.roots import RequestContext
async def roots_callback(context: RequestContext) -> list[str]:
print(f"Server requested roots (Request ID: {context.request_id})")
return ["/path/to/root1", "/path/to/root2"]
client = Client(
...,
roots=roots_callback,
)
```
</CodeGroup>
### Utility Methods
* **`ping()`**: Sends a ping request to the server to verify connectivity.
```python
async def check_connection():
async with client:
await client.ping()
print("Server is reachable")
```
### Error Handling
#### Error Handling
When a `call_tool` request results in an error on the server (e.g., the tool function raised an exception), the `client.call_tool()` method will raise a `fastmcp.client.ClientError`.

View file

@ -40,7 +40,7 @@ Streamable HTTP is the recommended transport for web-based deployments, providin
#### Overview
- **Class:** `fastmcp.client.transports.StreamableHttpTransport`
- **Inferred From:** URLs starting with `http://` or `https://` (default for HTTP URLs since v2.3.0)
- **Inferred From:** URLs starting with `http://` or `https://` (default for HTTP URLs since v2.3.0) that do not contain `/sse/` in the path
- **Server Compatibility:** Works with FastMCP servers running in `streamable-http` mode
#### Basic Usage
@ -62,6 +62,15 @@ async def main():
asyncio.run(main())
```
You can also explicitly instantiate the transport:
```python
from fastmcp.client.transports import StreamableHttpTransport
transport = StreamableHttpTransport(url="https://example.com/mcp")
client = Client(transport)
```
#### Authentication with Headers
For servers requiring authentication:
@ -88,23 +97,19 @@ Server-Sent Events (SSE) is a transport that allows servers to push data to clie
#### Overview
- **Class:** `fastmcp.client.transports.SSETransport`
- **Inferred From:** Not automatically inferred for HTTP URLs since v2.3.0 (must be explicitly specified)
- **Inferred From:** HTTP URLs containing `/sse/` in the path
- **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:
The simplest way to use SSE is to let the transport be inferred from a URL with `/sse/` in the path:
```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)
# The Client automatically uses SSETransport for URLs containing /sse/ in the path
client = Client("https://example.com/sse")
async def main():
async with client:
@ -114,6 +119,15 @@ async def main():
asyncio.run(main())
```
You can also explicitly instantiate the transport for URLs that do not contain `/sse/` in the path or for more control:
```python
from fastmcp.client.transports import SSETransport
transport = SSETransport(url="https://example.com/sse")
client = Client(transport)
```
#### Authentication with Headers
SSE transport also supports custom headers for authentication:
@ -146,6 +160,63 @@ client = Client(transport)
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.
### Session Management
All stdio transports support a `keep_alive` parameter (default: `True`) that controls session persistence across multiple client context managers:
- **`keep_alive=True` (default)**: The subprocess and session are maintained between client context exits and re-entries. This improves performance when making multiple separate connections to the same server.
- **`keep_alive=False`**: A new subprocess is started for each client context, ensuring complete isolation between sessions.
When `keep_alive=True`, you can manually close the session using `await client.close()` if needed. This will terminate the subprocess and require a new one to be started on the next connection.
<CodeGroup>
```python keep_alive=True
from fastmcp import Client
# Client with keep_alive=True (default)
client = Client("my_mcp_server.py")
async def example():
# First session
async with client:
await client.ping()
# Second session - uses the same subprocess
async with client:
await client.ping()
# Manually close the session
await client.close()
# Third session - will start a new subprocess
async with client:
await client.ping()
asyncio.run(example())
```
```python keep_alive=False
from fastmcp import Client
# Client with keep_alive=False
client = Client("my_mcp_server.py", keep_alive=False)
async def example():
# First session
async with client:
await client.ping()
# Second session - will start a new subprocess
async with client:
await client.ping()
# Third session - will start a new subprocess
async with client:
await client.ping()
asyncio.run(example())
```
</CodeGroup>
### Python Stdio
- **Class:** `fastmcp.client.transports.PythonStdioTransport`
@ -204,7 +275,7 @@ client = Client(node_server_script)
# Option 2: Explicit transport
transport = NodeStdioTransport(
script_path=node_server_script,
node_cmd="node" # Optional: specify path to Node executable
node_cmd="node", # Optional: specify path to Node executable
)
client = Client(transport)
@ -276,8 +347,8 @@ asyncio.run(main())
### FastMCP Transport
- **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
- **Inferred From:** An instance of `fastmcp.server.FastMCP` or a **FastMCP 1.0 server** (`mcp.server.fastmcp.FastMCP`)
- **Use Case:** Connecting directly to a FastMCP server instance in the same Python process
This is extremely useful for testing your FastMCP servers.
@ -303,4 +374,64 @@ async def main():
asyncio.run(main())
```
Communication happens through efficient in-memory queues, making it very fast and ideal for unit testing.
Communication happens through efficient in-memory queues, making it very fast and ideal for unit testing.
## Configuration-Based Transports
### MCPConfig Transport
<VersionBadge version="2.4.0" />
- **Class:** `fastmcp.client.transports.MCPConfigTransport`
- **Inferred From:** An instance of `MCPConfig` or a dictionary matching the MCPConfig schema
- **Use Case:** Connecting to one or more MCP servers defined in a configuration object
MCPConfig follows an emerging standard for MCP server configuration but is subject to change as the specification evolves. The standard supports both local servers (running via stdio) and remote servers (accessed via HTTP).
```python
from fastmcp import Client
# Configuration for multiple MCP servers (both local and remote)
config = {
"mcpServers": {
# Remote HTTP server
"weather": {
"url": "https://weather-api.example.com/mcp",
"transport": "streamable-http"
},
# Local stdio server
"assistant": {
"command": "python",
"args": ["./assistant_server.py"],
"env": {"DEBUG": "true"}
},
# Another remote server
"calendar": {
"url": "https://calendar-api.example.com/mcp",
"transport": "streamable-http"
}
}
}
# Create a transport from the config (happens automatically with Client)
client = Client(config)
async def main():
async with client:
# Tools are accessible with server name prefixes
weather = await client.call_tool("weather_get_forecast", {"city": "London"})
answer = await client.call_tool("assistant_answer_question", {"query": "What is MCP?"})
events = await client.call_tool("calendar_list_events", {"date": "2023-06-01"})
# Resources use prefixed URI paths
icons = await client.read_resource("weather://weather/icons/sunny")
docs = await client.read_resource("resource://assistant/docs/mcp")
asyncio.run(main())
```
If your configuration has only a single server, the client will connect directly to that server without any prefixing. This makes it convenient to switch between single and multi-server configurations without changing your client code.
<Note>
The MCPConfig format is an emerging standard for MCP server configuration and may change as the MCP ecosystem evolves. While FastMCP aims to maintain compatibility with future versions, be aware that field names or structure might change.
</Note>

View file

@ -27,7 +27,7 @@ fastmcp --help
### `run`
Run a FastMCP server directly.
Run a FastMCP server directly or proxy a remote server.
```bash
fastmcp run server.py
@ -47,13 +47,15 @@ This command runs the server directly in your current Python environment. You ar
| Log Level | `--log-level`, `-l` | Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) |
#### Server Specification
<VersionBadge version="2.3.5" />
The server can be specified in two ways:
The server can be specified in three ways:
1. `server.py` - imports the module and looks for a FastMCP object named `mcp`, `server`, or `app`. Errors if no such object is found.
2. `server.py:custom_name` - imports and uses the specified server object
3. `http://server-url/path` or `https://server-url/path` - connects to a remote server and creates a proxy
<Tip>
When using `fastmcp run`, it **ignores** the `if __name__ == "__main__"` block entirely. Instead, it finds your server object and calls its `run()` method directly with the transport options you specify. This means you can use `fastmcp run` to override the transport specified in your code.
When using `fastmcp run` with a local file, it **ignores** the `if __name__ == "__main__"` block entirely. Instead, it finds your server object and calls its `run()` method directly with the transport options you specify. This means you can use `fastmcp run` to override the transport specified in your code.
</Tip>
For example, if your code contains:
@ -79,11 +81,17 @@ You can run it with Streamable HTTP transport regardless of what's in the `__mai
fastmcp run server.py --transport streamable-http --port 8000
```
**Example**
**Examples**
```bash
# Run a server with Streamable HTTP transport on a custom port
# Run a local server with Streamable HTTP transport on a custom port
fastmcp run server.py --transport streamable-http --port 8000
# Connect to a remote server and proxy as a stdio server
fastmcp run https://example.com/mcp-server
# Connect to a remote server with specified log level
fastmcp run https://example.com/mcp-server --log-level DEBUG
```
### `dev`
@ -140,9 +148,12 @@ Install a MCP server in the Claude desktop app.
fastmcp install server.py
```
<Tip>
This command installs your server in an isolated environment. All dependencies must be explicitly specified using the `--with` and/or `--with-editable` options.
</Tip>
Note that for security reasons, Claude runs every MCP server in a completely isolated environment. Therefore, all dependencies must be explicitly specified using the `--with` and/or `--with-editable` options (following `uv` conventions) or by attaching them to your server in code via the `dependencies` parameter.
<Warning>
- **`uv` must be installed and available in your system PATH**. Claude Desktop runs in its own isolated environment and needs `uv` to manage dependencies.
- **On macOS, it is recommended to install `uv` globally with Homebrew** so that Claude Desktop will detect it: `brew install uv`. Installing `uv` with other methods may not make it accessible to Claude Desktop.
</Warning>
<Warning>
The `install` command currently only sets up servers for STDIO transport. When installed in the Claude desktop app, your server will be run using STDIO regardless of any transport configuration in your code.

View file

@ -50,6 +50,7 @@
"servers/resources",
"servers/prompts",
"servers/context",
"servers/openapi",
"servers/proxy",
"servers/composition"
]
@ -67,7 +68,8 @@
"group": "Clients",
"pages": [
"clients/client",
"clients/transports"
"clients/transports",
"clients/advanced-features"
]
},
{
@ -75,8 +77,6 @@
"pages": [
"patterns/decorating-methods",
"patterns/http-requests",
"patterns/openapi",
"patterns/fastapi",
"patterns/contrib",
"patterns/testing"
]

View file

@ -60,6 +60,20 @@ mcp = FastMCP("My MCP Server")
Prior to `fastmcp==2.3.0` and `mcp==1.8.0`, the 2.x API always mirrored the 1.0 API. However, as the projects diverge, this can not be guaranteed. You may see deprecation warnings if you attempt to use 1.0 APIs in FastMCP 2.x. Please refer to this documentation for details on new capabilities.
</Warning>
## Versioning and Breaking Changes
While we make every effort not to introduce backwards-incompatible changes to our public APIs and behavior, FastMCP exists in a rapidly evolving MCP landscape. We're committed to bringing the most cutting-edge features to our users, which occasionally necessitates changes to existing functionality.
As a practice, breaking changes will only occur on minor version changes (e.g., 2.3.x to 2.4.0). A minor version change indicates either:
- A significant new feature set that warrants a new minor version
- Introducing breaking changes that may affect behavior on upgrade
For users concerned about stability in production environments, we recommend pinning FastMCP to a specific version in your dependencies.
Whenever possible, FastMCP will issue deprecation warnings when users attempt to use APIs that are either deprecated or destined for future removal. These warnings will be maintained for at least 1 minor version release, and may be maintained longer.
Note that the "public API" includes the core functionality of the `FastMCP` server and its methods. It does not include private methods or objects that are stored as private attributes, as we do not expect users to rely on those implementation details.
## Installing for Development
If you plan to contribute to FastMCP, you should begin by cloning the repository and using uv to install all dependencies (development dependencies are installed automatically):

View file

@ -24,17 +24,13 @@ if __name__ == "__main__":
```
## FastMCP 2.0 and the Official MCP SDK
## FastMCP and the Official MCP SDK
<Tip>
Recognize the `FastMCP` name? You might have seen the version that was contributed to the [official MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk), which was based on **FastMCP 1.0**.
FastMCP is the standard framework for building MCP servers and clients. FastMCP 1.0 was incorporated into the [official MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk).
**This is FastMCP 2.0,** the [actively maintained version](https://github.com/jlowin/fastmcp) that significantly expands on 1.0's basic server-building capabilities by introducing full client support, server composition, OpenAPI/FastAPI integration, remote server proxying, built-in testing tools, and more.
**Welcome to FastMCP 2.0!** This is the [actively developed successor](https://github.com/jlowin/fastmcp), and it significantly expands on 1.0 by introducing powerful client capabilities, server proxying & composition, OpenAPI/FastAPI integration, and more advanced features.
FastMCP 2.0 is the recommended path for building modern, powerful MCP applications. Ready to upgrade or get started? Follow the [installation instructions](/getting-started/installation), which include specific steps for upgrading.
</Tip>
FastMCP 2.0 is the complete toolkit for modern AI applications. Ready to upgrade or get started? Follow the [installation instructions](/getting-started/installation), which include specific steps for upgrading from the official MCP SDK.
## What is MCP?

View file

@ -8,19 +8,18 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
<VersionBadge version="2.0.0" />
<Note>
**Documentation Moved**: The comprehensive FastAPI integration documentation has been moved to the [OpenAPI Integration](/patterns/openapi#fastapi-integration) page, where it's covered alongside all other OpenAPI features including route mapping and tags support.
</Note>
FastMCP can automatically convert FastAPI applications into MCP servers.
## Quick Start
<Tip>
FastMCP does *not* include FastAPI as a dependency; you must install it separately to run these examples.
</Tip>
FastMCP can automatically convert FastAPI applications into MCP servers:
```python {2, 22, 25}
```python
from fastapi import FastAPI
from fastmcp import FastMCP
# A FastAPI app
app = FastAPI()
@ -36,7 +35,6 @@ def get_item(item_id: int):
def create_item(name: str):
return {"id": 3, "name": name}
# Create an MCP server from your FastAPI app
mcp = FastMCP.from_fastapi(app=app)
@ -44,101 +42,6 @@ if __name__ == "__main__":
mcp.run() # Start the MCP server
```
## Configuration Options
### Timeout
You can set a timeout for all API requests:
```python
# Set a 5 second timeout for all requests
mcp = FastMCP.from_fastapi(app=app, timeout=5.0)
```
This timeout is applied to all requests made by tools, resources, and resource templates.
## Route Mapping
By default, FastMCP will map FastAPI routes to MCP components according to the following rules:
| FastAPI Route Type | FastAPI Example | MCP Component | Notes |
|--------------------|--------------|---------|-------|
| GET without path params | `@app.get("/stats")` | Resource | Simple resources for fetching data |
| GET with path params | `@app.get("/users/{id}")` | Resource Template | Path parameters become template parameters |
| POST, PUT, DELETE, etc. | `@app.post("/users")` | Tool | Operations that modify data |
For more details on route mapping or custom mapping rules, see the [OpenAPI integration documentation](/patterns/openapi#route-mapping); FastMCP uses the same mapping rules for both FastAPI and OpenAPI integrations.
## Complete Example
Here's a more detailed example with a data model:
```python [expandable]
import asyncio
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from fastmcp import FastMCP, Client
# Define your Pydantic model
class Item(BaseModel):
name: str
price: float
# Create your FastAPI app
app = FastAPI()
items = {} # In-memory database
@app.get("/items")
def list_items():
"""List all items"""
return list(items.values())
@app.get("/items/{item_id}")
def get_item(item_id: int):
"""Get item by ID"""
if item_id not in items:
raise HTTPException(404, "Item not found")
return items[item_id]
@app.post("/items")
def create_item(item: Item):
"""Create a new item"""
item_id = len(items) + 1
items[item_id] = {"id": item_id, **item.model_dump()}
return items[item_id]
# Test your MCP server with a client
async def check_mcp(mcp: FastMCP):
# List the components that were created
tools = await mcp.get_tools()
resources = await mcp.get_resources()
templates = await mcp.get_resource_templates()
print(
f"{len(tools)} Tool(s): {', '.join([t.name for t in tools.values()])}"
)
print(
f"{len(resources)} Resource(s): {', '.join([r.name for r in resources.values()])}"
)
print(
f"{len(templates)} Resource Template(s): {', '.join([t.name for t in templates.values()])}"
)
return mcp
if __name__ == "__main__":
# Create MCP server from FastAPI app
mcp = FastMCP.from_fastapi(app=app)
asyncio.run(check_mcp(mcp))
# In a real scenario, you would run the server:
mcp.run()
```
## Benefits
- **Leverage existing FastAPI apps** - No need to rewrite your API logic
- **Schema reuse** - FastAPI's Pydantic models and validation are inherited
- **Full feature support** - Works with FastAPI's authentication, dependencies, etc.
- **ASGI transport** - Direct communication without additional HTTP overhead
<Tip>
For complete documentation including tag-based routing, route mapping configuration, timeout settings, authentication examples, and advanced configuration options, see the comprehensive [OpenAPI Integration documentation](/patterns/openapi#fastapi-integration).
</Tip>

View file

@ -23,7 +23,7 @@ from fastmcp import FastMCP
from fastmcp.server.dependencies import get_http_request
from starlette.requests import Request
mcp = FastMCP(name="HTTPRequestDemo")
mcp = FastMCP(name="HTTP Request Demo")
@mcp.tool()
async def user_agent_info() -> dict:
@ -48,32 +48,40 @@ This approach works anywhere within a request's execution flow, not just within
2. You're calling nested functions that need HTTP request data
3. You're working with middleware or other request processing code
## Important Notes
## Accessing HTTP Headers Only
- HTTP requests are only available when FastMCP is running as part of a web application
- Accessing the HTTP request outside of a web request context will raise a `RuntimeError`
- The `get_http_request()` function returns a standard [Starlette Request](https://www.starlette.io/requests/) object
If you only need request headers and want to avoid potential errors, you can use the `get_http_headers()` helper:
## Common Use Cases
```python {2}
from fastmcp import FastMCP
from fastmcp.server.dependencies import get_http_headers
### Accessing Request Headers
```python
from fastmcp.server.dependencies import get_http_request
mcp = FastMCP(name="Headers Demo")
@mcp.tool()
async def get_auth_info() -> dict:
"""Get authentication information from request headers."""
request = get_http_request()
async def safe_header_info() -> dict:
"""Safely get header information without raising errors."""
# Get headers (returns empty dict if no request context)
headers = get_http_headers()
# Get authorization header
auth_header = request.headers.get("authorization", "")
# Check for Bearer token
auth_header = headers.get("authorization", "")
is_bearer = auth_header.startswith("Bearer ")
return {
"user_agent": headers.get("user-agent", "Unknown"),
"content_type": headers.get("content-type", "Unknown"),
"has_auth": bool(auth_header),
"auth_type": "Bearer" if is_bearer else "Other" if auth_header else "None"
"auth_type": "Bearer" if is_bearer else "Other" if auth_header else "None",
"headers_count": len(headers)
}
```
By default, `get_http_headers()` excludes problematic headers like `host` and `content-length`. To include all headers, use `get_http_headers(include_all=True)`.
## Important Notes
- HTTP requests are only available when FastMCP is running as part of a web application
- Accessing the HTTP request with `get_http_request()` outside of a web request context will raise a `RuntimeError`
- The `get_http_headers()` function **never raises errors** - it returns an empty dict when no request context is available
- The `get_http_request()` function returns a standard [Starlette Request](https://www.starlette.io/requests/) object

View file

@ -1,226 +0,0 @@
---
title: OpenAPI Integration
sidebarTitle: OpenAPI
description: Generate MCP servers from OpenAPI specs
icon: code-branch
---
import { VersionBadge } from '/snippets/version-badge.mdx'
<VersionBadge version="2.0.0" />
FastMCP can automatically generate an MCP server from an OpenAPI specification. Users only need to provide an OpenAPI specification (3.0 or 3.1) and an API client.
```python
import httpx
from fastmcp import FastMCP
# Create a client for your API
api_client = httpx.AsyncClient(base_url="https://api.example.com")
# Load your OpenAPI spec
spec = {...}
# Create an MCP server from your OpenAPI spec
mcp = FastMCP.from_openapi(openapi_spec=spec, client=api_client)
if __name__ == "__main__":
mcp.run()
```
## Configuration Options
### Timeout
You can set a timeout for all API requests:
```python
# Set a 5 second timeout for all requests
mcp = FastMCP.from_openapi(
openapi_spec=spec,
client=api_client,
timeout=5.0
)
```
This timeout is applied to all requests made by tools, resources, and resource templates.
## Route Mapping
By default, OpenAPI routes are mapped to MCP components based on these rules:
| OpenAPI Route | Example |MCP Component | Notes |
|- | - | - | - |
| `GET` without path params | `GET /stats` | Resource | Simple resources for fetching data |
| `GET` with path params | `GET /users/{id}` | Resource Template | Path parameters become template parameters |
| `POST`, `PUT`, `PATCH`, `DELETE`, etc. | `POST /users` | Tool | Operations that modify data |
Internally, FastMCP uses a priority-ordered set of `RouteMap` objects to determine the component type. Route maps indicate that a specific HTTP method (or methods) and path pattern should be treated as a specific component type. This is the default set of route maps:
```python
# Simplified version of the actual mapping rules
DEFAULT_ROUTE_MAPPINGS = [
# GET with path parameters -> ResourceTemplate
RouteMap(methods=["GET"], pattern=r".*\{.*\}.*",
route_type=RouteType.RESOURCE_TEMPLATE),
# GET without path parameters -> Resource
RouteMap(methods=["GET"], pattern=r".*",
route_type=RouteType.RESOURCE),
# All other methods -> Tool
RouteMap(methods=["POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
pattern=r".*", route_type=RouteType.TOOL),
]
```
### Custom Route Maps
Users can add custom route maps to override the default mapping behavior. User-supplied route maps are always applied first, before the default route maps.
```python
from fastmcp.server.openapi import RouteMap, RouteType
# Custom mapping rules
custom_maps = [
# Force all analytics endpoints to be Tools
RouteMap(methods=["GET"],
pattern=r"^/analytics/.*",
route_type=RouteType.TOOL)
]
# Apply custom mappings
mcp = await FastMCP.from_openapi(
openapi_spec=spec,
client=api_client,
route_maps=custom_maps
)
```
## How It Works
1. FastMCP parses your OpenAPI spec to extract routes and schemas
2. It applies mapping rules to categorize each route
3. When an MCP client calls a tool or accesses a resource:
- FastMCP constructs an HTTP request based on the OpenAPI definition
- It sends the request through the provided httpx client
- It translates the HTTP response to the appropriate MCP format
### Request Parameter Handling
FastMCP carefully handles different types of parameters in OpenAPI requests:
#### Query Parameters
By default, FastMCP will only include query parameters that have non-empty values. Parameters with `None` values or empty strings (`""`) are automatically filtered out of requests. This ensures that API servers don't receive unnecessary empty parameters that might cause issues.
For example, if you call a tool with these parameters:
```python
await client.call_tool("search_products", {
"category": "electronics", # Will be included
"min_price": 100, # Will be included
"max_price": None, # Will be excluded
"brand": "", # Will be excluded
})
```
The resulting HTTP request will only include `category=electronics&min_price=100`.
#### Path Parameters
For path parameters, which are typically required by REST APIs, FastMCP filters out `None` values and checks that all required path parameters are provided. If a required path parameter is missing or `None`, an error will be raised.
```python
# This will work
await client.call_tool("get_product", {"product_id": 123})
# This will raise ValueError: "Missing required path parameters: {'product_id'}"
await client.call_tool("get_product", {"product_id": None})
```
## Complete Example
```python [expandable]
import asyncio
import httpx
from fastmcp import FastMCP
# Sample OpenAPI spec for a Pet Store API
petstore_spec = {
"openapi": "3.0.0",
"info": {
"title": "Pet Store API",
"version": "1.0.0",
"description": "A sample API for managing pets",
},
"paths": {
"/pets": {
"get": {
"operationId": "listPets",
"summary": "List all pets",
"responses": {"200": {"description": "A list of pets"}},
},
"post": {
"operationId": "createPet",
"summary": "Create a new pet",
"responses": {"201": {"description": "Pet created successfully"}},
},
},
"/pets/{petId}": {
"get": {
"operationId": "getPet",
"summary": "Get a pet by ID",
"parameters": [
{
"name": "petId",
"in": "path",
"required": True,
"schema": {"type": "string"},
}
],
"responses": {
"200": {"description": "Pet details"},
"404": {"description": "Pet not found"},
},
}
},
},
}
async def check_mcp(mcp: FastMCP):
# List what components were created
tools = await mcp.get_tools()
resources = await mcp.get_resources()
templates = await mcp.get_resource_templates()
print(
f"{len(tools)} Tool(s): {', '.join([t.name for t in tools.values()])}"
) # Should include createPet
print(
f"{len(resources)} Resource(s): {', '.join([r.name for r in resources.values()])}"
) # Should include listPets
print(
f"{len(templates)} Resource Template(s): {', '.join([t.name for t in templates.values()])}"
) # Should include getPet
return mcp
if __name__ == "__main__":
# Client for the Pet Store API
client = httpx.AsyncClient(base_url="https://petstore.example.com/api")
# Create the MCP server
mcp = FastMCP.from_openapi(
openapi_spec=petstore_spec, client=client, name="PetStore"
)
asyncio.run(check_mcp(mcp))
# Start the MCP server
mcp.run()
```

View file

@ -35,6 +35,10 @@ The choice of importing or mounting depends on your use case and requirements.
FastMCP supports [MCP proxying](/patterns/proxy), which allows you to mirror a local or remote server in a local FastMCP instance. Proxies are fully compatible with both importing and mounting.
<VersionBadge version="2.4.0" />
You can also create proxies from configuration dictionaries that follow the MCPConfig schema, which is useful for quickly connecting to one or more remote servers. See the [Proxy Servers documentation](/servers/proxy#configuration-based-proxies) for details on configuration-based proxying. Note that MCPConfig follows an emerging standard and its format may evolve over time.
## Importing (Static Composition)
The `import_server()` method copies all components (tools, resources, templates, prompts) from one `FastMCP` instance (the *subserver*) into another (the *main server*). A `prefix` is added to avoid naming conflicts.
@ -65,7 +69,7 @@ async def setup():
# Result: main_mcp now contains prefixed components:
# - Tool: "weather_get_forecast"
# - Resource: "weather+data://cities/supported"
# - Resource: "data://weather/cities/supported"
if __name__ == "__main__":
asyncio.run(setup())
@ -78,11 +82,11 @@ When you call `await main_mcp.import_server(prefix, subserver)`:
1. **Tools**: All tools from `subserver` are added to `main_mcp` with names prefixed using `{prefix}_`.
- `subserver.tool(name="my_tool")` becomes `main_mcp.tool(name="{prefix}_my_tool")`.
2. **Resources**: All resources are added with URIs prefixed using `{prefix}+`.
- `subserver.resource(uri="data://info")` becomes `main_mcp.resource(uri="{prefix}+data://info")`.
2. **Resources**: All resources are added with URIs prefixed in the format `protocol://{prefix}/path`.
- `subserver.resource(uri="data://info")` becomes `main_mcp.resource(uri="data://{prefix}/info")`.
3. **Resource Templates**: Templates are prefixed similarly to resources.
- `subserver.resource(uri="data://{id}")` becomes `main_mcp.resource(uri="{prefix}+data://{id}")`.
4. **Prompts**: All prompts are added with names prefixed like tools.
- `subserver.resource(uri="data://{id}")` becomes `main_mcp.resource(uri="data://{prefix}/{id}")`.
4. **Prompts**: All prompts are added with names prefixed using `{prefix}_`.
- `subserver.prompt(name="my_prompt")` becomes `main_mcp.prompt(name="{prefix}_my_prompt")`.
Note that `import_server` performs a **one-time copy** of components. Changes made to the `subserver` *after* importing **will not** be reflected in `main_mcp`. The `subserver`'s `lifespan` context is also **not** executed by the main server.
@ -167,46 +171,69 @@ FastMCP automatically uses proxy mounting when the mounted server has a custom l
#### Interaction with Proxy Servers
When using `FastMCP.from_client()` to create a proxy server, mounting that server will always use proxy mounting:
When using `FastMCP.as_proxy()` to create a proxy server, mounting that server will always use proxy mounting:
```python
# Create a proxy for a remote server
remote_proxy = FastMCP.from_client(Client("http://example.com/mcp"))
remote_proxy = FastMCP.as_proxy(Client("http://example.com/mcp"))
# Mount the proxy (always uses proxy mounting)
main_server.mount("remote", remote_proxy)
```
## Customizing Separators
Both `import_server()` and `mount()` allow you to customize the separators used for prefixing components. The defaults are `_` for tools and prompts, and `+` for resources.
<CodeGroup>
## Resource Prefix Formats
```python import_server
await main_mcp.import_server(
prefix="api",
app=some_subserver,
tool_separator="_", # Tool name becomes: "api_sub_tool_name"
resource_separator="+", # Resource URI becomes: "api+data://sub_resource"
prompt_separator="_" # Prompt name becomes: "api_sub_prompt_name"
)
<VersionBadge version="2.4.0" />
When mounting or importing servers, resource URIs are usually prefixed to avoid naming conflicts. FastMCP supports two different formats for resource prefixes:
### Path Format (Default)
In path format, prefixes are added to the path component of the URI:
```
resource://prefix/path/to/resource
```
```python mount
main_mcp.mount(
prefix="api",
app=some_subserver,
tool_separator="_", # Tool name becomes: "api_sub_tool_name"
resource_separator="+", # Resource URI becomes: "api+data://sub_resource"
prompt_separator="_" # Prompt name becomes: "api_sub_prompt_name"
)
```
</CodeGroup>
<Warning>
Be cautious when choosing separators. Some MCP clients (like Claude Desktop) might have restrictions on characters allowed in tool names (e.g., `/` might not be supported). The defaults (`_` for names, `+` for URIs) are generally safe.
</Warning>
This is the default format since FastMCP 2.4. This format is recommended because it avoids issues with URI protocol restrictions (like underscores not being allowed in protocol names).
<Tip>
To "cleanly" import or mount a server, set the prefix and all separators to `""` (empty string). This is generally unecessary but could save a couple tokens at the risk of a name collision!
</Tip>
### Protocol Format (Legacy)
In protocol format, prefixes are added as part of the protocol:
```
prefix+resource://path/to/resource
```
This was the default format in FastMCP before 2.4. While still supported, it's not recommended for new code as it can cause problems with prefix names that aren't valid in URI protocols.
### Configuring the Prefix Format
You can configure the prefix format globally in code:
```python
import fastmcp
fastmcp.settings.settings.resource_prefix_format = "protocol"
```
Or via environment variable:
```bash
FASTMCP_RESOURCE_PREFIX_FORMAT=protocol
```
Or per-server:
```python
from fastmcp import FastMCP
# Create a server that uses legacy protocol format
server = FastMCP("LegacyServer", resource_prefix_format="protocol")
# Create a server that uses new path format
server = FastMCP("NewServer", resource_prefix_format="path")
```
When mounting or importing servers, the prefix format of the parent server is used.

View file

@ -228,8 +228,8 @@ async def analyze_sentiment(text: str, ctx: Context) -> dict:
# Create a sampling prompt asking for sentiment analysis
prompt = f"Analyze the sentiment of the following text as positive, negative, or neutral. Just output a single word - 'positive', 'negative', or 'neutral'. Text to analyze: {text}"
# Send the sampling request to the client's LLM
response = await ctx.sample(prompt)
# Send the sampling request to the client's LLM (provide a hint for the model you want to use)
response = await ctx.sample(prompt, model_preferences="claude-3-sonnet")
# Process the LLM's response
sentiment = response.text.strip().lower()
@ -247,11 +247,12 @@ async def analyze_sentiment(text: str, ctx: Context) -> dict:
**Method signature:**
- **`ctx.sample(messages: str | list[str | SamplingMessage], system_prompt: str | None = None, temperature: float | None = None, max_tokens: int | None = None) -> TextContent | ImageContent`**
- **`ctx.sample(messages: str | list[str | SamplingMessage], system_prompt: str | None = None, temperature: float | None = None, max_tokens: int | None = None, model_preferences: ModelPreferences | str | list[str] | None = None) -> TextContent | ImageContent`**
- `messages`: A string or list of strings/message objects to send to the LLM
- `system_prompt`: Optional system prompt to guide the LLM's behavior
- `temperature`: Optional sampling temperature (controls randomness)
- `max_tokens`: Optional maximum number of tokens to generate (defaults to 512)
- `model_preferences`: Optional model selection preferences (e.g., a model hint string, list of hints, or a ModelPreferences object)
- Returns the LLM's response as TextContent or ImageContent
When providing a simple string, it's treated as a user message. For more complex scenarios, you can provide a list of messages with different roles.

View file

@ -156,7 +156,7 @@ main.mount("sub", sub)
<VersionBadge version="2.0.0" />
FastMCP can act as a proxy for any MCP server (local or remote) using `FastMCP.from_client`, letting you bridge transports or add a frontend to existing servers. For example, you can expose a remote SSE server locally via stdio, or vice versa.
FastMCP can act as a proxy for any MCP server (local or remote) using `FastMCP.as_proxy`, letting you bridge transports or add a frontend to existing servers. For example, you can expose a remote SSE server locally via stdio, or vice versa.
See the [Proxying Servers](/patterns/proxy) guide for details and advanced usage.
@ -164,7 +164,7 @@ See the [Proxying Servers](/patterns/proxy) guide for details and advanced usage
from fastmcp import FastMCP, Client
backend = Client("http://example.com/mcp/sse")
proxy = FastMCP.from_client(backend, name="ProxyServer")
proxy = FastMCP.as_proxy(backend, name="ProxyServer")
# Now use the proxy like any FastMCP server
```

463
docs/servers/openapi.mdx Normal file
View file

@ -0,0 +1,463 @@
---
title: OpenAPI Integration
sidebarTitle: OpenAPI Integration
description: Generate MCP servers from OpenAPI specs and FastAPI apps
icon: code-branch
---
import { VersionBadge } from '/snippets/version-badge.mdx'
<VersionBadge version="2.0.0" />
FastMCP can automatically generate an MCP server from an OpenAPI specification or FastAPI app. Instead of manually creating tools and resources, you provide an OpenAPI spec and FastMCP intelligently converts your API endpoints into the appropriate MCP components.
## Quick Start
To convert an OpenAPI specification to an MCP server, you can use the `FastMCP.from_openapi` class method. This method takes an OpenAPI specification and an async HTTPX client that can be used to make requests to the API, and returns an MCP server.
Here's an example:
```python {11-15}
import httpx
from fastmcp import FastMCP
# Create an HTTP client for your API
client = httpx.AsyncClient(base_url="https://api.example.com")
# Load your OpenAPI spec
openapi_spec = httpx.get("https://api.example.com/openapi.json").json()
# Create the MCP server
mcp = FastMCP.from_openapi(
openapi_spec=openapi_spec,
client=client,
name="My API Server"
)
if __name__ == "__main__":
mcp.run()
```
That's it! Your entire API is now available as an MCP server. Clients can discover and interact with your API endpoints through the MCP protocol, with full schema validation and type safety.
## Route Mapping
FastMCP analyzes your API specification and automatically creates MCP components based on HTTP semantics and REST conventions. By default, the following rules are used to determine what MCP component to create for each route:
| OpenAPI Route | Example | MCP Component |
|---------------|---------|---------------|
| `GET` with path params | `GET /users/{id}` | **Resource Template** |
| `GET` without path params | `GET /stats` | **Resource** |
| `POST`, `PUT`, `PATCH`, `DELETE`, etc. | `POST /users` | **Tool** |
Interally, FastMCP uses an ordered list of `RouteMap` objects to determine how to map OpenAPI routes to various MCP component types.
Each `RouteMap` specifies a combination of methods, patterns, and tags, as well as a corresponding MCP component type. Each OpenAPI route is checked against each `RouteMap` in order, and the first one that matches every criteria is used to determine its converted MCP type. A special type, `EXCLUDE`, can be used to exclude routes from the MCP server entirely.
- **Methods**: HTTP methods to match (e.g. `["GET", "POST"]` or `"*"` for all)
- **Pattern**: Regex pattern to match the route path (e.g. `r"^/users/.*"` or `r".*"` for all)
- **Tags**: A set of OpenAPI tags that must all be present. An empty set (`{}`) means no tag filtering, so the route matches regardless of its tags.
- **MCP type**: What MCP component type to create (`TOOL`, `RESOURCE`, `RESOURCE_TEMPLATE`, or `EXCLUDE`)
To illustrate this in practice, here are FastMCP's default rules as a list of `RouteMap` objects:
```python
from fastmcp.server.openapi import RouteMap, MCPType
DEFAULT_ROUTE_MAPPINGS = [
# GET with path parameters → ResourceTemplate
RouteMap(
methods=["GET"],
pattern=r".*\{.*\}.*",
mcp_type=MCPType.RESOURCE_TEMPLATE
),
# GET without path parameters → Resource
RouteMap(
methods=["GET"],
pattern=r".*",
mcp_type=MCPType.RESOURCE
),
# All other methods → Tool
RouteMap(
methods=["*"],
pattern=r".*",
mcp_type=MCPType.TOOL
),
]
```
### Custom Route Maps
When creating your FastMCP server, you can customize routing behavior by providing your own list of `RouteMap` objects. Your custom maps are processed before the default route maps, and routes will be assigned to the first matching custom map.
For example, the following simple rule will treat every OpenAPI route as a tool:
```python {7}
from fastmcp import FastMCP
from fastmcp.server.openapi import RouteMap, MCPType
mcp = FastMCP.from_openapi(
...,
route_maps=[
RouteMap(mcp_type=MCPType.TOOL),
],
)
```
Here is a more complete example that uses custom route maps to convert all `GET` endpoints under `/analytics/` to tools while excluding all admin endpoints and all routes tagged "internal". All other routes will be handled by the default rules:
```python
from fastmcp import FastMCP
from fastmcp.server.openapi import RouteMap, MCPType
mcp = FastMCP.from_openapi(
...,
route_maps=[
# Analytics `GET` endpoints are tools
RouteMap(
methods=["GET"],
pattern=r"^/analytics/.*",
mcp_type=MCPType.TOOL,
),
# Exclude all admin endpoints
RouteMap(
pattern=r"^/admin/.*",
mcp_type=MCPType.EXCLUDE,
),
# Exclude all routes tagged "internal"
RouteMap(
tags={"internal"},
mcp_type=MCPType.EXCLUDE,
),
],
)
```
<Tip>
The default route maps are always applied after your custom maps, so you do not have to create route maps for every possible route.
</Tip>
### Excluding Routes
To exclude routes from the MCP server, use a route map to assign them to `MCPType.EXCLUDE`.
You can use this to remove sensitive or internal routes by targeting them specifically:
```python {7,8}
from fastmcp import FastMCP
from fastmcp.server.openapi import RouteMap, MCPType
mcp = FastMCP.from_openapi(
...,
route_maps=[
RouteMap(pattern=r"^/admin/.*", mcp_type=MCPType.EXCLUDE),
RouteMap(tags={"internal"}, mcp_type=MCPType.EXCLUDE),
],
)
```
Or you can use a catch-all rule to exclude everything that your maps don't handle explicitly:
```python {10}
from fastmcp import FastMCP
from fastmcp.server.openapi import RouteMap, MCPType
mcp = FastMCP.from_openapi(
...,
route_maps=[
# custom mapping logic goes here
...,
# exclude all remaining routes
RouteMap(mcp_type=MCPType.EXCLUDE),
],
)
```
<Tip>
Using a catch-all exclusion rule will prevent the default route mappings from being applied, since it will match every remaining route. This is useful if you want to explicitly allow-list certain routes.
</Tip>
### Advanced Route Mapping
<VersionBadge version="2.5.0" />
For advanced use cases that require more complex logic, you can provide a `route_map_fn` callable. After the route map logic is applied, this function is called on each matched route and its assigned MCP component type. It can optionally return a different component type to override the mapped assignment. If it returns `None`, the assigned type is used.
In addition to more precise targeting of methods, patterns, and tags, this function can access any additional OpenAPI metadata about the route.
<Tip>
The `route_map_fn` **is** called on routes that matched `MCPType.EXCLUDE` in your custom maps, giving you an opportunity to override the exclusion.
</Tip>
```python
from fastmcp import FastMCP
from fastmcp.server.openapi import RouteMap, MCPType, HTTPRoute
def custom_route_mapper(route: HTTPRoute, mcp_type: MCPType) -> MCPType | None:
"""Advanced route type mapping."""
# Convert all admin routes to tools regardless of HTTP method
if "/admin/" in route.path:
return MCPType.TOOL
elif "internal" in route.tags:
return MCPType.EXCLUDE
# Convert user detail routes to templates even if they're POST
elif route.path.startswith("/users/") and route.method == "POST":
return MCPType.RESOURCE_TEMPLATE
# Use defaults for all other routes
return None
mcp = FastMCP.from_openapi(
...,
route_map_fn=custom_route_mapper,
)
```
## Customizing MCP Components
### Component Names
<VersionBadge version="2.5.0" />
FastMCP automatically generates names for MCP components based on the OpenAPI specification. By default, it uses the `operationId` from your OpenAPI spec, up to the first double underscore (`__`).
All component names are automatically:
- **Slugified**: Spaces and special characters are converted to underscores or removed
- **Truncated**: Limited to 56 characters maximum to ensure compatibility
- **Unique**: If multiple components have the same name, a number is automatically appended to make them unique
For more control over component names, you can provide an `mcp_names` dictionary that maps `operationId` values to your desired names. The `operationId` must be exactly as it appears in the OpenAPI spec. The provided name will always be slugified and truncated.
```python {5-9}
from fastmcp import FastMCP
mcp = FastMCP.from_openapi(
...
mcp_names={
"list_users__with_pagination": "user_list",
"create_user__admin_required": "create_user",
"get_user_details__admin_required": "user_detail",
}
)
```
Any `operationId` not found in `mcp_names` will use the default strategy (operationId up to the first `__`).
### Advanced Customization
<VersionBadge version="2.5.0" />
By default, FastMCP creates MCP components using a variety of metadata from the OpenAPI spec, such as incorporating the OpenAPI description into the MCP component description.
At times you may want to modify those MCP components in a variety of ways, such as adding LLM-specific instructions or tags. For fine-grained customization, you can provide a `mcp_component_fn` when creating the MCP server. After each MCP component has been created, this function is called on it and has the opportunity to modify it in-place.
<Tip>
Your `mcp_component_fn` is expected to modify the component in-place, not to return a new component. The result of the function is ignored.
</Tip>
```python {27}
from fastmcp import FastMCP
from fastmcp.server.openapi import (
HTTPRoute,
OpenAPITool,
OpenAPIResource,
OpenAPIResourceTemplate,
)
def customize_components(
route: HTTPRoute,
component: OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate,
) -> None:
# Add custom tags to all components
component.tags.add("openapi")
# Customize based on component type
if isinstance(component, OpenAPITool):
component.description = f"🔧 {component.description} (via API)"
if isinstance(component, OpenAPIResource):
component.description = f"📊 {component.description}"
component.tags.add("data")
mcp = FastMCP.from_openapi(
...,
mcp_component_fn=customize_components,
)
```
## Request Parameter Handling
FastMCP intelligently handles different types of parameters in OpenAPI requests:
### Query Parameters
By default, FastMCP only includes query parameters that have non-empty values. Parameters with `None` values or empty strings are automatically filtered out.
```python
# When calling this tool...
await client.call_tool("search_products", {
"category": "electronics", # ✅ Included
"min_price": 100, # ✅ Included
"max_price": None, # ❌ Excluded
"brand": "", # ❌ Excluded
})
# The HTTP request will be: GET /products?category=electronics&min_price=100
```
### Path Parameters
Path parameters are typically required by REST APIs. FastMCP:
- Filters out `None` values
- Validates that all required path parameters are provided
- Raises clear errors for missing required parameters
```python
# ✅ This works
await client.call_tool("get_user", {"user_id": 123})
# ❌ This raises: "Missing required path parameters: {'user_id'}"
await client.call_tool("get_user", {"user_id": None})
```
### Array Parameters
FastMCP handles array parameters according to OpenAPI specifications:
- **Query arrays**: Serialized based on the `explode` parameter (default: `True`)
- **Path arrays**: Serialized as comma-separated values (OpenAPI 'simple' style)
```python
# Query array with explode=true (default)
# ?tags=red&tags=blue&tags=green
# Query array with explode=false
# ?tags=red,blue,green
# Path array (always comma-separated)
# /items/red,blue,green
```
### Headers
Header parameters are automatically converted to strings and included in the HTTP request.
## Auth
If your API requires authentication, configure it on the HTTP client before creating the MCP server:
```python
import httpx
from fastmcp import FastMCP
# Bearer token authentication
api_client = httpx.AsyncClient(
base_url="https://api.example.com",
headers={"Authorization": "Bearer YOUR_TOKEN"}
)
# Create MCP server with authenticated client
mcp = FastMCP.from_openapi(..., client=api_client)
```
## Timeouts
Set a timeout for all API requests:
```python
mcp = FastMCP.from_openapi(
openapi_spec=spec,
client=api_client,
timeout=30.0 # 30 second timeout for all requests
)
```
## FastAPI Integration
<VersionBadge version="2.0.0" />
FastMCP can directly convert FastAPI applications into MCP servers by extracting their OpenAPI specifications:
<Tip>
FastMCP does *not* include FastAPI as a dependency; you must install it separately to use this integration.
</Tip>
```python
from fastapi import FastAPI
from fastmcp import FastMCP
# Your FastAPI app
app = FastAPI(title="My API", version="1.0.0")
@app.get("/items", tags=["items"], operation_id="list_items")
def list_items():
return [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}]
@app.get("/items/{item_id}", tags=["items", "detail"], operation_id="get_item")
def get_item(item_id: int):
return {"id": item_id, "name": f"Item {item_id}"}
@app.post("/items", tags=["items", "create"], operation_id="create_item")
def create_item(name: str):
return {"id": 3, "name": name}
# Convert FastAPI app to MCP server
mcp = FastMCP.from_fastapi(app=app)
if __name__ == "__main__":
mcp.run() # Run as MCP server
```
Note that operation ids are optional, but are used to create component names. You can also provide custom names, just like with OpenAPI specs.
<Warning>
FastMCP servers are not FastAPI apps, even when created from one. To learn how to deploy them as an ASGI app, see the [ASGI Integration](/deployment/asgi) documentation.
</Warning>
### FastAPI Configuration
All OpenAPI integration features work with FastAPI apps:
```python
from fastmcp.server.openapi import RouteMap, MCPType
# Custom route mapping with FastAPI
mcp = FastMCP.from_fastapi(
app=app,
name="My Custom Server",
timeout=5.0,
mcp_names={"operationId": "friendly_name"}, # Custom component names
route_maps=[
# Admin endpoints become tools
RouteMap(methods="*", pattern=r"^/admin/.*", mcp_type=MCPType.TOOL),
# Internal endpoints are excluded
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.EXCLUDE, tags={"internal"}),
],
route_map_fn=my_route_mapper,
mcp_component_fn=my_component_customizer,
mcp_names={
"get_user_details_users__user_id__get": "get_user_details",
}
)
```
### FastAPI Benefits
- **Zero code duplication**: Reuse existing FastAPI endpoints
- **Schema inheritance**: Pydantic models and validation are preserved
- **ASGI transport**: Direct in-memory communication (no HTTP overhead)
- **Full FastAPI features**: Dependencies, middleware, authentication all work

View file

@ -8,7 +8,9 @@ import { VersionBadge } from '/snippets/version-badge.mdx'
<VersionBadge version="2.0.0" />
FastMCP provides a powerful proxying capability that allows one FastMCP server instance to act as a frontend for another MCP server (which could be remote, running on a different transport, or even another FastMCP instance). This is achieved using the `FastMCP.from_client()` class method.
FastMCP provides a powerful proxying capability that allows one FastMCP server instance to act as a frontend for another MCP server (which could be remote, running on a different transport, or even another FastMCP instance). This is achieved using the `FastMCP.as_proxy()` class method.
`as_proxy()` accepts either an existing `Client` or any argument that can be passed to a `Client` as its `transport` parameter&mdash;such as another `FastMCP` instance, a URL to a remote server, or an MCP configuration dictionary.
## What is Proxying?
@ -37,26 +39,23 @@ sequenceDiagram
## Creating a Proxy
The easiest way to create a proxy is using the `FastMCP.from_client()` class method. This creates a standard FastMCP server that forwards requests to another MCP server.
The easiest way to create a proxy is using the `FastMCP.as_proxy()` class method. This creates a standard FastMCP server that forwards requests to another MCP server.
```python
from fastmcp import FastMCP, Client
from fastmcp import FastMCP
# Create a client configured to talk to the backend server
# This could be any MCP server - remote, local, or using any transport
backend_client = Client("backend_server.py") # Could be "http://remote.server/sse", etc.
# Create the proxy server with from_client()
proxy_server = FastMCP.from_client(
backend_client,
# Provide the backend in any form accepted by Client
proxy_server = FastMCP.as_proxy(
"backend_server.py", # Could also be a FastMCP instance, config dict, or a remote URL
name="MyProxyServer" # Optional settings for the proxy
)
# That's it! You now have a proxy FastMCP server that can be used
# with any transport (SSE, stdio, etc.) just like any other FastMCP server
# Or create the Client yourself for custom configuration
backend_client = Client("backend_server.py")
proxy_from_client = FastMCP.as_proxy(backend_client)
```
**How `from_client` Works:**
**How `as_proxy` Works:**
1. It connects to the backend server using the provided client.
2. It discovers all the tools, resources, resource templates, and prompts available on the backend server.
@ -72,13 +71,10 @@ Currently, proxying focuses primarily on exposing the major MCP objects (tools,
A common use case is to bridge transports. For example, making a remote SSE server available locally via Stdio:
```python
from fastmcp import FastMCP, Client
from fastmcp import FastMCP
# Client targeting a remote SSE server
client = Client("http://example.com/mcp/sse")
# Create a proxy server - it's just a regular FastMCP server
proxy = FastMCP.from_client(client, name="SSE to Stdio Proxy")
# Target a remote SSE server directly by URL
proxy = FastMCP.as_proxy("http://example.com/mcp/sse", name="SSE to Stdio Proxy")
# The proxy can now be used with any transport
# No special handling needed - it works like any FastMCP server
@ -89,7 +85,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, Client
from fastmcp import FastMCP
# Original server
original_server = FastMCP(name="Original")
@ -98,12 +94,9 @@ original_server = FastMCP(name="Original")
def tool_a() -> str:
return "A"
# 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(
client_to_original,
# Create a proxy of the original server directly
proxy = FastMCP.as_proxy(
original_server,
name="Proxy Server"
)
@ -111,8 +104,66 @@ proxy = FastMCP.from_client(
# requests to original_server
```
### Configuration-Based Proxies
<VersionBadge version="2.4.0" />
You can create a proxy directly from a configuration dictionary that follows the MCPConfig schema. This is useful for quickly setting up proxies to remote servers without manually configuring each connection detail.
```python
from fastmcp import FastMCP
# Create a proxy directly from a config dictionary
config = {
"mcpServers": {
"default": { # For single server configs, 'default' is commonly used
"url": "https://example.com/mcp",
"transport": "streamable-http"
}
}
}
# Create a proxy to the configured server
proxy = FastMCP.as_proxy(config, name="Config-Based Proxy")
# Run the proxy with stdio transport for local access
if __name__ == "__main__":
proxy.run()
```
<Note>
The MCPConfig format follows an emerging standard for MCP server configuration and may evolve as the specification matures. While FastMCP aims to maintain compatibility with future versions, be aware that field names or structure might change.
</Note>
You can also use MCPConfig to create a proxy to multiple servers. When multiple servers are specified, they are automatically mounted with their config names as prefixes, providing a unified interface to all servers:
```python
from fastmcp import FastMCP
# Multi-server configuration
config = {
"mcpServers": {
"weather": {
"url": "https://weather-api.example.com/mcp",
"transport": "streamable-http"
},
"calendar": {
"url": "https://calendar-api.example.com/mcp",
"transport": "streamable-http"
}
}
}
# Create a proxy to multiple servers
composite_proxy = FastMCP.as_proxy(config, name="Composite Proxy")
# Tools and resources are accessible with prefixes:
# - weather_get_forecast, calendar_add_event
# - weather://weather/icons/sunny, calendar://calendar/events/today
```
## `FastMCPProxy` Class
Internally, `FastMCP.from_client()` uses the `FastMCPProxy` class. You generally don't need to interact with this class directly, but it's available if needed.
Internally, `FastMCP.as_proxy()` uses the `FastMCPProxy` class. You generally don't need to interact with this class directly, but it's available if needed.
Using the class directly might be necessary for advanced scenarios, like subclassing `FastMCPProxy` to add custom logic before or after forwarding requests.

View file

@ -408,12 +408,20 @@ Templates provide a powerful way to expose parameterized data access points foll
## Error Handling
<VersionBadge version="2.3.4" />
<VersionBadge version="2.4.1" />
If your resource function encounters an error, you can raise a standard Python exception (`ValueError`, `TypeError`, `FileNotFoundError`, custom exceptions, etc.) or a FastMCP `ResourceError`.
For security reasons, most exceptions are wrapped in a generic `ResourceError` before being sent to the client, with internal error details masked. However, if you raise a `ResourceError` directly, its contents **are** included in the response. This allows you to provide informative error messages to the client on an opt-in basis.
By default, all exceptions (including their details) are logged and converted into an MCP error response to be sent back to the client LLM. This helps the LLM understand failures and react appropriately.
If you want to mask internal error details for security reasons, you can:
1. Use the `mask_error_details=True` parameter when creating your `FastMCP` instance:
```python
mcp = FastMCP(name="SecureServer", mask_error_details=True)
```
2. Or use `ResourceError` to explicitly control what error information is sent to clients:
```python
from fastmcp import FastMCP
from fastmcp.exceptions import ResourceError
@ -423,13 +431,14 @@ mcp = FastMCP(name="DataServer")
@mcp.resource("resource://safe-error")
def fail_with_details() -> str:
"""This resource provides detailed error information."""
# ResourceError contents are sent back to clients
# ResourceError contents are always sent back to clients,
# regardless of mask_error_details setting
raise ResourceError("Unable to retrieve data: file not found")
@mcp.resource("resource://masked-error")
def fail_with_masked_details() -> str:
"""This resource masks internal error details."""
# Other exceptions are converted to ResourceError with generic message
"""This resource masks internal error details when mask_error_details=True."""
# This message would be masked if mask_error_details=True
raise ValueError("Sensitive internal file path: /etc/secrets.conf")
@mcp.resource("data://{id}")
@ -442,7 +451,7 @@ def get_data_by_id(id: str) -> dict:
return {"id": id, "value": "data"}
```
This error handling pattern applies to both regular resources and resource templates.
When `mask_error_details=True`, only error messages from `ResourceError` will include details, other exceptions will be converted to a generic message.
## Server Behavior

View file

@ -248,13 +248,21 @@ def do_nothing() -> None:
### Error Handling
<VersionBadge version="2.3.4" />
<VersionBadge version="2.4.1" />
If your tool encounters an error, you can raise a standard Python exception (`ValueError`, `TypeError`, `FileNotFoundError`, custom exceptions, etc.) or a FastMCP `ToolError`.
In all cases, the exception is logged and converted into an MCP error response to be sent back to the client LLM. For security reasons, the error message is **not** included in the response by default. However, if you raise a `ToolError`, the contents of the exception **are** included in the response. This allows you to provide informative error messages to the client LLM on an opt-in basis, which can help the LLM understand failures and react appropriately.
By default, all exceptions (including their details) are logged and converted into an MCP error response to be sent back to the client LLM. This helps the LLM understand failures and react appropriately.
```python {2, 10, 14}
If you want to mask internal error details for security reasons, you can:
1. Use the `mask_error_details=True` parameter when creating your `FastMCP` instance:
```python
mcp = FastMCP(name="SecureServer", mask_error_details=True)
```
2. Or use `ToolError` to explicitly control what error information is sent to clients:
```python
from fastmcp import FastMCP
from fastmcp.exceptions import ToolError
@ -262,16 +270,20 @@ from fastmcp.exceptions import ToolError
def divide(a: float, b: float) -> float:
"""Divide a by b."""
# Python exceptions raise errors but the contents are not sent to clients
if b == 0:
# Error messages from ToolError are always sent to clients,
# regardless of mask_error_details setting
raise ToolError("Division by zero is not allowed.")
# If mask_error_details=True, this message would be masked
if not isinstance(a, (int, float)) or not isinstance(b, (int, float)):
raise TypeError("Both arguments must be numbers.")
if b == 0:
# ToolError contents are sent back to clients
raise ToolError("Division by zero is not allowed.")
return a / b
```
When `mask_error_details=True`, only error messages from `ToolError` will include details, other exceptions will be converted to a generic message.
### Annotations
<VersionBadge version="2.2.7" />

View file

@ -3,9 +3,8 @@ 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.
2. Create a proxy FastMCP server using ``FastMCP.as_proxy(original_server)``.
3. Use another Client to connect to the proxy server (in-memory) and interact with the original server's tools through the proxy.
"""
import asyncio
@ -36,24 +35,18 @@ async def main():
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)...")
# 2. Proxy Server Creation
print("\nStep 2: Creating the Proxy Server (InMemoryProxy)...")
print(
f" (Using FastMCP.from_client, passing it the client from Step 2 that targets '{original_server.name}')"
f" (Using FastMCP.as_proxy to wrap '{original_server.name}' directly)"
)
proxy_server = FastMCP.from_client(client_to_original, name="InMemoryProxy")
proxy_server = FastMCP.as_proxy(original_server, 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...")
# 3. Interacting via Proxy
print("\nStep 3: 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}'.")

141
examples/tags_example.py Normal file
View file

@ -0,0 +1,141 @@
"""
Example demonstrating RouteMap tags functionality.
This example shows how to use the tags parameter in RouteMap
to selectively route OpenAPI endpoints based on their tags.
"""
import asyncio
from fastapi import FastAPI
from fastmcp import FastMCP
from fastmcp.server.openapi import MCPType, RouteMap
# Create a FastAPI app with tagged endpoints
app = FastAPI(title="Tagged API Example")
@app.get("/users", tags=["users", "public"])
async def get_users():
"""Get all users - public endpoint"""
return [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
@app.post("/users", tags=["users", "admin"])
async def create_user(name: str):
"""Create a user - admin only"""
return {"id": 3, "name": name}
@app.get("/admin/stats", tags=["admin", "internal"])
async def get_admin_stats():
"""Get admin statistics - internal use"""
return {"total_users": 100, "active_sessions": 25}
@app.get("/health", tags=["public"])
async def health_check():
"""Public health check"""
return {"status": "healthy"}
@app.get("/metrics")
async def get_metrics():
"""Metrics endpoint with no tags"""
return {"requests": 1000, "errors": 5}
async def main():
"""Demonstrate different tag-based routing strategies."""
print("=== Example 1: Make admin-tagged routes tools ===")
# Strategy 1: Convert admin-tagged routes to tools
mcp1 = FastMCP.from_fastapi(
app=app,
route_maps=[
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags={"admin"}),
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
],
)
tools = await mcp1.get_tools()
resources = await mcp1.get_resources()
print(f"Tools ({len(tools)}): {', '.join(tools.keys())}")
print(f"Resources ({len(resources)}): {', '.join(resources.keys())}")
print("\n=== Example 2: Exclude internal routes ===")
# Strategy 2: Exclude internal routes entirely
mcp2 = FastMCP.from_fastapi(
app=app,
route_maps=[
RouteMap(
methods="*", pattern=r".*", mcp_type=MCPType.EXCLUDE, tags={"internal"}
),
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL),
],
)
tools = await mcp2.get_tools()
resources = await mcp2.get_resources()
print(f"Tools ({len(tools)}): {', '.join(tools.keys())}")
print(f"Resources ({len(resources)}): {', '.join(resources.keys())}")
print("\n=== Example 3: Pattern + Tags combination ===")
# Strategy 3: Routes matching both pattern AND tags
mcp3 = FastMCP.from_fastapi(
app=app,
route_maps=[
# Admin routes under /admin path -> tools
RouteMap(
methods="*",
pattern=r".*/admin/.*",
mcp_type=MCPType.TOOL,
tags={"admin"},
),
# Public routes -> tools
RouteMap(
methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags={"public"}
),
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
],
)
tools = await mcp3.get_tools()
resources = await mcp3.get_resources()
print(f"Tools ({len(tools)}): {', '.join(tools.keys())}")
print(f"Resources ({len(resources)}): {', '.join(resources.keys())}")
print("\n=== Example 4: Multiple tag AND condition ===")
# Strategy 4: Routes must have ALL specified tags
mcp4 = FastMCP.from_fastapi(
app=app,
route_maps=[
# Routes with BOTH "users" AND "admin" tags -> tools
RouteMap(
methods="*",
pattern=r".*",
mcp_type=MCPType.TOOL,
tags={"users", "admin"},
),
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
],
)
tools = await mcp4.get_tools()
resources = await mcp4.get_resources()
print(f"Tools ({len(tools)}): {', '.join(tools.keys())}")
print(f"Resources ({len(resources)}): {', '.join(resources.keys())}")
if __name__ == "__main__":
asyncio.run(main())

View file

@ -49,6 +49,7 @@ dev = [
"pytest>=8.3.3",
"pytest-asyncio>=0.23.5",
"pytest-cov>=6.1.1",
"pytest-env>=1.1.5",
"pytest-flakefinder",
"pytest-report>=0.2.1",
"pytest-timeout>=2.4.0",
@ -85,6 +86,11 @@ asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"
filterwarnings = []
timeout = 3
env = [
"FASTMCP_TEST_MODE=1",
'D:FASTMCP_LOG_LEVEL=DEBUG',
'D:FASTMCP_ENABLE_RICH_TRACEBACKS=0',
]
[tool.pyright]
include = ["src", "tests"]

View file

@ -17,6 +17,7 @@ from typer import Context, Exit
import fastmcp
from fastmcp.cli import claude
from fastmcp.cli import run as run_module
from fastmcp.utilities.logging import get_logger
logger = get_logger("cli")
@ -49,16 +50,14 @@ def _get_npx_command():
def _parse_env_var(env_var: str) -> tuple[str, str]:
"""Parse environment variable string in format KEY=VALUE."""
if "=" not in env_var:
logger.error(
f"Invalid environment variable format: {env_var}. Must be KEY=VALUE"
)
logger.error("Invalid environment variable format. Must be KEY=VALUE")
sys.exit(1)
key, value = env_var.split("=", 1)
return key.strip(), value.strip()
def _build_uv_command(
file_spec: str,
server_spec: str,
with_editable: Path | None = None,
with_packages: list[str] | None = None,
) -> list[str]:
@ -76,106 +75,10 @@ def _build_uv_command(
cmd.extend(["--with", pkg])
# Add mcp run command
cmd.extend(["fastmcp", "run", file_spec])
cmd.extend(["fastmcp", "run", server_spec])
return cmd
def _parse_file_path(file_spec: str) -> tuple[Path, str | None]:
"""Parse a file path that may include a server object specification.
Args:
file_spec: Path to file, optionally with :object suffix
Returns:
Tuple of (file_path, server_object)
"""
# First check if we have a Windows path (e.g., C:\...)
has_windows_drive = len(file_spec) > 1 and file_spec[1] == ":"
# Split on the last colon, but only if it's not part of the Windows drive letter
# and there's actually another colon in the string after the drive letter
if ":" in (file_spec[2:] if has_windows_drive else file_spec):
file_str, server_object = file_spec.rsplit(":", 1)
else:
file_str, server_object = file_spec, None
# Resolve the file path
file_path = Path(file_str).expanduser().resolve()
if not file_path.exists():
logger.error(f"File not found: {file_path}")
sys.exit(1)
if not file_path.is_file():
logger.error(f"Not a file: {file_path}")
sys.exit(1)
return file_path, server_object
def _import_server(file: Path, server_object: str | None = None):
"""Import a MCP server from a file.
Args:
file: Path to the file
server_object: Optional object name in format "module:object" or just "object"
Returns:
The server object
"""
# Add parent directory to Python path so imports can be resolved
file_dir = str(file.parent)
if file_dir not in sys.path:
sys.path.insert(0, file_dir)
# Import the module
spec = importlib.util.spec_from_file_location("server_module", file)
if not spec or not spec.loader:
logger.error("Could not load module", extra={"file": str(file)})
sys.exit(1)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# If no object specified, try common server names
if not server_object:
# Look for the most common server object names
for name in ["mcp", "server", "app"]:
if hasattr(module, name):
return getattr(module, name)
logger.error(
f"No server object found in {file}. Please either:\n"
"1. Use a standard variable name (mcp, server, or app)\n"
"2. Specify the object name with file:object syntax",
extra={"file": str(file)},
)
sys.exit(1)
# Handle module:object syntax
if ":" in server_object:
module_name, object_name = server_object.split(":", 1)
try:
server_module = importlib.import_module(module_name)
server = getattr(server_module, object_name, None)
except ImportError:
logger.error(
f"Could not import module '{module_name}'",
extra={"file": str(file)},
)
sys.exit(1)
else:
# Just object name
server = getattr(module, server_object, None)
if server is None:
logger.error(
f"Server object '{server_object}' not found",
extra={"file": str(file)},
)
sys.exit(1)
return server
@app.command()
def version(ctx: Context):
if ctx.resilient_parsing:
@ -201,7 +104,7 @@ def version(ctx: Context):
@app.command()
def dev(
file_spec: str = typer.Argument(
server_spec: str = typer.Argument(
...,
help="Python file to run, optionally with :object suffix",
),
@ -246,7 +149,7 @@ def dev(
] = None,
) -> None:
"""Run a MCP server with the MCP Inspector."""
file, server_object = _parse_file_path(file_spec)
file, server_object = run_module.parse_file_path(server_spec)
logger.debug(
"Starting dev server",
@ -262,7 +165,7 @@ def dev(
try:
# Import server to get dependencies
server = _import_server(file, server_object)
server = run_module.import_server(file, server_object)
if hasattr(server, "dependencies") and server.dependencies is not None:
with_packages = list(set(with_packages + server.dependencies))
@ -285,7 +188,7 @@ def dev(
if inspector_version:
inspector_cmd += f"@{inspector_version}"
uv_cmd = _build_uv_command(file_spec, with_editable, with_packages)
uv_cmd = _build_uv_command(server_spec, with_editable, with_packages)
# Run the MCP Inspector command with shell=True on Windows
shell = sys.platform == "win32"
@ -318,9 +221,9 @@ def dev(
@app.command()
def run(
file_spec: str = typer.Argument(
server_spec: str = typer.Argument(
...,
help="Python file to run, optionally with :object suffix",
help="Python file, object specification (file:obj), or URL",
),
transport: Annotated[
str | None,
@ -354,22 +257,20 @@ def run(
),
] = None,
) -> None:
"""Run a MCP server.
"""Run a MCP server or connect to a remote one.
The server can be specified in two ways:
1. Module approach: server.py - runs the module directly, expecting a server.run() call.\n
2. Import approach: server.py:app - imports and runs the specified server object.\n\n
The server can be specified in three ways:
1. Module approach: server.py - runs the module directly, looking for an object named mcp/server/app.\n
2. Import approach: server.py:app - imports and runs the specified server object.\n
3. URL approach: http://server-url - connects to a remote server and creates a proxy.\n\n
Note: This command runs the server directly. You are responsible for ensuring
all dependencies are available.
"""
file, server_object = _parse_file_path(file_spec)
logger.debug(
"Running server",
"Running server or client",
extra={
"file": str(file),
"server_object": server_object,
"server_spec": server_spec,
"transport": transport,
"host": host,
"port": port,
@ -378,29 +279,18 @@ def run(
)
try:
# Import and get server object
server = _import_server(file, server_object)
logger.info(f'Found server "{server.name}" in {file}')
# Run the server
kwargs = {}
if transport:
kwargs["transport"] = transport
if host:
kwargs["host"] = host
if port:
kwargs["port"] = port
if log_level:
kwargs["log_level"] = log_level
server.run(**kwargs)
run_module.run_command(
server_spec=server_spec,
transport=transport,
host=host,
port=port,
log_level=log_level,
)
except Exception as e:
logger.error(
f"Failed to run server: {e}",
f"Failed to run: {e}",
extra={
"file": str(file),
"server_spec": server_spec,
"error": str(e),
},
)
@ -409,7 +299,7 @@ def run(
@app.command()
def install(
file_spec: str = typer.Argument(
server_spec: str = typer.Argument(
...,
help="Python file to run, optionally with :object suffix",
),
@ -466,7 +356,7 @@ def install(
Environment variables are preserved once added and only updated if new values
are explicitly provided.
"""
file, server_object = _parse_file_path(file_spec)
file, server_object = run_module.parse_file_path(server_spec)
logger.debug(
"Installing server",
@ -489,7 +379,7 @@ def install(
server = None
if not name:
try:
server = _import_server(file, server_object)
server = run_module.import_server(file, server_object)
name = server.name
except (ImportError, ModuleNotFoundError) as e:
logger.debug(
@ -526,7 +416,7 @@ def install(
env_dict[key] = value
if claude.update_claude_config(
file_spec,
server_spec,
name,
with_editable=with_editable,
with_packages=with_packages,

179
src/fastmcp/cli/run.py Normal file
View file

@ -0,0 +1,179 @@
"""FastMCP run command implementation."""
import importlib.util
import re
import sys
from pathlib import Path
from typing import Any, Literal
from fastmcp.utilities.logging import get_logger
logger = get_logger("cli.run")
TransportType = Literal["stdio", "streamable-http", "sse"]
def is_url(path: str) -> bool:
"""Check if a string is a URL."""
url_pattern = re.compile(r"^https?://")
return bool(url_pattern.match(path))
def parse_file_path(server_spec: str) -> tuple[Path, str | None]:
"""Parse a file path that may include a server object specification.
Args:
server_spec: Path to file, optionally with :object suffix
Returns:
Tuple of (file_path, server_object)
"""
# First check if we have a Windows path (e.g., C:\...)
has_windows_drive = len(server_spec) > 1 and server_spec[1] == ":"
# Split on the last colon, but only if it's not part of the Windows drive letter
# and there's actually another colon in the string after the drive letter
if ":" in (server_spec[2:] if has_windows_drive else server_spec):
file_str, server_object = server_spec.rsplit(":", 1)
else:
file_str, server_object = server_spec, None
# Resolve the file path
file_path = Path(file_str).expanduser().resolve()
if not file_path.exists():
logger.error(f"File not found: {file_path}")
sys.exit(1)
if not file_path.is_file():
logger.error(f"Not a file: {file_path}")
sys.exit(1)
return file_path, server_object
def import_server(file: Path, server_object: str | None = None) -> Any:
"""Import a MCP server from a file.
Args:
file: Path to the file
server_object: Optional object name in format "module:object" or just "object"
Returns:
The server object
"""
# Add parent directory to Python path so imports can be resolved
file_dir = str(file.parent)
if file_dir not in sys.path:
sys.path.insert(0, file_dir)
# Import the module
spec = importlib.util.spec_from_file_location("server_module", file)
if not spec or not spec.loader:
logger.error("Could not load module", extra={"file": str(file)})
sys.exit(1)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# If no object specified, try common server names
if not server_object:
# Look for the most common server object names
for name in ["mcp", "server", "app"]:
if hasattr(module, name):
return getattr(module, name)
logger.error(
f"No server object found in {file}. Please either:\n"
"1. Use a standard variable name (mcp, server, or app)\n"
"2. Specify the object name with file:object syntax",
extra={"file": str(file)},
)
sys.exit(1)
# Handle module:object syntax
if ":" in server_object:
module_name, object_name = server_object.split(":", 1)
try:
server_module = importlib.import_module(module_name)
server = getattr(server_module, object_name, None)
except ImportError:
logger.error(
f"Could not import module '{module_name}'",
extra={"file": str(file)},
)
sys.exit(1)
else:
# Just object name
server = getattr(module, server_object, None)
if server is None:
logger.error(
f"Server object '{server_object}' not found",
extra={"file": str(file)},
)
sys.exit(1)
return server
def create_client_server(url: str) -> Any:
"""Create a FastMCP server from a client URL.
Args:
url: The URL to connect to
Returns:
A FastMCP server instance
"""
try:
import fastmcp
client = fastmcp.Client(url)
server = fastmcp.FastMCP.from_client(client)
return server
except Exception as e:
logger.error(f"Failed to create client for URL {url}: {e}")
sys.exit(1)
def run_command(
server_spec: str,
transport: str | None = None,
host: str | None = None,
port: int | None = None,
log_level: str | None = None,
) -> None:
"""Run a MCP server or connect to a remote one.
Args:
server_spec: Python file, object specification (file:obj), or URL
transport: Transport protocol to use
host: Host to bind to when using http transport
port: Port to bind to when using http transport
log_level: Log level
"""
if is_url(server_spec):
# Handle URL case
server = create_client_server(server_spec)
logger.debug(f"Created client proxy server for {server_spec}")
else:
# Handle file case
file, server_object = parse_file_path(server_spec)
server = import_server(file, server_object)
logger.debug(f'Found server "{server.name}" in {file}')
# Run the server
kwargs = {}
if transport:
kwargs["transport"] = transport
if host:
kwargs["host"] = host
if port:
kwargs["port"] = port
if log_level:
kwargs["log_level"] = log_level
try:
server.run(**kwargs)
except Exception as e:
logger.error(f"Failed to run server: {e}")
sys.exit(1)

View file

@ -1,24 +1,22 @@
import abc
import contextlib
import datetime
from collections.abc import AsyncIterator
from contextlib import AsyncExitStack
from contextlib import AsyncExitStack, asynccontextmanager
from pathlib import Path
from typing import Any, TypedDict, cast
from typing import Any, Generic, cast, overload
import anyio
import mcp.types
from exceptiongroup import catch
from mcp import ClientSession
from mcp.client.session import (
ListRootsFnT,
LoggingFnT,
MessageHandlerFnT,
SamplingFnT,
)
from pydantic import AnyUrl
from typing_extensions import Unpack
from fastmcp.client.logging import LogHandler, MessageHandler
import fastmcp
from fastmcp.client.logging import (
LogHandler,
MessageHandler,
create_log_callback,
default_log_handler,
)
from fastmcp.client.progress import ProgressHandler, default_progress_handler
from fastmcp.client.roots import (
RootsHandler,
RootsList,
@ -28,6 +26,20 @@ from fastmcp.client.sampling import SamplingHandler, create_sampling_callback
from fastmcp.exceptions import ToolError
from fastmcp.server import FastMCP
from fastmcp.utilities.exceptions import get_catch_handlers
from fastmcp.utilities.mcp_config import MCPConfig
from .transports import (
ClientTransportT,
FastMCP1Server,
FastMCPTransport,
MCPConfigTransport,
NodeStdioTransport,
PythonStdioTransport,
SessionKwargs,
SSETransport,
StreamableHttpTransport,
infer_transport,
)
__all__ = [
"Client",
@ -38,61 +50,17 @@ __all__ = [
"LogHandler",
"MessageHandler",
"SamplingHandler",
"ProgressHandler",
]
class SessionKwargs(TypedDict, total=False):
"""Keyword arguments for the MCP ClientSession constructor."""
sampling_callback: SamplingFnT | None
list_roots_callback: ListRootsFnT | None
logging_callback: LoggingFnT | None
message_handler: MessageHandlerFnT | None
read_timeout_seconds: datetime.timedelta | None
class ClientTransport(abc.ABC):
"""
Abstract base class for different MCP client transport mechanisms.
A Transport is responsible for establishing and managing connections
to an MCP server, and providing a ClientSession within an async context.
"""
@abc.abstractmethod
@contextlib.asynccontextmanager
async def connect_session(
self, **session_kwargs: Unpack[SessionKwargs]
) -> AsyncIterator[ClientSession]:
"""
Establishes a connection and yields an active, initialized ClientSession.
The session is guaranteed to be valid only within the scope of the
async context manager. Connection setup and teardown are handled
within this context.
Args:
**session_kwargs: Keyword arguments to pass to the ClientSession
constructor (e.g., callbacks, timeouts).
Yields:
An initialized mcp.ClientSession instance.
"""
raise NotImplementedError
yield None # type: ignore
def __repr__(self) -> str:
# Basic representation for subclasses
return f"<{self.__class__.__name__}>"
class Client:
class Client(Generic[ClientTransportT]):
"""
MCP client that delegates connection management to a Transport instance.
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.
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:
@ -100,51 +68,115 @@ class Client:
- FastMCP: In-process FastMCP server
- AnyUrl | str: URL to connect to
- Path: File path for local socket
- MCPConfig: MCP server configuration
- 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
progress_handler: Optional handler for progress notifications
timeout: Optional timeout for requests (seconds or timedelta)
init_timeout: Optional timeout for initial connection (seconds or timedelta).
Set to 0 to disable. If None, uses the value in the FastMCP global settings.
Examples:
```python
# Connect to FastMCP server
client = Client("http://localhost:8080")
```python # Connect to FastMCP server client =
Client("http://localhost:8080")
async with client:
# List available resources
resources = await client.list_resources()
# List available resources resources = await client.list_resources()
# Call a tool
result = await client.call_tool("my_tool", {"param": "value"})
# Call a tool result = await client.call_tool("my_tool", {"param":
"value"})
```
"""
@overload
def __new__(
cls,
transport: ClientTransportT,
**kwargs: Any,
) -> "Client[ClientTransportT]": ...
@overload
def __new__(
cls, transport: AnyUrl, **kwargs
) -> "Client[SSETransport|StreamableHttpTransport]": ...
@overload
def __new__(
cls, transport: FastMCP | FastMCP1Server, **kwargs
) -> "Client[FastMCPTransport]": ...
@overload
def __new__(
cls, transport: Path, **kwargs
) -> "Client[PythonStdioTransport|NodeStdioTransport]": ...
@overload
def __new__(
cls, transport: MCPConfig | dict[str, Any], **kwargs
) -> "Client[MCPConfigTransport]": ...
@overload
def __new__(
cls, transport: str, **kwargs
) -> "Client[PythonStdioTransport|NodeStdioTransport|SSETransport|StreamableHttpTransport]": ...
def __new__(cls, transport, **kwargs) -> "Client":
instance = super().__new__(cls)
return instance
def __init__(
self,
transport: ClientTransport | FastMCP | AnyUrl | Path | dict[str, Any] | str,
transport: ClientTransportT
| FastMCP
| AnyUrl
| Path
| MCPConfig
| dict[str, Any]
| str,
# Common args
roots: RootsList | RootsHandler | None = None,
sampling_handler: SamplingHandler | None = None,
log_handler: LogHandler | None = None,
message_handler: MessageHandler | None = None,
progress_handler: ProgressHandler | None = None,
timeout: datetime.timedelta | float | int | None = None,
init_timeout: datetime.timedelta | float | int | None = None,
):
from fastmcp.client.transports import infer_transport
self.transport = infer_transport(transport)
self.transport = cast(ClientTransportT, infer_transport(transport))
self._session: ClientSession | None = None
self._exit_stack: AsyncExitStack | None = None
self._nesting_counter: int = 0
self._initialize_result: mcp.types.InitializeResult | None = None
if log_handler is None:
log_handler = default_log_handler
if progress_handler is None:
progress_handler = default_progress_handler
self._progress_handler = progress_handler
if isinstance(timeout, int | float):
timeout = datetime.timedelta(seconds=timeout)
# handle init handshake timeout
if init_timeout is None:
init_timeout = fastmcp.settings.settings.client_init_timeout
if isinstance(init_timeout, datetime.timedelta):
init_timeout = init_timeout.total_seconds()
elif not init_timeout:
init_timeout = None
else:
init_timeout = float(init_timeout)
self._init_timeout = init_timeout
self._session_kwargs: SessionKwargs = {
"sampling_callback": None,
"list_roots_callback": None,
"logging_callback": log_handler,
"logging_callback": create_log_callback(log_handler),
"message_handler": message_handler,
"read_timeout_seconds": timeout,
}
@ -153,17 +185,29 @@ class Client:
self.set_roots(roots)
if sampling_handler is not None:
self.set_sampling_callback(sampling_handler)
self._session_kwargs["sampling_callback"] = create_sampling_callback(
sampling_handler
)
@property
def session(self) -> ClientSession:
"""Get the current active session. Raises RuntimeError if not connected."""
if self._session is None:
raise RuntimeError(
"Client is not connected. Use 'async with client:' context manager first."
"Client is not connected. Use the 'async with client:' context manager first."
)
return self._session
@property
def initialize_result(self) -> mcp.types.InitializeResult:
"""Get the result of the initialization request."""
if self._initialize_result is None:
raise RuntimeError(
"Client is not connected. Use the 'async with client:' context manager first."
)
return self._initialize_result
def set_roots(self, roots: RootsList | RootsHandler) -> None:
"""Set the roots for the client. This does not automatically call `send_roots_list_changed`."""
self._session_kwargs["list_roots_callback"] = create_roots_callback(roots)
@ -178,27 +222,39 @@ class Client:
"""Check if the client is currently connected."""
return self._session is not None
@asynccontextmanager
async def _context_manager(self):
with catch(get_catch_handlers()):
async with self.transport.connect_session(
**self._session_kwargs
) as session:
self._session = session
# Initialize the session
try:
with anyio.fail_after(self._init_timeout):
self._initialize_result = await self._session.initialize()
yield
except anyio.ClosedResourceError:
raise RuntimeError("Server session was closed unexpectedly")
except TimeoutError:
raise RuntimeError("Failed to initialize server session")
finally:
self._exit_stack = None
self._session = None
self._initialize_result = None
async def __aenter__(self):
if self._nesting_counter == 0:
# 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()))
await stack.enter_async_context(self._context_manager())
# 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
async def __aexit__(self, exc_type, exc_val, exc_tb):
@ -211,7 +267,11 @@ class Client:
await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
finally:
self._exit_stack = None
self._session = None
async def close(self):
await self.transport.close()
self._session = None
self._initialize_result = None
# --- MCP Client Methods ---
@ -220,6 +280,23 @@ class Client:
result = await self.session.send_ping()
return isinstance(result, mcp.types.EmptyResult)
async def cancel(
self,
request_id: str | int,
reason: str | None = None,
) -> None:
"""Send a cancellation notification for an in-progress request."""
notification = mcp.types.ClientNotification(
mcp.types.CancelledNotification(
method="notifications/cancelled",
params=mcp.types.CancelledNotificationParams(
requestId=request_id,
reason=reason,
),
)
)
await self.session.send_notification(notification)
async def progress(
self,
progress_token: str | int,
@ -332,7 +409,12 @@ class Client:
RuntimeError: If called while the client is not connected.
"""
if isinstance(uri, str):
uri = AnyUrl(uri) # Ensure AnyUrl
try:
uri = AnyUrl(uri) # Ensure AnyUrl
except Exception as e:
raise ValueError(
f"Provided resource URI is invalid: {str(uri)!r}"
) from e
result = await self.read_resource_mcp(uri)
return result.contents
@ -490,6 +572,7 @@ class Client:
self,
name: str,
arguments: dict[str, Any],
progress_handler: ProgressHandler | None = None,
timeout: datetime.timedelta | float | int | None = None,
) -> mcp.types.CallToolResult:
"""Send a tools/call request and return the complete MCP protocol result.
@ -501,6 +584,8 @@ class Client:
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.
progress_handler (ProgressHandler | None, optional): The progress handler to use 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.
@ -512,7 +597,10 @@ class Client:
if isinstance(timeout, int | float):
timeout = datetime.timedelta(seconds=timeout)
result = await self.session.call_tool(
name=name, arguments=arguments, read_timeout_seconds=timeout
name=name,
arguments=arguments,
read_timeout_seconds=timeout,
progress_callback=progress_handler or self._progress_handler,
)
return result
@ -521,6 +609,7 @@ class Client:
name: str,
arguments: dict[str, Any] | None = None,
timeout: datetime.timedelta | float | int | None = None,
progress_handler: ProgressHandler | None = None,
) -> list[
mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource
]:
@ -531,6 +620,8 @@ class Client:
Args:
name (str): The name of the tool to call.
arguments (dict[str, Any] | None, optional): Arguments to pass to the tool. Defaults to None.
timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None.
Returns:
list[mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource]:
@ -544,6 +635,7 @@ class Client:
name=name,
arguments=arguments or {},
timeout=timeout,
progress_handler=progress_handler,
)
if result.isError:
msg = cast(mcp.types.TextContent, result.content[0]).text

View file

@ -1,13 +1,27 @@
from collections.abc import Awaitable, Callable
from typing import TypeAlias
from mcp.client.session import (
LoggingFnT,
MessageHandlerFnT,
)
from mcp.client.session import LoggingFnT, MessageHandlerFnT
from mcp.types import LoggingMessageNotificationParams
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
LogMessage: TypeAlias = LoggingMessageNotificationParams
LogHandler: TypeAlias = LoggingFnT
LogHandler: TypeAlias = Callable[[LogMessage], Awaitable[None]]
MessageHandler: TypeAlias = MessageHandlerFnT
__all__ = ["LogMessage", "LogHandler", "MessageHandler"]
async def default_log_handler(message: LogMessage) -> None:
logger.debug(f"Log received: {message}")
def create_log_callback(handler: LogHandler | None = None) -> LoggingFnT:
if handler is None:
handler = default_log_handler
async def log_callback(params: LoggingMessageNotificationParams) -> None:
await handler(params)
return log_callback

View file

@ -0,0 +1,38 @@
from typing import TypeAlias
from mcp.shared.session import ProgressFnT
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
ProgressHandler: TypeAlias = ProgressFnT
async def default_progress_handler(
progress: float, total: float | None, message: str | None
) -> None:
"""Default handler for progress notifications.
Logs progress updates at debug level, properly handling missing total or message values.
Args:
progress: Current progress value
total: Optional total expected value
message: Optional status message
"""
if total is not None:
# We have both progress and total
percent = (progress / total) * 100
progress_str = f"{progress}/{total} ({percent:.1f}%)"
else:
# We only have progress
progress_str = f"{progress}"
# Include message if available
if message:
log_msg = f"Progress: {progress_str} - {message}"
else:
log_msg = f"Progress: {progress_str}"
logger.debug(log_msg)

View file

@ -1,14 +1,18 @@
import abc
import asyncio
import contextlib
import datetime
import os
import shutil
import sys
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast, overload
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp.client.websocket import websocket_client
from mcp.server.fastmcp import FastMCP as FastMCP1Server
from mcp.shared.memory import create_connected_server_and_client_session
from pydantic import AnyUrl
from typing_extensions import Unpack
@ -17,13 +21,24 @@ from fastmcp.client.client import ClientTransport, SessionKwargs
from fastmcp.client.sse import SSETransport
from fastmcp.client.streamable_http import StreamableHttpTransport
from fastmcp.server import FastMCP as FastMCPServer
from fastmcp.server.dependencies import get_http_headers
from fastmcp.server.server import FastMCP
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.mcp_config import MCPConfig, infer_transport_type_from_url
if TYPE_CHECKING:
from fastmcp.utilities.mcp_config import MCPConfig
logger = get_logger(__name__)
# TypeVar for preserving specific ClientTransport subclass types
ClientTransportT = TypeVar("ClientTransportT", bound="ClientTransport")
__all__ = [
"ClientTransport",
"SSETransport",
"StreamableHttpTransport",
"FastMCPServer",
"WSTransport",
"StdioTransport",
"PythonStdioTransport",
"FastMCPStdioTransport",
@ -35,10 +50,68 @@ __all__ = [
]
class SessionKwargs(TypedDict, total=False):
"""Keyword arguments for the MCP ClientSession constructor."""
sampling_callback: SamplingFnT | None
list_roots_callback: ListRootsFnT | None
logging_callback: LoggingFnT | None
message_handler: MessageHandlerFnT | None
read_timeout_seconds: datetime.timedelta | None
class ClientTransport(abc.ABC):
"""
Abstract base class for different MCP client transport mechanisms.
A Transport is responsible for establishing and managing connections
to an MCP server, and providing a ClientSession within an async context.
"""
@abc.abstractmethod
@contextlib.asynccontextmanager
async def connect_session(
self, **session_kwargs: Unpack[SessionKwargs]
) -> AsyncIterator[ClientSession]:
"""
Establishes a connection and yields an active ClientSession.
The ClientSession is *not* expected to be initialized in this context manager.
The session is guaranteed to be valid only within the scope of the
async context manager. Connection setup and teardown are handled
within this context.
Args:
**session_kwargs: Keyword arguments to pass to the ClientSession
constructor (e.g., callbacks, timeouts).
Yields:
A mcp.ClientSession instance.
"""
raise NotImplementedError
yield # type: ignore
def __repr__(self) -> str:
# Basic representation for subclasses
return f"<{self.__class__.__name__}>"
async def close(self):
"""Close the transport."""
pass
class WSTransport(ClientTransport):
"""Transport implementation that connects to an MCP server via WebSockets."""
def __init__(self, url: str | AnyUrl):
# we never really used this transport, so it can be removed at any time
warnings.warn(
"WSTransport is a deprecated MCP transport and will be removed in a future version. Use StreamableHttpTransport instead.",
DeprecationWarning,
stacklevel=2,
)
if isinstance(url, AnyUrl):
url = str(url)
if not isinstance(url, str) or not url.startswith("ws"):
@ -54,13 +127,113 @@ class WSTransport(ClientTransport):
async with ClientSession(
read_stream, write_stream, **session_kwargs
) as session:
await session.initialize() # Initialize after session creation
yield session
def __repr__(self) -> str:
return f"<WebSocket(url='{self.url}')>"
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,
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"):
raise ValueError("Invalid HTTP/S URL provided for SSE.")
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]:
client_kwargs: dict[str, Any] = {}
# load headers from an active HTTP request, if available. This will only be true
# if the client is used in a FastMCP Proxy, in which case the MCP client headers
# need to be forwarded to the remote server.
client_kwargs["headers"] = get_http_headers() | self.headers
# 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, **client_kwargs) as transport:
read_stream, write_stream = transport
async with ClientSession(
read_stream, write_stream, **session_kwargs
) as session:
yield session
def __repr__(self) -> str:
return f"<SSE(url='{self.url}')>"
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,
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"):
raise ValueError("Invalid HTTP/S URL provided for Streamable HTTP.")
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]:
client_kwargs: dict[str, Any] = {}
# load headers from an active HTTP request, if available. This will only be true
# if the client is used in a FastMCP Proxy, in which case the MCP client headers
# need to be forwarded to the remote server.
client_kwargs["headers"] = get_http_headers() | self.headers
# 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, **client_kwargs) as transport:
read_stream, write_stream, _ = transport
async with ClientSession(
read_stream, write_stream, **session_kwargs
) as session:
yield session
def __repr__(self) -> str:
return f"<StreamableHttp(url='{self.url}')>"
class StdioTransport(ClientTransport):
"""
Base transport for connecting to an MCP server via subprocess with stdio.
@ -75,6 +248,7 @@ class StdioTransport(ClientTransport):
args: list[str],
env: dict[str, str] | None = None,
cwd: str | None = None,
keep_alive: bool | None = None,
):
"""
Initialize a Stdio transport.
@ -84,26 +258,90 @@ class StdioTransport(ClientTransport):
args: The arguments to pass to the command
env: Environment variables to set for the subprocess
cwd: Current working directory for the subprocess
keep_alive: Whether to keep the subprocess alive between connections.
Defaults to True. When True, the subprocess remains active
after the connection context exits, allowing reuse in
subsequent connections.
"""
self.command = command
self.args = args
self.env = env
self.cwd = cwd
if keep_alive is None:
keep_alive = True
self.keep_alive = keep_alive
self._session: ClientSession | None = None
self._connect_task: asyncio.Task | None = None
self._ready_event = asyncio.Event()
self._stop_event = asyncio.Event()
@contextlib.asynccontextmanager
async def connect_session(
self, **session_kwargs: Unpack[SessionKwargs]
) -> AsyncIterator[ClientSession]:
server_params = StdioServerParameters(
command=self.command, args=self.args, env=self.env, cwd=self.cwd
)
async with stdio_client(server_params) as transport:
read_stream, write_stream = transport
async with ClientSession(
read_stream, write_stream, **session_kwargs
) as session:
await session.initialize()
yield session
try:
await self.connect(**session_kwargs)
assert self._session is not None
yield self._session
finally:
if not self.keep_alive:
await self.disconnect()
else:
logger.debug("Stdio transport has keep_alive=True, not disconnecting")
async def connect(
self, **session_kwargs: Unpack[SessionKwargs]
) -> ClientSession | None:
if self._connect_task is not None:
return
async def _connect_task():
async with contextlib.AsyncExitStack() as stack:
try:
server_params = StdioServerParameters(
command=self.command, args=self.args, env=self.env, cwd=self.cwd
)
transport = await stack.enter_async_context(
stdio_client(server_params)
)
read_stream, write_stream = transport
self._session = await stack.enter_async_context(
ClientSession(read_stream, write_stream, **session_kwargs)
)
logger.debug("Stdio transport connected")
self._ready_event.set()
# Wait until disconnect is requested (stop_event is set)
await self._stop_event.wait()
finally:
# Clean up client on exit
self._session = None
logger.debug("Stdio transport disconnected")
# start the connection task
self._connect_task = asyncio.create_task(_connect_task())
# wait for the client to be ready before returning
await self._ready_event.wait()
async def disconnect(self):
if self._connect_task is None:
return
# signal the connection task to stop
self._stop_event.set()
# wait for the connection task to finish cleanly
await self._connect_task
# reset variables and events for potential future reconnects
self._connect_task = None
self._stop_event = asyncio.Event()
self._ready_event = asyncio.Event()
async def close(self):
await self.disconnect()
def __repr__(self) -> str:
return (
@ -121,6 +359,7 @@ class PythonStdioTransport(StdioTransport):
env: dict[str, str] | None = None,
cwd: str | None = None,
python_cmd: str = sys.executable,
keep_alive: bool | None = None,
):
"""
Initialize a Python transport.
@ -131,6 +370,10 @@ class PythonStdioTransport(StdioTransport):
env: Environment variables to set for the subprocess
cwd: Current working directory for the subprocess
python_cmd: Python command to use (default: "python")
keep_alive: Whether to keep the subprocess alive between connections.
Defaults to True. When True, the subprocess remains active
after the connection context exits, allowing reuse in
subsequent connections.
"""
script_path = Path(script_path).resolve()
if not script_path.is_file():
@ -142,7 +385,13 @@ class PythonStdioTransport(StdioTransport):
if args:
full_args.extend(args)
super().__init__(command=python_cmd, args=full_args, env=env, cwd=cwd)
super().__init__(
command=python_cmd,
args=full_args,
env=env,
cwd=cwd,
keep_alive=keep_alive,
)
self.script_path = script_path
@ -155,6 +404,7 @@ class FastMCPStdioTransport(StdioTransport):
args: list[str] | None = None,
env: dict[str, str] | None = None,
cwd: str | None = None,
keep_alive: bool | None = None,
):
script_path = Path(script_path).resolve()
if not script_path.is_file():
@ -163,7 +413,11 @@ class FastMCPStdioTransport(StdioTransport):
raise ValueError(f"Not a Python script: {script_path}")
super().__init__(
command="fastmcp", args=["run", str(script_path)], env=env, cwd=cwd
command="fastmcp",
args=["run", str(script_path)],
env=env,
cwd=cwd,
keep_alive=keep_alive,
)
self.script_path = script_path
@ -178,6 +432,7 @@ class NodeStdioTransport(StdioTransport):
env: dict[str, str] | None = None,
cwd: str | None = None,
node_cmd: str = "node",
keep_alive: bool | None = None,
):
"""
Initialize a Node transport.
@ -188,6 +443,10 @@ class NodeStdioTransport(StdioTransport):
env: Environment variables to set for the subprocess
cwd: Current working directory for the subprocess
node_cmd: Node.js command to use (default: "node")
keep_alive: Whether to keep the subprocess alive between connections.
Defaults to True. When True, the subprocess remains active
after the connection context exits, allowing reuse in
subsequent connections.
"""
script_path = Path(script_path).resolve()
if not script_path.is_file():
@ -199,7 +458,9 @@ class NodeStdioTransport(StdioTransport):
if args:
full_args.extend(args)
super().__init__(command=node_cmd, args=full_args, env=env, cwd=cwd)
super().__init__(
command=node_cmd, args=full_args, env=env, cwd=cwd, keep_alive=keep_alive
)
self.script_path = script_path
@ -215,6 +476,7 @@ class UvxStdioTransport(StdioTransport):
with_packages: list[str] | None = None,
from_package: str | None = None,
env_vars: dict[str, str] | None = None,
keep_alive: bool | None = None,
):
"""
Initialize a Uvx transport.
@ -227,6 +489,10 @@ class UvxStdioTransport(StdioTransport):
with_packages: Additional packages to include
from_package: Package to install the tool from
env_vars: Additional environment variables
keep_alive: Whether to keep the subprocess alive between connections.
Defaults to True. When True, the subprocess remains active
after the connection context exits, allowing reuse in
subsequent connections.
"""
# Basic validation
if project_directory and not Path(project_directory).exists():
@ -254,7 +520,13 @@ class UvxStdioTransport(StdioTransport):
env = os.environ.copy()
env.update(env_vars)
super().__init__(command="uvx", args=uvx_args, env=env, cwd=project_directory)
super().__init__(
command="uvx",
args=uvx_args,
env=env,
cwd=project_directory,
keep_alive=keep_alive,
)
self.tool_name = tool_name
@ -268,6 +540,7 @@ class NpxStdioTransport(StdioTransport):
project_directory: str | None = None,
env_vars: dict[str, str] | None = None,
use_package_lock: bool = True,
keep_alive: bool | None = None,
):
"""
Initialize an Npx transport.
@ -278,6 +551,10 @@ class NpxStdioTransport(StdioTransport):
project_directory: Project directory with package.json
env_vars: Additional environment variables
use_package_lock: Whether to use package-lock.json (--prefer-offline)
keep_alive: Whether to keep the subprocess alive between connections.
Defaults to True. When True, the subprocess remains active
after the connection context exits, allowing reuse in
subsequent connections.
"""
# verify npx is installed
if shutil.which("npx") is None:
@ -305,20 +582,32 @@ class NpxStdioTransport(StdioTransport):
env = os.environ.copy()
env.update(env_vars)
super().__init__(command="npx", args=npx_args, env=env, cwd=project_directory)
super().__init__(
command="npx",
args=npx_args,
env=env,
cwd=project_directory,
keep_alive=keep_alive,
)
self.package = package
class FastMCPTransport(ClientTransport):
"""
Special transport for in-memory connections to an MCP server.
"""In-memory transport for FastMCP servers.
This is particularly useful for testing or when client and server
are in the same process.
This transport connects directly to a FastMCP server instance in the same
Python process. It works with both FastMCP 2.x servers and FastMCP 1.0
servers from the low-level MCP SDK. This is particularly useful for unit
tests or scenarios where client and server run in the same runtime.
"""
def __init__(self, mcp: FastMCPServer):
self._fastmcp = mcp # Can be FastMCP or MCPServer
def __init__(self, mcp: FastMCPServer | FastMCP1Server):
"""Initialize a FastMCPTransport from a FastMCP server instance."""
# Accept both FastMCP 2.x and FastMCP 1.0 servers. Both expose a
# ``_mcp_server`` attribute pointing to the underlying MCP server
# implementation, so we can treat them identically.
self.server = mcp
@contextlib.asynccontextmanager
async def connect_session(
@ -326,17 +615,148 @@ class FastMCPTransport(ClientTransport):
) -> AsyncIterator[ClientSession]:
# create_connected_server_and_client_session manages the session lifecycle itself
async with create_connected_server_and_client_session(
server=self._fastmcp._mcp_server,
server=self.server._mcp_server,
**session_kwargs,
) as session:
yield session
def __repr__(self) -> str:
return f"<FastMCP(server='{self._fastmcp.name}')>"
return f"<FastMCP(server='{self.server.name}')>"
class MCPConfigTransport(ClientTransport):
"""Transport for connecting to one or more MCP servers defined in an MCPConfig.
This transport provides a unified interface to multiple MCP servers defined in an MCPConfig
object or dictionary matching the MCPConfig schema. It supports two key scenarios:
1. If the MCPConfig contains exactly one server, it creates a direct transport to that server.
2. If the MCPConfig contains multiple servers, it creates a composite client by mounting
all servers on a single FastMCP instance, with each server's name used as its mounting prefix.
In the multi-server case, tools are accessible with the prefix pattern `{server_name}_{tool_name}`
and resources with the pattern `protocol://{server_name}/path/to/resource`.
This is particularly useful for creating clients that need to interact with multiple specialized
MCP servers through a single interface, simplifying client code.
Examples:
```python
from fastmcp import Client
from fastmcp.utilities.mcp_config import MCPConfig
# Create a config with multiple servers
config = {
"mcpServers": {
"weather": {
"url": "https://weather-api.example.com/mcp",
"transport": "streamable-http"
},
"calendar": {
"url": "https://calendar-api.example.com/mcp",
"transport": "streamable-http"
}
}
}
# Create a client with the config
client = Client(config)
async with client:
# Access tools with prefixes
weather = await client.call_tool("weather_get_forecast", {"city": "London"})
events = await client.call_tool("calendar_list_events", {"date": "2023-06-01"})
# Access resources with prefixed URIs
icons = await client.read_resource("weather://weather/icons/sunny")
```
"""
def __init__(self, config: MCPConfig | dict):
from fastmcp.client.client import Client
if isinstance(config, dict):
config = MCPConfig.from_dict(config)
self.config = config
# if there are no servers, raise an error
if len(self.config.mcpServers) == 0:
raise ValueError("No MCP servers defined in the config")
# if there's exactly one server, create a client for that server
elif len(self.config.mcpServers) == 1:
self.transport = list(self.config.mcpServers.values())[0].to_transport()
# otherwise create a composite client
else:
composite_server = FastMCP()
for name, server in self.config.mcpServers.items():
server_client = Client(transport=server.to_transport())
composite_server.mount(
prefix=name, server=FastMCP.as_proxy(server_client)
)
self.transport = FastMCPTransport(mcp=composite_server)
@contextlib.asynccontextmanager
async def connect_session(
self, **session_kwargs: Unpack[SessionKwargs]
) -> AsyncIterator[ClientSession]:
async with self.transport.connect_session(**session_kwargs) as session:
yield session
def __repr__(self) -> str:
return f"<MCPConfig(config='{self.config}')>"
@overload
def infer_transport(transport: ClientTransportT) -> ClientTransportT: ...
@overload
def infer_transport(transport: FastMCPServer) -> FastMCPTransport: ...
@overload
def infer_transport(transport: FastMCP1Server) -> FastMCPTransport: ...
@overload
def infer_transport(transport: MCPConfig) -> MCPConfigTransport: ...
@overload
def infer_transport(transport: dict[str, Any]) -> MCPConfigTransport: ...
@overload
def infer_transport(
transport: AnyUrl,
) -> SSETransport | StreamableHttpTransport: ...
@overload
def infer_transport(
transport: str,
) -> (
PythonStdioTransport | NodeStdioTransport | SSETransport | StreamableHttpTransport
): ...
@overload
def infer_transport(transport: Path) -> PythonStdioTransport | NodeStdioTransport: ...
def infer_transport(
transport: ClientTransport | FastMCPServer | AnyUrl | Path | dict[str, Any] | str,
transport: ClientTransport
| FastMCPServer
| FastMCP1Server
| AnyUrl
| Path
| MCPConfig
| dict[str, Any]
| str,
) -> ClientTransport:
"""
Infer the appropriate transport type from the given transport argument.
@ -345,65 +765,73 @@ def infer_transport(
argument, handling various input types and converting them to the appropriate
ClientTransport subclass.
The function supports these input types:
- ClientTransport: Used directly without modification
- FastMCPServer or FastMCP1Server: Creates an in-memory FastMCPTransport
- Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js)
- AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints)
- MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers
For HTTP URLs, they are assumed to be Streamable HTTP URLs unless they end in `/sse`.
For MCPConfig with multiple servers, a composite client is created where each server
is mounted with its name as prefix. This allows accessing tools and resources from multiple
servers through a single unified client interface, using naming patterns like
`servername_toolname` for tools and `protocol://servername/path` for resources.
If the MCPConfig contains only one server, a direct connection is established without prefixing.
Examples:
```python
# Connect to a local Python script
transport = infer_transport("my_script.py")
# Connect to a remote server via HTTP
transport = infer_transport("http://example.com/mcp")
# Connect to multiple servers using MCPConfig
config = {
"mcpServers": {
"weather": {"url": "http://weather.example.com/mcp"},
"calendar": {"url": "http://calendar.example.com/mcp"}
}
}
transport = infer_transport(config)
```
"""
from fastmcp.utilities.mcp_config import MCPConfig
# the transport is already a ClientTransport
if isinstance(transport, ClientTransport):
return transport
# the transport is a FastMCP server
elif isinstance(transport, FastMCPServer):
return FastMCPTransport(mcp=transport)
# the transport is a FastMCP server (2.x or 1.0)
elif isinstance(transport, FastMCPServer | FastMCP1Server):
inferred_transport = FastMCPTransport(mcp=transport)
# the transport is a path to a script
elif isinstance(transport, Path | str) and Path(transport).exists():
if str(transport).endswith(".py"):
return PythonStdioTransport(script_path=transport)
inferred_transport = PythonStdioTransport(script_path=transport)
elif str(transport).endswith(".js"):
return NodeStdioTransport(script_path=transport)
inferred_transport = NodeStdioTransport(script_path=transport)
else:
raise ValueError(f"Unsupported script type: {transport}")
# the transport is an http(s) URL
elif isinstance(transport, AnyUrl | str) and str(transport).startswith("http"):
if str(transport).rstrip("/").endswith("/sse"):
return SSETransport(url=transport)
inferred_transport_type = infer_transport_type_from_url(transport)
if inferred_transport_type == "sse":
inferred_transport = SSETransport(url=transport)
else:
return StreamableHttpTransport(url=transport)
inferred_transport = StreamableHttpTransport(url=transport)
# the transport is a websocket URL
elif isinstance(transport, AnyUrl | str) and str(transport).startswith("ws"):
return WSTransport(url=transport)
## if the transport is a config dict
elif isinstance(transport, dict):
if "mcpServers" not in transport:
raise ValueError("Invalid transport dictionary: missing 'mcpServers' key")
else:
server = transport["mcpServers"]
if len(list(server.keys())) > 1:
raise ValueError(
"Invalid transport dictionary: multiple servers found - only one expected"
)
server_name = list(server.keys())[0]
# Stdio transport
if "command" in server[server_name] and "args" in server[server_name]:
return StdioTransport(
command=server[server_name]["command"],
args=server[server_name]["args"],
env=server[server_name].get("env", None),
cwd=server[server_name].get("cwd", None),
)
# HTTP transport
elif "url" in server:
return SSETransport(
url=server["url"],
headers=server.get("headers", None),
)
raise ValueError("Cannot determine transport type from dictionary")
# if the transport is a config dict or MCPConfig
elif isinstance(transport, dict | MCPConfig):
inferred_transport = MCPConfigTransport(config=transport)
# the transport is an unknown type
else:
raise ValueError(f"Could not infer a valid transport from: {transport}")
logger.debug(f"Inferred transport: {inferred_transport}")
return inferred_transport

View file

@ -12,6 +12,7 @@ from mcp.types import Prompt as MCPPrompt
from mcp.types import PromptArgument as MCPPromptArgument
from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
from fastmcp.exceptions import PromptError
from fastmcp.server.dependencies import get_context
from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.logging import get_logger
@ -96,7 +97,7 @@ class Prompt(BaseModel):
"""
from fastmcp.server.context import Context
func_name = name or fn.__name__
func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__
if func_name == "<lambda>":
raise ValueError("You must provide a name for lambda functions")
@ -108,6 +109,12 @@ class Prompt(BaseModel):
if param.kind == inspect.Parameter.VAR_KEYWORD:
raise ValueError("Functions with **kwargs are not supported as prompts")
description = description or fn.__doc__
# if the fn is a callable class, we need to get the __call__ method from here out
if not inspect.isroutine(fn):
fn = fn.__call__
type_adapter = get_cached_typeadapter(fn)
parameters = type_adapter.json_schema()
@ -138,7 +145,7 @@ class Prompt(BaseModel):
return cls(
name=func_name,
description=description or fn.__doc__,
description=description,
arguments=arguments,
fn=fn,
tags=tags or set(),
@ -199,12 +206,12 @@ class Prompt(BaseModel):
)
)
except Exception:
raise ValueError("Could not convert prompt result to message.")
raise PromptError("Could not convert prompt result to message.")
return messages
except Exception as e:
logger.exception(f"Error rendering prompt {self.name}: {e}")
raise ValueError(f"Error rendering prompt {self.name}.")
raise PromptError(f"Error rendering prompt {self.name}.")
def __eq__(self, other: object) -> bool:
if not isinstance(other, Prompt):

View file

@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any
from mcp import GetPromptResult
from fastmcp.exceptions import NotFoundError
from fastmcp.exceptions import NotFoundError, PromptError
from fastmcp.prompts.prompt import Prompt, PromptResult
from fastmcp.settings import DuplicateBehavior
from fastmcp.utilities.logging import get_logger
@ -21,8 +21,13 @@ logger = get_logger(__name__)
class PromptManager:
"""Manages FastMCP prompts."""
def __init__(self, duplicate_behavior: DuplicateBehavior | None = None):
def __init__(
self,
duplicate_behavior: DuplicateBehavior | None = None,
mask_error_details: bool = False,
):
self._prompts: dict[str, Prompt] = {}
self.mask_error_details = mask_error_details
# Default to "warn" if None is provided
if duplicate_behavior is None:
@ -85,9 +90,24 @@ class PromptManager:
if not prompt:
raise NotFoundError(f"Unknown prompt: {name}")
messages = await prompt.render(arguments)
try:
messages = await prompt.render(arguments)
return GetPromptResult(description=prompt.description, messages=messages)
return GetPromptResult(description=prompt.description, messages=messages)
# Pass through PromptErrors as-is
except PromptError as e:
logger.exception(f"Error rendering prompt {name!r}: {e}")
raise e
# Handle other exceptions
except Exception as e:
logger.exception(f"Error rendering prompt {name!r}: {e}")
if self.mask_error_details:
# Mask internal details
raise PromptError(f"Error rendering prompt {name!r}")
else:
# Include original error details
raise PromptError(f"Error rendering prompt {name!r}: {e}")
def has_prompt(self, key: str) -> bool:
"""Check if a prompt exists."""

View file

@ -22,9 +22,22 @@ logger = get_logger(__name__)
class ResourceManager:
"""Manages FastMCP resources."""
def __init__(self, duplicate_behavior: DuplicateBehavior | None = None):
def __init__(
self,
duplicate_behavior: DuplicateBehavior | None = None,
mask_error_details: bool = False,
):
"""Initialize the ResourceManager.
Args:
duplicate_behavior: How to handle duplicate resources
(warn, error, replace, ignore)
mask_error_details: Whether to mask error details from exceptions
other than ResourceError
"""
self._resources: dict[str, Resource] = {}
self._templates: dict[str, ResourceTemplate] = {}
self.mask_error_details = mask_error_details
# Default to "warn" if None is provided
if duplicate_behavior is None:
@ -35,7 +48,6 @@ class ResourceManager:
f"Invalid duplicate_behavior: {duplicate_behavior}. "
f"Must be one of: {', '.join(DuplicateBehavior.__args__)}"
)
self.duplicate_behavior = duplicate_behavior
def add_resource_or_template_from_fn(
@ -244,12 +256,21 @@ class ResourceManager:
uri_str,
params=params,
)
# Pass through ResourceErrors as-is
except ResourceError as e:
logger.error(f"Error creating resource from template: {e}")
raise e
# Handle other exceptions
except Exception as e:
logger.error(f"Error creating resource from template: {e}")
raise ValueError(f"Error creating resource from template: {e}")
if self.mask_error_details:
# Mask internal details
raise ValueError("Error creating resource from template") from e
else:
# Include original error details
raise ValueError(
f"Error creating resource from template: {e}"
) from e
raise NotFoundError(f"Unknown resource: {uri_str}")
@ -265,10 +286,15 @@ class ResourceManager:
logger.error(f"Error reading resource {uri!r}: {e}")
raise e
# raise other exceptions as ResourceErrors without revealing internal details
# Handle other exceptions
except Exception as e:
logger.error(f"Error reading resource {uri!r}: {e}")
raise ResourceError(f"Error reading resource {uri!r}") from e
if self.mask_error_details:
# Mask internal details
raise ResourceError(f"Error reading resource {uri!r}") from e
else:
# Include original error details
raise ResourceError(f"Error reading resource {uri!r}: {e}") from e
def get_resources(self) -> dict[str, Resource]:
"""Get all registered resources, keyed by URI."""

View file

@ -14,7 +14,6 @@ from pydantic import (
BaseModel,
BeforeValidator,
Field,
TypeAdapter,
field_validator,
validate_call,
)
@ -25,6 +24,7 @@ from fastmcp.utilities.json_schema import compress_schema
from fastmcp.utilities.types import (
_convert_set_defaults,
find_kwarg_by_type,
get_cached_typeadapter,
)
@ -97,7 +97,7 @@ class ResourceTemplate(BaseModel):
"""Create a template from a function."""
from fastmcp.server.context import Context
func_name = name or fn.__name__
func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__
if func_name == "<lambda>":
raise ValueError("You must provide a name for lambda functions")
@ -148,8 +148,13 @@ class ResourceTemplate(BaseModel):
f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}"
)
# Get schema from TypeAdapter - will fail if function isn't properly typed
parameters = TypeAdapter(fn).json_schema()
description = description or fn.__doc__
if not inspect.isroutine(fn):
fn = fn.__call__
type_adapter = get_cached_typeadapter(fn)
parameters = type_adapter.json_schema()
# compress the schema
prune_params = [context_kwarg] if context_kwarg else None
@ -161,7 +166,7 @@ class ResourceTemplate(BaseModel):
return cls(
uri_template=uri_template,
name=func_name,
description=description or fn.__doc__ or "",
description=description,
mime_type=mime_type or "text/plain",
fn=fn,
parameters=parameters,

View file

@ -12,6 +12,8 @@ from mcp.shared.context import RequestContext
from mcp.types import (
CreateMessageResult,
ImageContent,
ModelHint,
ModelPreferences,
Root,
SamplingMessage,
TextContent,
@ -200,6 +202,7 @@ class Context:
system_prompt: str | None = None,
temperature: float | None = None,
max_tokens: int | None = None,
model_preferences: ModelPreferences | str | list[str] | None = None,
) -> TextContent | ImageContent:
"""
Send a sampling request to the client and await the response.
@ -231,6 +234,7 @@ class Context:
system_prompt=system_prompt,
temperature=temperature,
max_tokens=max_tokens,
model_preferences=self._parse_model_preferences(model_preferences),
)
return result.content
@ -248,3 +252,45 @@ class Context:
)
return fastmcp.server.dependencies.get_http_request()
def _parse_model_preferences(
self, model_preferences: ModelPreferences | str | list[str] | None
) -> ModelPreferences | None:
"""
Validates and converts user input for model_preferences into a ModelPreferences object.
Args:
model_preferences (ModelPreferences | str | list[str] | None):
The model preferences to use. Accepts:
- ModelPreferences (returns as-is)
- str (single model hint)
- list[str] (multiple model hints)
- None (no preferences)
Returns:
ModelPreferences | None: The parsed ModelPreferences object, or None if not provided.
Raises:
ValueError: If the input is not a supported type or contains invalid values.
"""
if model_preferences is None:
return None
elif isinstance(model_preferences, ModelPreferences):
return model_preferences
elif isinstance(model_preferences, str):
# Single model hint
return ModelPreferences(hints=[ModelHint(name=model_preferences)])
elif isinstance(model_preferences, list):
# List of model hints (strings)
if not all(isinstance(h, str) for h in model_preferences):
raise ValueError(
"All elements of model_preferences list must be"
" strings (model name hints)."
)
return ModelPreferences(
hints=[ModelHint(name=h) for h in model_preferences]
)
else:
raise ValueError(
"model_preferences must be one of: ModelPreferences, str, list[str], or None."
)

View file

@ -33,3 +33,46 @@ def get_http_request() -> Request:
if request is None:
raise RuntimeError("No active HTTP request found.")
return request
def get_http_headers(include_all: bool = False) -> dict[str, str]:
"""
Extract headers from the current HTTP request if available.
Never raises an exception, even if there is no active HTTP request (in which case
an empty dict is returned).
By default, strips problematic headers like `content-length` that cause issues if forwarded to downstream clients.
If `include_all` is True, all headers are returned.
"""
if include_all:
exclude_headers = set()
else:
exclude_headers = {
"host",
"content-length",
"connection",
"transfer-encoding",
"upgrade",
"te",
"keep-alive",
"expect",
# Proxy-related headers
"proxy-authenticate",
"proxy-authorization",
"proxy-connection",
}
# (just in case)
if not all(h.lower() == h for h in exclude_headers):
raise ValueError("Excluded headers must be lowercase")
headers = {}
try:
request = get_http_request()
for name, value in request.headers.items():
lower_name = name.lower()
if lower_name not in exclude_headers:
headers[lower_name] = str(value)
return headers
except RuntimeError:
return {}

View file

@ -241,6 +241,7 @@ def create_sse_app(
# Add custom routes with lowest precedence
if routes:
server_routes.extend(routes)
server_routes.extend(server._additional_http_routes)
# Add middleware
if middleware:
@ -254,6 +255,7 @@ def create_sse_app(
)
# Store the FastMCP server instance on the Starlette app state
app.state.fastmcp_server = server
app.state.path = sse_path
return app
@ -305,7 +307,29 @@ def create_streamable_http_app(
async def handle_streamable_http(
scope: Scope, receive: Receive, send: Send
) -> None:
await session_manager.handle_request(scope, receive, send)
try:
await session_manager.handle_request(scope, receive, send)
except RuntimeError as e:
if str(e) == "Task group is not initialized. Make sure to use run().":
logger.error(
f"Original RuntimeError from mcp library: {e}", exc_info=True
)
new_error_message = (
"FastMCP's StreamableHTTPSessionManager task group was not initialized. "
"This commonly occurs when the FastMCP application's lifespan is not "
"passed to the parent ASGI application (e.g., FastAPI or Starlette). "
"Please ensure you are setting `lifespan=mcp_app.lifespan` in your "
"parent app's constructor, where `mcp_app` is the application instance "
"returned by `fastmcp_instance.http_app()`. \\n"
"For more details, see the FastMCP ASGI integration documentation: "
"https://gofastmcp.com/deployment/asgi"
)
# Raise a new RuntimeError that includes the original error's message
# for full context, but leads with the more helpful guidance.
raise RuntimeError(f"{new_error_message}\\nOriginal error: {e}") from e
else:
# Re-raise other RuntimeErrors if they don't match the specific message
raise
# Get auth middleware and routes
auth_middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes(
@ -336,6 +360,7 @@ def create_streamable_http_app(
# Add custom routes with lowest precedence
if routes:
server_routes.extend(routes)
server_routes.extend(server._additional_http_routes)
# Add middleware
if middleware:
@ -357,4 +382,6 @@ def create_streamable_http_app(
# Store the FastMCP server instance on the Starlette app state
app.state.fastmcp_server = server
app.state.path = streamable_http_path
return app

View file

@ -5,8 +5,10 @@ from __future__ import annotations
import enum
import json
import re
import warnings
from collections import Counter
from collections.abc import Callable
from dataclasses import dataclass
from dataclasses import dataclass, field
from re import Pattern
from typing import TYPE_CHECKING, Any, Literal
@ -16,11 +18,13 @@ from pydantic.networks import AnyUrl
from fastmcp.exceptions import ToolError
from fastmcp.resources import Resource, ResourceTemplate
from fastmcp.server.dependencies import get_http_headers
from fastmcp.server.server import FastMCP
from fastmcp.tools.tool import Tool, _convert_to_content
from fastmcp.utilities import openapi
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.openapi import (
HTTPRoute,
_combine_schemas,
format_description_with_responses,
)
@ -33,13 +37,69 @@ logger = get_logger(__name__)
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
class RouteType(enum.Enum):
"""Type of FastMCP component to create from a route."""
def _slugify(text: str) -> str:
"""
Convert text to a URL-friendly slug format that only contains lowercase
letters, uppercase letters, numbers, and underscores.
"""
if not text:
return ""
# Replace spaces and common separators with underscores
slug = re.sub(r"[\s\-\.]+", "_", text)
# Remove non-alphanumeric characters except underscores
slug = re.sub(r"[^a-zA-Z0-9_]", "", slug)
# Remove multiple consecutive underscores
slug = re.sub(r"_+", "_", slug)
# Remove leading/trailing underscores
slug = slug.strip("_")
return slug
# Type definitions for the mapping functions
RouteMapFn = Callable[[HTTPRoute, "MCPType"], "MCPType | None"]
ComponentFn = Callable[
[
HTTPRoute,
"OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate",
],
None,
]
class MCPType(enum.Enum):
"""Type of FastMCP component to create from a route.
Enum values:
TOOL: Convert the route to a callable Tool
RESOURCE: Convert the route to a Resource (typically GET endpoints)
RESOURCE_TEMPLATE: Convert the route to a ResourceTemplate (typically GET with path params)
EXCLUDE: Exclude the route from being converted to any MCP component
IGNORE: Deprecated, use EXCLUDE instead
"""
TOOL = "TOOL"
RESOURCE = "RESOURCE"
RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE"
# PROMPT = "PROMPT"
EXCLUDE = "EXCLUDE"
# Keep RouteType as an alias to MCPType for backward compatibility
class RouteType(enum.Enum):
"""
Deprecated: Use MCPType instead.
This enum is kept for backward compatibility and will be removed in a future version.
"""
TOOL = "TOOL"
RESOURCE = "RESOURCE"
RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE"
PROMPT = "PROMPT"
IGNORE = "IGNORE"
@ -47,32 +107,71 @@ class RouteType(enum.Enum):
class RouteMap:
"""Mapping configuration for HTTP routes to FastMCP component types."""
methods: list[HttpMethod]
pattern: Pattern[str] | str
route_type: RouteType
methods: list[HttpMethod] | Literal["*"] = field(default="*")
pattern: Pattern[str] | str = field(default=r".*")
mcp_type: MCPType | None = field(default=None)
route_type: RouteType | MCPType | None = field(default=None)
tags: set[str] = field(default_factory=set)
def __post_init__(self):
"""Validate and process the route map after initialization."""
# Handle backward compatibility for route_type, deprecated in 2.5.0
if self.mcp_type is None and self.route_type is not None:
warnings.warn(
"The 'route_type' parameter is deprecated and will be removed in a future version. "
"Use 'mcp_type' instead with the appropriate MCPType value.",
DeprecationWarning,
stacklevel=2,
)
if isinstance(self.route_type, RouteType):
warnings.warn(
"The RouteType class is deprecated and will be removed in a future version. "
"Use MCPType instead.",
DeprecationWarning,
stacklevel=2,
)
# Check for the deprecated IGNORE value
if self.route_type == RouteType.IGNORE:
warnings.warn(
"RouteType.IGNORE is deprecated and will be removed in a future version. "
"Use MCPType.EXCLUDE instead.",
DeprecationWarning,
stacklevel=2,
)
# Convert from RouteType to MCPType if needed
if isinstance(self.route_type, RouteType):
route_type_name = self.route_type.name
if route_type_name == "IGNORE":
route_type_name = "EXCLUDE"
self.mcp_type = getattr(MCPType, route_type_name)
else:
self.mcp_type = self.route_type
elif self.mcp_type is None:
raise ValueError("`mcp_type` must be provided")
# Set route_type to match mcp_type for backward compatibility
if self.route_type is None:
self.route_type = self.mcp_type
# Default route mappings as a list, where order determines priority
DEFAULT_ROUTE_MAPPINGS = [
# GET requests with path parameters go to ResourceTemplate
RouteMap(
methods=["GET"], pattern=r".*\{.*\}.*", route_type=RouteType.RESOURCE_TEMPLATE
methods=["GET"], pattern=r".*\{.*\}.*", mcp_type=MCPType.RESOURCE_TEMPLATE
),
# GET requests without path parameters go to Resource
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE),
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
# All other HTTP methods go to Tool
RouteMap(
methods=["POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
pattern=r".*",
route_type=RouteType.TOOL,
),
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL),
]
def _determine_route_type(
route: openapi.HTTPRoute,
mappings: list[RouteMap],
) -> RouteType:
) -> MCPType:
"""
Determines the FastMCP component type based on the route and mappings.
@ -81,12 +180,12 @@ def _determine_route_type(
mappings: List of RouteMap objects in priority order
Returns:
RouteType for this route
MCPType for this route
"""
# Check mappings in priority order (first match wins)
for route_map in mappings:
# Check if the HTTP method matches
if route.method in route_map.methods:
if route_map.methods == "*" or route.method in route_map.methods:
# Handle both string patterns and compiled Pattern objects
if isinstance(route_map.pattern, Pattern):
pattern_matches = route_map.pattern.search(route.path)
@ -94,20 +193,24 @@ def _determine_route_type(
pattern_matches = re.search(route_map.pattern, route.path)
if pattern_matches:
# Check if tags match (if specified)
# If route_map.tags is empty, tags are not matched
# If route_map.tags is non-empty, all tags must be present in route.tags (AND condition)
if route_map.tags:
route_tags_set = set(route.tags or [])
if not route_map.tags.issubset(route_tags_set):
# Tags don't match, continue to next mapping
continue
# We know mcp_type is not None here due to post_init validation
assert route_map.mcp_type is not None
logger.debug(
f"Route {route.method} {route.path} matched mapping to {route_map.route_type.name}"
f"Route {route.method} {route.path} matched mapping to {route_map.mcp_type.name}"
)
return route_map.route_type
return route_map.mcp_type
# Default fallback
return RouteType.TOOL
# Placeholder function to provide function metadata
async def _openapi_passthrough(*args, **kwargs):
"""Placeholder function for OpenAPI endpoints."""
# This is kept for metadata generation purposes
pass
return MCPType.TOOL
class OpenAPITool(Tool):
@ -171,27 +274,138 @@ class OpenAPITool(Tool):
raise ToolError(f"Missing required path parameters: {missing_params}")
for param_name, param_value in path_params.items():
# Handle array path parameters with style 'simple' (comma-separated)
# In OpenAPI, 'simple' is the default style for path parameters
param_info = next(
(p for p in self._route.parameters if p.name == param_name), None
)
if param_info and isinstance(param_value, list):
# Check if schema indicates an array type
schema = param_info.schema_
is_array = schema.get("type") == "array"
if is_array:
# Format array values as comma-separated string
# This follows the OpenAPI 'simple' style (default for path)
if all(
isinstance(item, str | int | float | bool)
for item in param_value
):
# Handle simple array types
path = path.replace(
f"{{{param_name}}}", ",".join(str(v) for v in param_value)
)
else:
# Handle complex array types (containing objects/dicts)
try:
# Try to create a simple representation without Python syntax artifacts
formatted_parts = []
for item in param_value:
if isinstance(item, dict):
# For objects, serialize key-value pairs
item_parts = []
for k, v in item.items():
item_parts.append(f"{k}:{v}")
formatted_parts.append(".".join(item_parts))
else:
# Fallback for other complex types
formatted_parts.append(str(item))
# Join parts with commas
formatted_value = ",".join(formatted_parts)
path = path.replace(f"{{{param_name}}}", formatted_value)
except Exception as e:
logger.warning(
f"Failed to format complex array path parameter '{param_name}': {e}"
)
# Fallback to string representation, but remove Python syntax artifacts
str_value = (
str(param_value)
.replace("[", "")
.replace("]", "")
.replace("'", "")
.replace('"', "")
)
path = path.replace(f"{{{param_name}}}", str_value)
continue
# Default handling for non-array parameters or non-array schemas
path = path.replace(f"{{{param_name}}}", str(param_value))
# Prepare query parameters - filter out None and empty strings
query_params = {
p.name: kwargs.get(p.name)
for p in self._route.parameters
if p.location == "query"
and p.name in kwargs
and kwargs.get(p.name) is not None
and kwargs.get(p.name) != ""
}
query_params = {}
for p in self._route.parameters:
if (
p.location == "query"
and p.name in kwargs
and kwargs.get(p.name) is not None
and kwargs.get(p.name) != ""
):
param_value = kwargs.get(p.name)
# Format array query parameters as comma-separated strings
# following OpenAPI form style (default for query parameters)
if isinstance(param_value, list) and p.schema_.get("type") == "array":
# Get explode parameter from schema, default is True for query parameters
# If explode is True, the array is serialized as separate parameters
# If explode is False, the array is serialized as a comma-separated string
explode = p.schema_.get("explode", True)
if explode:
# When explode=True, we pass the array directly, which HTTPX will serialize
# as multiple parameters with the same name
query_params[p.name] = param_value
else:
# For arrays of simple types (strings, numbers, etc.), join with commas
if all(
isinstance(item, str | int | float | bool)
for item in param_value
):
query_params[p.name] = ",".join(str(v) for v in param_value)
else:
# For complex types, try to create a simpler representation
try:
# Try to create a simple string representation
formatted_parts = []
for item in param_value:
if isinstance(item, dict):
# For objects, serialize key-value pairs
item_parts = []
for k, v in item.items():
item_parts.append(f"{k}:{v}")
formatted_parts.append(".".join(item_parts))
else:
formatted_parts.append(str(item))
query_params[p.name] = ",".join(formatted_parts)
except Exception as e:
logger.warning(
f"Failed to format complex array query parameter '{p.name}': {e}"
)
# Fallback to string representation
query_params[p.name] = param_value
else:
# Non-array parameters are passed as is
query_params[p.name] = param_value
# Prepare headers - fix typing by ensuring all values are strings
headers = {}
# Start with OpenAPI-defined header parameters
openapi_headers = {}
for p in self._route.parameters:
if (
p.location == "header"
and p.name in kwargs
and kwargs[p.name] is not None
):
headers[p.name] = str(kwargs[p.name])
openapi_headers[p.name.lower()] = str(kwargs[p.name])
headers.update(openapi_headers)
# Add headers from the current MCP client HTTP request (these take precedence)
mcp_headers = get_http_headers()
headers.update(mcp_headers)
# Prepare request body
json_data = None
@ -339,10 +553,16 @@ class OpenAPIResource(Resource):
if value is not None and value != "":
query_params[param.name] = value
# Prepare headers from MCP client request if available
headers = {}
mcp_headers = get_http_headers()
headers.update(mcp_headers)
response = await self._client.request(
method=self._route.method,
url=path,
params=query_params,
headers=headers,
timeout=self._timeout,
)
@ -443,7 +663,7 @@ class FastMCPOpenAPI(FastMCP):
Example:
```python
from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap, RouteType
from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap, MCPType
import httpx
# Define custom route mappings
@ -452,17 +672,17 @@ class FastMCPOpenAPI(FastMCP):
RouteMap(
methods=["GET", "POST", "PATCH"],
pattern=r".*/users/.*",
route_type=RouteType.RESOURCE_TEMPLATE
mcp_type=MCPType.RESOURCE_TEMPLATE
),
# Map all analytics endpoints to Tool
RouteMap(
methods=["GET"],
pattern=r".*/analytics/.*",
route_type=RouteType.TOOL
mcp_type=MCPType.TOOL
),
]
# Create server with custom mappings
# Create server with custom mappings and route mapper
server = FastMCPOpenAPI(
openapi_spec=spec,
client=httpx.AsyncClient(),
@ -478,6 +698,9 @@ class FastMCPOpenAPI(FastMCP):
client: httpx.AsyncClient,
name: str | None = None,
route_maps: list[RouteMap] | None = None,
route_map_fn: RouteMapFn | None = None,
mcp_component_fn: ComponentFn | None = None,
mcp_names: dict[str, str] | None = None,
timeout: float | None = None,
**settings: Any,
):
@ -489,6 +712,17 @@ class FastMCPOpenAPI(FastMCP):
client: httpx AsyncClient for making HTTP requests
name: Optional name for the server
route_maps: Optional list of RouteMap objects defining route mappings
route_map_fn: Optional callable for advanced route type mapping.
Receives (route, mcp_type) and returns MCPType or None.
Called on every route, including excluded ones.
mcp_component_fn: Optional callable for component customization.
Receives (route, component) and can modify the component in-place.
Called on every created component.
mcp_names: Optional dictionary mapping operationId to desired component names.
If an operationId is not in the dictionary, falls back to using the
operationId up to the first double underscore. If no operationId exists,
falls back to slugified summary or path-based naming.
All names are truncated to 56 characters maximum.
timeout: Optional timeout (in seconds) for all requests
**settings: Additional settings for FastMCP
"""
@ -496,6 +730,18 @@ class FastMCPOpenAPI(FastMCP):
self._client = client
self._timeout = timeout
self._route_map_fn = route_map_fn
self._mcp_component_fn = mcp_component_fn
self._mcp_names = mcp_names or {}
# Keep track of names to detect collisions
self._used_names = {
"tool": Counter(),
"resource": Counter(),
"resource_template": Counter(),
"prompt": Counter(),
}
http_routes = openapi.parse_openapi_to_http_routes(openapi_spec)
# Process routes
@ -504,34 +750,97 @@ class FastMCPOpenAPI(FastMCP):
# Determine route type based on mappings or default rules
route_type = _determine_route_type(route, route_maps)
# Use operation_id if available, otherwise generate a name
operation_id = route.operation_id
if not operation_id:
# Generate operation ID from method and path
path_parts = route.path.strip("/").split("/")
path_name = "_".join(p for p in path_parts if not p.startswith("{"))
operation_id = f"{route.method.lower()}_{path_name}"
# Call route_map_fn if provided
if self._route_map_fn is not None:
try:
result = self._route_map_fn(route, route_type)
if result is not None:
route_type = result
logger.debug(
f"Route {route.method} {route.path} mapping customized by route_map_fn: "
f"type={route_type.name}"
)
except Exception as e:
logger.warning(
f"Error in route_map_fn for {route.method} {route.path}: {e}. "
f"Using default values."
)
if route_type == RouteType.TOOL:
self._create_openapi_tool(route, operation_id)
elif route_type == RouteType.RESOURCE:
self._create_openapi_resource(route, operation_id)
elif route_type == RouteType.RESOURCE_TEMPLATE:
self._create_openapi_template(route, operation_id)
elif route_type == RouteType.PROMPT:
# Not implemented yet
logger.warning(
f"PROMPT route type not implemented: {route.method} {route.path}"
)
elif route_type == RouteType.IGNORE:
logger.info(f"Ignoring route: {route.method} {route.path}")
# Generate a default name from the route
component_name = self._generate_default_name(route, route_type)
if route_type == MCPType.TOOL:
self._create_openapi_tool(route, component_name)
elif route_type == MCPType.RESOURCE:
self._create_openapi_resource(route, component_name)
elif route_type == MCPType.RESOURCE_TEMPLATE:
self._create_openapi_template(route, component_name)
elif route_type == MCPType.EXCLUDE:
logger.info(f"Excluding route: {route.method} {route.path}")
logger.info(f"Created FastMCP OpenAPI server with {len(http_routes)} routes")
def _create_openapi_tool(self, route: openapi.HTTPRoute, operation_id: str):
def _generate_default_name(
self, route: openapi.HTTPRoute, mcp_type: MCPType
) -> str:
"""Generate a default name from the route using the configured strategy."""
name = ""
# First check if there's a custom mapping for this operationId
if route.operation_id:
if route.operation_id in self._mcp_names:
name = self._mcp_names[route.operation_id]
else:
# If there's a double underscore in the operationId, use the first part
name = route.operation_id.split("__")[0]
else:
name = route.summary or f"{route.method}_{route.path}"
name = _slugify(name)
# Truncate to 56 characters maximum
if len(name) > 56:
name = name[:56]
return name
def _get_unique_name(
self,
name: str,
component_type: Literal["tool", "resource", "resource_template", "prompt"],
) -> str:
"""
Ensure the name is unique within its component type by appending numbers if needed.
Args:
name: The proposed name
component_type: The type of component ("tools", "resources", or "templates")
Returns:
str: A unique name for the component
"""
# Check if the name is already used
self._used_names[component_type][name] += 1
if self._used_names[component_type][name] == 1:
return name
else:
# Create the new name
new_name = f"{name}_{self._used_names[component_type][name]}"
logger.debug(
f"Name collision detected: '{name}' already exists as a {component_type[:-1]}. "
f"Using '{new_name}' instead."
)
return new_name
def _create_openapi_tool(self, route: openapi.HTTPRoute, name: str):
"""Creates and registers an OpenAPITool with enhanced description."""
combined_schema = _combine_schemas(route)
tool_name = operation_id
# Get a unique tool name
tool_name = self._get_unique_name(name, "tool")
base_description = (
route.description
or route.summary
@ -555,16 +864,30 @@ class FastMCPOpenAPI(FastMCP):
tags=set(route.tags or []),
timeout=self._timeout,
)
# Call component_fn if provided
if self._mcp_component_fn is not None:
try:
self._mcp_component_fn(route, tool)
logger.debug(f"Tool {tool_name} customized by component_fn")
except Exception as e:
logger.warning(
f"Error in component_fn for tool {tool_name}: {e}. "
f"Using component as-is."
)
# Register the tool by directly assigning to the tools dictionary
self._tool_manager._tools[tool_name] = tool
logger.debug(
f"Registered TOOL: {tool_name} ({route.method} {route.path}) with tags: {route.tags}"
)
def _create_openapi_resource(self, route: openapi.HTTPRoute, operation_id: str):
def _create_openapi_resource(self, route: openapi.HTTPRoute, name: str):
"""Creates and registers an OpenAPIResource with enhanced description."""
resource_name = operation_id
resource_uri = f"resource://openapi/{resource_name}"
# Get a unique resource name
resource_name = self._get_unique_name(name, "resource")
resource_uri = f"resource://{resource_name}"
base_description = (
route.description or route.summary or f"Represents {route.path}"
)
@ -586,19 +909,33 @@ class FastMCPOpenAPI(FastMCP):
tags=set(route.tags or []),
timeout=self._timeout,
)
# Call component_fn if provided
if self._mcp_component_fn is not None:
try:
self._mcp_component_fn(route, resource)
logger.debug(f"Resource {resource_uri} customized by component_fn")
except Exception as e:
logger.warning(
f"Error in component_fn for resource {resource_uri}: {e}. "
f"Using component as-is."
)
# Register the resource by directly assigning to the resources dictionary
self._resource_manager._resources[str(resource.uri)] = resource
logger.debug(
f"Registered RESOURCE: {resource_uri} ({route.method} {route.path}) with tags: {route.tags}"
)
def _create_openapi_template(self, route: openapi.HTTPRoute, operation_id: str):
def _create_openapi_template(self, route: openapi.HTTPRoute, name: str):
"""Creates and registers an OpenAPIResourceTemplate with enhanced description."""
template_name = operation_id
# Get a unique template name
template_name = self._get_unique_name(name, "resource_template")
path_params = [p.name for p in route.parameters if p.location == "path"]
path_params.sort() # Sort for consistent URIs
uri_template_str = f"resource://openapi/{template_name}"
uri_template_str = f"resource://{template_name}"
if path_params:
uri_template_str += "/" + "/".join(f"{{{p}}}" for p in path_params)
@ -646,13 +983,20 @@ class FastMCPOpenAPI(FastMCP):
tags=set(route.tags or []),
timeout=self._timeout,
)
# Call component_fn if provided
if self._mcp_component_fn is not None:
try:
self._mcp_component_fn(route, template)
logger.debug(f"Template {uri_template_str} customized by component_fn")
except Exception as e:
logger.warning(
f"Error in component_fn for template {uri_template_str}: {e}. "
f"Using component as-is."
)
# Register the template by directly assigning to the templates dictionary
self._resource_manager._templates[uri_template_str] = template
logger.debug(
f"Registered TEMPLATE: {uri_template_str} ({route.method} {route.path}) with tags: {route.tags}"
)
async def _mcp_call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
"""Override the call_tool method to return the raw result without converting to content."""
result = await self._tool_manager.call_tool(name, arguments)
return result

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import datetime
import re
import warnings
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import (
@ -11,11 +12,11 @@ from contextlib import (
asynccontextmanager,
)
from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING, Any, Generic, Literal
import anyio
import httpx
import pydantic
import uvicorn
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
from mcp.server.lowlevel.helper_types import ReadResourceContents
@ -57,15 +58,22 @@ from fastmcp.tools.tool import Tool
from fastmcp.utilities.cache import TimedCache
from fastmcp.utilities.decorators import DecoratedFunction
from fastmcp.utilities.logging import get_logger
from fastmcp.utilities.mcp_config import MCPConfig
if TYPE_CHECKING:
from fastmcp.client import Client
from fastmcp.server.openapi import FastMCPOpenAPI
from fastmcp.client.transports import ClientTransport, ClientTransportT
from fastmcp.server.openapi import ComponentFn as OpenAPIComponentFn
from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap
from fastmcp.server.openapi import RouteMapFn as OpenAPIRouteMapFn
from fastmcp.server.proxy import FastMCPProxy
logger = get_logger(__name__)
DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
# Compiled URI parsing regex to split a URI into protocol and path components
URI_PATTERN = re.compile(r"^([^:]+://)(.*?)$")
@asynccontextmanager
async def default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any]:
@ -118,6 +126,8 @@ class FastMCP(Generic[LifespanResultT]):
on_duplicate_tools: DuplicateBehavior | None = None,
on_duplicate_resources: DuplicateBehavior | None = None,
on_duplicate_prompts: DuplicateBehavior | None = None,
resource_prefix_format: Literal["protocol", "path"] | None = None,
mask_error_details: bool | None = None,
**settings: Any,
):
if settings:
@ -132,6 +142,18 @@ class FastMCP(Generic[LifespanResultT]):
)
self.settings = fastmcp.settings.ServerSettings(**settings)
# If mask_error_details is provided, override the settings value
if mask_error_details is not None:
self.settings.mask_error_details = mask_error_details
self.resource_prefix_format: Literal["protocol", "path"]
if resource_prefix_format is None:
self.resource_prefix_format = (
fastmcp.settings.settings.resource_prefix_format
)
else:
self.resource_prefix_format = resource_prefix_format
self.tags: set[str] = tags or set()
self.dependencies = dependencies
self._cache = TimedCache(
@ -142,11 +164,16 @@ class FastMCP(Generic[LifespanResultT]):
self._tool_manager = ToolManager(
duplicate_behavior=on_duplicate_tools,
serializer=tool_serializer,
mask_error_details=self.settings.mask_error_details,
)
self._resource_manager = ResourceManager(
duplicate_behavior=on_duplicate_resources
duplicate_behavior=on_duplicate_resources,
mask_error_details=self.settings.mask_error_details,
)
self._prompt_manager = PromptManager(
duplicate_behavior=on_duplicate_prompts,
mask_error_details=self.settings.mask_error_details,
)
self._prompt_manager = PromptManager(duplicate_behavior=on_duplicate_prompts)
if lifespan is None:
self._has_lifespan = False
@ -213,7 +240,6 @@ class FastMCP(Generic[LifespanResultT]):
Args:
transport: Transport protocol to use ("stdio", "sse", or "streamable-http")
"""
logger.info(f'Starting server "{self.name}"...')
anyio.run(partial(self.run_async, transport, **transport_kwargs))
@ -231,9 +257,15 @@ class FastMCP(Generic[LifespanResultT]):
"""Get all registered tools, indexed by registered key."""
if (tools := self._cache.get("tools")) is self._cache.NOT_FOUND:
tools: dict[str, Tool] = {}
for server in self._mounted_servers.values():
server_tools = await server.get_tools()
tools.update(server_tools)
for prefix, server in self._mounted_servers.items():
try:
server_tools = await server.get_tools()
tools.update(server_tools)
except Exception as e:
logger.warning(
f"Failed to get tools from mounted server '{prefix}': {e}"
)
continue
tools.update(self._tool_manager.get_tools())
self._cache.set("tools", tools)
return tools
@ -242,9 +274,15 @@ class FastMCP(Generic[LifespanResultT]):
"""Get all registered resources, indexed by registered key."""
if (resources := self._cache.get("resources")) is self._cache.NOT_FOUND:
resources: dict[str, Resource] = {}
for server in self._mounted_servers.values():
server_resources = await server.get_resources()
resources.update(server_resources)
for prefix, server in self._mounted_servers.items():
try:
server_resources = await server.get_resources()
resources.update(server_resources)
except Exception as e:
logger.warning(
f"Failed to get resources from mounted server '{prefix}': {e}"
)
continue
resources.update(self._resource_manager.get_resources())
self._cache.set("resources", resources)
return resources
@ -255,9 +293,16 @@ class FastMCP(Generic[LifespanResultT]):
templates := self._cache.get("resource_templates")
) is self._cache.NOT_FOUND:
templates: dict[str, ResourceTemplate] = {}
for server in self._mounted_servers.values():
server_templates = await server.get_resource_templates()
templates.update(server_templates)
for prefix, server in self._mounted_servers.items():
try:
server_templates = await server.get_resource_templates()
templates.update(server_templates)
except Exception as e:
logger.warning(
"Failed to get resource templates from mounted server "
f"'{prefix}': {e}"
)
continue
templates.update(self._resource_manager.get_templates())
self._cache.set("resource_templates", templates)
return templates
@ -268,9 +313,15 @@ class FastMCP(Generic[LifespanResultT]):
"""
if (prompts := self._cache.get("prompts")) is self._cache.NOT_FOUND:
prompts: dict[str, Prompt] = {}
for server in self._mounted_servers.values():
server_prompts = await server.get_prompts()
prompts.update(server_prompts)
for prefix, server in self._mounted_servers.items():
try:
server_prompts = await server.get_prompts()
prompts.update(server_prompts)
except Exception as e:
logger.warning(
f"Failed to get prompts from mounted server '{prefix}': {e}"
)
continue
prompts.update(self._prompt_manager.get_prompts())
self._cache.set("prompts", prompts)
return prompts
@ -363,21 +414,30 @@ class FastMCP(Generic[LifespanResultT]):
async def _mcp_call_tool(
self, key: str, arguments: dict[str, Any]
) -> list[TextContent | ImageContent | EmbeddedResource]:
"""Call a tool by name with arguments."""
"""Handle MCP 'callTool' requests.
Args:
key: The name of the tool to call
arguments: Arguments to pass to the tool
Returns:
List of MCP Content objects containing the tool results
"""
logger.debug("Call tool: %s with %s", key, arguments)
# Create and use context for the entire call
with fastmcp.server.context.Context(fastmcp=self):
# Get tool, checking first from our tools, then from the mounted servers
if self._tool_manager.has_tool(key):
result = await self._tool_manager.call_tool(key, arguments)
return await self._tool_manager.call_tool(key, arguments)
else:
for server in self._mounted_servers.values():
if server.match_tool(key):
new_key = server.strip_tool_prefix(key)
result = await server.server._mcp_call_tool(new_key, arguments)
break
else:
raise NotFoundError(f"Unknown tool: {key}")
return result
# Check mounted servers to see if they have the tool
for server in self._mounted_servers.values():
if server.match_tool(key):
tool_key = server.strip_tool_prefix(key)
return await server.server._mcp_call_tool(tool_key, arguments)
raise NotFoundError(f"Unknown tool: {key}")
async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
"""
@ -405,24 +465,30 @@ class FastMCP(Generic[LifespanResultT]):
async def _mcp_get_prompt(
self, name: str, arguments: dict[str, Any] | None = None
) -> GetPromptResult:
"""
Get a prompt by name with arguments, in the format expected by the low-level
MCP server.
"""Handle MCP 'getPrompt' requests.
Args:
name: The name of the prompt to render
arguments: Arguments to pass to the prompt
Returns:
GetPromptResult containing the rendered prompt messages
"""
logger.debug("Get prompt: %s with %s", name, arguments)
# Create and use context for the entire call
with fastmcp.server.context.Context(fastmcp=self):
# Get prompt, checking first from our prompts, then from the mounted servers
if self._prompt_manager.has_prompt(name):
prompt_result = await self._prompt_manager.render_prompt(
name, arguments=arguments or {}
)
return prompt_result
else:
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)
else:
raise NotFoundError(f"Unknown prompt: {name}")
return await self._prompt_manager.render_prompt(name, arguments)
# Check mounted servers to see if they have the prompt
for server in self._mounted_servers.values():
if server.match_prompt(name):
prompt_name = server.strip_prompt_prefix(name)
return await server.server._mcp_get_prompt(prompt_name, arguments)
raise NotFoundError(f"Unknown prompt: {name}")
def add_tool(
self,
@ -730,6 +796,7 @@ class FastMCP(Generic[LifespanResultT]):
async def run_stdio_async(self) -> None:
"""Run the server using stdio transport."""
async with stdio_server() as (read_stream, write_stream):
logger.info(f"Starting MCP server {self.name!r} with transport 'stdio'")
await self._mcp_server.run(
read_stream,
write_stream,
@ -758,21 +825,29 @@ class FastMCP(Generic[LifespanResultT]):
path: Path for the endpoint (defaults to settings.streamable_http_path or settings.sse_path)
uvicorn_config: Additional configuration for the Uvicorn server
"""
uvicorn_config = uvicorn_config or {}
uvicorn_config.setdefault("timeout_graceful_shutdown", 0)
# lifespan is required for streamable http
uvicorn_config["lifespan"] = "on"
host = host or self.settings.host
port = port or self.settings.port
default_log_level_to_use = (log_level or self.settings.log_level).lower()
app = self.http_app(path=path, transport=transport, middleware=middleware)
config = uvicorn.Config(
app,
host=host or self.settings.host,
port=port or self.settings.port,
log_level=log_level or self.settings.log_level.lower(),
**uvicorn_config,
)
_uvicorn_config_from_user = uvicorn_config or {}
config_kwargs: dict[str, Any] = {
"timeout_graceful_shutdown": 0,
"lifespan": "on",
}
config_kwargs.update(_uvicorn_config_from_user)
if "log_config" not in config_kwargs and "log_level" not in config_kwargs:
config_kwargs["log_level"] = default_log_level_to_use
config = uvicorn.Config(app, host=host, port=port, **config_kwargs)
server = uvicorn.Server(config)
path = app.state.path.lstrip("/") # type: ignore
logger.info(
f"Starting MCP server {self.name!r} with transport {transport!r} on http://{host}:{port}/{path}"
)
await server.serve()
async def run_sse_async(
@ -831,7 +906,6 @@ class FastMCP(Generic[LifespanResultT]):
auth_server_provider=self._auth_server_provider,
auth_settings=self.settings.auth,
debug=self.settings.debug,
routes=self._additional_http_routes,
middleware=middleware,
)
@ -882,7 +956,6 @@ class FastMCP(Generic[LifespanResultT]):
json_response=self.settings.json_response,
stateless_http=self.settings.stateless_http,
debug=self.settings.debug,
routes=self._additional_http_routes,
middleware=middleware,
)
elif transport == "sse":
@ -893,7 +966,6 @@ class FastMCP(Generic[LifespanResultT]):
auth_server_provider=self._auth_server_provider,
auth_settings=self.settings.auth,
debug=self.settings.debug,
routes=self._additional_http_routes,
middleware=middleware,
)
@ -925,10 +997,11 @@ class FastMCP(Generic[LifespanResultT]):
self,
prefix: str,
server: FastMCP[LifespanResultT],
as_proxy: bool | None = None,
*,
tool_separator: str | None = None,
resource_separator: str | None = None,
prompt_separator: str | None = None,
as_proxy: bool | None = None,
) -> None:
"""Mount another FastMCP server on this server with the given prefix.
@ -939,15 +1012,15 @@ class FastMCP(Generic[LifespanResultT]):
through the parent.
When a server is mounted:
- Tools from the mounted server are accessible with prefixed names using the tool_separator.
- Tools from the mounted server are accessible with prefixed names.
Example: If server has a tool named "get_weather", it will be available as "prefix_get_weather".
- Resources are accessible with prefixed URIs using the resource_separator.
- Resources are accessible with prefixed URIs.
Example: If server has a resource with URI "weather://forecast", it will be available as
"prefix+weather://forecast".
- Templates are accessible with prefixed URI templates using the resource_separator.
"weather://prefix/forecast".
- Templates are accessible with prefixed URI templates.
Example: If server has a template with URI "weather://location/{id}", it will be available
as "prefix+weather://location/{id}".
- Prompts are accessible with prefixed names using the prompt_separator.
as "weather://prefix/location/{id}".
- Prompts are accessible with prefixed names.
Example: If server has a prompt named "weather_prompt", it will be available as
"prefix_weather_prompt".
@ -965,17 +1038,44 @@ class FastMCP(Generic[LifespanResultT]):
Args:
prefix: Prefix to use for the mounted server's objects.
server: The FastMCP server to mount.
tool_separator: Separator character for tool names (defaults to "_").
resource_separator: Separator character for resource URIs (defaults to "+").
prompt_separator: Separator character for prompt names (defaults to "_").
as_proxy: Whether to treat the mounted server as a proxy. If None (default),
automatically determined based on whether the server has a custom lifespan
(True if it has a custom lifespan, False otherwise).
tool_separator: Deprecated. Separator character for tool names.
resource_separator: Deprecated. Separator character for resource URIs.
prompt_separator: Deprecated. Separator character for prompt names.
"""
from fastmcp import Client
from fastmcp.client.transports import FastMCPTransport
from fastmcp.server.proxy import FastMCPProxy
if tool_separator is not None:
# Deprecated since 2.4.0
warnings.warn(
"The tool_separator parameter is deprecated and will be removed in a future version. "
"Tools are now prefixed using 'prefix_toolname' format.",
DeprecationWarning,
stacklevel=2,
)
if resource_separator is not None:
# Deprecated since 2.4.0
warnings.warn(
"The resource_separator parameter is deprecated and ignored. "
"Resource prefixes are now added using the protocol://prefix/path format.",
DeprecationWarning,
stacklevel=2,
)
if prompt_separator is not None:
# Deprecated since 2.4.0
warnings.warn(
"The prompt_separator parameter is deprecated and will be removed in a future version. "
"Prompts are now prefixed using 'prefix_promptname' format.",
DeprecationWarning,
stacklevel=2,
)
# if as_proxy is not specified and the server has a custom lifespan,
# we should treat it as a proxy
if as_proxy is None:
@ -987,9 +1087,6 @@ class FastMCP(Generic[LifespanResultT]):
mounted_server = MountedServer(
server=server,
prefix=prefix,
tool_separator=tool_separator,
resource_separator=resource_separator,
prompt_separator=prompt_separator,
)
self._mounted_servers[prefix] = mounted_server
self._cache.clear()
@ -1015,183 +1112,439 @@ class FastMCP(Generic[LifespanResultT]):
future changes to the imported server will not be reflected in the
importing server. Server-level configurations and lifespans are not imported.
When a server is mounted: - The tools are imported with prefixed names
using the tool_separator
When a server is imported:
- The tools are imported with prefixed names
Example: If server has a tool named "get_weather", it will be
available as "weatherget_weather"
- The resources are imported with prefixed URIs using the
resource_separator Example: If server has a resource with URI
"weather://forecast", it will be available as
"weather+weather://forecast"
- The templates are imported with prefixed URI templates using the
resource_separator Example: If server has a template with URI
"weather://location/{id}", it will be available as
"weather+weather://location/{id}"
- The prompts are imported with prefixed names using the
prompt_separator Example: If server has a prompt named
"weather_prompt", it will be available as "weather_weather_prompt"
- The mounted server's lifespan will be executed when the parent
server's lifespan runs, ensuring that any setup needed by the mounted
server is performed
available as "prefix_get_weather"
- The resources are imported with prefixed URIs using the new format
Example: If server has a resource with URI "weather://forecast", it will
be available as "weather://prefix/forecast"
- The templates are imported with prefixed URI templates using the new format
Example: If server has a template with URI "weather://location/{id}", it will
be available as "weather://prefix/location/{id}"
- The prompts are imported with prefixed names
Example: If server has a prompt named "weather_prompt", it will be available as
"prefix_weather_prompt"
Args:
prefix: The prefix to use for the mounted server server: The FastMCP
server to mount tool_separator: Separator for tool names (defaults
to "_") resource_separator: Separator for resource URIs (defaults to
"+") prompt_separator: Separator for prompt names (defaults to "_")
prefix: The prefix to use for the imported server
server: The FastMCP server to import
tool_separator: Deprecated. Separator for tool names.
resource_separator: Deprecated and ignored. Prefix is now
applied using the protocol://prefix/path format
prompt_separator: Deprecated. Separator for prompt names.
"""
if tool_separator is None:
tool_separator = "_"
if resource_separator is None:
resource_separator = "+"
if prompt_separator is None:
prompt_separator = "_"
if tool_separator is not None:
# Deprecated since 2.4.0
warnings.warn(
"The tool_separator parameter is deprecated and will be removed in a future version. "
"Tools are now prefixed using 'prefix_toolname' format.",
DeprecationWarning,
stacklevel=2,
)
if resource_separator is not None:
# Deprecated since 2.4.0
warnings.warn(
"The resource_separator parameter is deprecated and ignored. "
"Resource prefixes are now added using the protocol://prefix/path format.",
DeprecationWarning,
stacklevel=2,
)
if prompt_separator is not None:
# Deprecated since 2.4.0
warnings.warn(
"The prompt_separator parameter is deprecated and will be removed in a future version. "
"Prompts are now prefixed using 'prefix_promptname' format.",
DeprecationWarning,
stacklevel=2,
)
# Import tools from the mounted server
tool_prefix = f"{prefix}{tool_separator}"
tool_prefix = f"{prefix}_"
for key, tool in (await server.get_tools()).items():
self._tool_manager.add_tool(tool, key=f"{tool_prefix}{key}")
# Import resources and templates from the mounted server
resource_prefix = f"{prefix}{resource_separator}"
_validate_resource_prefix(resource_prefix)
for key, resource in (await server.get_resources()).items():
self._resource_manager.add_resource(resource, key=f"{resource_prefix}{key}")
prefixed_key = add_resource_prefix(key, prefix, self.resource_prefix_format)
self._resource_manager.add_resource(resource, key=prefixed_key)
for key, template in (await server.get_resource_templates()).items():
self._resource_manager.add_template(template, key=f"{resource_prefix}{key}")
prefixed_key = add_resource_prefix(key, prefix, self.resource_prefix_format)
self._resource_manager.add_template(template, key=prefixed_key)
# Import prompts from the mounted server
prompt_prefix = f"{prefix}{prompt_separator}"
prompt_prefix = f"{prefix}_"
for key, prompt in (await server.get_prompts()).items():
self._prompt_manager.add_prompt(prompt, key=f"{prompt_prefix}{key}")
logger.info(f"Imported server {server.name} with prefix '{prefix}'")
logger.debug(f"Imported tools with prefix '{tool_prefix}'")
logger.debug(f"Imported resources with prefix '{resource_prefix}'")
logger.debug(f"Imported templates with prefix '{resource_prefix}'")
logger.debug(f"Imported resources and templates with prefix '{prefix}/'")
logger.debug(f"Imported prompts with prefix '{prompt_prefix}'")
self._cache.clear()
@classmethod
def from_openapi(
cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, **settings: Any
cls,
openapi_spec: dict[str, Any],
client: httpx.AsyncClient,
route_maps: list[RouteMap] | None = None,
route_map_fn: OpenAPIRouteMapFn | None = None,
mcp_component_fn: OpenAPIComponentFn | None = None,
mcp_names: dict[str, str] | None = None,
all_routes_as_tools: bool = False,
**settings: Any,
) -> FastMCPOpenAPI:
"""
Create a FastMCP server from an OpenAPI specification.
"""
from .openapi import FastMCPOpenAPI
from .openapi import FastMCPOpenAPI, MCPType, RouteMap
return FastMCPOpenAPI(openapi_spec=openapi_spec, client=client, **settings)
# Deprecated since 2.5.0
if all_routes_as_tools:
warnings.warn(
"The 'all_routes_as_tools' parameter is deprecated and will be removed in a future version. "
'Use \'route_maps=[RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]\' instead.',
DeprecationWarning,
stacklevel=2,
)
if all_routes_as_tools and route_maps:
raise ValueError("Cannot specify both all_routes_as_tools and route_maps")
elif all_routes_as_tools:
route_maps = [RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]
return FastMCPOpenAPI(
openapi_spec=openapi_spec,
client=client,
route_maps=route_maps,
route_map_fn=route_map_fn,
mcp_component_fn=mcp_component_fn,
mcp_names=mcp_names,
**settings,
)
@classmethod
def from_fastapi(
cls, app: Any, name: str | None = None, **settings: Any
cls,
app: Any,
name: str | None = None,
route_maps: list[RouteMap] | None = None,
route_map_fn: OpenAPIRouteMapFn | None = None,
mcp_component_fn: OpenAPIComponentFn | None = None,
mcp_names: dict[str, str] | None = None,
all_routes_as_tools: bool = False,
httpx_client_kwargs: dict[str, Any] | None = None,
**settings: Any,
) -> FastMCPOpenAPI:
"""
Create a FastMCP server from a FastAPI application.
"""
from .openapi import FastMCPOpenAPI
from .openapi import FastMCPOpenAPI, MCPType, RouteMap
# Deprecated since 2.5.0
if all_routes_as_tools:
warnings.warn(
"The 'all_routes_as_tools' parameter is deprecated and will be removed in a future version. "
'Use \'route_maps=[RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]\' instead.',
DeprecationWarning,
stacklevel=2,
)
if all_routes_as_tools and route_maps:
raise ValueError("Cannot specify both all_routes_as_tools and route_maps")
elif all_routes_as_tools:
route_maps = [RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]
if httpx_client_kwargs is None:
httpx_client_kwargs = {}
httpx_client_kwargs.setdefault("base_url", "http://fastapi")
client = httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://fastapi"
transport=httpx.ASGITransport(app=app),
**httpx_client_kwargs,
)
name = name or app.title
return FastMCPOpenAPI(
openapi_spec=app.openapi(), client=client, name=name, **settings
openapi_spec=app.openapi(),
client=client,
name=name,
route_maps=route_maps,
route_map_fn=route_map_fn,
mcp_component_fn=mcp_component_fn,
mcp_names=mcp_names,
**settings,
)
@classmethod
def from_client(cls, client: Client, **settings: Any) -> FastMCPProxy:
"""
Create a FastMCP proxy server from a FastMCP client.
def as_proxy(
cls,
backend: Client[ClientTransportT]
| ClientTransport
| FastMCP[Any]
| AnyUrl
| Path
| MCPConfig
| dict[str, Any]
| str,
**settings: Any,
) -> FastMCPProxy:
"""Create a FastMCP proxy server for the given backend.
The ``backend`` argument can be either an existing :class:`~fastmcp.client.Client`
instance or any value accepted as the ``transport`` argument of
:class:`~fastmcp.client.Client`. This mirrors the convenience of the
``Client`` constructor.
"""
from fastmcp.client.client import Client
from fastmcp.server.proxy import FastMCPProxy
if isinstance(backend, Client):
client = backend
else:
client = Client(backend)
return FastMCPProxy(client=client, **settings)
def _validate_resource_prefix(prefix: str) -> None:
valid_resource = "resource://path/to/resource"
test_case = f"{prefix}{valid_resource}"
try:
AnyUrl(test_case)
except pydantic.ValidationError as e:
raise ValueError(
"Resource prefix or separator would result in an "
f"invalid resource URI (test case was {test_case!r}): {e}"
@classmethod
def from_client(
cls, client: Client[ClientTransportT], **settings: Any
) -> FastMCPProxy:
"""
Create a FastMCP proxy server from a FastMCP client.
"""
# Deprecated since 2.3.5
warnings.warn(
"FastMCP.from_client() is deprecated; use FastMCP.as_proxy() instead.",
DeprecationWarning,
stacklevel=2,
)
return cls.as_proxy(client, **settings)
class MountedServer:
def __init__(
self,
prefix: str,
server: FastMCP[LifespanResultT],
tool_separator: str | None = None,
resource_separator: str | None = None,
prompt_separator: str | None = None,
):
if tool_separator is None:
tool_separator = "_"
if resource_separator is None:
resource_separator = "+"
if prompt_separator is None:
prompt_separator = "_"
_validate_resource_prefix(f"{prefix}{resource_separator}")
self.server = server
self.prefix = prefix
self.tool_separator = tool_separator
self.resource_separator = resource_separator
self.prompt_separator = prompt_separator
async def get_tools(self) -> dict[str, Tool]:
tools = await self.server.get_tools()
return {
f"{self.prefix}{self.tool_separator}{key}": tool
for key, tool in tools.items()
}
return {f"{self.prefix}_{key}": tool for key, tool in tools.items()}
async def get_resources(self) -> dict[str, Resource]:
resources = await self.server.get_resources()
return {
f"{self.prefix}{self.resource_separator}{key}": resource
add_resource_prefix(
key, self.prefix, self.server.resource_prefix_format
): resource
for key, resource in resources.items()
}
async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
templates = await self.server.get_resource_templates()
return {
f"{self.prefix}{self.resource_separator}{key}": template
add_resource_prefix(
key, self.prefix, self.server.resource_prefix_format
): template
for key, template in templates.items()
}
async def get_prompts(self) -> dict[str, Prompt]:
prompts = await self.server.get_prompts()
return {
f"{self.prefix}{self.prompt_separator}{key}": prompt
for key, prompt in prompts.items()
}
return {f"{self.prefix}_{key}": prompt for key, prompt in prompts.items()}
def match_tool(self, key: str) -> bool:
return key.startswith(f"{self.prefix}{self.tool_separator}")
return key.startswith(f"{self.prefix}_")
def strip_tool_prefix(self, key: str) -> str:
return key.removeprefix(f"{self.prefix}{self.tool_separator}")
return key.removeprefix(f"{self.prefix}_")
def match_resource(self, key: str) -> bool:
return key.startswith(f"{self.prefix}{self.resource_separator}")
return has_resource_prefix(key, self.prefix, self.server.resource_prefix_format)
def strip_resource_prefix(self, key: str) -> str:
return key.removeprefix(f"{self.prefix}{self.resource_separator}")
return remove_resource_prefix(
key, self.prefix, self.server.resource_prefix_format
)
def match_prompt(self, key: str) -> bool:
return key.startswith(f"{self.prefix}{self.prompt_separator}")
return key.startswith(f"{self.prefix}_")
def strip_prompt_prefix(self, key: str) -> str:
return key.removeprefix(f"{self.prefix}{self.prompt_separator}")
return key.removeprefix(f"{self.prefix}_")
def add_resource_prefix(
uri: str, prefix: str, prefix_format: Literal["protocol", "path"] | None = None
) -> str:
"""Add a prefix to a resource URI.
Args:
uri: The original resource URI
prefix: The prefix to add
Returns:
The resource URI with the prefix added
Examples:
>>> add_resource_prefix("resource://path/to/resource", "prefix")
"resource://prefix/path/to/resource" # with new style
>>> add_resource_prefix("resource://path/to/resource", "prefix")
"prefix+resource://path/to/resource" # with legacy style
>>> add_resource_prefix("resource:///absolute/path", "prefix")
"resource://prefix//absolute/path" # with new style
Raises:
ValueError: If the URI doesn't match the expected protocol://path format
"""
if not prefix:
return uri
# Get the server settings to check for legacy format preference
if prefix_format is None:
prefix_format = fastmcp.settings.settings.resource_prefix_format
if prefix_format == "protocol":
# Legacy style: prefix+protocol://path
return f"{prefix}+{uri}"
elif prefix_format == "path":
# New style: protocol://prefix/path
# Split the URI into protocol and path
match = URI_PATTERN.match(uri)
if not match:
raise ValueError(
f"Invalid URI format: {uri}. Expected protocol://path format."
)
protocol, path = match.groups()
# Add the prefix to the path
return f"{protocol}{prefix}/{path}"
else:
raise ValueError(f"Invalid prefix format: {prefix_format}")
def remove_resource_prefix(
uri: str, prefix: str, prefix_format: Literal["protocol", "path"] | None = None
) -> str:
"""Remove a prefix from a resource URI.
Args:
uri: The resource URI with a prefix
prefix: The prefix to remove
prefix_format: The format of the prefix to remove
Returns:
The resource URI with the prefix removed
Examples:
>>> remove_resource_prefix("resource://prefix/path/to/resource", "prefix")
"resource://path/to/resource" # with new style
>>> remove_resource_prefix("prefix+resource://path/to/resource", "prefix")
"resource://path/to/resource" # with legacy style
>>> remove_resource_prefix("resource://prefix//absolute/path", "prefix")
"resource:///absolute/path" # with new style
Raises:
ValueError: If the URI doesn't match the expected protocol://path format
"""
if not prefix:
return uri
if prefix_format is None:
prefix_format = fastmcp.settings.settings.resource_prefix_format
if prefix_format == "protocol":
# Legacy style: prefix+protocol://path
legacy_prefix = f"{prefix}+"
if uri.startswith(legacy_prefix):
return uri[len(legacy_prefix) :]
return uri
elif prefix_format == "path":
# New style: protocol://prefix/path
# Split the URI into protocol and path
match = URI_PATTERN.match(uri)
if not match:
raise ValueError(
f"Invalid URI format: {uri}. Expected protocol://path format."
)
protocol, path = match.groups()
# Check if the path starts with the prefix followed by a /
prefix_pattern = f"^{re.escape(prefix)}/(.*?)$"
path_match = re.match(prefix_pattern, path)
if not path_match:
return uri
# Return the URI without the prefix
return f"{protocol}{path_match.group(1)}"
else:
raise ValueError(f"Invalid prefix format: {prefix_format}")
def has_resource_prefix(
uri: str, prefix: str, prefix_format: Literal["protocol", "path"] | None = None
) -> bool:
"""Check if a resource URI has a specific prefix.
Args:
uri: The resource URI to check
prefix: The prefix to look for
Returns:
True if the URI has the specified prefix, False otherwise
Examples:
>>> has_resource_prefix("resource://prefix/path/to/resource", "prefix")
True # with new style
>>> has_resource_prefix("prefix+resource://path/to/resource", "prefix")
True # with legacy style
>>> has_resource_prefix("resource://other/path/to/resource", "prefix")
False
Raises:
ValueError: If the URI doesn't match the expected protocol://path format
"""
if not prefix:
return False
# Get the server settings to check for legacy format preference
if prefix_format is None:
prefix_format = fastmcp.settings.settings.resource_prefix_format
if prefix_format == "protocol":
# Legacy style: prefix+protocol://path
legacy_prefix = f"{prefix}+"
return uri.startswith(legacy_prefix)
elif prefix_format == "path":
# New style: protocol://prefix/path
# Split the URI into protocol and path
match = URI_PATTERN.match(uri)
if not match:
raise ValueError(
f"Invalid URI format: {uri}. Expected protocol://path format."
)
_, path = match.groups()
# Check if the path starts with the prefix followed by a /
prefix_pattern = f"^{re.escape(prefix)}/"
return bool(re.match(prefix_pattern, path))
else:
raise ValueError(f"Invalid prefix format: {prefix_format}")

View file

@ -32,6 +32,17 @@ class Settings(BaseSettings):
test_mode: bool = False
log_level: LOG_LEVEL = "INFO"
enable_rich_tracebacks: Annotated[
bool,
Field(
description=inspect.cleandoc(
"""
If True, will use rich tracebacks for logging.
"""
)
),
] = True
client_raise_first_exceptiongroup_error: Annotated[
bool,
Field(
@ -47,6 +58,21 @@ class Settings(BaseSettings):
),
),
] = True
resource_prefix_format: Annotated[
Literal["protocol", "path"],
Field(
default="path",
description=inspect.cleandoc(
"""
When perfixing a resource URI, either use path formatting (resource://prefix/path)
or protocol formatting (prefix+resource://path). Protocol formatting was the default in FastMCP < 2.4;
path formatting is current default.
"""
),
),
] = "path"
tool_attempt_parse_json_args: Annotated[
bool,
Field(
@ -64,12 +90,21 @@ class Settings(BaseSettings):
),
] = False
client_init_timeout: Annotated[
float | None,
Field(
description="The timeout for the client's initialization handshake, in seconds. Set to None or 0 to disable.",
),
] = None
@model_validator(mode="after")
def setup_logging(self) -> Self:
"""Finalize the settings."""
from fastmcp.utilities.logging import configure_logging
configure_logging(self.log_level)
configure_logging(
self.log_level, enable_rich_tracebacks=self.enable_rich_tracebacks
)
return self
@ -111,6 +146,23 @@ class ServerSettings(BaseSettings):
# prompt settings
on_duplicate_prompts: DuplicateBehavior = "warn"
# error handling
mask_error_details: Annotated[
bool,
Field(
default=False,
description=inspect.cleandoc(
"""
If True, error details from user-supplied functions (tool, resource, prompt)
will be masked before being sent to clients. Only error messages from explicitly
raised ToolError, ResourceError, or PromptError will be included in responses.
If False (default), all error details will be included in responses, but prefixed
with appropriate context.
"""
),
),
] = False
dependencies: Annotated[
list[str],
Field(

View file

@ -36,7 +36,9 @@ class Tool(BaseModel):
fn: Callable[..., Any]
name: str = Field(description="Name of the tool")
description: str = Field(description="Description of what the tool does")
description: str | None = Field(
default=None, description="Description of what the tool does"
)
parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
tags: Annotated[set[str], BeforeValidator(_convert_set_defaults)] = Field(
default_factory=set, description="Tags for the tool"
@ -69,12 +71,16 @@ class Tool(BaseModel):
if param.kind == inspect.Parameter.VAR_KEYWORD:
raise ValueError("Functions with **kwargs are not supported as tools")
func_name = name or fn.__name__
func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__
if func_name == "<lambda>":
raise ValueError("You must provide a name for lambda functions")
func_doc = description or fn.__doc__ or ""
func_doc = description or fn.__doc__
# if the fn is a callable class, we need to get the __call__ method from here out
if not inspect.isroutine(fn):
fn = fn.__call__
type_adapter = get_cached_typeadapter(fn)
schema = type_adapter.json_schema()

View file

@ -23,9 +23,11 @@ class ToolManager:
self,
duplicate_behavior: DuplicateBehavior | None = None,
serializer: Callable[[Any], str] | None = None,
mask_error_details: bool = False,
):
self._tools: dict[str, Tool] = {}
self._serializer = serializer
self.mask_error_details = mask_error_details
# Default to "warn" if None is provided
if duplicate_behavior is None:
@ -124,7 +126,12 @@ class ToolManager:
logger.exception(f"Error calling tool {key!r}: {e}")
raise e
# raise other exceptions as ToolErrors without revealing internal details
# Handle other exceptions
except Exception as e:
logger.exception(f"Error calling tool {key!r}: {e}")
raise ToolError(f"Error calling tool {key!r}") from e
if self.mask_error_details:
# Mask internal details
raise ToolError(f"Error calling tool {key!r}") from e
else:
# Include original error details
raise ToolError(f"Error calling tool {key!r}: {e}") from e

View file

@ -22,6 +22,7 @@ def get_logger(name: str) -> logging.Logger:
def configure_logging(
level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | int = "INFO",
logger: logging.Logger | None = None,
enable_rich_tracebacks: bool = True,
) -> None:
"""
Configure logging for FastMCP.
@ -30,11 +31,15 @@ def configure_logging(
logger: the logger to configure
level: the log level to use
"""
if logger is None:
logger = logging.getLogger("FastMCP")
# Only configure the FastMCP logger namespace
handler = RichHandler(console=Console(stderr=True), rich_tracebacks=True)
handler = RichHandler(
console=Console(stderr=True),
rich_tracebacks=enable_rich_tracebacks,
)
formatter = logging.Formatter("%(message)s")
handler.setFormatter(formatter)

View file

@ -0,0 +1,77 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal
from urllib.parse import urlparse
from pydantic import AnyUrl, BaseModel, Field
if TYPE_CHECKING:
from fastmcp.client.transports import (
SSETransport,
StdioTransport,
StreamableHttpTransport,
)
def infer_transport_type_from_url(
url: str | AnyUrl,
) -> Literal["streamable-http", "sse"]:
"""
Infer the appropriate transport type from the given URL.
"""
url = str(url)
if not url.startswith("http"):
raise ValueError(f"Invalid URL: {url}")
parsed_url = urlparse(url)
path = parsed_url.path
if "/sse/" in path or path.rstrip("/").endswith("/sse"):
return "sse"
else:
return "streamable-http"
class StdioMCPServer(BaseModel):
command: str
args: list[str] = Field(default_factory=list)
env: dict[str, Any] = Field(default_factory=dict)
cwd: str | None = None
transport: Literal["stdio"] = "stdio"
def to_transport(self) -> StdioTransport:
from fastmcp.client.transports import StdioTransport
return StdioTransport(
command=self.command,
args=self.args,
env=self.env,
cwd=self.cwd,
)
class RemoteMCPServer(BaseModel):
url: str
headers: dict[str, str] = Field(default_factory=dict)
transport: Literal["streamable-http", "sse", "http"] | None = None
def to_transport(self) -> StreamableHttpTransport | SSETransport:
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
if self.transport is None:
transport = infer_transport_type_from_url(self.url)
else:
transport = self.transport
if transport == "sse":
return SSETransport(self.url, headers=self.headers)
else:
return StreamableHttpTransport(self.url, headers=self.headers)
class MCPConfig(BaseModel):
mcpServers: dict[str, StdioMCPServer | RemoteMCPServer]
@classmethod
def from_dict(cls, config: dict[str, Any]) -> MCPConfig:
return cls(mcpServers=config.get("mcpServers", config))

File diff suppressed because it is too large Load diff

View file

@ -71,7 +71,7 @@ def _run_server(mcp_server: FastMCP, transport: Literal["sse"], port: int) -> No
@contextmanager
def run_server_in_process(
server_fn: Callable[[str, int], None], *args
server_fn: Callable[..., None], *args
) -> Generator[str, None, None]:
"""
Context manager that runs a Starlette app in a separate process and returns the
@ -109,7 +109,11 @@ def run_server_in_process(
yield f"http://{host}:{port}"
proc.kill()
proc.join(timeout=2)
proc.terminate()
proc.join(timeout=5)
if proc.is_alive():
raise RuntimeError("Server process failed to terminate")
# If it's still alive, then force kill it
proc.kill()
proc.join(timeout=2)
if proc.is_alive():
raise RuntimeError("Server process failed to terminate even after kill")

View file

@ -173,74 +173,6 @@ class TestHelperFunctions:
"file.py:server",
]
def test_parse_file_path_simple(self):
"""Test parsing simple file path."""
with (
patch("pathlib.Path.exists") as mock_exists,
patch("pathlib.Path.is_file") as mock_is_file,
patch("pathlib.Path.expanduser") as mock_expanduser,
patch("pathlib.Path.resolve") as mock_resolve,
):
mock_exists.return_value = True
mock_is_file.return_value = True
mock_expanduser.return_value = Path("file.py")
mock_resolve.return_value = Path("file.py")
path, obj = cli._parse_file_path("file.py")
assert path == Path("file.py")
assert obj is None
def test_parse_file_path_with_object(self):
"""Test parsing file path with object."""
with (
patch("pathlib.Path.exists") as mock_exists,
patch("pathlib.Path.is_file") as mock_is_file,
patch("pathlib.Path.expanduser") as mock_expanduser,
patch("pathlib.Path.resolve") as mock_resolve,
):
mock_exists.return_value = True
mock_is_file.return_value = True
mock_expanduser.return_value = Path("file.py")
mock_resolve.return_value = Path("file.py")
path, obj = cli._parse_file_path("file.py:server")
assert path == Path("file.py")
assert obj == "server"
def test_parse_file_path_windows(self):
"""Test parsing Windows file path."""
with (
patch("pathlib.Path.exists") as mock_exists,
patch("pathlib.Path.is_file") as mock_is_file,
patch("pathlib.Path.expanduser") as mock_expanduser,
patch("pathlib.Path.resolve") as mock_resolve,
):
mock_exists.return_value = True
mock_is_file.return_value = True
mock_expanduser.return_value = Path("C:/path/file.py")
mock_resolve.return_value = Path("C:/path/file.py")
path, obj = cli._parse_file_path("C:/path/file.py:server")
assert path == Path("C:/path/file.py")
assert obj == "server"
def test_parse_file_path_not_file(self, mock_exit, mock_logger):
"""Test parsing path that is not a file."""
with (
patch("pathlib.Path.exists") as mock_exists,
patch("pathlib.Path.is_file") as mock_is_file,
patch("pathlib.Path.expanduser") as mock_expanduser,
patch("pathlib.Path.resolve") as mock_resolve,
):
mock_exists.return_value = True
mock_is_file.return_value = False
mock_expanduser.return_value = Path("directory")
mock_resolve.return_value = Path("directory")
cli._parse_file_path("directory")
mock_logger.error.assert_called_once()
mock_exit.assert_called_once_with(1)
class TestVersionCommand:
"""Tests for the version command."""
@ -259,8 +191,8 @@ class TestDevCommand:
def test_dev_command_success(self, temp_python_file, mock_logger):
"""Test successful dev command execution."""
with (
patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
patch("fastmcp.cli.cli._import_server") as mock_import,
patch("fastmcp.cli.run.parse_file_path") as mock_parse,
patch("fastmcp.cli.run.import_server") as mock_import,
patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx,
patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv,
patch("subprocess.run") as mock_run,
@ -285,8 +217,8 @@ class TestDevCommand:
def test_dev_command_with_ui_port(self, temp_python_file):
"""Test dev command with UI port."""
with (
patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
patch("fastmcp.cli.cli._import_server") as mock_import,
patch("fastmcp.cli.run.parse_file_path") as mock_parse,
patch("fastmcp.cli.run.import_server") as mock_import,
patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx,
patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv,
patch("subprocess.run") as mock_run,
@ -310,8 +242,8 @@ class TestDevCommand:
def test_dev_command_with_server_port(self, temp_python_file):
"""Test dev command with server port."""
with (
patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
patch("fastmcp.cli.cli._import_server") as mock_import,
patch("fastmcp.cli.run.parse_file_path") as mock_parse,
patch("fastmcp.cli.run.import_server") as mock_import,
patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx,
patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv,
patch("subprocess.run") as mock_run,
@ -335,8 +267,8 @@ class TestDevCommand:
def test_dev_command_inspector_version(self, temp_python_file):
"""Test dev command with specific inspector version."""
with (
patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
patch("fastmcp.cli.cli._import_server") as mock_import,
patch("fastmcp.cli.run.parse_file_path") as mock_parse,
patch("fastmcp.cli.run.import_server") as mock_import,
patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx,
patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv,
patch("subprocess.run") as mock_run,
@ -360,11 +292,12 @@ class TestDevCommand:
class TestRunCommand:
"""Tests for the run command."""
def test_run_command_success(self, temp_python_file, mock_logger):
def test_run_command_success(self, temp_python_file):
"""Test successful run command execution."""
with (
patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
patch("fastmcp.cli.cli._import_server") as mock_import,
patch("fastmcp.cli.run.parse_file_path") as mock_parse,
patch("fastmcp.cli.run.import_server") as mock_import,
patch("fastmcp.cli.run.logger") as mock_logger,
):
mock_parse.return_value = (temp_python_file, None)
mock_server = MagicMock()
@ -374,15 +307,15 @@ class TestRunCommand:
result = runner.invoke(cli.app, ["run", str(temp_python_file)])
assert result.exit_code == 0
mock_server.run.assert_called_once_with()
mock_logger.info.assert_called_with(
mock_logger.debug.assert_called_with(
f'Found server "test_server" in {temp_python_file}'
)
def test_run_command_with_transport(self, temp_python_file):
"""Test run command with transport option."""
with (
patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
patch("fastmcp.cli.cli._import_server") as mock_import,
patch("fastmcp.cli.run.parse_file_path") as mock_parse,
patch("fastmcp.cli.run.import_server") as mock_import,
):
mock_parse.return_value = (temp_python_file, None)
mock_server = MagicMock()
@ -398,8 +331,8 @@ class TestRunCommand:
def test_run_command_with_host(self, temp_python_file):
"""Test run command with host option."""
with (
patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
patch("fastmcp.cli.cli._import_server") as mock_import,
patch("fastmcp.cli.run.parse_file_path") as mock_parse,
patch("fastmcp.cli.run.import_server") as mock_import,
):
mock_parse.return_value = (temp_python_file, None)
mock_server = MagicMock()
@ -415,8 +348,8 @@ class TestRunCommand:
def test_run_command_with_port(self, temp_python_file):
"""Test run command with port option."""
with (
patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
patch("fastmcp.cli.cli._import_server") as mock_import,
patch("fastmcp.cli.run.parse_file_path") as mock_parse,
patch("fastmcp.cli.run.import_server") as mock_import,
):
mock_parse.return_value = (temp_python_file, None)
mock_server = MagicMock()
@ -432,8 +365,8 @@ class TestRunCommand:
def test_run_command_with_log_level(self, temp_python_file):
"""Test run command with log level option."""
with (
patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
patch("fastmcp.cli.cli._import_server") as mock_import,
patch("fastmcp.cli.run.parse_file_path") as mock_parse,
patch("fastmcp.cli.run.import_server") as mock_import,
):
mock_parse.return_value = (temp_python_file, None)
mock_server = MagicMock()
@ -449,8 +382,8 @@ class TestRunCommand:
def test_run_command_with_multiple_options(self, temp_python_file):
"""Test run command with multiple options."""
with (
patch("fastmcp.cli.cli._parse_file_path") as mock_parse,
patch("fastmcp.cli.cli._import_server") as mock_import,
patch("fastmcp.cli.run.parse_file_path") as mock_parse,
patch("fastmcp.cli.run.import_server") as mock_import,
):
mock_parse.return_value = (temp_python_file, None)
mock_server = MagicMock()

View file

@ -1,22 +1,262 @@
"""Tests for the CLI module."""
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from typer.testing import CliRunner
import fastmcp.cli.run
from fastmcp.cli import cli
# Set up test runner
runner = CliRunner()
@pytest.fixture
def server_file(tmp_path):
"""Create a simple server file for testing"""
server_path = tmp_path / "test_server.py"
server_path.write_text(
"""
from fastmcp import FastMCP
def mock_console():
"""Mock the rich console to test output."""
with patch("fastmcp.cli.cli.console") as mock_console:
yield mock_console
mcp = FastMCP(name="TestServer")
@mcp.tool()
def hello(name: str) -> str:
return f"Hello, {name}!"
@pytest.fixture
def mock_logger():
"""Mock the logger to test logging."""
with patch("fastmcp.cli.cli.logger") as mock_logger:
yield mock_logger
if __name__ == "__main__":
mcp.run()
@pytest.fixture
def mock_exit():
"""Mock sys.exit to prevent tests from exiting."""
with patch("sys.exit") as mock_exit:
yield mock_exit
@pytest.fixture
def temp_python_file(tmp_path):
"""Create a temporary Python file with a test server."""
server_code = """
from mcp import Server
class TestServer(Server):
name = "test_server"
dependencies = ["package1", "package2"]
def run(self, **kwargs):
print("Running server with", kwargs)
mcp = TestServer()
server = TestServer()
app = TestServer()
custom_server = TestServer()
"""
)
return server_path
file_path = tmp_path / "test_server.py"
file_path.write_text(server_code)
return file_path
@pytest.fixture
def temp_env_file(tmp_path):
"""Create a temporary .env file."""
env_content = """
TEST_VAR1=value1
TEST_VAR2=value2
"""
env_path = tmp_path / ".env"
env_path.write_text(env_content)
return env_path
class TestHelperFunctions:
def test_parse_file_path_simple(self):
"""Test parsing simple file path."""
with (
patch("pathlib.Path.exists") as mock_exists,
patch("pathlib.Path.is_file") as mock_is_file,
patch("pathlib.Path.expanduser") as mock_expanduser,
patch("pathlib.Path.resolve") as mock_resolve,
):
mock_exists.return_value = True
mock_is_file.return_value = True
mock_expanduser.return_value = Path("file.py")
mock_resolve.return_value = Path("file.py")
path, obj = fastmcp.cli.run.parse_file_path("file.py")
assert path == Path("file.py")
assert obj is None
def test_parse_file_path_with_object(self):
"""Test parsing file path with object."""
with (
patch("pathlib.Path.exists") as mock_exists,
patch("pathlib.Path.is_file") as mock_is_file,
patch("pathlib.Path.expanduser") as mock_expanduser,
patch("pathlib.Path.resolve") as mock_resolve,
):
mock_exists.return_value = True
mock_is_file.return_value = True
mock_expanduser.return_value = Path("file.py")
mock_resolve.return_value = Path("file.py")
path, obj = fastmcp.cli.run.parse_file_path("file.py:server")
assert path == Path("file.py")
assert obj == "server"
def test_parse_file_path_windows(self):
"""Test parsing Windows file path."""
with (
patch("pathlib.Path.exists") as mock_exists,
patch("pathlib.Path.is_file") as mock_is_file,
patch("pathlib.Path.expanduser") as mock_expanduser,
patch("pathlib.Path.resolve") as mock_resolve,
):
mock_exists.return_value = True
mock_is_file.return_value = True
mock_expanduser.return_value = Path("C:/path/file.py")
mock_resolve.return_value = Path("C:/path/file.py")
path, obj = fastmcp.cli.run.parse_file_path("C:/path/file.py:server")
assert path == Path("C:/path/file.py")
assert obj == "server"
def test_parse_file_path_not_file(self, mock_exit):
"""Test parsing path that is not a file."""
with (
patch("pathlib.Path.exists") as mock_exists,
patch("pathlib.Path.is_file") as mock_is_file,
patch("pathlib.Path.expanduser") as mock_expanduser,
patch("pathlib.Path.resolve") as mock_resolve,
patch("fastmcp.cli.run.logger") as mock_logger,
):
mock_exists.return_value = True
mock_is_file.return_value = False
mock_expanduser.return_value = Path("directory")
mock_resolve.return_value = Path("directory")
fastmcp.cli.run.parse_file_path("directory")
mock_logger.error.assert_called_once()
mock_exit.assert_called_once_with(1)
class TestRunCommand:
"""Tests for the run command."""
def test_run_command_success(self, temp_python_file):
"""Test successful run command execution."""
with (
patch("fastmcp.cli.run.parse_file_path") as mock_parse,
patch("fastmcp.cli.run.import_server") as mock_import,
patch("fastmcp.cli.run.logger") as mock_logger,
):
mock_parse.return_value = (temp_python_file, None)
mock_server = MagicMock()
mock_server.name = "test_server"
mock_import.return_value = mock_server
result = runner.invoke(cli.app, ["run", str(temp_python_file)])
assert result.exit_code == 0
mock_server.run.assert_called_once_with()
mock_logger.debug.assert_called_with(
f'Found server "test_server" in {temp_python_file}'
)
def test_run_command_with_transport(self, temp_python_file):
"""Test run command with transport option."""
with (
patch("fastmcp.cli.run.parse_file_path") as mock_parse,
patch("fastmcp.cli.run.import_server") as mock_import,
):
mock_parse.return_value = (temp_python_file, None)
mock_server = MagicMock()
mock_server.name = "test_server"
mock_import.return_value = mock_server
result = runner.invoke(
cli.app, ["run", str(temp_python_file), "--transport", "sse"]
)
assert result.exit_code == 0
mock_server.run.assert_called_once_with(transport="sse")
def test_run_command_with_host(self, temp_python_file):
"""Test run command with host option."""
with (
patch("fastmcp.cli.run.parse_file_path") as mock_parse,
patch("fastmcp.cli.run.import_server") as mock_import,
):
mock_parse.return_value = (temp_python_file, None)
mock_server = MagicMock()
mock_server.name = "test_server"
mock_import.return_value = mock_server
result = runner.invoke(
cli.app, ["run", str(temp_python_file), "--host", "0.0.0.0"]
)
assert result.exit_code == 0
mock_server.run.assert_called_once_with(host="0.0.0.0")
def test_run_command_with_port(self, temp_python_file):
"""Test run command with port option."""
with (
patch("fastmcp.cli.run.parse_file_path") as mock_parse,
patch("fastmcp.cli.run.import_server") as mock_import,
):
mock_parse.return_value = (temp_python_file, None)
mock_server = MagicMock()
mock_server.name = "test_server"
mock_import.return_value = mock_server
result = runner.invoke(
cli.app, ["run", str(temp_python_file), "--port", "8080"]
)
assert result.exit_code == 0
mock_server.run.assert_called_once_with(port=8080)
def test_run_command_with_log_level(self, temp_python_file):
"""Test run command with log level option."""
with (
patch("fastmcp.cli.run.parse_file_path") as mock_parse,
patch("fastmcp.cli.run.import_server") as mock_import,
):
mock_parse.return_value = (temp_python_file, None)
mock_server = MagicMock()
mock_server.name = "test_server"
mock_import.return_value = mock_server
result = runner.invoke(
cli.app, ["run", str(temp_python_file), "--log-level", "DEBUG"]
)
assert result.exit_code == 0
mock_server.run.assert_called_once_with(log_level="DEBUG")
def test_run_command_with_multiple_options(self, temp_python_file):
"""Test run command with multiple options."""
with (
patch("fastmcp.cli.run.parse_file_path") as mock_parse,
patch("fastmcp.cli.run.import_server") as mock_import,
):
mock_parse.return_value = (temp_python_file, None)
mock_server = MagicMock()
mock_server.name = "test_server"
mock_import.return_value = mock_server
result = runner.invoke(
cli.app,
[
"run",
str(temp_python_file),
"--transport",
"sse",
"--host",
"0.0.0.0",
"--port",
"8080",
"--log-level",
"DEBUG",
],
)
assert result.exit_code == 0
mock_server.run.assert_called_once_with(
transport="sse", host="0.0.0.0", port=8080, log_level="DEBUG"
)

View file

@ -1,4 +1,5 @@
import asyncio
import sys
from typing import cast
import pytest
@ -6,7 +7,14 @@ from mcp import McpError
from pydantic import AnyUrl
from fastmcp.client import Client
from fastmcp.client.transports import FastMCPTransport
from fastmcp.client.transports import (
FastMCPTransport,
MCPConfigTransport,
SSETransport,
StdioTransport,
StreamableHttpTransport,
infer_transport,
)
from fastmcp.exceptions import ResourceError, ToolError
from fastmcp.prompts.prompt import TextContent
from fastmcp.server.server import FastMCP
@ -211,6 +219,13 @@ async def test_get_prompt_mcp(fastmcp_server):
assert result.description == "Example greeting prompt."
async def test_read_resource_invalid_uri(fastmcp_server):
"""Test reading a resource with an invalid URI."""
client = Client(transport=FastMCPTransport(fastmcp_server))
with pytest.raises(ValueError, match="Provided resource URI is invalid"):
await client.read_resource("invalid_uri")
async def test_read_resource(fastmcp_server):
"""Test reading a resource with InMemoryClient."""
client = Client(transport=FastMCPTransport(fastmcp_server))
@ -250,18 +265,51 @@ async def test_read_resource_mcp(fastmcp_server):
async def test_client_connection(fastmcp_server):
"""Test that the client connects and disconnects properly."""
"""Test that connect is idempotent."""
client = Client(transport=FastMCPTransport(fastmcp_server))
# Before connection
# Connect idempotently
async with client:
assert client.is_connected()
# Make a request to ensure connection is working
await client.ping()
assert not client.is_connected()
# During connection
async def test_initialize_result_connected(fastmcp_server):
"""Test that initialize_result returns the correct result when connected."""
client = Client(transport=FastMCPTransport(fastmcp_server))
# Initialize result should not be accessible before connection
with pytest.raises(RuntimeError, match="Client is not connected"):
_ = client.initialize_result
async with client:
# Once connected, initialize_result should be available
result = client.initialize_result
# Verify the initialize result has expected properties
assert hasattr(result, "serverInfo")
assert result.serverInfo.name == "TestServer"
assert result.serverInfo.version is not None
async def test_initialize_result_disconnected(fastmcp_server):
"""Test that initialize_result raises an error when not connected."""
client = Client(transport=FastMCPTransport(fastmcp_server))
# Initialize result should not be accessible before connection
with pytest.raises(RuntimeError, match="Client is not connected"):
_ = client.initialize_result
# Connect and then disconnect
async with client:
assert client.is_connected()
# After connection
# After disconnection, initialize_result should raise an error
assert not client.is_connected()
with pytest.raises(RuntimeError, match="Client is not connected"):
_ = client.initialize_result
async def test_client_nested_context_manager(fastmcp_server):
@ -416,7 +464,7 @@ async def test_tagged_template_functionality(tagged_resources_server):
class TestErrorHandling:
async def test_general_tool_exceptions_are_masked(self):
async def test_general_tool_exceptions_are_not_masked_by_default(self):
mcp = FastMCP("TestServer")
@mcp.tool()
@ -425,6 +473,22 @@ class TestErrorHandling:
client = Client(transport=FastMCPTransport(mcp))
async with client:
result = await client.call_tool_mcp("error_tool", {})
assert result.isError
assert isinstance(result.content[0], TextContent)
assert "test error" in result.content[0].text
assert "abc" in result.content[0].text
async def test_general_tool_exceptions_are_masked_when_enabled(self):
mcp = FastMCP("TestServer", mask_error_details=True)
@mcp.tool()
def error_tool():
raise ValueError("This is a test error (abc)")
client = Client(transport=FastMCPTransport(mcp))
async with client:
result = await client.call_tool_mcp("error_tool", {})
assert result.isError
@ -448,7 +512,7 @@ class TestErrorHandling:
assert "test error" in result.content[0].text
assert "abc" in result.content[0].text
async def test_general_resource_exceptions_are_masked(self):
async def test_general_resource_exceptions_are_not_masked_by_default(self):
mcp = FastMCP("TestServer")
@mcp.resource(uri="exception://resource")
@ -457,6 +521,22 @@ class TestErrorHandling:
client = Client(transport=FastMCPTransport(mcp))
async with client:
with pytest.raises(Exception) as excinfo:
await client.read_resource(AnyUrl("exception://resource"))
assert "Error reading resource" in str(excinfo.value)
assert "sensitive" in str(excinfo.value)
assert "internal error" in str(excinfo.value)
async def test_general_resource_exceptions_are_masked_when_enabled(self):
mcp = FastMCP("TestServer", mask_error_details=True)
@mcp.resource(uri="exception://resource")
async def exception_resource():
raise ValueError("This is an internal error (sensitive)")
client = Client(transport=FastMCPTransport(mcp))
async with client:
with pytest.raises(Exception) as excinfo:
await client.read_resource(AnyUrl("exception://resource"))
@ -478,7 +558,7 @@ class TestErrorHandling:
await client.read_resource(AnyUrl("error://resource"))
assert "This is a resource error (xyz)" in str(excinfo.value)
async def test_general_template_exceptions_are_masked(self):
async def test_general_template_exceptions_are_not_masked_by_default(self):
mcp = FastMCP("TestServer")
@mcp.resource(uri="exception://resource/{id}")
@ -487,6 +567,22 @@ class TestErrorHandling:
client = Client(transport=FastMCPTransport(mcp))
async with client:
with pytest.raises(Exception) as excinfo:
await client.read_resource(AnyUrl("exception://resource/123"))
assert "Error reading resource" in str(excinfo.value)
assert "sensitive" in str(excinfo.value)
assert "internal error" in str(excinfo.value)
async def test_general_template_exceptions_are_masked_when_enabled(self):
mcp = FastMCP("TestServer", mask_error_details=True)
@mcp.resource(uri="exception://resource/{id}")
async def exception_resource(id: str):
raise ValueError("This is an internal error (sensitive)")
client = Client(transport=FastMCPTransport(mcp))
async with client:
with pytest.raises(Exception) as excinfo:
await client.read_resource(AnyUrl("exception://resource/123"))
@ -509,6 +605,10 @@ class TestErrorHandling:
assert "This is a resource error (xyz)" in str(excinfo.value)
@pytest.mark.skipif(
sys.platform == "win32",
reason="Timeout tests are flaky on Windows. Timeouts *are* supported but the tests are unreliable.",
)
class TestTimeout:
async def test_timeout(self, fastmcp_server: FastMCP):
async with Client(
@ -535,6 +635,10 @@ class TestTimeout:
with pytest.raises(McpError):
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_tool_call_overrides_client_timeout_even_if_lower(
self, fastmcp_server: FastMCP
):
@ -543,3 +647,126 @@ class TestTimeout:
timeout=0.01,
) as client:
await client.call_tool("sleep", {"seconds": 0.1}, timeout=2)
class TestInferTransport:
"""Tests for the infer_transport function."""
@pytest.mark.parametrize(
"url",
[
"http://example.com/api/sse/stream",
"https://localhost:8080/mcp/sse/endpoint",
"http://example.com/api/sse",
"https://localhost:8080/mcp/sse",
"http://example.com/api/sse?param=value",
"https://localhost:8080/mcp/sse/?param=value",
"https://localhost:8000/mcp/sse?x=1&y=2",
],
ids=[
"path_with_sse_directory",
"path_with_sse_subdirectory",
"path_ending_with_sse",
"path_ending_with_sse_https",
"path_with_sse_and_query_params",
"path_with_sse_slash_and_query_params",
"path_with_sse_and_ampersand_param",
],
)
def test_url_returns_sse_transport(self, url):
"""Test that URLs with /sse/ pattern return SSETransport."""
assert isinstance(infer_transport(url), SSETransport)
@pytest.mark.parametrize(
"url",
[
"http://example.com/api",
"https://localhost:8080/mcp",
"http://example.com/asset/image.jpg",
"https://localhost:8080/sservice/endpoint",
"https://example.com/assets/file",
],
ids=[
"regular_http_url",
"regular_https_url",
"url_with_unrelated_path",
"url_with_sservice_in_path",
"url_with_assets_in_path",
],
)
def test_url_returns_streamable_http_transport(self, url):
"""Test that URLs without /sse/ pattern return StreamableHttpTransport."""
assert isinstance(infer_transport(url), StreamableHttpTransport)
def test_infer_remote_transport_from_config(self):
config = {
"mcpServers": {
"test_server": {
"url": "http://localhost:8000/sse",
"headers": {"Authorization": "Bearer 123"},
},
}
}
transport = infer_transport(config)
assert isinstance(transport, MCPConfigTransport)
assert isinstance(transport.transport, SSETransport)
assert transport.transport.url == "http://localhost:8000/sse"
assert transport.transport.headers == {"Authorization": "Bearer 123"}
def test_infer_local_transport_from_config(self):
config = {
"mcpServers": {
"test_server": {
"command": "echo",
"args": ["hello"],
},
}
}
transport = infer_transport(config)
assert isinstance(transport, MCPConfigTransport)
assert isinstance(transport.transport, StdioTransport)
assert transport.transport.command == "echo"
assert transport.transport.args == ["hello"]
def test_config_with_no_servers(self):
"""Test that an empty MCPConfig raises a ValueError."""
config = {"mcpServers": {}}
with pytest.raises(ValueError, match="No MCP servers defined in the config"):
infer_transport(config)
def test_mcpconfigtransport_with_no_servers(self):
"""Test that MCPConfigTransport raises a ValueError when initialized with an empty config."""
config = {"mcpServers": {}}
with pytest.raises(ValueError, match="No MCP servers defined in the config"):
MCPConfigTransport(config=config)
def test_infer_composite_client(self):
config = {
"mcpServers": {
"local": {
"command": "echo",
"args": ["hello"],
},
"remote": {
"url": "http://localhost:8000/sse",
"headers": {"Authorization": "Bearer 123"},
},
}
}
transport = infer_transport(config)
assert isinstance(transport, MCPConfigTransport)
assert isinstance(transport.transport, FastMCPTransport)
assert len(cast(FastMCP, transport.transport.server)._mounted_servers) == 2
def test_infer_fastmcp_server(self, fastmcp_server):
"""FastMCP server instances should infer to FastMCPTransport."""
transport = infer_transport(fastmcp_server)
assert isinstance(transport, FastMCPTransport)
def test_infer_fastmcp_v1_server(self):
"""FastMCP 1.0 server instances should infer to FastMCPTransport."""
from mcp.server.fastmcp import FastMCP as FastMCP1
server = FastMCP1()
transport = infer_transport(server)
assert isinstance(transport, FastMCPTransport)

View file

@ -9,8 +9,8 @@ class LogHandler:
def __init__(self):
self.logs: list[LogMessage] = []
async def handle_log(self, params: LogMessage) -> None:
self.logs.append(params)
async def handle_log(self, message: LogMessage) -> None:
self.logs.append(message)
@pytest.fixture

View file

@ -0,0 +1,209 @@
import json
import sys
from collections.abc import Generator
import pytest
import uvicorn
from fastapi import FastAPI, Request
from mcp.types import TextContent, TextResourceContents
from fastmcp import Client, FastMCP
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
from fastmcp.utilities.tests import run_server_in_process
def fastmcp_server_for_headers() -> FastMCP:
app = FastAPI()
@app.get("/headers")
def get_headers(request: Request):
return request.headers
@app.get("/headers/{header_name}")
def get_header_by_name(header_name: str, request: Request):
return request.headers[header_name]
@app.post("/headers")
def post_headers(request: Request):
return request.headers
mcp = FastMCP.from_fastapi(
app,
httpx_client_kwargs={"headers": {"x-server-header": "test-abc"}},
)
return mcp
class TestClientHeaders:
def run_shttp_server(self, host: str, port: int) -> None:
try:
app = fastmcp_server_for_headers().http_app(transport="streamable-http")
server = uvicorn.Server(
config=uvicorn.Config(
app=app,
host=host,
port=port,
log_level="error",
lifespan="on",
)
)
server.run()
except Exception as e:
print(f"Server error: {e}")
sys.exit(1)
sys.exit(0)
def run_sse_server(self, host: str, port: int) -> None:
try:
app = fastmcp_server_for_headers().http_app(transport="sse")
server = uvicorn.Server(
config=uvicorn.Config(
app=app,
host=host,
port=port,
log_level="error",
lifespan="on",
)
)
server.run()
except Exception as e:
print(f"Server error: {e}")
sys.exit(1)
sys.exit(0)
def run_proxy_server(self, host: str, port: int, remote_url: str) -> None:
try:
client = Client(transport=StreamableHttpTransport(remote_url))
app = FastMCP.as_proxy(client).http_app(transport="streamable-http")
server = uvicorn.Server(
config=uvicorn.Config(
app=app,
host=host,
port=port,
log_level="error",
lifespan="on",
)
)
server.run()
except Exception as e:
print(f"Server error: {e}")
sys.exit(1)
sys.exit(0)
@pytest.fixture(scope="class")
def shttp_server(self) -> Generator[str, None, None]:
with run_server_in_process(self.run_shttp_server) as url:
yield f"{url}/mcp"
@pytest.fixture(scope="class")
def sse_server(self) -> Generator[str, None, None]:
with run_server_in_process(self.run_sse_server) as url:
yield f"{url}/sse"
@pytest.fixture(scope="class")
def proxy_server(self, shttp_server: str) -> Generator[str, None, None]:
with run_server_in_process(self.run_proxy_server, shttp_server + "/mcp") as url:
yield f"{url}/mcp"
async def test_client_headers_sse_resource(self, sse_server: str):
async with Client(
transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
) as client:
result = await client.read_resource("resource://get_headers_headers_get")
assert isinstance(result[0], TextResourceContents)
headers = json.loads(result[0].text)
assert headers["x-test"] == "test-123"
async def test_client_headers_shttp_resource(self, shttp_server: str):
async with Client(
transport=StreamableHttpTransport(
shttp_server, headers={"X-TEST": "test-123"}
)
) as client:
result = await client.read_resource("resource://get_headers_headers_get")
assert isinstance(result[0], TextResourceContents)
headers = json.loads(result[0].text)
assert headers["x-test"] == "test-123"
async def test_client_headers_sse_resource_template(self, sse_server: str):
async with Client(
transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
) as client:
result = await client.read_resource(
"resource://get_header_by_name_headers/x-test"
)
assert isinstance(result[0], TextResourceContents)
header = json.loads(result[0].text)
assert header == "test-123"
async def test_client_headers_shttp_resource_template(self, shttp_server: str):
async with Client(
transport=StreamableHttpTransport(
shttp_server, headers={"X-TEST": "test-123"}
)
) as client:
result = await client.read_resource(
"resource://get_header_by_name_headers/x-test"
)
assert isinstance(result[0], TextResourceContents)
header = json.loads(result[0].text)
assert header == "test-123"
async def test_client_headers_sse_tool(self, sse_server: str):
async with Client(
transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
) as client:
result = await client.call_tool("post_headers_headers_post")
assert isinstance(result[0], TextContent)
headers = json.loads(result[0].text)
assert headers["x-test"] == "test-123"
async def test_client_headers_shttp_tool(self, shttp_server: str):
async with Client(
transport=StreamableHttpTransport(
shttp_server, headers={"X-TEST": "test-123"}
)
) as client:
result = await client.call_tool("post_headers_headers_post")
assert isinstance(result[0], TextContent)
headers = json.loads(result[0].text)
assert headers["x-test"] == "test-123"
async def test_client_overrides_server_headers(self, shttp_server: str):
async with Client(
transport=StreamableHttpTransport(
shttp_server, headers={"x-server-header": "test-client"}
)
) as client:
result = await client.read_resource("resource://get_headers_headers_get")
assert isinstance(result[0], TextResourceContents)
headers = json.loads(result[0].text)
assert headers["x-server-header"] == "test-client"
async def test_client_with_excluded_header_is_ignored(self, sse_server: str):
async with Client(
transport=SSETransport(
sse_server,
headers={
"x-server-header": "test-client",
"host": "1.2.3.4",
"not-host": "1.2.3.4",
},
)
) as client:
result = await client.read_resource("resource://get_headers_headers_get")
assert isinstance(result[0], TextResourceContents)
headers = json.loads(result[0].text)
assert headers["not-host"] == "1.2.3.4"
assert headers["host"] == "fastapi"
async def test_client_headers_proxy(self, proxy_server: str):
"""
Test that client headers are passed through the proxy to the remove server.
"""
async with Client(transport=StreamableHttpTransport(proxy_server)) as client:
result = await client.read_resource("resource://get_headers_headers_get")
assert isinstance(result[0], TextResourceContents)
headers = json.loads(result[0].text)
assert headers["x-server-header"] == "test-abc"

View file

@ -0,0 +1,70 @@
import pytest
from fastmcp import Client, Context, FastMCP
PROGRESS_MESSAGES = []
@pytest.fixture(autouse=True)
def clear_progress_messages():
PROGRESS_MESSAGES.clear()
yield
PROGRESS_MESSAGES.clear()
@pytest.fixture
def fastmcp_server():
mcp = FastMCP()
@mcp.tool()
async def progress_tool(context: Context) -> int:
for i in range(3):
await context.report_progress(
progress=i + 1,
total=3,
message=f"{(i + 1) / 3 * 100:.2f}% complete",
)
return 100
return mcp
EXPECTED_PROGRESS_MESSAGES = [
dict(progress=1, total=3, message="33.33% complete"),
dict(progress=2, total=3, message="66.67% complete"),
dict(progress=3, total=3, message="100.00% complete"),
]
async def progress_handler(
progress: float, total: float | None, message: str | None
) -> None:
PROGRESS_MESSAGES.append(dict(progress=progress, total=total, message=message))
async def test_progress_handler(fastmcp_server: FastMCP):
async with Client(fastmcp_server, progress_handler=progress_handler) as client:
await client.call_tool("progress_tool", {})
assert PROGRESS_MESSAGES == EXPECTED_PROGRESS_MESSAGES
async def test_progress_handler_can_be_supplied_on_tool_call(fastmcp_server: FastMCP):
async with Client(fastmcp_server) as client:
await client.call_tool("progress_tool", {}, progress_handler=progress_handler)
assert PROGRESS_MESSAGES == EXPECTED_PROGRESS_MESSAGES
async def test_progress_handler_supplied_on_tool_call_overrides_default(
fastmcp_server: FastMCP,
):
async def bad_progress_handler(
progress: float, total: float | None, message: str | None
) -> None:
raise Exception("This should not be called")
async with Client(fastmcp_server, progress_handler=bad_progress_handler) as client:
await client.call_tool("progress_tool", {}, progress_handler=progress_handler)
assert PROGRESS_MESSAGES == EXPECTED_PROGRESS_MESSAGES

View file

@ -136,11 +136,11 @@ async def test_nested_sse_server_resolves_correctly():
assert result is True
@pytest.mark.skipif(
sys.platform == "win32",
reason="Timeout tests are flaky on Windows. Timeouts *are* supported but the tests are unreliable.",
)
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,
@ -167,10 +167,6 @@ class TestTimeout:
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
):

127
tests/client/test_stdio.py Normal file
View file

@ -0,0 +1,127 @@
import inspect
import pytest
from mcp.types import TextContent
from fastmcp import Client
from fastmcp.client.transports import PythonStdioTransport, StdioTransport
class TestKeepAlive:
# https://github.com/jlowin/fastmcp/issues/581
@pytest.fixture
def stdio_script(self, tmp_path):
script = inspect.cleandoc('''
import os
from fastmcp import FastMCP
mcp = FastMCP()
@mcp.tool()
def pid() -> int:
"""Gets PID of server"""
return os.getpid()
if __name__ == "__main__":
mcp.run()
''')
script_file = tmp_path / "stdio.py"
script_file.write_text(script)
return script_file
async def test_keep_alive_default_true(self):
client = Client(transport=StdioTransport(command="python", args=[""]))
assert client.transport.keep_alive is True
async def test_keep_alive_set_false(self):
client = Client(
transport=StdioTransport(command="python", args=[""], keep_alive=False)
)
assert client.transport.keep_alive is False
async def test_keep_alive_maintains_session_across_multiple_calls(
self, stdio_script
):
client = Client(transport=PythonStdioTransport(script_path=stdio_script))
assert client.transport.keep_alive is True
async with client:
result1 = await client.call_tool("pid")
assert isinstance(result1[0], TextContent)
pid1 = int(result1[0].text)
async with client:
result2 = await client.call_tool("pid")
assert isinstance(result2[0], TextContent)
pid2 = int(result2[0].text)
assert pid1 == pid2
async def test_keep_alive_false_starts_new_session_across_multiple_calls(
self, stdio_script
):
client = Client(
transport=PythonStdioTransport(script_path=stdio_script, keep_alive=False)
)
assert client.transport.keep_alive is False
async with client:
result1 = await client.call_tool("pid")
assert isinstance(result1[0], TextContent)
pid1 = int(result1[0].text)
async with client:
result2 = await client.call_tool("pid")
assert isinstance(result2[0], TextContent)
pid2 = int(result2[0].text)
assert pid1 != pid2
async def test_keep_alive_starts_new_session_if_manually_closed(self, stdio_script):
client = Client(transport=PythonStdioTransport(script_path=stdio_script))
assert client.transport.keep_alive is True
async with client:
result1 = await client.call_tool("pid")
assert isinstance(result1[0], TextContent)
pid1 = int(result1[0].text)
await client.close()
async with client:
result2 = await client.call_tool("pid")
assert isinstance(result2[0], TextContent)
pid2 = int(result2[0].text)
assert pid1 != pid2
async def test_keep_alive_maintains_session_if_reentered(self, stdio_script):
client = Client(transport=PythonStdioTransport(script_path=stdio_script))
assert client.transport.keep_alive is True
async with client:
result1 = await client.call_tool("pid")
assert isinstance(result1[0], TextContent)
pid1 = int(result1[0].text)
async with client:
result2 = await client.call_tool("pid")
assert isinstance(result2[0], TextContent)
pid2 = int(result2[0].text)
result3 = await client.call_tool("pid")
assert isinstance(result3[0], TextContent)
pid3 = int(result3[0].text)
assert pid1 == pid2 == pid3
async def test_close_session_and_try_to_use_client_raises_error(self, stdio_script):
client = Client(transport=PythonStdioTransport(script_path=stdio_script))
assert client.transport.keep_alive is True
async with client:
await client.close()
with pytest.raises(RuntimeError, match="Client is not connected"):
await client.call_tool("pid")

View file

@ -149,6 +149,10 @@ async def test_nested_streamable_http_server_resolves_correctly():
assert result is True
@pytest.mark.skipif(
sys.platform == "win32",
reason="Timeout tests are flaky on Windows. Timeouts *are* supported but the tests are unreliable.",
)
class TestTimeout:
async def test_timeout(self, streamable_http_server: str):
# note this transport behaves differently than others and raises

View file

@ -27,7 +27,9 @@ async def error_tool(arg1: str) -> dict[str, Any]:
def error_tool_result_factory(arg1: str) -> CallToolRequestResult:
"""Generates the expected error result for error_tool."""
# Mimic the error message format generated by BulkToolCaller when catching ToolException
formatted_error_text = "Error calling tool 'error_tool'"
formatted_error_text = (
"Error calling tool 'error_tool': Error in tool with arg1: " + arg1
)
return CallToolRequestResult(
isError=True,
content=[TextContent(text=formatted_error_text, type="text")],
@ -85,7 +87,6 @@ ERROR_TOOL_NAME = "error_tool"
NO_RETURN_TOOL_NAME = "no_return_tool"
@pytest.mark.asyncio
async def test_call_tool_bulk_single_success(bulk_caller_live: BulkToolCaller):
"""Test single successful call via call_tool_bulk using echo_tool."""
tool_arguments = [{"arg1": "value1"}]
@ -98,7 +99,6 @@ async def test_call_tool_bulk_single_success(bulk_caller_live: BulkToolCaller):
assert result == expected_result
@pytest.mark.asyncio
async def test_call_tool_bulk_multiple_success(bulk_caller_live: BulkToolCaller):
"""Test multiple successful calls via call_tool_bulk using echo_tool."""
tool_arguments = [{"arg1": "value1"}, {"arg1": "value2"}]
@ -110,7 +110,6 @@ async def test_call_tool_bulk_multiple_success(bulk_caller_live: BulkToolCaller)
assert results == expected_results
@pytest.mark.asyncio
async def test_call_tool_bulk_error_stops(bulk_caller_live: BulkToolCaller):
"""Test call_tool_bulk stops on first error using error_tool."""
tool_arguments = [{"arg1": "error_value"}, {"arg1": "value2"}]
@ -125,7 +124,6 @@ async def test_call_tool_bulk_error_stops(bulk_caller_live: BulkToolCaller):
assert result == expected_result
@pytest.mark.asyncio
async def test_call_tool_bulk_error_continues(bulk_caller_live: BulkToolCaller):
"""Test call_tool_bulk continues on error using error_tool and echo_tool."""
tool_arguments = [{"arg1": "error_value"}, {"arg1": "success_value"}]
@ -148,7 +146,6 @@ async def test_call_tool_bulk_error_continues(bulk_caller_live: BulkToolCaller):
assert success_result == expected_success_result
@pytest.mark.asyncio
async def test_call_tools_bulk_single_success(bulk_caller_live: BulkToolCaller):
"""Test single successful call via call_tools_bulk using echo_tool."""
tool_calls = [CallToolRequest(tool=ECHO_TOOL_NAME, arguments={"arg1": "value1"})]
@ -161,7 +158,6 @@ async def test_call_tools_bulk_single_success(bulk_caller_live: BulkToolCaller):
assert result == expected_result
@pytest.mark.asyncio
async def test_call_tools_bulk_multiple_success(bulk_caller_live: BulkToolCaller):
"""Test multiple successful calls via call_tools_bulk with different tools."""
tool_calls = [
@ -181,7 +177,6 @@ async def test_call_tools_bulk_multiple_success(bulk_caller_live: BulkToolCaller
assert results == expected_results
@pytest.mark.asyncio
async def test_call_tools_bulk_error_stops(bulk_caller_live: BulkToolCaller):
"""Test call_tools_bulk stops on first error using error_tool."""
tool_calls = [
@ -199,7 +194,6 @@ async def test_call_tools_bulk_error_stops(bulk_caller_live: BulkToolCaller):
assert result == expected_result
@pytest.mark.asyncio
async def test_call_tools_bulk_error_continues(bulk_caller_live: BulkToolCaller):
"""Test call_tools_bulk continues on error using error_tool and echo_tool."""
tool_calls = [

View file

View file

@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch
import pytest
from starlette.applications import Starlette
from fastmcp import FastMCP
from fastmcp import Client, FastMCP
def test_fastmcp_kwargs_settings_deprecation_warning():
@ -40,7 +40,6 @@ def test_streamable_http_app_deprecation_warning():
assert isinstance(app, Starlette)
@pytest.mark.asyncio
async def test_run_sse_async_deprecation_warning():
"""Test that run_sse_async raises a deprecation warning."""
server = FastMCP("TestServer")
@ -58,7 +57,6 @@ async def test_run_sse_async_deprecation_warning():
assert call_kwargs.get("transport") == "sse"
@pytest.mark.asyncio
async def test_run_streamable_http_async_deprecation_warning():
"""Test that run_streamable_http_async raises a deprecation warning."""
server = FastMCP("TestServer")
@ -91,3 +89,90 @@ def test_http_app_with_sse_transport():
w for w in recorded_warnings if issubclass(w.category, DeprecationWarning)
]
assert len(deprecation_warnings) == 0
def test_from_client_deprecation_warning():
"""Test that FastMCP.from_client raises a deprecation warning."""
server = FastMCP("TestServer")
with pytest.warns(DeprecationWarning, match="from_client"):
FastMCP.from_client(Client(server))
def test_mount_tool_separator_deprecation_warning():
"""Test that using tool_separator in mount() raises a deprecation warning."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
with pytest.warns(
DeprecationWarning,
match="The tool_separator parameter is deprecated and will be removed in a future version",
):
main_app.mount("sub", sub_app, tool_separator="-")
# Verify the separator is ignored and the default is used
@sub_app.tool()
def test_tool():
return "test"
mounted_server = main_app._mounted_servers["sub"]
assert mounted_server.match_tool("sub_test_tool")
assert not mounted_server.match_tool("sub-test_tool")
def test_mount_resource_separator_deprecation_warning():
"""Test that using resource_separator in mount() raises a deprecation warning."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
with pytest.warns(
DeprecationWarning,
match="The resource_separator parameter is deprecated and ignored",
):
main_app.mount("sub", sub_app, resource_separator="+")
def test_mount_prompt_separator_deprecation_warning():
"""Test that using prompt_separator in mount() raises a deprecation warning."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
with pytest.warns(
DeprecationWarning,
match="The prompt_separator parameter is deprecated and will be removed in a future version",
):
main_app.mount("sub", sub_app, prompt_separator="-")
# Verify the separator is ignored and the default is used
@sub_app.prompt()
def test_prompt():
return "test"
mounted_server = main_app._mounted_servers["sub"]
assert mounted_server.match_prompt("sub_test_prompt")
assert not mounted_server.match_prompt("sub-test_prompt")
async def test_import_server_separator_deprecation_warnings():
"""Test that using separators in import_server() raises deprecation warnings."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
with pytest.warns(
DeprecationWarning,
match="The tool_separator parameter is deprecated and will be removed in a future version",
):
await main_app.import_server("sub", sub_app, tool_separator="-")
main_app = FastMCP("MainApp")
with pytest.warns(
DeprecationWarning,
match="The resource_separator parameter is deprecated and ignored",
):
await main_app.import_server("sub", sub_app, resource_separator="+")
main_app = FastMCP("MainApp")
with pytest.warns(
DeprecationWarning,
match="The prompt_separator parameter is deprecated and will be removed in a future version",
):
await main_app.import_server("sub", sub_app, prompt_separator="-")

View file

@ -0,0 +1,85 @@
"""Tests for the deprecated separator parameters in mount() and import_server() methods."""
import pytest
from fastmcp import FastMCP
def test_mount_tool_separator_deprecation_warning():
"""Test that using tool_separator in mount() raises a deprecation warning."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
with pytest.warns(
DeprecationWarning,
match="The tool_separator parameter is deprecated and will be removed in a future version",
):
main_app.mount("sub", sub_app, tool_separator="-")
# Verify the separator is ignored and the default is used
@sub_app.tool()
def test_tool():
return "test"
mounted_server = main_app._mounted_servers["sub"]
assert mounted_server.match_tool("sub_test_tool")
assert not mounted_server.match_tool("sub-test_tool")
def test_mount_resource_separator_deprecation_warning():
"""Test that using resource_separator in mount() raises a deprecation warning."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
with pytest.warns(
DeprecationWarning,
match="The resource_separator parameter is deprecated and ignored",
):
main_app.mount("sub", sub_app, resource_separator="+")
def test_mount_prompt_separator_deprecation_warning():
"""Test that using prompt_separator in mount() raises a deprecation warning."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
with pytest.warns(
DeprecationWarning,
match="The prompt_separator parameter is deprecated and will be removed in a future version",
):
main_app.mount("sub", sub_app, prompt_separator="-")
# Verify the separator is ignored and the default is used
@sub_app.prompt()
def test_prompt():
return "test"
mounted_server = main_app._mounted_servers["sub"]
assert mounted_server.match_prompt("sub_test_prompt")
assert not mounted_server.match_prompt("sub-test_prompt")
async def test_import_server_separator_deprecation_warnings():
"""Test that using separators in import_server() raises deprecation warnings."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
with pytest.warns(
DeprecationWarning,
match="The tool_separator parameter is deprecated and will be removed in a future version",
):
await main_app.import_server("sub", sub_app, tool_separator="-")
main_app = FastMCP("MainApp")
with pytest.warns(
DeprecationWarning,
match="The resource_separator parameter is deprecated and ignored",
):
await main_app.import_server("sub", sub_app, resource_separator="+")
main_app = FastMCP("MainApp")
with pytest.warns(
DeprecationWarning,
match="The prompt_separator parameter is deprecated and will be removed in a future version",
):
await main_app.import_server("sub", sub_app, prompt_separator="-")

View file

@ -0,0 +1,98 @@
"""Tests for legacy resource prefix behavior."""
from fastmcp import Client, FastMCP
from fastmcp.server.server import (
add_resource_prefix,
has_resource_prefix,
remove_resource_prefix,
)
from fastmcp.utilities.tests import temporary_settings
class TestLegacyResourcePrefixes:
"""Test the legacy resource prefix behavior."""
def test_add_resource_prefix_legacy(self):
"""Test that add_resource_prefix uses the legacy format when resource_prefix_format is 'protocol'."""
with temporary_settings(resource_prefix_format="protocol"):
result = add_resource_prefix("resource://path/to/resource", "prefix")
assert result == "prefix+resource://path/to/resource"
# Empty prefix should return the original URI
result = add_resource_prefix("resource://path/to/resource", "")
assert result == "resource://path/to/resource"
def test_remove_resource_prefix_legacy(self):
"""Test that remove_resource_prefix uses the legacy format when resource_prefix_format is 'protocol'."""
with temporary_settings(resource_prefix_format="protocol"):
result = remove_resource_prefix(
"prefix+resource://path/to/resource", "prefix"
)
assert result == "resource://path/to/resource"
# URI without the prefix should be returned as is
result = remove_resource_prefix("resource://path/to/resource", "prefix")
assert result == "resource://path/to/resource"
# Empty prefix should return the original URI
result = remove_resource_prefix("resource://path/to/resource", "")
assert result == "resource://path/to/resource"
def test_has_resource_prefix_legacy(self):
"""Test that has_resource_prefix uses the legacy format when resource_prefix_format is 'protocol'."""
with temporary_settings(resource_prefix_format="protocol"):
result = has_resource_prefix("prefix+resource://path/to/resource", "prefix")
assert result is True
result = has_resource_prefix("resource://path/to/resource", "prefix")
assert result is False
# Empty prefix should always return False
result = has_resource_prefix("resource://path/to/resource", "")
assert result is False
async def test_mount_with_legacy_prefixes():
"""Test mounting a server with legacy resource prefixes."""
with temporary_settings(resource_prefix_format="protocol"):
main_server = FastMCP("MainServer")
sub_server = FastMCP("SubServer")
@sub_server.resource("resource://test")
def get_test():
return "test content"
# Mount the server with a prefix
main_server.mount("sub", sub_server)
# Check that the resource is prefixed using the legacy format
resources = await main_server.get_resources()
# In legacy format, the key would be "sub+resource://test"
assert "sub+resource://test" in resources
# Test accessing the resource through client
async with Client(main_server) as client:
result = await client.read_resource("sub+resource://test")
# Different content types might be returned, but we just want to verify we got something
assert len(result) > 0
async def test_import_server_with_legacy_prefixes():
"""Test importing a server with legacy resource prefixes."""
with temporary_settings(resource_prefix_format="protocol"):
main_server = FastMCP("MainServer")
sub_server = FastMCP("SubServer")
@sub_server.resource("resource://test")
def get_test():
return "test content"
# Import the server with a prefix
await main_server.import_server("sub", sub_server)
# Check that the resource is prefixed using the legacy format
resources = main_server._resource_manager.get_resources()
# In legacy format, the key would be "sub+resource://test"
assert "sub+resource://test" in resources

View file

@ -0,0 +1,113 @@
"""Tests for the deprecated RouteType.IGNORE."""
import warnings
import httpx
import pytest
from fastmcp.server.openapi import (
FastMCPOpenAPI,
MCPType,
RouteMap,
RouteType,
)
def test_route_type_ignore_deprecation_warning():
"""Test that using RouteType.IGNORE emits a deprecation warning."""
# Let's manually capture the warnings
# Record all warnings
with warnings.catch_warnings(record=True) as recorded:
# Make sure warnings are always triggered
warnings.simplefilter("always")
# Create a RouteMap with RouteType.IGNORE
route_map = RouteMap(
methods=["GET"], pattern=r"^/analytics$", route_type=RouteType.IGNORE
)
# Check for the expected warnings in the recorded warnings
route_type_warning = False
ignore_warning = False
for w in recorded:
if issubclass(w.category, DeprecationWarning):
message = str(w.message)
if "route_type' parameter is deprecated" in message:
route_type_warning = True
if "RouteType.IGNORE is deprecated" in message:
ignore_warning = True
# Make sure both warnings were triggered
assert route_type_warning, "Missing 'route_type' deprecation warning"
assert ignore_warning, "Missing 'RouteType.IGNORE' deprecation warning"
# Verify that RouteType.IGNORE was converted to MCPType.EXCLUDE
assert route_map.mcp_type == MCPType.EXCLUDE
class TestRouteTypeIgnoreDeprecation:
"""Test class for the deprecated RouteType.IGNORE."""
@pytest.fixture
def basic_openapi_spec(self) -> dict:
"""Create a simple OpenAPI spec for testing."""
return {
"openapi": "3.0.0",
"info": {"title": "Test API", "version": "1.0.0"},
"paths": {
"/items": {
"get": {
"operationId": "get_items",
"summary": "Get all items",
"responses": {"200": {"description": "Success"}},
}
},
"/analytics": {
"get": {
"operationId": "get_analytics",
"summary": "Get analytics data",
"responses": {"200": {"description": "Success"}},
}
},
},
}
@pytest.fixture
async def mock_client(self) -> httpx.AsyncClient:
"""Create a mock client for testing."""
async def _responder(request):
return httpx.Response(200, json={"success": True})
return httpx.AsyncClient(transport=httpx.MockTransport(_responder))
async def test_route_type_ignore_conversion(self, basic_openapi_spec, mock_client):
"""Test that routes with RouteType.IGNORE are properly excluded."""
# Capture the deprecation warning without checking the exact message
with pytest.warns(DeprecationWarning):
server = FastMCPOpenAPI(
openapi_spec=basic_openapi_spec,
client=mock_client,
route_maps=[
# Use the deprecated RouteType.IGNORE
RouteMap(
methods=["GET"],
pattern=r"^/analytics$",
route_type=RouteType.IGNORE,
),
# Make everything else a resource
RouteMap(
methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE
),
],
)
# Check that the analytics route was excluded (converted from IGNORE to EXCLUDE)
resources = await server.get_resources()
resource_uris = [str(r.uri) for r in resources.values()]
# Analytics should be excluded
assert "resource://get_items" in resource_uris
assert "resource://get_analytics" not in resource_uris

View file

@ -47,6 +47,30 @@ class TestRenderPrompt:
)
]
async def test_callable_object(self):
class MyPrompt:
def __call__(self, name: str) -> str:
return f"Hello, {name}!"
prompt = Prompt.from_function(MyPrompt())
assert await prompt.render(arguments=dict(name="World")) == [
PromptMessage(
role="user", content=TextContent(type="text", text="Hello, World!")
)
]
async def test_async_callable_object(self):
class MyPrompt:
async def __call__(self, name: str) -> str:
return f"Hello, {name}!"
prompt = Prompt.from_function(MyPrompt())
assert await prompt.render(arguments=dict(name="World")) == [
PromptMessage(
role="user", content=TextContent(type="text", text="Hello, World!")
)
]
async def test_fn_with_invalid_kwargs(self):
async def fn(name: str, age: int = 30) -> str:
return f"Hello, {name}! You're {age} years old."

View file

@ -3,7 +3,7 @@ from typing import Annotated
import pytest
from fastmcp import Context
from fastmcp.exceptions import NotFoundError
from fastmcp.exceptions import NotFoundError, PromptError
from fastmcp.prompts import Prompt
from fastmcp.prompts.prompt import PromptMessage, TextContent
from fastmcp.prompts.prompt_manager import PromptManager
@ -141,6 +141,8 @@ class TestPromptManager:
assert prompts["fn1"] == prompt1
assert prompts["fn2"] == prompt2
class TestRenderPrompt:
async def test_render_prompt(self):
"""Test rendering a prompt."""
@ -177,6 +179,48 @@ class TestPromptManager:
)
]
async def test_render_prompt_callable_object(self):
"""Test rendering a prompt with a callable object."""
class MyPrompt:
"""A callable object that can be used as a prompt."""
def __call__(self, name: str) -> str:
"""ignore this"""
return f"Hello, {name}!"
manager = PromptManager()
prompt = Prompt.from_function(MyPrompt())
manager.add_prompt(prompt)
result = await manager.render_prompt("MyPrompt", arguments={"name": "World"})
assert result.description == "A callable object that can be used as a prompt."
assert result.messages == [
PromptMessage(
role="user", content=TextContent(type="text", text="Hello, World!")
)
]
async def test_render_prompt_callable_object_async(self):
"""Test rendering a prompt with a callable object."""
class MyPrompt:
"""A callable object that can be used as a prompt."""
async def __call__(self, name: str) -> str:
"""ignore this"""
return f"Hello, {name}!"
manager = PromptManager()
prompt = Prompt.from_function(MyPrompt())
manager.add_prompt(prompt)
result = await manager.render_prompt("MyPrompt", arguments={"name": "World"})
assert result.description == "A callable object that can be used as a prompt."
assert result.messages == [
PromptMessage(
role="user", content=TextContent(type="text", text="Hello, World!")
)
]
async def test_render_unknown_prompt(self):
"""Test rendering a non-existent prompt."""
manager = PromptManager()
@ -192,7 +236,7 @@ class TestPromptManager:
manager = PromptManager()
prompt = Prompt.from_function(fn)
manager.add_prompt(prompt)
with pytest.raises(ValueError, match="Missing required arguments"):
with pytest.raises(PromptError, match="Missing required arguments"):
await manager.render_prompt("fn")
async def test_prompt_with_varargs_not_allowed(self):

View file

@ -99,7 +99,8 @@ class TestFileResource:
await resource.read()
@pytest.mark.skipif(
os.name == "nt", reason="File permissions behave differently on Windows"
os.name == "nt" or (hasattr(os, "getuid") and os.getuid() == 0),
reason="File permissions behave differently on Windows or when running as root",
)
async def test_permission_error(self, temp_file: Path):
"""Test reading a file without permissions."""

View file

@ -563,28 +563,6 @@ class TestResourceErrorHandling:
with pytest.raises(ResourceError, match="Specific resource error"):
await manager.read_resource("error://resource")
async def test_exception_converted_to_resource_error(self):
"""Test that other exceptions are converted to ResourceError."""
manager = ResourceManager()
async def buggy_resource():
"""Resource that raises a ValueError."""
raise ValueError("Internal error details")
resource = FunctionResource(
uri=AnyUrl("buggy://resource"),
name="buggy_resource",
fn=buggy_resource,
)
manager.add_resource(resource)
with pytest.raises(ResourceError) as excinfo:
await manager.read_resource("buggy://resource")
# Exception message should contain the resource URI but not the internal details
assert "Error reading resource 'buggy://resource'" in str(excinfo.value)
assert "Internal error details" not in str(excinfo.value)
async def test_template_resource_error_passthrough(self):
"""Test that ResourceErrors from template-generated resources are passed through."""
manager = ResourceManager()
@ -606,21 +584,46 @@ class TestResourceErrorHandling:
# The original error message should be included in the ValueError
assert "Template error with param test" in str(excinfo.value)
async def test_template_exception_converted_to_resource_error(self):
"""Test that other exceptions from template-generated resources are converted."""
async def test_exception_converted_to_resource_error_with_details(self):
"""Test that other exceptions are converted to ResourceError with details by default."""
manager = ResourceManager()
def buggy_template(param: str):
"""Template that raises a ValueError."""
raise ValueError(f"Internal template error with {param}")
async def buggy_resource():
"""Resource that raises a ValueError."""
raise ValueError("Internal error details")
template = ResourceTemplate.from_function(
fn=buggy_template,
uri_template="buggy://{param}",
name="buggy_template",
resource = FunctionResource(
uri=AnyUrl("buggy://resource"),
name="buggy_resource",
fn=buggy_resource,
)
manager.add_template(template)
manager.add_resource(resource)
# First, the template creation will fail with ValueError
with pytest.raises(ResourceError, match="Error reading resource"):
await manager.read_resource("buggy://test")
with pytest.raises(ResourceError) as excinfo:
await manager.read_resource("buggy://resource")
# The error message should include the original exception details
assert "Error reading resource 'buggy://resource'" in str(excinfo.value)
assert "Internal error details" in str(excinfo.value)
async def test_exception_converted_to_masked_resource_error(self):
"""Test that other exceptions are masked when enabled."""
manager = ResourceManager(mask_error_details=True)
async def buggy_resource():
"""Resource that raises a ValueError."""
raise ValueError("Internal error details")
resource = FunctionResource(
uri=AnyUrl("buggy://resource"),
name="buggy_resource",
fn=buggy_resource,
)
manager.add_resource(resource)
with pytest.raises(ResourceError) as excinfo:
await manager.read_resource("buggy://resource")
# The error message should not include the original exception details
assert "Error reading resource 'buggy://resource'" in str(excinfo.value)
assert "Internal error details" not in str(excinfo.value)

View file

@ -368,6 +368,31 @@ class TestResourceTemplate:
)
assert template.uri_template == "test://{x}/{y}/{z}"
async def test_callable_object_as_template(self):
"""Test that a callable object can be used as a template."""
class MyTemplate:
"""This is my template"""
def __call__(self, x: str) -> str:
"""ignore this"""
return f"X was {x}"
template = ResourceTemplate.from_function(
fn=MyTemplate(),
uri_template="test://{x}",
name="test",
)
resource = await template.create_resource(
"test://foo",
{"x": "foo"},
)
assert isinstance(resource, FunctionResource)
content = await resource.read()
assert content == "X was foo"
class TestMatchUriTemplate:
"""Test match_uri_template function."""

View file

@ -0,0 +1,105 @@
import pytest
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route
from fastmcp import FastMCP
from fastmcp.server.http import create_sse_app, create_streamable_http_app
class TestCustomRoutes:
@pytest.fixture
def server_with_custom_route(self):
"""Create a FastMCP server with a custom route."""
server = FastMCP()
@server.custom_route("/custom-route", methods=["GET"])
async def custom_route(request: Request):
return JSONResponse({"message": "custom route"})
return server
def test_custom_routes_via_server_http_app(self, server_with_custom_route):
"""Test that custom routes are included when using server.http_app()."""
# Get the app via server.http_app()
app = server_with_custom_route.http_app()
# Verify that the custom route is included
custom_route_found = False
for route in app.routes:
if isinstance(route, Route) and route.path == "/custom-route":
custom_route_found = True
break
assert custom_route_found, "Custom route was not found in app routes"
def test_custom_routes_via_streamable_http_app_direct(
self, server_with_custom_route
):
"""Test that custom routes are included when using create_streamable_http_app directly."""
# Create the app by calling the constructor function directly
app = create_streamable_http_app(
server=server_with_custom_route, streamable_http_path="/api"
)
# Verify that the custom route is included
custom_route_found = False
for route in app.routes:
if isinstance(route, Route) and route.path == "/custom-route":
custom_route_found = True
break
assert custom_route_found, "Custom route was not found in app routes"
def test_custom_routes_via_sse_app_direct(self, server_with_custom_route):
"""Test that custom routes are included when using create_sse_app directly."""
# Create the app by calling the constructor function directly
app = create_sse_app(
server=server_with_custom_route, message_path="/message", sse_path="/sse"
)
# Verify that the custom route is included
custom_route_found = False
for route in app.routes:
if isinstance(route, Route) and route.path == "/custom-route":
custom_route_found = True
break
assert custom_route_found, "Custom route was not found in app routes"
def test_multiple_custom_routes(
self,
):
"""Test that multiple custom routes are included in both methods."""
server = FastMCP()
custom_paths = ["/route1", "/route2", "/route3"]
# Add multiple custom routes
for path in custom_paths:
@server.custom_route(path, methods=["GET"])
async def custom_route(request: Request):
return JSONResponse({"message": f"route {path}"})
# Test with server.http_app()
app1 = server.http_app()
# Test with direct constructor call
app2 = create_streamable_http_app(server=server, streamable_http_path="/api")
# Check all routes are in both apps
for path in custom_paths:
# Check in app1
route_in_app1 = any(
isinstance(route, Route) and route.path == path for route in app1.routes
)
assert route_in_app1, f"Route {path} not found in server.http_app()"
# Check in app2
route_in_app2 = any(
isinstance(route, Route) and route.path == path for route in app2.routes
)
assert route_in_app2, (
f"Route {path} not found in create_streamable_http_app()"
)

View file

@ -0,0 +1,168 @@
import json
import sys
from collections.abc import Generator
import pytest
import uvicorn
from mcp.types import TextContent, TextResourceContents
from fastmcp.client import Client
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
from fastmcp.server.dependencies import get_http_request
from fastmcp.server.server import FastMCP
from fastmcp.utilities.tests import run_server_in_process
def fastmcp_server():
server = FastMCP()
# Add a tool
@server.tool()
def get_headers_tool() -> dict[str, str]:
"""Get the HTTP headers from the request."""
request = get_http_request()
return dict(request.headers)
@server.resource(uri="request://headers")
async def get_headers_resource() -> dict[str, str]:
request = get_http_request()
return dict(request.headers)
# Add a prompt
@server.prompt()
def get_headers_prompt() -> str:
"""Get the HTTP headers from the request."""
request = get_http_request()
return json.dumps(dict(request.headers))
return server
def run_shttp_server(host: str, port: int) -> None:
try:
app = fastmcp_server().http_app(transport="streamable-http")
server = uvicorn.Server(
config=uvicorn.Config(
app=app,
host=host,
port=port,
log_level="error",
lifespan="on",
)
)
server.run()
except Exception as e:
print(f"Server error: {e}")
sys.exit(1)
sys.exit(0)
def run_sse_server(host: str, port: int) -> None:
try:
app = fastmcp_server().http_app(transport="sse")
server = uvicorn.Server(
config=uvicorn.Config(
app=app,
host=host,
port=port,
log_level="error",
lifespan="on",
)
)
server.run()
except Exception as e:
print(f"Server error: {e}")
sys.exit(1)
sys.exit(0)
@pytest.fixture(autouse=True, scope="module")
def shttp_server() -> Generator[str, None, None]:
with run_server_in_process(run_shttp_server) as url:
yield f"{url}/mcp"
@pytest.fixture(autouse=True, scope="module")
def sse_server() -> Generator[str, None, None]:
with run_server_in_process(run_sse_server) as url:
yield f"{url}/sse"
async def test_http_headers_resource_shttp(shttp_server: str):
"""Test getting HTTP headers from the server."""
async with Client(
transport=StreamableHttpTransport(
shttp_server, headers={"X-DEMO-HEADER": "ABC"}
)
) as client:
raw_result = await client.read_resource("request://headers")
assert isinstance(raw_result[0], TextResourceContents)
json_result = json.loads(raw_result[0].text)
assert "x-demo-header" in json_result
assert json_result["x-demo-header"] == "ABC"
async def test_http_headers_resource_sse(sse_server: str):
"""Test getting HTTP headers from the server."""
async with Client(
transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
) as client:
raw_result = await client.read_resource("request://headers")
assert isinstance(raw_result[0], TextResourceContents)
json_result = json.loads(raw_result[0].text)
assert "x-demo-header" in json_result
assert json_result["x-demo-header"] == "ABC"
async def test_http_headers_tool_shttp(shttp_server: str):
"""Test getting HTTP headers from the server."""
async with Client(
transport=StreamableHttpTransport(
shttp_server, headers={"X-DEMO-HEADER": "ABC"}
)
) as client:
result = await client.call_tool("get_headers_tool")
assert isinstance(result[0], TextContent)
json_result = json.loads(result[0].text)
assert "x-demo-header" in json_result
assert json_result["x-demo-header"] == "ABC"
async def test_http_headers_tool_sse(sse_server: str):
async with Client(
transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
) as client:
result = await client.call_tool("get_headers_tool")
assert isinstance(result[0], TextContent)
json_result = json.loads(result[0].text)
assert "x-demo-header" in json_result
assert json_result["x-demo-header"] == "ABC"
async def test_http_headers_prompt_shttp(shttp_server: str):
"""Test getting HTTP headers from the server."""
async with Client(
transport=StreamableHttpTransport(
shttp_server, headers={"X-DEMO-HEADER": "ABC"}
)
) as client:
result = await client.get_prompt("get_headers_prompt")
assert isinstance(result.messages[0].content, TextContent)
json_result = json.loads(result.messages[0].content.text)
assert "x-demo-header" in json_result
assert json_result["x-demo-header"] == "ABC"
async def test_http_headers_prompt_sse(sse_server: str):
"""Test getting HTTP headers from the server."""
async with Client(
transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
) as client:
result = await client.get_prompt("get_headers_prompt")
assert isinstance(result.messages[0].content, TextContent)
json_result = json.loads(result.messages[0].content.text)
assert "x-demo-header" in json_result
assert json_result["x-demo-header"] == "ABC"

View file

@ -4,7 +4,6 @@ from collections.abc import Callable
from typing import Any
import httpx
import pytest
from httpx import ASGITransport
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
@ -51,7 +50,6 @@ async def endpoint_handler(request: Request):
return JSONResponse({"message": "Hello, world!"})
@pytest.mark.asyncio
async def test_sse_app_with_custom_middleware():
"""Test that custom middleware works with SSE app."""
server = FastMCP(name="TestServer")
@ -82,7 +80,6 @@ async def test_sse_app_with_custom_middleware():
assert response.headers["X-Custom-Header"] == "test-value"
@pytest.mark.asyncio
async def test_streamable_http_app_with_custom_middleware():
"""Test that custom middleware works with StreamableHTTP app."""
server = FastMCP(name="TestServer")
@ -113,7 +110,6 @@ async def test_streamable_http_app_with_custom_middleware():
assert response.headers["X-Custom-Header"] == "test-value"
@pytest.mark.asyncio
async def test_create_sse_app_with_custom_middleware():
"""Test that custom middleware works with create_sse_app function."""
server = FastMCP(name="TestServer")
@ -149,7 +145,6 @@ async def test_create_sse_app_with_custom_middleware():
assert data["state"]["modified_by"] == "middleware"
@pytest.mark.asyncio
async def test_create_streamable_http_app_with_custom_middleware():
"""Test that custom middleware works with create_streamable_http_app function."""
server = FastMCP(name="TestServer")
@ -184,7 +179,6 @@ async def test_create_streamable_http_app_with_custom_middleware():
assert data["state"]["modified_by"] == "middleware"
@pytest.mark.asyncio
async def test_multiple_middleware_ordering():
"""Test that multiple middleware are applied in the correct order."""
server = FastMCP(name="TestServer")

View file

@ -18,11 +18,11 @@ from fastmcp.client import Client
from fastmcp.exceptions import ToolError
from fastmcp.server.openapi import (
FastMCPOpenAPI,
MCPType,
OpenAPIResource,
OpenAPIResourceTemplate,
OpenAPITool,
RouteMap,
RouteType,
)
@ -208,7 +208,7 @@ class TestTools:
},
)
assert tools[1].model_dump() == dict(
name="update_user_name_users__user_id__name_patch",
name="update_user_name_users",
annotations=None,
description=IsStr(
regex=r"^Update a user's name\..*$", regex_flags=re.DOTALL
@ -248,9 +248,7 @@ class TestTools:
# Check that the user was created via MCP
async with Client(fastmcp_openapi_server) as client:
user_response = await client.read_resource(
"resource://openapi/get_user_users__user_id__get/4"
)
user_response = await client.read_resource("resource://get_user_users/4")
assert isinstance(user_response[0], TextResourceContents)
response_text = user_response[0].text
user = json.loads(response_text)
@ -264,7 +262,7 @@ class TestTools:
"""
async with Client(fastmcp_openapi_server) as client:
tool_response = await client.call_tool(
"update_user_name_users__user_id__name_patch",
"update_user_name_users",
{"user_id": 1, "name": "XYZ"},
)
@ -282,9 +280,7 @@ class TestTools:
# Check that the user was updated via MCP
async with Client(fastmcp_openapi_server) as client:
user_response = await client.read_resource(
"resource://openapi/get_user_users__user_id__get/1"
)
user_response = await client.read_resource("resource://get_user_users/1")
assert isinstance(user_response[0], TextResourceContents)
response_text = user_response[0].text
user = json.loads(response_text)
@ -304,7 +300,7 @@ class TestTools:
openapi_spec=openapi_spec,
client=api_client,
route_maps=[
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.TOOL)
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)
],
)
async with Client(mcp_server) as client:
@ -325,7 +321,7 @@ class TestResources:
async with Client(fastmcp_openapi_server) as client:
resources = await client.list_resources()
assert len(resources) == 4
assert resources[0].uri == AnyUrl("resource://openapi/get_users_users_get")
assert resources[0].uri == AnyUrl("resource://get_users_users_get")
assert resources[0].name == "get_users_users_get"
async def test_get_resource(
@ -343,7 +339,7 @@ class TestResources:
)
async with Client(fastmcp_openapi_server) as client:
resource_response = await client.read_resource(
"resource://openapi/get_users_users_get"
"resource://get_users_users_get"
)
assert isinstance(resource_response[0], TextResourceContents)
response_text = resource_response[0].text
@ -360,7 +356,7 @@ class TestResources:
"""Test reading a resource that returns bytes."""
async with Client(fastmcp_openapi_server) as client:
resource_response = await client.read_resource(
"resource://openapi/ping_bytes_ping_bytes_get"
"resource://ping_bytes_ping_bytes_get"
)
assert isinstance(resource_response[0], BlobResourceContents)
assert base64.b64decode(resource_response[0].blob) == b"pong"
@ -372,9 +368,7 @@ class TestResources:
):
"""Test reading a resource that returns a string."""
async with Client(fastmcp_openapi_server) as client:
resource_response = await client.read_resource(
"resource://openapi/ping_ping_get"
)
resource_response = await client.read_resource("resource://ping_ping_get")
assert isinstance(resource_response[0], TextResourceContents)
assert resource_response[0].text == "pong"
@ -389,18 +383,14 @@ class TestResourceTemplates:
async with Client(fastmcp_openapi_server) as client:
resource_templates = await client.list_resource_templates()
assert len(resource_templates) == 2
assert resource_templates[0].name == "get_user_users__user_id__get"
assert resource_templates[0].name == "get_user_users"
assert (
resource_templates[0].uriTemplate
== r"resource://openapi/get_user_users__user_id__get/{user_id}"
)
assert (
resource_templates[1].name
== "get_user_active_state_users__user_id___is_active__get"
resource_templates[0].uriTemplate == r"resource://get_user_users/{user_id}"
)
assert resource_templates[1].name == "get_user_active_state_users"
assert (
resource_templates[1].uriTemplate
== r"resource://openapi/get_user_active_state_users__user_id___is_active__get/{is_active}/{user_id}"
== r"resource://get_user_active_state_users/{is_active}/{user_id}"
)
async def test_get_resource_template(
@ -415,7 +405,7 @@ class TestResourceTemplates:
user_id = 2
async with Client(fastmcp_openapi_server) as client:
resource_response = await client.read_resource(
f"resource://openapi/get_user_users__user_id__get/{user_id}"
f"resource://get_user_users/{user_id}"
)
assert isinstance(resource_response[0], TextResourceContents)
response_text = resource_response[0].text
@ -438,7 +428,7 @@ class TestResourceTemplates:
is_active = True
async with Client(fastmcp_openapi_server) as client:
resource_response = await client.read_resource(
f"resource://openapi/get_user_active_state_users__user_id___is_active__get/{is_active}/{user_id}"
f"resource://get_user_active_state_users/{is_active}/{user_id}"
)
assert isinstance(resource_response[0], TextResourceContents)
response_text = resource_response[0].text
@ -474,11 +464,7 @@ class TestTagTransfer:
(t for t in tools if t.name == "create_user_users_post"), None
)
update_user_tool = next(
(
t
for t in tools
if t.name == "update_user_name_users__user_id__name_patch"
),
(t for t in tools if t.name == "update_user_name_users"),
None,
)
@ -526,7 +512,7 @@ class TestTagTransfer:
# Find the get_user template
get_user_template = next(
(t for t in templates if t.name == "get_user_users__user_id__get"), None
(t for t in templates if t.name == "get_user_users"), None
)
assert get_user_template is not None
@ -547,7 +533,7 @@ class TestTagTransfer:
# Find the get_user template
get_user_template = next(
(t for t in templates if t.name == "get_user_users__user_id__get"), None
(t for t in templates if t.name == "get_user_users"), None
)
assert get_user_template is not None
@ -555,7 +541,7 @@ class TestTagTransfer:
# Manually create a resource from template
params = {"user_id": 1}
resource = await get_user_template.create_resource(
"resource://openapi/get_user_users__user_id__get/1", params
"resource://get_user_users/1", params
)
# Verify tags are preserved from template to resource
@ -672,7 +658,7 @@ class TestOpenAPI30Compatibility:
async with Client(openapi_30_server) as client:
resources = await client.list_resources()
assert len(resources) == 1
assert resources[0].uri == AnyUrl("resource://openapi/listProducts")
assert resources[0].uri == AnyUrl("resource://listProducts")
async def test_resource_template_discovery(self, openapi_30_server):
"""Test that resource templates are correctly discovered from an OpenAPI 3.0 spec."""
@ -680,7 +666,7 @@ class TestOpenAPI30Compatibility:
templates = await client.list_resource_templates()
assert len(templates) == 1
assert templates[0].name == "getProduct"
assert templates[0].uriTemplate == r"resource://openapi/getProduct/{product_id}"
assert templates[0].uriTemplate == r"resource://getProduct/{product_id}"
async def test_tool_discovery(self, openapi_30_server):
"""Test that tools are correctly discovered from an OpenAPI 3.0 spec."""
@ -694,9 +680,7 @@ class TestOpenAPI30Compatibility:
async def test_resource_access(self, openapi_30_server):
"""Test reading a resource from an OpenAPI 3.0 server."""
async with Client(openapi_30_server) as client:
resource_response = await client.read_resource(
"resource://openapi/listProducts"
)
resource_response = await client.read_resource("resource://listProducts")
assert isinstance(resource_response[0], TextResourceContents)
response_text = resource_response[0].text
content = json.loads(response_text)
@ -707,9 +691,7 @@ class TestOpenAPI30Compatibility:
async def test_resource_template_access(self, openapi_30_server):
"""Test reading a resource from template from an OpenAPI 3.0 server."""
async with Client(openapi_30_server) as client:
resource_response = await client.read_resource(
"resource://openapi/getProduct/p1"
)
resource_response = await client.read_resource("resource://getProduct/p1")
assert isinstance(resource_response[0], TextResourceContents)
response_text = resource_response[0].text
content = json.loads(response_text)
@ -852,7 +834,7 @@ class TestOpenAPI31Compatibility:
async with Client(openapi_31_server) as client:
resources = await client.list_resources()
assert len(resources) == 1
assert resources[0].uri == AnyUrl("resource://openapi/listOrders")
assert resources[0].uri == AnyUrl("resource://listOrders")
async def test_resource_template_discovery(self, openapi_31_server):
"""Test that resource templates are correctly discovered from an OpenAPI 3.1 spec."""
@ -860,7 +842,7 @@ class TestOpenAPI31Compatibility:
templates = await client.list_resource_templates()
assert len(templates) == 1
assert templates[0].name == "getOrder"
assert templates[0].uriTemplate == r"resource://openapi/getOrder/{order_id}"
assert templates[0].uriTemplate == r"resource://getOrder/{order_id}"
async def test_tool_discovery(self, openapi_31_server):
"""Test that tools are correctly discovered from an OpenAPI 3.1 spec."""
@ -874,9 +856,7 @@ class TestOpenAPI31Compatibility:
async def test_resource_access(self, openapi_31_server):
"""Test reading a resource from an OpenAPI 3.1 server."""
async with Client(openapi_31_server) as client:
resource_response = await client.read_resource(
"resource://openapi/listOrders"
)
resource_response = await client.read_resource("resource://listOrders")
assert isinstance(resource_response[0], TextResourceContents)
response_text = resource_response[0].text
content = json.loads(response_text)
@ -887,9 +867,7 @@ class TestOpenAPI31Compatibility:
async def test_resource_template_access(self, openapi_31_server):
"""Test reading a resource from template from an OpenAPI 3.1 server."""
async with Client(openapi_31_server) as client:
resource_response = await client.read_resource(
"resource://openapi/getOrder/o1"
)
resource_response = await client.read_resource("resource://getOrder/o1")
assert isinstance(resource_response[0], TextResourceContents)
response_text = resource_response[0].text
content = json.loads(response_text)
@ -927,32 +905,10 @@ class TestMountFastMCP:
assert len(resources) == 4 # Updated to account for new search endpoint
# We're checking the key used by mcp to store the resource
# The prefixed URI is used as the key, but the resource's original uri is preserved
prefixed_uri = "fastapi+resource://openapi/get_users_users_get"
prefixed_uri = "resource://fastapi/get_users_users_get"
resource = mcp._resource_manager.get_resources().get(prefixed_uri)
assert resource is not None
# Check that templates are available with prefixed URIs
async with Client(mcp) as client:
templates = await client.list_resource_templates()
assert len(templates) == 2
assert templates[0].name == "get_user_users__user_id__get"
prefixed_template_uri = (
r"fastapi+resource://openapi/get_user_users__user_id__get/{user_id}"
)
template = mcp._resource_manager.get_templates().get(prefixed_template_uri)
assert template is not None
# Check that tools are available with prefixed names
async with Client(mcp) as client:
tools = await client.list_tools()
assert len(tools) == 2
assert tools[0].name == "fastapi_create_user_users_post"
assert tools[1].name == "fastapi_update_user_name_users__user_id__name_patch"
async with Client(mcp) as client:
prompts = await client.list_prompts()
assert len(prompts) == 0
async def test_empty_query_parameters_not_sent(
fastapi_app: FastAPI, api_client: httpx.AsyncClient
@ -978,9 +934,7 @@ async def test_empty_query_parameters_not_sent(
mcp_server = FastMCPOpenAPI(
openapi_spec=openapi_spec,
client=api_client,
route_maps=[
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.TOOL)
],
route_maps=[RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)],
)
# Call the search tool with mixed parameter values
@ -1031,7 +985,7 @@ async def test_none_path_parameters_rejected(
# get_user has a required path parameter user_id
with pytest.raises(ToolError, match="Missing required path parameters"):
await client.call_tool(
"update_user_name_users__user_id__name_patch",
"update_user_name_users",
{
"user_id": None, # This should cause an error
"name": "New Name",
@ -1521,17 +1475,15 @@ class TestFastAPIDescriptionPropagation:
# Create custom route mappings
route_maps = [
# Map GET /items to Resource
RouteMap(
methods=["GET"], pattern=r"^/items$", route_type=RouteType.RESOURCE
),
RouteMap(methods=["GET"], pattern=r"^/items$", mcp_type=MCPType.RESOURCE),
# Map GET /items/{item_id} to ResourceTemplate
RouteMap(
methods=["GET"],
pattern=r"^/items/\{.*\}$",
route_type=RouteType.RESOURCE_TEMPLATE,
mcp_type=MCPType.RESOURCE_TEMPLATE,
),
# Map POST /items to Tool
RouteMap(methods=["POST"], pattern=r"^/items$", route_type=RouteType.TOOL),
RouteMap(methods=["POST"], pattern=r"^/items$", mcp_type=MCPType.TOOL),
]
# Create FastMCP server with the OpenAPI spec and custom route mappings
@ -1596,9 +1548,7 @@ class TestFastAPIDescriptionPropagation:
async def test_template_includes_function_docstring(self, fastapi_server):
"""Test that a ResourceTemplate includes the function docstring."""
templates = list(fastapi_server._resource_manager.get_templates().values())
get_template = next(
(t for t in templates if "items__item_id__get" in t.name), None
)
get_template = next((t for t in templates if "get_item_items" in t.name), None)
assert get_template is not None, "GET /items/{item_id} template wasn't created"
description = get_template.description or ""
@ -1613,9 +1563,7 @@ class TestFastAPIDescriptionPropagation:
are not properly propagated to the OpenAPI schema. The parameters appear but without the description.
"""
templates = list(fastapi_server._resource_manager.get_templates().values())
get_template = next(
(t for t in templates if "items__item_id__get" in t.name), None
)
get_template = next((t for t in templates if "get_item_items" in t.name), None)
assert get_template is not None, "GET /items/{item_id} template wasn't created"
description = get_template.description or ""
@ -1635,9 +1583,7 @@ class TestFastAPIDescriptionPropagation:
are not properly propagated to the OpenAPI schema. The parameters appear but without the description.
"""
templates = list(fastapi_server._resource_manager.get_templates().values())
get_template = next(
(t for t in templates if "items__item_id__get" in t.name), None
)
get_template = next((t for t in templates if "get_item_items" in t.name), None)
assert get_template is not None, "GET /items/{item_id} template wasn't created"
description = get_template.description or ""
@ -1653,9 +1599,7 @@ class TestFastAPIDescriptionPropagation:
async def test_template_parameter_schema_includes_description(self, fastapi_server):
"""Test that a ResourceTemplate's parameter schema includes parameter descriptions."""
templates = list(fastapi_server._resource_manager.get_templates().values())
get_template = next(
(t for t in templates if "items__item_id__get" in t.name), None
)
get_template = next((t for t in templates if "get_item_items" in t.name), None)
assert get_template is not None, "GET /items/{item_id} template wasn't created"
assert "properties" in get_template.parameters, (
@ -1727,7 +1671,7 @@ class TestFastAPIDescriptionPropagation:
async with Client(fastapi_server) as client:
templates = await client.list_resource_templates()
get_template = next(
(t for t in templates if "items__item_id__get" in t.name), None
(t for t in templates if "get_item_items" in t.name), None
)
assert get_template is not None, (
@ -1857,9 +1801,7 @@ class TestEnumHandling:
tools = server._tool_manager.list_tools()
# Find the read_item tool
read_item_tool = next(
(t for t in tools if t.name == "read_item_items__item_id__post"), None
)
read_item_tool = next((t for t in tools if t.name == "read_item_items"), None)
# Verify the tool exists
assert read_item_tool is not None, "read_item tool wasn't created"
@ -1890,3 +1832,569 @@ class TestEnumHandling:
assert "enum" in enum_def
assert enum_def["enum"] == ["foo", "bar", "baz"]
assert enum_def["type"] == "string"
class TestRouteMapWildcard:
"""Tests for wildcard RouteMap methods functionality."""
@pytest.fixture
def basic_openapi_spec(self) -> dict:
"""Create a minimal OpenAPI spec with different HTTP methods."""
return {
"openapi": "3.1.0",
"info": {"title": "Test API", "version": "1.0.0"},
"paths": {
"/users": {
"get": {
"operationId": "getUsers",
"responses": {"200": {"description": "Success"}},
},
"post": {
"operationId": "createUser",
"responses": {"201": {"description": "Created"}},
},
},
"/posts": {
"get": {
"operationId": "getPosts",
"responses": {"200": {"description": "Success"}},
},
"post": {
"operationId": "createPost",
"responses": {"201": {"description": "Created"}},
},
},
},
}
@pytest.fixture
async def mock_basic_client(self) -> httpx.AsyncClient:
"""Create a simple mock client."""
async def _responder(request):
return httpx.Response(200, json={"status": "ok"})
transport = httpx.MockTransport(_responder)
return httpx.AsyncClient(transport=transport, base_url="http://test")
async def test_wildcard_matches_all_methods(
self, basic_openapi_spec, mock_basic_client
):
"""Test that a RouteMap with methods='*' matches all HTTP methods."""
# Create a single route map with wildcard method
route_maps = [RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]
mcp = FastMCPOpenAPI(
openapi_spec=basic_openapi_spec,
client=mock_basic_client,
route_maps=route_maps,
)
# All operations should be mapped to tools
tools = mcp._tool_manager.list_tools()
tool_names = {tool.name for tool in tools}
# Check that all 4 operations became tools
expected_tools = {"getUsers", "createUser", "getPosts", "createPost"}
assert tool_names == expected_tools
class TestRouteMapTags:
"""Tests for RouteMap tags functionality."""
@pytest.fixture
def tagged_openapi_spec(self) -> dict:
"""Create an OpenAPI spec with various tags for testing."""
return {
"openapi": "3.1.0",
"info": {"title": "Tagged API", "version": "1.0.0"},
"paths": {
"/users": {
"get": {
"operationId": "getUsers",
"tags": ["users", "public"],
"responses": {"200": {"description": "Success"}},
},
"post": {
"operationId": "createUser",
"tags": ["users", "admin"],
"responses": {"201": {"description": "Created"}},
},
},
"/admin/stats": {
"get": {
"operationId": "getAdminStats",
"tags": ["admin", "internal"],
"responses": {"200": {"description": "Success"}},
}
},
"/health": {
"get": {
"operationId": "getHealth",
"tags": ["public"],
"responses": {"200": {"description": "Success"}},
}
},
"/metrics": {
"get": {
"operationId": "getMetrics",
"responses": {"200": {"description": "Success"}},
}
},
},
}
@pytest.fixture
async def mock_client(self) -> httpx.AsyncClient:
"""Create a simple mock client."""
async def _responder(request):
return httpx.Response(200, json={"status": "ok"})
transport = httpx.MockTransport(_responder)
return httpx.AsyncClient(transport=transport, base_url="http://test")
async def test_tags_as_tools(self, tagged_openapi_spec, mock_client):
"""Test that routes with specific tags are converted to tools."""
# Convert routes with "admin" tag to tools
route_maps = [
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags={"admin"}),
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
]
server = FastMCPOpenAPI(
openapi_spec=tagged_openapi_spec,
client=mock_client,
route_maps=route_maps,
)
# Check that admin-tagged routes are tools
tools = server._tool_manager.get_tools()
tool_names = {t.name for t in tools.values()}
resources = server._resource_manager.get_resources()
resource_names = {r.name for r in resources.values()}
# Routes with "admin" tag should be tools
assert "createUser" in tool_names
assert "getAdminStats" in tool_names
# Routes without "admin" tag should be resources
assert "getUsers" in resource_names
assert "getHealth" in resource_names
assert "getMetrics" in resource_names
async def test_exclude_tags(self, tagged_openapi_spec, mock_client):
"""Test that routes with specific tags are excluded."""
# Exclude routes with "internal" tag
route_maps = [
RouteMap(
methods="*", pattern=r".*", mcp_type=MCPType.EXCLUDE, tags={"internal"}
),
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL),
]
server = FastMCPOpenAPI(
openapi_spec=tagged_openapi_spec,
client=mock_client,
route_maps=route_maps,
)
# Check that internal-tagged routes are excluded
resources = server._resource_manager.get_resources()
resource_names = {r.name for r in resources.values()}
tools = server._tool_manager.get_tools()
tool_names = {t.name for t in tools.values()}
# Internal-tagged route should be excluded
assert "getAdminStats" not in resource_names
assert "getAdminStats" not in tool_names
# Other routes should still be present
assert "getUsers" in resource_names
assert "getHealth" in resource_names
assert "getMetrics" in resource_names
assert "createUser" in tool_names
async def test_multiple_tags_and_condition(self, tagged_openapi_spec, mock_client):
"""Test that routes must have ALL specified tags (AND condition)."""
# Routes must have BOTH "users" AND "admin" tags
route_maps = [
RouteMap(
methods="*",
pattern=r".*",
mcp_type=MCPType.TOOL,
tags={"users", "admin"},
),
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
]
server = FastMCPOpenAPI(
openapi_spec=tagged_openapi_spec,
client=mock_client,
route_maps=route_maps,
)
tools = server._tool_manager.get_tools()
tool_names = {t.name for t in tools.values()}
resources = server._resource_manager.get_resources()
resource_names = {r.name for r in resources.values()}
# Only createUser has both "users" AND "admin" tags
assert "createUser" in tool_names
# Other routes should be resources
assert "getUsers" in resource_names # has "users" but not "admin"
assert "getAdminStats" in resource_names # has "admin" but not "users"
assert "getHealth" in resource_names
assert "getMetrics" in resource_names
async def test_pattern_and_tags_combination(self, tagged_openapi_spec, mock_client):
"""Test that both pattern and tags must be satisfied."""
# Routes matching pattern AND having specific tags
route_maps = [
RouteMap(
methods="*",
pattern=r".*/admin/.*",
mcp_type=MCPType.TOOL,
tags={"admin"},
),
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL),
]
server = FastMCPOpenAPI(
openapi_spec=tagged_openapi_spec,
client=mock_client,
route_maps=route_maps,
)
tools = server._tool_manager.get_tools()
tool_names = {t.name for t in tools.values()}
resources = server._resource_manager.get_resources()
resource_names = {r.name for r in resources.values()}
# Only getAdminStats matches both /admin/ pattern AND "admin" tag
assert "getAdminStats" in tool_names
# createUser has "admin" tag but doesn't match pattern, so it becomes a tool via POST rule
assert "createUser" in tool_names
# Other routes should be resources (GET)
assert "getUsers" in resource_names
assert "getHealth" in resource_names
assert "getMetrics" in resource_names
async def test_empty_tags_ignored(self, tagged_openapi_spec, mock_client):
"""Test that empty tags set is ignored (matches all routes)."""
# Empty tags should match all routes
route_maps = [
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags=set()),
]
server = FastMCPOpenAPI(
openapi_spec=tagged_openapi_spec,
client=mock_client,
route_maps=route_maps,
)
tools = server._tool_manager.get_tools()
tool_names = {t.name for t in tools.values()}
# All routes should be tools since empty tags matches everything
expected_tools = {
"getUsers",
"createUser",
"getAdminStats",
"getHealth",
"getMetrics",
}
assert tool_names == expected_tools
class TestMCPNames:
"""Tests for the mcp_names dictionary functionality."""
@pytest.fixture
def mcp_names_openapi_spec(self) -> dict:
"""OpenAPI spec with various operationIds for testing naming strategies."""
return {
"openapi": "3.1.0",
"info": {"title": "MCP Names Test API", "version": "1.0.0"},
"paths": {
"/users": {
"get": {
"operationId": "list_users__with_pagination",
"summary": "Get All Users",
"responses": {"200": {"description": "Success"}},
},
"post": {
"operationId": "create_user_admin__special_permissions",
"summary": "Create New User",
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
}
}
},
},
"responses": {"201": {"description": "Created"}},
},
},
"/users/{id}": {
"get": {
"operationId": "get_user_by_id__admin_only",
"summary": "Fetch Single User Profile",
"parameters": [
{
"name": "id",
"in": "path",
"required": True,
"schema": {"type": "integer"},
}
],
"responses": {"200": {"description": "Success"}},
}
},
"/very-long-endpoint-name": {
"get": {
"operationId": "this_is_a_very_long_operation_id_that_exceeds_fifty_six_characters_and_should_be_truncated",
"summary": "This Is A Very Long Summary That Should Also Be Truncated When Used As Name",
"responses": {"200": {"description": "Success"}},
}
},
"/special": {
"get": {
"operationId": "special-chars@and#spaces in$operation%id",
"summary": "Special Chars & Spaces In Summary!",
"responses": {"200": {"description": "Success"}},
}
},
},
}
@pytest.fixture
async def mock_client(self) -> httpx.AsyncClient:
"""Mock client for testing."""
async def _responder(request):
return httpx.Response(200, json={"status": "ok"})
transport = httpx.MockTransport(_responder)
return httpx.AsyncClient(transport=transport, base_url="http://test")
async def test_mcp_names_custom_mapping(self, mcp_names_openapi_spec, mock_client):
"""Test that mcp_names dictionary provides custom names for components."""
mcp_names = {
"list_users__with_pagination": "user_list",
"create_user_admin__special_permissions": "admin_create_user",
"get_user_by_id__admin_only": "user_detail",
}
server = FastMCPOpenAPI(
openapi_spec=mcp_names_openapi_spec,
client=mock_client,
mcp_names=mcp_names,
)
# Check tools use custom names
tools = server._tool_manager.list_tools()
tool_names = {tool.name for tool in tools}
assert "admin_create_user" in tool_names
# Check resource templates use custom names
templates = list(server._resource_manager.get_templates().values())
template_names = {template.name for template in templates}
assert "user_detail" in template_names
# Check resources use custom names
resources = list(server._resource_manager.get_resources().values())
resource_names = {resource.name for resource in resources}
assert "user_list" in resource_names
async def test_mcp_names_fallback_to_operation_id_short(
self, mcp_names_openapi_spec, mock_client
):
"""Test fallback to operationId up to double underscore when not in mcp_names."""
# Only provide mapping for one operationId
mcp_names = {
"list_users__with_pagination": "custom_user_list",
}
server = FastMCPOpenAPI(
openapi_spec=mcp_names_openapi_spec,
client=mock_client,
mcp_names=mcp_names,
)
tools = server._tool_manager.list_tools()
tool_names = {tool.name for tool in tools}
templates = list(server._resource_manager.get_templates().values())
template_names = {template.name for template in templates}
resources = list(server._resource_manager.get_resources().values())
resource_names = {resource.name for resource in resources}
# Custom mapped name should be used
assert "custom_user_list" in resource_names
# Unmapped operationIds should use short version (up to __)
assert "create_user_admin" in tool_names
assert "get_user_by_id" in template_names
async def test_names_are_slugified(self, mcp_names_openapi_spec, mock_client):
"""Test that names are properly slugified (spaces, special chars removed)."""
server = FastMCPOpenAPI(
openapi_spec=mcp_names_openapi_spec,
client=mock_client,
)
resources = list(server._resource_manager.get_resources().values())
resource_names = {
resource.name for resource in resources if resource.name is not None
}
# Special chars and spaces should be slugified
slugified_name = next(
(name for name in resource_names if "special" in name), None
)
assert slugified_name is not None
# Should not contain special characters or spaces
assert "@" not in slugified_name
assert "#" not in slugified_name
assert "$" not in slugified_name
assert "%" not in slugified_name
assert " " not in slugified_name
async def test_names_are_truncated_to_56_chars(
self, mcp_names_openapi_spec, mock_client
):
"""Test that names are truncated to 56 characters maximum."""
server = FastMCPOpenAPI(
openapi_spec=mcp_names_openapi_spec,
client=mock_client,
)
# Check all component types
all_names = []
tools = server._tool_manager.list_tools()
all_names.extend(tool.name for tool in tools)
resources = list(server._resource_manager.get_resources().values())
all_names.extend(resource.name for resource in resources)
templates = list(server._resource_manager.get_templates().values())
all_names.extend(template.name for template in templates)
# All names should be 56 characters or less
for name in all_names:
assert len(name) <= 56, (
f"Name '{name}' exceeds 56 characters (length: {len(name)})"
)
# Verify that the long operationId was actually truncated
long_name = next((name for name in all_names if len(name) > 50), None)
assert long_name is not None, "Expected to find a truncated name for testing"
async def test_mcp_names_with_from_openapi_classmethod(
self, mcp_names_openapi_spec, mock_client
):
"""Test mcp_names works with FastMCP.from_openapi() classmethod."""
mcp_names = {
"list_users__with_pagination": "openapi_user_list",
}
server = FastMCP.from_openapi(
openapi_spec=mcp_names_openapi_spec,
client=mock_client,
mcp_names=mcp_names,
)
resources = list(server._resource_manager.get_resources().values())
resource_names = {resource.name for resource in resources}
assert "openapi_user_list" in resource_names
async def test_mcp_names_with_from_fastapi_classmethod(self):
"""Test mcp_names works with FastMCP.from_fastapi() classmethod."""
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI(title="FastAPI MCP Names Test")
class User(BaseModel):
name: str
@app.get("/users", operation_id="list_users__with_filters")
async def get_users() -> list[User]:
return [User(name="test")]
@app.post("/users", operation_id="create_user__admin_required")
async def create_user(user: User) -> User:
return user
mcp_names = {
"list_users__with_filters": "fastapi_user_list",
"create_user__admin_required": "fastapi_create_user",
}
server = FastMCP.from_fastapi(
app=app,
mcp_names=mcp_names,
)
tools = server._tool_manager.list_tools()
tool_names = {tool.name for tool in tools}
resources = list(server._resource_manager.get_resources().values())
resource_names = {resource.name for resource in resources}
assert "fastapi_create_user" in tool_names
assert "fastapi_user_list" in resource_names
async def test_mcp_names_custom_names_are_also_truncated(
self, mcp_names_openapi_spec, mock_client
):
"""Test that custom names in mcp_names are also truncated to 56 characters."""
# Provide a custom name that's longer than 56 characters
very_long_custom_name = "this_is_a_very_long_custom_name_that_exceeds_fifty_six_characters_and_should_be_truncated"
mcp_names = {
"list_users__with_pagination": very_long_custom_name,
}
server = FastMCPOpenAPI(
openapi_spec=mcp_names_openapi_spec,
client=mock_client,
mcp_names=mcp_names,
)
resources = list(server._resource_manager.get_resources().values())
resource_names = {
resource.name for resource in resources if resource.name is not None
}
# Find the resource that should have the custom name
truncated_name = next(
(
name
for name in resource_names
if "this_is_a_very_long_custom_name" in name
),
None,
)
assert truncated_name is not None
assert len(truncated_name) <= 56
assert (
len(truncated_name) == 56
) # Should be exactly 56 since original was longer

View file

@ -0,0 +1,457 @@
from typing import Annotated, Literal
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from fastapi import FastAPI, Query
from fastmcp import Client, FastMCP
from fastmcp.server.openapi import MCPType, OpenAPITool, RouteMap
from fastmcp.utilities.openapi import HTTPRoute, ParameterInfo
@pytest.fixture
def array_path_spec():
"""Load a minimal OpenAPI spec with an array path parameter."""
return {
"openapi": "3.1.0",
"info": {"title": "Test API", "version": "1.0.0"},
"paths": {
"/select/{days}": {
"put": {
"operationId": "test-operation",
"parameters": [
{
"name": "days",
"in": "path",
"required": True,
"style": "simple",
"explode": False,
"schema": {
"type": "array",
"items": {
"type": "string",
"enum": [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
],
},
},
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {"result": {"type": "string"}},
"required": ["result"],
}
}
},
}
},
}
}
},
}
@pytest.fixture
def mock_client():
"""Create a mock httpx.AsyncClient."""
client = AsyncMock(spec=httpx.AsyncClient)
# Set up a mock response
mock_response = MagicMock()
mock_response.json.return_value = {"result": "success"}
mock_response.raise_for_status.return_value = None
client.request.return_value = mock_response
return client
async def test_fastmcp_from_openapi(array_path_spec, mock_client):
"""Test creating FastMCP from OpenAPI spec with array path parameter."""
# Create FastMCP from the spec
mcp = FastMCP.from_openapi(array_path_spec, client=mock_client)
# Verify the tool was created using the MCP protocol method
tools_result = await mcp.get_tools()
tool_names = [tool.name for tool in tools_result.values()]
assert "test_operation" in tool_names
async def test_array_path_parameter_handling(mock_client):
"""Test how array path parameters are handled."""
# Create a simple route with array path parameter
route = HTTPRoute(
path="/select/{days}",
method="PUT",
operation_id="test_operation",
parameters=[
ParameterInfo(
name="days",
location="path",
required=True,
schema={
"type": "array",
"items": {
"type": "string",
"enum": [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
],
},
},
)
],
)
# Create the tool
tool = OpenAPITool(
client=mock_client,
route=route,
name="test_operation",
description="Test operation",
parameters={},
)
# Test with a single value
await tool._execute_request(days=["monday"])
# Check that the path parameter is formatted correctly
# This is where the bug is: it should be '/select/monday' not '/select/[\'monday\']'
mock_client.request.assert_called_with(
method="PUT",
url="/select/monday", # This is the expected format
params={},
headers={},
json=None,
timeout=None,
)
mock_client.request.reset_mock()
# Test with multiple values
await tool._execute_request(days=["monday", "tuesday"])
# Check that the path parameter is formatted correctly
# It should be '/select/monday,tuesday' not '/select/[\'monday\', \'tuesday\']'
mock_client.request.assert_called_with(
method="PUT",
url="/select/monday,tuesday", # This is the expected format
params={},
headers={},
json=None,
timeout=None,
)
async def test_integration_array_path_parameter(array_path_spec, mock_client):
"""Integration test for array path parameters."""
# Create FastMCP from the spec
mcp = FastMCP.from_openapi(array_path_spec, client=mock_client)
# Call the tool with a single value
await mcp._mcp_call_tool("test_operation", {"days": ["monday"]})
# Check the request was made correctly
mock_client.request.assert_called_with(
method="PUT",
url="/select/monday",
params={},
headers={},
json=None,
timeout=None,
)
mock_client.request.reset_mock()
# Call the tool with multiple values
await mcp._mcp_call_tool("test_operation", {"days": ["monday", "tuesday"]})
# Check the request was made correctly
mock_client.request.assert_called_with(
method="PUT",
url="/select/monday,tuesday",
params={},
headers={},
json=None,
timeout=None,
)
async def test_complex_nested_array_path_parameter(mock_client):
"""Test handling of complex nested array path parameters."""
# Create a route with a path parameter that contains nested objects in an array
route = HTTPRoute(
path="/report/{filters}",
method="GET",
operation_id="test-complex-filters",
parameters=[
ParameterInfo(
name="filters",
location="path",
required=True,
schema={
"type": "array",
"items": {
"type": "object",
"properties": {
"field": {"type": "string"},
"value": {"type": "string"},
},
},
},
)
],
)
# Create the tool
tool = OpenAPITool(
client=mock_client,
route=route,
name="test-complex-filters",
description="Test operation with complex filters",
parameters={},
)
# Test with a more complex path parameter
# This would typically be serialized as JSON or a more complex format
# But for path parameters with style=simple, it should be comma-separated
complex_filters = [
{"field": "status", "value": "active"},
{"field": "type", "value": "user"},
]
# Execute the request with complex filters
await tool._execute_request(filters=complex_filters)
# The complex object should be properly serialized in the URL
# For path parameters, this would typically need a custom serialization strategy
# but our implementation should handle it safely
call_args = mock_client.request.call_args
# Verify the request was made
assert call_args is not None, "The request was not made"
# Get the called URL and verify it contains the serialized path parameter
called_url = call_args[1].get("url")
# Check that the path parameter is handled (we don't expect perfect serialization,
# but it should not cause errors and should maintain the array structure)
assert "/report/" in called_url, "The URL should contain the path prefix"
# Check that it didn't just convert the objects to string representations
# that include the Python object syntax
assert "status" in called_url, "The URL should contain filter field names"
assert "active" in called_url, "The URL should contain filter values"
assert "}" not in called_url, "The URL should not contain Python object syntax"
assert "{" not in called_url, "The URL should not contain Python object syntax"
async def test_array_query_param_with_fastapi():
"""Test array query parameters using FastAPI and FastMCP.from_fastapi integration."""
# Create a FastAPI app with a route that has an array query parameter
app = FastAPI()
@app.get("/select")
async def select_days(
days: Annotated[
list[
Literal[
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
]
],
Query(explode=True),
],
): # Using explode=True to get days=monday&days=tuesday format
return {"selected": days}
# Create a FastMCP server from the FastAPI app
mcp = FastMCP.from_fastapi(
app,
route_maps=[RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)],
)
# Test with the client
async with Client(mcp) as client:
# Get the actual tool name first
tools = await client.list_tools()
tool_names = [tool.name for tool in tools]
assert len(tool_names) == 1, (
f"Expected one tool, got {len(tool_names)}: {tool_names}"
)
tool_name = tool_names[0]
# Single day
result = await client.call_tool(tool_name, {"days": ["monday"]})
# Client returns TextContent objects, so parse the JSON
assert len(result) == 1
assert result[0].type == "text"
import json
result_data = json.loads(result[0].text)
assert result_data == {"selected": ["monday"]}
# Multiple days
result = await client.call_tool(tool_name, {"days": ["monday", "tuesday"]})
assert len(result) == 1
assert result[0].type == "text"
result_data = json.loads(result[0].text)
assert result_data == {"selected": ["monday", "tuesday"]}
async def test_array_query_parameter_format(mock_client):
"""Test that array query parameters are formatted as comma-separated values when explode=False."""
# Create a route with array query parameter
route = HTTPRoute(
path="/select",
method="GET",
operation_id="test-operation",
parameters=[
ParameterInfo(
name="days",
location="query", # This is a query parameter
required=True,
schema={
"type": "array",
"explode": False, # Set explode=False to test comma-separated formatting
"items": {
"type": "string",
"enum": [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
],
},
},
)
],
)
# Create the tool
tool = OpenAPITool(
client=mock_client,
route=route,
name="test-operation",
description="Test operation",
parameters={},
)
# Test with a single value
await tool._execute_request(days=["monday"])
# Check that the query parameter is formatted correctly
mock_client.request.assert_called_with(
method="GET",
url="/select",
params={"days": "monday"}, # Should be formatted as a string, not a list
headers={},
json=None,
timeout=None,
)
mock_client.request.reset_mock()
# Test with multiple values
await tool._execute_request(days=["monday", "tuesday"])
# Check that the query parameter is formatted correctly
# It should be 'days=monday,tuesday' not 'days=["monday","tuesday"]'
mock_client.request.assert_called_with(
method="GET",
url="/select",
params={"days": "monday,tuesday"}, # Should be comma-separated
headers={},
json=None,
timeout=None,
)
async def test_array_query_parameter_exploded_format(mock_client):
"""Test that array query parameters are formatted as separate parameters when explode=True."""
# Create a route with array query parameter with explode=True (default)
route = HTTPRoute(
path="/select-exploded",
method="GET",
operation_id="test-exploded-operation",
parameters=[
ParameterInfo(
name="days",
location="query", # This is a query parameter
required=True,
schema={
"type": "array",
"explode": True, # Set explode=True for separate parameter serialization
"items": {
"type": "string",
"enum": [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
],
},
},
)
],
)
# Create the tool
tool = OpenAPITool(
client=mock_client,
route=route,
name="test-exploded-operation",
description="Test operation with exploded arrays",
parameters={},
)
# Test with a single value
await tool._execute_request(days=["monday"])
# Check that the query parameter is formatted correctly
mock_client.request.assert_called_with(
method="GET",
url="/select-exploded",
params={"days": ["monday"]}, # Should be passed as a list for explode=True
headers={},
json=None,
timeout=None,
)
mock_client.request.reset_mock()
# Test with multiple values
await tool._execute_request(days=["monday", "tuesday"])
# Check that the query parameter is formatted correctly
# It should be passed as an array, which httpx will serialize as days=monday&days=tuesday
mock_client.request.assert_called_with(
method="GET",
url="/select-exploded",
params={"days": ["monday", "tuesday"]}, # Should be passed as a list
headers={},
json=None,
timeout=None,
)

View file

@ -0,0 +1,377 @@
"""Tests for the route_map_fn and component_fn functionality in FastMCPOpenAPI."""
import httpx
import pytest
from fastmcp.server.openapi import FastMCPOpenAPI, MCPType
@pytest.fixture
def sample_openapi_spec():
"""Sample OpenAPI spec for testing."""
return {
"openapi": "3.0.0",
"info": {"title": "Test API", "version": "1.0.0"},
"paths": {
"/users": {
"get": {
"summary": "List users",
"operationId": "listUsers",
"responses": {"200": {"description": "Success"}},
}
},
"/users/{id}": {
"get": {
"summary": "Get user by ID",
"operationId": "getUserById",
"parameters": [
{
"name": "id",
"in": "path",
"required": True,
"schema": {"type": "string"},
}
],
"responses": {"200": {"description": "Success"}},
}
},
"/admin/settings": {
"get": {
"summary": "Get admin settings",
"operationId": "getAdminSettings",
"responses": {"200": {"description": "Success"}},
},
"post": {
"summary": "Update admin settings",
"operationId": "updateAdminSettings",
"requestBody": {
"content": {"application/json": {"schema": {"type": "object"}}}
},
"responses": {"200": {"description": "Success"}},
},
},
"/api/data": {
"get": {
"summary": "Get data",
"operationId": "getData",
"responses": {"200": {"description": "Success"}},
}
},
},
}
@pytest.fixture
def http_client():
"""HTTP client for testing."""
return httpx.AsyncClient()
def test_route_map_fn_none(sample_openapi_spec, http_client):
"""Test that server works correctly when route_map_fn is None."""
server = FastMCPOpenAPI(
openapi_spec=sample_openapi_spec,
client=http_client,
name="Test Server",
route_map_fn=None, # Explicitly set to None
)
assert server.name == "Test Server"
def test_route_map_fn_custom_type_conversion(sample_openapi_spec, http_client):
"""Test that route_map_fn can convert route types."""
def admin_routes_to_tools(route, mcp_type):
"""Convert all admin routes to tools."""
if "/admin/" in route.path:
return MCPType.TOOL
return None
server = FastMCPOpenAPI(
openapi_spec=sample_openapi_spec,
client=http_client,
name="Test Server",
route_map_fn=admin_routes_to_tools,
)
# Admin GET route should be converted to tool instead of resource
tools = server._tool_manager._tools
assert "getAdminSettings" in tools
# Admin POST route should still be a tool (was already)
assert "updateAdminSettings" in tools
def test_component_fn_customization(sample_openapi_spec, http_client):
"""Test that component_fn can customize components."""
def customize_components(route, component):
"""Customize components based on route."""
from fastmcp.server.openapi import OpenAPIResource, OpenAPITool
# Add custom tags to all components
component.tags.add("custom")
# Modify tool descriptions
if isinstance(component, OpenAPITool):
component.description = (component.description or "") + " [CUSTOMIZED TOOL]"
# Modify resource descriptions
if isinstance(component, OpenAPIResource):
component.description = (
component.description or ""
) + " [CUSTOMIZED RESOURCE]"
server = FastMCPOpenAPI(
openapi_spec=sample_openapi_spec,
client=http_client,
name="Test Server",
mcp_component_fn=customize_components,
)
# Check that components were customized
tools = server._tool_manager._tools
resources = server._resource_manager._resources
# Tools should have custom tags and modified descriptions
for tool in tools.values():
assert "custom" in tool.tags
assert "[CUSTOMIZED TOOL]" in (tool.description or "")
# Resources should have custom tags and modified descriptions
for resource in resources.values():
assert "custom" in resource.tags
assert "[CUSTOMIZED RESOURCE]" in (resource.description or "")
def test_route_map_fn_returns_none(sample_openapi_spec, http_client):
"""Test that route_map_fn returning None uses defaults."""
def always_return_none(route, mcp_type):
"""Always return None to use defaults."""
return None
server = FastMCPOpenAPI(
openapi_spec=sample_openapi_spec,
client=http_client,
name="Test Server",
route_map_fn=always_return_none,
)
# Should have default behavior
assert server.name == "Test Server"
# Check that components were created with default types
tools = server._tool_manager._tools
resources = server._resource_manager._resources
templates = server._resource_manager._templates
# Should have tools, resources, and templates based on default mapping
assert len(tools) > 0
assert len(resources) > 0
assert len(templates) > 0
def test_route_map_fn_called_for_excluded_routes(sample_openapi_spec, http_client):
"""Test that route_map_fn is called for excluded routes and can rescue them."""
from fastmcp.server.openapi import RouteMap
# Exclude all admin routes
route_maps = [
RouteMap(
methods=["GET", "POST"], pattern=r".*/admin/.*", mcp_type=MCPType.EXCLUDE
)
]
called_routes = []
def track_calls_and_rescue(route, mcp_type):
"""Track which routes the function is called for and rescue some excluded routes."""
called_routes.append(route.path)
# Rescue the admin GET route by converting it to a tool
if route.path == "/admin/settings" and route.method == "GET":
return MCPType.TOOL
return None # Accept the assignment for other routes
server = FastMCPOpenAPI(
openapi_spec=sample_openapi_spec,
client=http_client,
name="Test Server",
route_maps=route_maps,
route_map_fn=track_calls_and_rescue,
)
# route_map_fn should now be called for all routes, including excluded admin routes
assert "/admin/settings" in called_routes
assert "/users" in called_routes
assert "/users/{id}" in called_routes
assert "/api/data" in called_routes
# The rescued admin GET route should now be a tool
tools = server._tool_manager._tools
assert "getAdminSettings" in tools
# The admin POST route should still be excluded (not rescued)
assert "updateAdminSettings" not in tools
def test_route_map_fn_error_handling(sample_openapi_spec, http_client):
"""Test that errors in route_map_fn are handled gracefully."""
def error_function(route, mcp_type):
"""Function that raises an error."""
if route.path == "/users":
raise ValueError("Test error")
return None
# Should not raise an error, but log a warning
server = FastMCPOpenAPI(
openapi_spec=sample_openapi_spec,
client=http_client,
name="Test Server",
route_map_fn=error_function,
)
# Server should still be created successfully
assert server.name == "Test Server"
def test_component_fn_error_handling(sample_openapi_spec, http_client):
"""Test that errors in component_fn are handled gracefully."""
def error_function(route, component):
"""Function that raises an error."""
if route.path == "/users":
raise ValueError("Test error in component_fn")
# Should not raise an error, but log a warning
server = FastMCPOpenAPI(
openapi_spec=sample_openapi_spec,
client=http_client,
name="Test Server",
mcp_component_fn=error_function,
)
# Server should still be created successfully
assert server.name == "Test Server"
def test_combined_route_map_fn_and_component_fn(sample_openapi_spec, http_client):
"""Test using both route_map_fn and component_fn together."""
def route_mapper(route, mcp_type):
"""Convert admin routes to tools."""
if "/admin/" in route.path:
return MCPType.TOOL
return None
def component_customizer(route, component):
"""Add admin tag to admin components."""
if "/admin/" in route.path:
component.tags.add("admin")
server = FastMCPOpenAPI(
openapi_spec=sample_openapi_spec,
client=http_client,
name="Test Server",
route_map_fn=route_mapper,
mcp_component_fn=component_customizer,
)
# Check that both functions worked
tools = server._tool_manager._tools
# Admin GET route should be converted to tool
assert "getAdminSettings" in tools
admin_tool = tools["getAdminSettings"]
assert "admin" in admin_tool.tags
# Admin POST route should have admin tag
admin_post_tool = tools["updateAdminSettings"]
assert "admin" in admin_post_tool.tags
def test_route_map_fn_signature_validation():
"""Test that route_map_fn has the correct signature."""
from fastmcp.server.openapi import RouteMapFn
from fastmcp.utilities import openapi
# This is more of a type checking test
def valid_route_map_fn(
route: openapi.HTTPRoute, mcp_type: MCPType
) -> MCPType | None:
return None
# Should be assignable to RouteMapFn type
fn: RouteMapFn = valid_route_map_fn
assert callable(fn)
def test_component_fn_signature_validation():
"""Test that component_fn has the correct signature."""
from fastmcp.server.openapi import (
ComponentFn,
OpenAPIResource,
OpenAPIResourceTemplate,
OpenAPITool,
)
from fastmcp.utilities import openapi
# This is more of a type checking test
def valid_component_fn(
route: openapi.HTTPRoute,
component: OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate,
) -> None:
pass
# Should be assignable to ComponentFn type
fn: ComponentFn = valid_component_fn
assert callable(fn)
def test_route_map_fn_can_rescue_excluded_routes(sample_openapi_spec, http_client):
"""Test that route_map_fn can rescue routes that were excluded by RouteMap."""
from fastmcp.server.openapi import RouteMap
# Exclude ALL routes by default
route_maps = [
RouteMap(mcp_type=MCPType.EXCLUDE) # Catch-all exclusion
]
def rescue_users_routes(route, mcp_type):
"""Rescue only user-related routes."""
if "/users" in route.path:
# Rescue user routes as tools
return MCPType.TOOL
# Let everything else stay excluded
return None
server = FastMCPOpenAPI(
openapi_spec=sample_openapi_spec,
client=http_client,
name="Test Server",
route_maps=route_maps,
route_map_fn=rescue_users_routes,
)
# Only user routes should be rescued as tools
tools = server._tool_manager._tools
resources = server._resource_manager._resources
templates = server._resource_manager._templates
# Should have user-related tools
assert "listUsers" in tools
assert "getUserById" in tools
# Should have no resources or templates (everything excluded except rescued tools)
assert len(resources) == 0
assert len(templates) == 0
# Admin and API routes should still be excluded
assert "getAdminSettings" not in tools
assert "updateAdminSettings" not in tools
assert "getData" not in tools

View file

@ -341,7 +341,6 @@ async def tokens(test_client, registered_client, auth_code, pkce_challenge, requ
class TestAuthEndpoints:
@pytest.mark.anyio
async def test_metadata_endpoint(self, test_client: httpx.AsyncClient):
"""Test the OAuth 2.0 metadata endpoint."""
print("Sending request to metadata endpoint")
@ -370,7 +369,6 @@ class TestAuthEndpoints:
]
assert metadata["service_documentation"] == "https://docs.example.com/"
@pytest.mark.anyio
async def test_token_validation_error(self, test_client: httpx.AsyncClient):
"""Test token endpoint error - validation error."""
# Missing required fields
@ -387,7 +385,6 @@ class TestAuthEndpoints:
"error_description" in error_response
) # Contains validation error messages
@pytest.mark.anyio
async def test_token_invalid_auth_code(
self, test_client, registered_client, pkce_challenge
):
@ -414,7 +411,6 @@ class TestAuthEndpoints:
"authorization code does not exist" in error_response["error_description"]
)
@pytest.mark.anyio
async def test_token_expired_auth_code(
self,
test_client,
@ -459,7 +455,6 @@ class TestAuthEndpoints:
"authorization code has expired" in error_response["error_description"]
)
@pytest.mark.anyio
@pytest.mark.parametrize(
"registered_client",
[
@ -494,7 +489,6 @@ class TestAuthEndpoints:
assert error_response["error"] == "invalid_request"
assert "redirect_uri did not match" in error_response["error_description"]
@pytest.mark.anyio
async def test_token_code_verifier_mismatch(
self, test_client, registered_client, auth_code
):
@ -517,7 +511,6 @@ class TestAuthEndpoints:
assert error_response["error"] == "invalid_grant"
assert "incorrect code_verifier" in error_response["error_description"]
@pytest.mark.anyio
async def test_token_invalid_refresh_token(self, test_client, registered_client):
"""Test token endpoint error - refresh token does not exist."""
# Try to use a non-existent refresh token
@ -535,7 +528,6 @@ class TestAuthEndpoints:
assert error_response["error"] == "invalid_grant"
assert "refresh token does not exist" in error_response["error_description"]
@pytest.mark.anyio
async def test_token_expired_refresh_token(
self,
test_client,
@ -586,7 +578,6 @@ class TestAuthEndpoints:
assert error_response["error"] == "invalid_grant"
assert "refresh token has expired" in error_response["error_description"]
@pytest.mark.anyio
async def test_token_invalid_scope(
self, test_client, registered_client, auth_code, pkce_challenge
):
@ -624,7 +615,6 @@ class TestAuthEndpoints:
assert error_response["error"] == "invalid_scope"
assert "cannot request scope" in error_response["error_description"]
@pytest.mark.anyio
async def test_client_registration(
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider
):
@ -652,7 +642,6 @@ class TestAuthEndpoints:
# client_info["client_id"]
# ) is not None
@pytest.mark.anyio
async def test_client_registration_missing_required_fields(
self, test_client: httpx.AsyncClient
):
@ -673,7 +662,6 @@ class TestAuthEndpoints:
assert error_data["error"] == "invalid_client_metadata"
assert error_data["error_description"] == "redirect_uris: Field required"
@pytest.mark.anyio
async def test_client_registration_invalid_uri(
self, test_client: httpx.AsyncClient
):
@ -696,7 +684,6 @@ class TestAuthEndpoints:
"redirect_uris.0: Input should be a valid URL, relative URL without a base"
)
@pytest.mark.anyio
async def test_client_registration_empty_redirect_uris(
self, test_client: httpx.AsyncClient
):
@ -719,7 +706,6 @@ class TestAuthEndpoints:
== "redirect_uris: List should have at least 1 item after validation, not 0"
)
@pytest.mark.anyio
async def test_authorize_form_post(
self,
test_client: httpx.AsyncClient,
@ -763,7 +749,6 @@ class TestAuthEndpoints:
assert "code" in query_params
assert query_params["state"][0] == "test_form_state"
@pytest.mark.anyio
async def test_authorization_get(
self,
test_client: httpx.AsyncClient,
@ -878,7 +863,6 @@ class TestAuthEndpoints:
is None
)
@pytest.mark.anyio
async def test_revoke_invalid_token(self, test_client, registered_client):
"""Test revoking an invalid token."""
response = await test_client.post(
@ -892,7 +876,6 @@ class TestAuthEndpoints:
# per RFC, this should return 200 even if the token is invalid
assert response.status_code == 200
@pytest.mark.anyio
async def test_revoke_with_malformed_token(self, test_client, registered_client):
response = await test_client.post(
"/revoke",
@ -908,7 +891,6 @@ class TestAuthEndpoints:
assert error_response["error"] == "invalid_request"
assert "token_type_hint" in error_response["error_description"]
@pytest.mark.anyio
async def test_client_registration_disallowed_scopes(
self, test_client: httpx.AsyncClient
):
@ -930,7 +912,6 @@ class TestAuthEndpoints:
assert "scope" in error_data["error_description"]
assert "admin" in error_data["error_description"]
@pytest.mark.anyio
async def test_client_registration_default_scopes(
self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider
):
@ -959,7 +940,6 @@ class TestAuthEndpoints:
# Check that default scopes were applied
assert registered_client.scope == "read write"
@pytest.mark.anyio
async def test_client_registration_invalid_grant_type(
self, test_client: httpx.AsyncClient
):
@ -986,7 +966,6 @@ class TestAuthEndpoints:
class TestAuthorizeEndpointErrors:
"""Test error handling in the OAuth authorization endpoint."""
@pytest.mark.anyio
async def test_authorize_missing_client_id(
self, test_client: httpx.AsyncClient, pkce_challenge
):
@ -1012,7 +991,6 @@ class TestAuthorizeEndpointErrors:
# The response should include an error message about missing client_id
assert "client_id" in response.text.lower()
@pytest.mark.anyio
async def test_authorize_invalid_client_id(
self, test_client: httpx.AsyncClient, pkce_challenge
):
@ -1038,7 +1016,6 @@ class TestAuthorizeEndpointErrors:
# The response should include an error message about invalid client_id
assert "client" in response.text.lower()
@pytest.mark.anyio
async def test_authorize_missing_redirect_uri(
self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
):
@ -1064,7 +1041,6 @@ class TestAuthorizeEndpointErrors:
redirect_url = response.headers["location"]
assert redirect_url.startswith("https://client.example.com/callback")
@pytest.mark.anyio
async def test_authorize_invalid_redirect_uri(
self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
):
@ -1092,7 +1068,6 @@ class TestAuthorizeEndpointErrors:
# The response should include an error message about redirect_uri mismatch
assert "redirect" in response.text.lower()
@pytest.mark.anyio
@pytest.mark.parametrize(
"registered_client",
[
@ -1130,7 +1105,6 @@ class TestAuthorizeEndpointErrors:
# The response should include an error message about missing redirect_uri
assert "redirect_uri" in response.text.lower()
@pytest.mark.anyio
async def test_authorize_unsupported_response_type(
self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
):
@ -1164,7 +1138,6 @@ class TestAuthorizeEndpointErrors:
assert "state" in query_params
assert query_params["state"][0] == "test_state"
@pytest.mark.anyio
async def test_authorize_missing_response_type(
self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
):
@ -1197,7 +1170,6 @@ class TestAuthorizeEndpointErrors:
assert "state" in query_params
assert query_params["state"][0] == "test_state"
@pytest.mark.anyio
async def test_authorize_missing_pkce_challenge(
self, test_client: httpx.AsyncClient, registered_client
):
@ -1228,7 +1200,6 @@ class TestAuthorizeEndpointErrors:
assert "state" in query_params
assert query_params["state"][0] == "test_state"
@pytest.mark.anyio
async def test_authorize_invalid_scope(
self, test_client: httpx.AsyncClient, registered_client, pkce_challenge
):

View file

@ -2,9 +2,11 @@ import warnings
from unittest.mock import MagicMock, patch
import pytest
from mcp.types import ModelPreferences
from starlette.requests import Request
from fastmcp.server.context import Context
from fastmcp.server.server import FastMCP
class TestContextDeprecations:
@ -57,3 +59,30 @@ class TestContextDeprecations:
assert "https://gofastmcp.com/patterns/http-requests" in str(
warning.message
)
@pytest.fixture
def context():
return Context(fastmcp=FastMCP())
class TestParseModelPreferences:
def test_parse_model_preferences_string(self, context):
mp = context._parse_model_preferences("claude-3-sonnet")
assert isinstance(mp, ModelPreferences)
assert mp.hints is not None
assert mp.hints[0].name == "claude-3-sonnet"
def test_parse_model_preferences_list(self, context):
mp = context._parse_model_preferences(["claude-3-sonnet", "claude"])
assert isinstance(mp, ModelPreferences)
assert mp.hints is not None
assert [h.name for h in mp.hints] == ["claude-3-sonnet", "claude"]
def test_parse_model_preferences_object(self, context):
obj = ModelPreferences(hints=[])
assert context._parse_model_preferences(obj) is obj
def test_parse_model_preferences_invalid_type(self, context):
with pytest.raises(ValueError):
context._parse_model_preferences(123)

View file

@ -1,102 +0,0 @@
import json
import sys
from collections.abc import Generator
import pytest
import uvicorn
from mcp.types import TextContent, TextResourceContents
from fastmcp.client import Client
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.server.dependencies import get_http_request
from fastmcp.server.server import FastMCP
from fastmcp.utilities.tests import run_server_in_process
def fastmcp_server():
server = FastMCP()
# Add a tool
@server.tool()
def get_headers_tool() -> dict[str, str]:
"""Get the HTTP headers from the request."""
request = get_http_request()
return dict(request.headers)
@server.resource(uri="request://headers")
async def get_headers_resource() -> dict[str, str]:
request = get_http_request()
return dict(request.headers)
# Add a prompt
@server.prompt()
def get_headers_prompt() -> str:
"""Get the HTTP headers from the request."""
request = get_http_request()
return json.dumps(dict(request.headers))
return server
def run_server(host: str, port: int) -> None:
try:
app = fastmcp_server().http_app()
server = uvicorn.Server(
config=uvicorn.Config(
app=app,
host=host,
port=port,
log_level="error",
lifespan="on",
)
)
server.run()
except Exception as e:
print(f"Server error: {e}")
sys.exit(1)
sys.exit(0)
@pytest.fixture(autouse=True, scope="module")
def sse_server() -> Generator[str, None, None]:
with run_server_in_process(run_server) as url:
yield f"{url}/mcp"
async def test_http_headers_resource(sse_server: str):
"""Test getting HTTP headers from the server."""
async with Client(
transport=StreamableHttpTransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
) as client:
raw_result = await client.read_resource("request://headers")
assert isinstance(raw_result[0], TextResourceContents)
json_result = json.loads(raw_result[0].text)
assert "x-demo-header" in json_result
assert json_result["x-demo-header"] == "ABC"
async def test_http_headers_tool(sse_server: str):
"""Test getting HTTP headers from the server."""
async with Client(
transport=StreamableHttpTransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
) as client:
result = await client.call_tool("get_headers_tool")
assert isinstance(result[0], TextContent)
json_result = json.loads(result[0].text)
assert "x-demo-header" in json_result
assert json_result["x-demo-header"] == "ABC"
async def test_http_headers_prompt(sse_server: str):
"""Test getting HTTP headers from the server."""
async with Client(
transport=StreamableHttpTransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
) as client:
result = await client.get_prompt("get_headers_prompt")
assert isinstance(result.messages[0].content, TextContent)
json_result = json.loads(result.messages[0].content.text)
assert "x-demo-header" in json_result
assert json_result["x-demo-header"] == "ABC"

View file

@ -1,7 +1,6 @@
import json
from urllib.parse import quote
import pytest
from mcp.types import TextContent, TextResourceContents
from fastmcp.client.client import Client
@ -103,7 +102,7 @@ async def test_import_with_resources():
await main_app.import_server("data", data_app)
# Verify the resource was imported with the prefix
assert "data+data://users" in main_app._resource_manager._resources
assert "data://data/users" in main_app._resource_manager._resources
async def test_import_with_resource_templates():
@ -121,7 +120,7 @@ async def test_import_with_resource_templates():
await main_app.import_server("api", user_app)
# Verify the template was imported with the prefix
assert "api+users://{user_id}/profile" in main_app._resource_manager._templates
assert "users://api/{user_id}/profile" in main_app._resource_manager._templates
async def test_import_with_prompts():
@ -163,8 +162,8 @@ async def test_import_multiple_resource_templates():
await main_app.import_server("content", news_app)
# Verify templates were imported with correct prefixes
assert "data+weather://{city}" in main_app._resource_manager._templates
assert "content+news://{category}" in main_app._resource_manager._templates
assert "weather://data/{city}" in main_app._resource_manager._templates
assert "news://content/{category}" in main_app._resource_manager._templates
async def test_import_multiple_prompts():
@ -299,7 +298,7 @@ async def test_import_with_proxy_tools():
def get_data(query: str) -> str:
return f"Data for query: {query}"
proxy_app = FastMCP.from_client(Client(api_app))
proxy_app = FastMCP.as_proxy(Client(api_app))
await main_app.import_server("api", proxy_app)
result = await main_app._mcp_call_tool("api_get_data", {"query": "test"})
@ -323,7 +322,7 @@ async def test_import_with_proxy_prompts():
"""Example greeting prompt."""
return f"Hello, {name} from API!"
proxy_app = FastMCP.from_client(Client(api_app))
proxy_app = FastMCP.as_proxy(Client(api_app))
await main_app.import_server("api", proxy_app)
result = await main_app._mcp_get_prompt("api_greeting", {"name": "World"})
@ -351,16 +350,16 @@ async def test_import_with_proxy_resources():
"base_url": "https://api.example.com",
}
proxy_app = FastMCP.from_client(Client(api_app))
proxy_app = FastMCP.as_proxy(Client(api_app))
await main_app.import_server("api", proxy_app)
# Access the resource through the main app with the prefixed key
async with Client(main_app) as client:
result = await client.read_resource("api+config://settings")
result = await client.read_resource("config://api/settings")
assert isinstance(result[0], TextResourceContents)
config_data = json.loads(result[0].text)
assert config_data["api_key"] == "12345"
assert config_data["base_url"] == "https://api.example.com"
content = json.loads(result[0].text)
assert content["api_key"] == "12345"
assert content["base_url"] == "https://api.example.com"
async def test_import_with_proxy_resource_templates():
@ -379,7 +378,7 @@ async def test_import_with_proxy_resource_templates():
def create_user(name: str, email: str):
return {"name": name, "email": email}
proxy_app = FastMCP.from_client(Client(api_app))
proxy_app = FastMCP.as_proxy(Client(api_app))
await main_app.import_server("api", proxy_app)
# Instantiate the template through the main app with the prefixed key
@ -387,30 +386,27 @@ async def test_import_with_proxy_resource_templates():
quoted_name = quote("John Doe", safe="")
quoted_email = quote("john@example.com", safe="")
async with Client(main_app) as client:
result = await client.read_resource(f"api+user://{quoted_name}/{quoted_email}")
result = await client.read_resource(f"user://api/{quoted_name}/{quoted_email}")
assert isinstance(result[0], TextResourceContents)
user_data = json.loads(result[0].text)
assert user_data["name"] == "John Doe"
assert user_data["email"] == "john@example.com"
content = json.loads(result[0].text)
assert content["name"] == "John Doe"
assert content["email"] == "john@example.com"
async def test_import_invalid_resource_prefix():
main_app = FastMCP("MainApp")
api_app = FastMCP("APIApp")
with pytest.raises(
ValueError,
match="Resource prefix or separator would result in an invalid resource URI",
):
await main_app.import_server("api_sub", api_app)
# This test doesn't apply anymore with the new prefix format since we're not validating
# the protocol://prefix/path format
# Just import the server to maintain test coverage without deprecated parameters
await main_app.import_server("api_sub", api_app)
async def test_import_invalid_resource_separator():
main_app = FastMCP("MainApp")
api_app = FastMCP("APIApp")
with pytest.raises(
ValueError,
match="Resource prefix or separator would result in an invalid resource URI",
):
await main_app.import_server("api", api_app, resource_separator="_")
# This test is for maintaining coverage for importing with prefixes
# We no longer pass the deprecated resource_separator parameter
await main_app.import_server("api", api_app)

View file

@ -1,10 +1,15 @@
"""Tests for lifespan functionality in both low-level and FastMCP servers."""
import os
import sys
import traceback
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
import anyio
import pytest
import httpx
import uvicorn
from mcp.server.lowlevel.server import NotificationOptions, Server
from mcp.server.models import InitializationOptions
from mcp.shared.message import SessionMessage
@ -17,11 +22,13 @@ from mcp.types import (
JSONRPCRequest,
)
from pydantic import TypeAdapter
from starlette.applications import Starlette
from starlette.routing import Mount
from fastmcp import Context, FastMCP
from fastmcp.utilities.tests import run_server_in_process
@pytest.mark.anyio
async def test_lowlevel_server_lifespan():
"""Test that lifespan works in low-level server."""
@ -132,7 +139,6 @@ async def test_lowlevel_server_lifespan():
tg.cancel_scope.cancel()
@pytest.mark.anyio
async def test_fastmcp_server_lifespan():
"""Test that lifespan works in FastMCP server."""
@ -234,3 +240,157 @@ async def test_fastmcp_server_lifespan():
# Cancel server task
tg.cancel_scope.cancel()
def run_server_with_incorrect_lifespan_setup(
host: str, port: int, server_log_file_path: str
) -> None:
os.makedirs(os.path.dirname(server_log_file_path), exist_ok=True)
CUSTOM_LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"()": "uvicorn.logging.DefaultFormatter",
"fmt": "%(levelprefix)s %(asctime)s [%(name)s] %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
"use_colors": False,
},
"access": {
"()": "uvicorn.logging.AccessFormatter",
"fmt": '%(levelprefix)s %(asctime)s [%(name)s] %(client_addr)s - "%(request_line)s" %(status_code)s',
"datefmt": "%Y-%m-%d %H:%M:%S",
"use_colors": False,
},
},
"handlers": {
"file_default": {
"formatter": "default",
"class": "logging.FileHandler",
"filename": server_log_file_path,
"mode": "w",
},
"file_access": {
"formatter": "access",
"class": "logging.FileHandler",
"filename": server_log_file_path,
"mode": "a",
},
},
"loggers": {
"uvicorn": { # Catches uvicorn root logs
"handlers": ["file_default"],
"level": "DEBUG",
"propagate": False,
},
"uvicorn.error": {
"handlers": ["file_default"],
"level": "DEBUG",
"propagate": False,
},
"uvicorn.access": {
"handlers": ["file_access"],
"level": "INFO",
"propagate": False,
},
},
"root": {
"handlers": ["file_default"],
"level": "DEBUG",
},
}
try:
mcp = FastMCP()
@mcp.tool("ping_tool", "A simple ping tool for the test server")
def ping_tool() -> str:
return "pong"
mcp_asgi_app = mcp.http_app(transport="streamable-http")
parent_app = Starlette(
routes=[Mount("/mounted_mcp", app=mcp_asgi_app)],
)
uvicorn.run(
parent_app,
host=host,
port=port,
log_config=CUSTOM_LOGGING_CONFIG,
log_level=None,
)
sys.exit(0)
except Exception as e_outer:
with open(server_log_file_path, "a") as f_fallback:
f_fallback.write(
"--- FALLBACK EXCEPTION IN SERVER RUNNER (PRE-UVICORN) ---\n"
)
f_fallback.write(f"{type(e_outer).__name__}: {e_outer}\n")
f_fallback.write(traceback.format_exc())
sys.exit(1)
async def test_missing_lifespan_logs_informative_error(tmp_path: Path):
server_log_file = tmp_path / "server.log"
with run_server_in_process(
run_server_with_incorrect_lifespan_setup, str(server_log_file)
) as server_url:
full_mcp_path = server_url + "/mounted_mcp/mcp/"
client_triggered_error = False
response_status = -1
response_body = ""
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(
full_mcp_path,
json={"id": 1, "method": "list_tools", "jsonrpc": "2.0"},
)
response_status = response.status_code
response_body = response.text
if response.status_code == 500:
client_triggered_error = True
else:
print(
f"Client received unexpected status code: {response.status_code} "
f"Response: {response_body[:500]}"
)
except httpx.RequestError as e:
print(f"Client request failed with RequestError: {e}")
client_triggered_error = True
assert client_triggered_error, (
f"Client request did not result in a 500 error or a request error. "
f"Status: {response_status}, Body: {response_body[:500]}"
)
assert server_log_file.exists(), (
f"Server log file was not created at {server_log_file}"
)
log_content = server_log_file.read_text()
print(f"--- Captured Server Log Content ({server_log_file}) ---")
print(log_content)
print("--- End Server Log Content ---")
# Core assertions for the enhanced error message
assert (
"FastMCP's StreamableHTTPSessionManager task group was not initialized"
in log_content
)
assert "lifespan=mcp_app.lifespan" in log_content
assert "gofastmcp.com/deployment/asgi" in log_content
assert "Original error: Task group is not initialized" in log_content
# Check for Uvicorn's own error logging wrapper for the request
assert "ERROR" in log_content # General check for ERROR level logs
assert "Exception in ASGI application" in log_content
# Sanity checks for server operation and logging setup
assert "Uvicorn running on" in log_content
assert (
"--- FALLBACK EXCEPTION IN SERVER RUNNER (PRE-UVICORN) ---" not in log_content
)

View file

@ -0,0 +1,176 @@
import asyncio
import logging
from unittest.mock import AsyncMock, Mock, patch
import pytest
from fastmcp.server.server import FastMCP
class CustomLogFormatterForTest(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
return f"TEST_FORMAT::{record.levelname}::{record.name}::{record.getMessage()}"
@pytest.fixture
def mcp_server() -> FastMCP:
return FastMCP(name="TestLogServer")
@patch("fastmcp.server.server.uvicorn.Server")
@patch("fastmcp.server.server.uvicorn.Config")
async def test_uvicorn_logging_default_level(
mock_uvicorn_config_constructor: Mock,
mock_uvicorn_server_constructor: Mock,
mcp_server: FastMCP,
):
"""Tests that FastMCP passes log_level to uvicorn.Config if no log_config is given."""
mock_server_instance = AsyncMock()
mock_uvicorn_server_constructor.return_value = mock_server_instance
serve_finished_event = asyncio.Event()
mock_server_instance.serve.side_effect = serve_finished_event.wait
test_log_level = "warning"
server_task = asyncio.create_task(
mcp_server.run_http_async(log_level=test_log_level, port=8003)
)
await asyncio.sleep(0.01)
mock_uvicorn_config_constructor.assert_called_once()
_, kwargs_config = mock_uvicorn_config_constructor.call_args
assert kwargs_config.get("log_level") == test_log_level.lower()
assert "log_config" not in kwargs_config
mock_uvicorn_server_constructor.assert_called_once_with(
mock_uvicorn_config_constructor.return_value
)
mock_server_instance.serve.assert_awaited_once()
server_task.cancel()
with pytest.raises(asyncio.CancelledError):
await server_task
@patch("fastmcp.server.server.uvicorn.Server")
@patch("fastmcp.server.server.uvicorn.Config")
async def test_uvicorn_logging_with_custom_log_config(
mock_uvicorn_config_constructor: Mock,
mock_uvicorn_server_constructor: Mock,
mcp_server: FastMCP,
):
"""Tests that FastMCP passes log_config to uvicorn.Config and not log_level."""
mock_server_instance = AsyncMock()
mock_uvicorn_server_constructor.return_value = mock_server_instance
serve_finished_event = asyncio.Event()
mock_server_instance.serve.side_effect = serve_finished_event.wait
sample_log_config = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"test_formatter": {
"()": "tests.server.test_logging.CustomLogFormatterForTest"
}
},
"handlers": {
"test_handler": {
"formatter": "test_formatter",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
}
},
"loggers": {
"uvicorn.error": {
"handlers": ["test_handler"],
"level": "INFO",
"propagate": False,
}
},
}
server_task = asyncio.create_task(
mcp_server.run_http_async(
uvicorn_config={"log_config": sample_log_config}, port=8004
)
)
await asyncio.sleep(0.01)
mock_uvicorn_config_constructor.assert_called_once()
_, kwargs_config = mock_uvicorn_config_constructor.call_args
assert kwargs_config.get("log_config") == sample_log_config
assert "log_level" not in kwargs_config
mock_uvicorn_server_constructor.assert_called_once_with(
mock_uvicorn_config_constructor.return_value
)
mock_server_instance.serve.assert_awaited_once()
server_task.cancel()
with pytest.raises(asyncio.CancelledError):
await server_task
@patch("fastmcp.server.server.uvicorn.Server")
@patch("fastmcp.server.server.uvicorn.Config")
async def test_uvicorn_logging_custom_log_config_overrides_log_level_param(
mock_uvicorn_config_constructor: Mock,
mock_uvicorn_server_constructor: Mock,
mcp_server: FastMCP,
):
"""Tests log_config precedence if log_level is also passed to run_http_async."""
mock_server_instance = AsyncMock()
mock_uvicorn_server_constructor.return_value = mock_server_instance
serve_finished_event = asyncio.Event()
mock_server_instance.serve.side_effect = serve_finished_event.wait
sample_log_config = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"test_formatter": {
"()": "tests.server.test_logging.CustomLogFormatterForTest"
}
},
"handlers": {
"test_handler": {
"formatter": "test_formatter",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
}
},
"loggers": {
"uvicorn.error": {
"handlers": ["test_handler"],
"level": "INFO",
"propagate": False,
}
},
}
explicit_log_level = "debug"
server_task = asyncio.create_task(
mcp_server.run_http_async(
log_level=explicit_log_level,
uvicorn_config={"log_config": sample_log_config},
port=8005,
)
)
await asyncio.sleep(0.01)
mock_uvicorn_config_constructor.assert_called_once()
_, kwargs_config = mock_uvicorn_config_constructor.call_args
assert kwargs_config.get("log_config") == sample_log_config
assert "log_level" not in kwargs_config
mock_uvicorn_server_constructor.assert_called_once_with(
mock_uvicorn_config_constructor.return_value
)
mock_server_instance.serve.assert_awaited_once()
server_task.cancel()
with pytest.raises(asyncio.CancelledError):
await server_task

View file

@ -1,4 +1,5 @@
import json
import sys
from contextlib import asynccontextmanager
import pytest
@ -7,7 +8,7 @@ from mcp.types import TextContent, TextResourceContents
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.client.transports import FastMCPTransport
from fastmcp.client.transports import FastMCPTransport, SSETransport
from fastmcp.exceptions import NotFoundError
from fastmcp.server.proxy import FastMCPProxy
@ -39,7 +40,7 @@ class TestBasicMount:
assert result[0].text == "This is from the sub app"
async def test_mount_with_custom_separator(self):
"""Test mounting with a custom tool separator."""
"""Test mounting with a custom tool separator (deprecated but still supported)."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
@ -47,15 +48,15 @@ class TestBasicMount:
def greet(name: str) -> str:
return f"Hello, {name}!"
# Mount with custom separator
main_app.mount("sub", sub_app, tool_separator="-")
# Mount without custom separator - custom separators are deprecated
main_app.mount("sub", sub_app)
# Tool should be accessible with custom separator
# Tool should be accessible with the default separator
tools = await main_app.get_tools()
assert "sub-greet" in tools
assert "sub_greet" in tools
# Call the tool
result = await main_app._mcp_call_tool("sub-greet", {"name": "World"})
result = await main_app._mcp_call_tool("sub_greet", {"name": "World"})
assert isinstance(result[0], TextContent)
assert result[0].text == "Hello, World!"
@ -63,21 +64,17 @@ class TestBasicMount:
main_app = FastMCP("MainApp")
api_app = FastMCP("APIApp")
with pytest.raises(
ValueError,
match="Resource prefix or separator would result in an invalid resource URI",
):
main_app.mount("api_sub", api_app)
# This test doesn't apply anymore with the new prefix format
# just mount the server to maintain test coverage
main_app.mount("api:sub", api_app)
async def test_mount_invalid_resource_separator(self):
main_app = FastMCP("MainApp")
api_app = FastMCP("APIApp")
with pytest.raises(
ValueError,
match="Resource prefix or separator would result in an invalid resource URI",
):
main_app.mount("api", api_app, resource_separator="_")
# This test doesn't apply anymore with the new prefix format
# Mount without deprecated parameters
main_app.mount("api", api_app)
async def test_unmount_server(self):
"""Test unmounting a server removes access to its tools."""
@ -114,12 +111,12 @@ class TestBasicMount:
def sub_tool() -> str:
return "This is from the sub app"
main_app.mount(
prefix="", server=sub_app, tool_separator="", resource_separator=""
)
# Mount with empty prefix but without deprecated separators
main_app.mount(prefix="", server=sub_app)
tools = await main_app.get_tools()
assert "sub_tool" in tools
# With empty prefix, the format is now "_sub_tool" instead of "sub_tool"
assert "_sub_tool" in tools
class TestMultipleServerMount:
@ -186,6 +183,80 @@ class TestMultipleServerMount:
# Second app's tool should be accessible
assert "api_second_tool" in tools
@pytest.mark.skipif(
sys.platform == "win32", reason="Windows asyncio networking timeouts."
)
async def test_mount_with_unreachable_proxy_servers(self, caplog):
"""Test graceful handling when multiple mounted servers fail to connect."""
main_app = FastMCP("MainApp")
working_app = FastMCP("WorkingApp")
@working_app.tool()
def working_tool() -> str:
return "Working tool"
@working_app.resource(uri="working://data")
def working_resource():
return "Working resource"
@working_app.prompt()
def working_prompt() -> str:
return "Working prompt"
# Mount the working server
main_app.mount("working", working_app)
# Use an unreachable port
unreachable_client = Client(
transport=SSETransport("http://127.0.0.1:99999/sse")
)
# Create a proxy server that will fail to connect
unreachable_proxy = FastMCP.as_proxy(unreachable_client)
# Mount the unreachable proxy
main_app.mount("unreachable", unreachable_proxy)
# All object types should work from working server despite unreachable proxy
async with Client(main_app) as client:
# Test tools
tools = await client.list_tools()
tool_names = [tool.name for tool in tools]
assert "working_working_tool" in tool_names
# Test calling a tool
result = await client.call_tool("working_working_tool", {})
assert isinstance(result[0], TextContent)
assert result[0].text == "Working tool"
# Test resources
resources = await client.list_resources()
resource_uris = [str(resource.uri) for resource in resources]
assert "working://working/data" in resource_uris
# Test prompts
prompts = await client.list_prompts()
prompt_names = [prompt.name for prompt in prompts]
assert "working_working_prompt" in prompt_names
# Verify that warnings were logged for the unreachable server
warning_messages = [
record.message for record in caplog.records if record.levelname == "WARNING"
]
assert any(
"Failed to get tools from mounted server 'unreachable'" in msg
for msg in warning_messages
)
assert any(
"Failed to get resources from mounted server 'unreachable'" in msg
for msg in warning_messages
)
assert any(
"Failed to get prompts from mounted server 'unreachable'" in msg
for msg in warning_messages
)
class TestDynamicChanges:
"""Test that changes to mounted servers are reflected dynamically."""
@ -259,12 +330,13 @@ class TestResourcesAndTemplates:
# Resource should be accessible through main app
resources = await main_app.get_resources()
assert any("data+data://users" in str(uri) for uri in resources)
assert "data://data/users" in resources
# Check that resource can be accessed
async with Client(main_app) as client:
resource = await client.read_resource("data+data://users")
assert isinstance(resource[0], TextResourceContents)
assert resource[0].text == '[\n "user1",\n "user2"\n]'
result = await client.read_resource("data://data/users")
assert isinstance(result[0], TextResourceContents)
assert json.loads(result[0].text) == ["user1", "user2"]
async def test_mount_with_resource_templates(self):
"""Test mounting a server with resource templates."""
@ -280,14 +352,15 @@ class TestResourcesAndTemplates:
# Template should be accessible through main app
templates = await main_app.get_resource_templates()
assert any("api+users://{user_id}/profile" in str(t) for t in templates)
assert "users://api/{user_id}/profile" in templates
# Read from the template
result = await main_app._mcp_read_resource("api+users://123/profile")
assert isinstance(result[0], ReadResourceContents)
profile = json.loads(result[0].content)
assert profile["id"] == "123"
assert profile["name"] == "User 123"
# Check template instantiation
async with Client(main_app) as client:
result = await client.read_resource("users://api/123/profile")
assert isinstance(result[0], TextResourceContents)
profile = json.loads(result[0].text)
assert profile["id"] == "123"
assert profile["name"] == "User 123"
async def test_adding_resource_after_mounting(self):
"""Test adding a resource after mounting."""
@ -304,13 +377,14 @@ class TestResourcesAndTemplates:
# Resource should be accessible through main app
resources = await main_app.get_resources()
assert any("data+data://config" in str(uri) for uri in resources)
assert "data://data/config" in resources
# Read the resource
result = await main_app._mcp_read_resource("data+data://config")
assert isinstance(result[0], ReadResourceContents)
config = json.loads(result[0].content)
assert config["version"] == "1.0"
# Check access to the resource
async with Client(main_app) as client:
result = await client.read_resource("data://data/config")
assert isinstance(result[0], TextResourceContents)
config = json.loads(result[0].text)
assert config["version"] == "1.0"
class TestPrompts:
@ -373,7 +447,7 @@ class TestProxyServer:
return f"Data for {query}"
# Create proxy server
proxy_server = FastMCP.from_client(
proxy_server = FastMCP.as_proxy(
Client(transport=FastMCPTransport(original_server))
)
@ -396,7 +470,7 @@ class TestProxyServer:
original_server = FastMCP("OriginalServer")
# Create proxy server
proxy_server = FastMCP.from_client(
proxy_server = FastMCP.as_proxy(
Client(transport=FastMCPTransport(original_server))
)
@ -428,7 +502,7 @@ class TestProxyServer:
return {"api_key": "12345"}
# Create proxy server
proxy_server = FastMCP.from_client(
proxy_server = FastMCP.as_proxy(
Client(transport=FastMCPTransport(original_server))
)
@ -437,7 +511,7 @@ class TestProxyServer:
main_app.mount("proxy", proxy_server)
# Resource should be accessible through main app
result = await main_app._mcp_read_resource("proxy+config://settings")
result = await main_app._mcp_read_resource("config://proxy/settings")
assert isinstance(result[0], ReadResourceContents)
config = json.loads(result[0].content)
assert config["api_key"] == "12345"
@ -452,7 +526,7 @@ class TestProxyServer:
return f"Welcome, {name}!"
# Create proxy server
proxy_server = FastMCP.from_client(
proxy_server = FastMCP.as_proxy(
Client(transport=FastMCPTransport(original_server))
)
@ -510,7 +584,7 @@ class TestAsProxyKwarg:
async def test_as_proxy_ignored_for_proxy_mounts_default(self):
mcp = FastMCP("Main")
sub = FastMCP("Sub")
sub_proxy = FastMCP.from_client(Client(transport=FastMCPTransport(sub)))
sub_proxy = FastMCP.as_proxy(Client(transport=FastMCPTransport(sub)))
mcp.mount("sub", sub_proxy)
@ -519,7 +593,7 @@ class TestAsProxyKwarg:
async def test_as_proxy_ignored_for_proxy_mounts_false(self):
mcp = FastMCP("Main")
sub = FastMCP("Sub")
sub_proxy = FastMCP.from_client(Client(transport=FastMCPTransport(sub)))
sub_proxy = FastMCP.as_proxy(Client(transport=FastMCPTransport(sub)))
mcp.mount("sub", sub_proxy, as_proxy=False)
@ -528,7 +602,7 @@ class TestAsProxyKwarg:
async def test_as_proxy_ignored_for_proxy_mounts_true(self):
mcp = FastMCP("Main")
sub = FastMCP("Sub")
sub_proxy = FastMCP.from_client(Client(transport=FastMCPTransport(sub)))
sub_proxy = FastMCP.as_proxy(Client(transport=FastMCPTransport(sub)))
mcp.mount("sub", sub_proxy, as_proxy=True)

View file

@ -30,6 +30,10 @@ def fastmcp_server():
"""Greet someone by name."""
return f"Hello, {name}!"
@server.tool()
def tool_without_description() -> str:
return "Hello?"
@server.tool()
def add(a: int, b: int) -> int:
"""Add two numbers together."""
@ -66,7 +70,7 @@ def fastmcp_server():
@pytest.fixture
async def proxy_server(fastmcp_server):
"""Fixture that creates a FastMCP proxy server."""
return FastMCP.from_client(Client(transport=FastMCPTransport(fastmcp_server)))
return FastMCP.as_proxy(Client(transport=FastMCPTransport(fastmcp_server)))
async def test_create_proxy(fastmcp_server):
@ -74,19 +78,47 @@ async def test_create_proxy(fastmcp_server):
# Create a client
client = Client(transport=FastMCPTransport(fastmcp_server))
server = FastMCPProxy.from_client(client)
server = FastMCPProxy.as_proxy(client)
assert isinstance(server, FastMCPProxy)
assert isinstance(server, FastMCP)
assert server.name == "FastMCP"
async def test_as_proxy_with_server(fastmcp_server):
"""FastMCP.as_proxy should accept a FastMCP instance."""
proxy = FastMCP.as_proxy(fastmcp_server)
result = await proxy._mcp_call_tool("greet", {"name": "Test"})
assert isinstance(result[0], mcp.types.TextContent)
assert result[0].text == "Hello, Test!"
async def test_as_proxy_with_transport(fastmcp_server):
"""FastMCP.as_proxy should accept a ClientTransport."""
proxy = FastMCP.as_proxy(FastMCPTransport(fastmcp_server))
result = await proxy._mcp_call_tool("greet", {"name": "Test"})
assert isinstance(result[0], mcp.types.TextContent)
assert result[0].text == "Hello, Test!"
def test_as_proxy_with_url():
"""FastMCP.as_proxy should accept a URL without connecting."""
proxy = FastMCP.as_proxy("http://example.com/mcp")
assert isinstance(proxy, FastMCPProxy)
assert repr(proxy.client.transport).startswith("<StreamableHttp(")
class TestTools:
async def test_get_tools(self, proxy_server):
tools = await proxy_server.get_tools()
assert "greet" in tools
assert "add" in tools
assert "error_tool" in tools
assert "tool_without_description" in tools
async def test_tool_without_description(self, proxy_server):
tools = await proxy_server.get_tools()
assert tools["tool_without_description"].description is None
async def test_list_tools_same_as_original(self, fastmcp_server, proxy_server):
assert (

View file

@ -0,0 +1,65 @@
"""Tests for different resource prefix formats in server mounting and importing."""
from fastmcp import FastMCP
async def test_resource_prefix_format_in_constructor():
"""Test that the resource_prefix_format parameter is respected in the constructor."""
server_path = FastMCP("PathFormat", resource_prefix_format="path")
server_protocol = FastMCP("ProtocolFormat", resource_prefix_format="protocol")
# Check that the format is stored correctly
assert server_path.resource_prefix_format == "path"
assert server_protocol.resource_prefix_format == "protocol"
# Register resources
@server_path.resource("resource://test")
def get_test_path():
return "test content"
@server_protocol.resource("resource://test")
def get_test_protocol():
return "test content"
# Create mount servers
main_server_path = FastMCP("MainPath", resource_prefix_format="path")
main_server_protocol = FastMCP("MainProtocol", resource_prefix_format="protocol")
# Mount the servers
main_server_path.mount("sub", server_path)
main_server_protocol.mount("sub", server_protocol)
# Check that the resources are prefixed correctly
path_resources = await main_server_path.get_resources()
protocol_resources = await main_server_protocol.get_resources()
# Path format should be resource://sub/test
assert "resource://sub/test" in path_resources
# Protocol format should be sub+resource://test
assert "sub+resource://test" in protocol_resources
async def test_resource_prefix_format_in_import_server():
"""Test that the resource_prefix_format parameter is respected in import_server."""
server = FastMCP("TestServer")
@server.resource("resource://test")
def get_test():
return "test content"
# Import with path format
main_server_path = FastMCP("MainPath", resource_prefix_format="path")
await main_server_path.import_server("sub", server)
# Import with protocol format
main_server_protocol = FastMCP("MainProtocol", resource_prefix_format="protocol")
await main_server_protocol.import_server("sub", server)
# Check that the resources are prefixed correctly
path_resources = main_server_path._resource_manager.get_resources()
protocol_resources = main_server_protocol._resource_manager.get_resources()
# Path format should be resource://sub/test
assert "resource://sub/test" in path_resources
# Protocol format should be sub+resource://test
assert "sub+resource://test" in protocol_resources

View file

@ -10,6 +10,12 @@ from pydantic import Field
from fastmcp import Client, FastMCP
from fastmcp.exceptions import NotFoundError
from fastmcp.server.server import (
MountedServer,
add_resource_prefix,
has_resource_prefix,
remove_resource_prefix,
)
class TestCreateServer:
@ -754,3 +760,287 @@ class TestPromptDecorator:
assert len(prompts_dict) == 1
prompt = prompts_dict["sample_prompt"]
assert prompt.tags == {"example", "test-tag"}
class TestResourcePrefixHelpers:
@pytest.mark.parametrize(
"uri,prefix,expected",
[
# Normal paths
(
"resource://path/to/resource",
"prefix",
"resource://prefix/path/to/resource",
),
# Absolute paths (with triple slash)
("resource:///absolute/path", "prefix", "resource://prefix//absolute/path"),
# Empty prefix should return the original URI
("resource://path/to/resource", "", "resource://path/to/resource"),
# Different protocols
("file://path/to/file", "prefix", "file://prefix/path/to/file"),
("http://example.com/path", "prefix", "http://prefix/example.com/path"),
# Prefixes with special characters
(
"resource://path/to/resource",
"pre.fix",
"resource://pre.fix/path/to/resource",
),
(
"resource://path/to/resource",
"pre/fix",
"resource://pre/fix/path/to/resource",
),
# Empty paths
("resource://", "prefix", "resource://prefix/"),
],
)
def test_add_resource_prefix(self, uri, prefix, expected):
"""Test that add_resource_prefix correctly adds prefixes to URIs."""
result = add_resource_prefix(uri, prefix)
assert result == expected
@pytest.mark.parametrize(
"invalid_uri",
[
"not-a-uri",
"resource:no-slashes",
"missing-protocol",
"http:/missing-slash",
],
)
def test_add_resource_prefix_invalid_uri(self, invalid_uri):
"""Test that add_resource_prefix raises ValueError for invalid URIs."""
with pytest.raises(ValueError, match="Invalid URI format"):
add_resource_prefix(invalid_uri, "prefix")
@pytest.mark.parametrize(
"uri,prefix,expected",
[
# Normal paths
(
"resource://prefix/path/to/resource",
"prefix",
"resource://path/to/resource",
),
# Absolute paths (with triple slash)
("resource://prefix//absolute/path", "prefix", "resource:///absolute/path"),
# URI without the expected prefix should return the original URI
(
"resource://other/path/to/resource",
"prefix",
"resource://other/path/to/resource",
),
# Empty prefix should return the original URI
("resource://path/to/resource", "", "resource://path/to/resource"),
# Different protocols
("file://prefix/path/to/file", "prefix", "file://path/to/file"),
# Prefixes with special characters (that need escaping in regex)
(
"resource://pre.fix/path/to/resource",
"pre.fix",
"resource://path/to/resource",
),
(
"resource://pre/fix/path/to/resource",
"pre/fix",
"resource://path/to/resource",
),
# Empty paths
("resource://prefix/", "prefix", "resource://"),
],
)
def test_remove_resource_prefix(self, uri, prefix, expected):
"""Test that remove_resource_prefix correctly removes prefixes from URIs."""
result = remove_resource_prefix(uri, prefix)
assert result == expected
@pytest.mark.parametrize(
"invalid_uri",
[
"not-a-uri",
"resource:no-slashes",
"missing-protocol",
"http:/missing-slash",
],
)
def test_remove_resource_prefix_invalid_uri(self, invalid_uri):
"""Test that remove_resource_prefix raises ValueError for invalid URIs."""
with pytest.raises(ValueError, match="Invalid URI format"):
remove_resource_prefix(invalid_uri, "prefix")
@pytest.mark.parametrize(
"uri,prefix,expected",
[
# URI with prefix
("resource://prefix/path/to/resource", "prefix", True),
# URI with another prefix
("resource://other/path/to/resource", "prefix", False),
# URI with prefix as a substring but not at path start
("resource://path/prefix/resource", "prefix", False),
# Empty prefix
("resource://path/to/resource", "", False),
# Different protocols
("file://prefix/path/to/file", "prefix", True),
# Prefix with special characters
("resource://pre.fix/path/to/resource", "pre.fix", True),
# Empty paths
("resource://prefix/", "prefix", True),
],
)
def test_has_resource_prefix(self, uri, prefix, expected):
"""Test that has_resource_prefix correctly identifies prefixes in URIs."""
result = has_resource_prefix(uri, prefix)
assert result == expected
@pytest.mark.parametrize(
"invalid_uri",
[
"not-a-uri",
"resource:no-slashes",
"missing-protocol",
"http:/missing-slash",
],
)
def test_has_resource_prefix_invalid_uri(self, invalid_uri):
"""Test that has_resource_prefix raises ValueError for invalid URIs."""
with pytest.raises(ValueError, match="Invalid URI format"):
has_resource_prefix(invalid_uri, "prefix")
class TestResourcePrefixMounting:
"""Test resource prefixing in mounted servers."""
async def test_mounted_server_resource_prefixing(self):
"""Test that resources in mounted servers use the correct prefix format."""
# Create a server with resources
server = FastMCP(name="ResourceServer")
@server.resource("resource://test-resource")
def get_resource():
return "Resource content"
@server.resource("resource:///absolute/path")
def get_absolute_resource():
return "Absolute resource content"
@server.resource("resource://{param}/template")
def get_template_resource(param: str):
return f"Template resource with {param}"
# Create a main server and mount the resource server
main_server = FastMCP(name="MainServer")
main_server.mount("prefix", server)
# Check that the resources are mounted with the correct prefixes
resources = await main_server.get_resources()
templates = await main_server.get_resource_templates()
assert "resource://prefix/test-resource" in resources
assert "resource://prefix//absolute/path" in resources
assert "resource://prefix/{param}/template" in templates
# Test that prefixed resources can be accessed
async with Client(main_server) as client:
# Regular resource
result = await client.read_resource("resource://prefix/test-resource")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Resource content"
# Absolute path resource
result = await client.read_resource("resource://prefix//absolute/path")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Absolute resource content"
# Template resource
result = await client.read_resource(
"resource://prefix/param-value/template"
)
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Template resource with param-value"
@pytest.mark.parametrize(
"uri,prefix,expected_match,expected_strip",
[
# Regular resource
(
"resource://prefix/path/to/resource",
"prefix",
True,
"resource://path/to/resource",
),
# Absolute path
(
"resource://prefix//absolute/path",
"prefix",
True,
"resource:///absolute/path",
),
# Non-matching prefix
(
"resource://other/path/to/resource",
"prefix",
False,
"resource://other/path/to/resource",
),
# Different protocol
("http://prefix/example.com", "prefix", True, "http://example.com"),
],
)
async def test_mounted_server_matching_and_stripping(
self, uri, prefix, expected_match, expected_strip
):
"""Test that MountedServer correctly matches and strips resource prefixes."""
# Create a basic server to mount
server = FastMCP()
mounted = MountedServer(prefix=prefix, server=server)
# Test matching
assert mounted.match_resource(uri) == expected_match
# Test stripping
assert mounted.strip_resource_prefix(uri) == expected_strip
async def test_import_server_with_new_prefix_format(self):
"""Test that import_server correctly uses the new prefix format."""
# Create a server with resources
source_server = FastMCP(name="SourceServer")
@source_server.resource("resource://test-resource")
def get_resource():
return "Resource content"
@source_server.resource("resource:///absolute/path")
def get_absolute_resource():
return "Absolute resource content"
@source_server.resource("resource://{param}/template")
def get_template_resource(param: str):
return f"Template resource with {param}"
# Create target server and import the source server
target_server = FastMCP(name="TargetServer")
await target_server.import_server("imported", source_server)
# Check that the resources were imported with the correct prefixes
resources = await target_server.get_resources()
templates = await target_server.get_resource_templates()
assert "resource://imported/test-resource" in resources
assert "resource://imported//absolute/path" in resources
assert "resource://imported/{param}/template" in templates
# Verify we can access the resources
async with Client(target_server) as client:
result = await client.read_resource("resource://imported/test-resource")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Resource content"
result = await client.read_resource("resource://imported//absolute/path")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Absolute resource content"
result = await client.read_resource(
"resource://imported/param-value/template"
)
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Template resource with param-value"

View file

@ -640,7 +640,6 @@ class TestToolContextInjection:
assert len(result) == 1
content = result[0]
assert isinstance(content, TextContent)
assert content.text == "1"
async def test_async_context(self):
"""Test that context works in async functions."""
@ -656,8 +655,7 @@ class TestToolContextInjection:
assert len(result) == 1
content = result[0]
assert isinstance(content, TextContent)
assert "Async request" in content.text
assert "42" in content.text
assert content.text == "Async request 2: 42"
async def test_optional_context(self):
"""Test that context is optional."""
@ -711,6 +709,21 @@ class TestToolContextInjection:
assert len(tools) == 1
# Note: MCPTool from the client API doesn't expose tags
async def test_callable_object_with_context(self):
"""Test that a callable object can be used as a tool with context."""
mcp = FastMCP()
class MyTool:
async def __call__(self, x: int, ctx: Context) -> int:
return x + int(ctx.request_id)
mcp.add_tool(MyTool())
async with Client(mcp) as client:
result = await client.call_tool("MyTool", {"x": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "4"
class TestResource:
async def test_text_resource(self):
@ -798,7 +811,7 @@ class TestResourceContext:
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("resource://test"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "1"
assert result[0].text == "2"
class TestResourceTemplates:
@ -1096,7 +1109,21 @@ class TestResourceTemplateContext:
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("resource://test"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Resource template: test 1"
assert result[0].text.startswith("Resource template: test 2")
async def test_resource_template_context_with_callable_object(self):
mcp = FastMCP()
class MyResource:
def __call__(self, param: str, ctx: Context) -> str:
return f"Resource template: {param} {ctx.request_id}"
mcp.add_resource_fn(MyResource(), uri="resource://{param}")
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("resource://test"))
assert isinstance(result[0], TextResourceContents)
assert result[0].text.startswith("Resource template: test 2")
class TestPrompts:
@ -1300,3 +1327,20 @@ class TestPromptContext:
assert len(result.messages) == 1
message = result.messages[0]
assert message.role == "user"
async def test_prompt_context_with_callable_object(self):
mcp = FastMCP()
class MyPrompt:
def __call__(self, name: str, ctx: Context) -> str:
return f"Hello, {name}! {ctx.request_id}"
mcp.add_prompt(MyPrompt(), name="my_prompt")
async with Client(mcp) as client:
result = await client.get_prompt("my_prompt", {"name": "World"})
assert len(result.messages) == 1
message = result.messages[0]
assert message.role == "user"
assert isinstance(message.content, TextContent)
assert message.content.text == "Hello, World! 2"

View file

@ -1,6 +1,5 @@
"""Tests for example servers"""
import pytest
from mcp.types import (
PromptMessage,
TextContent,
@ -11,7 +10,6 @@ from pydantic import AnyUrl
from fastmcp import Client
@pytest.mark.anyio
async def test_simple_echo():
"""Test the simple echo server"""
from examples.simple_echo import mcp
@ -23,7 +21,6 @@ async def test_simple_echo():
assert result[0].text == "hello"
@pytest.mark.anyio
async def test_complex_inputs():
"""Test the complex inputs server"""
from examples.complex_inputs import mcp
@ -38,7 +35,6 @@ async def test_complex_inputs():
assert result[0].text == '[\n "bob",\n "alice",\n "charlie"\n]'
@pytest.mark.anyio
async def test_desktop(monkeypatch):
"""Test the desktop server"""
from examples.desktop import mcp
@ -58,7 +54,6 @@ async def test_desktop(monkeypatch):
assert result[0].text == "Hello, rooter12!"
@pytest.mark.anyio
async def test_echo():
"""Test the echo server"""
from examples.echo import mcp

View file

@ -21,6 +21,7 @@ class TestToolFromFunction:
assert tool.name == "add"
assert tool.description == "Add two numbers."
assert len(tool.parameters["properties"]) == 2
assert tool.parameters["properties"]["a"]["type"] == "integer"
assert tool.parameters["properties"]["b"]["type"] == "integer"
@ -37,6 +38,36 @@ class TestToolFromFunction:
assert tool.description == "Fetch data from URL."
assert tool.parameters["properties"]["url"]["type"] == "string"
def test_callable_object(self):
class Adder:
"""Adds two numbers."""
def __call__(self, x: int, y: int) -> int:
"""ignore this"""
return x + y
tool = Tool.from_function(Adder())
assert tool.name == "Adder"
assert tool.description == "Adds two numbers."
assert len(tool.parameters["properties"]) == 2
assert tool.parameters["properties"]["x"]["type"] == "integer"
assert tool.parameters["properties"]["y"]["type"] == "integer"
def test_async_callable_object(self):
class Adder:
"""Adds two numbers."""
async def __call__(self, x: int, y: int) -> int:
"""ignore this"""
return x + y
tool = Tool.from_function(Adder())
assert tool.name == "Adder"
assert tool.description == "Adds two numbers."
assert len(tool.parameters["properties"]) == 2
assert tool.parameters["properties"]["x"]["type"] == "integer"
assert tool.parameters["properties"]["y"]["type"] == "integer"
def test_pydantic_model_function(self):
"""Test registering a function that takes a Pydantic model."""

View file

@ -71,6 +71,44 @@ class TestAddTools:
assert "age" in tool.parameters["$defs"]["UserInput"]["properties"]
assert "flag" in tool.parameters["properties"]
def test_callable_object(self):
class Adder:
"""Adds two numbers."""
def __call__(self, x: int, y: int) -> int:
"""ignore this"""
return x + y
manager = ToolManager()
manager.add_tool_from_fn(Adder())
tool = manager.get_tool("Adder")
assert tool is not None
assert tool.name == "Adder"
assert tool.description == "Adds two numbers."
assert len(tool.parameters["properties"]) == 2
assert tool.parameters["properties"]["x"]["type"] == "integer"
assert tool.parameters["properties"]["y"]["type"] == "integer"
def test_async_callable_object(self):
class Adder:
"""Adds two numbers."""
async def __call__(self, x: int, y: int) -> int:
"""ignore this"""
return x + y
manager = ToolManager()
manager.add_tool_from_fn(Adder())
tool = manager.get_tool("Adder")
assert tool is not None
assert tool.name == "Adder"
assert tool.description == "Adds two numbers."
assert len(tool.parameters["properties"]) == 2
assert tool.parameters["properties"]["x"]["type"] == "integer"
assert tool.parameters["properties"]["y"]["type"] == "integer"
async def test_tool_with_image_return(self):
def image_tool(data: bytes) -> Image:
return Image(data=data)
@ -303,6 +341,40 @@ class TestCallTools:
assert result[0].text == "10"
assert json.loads(result[0].text) == 10
async def test_call_tool_callable_object(self):
class Adder:
"""Adds two numbers."""
def __call__(self, x: int, y: int) -> int:
"""ignore this"""
return x + y
manager = ToolManager()
manager.add_tool_from_fn(Adder())
result = await manager.call_tool("Adder", {"x": 1, "y": 2})
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
assert json.loads(result[0].text) == 3
async def test_call_tool_callable_object_async(self):
class Adder:
"""Adds two numbers."""
async def __call__(self, x: int, y: int) -> int:
"""ignore this"""
return x + y
manager = ToolManager()
manager.add_tool_from_fn(Adder())
result = await manager.call_tool("Adder", {"x": 1, "y": 2})
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
assert json.loads(result[0].text) == 3
async def test_call_tool_with_default_args(self):
def add(a: int, b: int = 1) -> int:
"""Add two numbers."""
@ -778,8 +850,8 @@ class TestToolErrorHandling:
with pytest.raises(ToolError, match="Specific tool error"):
await manager.call_tool("error_tool", {"x": 42})
async def test_exception_converted_to_tool_error(self):
"""Test that other exceptions are converted to ToolError."""
async def test_exception_converted_to_tool_error_with_details(self):
"""Test that other exceptions include details by default."""
manager = ToolManager()
def buggy_tool(x: int) -> int:
@ -791,7 +863,24 @@ class TestToolErrorHandling:
with pytest.raises(ToolError) as excinfo:
await manager.call_tool("buggy_tool", {"x": 42})
# Exception message should contain the tool name but not the internal details
# Exception message should include the tool name and the internal details
assert "Error calling tool 'buggy_tool'" in str(excinfo.value)
assert "Internal error details" in str(excinfo.value)
async def test_exception_converted_to_masked_tool_error(self):
"""Test that other exceptions are masked when enabled."""
manager = ToolManager(mask_error_details=True)
def buggy_tool(x: int) -> int:
"""Tool that raises a ValueError."""
raise ValueError("Internal error details")
manager.add_tool_from_fn(buggy_tool)
with pytest.raises(ToolError) as excinfo:
await manager.call_tool("buggy_tool", {"x": 42})
# Exception message should only contain the tool name, not the internal details
assert "Error calling tool 'buggy_tool'" in str(excinfo.value)
assert "Internal error details" not in str(excinfo.value)
@ -808,8 +897,8 @@ class TestToolErrorHandling:
with pytest.raises(ToolError, match="Async tool error"):
await manager.call_tool("async_error_tool", {"x": 42})
async def test_async_exception_converted_to_tool_error(self):
"""Test that other exceptions from async tools are converted to ToolError."""
async def test_async_exception_converted_to_tool_error_with_details(self):
"""Test that other exceptions from async tools include details by default."""
manager = ToolManager()
async def async_buggy_tool(x: int) -> int:
@ -818,6 +907,23 @@ class TestToolErrorHandling:
manager.add_tool_from_fn(async_buggy_tool)
with pytest.raises(ToolError) as excinfo:
await manager.call_tool("async_buggy_tool", {"x": 42})
# Exception message should include the tool name and the internal details
assert "Error calling tool 'async_buggy_tool'" in str(excinfo.value)
assert "Internal async error details" in str(excinfo.value)
async def test_async_exception_converted_to_masked_tool_error(self):
"""Test that other exceptions from async tools are masked when enabled."""
manager = ToolManager(mask_error_details=True)
async def async_buggy_tool(x: int) -> int:
"""Async tool that raises a ValueError."""
raise ValueError("Internal async error details")
manager.add_tool_from_fn(async_buggy_tool)
with pytest.raises(ToolError) as excinfo:
await manager.call_tool("async_buggy_tool", {"x": 42})

View file

@ -9,7 +9,7 @@ from fastmcp.utilities.openapi import parse_openapi_to_http_routes
@pytest.fixture
def fastapi_server() -> FastAPI:
def fastapi_app() -> FastAPI:
"""Fixture that returns a FastAPI app for live OpenAPI schema testing."""
from enum import Enum
@ -228,9 +228,9 @@ def fastapi_server() -> FastAPI:
@pytest.fixture
def fastapi_openapi_schema(fastapi_server) -> dict[str, Any]:
def fastapi_openapi_schema(fastapi_app) -> dict[str, Any]:
"""Fixture that returns the OpenAPI schema from a live FastAPI server."""
return fastapi_server.openapi()
return fastapi_app.openapi()
@pytest.fixture
@ -472,11 +472,11 @@ def test_tag_consistency_across_related_endpoints(route_map):
)
def test_tag_order_preservation(fastapi_server):
def test_tag_order_preservation(fastapi_app):
"""Test that tag order is preserved in the parsed routes."""
# Add a new endpoint with specifically ordered tags
@fastapi_server.get(
@fastapi_app.get(
"/test-tag-order",
tags=["first", "second", "third"],
operation_id="test_tag_order",
@ -485,7 +485,7 @@ def test_tag_order_preservation(fastapi_server):
return {"result": "testing tag order"}
# Get the updated schema and parse routes
routes = parse_openapi_to_http_routes(fastapi_server.openapi())
routes = parse_openapi_to_http_routes(fastapi_app.openapi())
# Find our test route
test_route = next((r for r in routes if r.path == "/test-tag-order"), None)
@ -497,11 +497,11 @@ def test_tag_order_preservation(fastapi_server):
)
def test_duplicate_tags_handling(fastapi_server):
def test_duplicate_tags_handling(fastapi_app):
"""Test handling of duplicate tags in the OpenAPI schema."""
# Add an endpoint with duplicate tags
@fastapi_server.get(
@fastapi_app.get(
"/test-duplicate-tags",
tags=["duplicate", "items", "duplicate"],
operation_id="test_duplicate_tags",
@ -510,7 +510,7 @@ def test_duplicate_tags_handling(fastapi_server):
return {"result": "testing duplicate tags"}
# Get the updated schema and parse routes
routes = parse_openapi_to_http_routes(fastapi_server.openapi())
routes = parse_openapi_to_http_routes(fastapi_app.openapi())
# Find our test route
test_route = next((r for r in routes if r.path == "/test-duplicate-tags"), None)

View file

@ -0,0 +1,142 @@
import inspect
from pathlib import Path
from mcp.types import TextContent
from fastmcp.client.client import Client
from fastmcp.client.transports import (
SSETransport,
StdioTransport,
StreamableHttpTransport,
)
from fastmcp.utilities.mcp_config import MCPConfig, RemoteMCPServer, StdioMCPServer
def test_parse_single_stdio_config():
config = {
"mcpServers": {
"test_server": {
"command": "echo",
"args": ["hello"],
}
}
}
mcp_config = MCPConfig.from_dict(config)
transport = mcp_config.mcpServers["test_server"].to_transport()
assert isinstance(transport, StdioTransport)
assert transport.command == "echo"
assert transport.args == ["hello"]
def test_parse_single_remote_config():
config = {
"mcpServers": {
"test_server": {
"url": "http://localhost:8000",
}
}
}
mcp_config = MCPConfig.from_dict(config)
transport = mcp_config.mcpServers["test_server"].to_transport()
assert isinstance(transport, StreamableHttpTransport)
assert transport.url == "http://localhost:8000"
def test_parse_remote_config_with_transport():
config = {
"mcpServers": {
"test_server": {
"url": "http://localhost:8000",
"transport": "sse",
}
}
}
mcp_config = MCPConfig.from_dict(config)
transport = mcp_config.mcpServers["test_server"].to_transport()
assert isinstance(transport, SSETransport)
assert transport.url == "http://localhost:8000"
def test_parse_remote_config_with_url_inference():
config = {
"mcpServers": {
"test_server": {
"url": "http://localhost:8000/sse",
}
}
}
mcp_config = MCPConfig.from_dict(config)
transport = mcp_config.mcpServers["test_server"].to_transport()
assert isinstance(transport, SSETransport)
assert transport.url == "http://localhost:8000/sse"
def test_parse_multiple_servers():
config = {
"mcpServers": {
"test_server": {
"url": "http://localhost:8000/sse",
},
"test_server_2": {
"command": "echo",
"args": ["hello"],
"env": {"TEST": "test"},
},
}
}
mcp_config = MCPConfig.from_dict(config)
assert len(mcp_config.mcpServers) == 2
assert isinstance(mcp_config.mcpServers["test_server"], RemoteMCPServer)
assert isinstance(mcp_config.mcpServers["test_server"].to_transport(), SSETransport)
assert isinstance(mcp_config.mcpServers["test_server_2"], StdioMCPServer)
assert isinstance(
mcp_config.mcpServers["test_server_2"].to_transport(), StdioTransport
)
assert mcp_config.mcpServers["test_server_2"].command == "echo"
assert mcp_config.mcpServers["test_server_2"].args == ["hello"]
assert mcp_config.mcpServers["test_server_2"].env == {"TEST": "test"}
async def test_multi_client(tmp_path: Path):
server_script = inspect.cleandoc("""
from fastmcp import FastMCP
mcp = FastMCP()
@mcp.tool()
def add(a: int, b: int) -> int:
return a + b
if __name__ == '__main__':
mcp.run()
""")
script_path = tmp_path / "test.py"
script_path.write_text(server_script)
config = {
"mcpServers": {
"test_1": {
"command": "python",
"args": [str(script_path)],
},
"test_2": {
"command": "python",
"args": [str(script_path)],
},
}
}
client = Client(config)
async with client:
tools = await client.list_tools()
assert len(tools) == 2
result_1 = await client.call_tool("test_1_add", {"a": 1, "b": 2})
result_2 = await client.call_tool("test_2_add", {"a": 1, "b": 2})
assert isinstance(result_1[0], TextContent)
assert result_1[0].text == "3"
assert isinstance(result_2[0], TextContent)
assert result_2[0].text == "3"

View file

@ -4,6 +4,7 @@ from fastmcp.utilities.tests import temporary_settings
class TestTemporarySettings:
def test_temporary_settings(self):
with temporary_settings(log_level="DEBUG"):
assert fastmcp.settings.settings.log_level == "DEBUG"
assert fastmcp.settings.settings.log_level == "INFO"
assert fastmcp.settings.settings.log_level == "DEBUG"
with temporary_settings(log_level="ERROR"):
assert fastmcp.settings.settings.log_level == "ERROR"
assert fastmcp.settings.settings.log_level == "DEBUG"

19
uv.lock generated
View file

@ -446,6 +446,7 @@ dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-env" },
{ name = "pytest-flakefinder" },
{ name = "pytest-report" },
{ name = "pytest-timeout" },
@ -478,6 +479,7 @@ dev = [
{ name = "pytest", specifier = ">=8.3.3" },
{ name = "pytest-asyncio", specifier = ">=0.23.5" },
{ name = "pytest-cov", specifier = ">=6.1.1" },
{ name = "pytest-env", specifier = ">=1.1.5" },
{ name = "pytest-flakefinder" },
{ name = "pytest-report", specifier = ">=0.2.1" },
{ name = "pytest-timeout", specifier = ">=2.4.0" },
@ -704,9 +706,9 @@ dependencies = [
{ name = "starlette" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bc/8d/0f4468582e9e97b0a24604b585c651dfd2144300ecffd1c06a680f5c8861/mcp-1.9.0.tar.gz", hash = "sha256:905d8d208baf7e3e71d70c82803b89112e321581bcd2530f9de0fe4103d28749", size = 281432 }
sdist = { url = "https://files.pythonhosted.org/packages/bc/8d/0f4468582e9e97b0a24604b585c651dfd2144300ecffd1c06a680f5c8861/mcp-1.9.0.tar.gz", hash = "sha256:905d8d208baf7e3e71d70c82803b89112e321581bcd2530f9de0fe4103d28749", size = 281432, upload-time = "2025-05-15T18:51:06.615Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/d5/22e36c95c83c80eb47c83f231095419cf57cf5cca5416f1c960032074c78/mcp-1.9.0-py3-none-any.whl", hash = "sha256:9dfb89c8c56f742da10a5910a1f64b0d2ac2c3ed2bd572ddb1cfab7f35957178", size = 125082 },
{ url = "https://files.pythonhosted.org/packages/a5/d5/22e36c95c83c80eb47c83f231095419cf57cf5cca5416f1c960032074c78/mcp-1.9.0-py3-none-any.whl", hash = "sha256:9dfb89c8c56f742da10a5910a1f64b0d2ac2c3ed2bd572ddb1cfab7f35957178", size = 125082, upload-time = "2025-05-15T18:51:04.916Z" },
]
[[package]]
@ -1068,6 +1070,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload-time = "2025-04-05T14:07:49.641Z" },
]
[[package]]
name = "pytest-env"
version = "1.1.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/31/27f28431a16b83cab7a636dce59cf397517807d247caa38ee67d65e71ef8/pytest_env-1.1.5.tar.gz", hash = "sha256:91209840aa0e43385073ac464a554ad2947cc2fd663a9debf88d03b01e0cc1cf", size = 8911, upload-time = "2024-09-17T22:39:18.566Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/b8/87cfb16045c9d4092cfcf526135d73b88101aac83bc1adcf82dfb5fd3833/pytest_env-1.1.5-py3-none-any.whl", hash = "sha256:ce90cf8772878515c24b31cd97c7fa1f4481cd68d588419fd45f10ecaee6bc30", size = 6141, upload-time = "2024-09-17T22:39:16.942Z" },
]
[[package]]
name = "pytest-flakefinder"
version = "1.1.0"