mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-24 06:24:18 +02:00
Merge branch 'main' into oauthclient
This commit is contained in:
commit
0c8469ea4c
89 changed files with 8268 additions and 2260 deletions
4
.github/workflows/run-tests.yml
vendored
4
.github/workflows/run-tests.yml
vendored
|
|
@ -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
67
AGENTS.md
Normal 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/` | High‑level client SDK + helpers |
|
||||
| ` └─resources/` | MCP resources and resource templates |
|
||||
| ` └─prompts/` | Prompt templates |
|
||||
| ` └─tools/` | Tool implementations |
|
||||
| `tests/` | Pytest test‑suite |
|
||||
| `docs/` | Mintlify‑flavoured 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.
|
||||
31
README.md
31
README.md
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
152
docs/clients/advanced-features.mdx
Normal file
152
docs/clients/advanced-features.mdx
Normal 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>
|
||||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
```
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
463
docs/servers/openapi.mdx
Normal 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
|
||||
|
|
@ -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—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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
141
examples/tags_example.py
Normal 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())
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
179
src/fastmcp/cli/run.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
38
src/fastmcp/client/progress.py
Normal file
38
src/fastmcp/client/progress.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
77
src/fastmcp/utilities/mcp_config.py
Normal file
77
src/fastmcp/utilities/mcp_config.py
Normal 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
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
209
tests/client/test_openapi.py
Normal file
209
tests/client/test_openapi.py
Normal 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"
|
||||
70
tests/client/test_progress.py
Normal file
70
tests/client/test_progress.py
Normal 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
|
||||
|
|
@ -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
127
tests/client/test_stdio.py
Normal 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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
0
tests/deprecated/__init__.py
Normal file
0
tests/deprecated/__init__.py
Normal 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="-")
|
||||
85
tests/deprecated/test_mount_separators.py
Normal file
85
tests/deprecated/test_mount_separators.py
Normal 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="-")
|
||||
98
tests/deprecated/test_resource_prefixes.py
Normal file
98
tests/deprecated/test_resource_prefixes.py
Normal 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
|
||||
113
tests/deprecated/test_route_type_ignore.py
Normal file
113
tests/deprecated/test_route_type_ignore.py
Normal 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
|
||||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
105
tests/server/http/test_custom_routes.py
Normal file
105
tests/server/http/test_custom_routes.py
Normal 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()"
|
||||
)
|
||||
168
tests/server/http/test_http_dependencies.py
Normal file
168
tests/server/http/test_http_dependencies.py
Normal 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"
|
||||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
457
tests/server/openapi/test_openapi_path_parameters.py
Normal file
457
tests/server/openapi/test_openapi_path_parameters.py
Normal 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,
|
||||
)
|
||||
377
tests/server/openapi/test_route_map_fn.py
Normal file
377
tests/server/openapi/test_route_map_fn.py
Normal 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
|
||||
|
|
@ -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
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
176
tests/server/test_logging.py
Normal file
176
tests/server/test_logging.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
65
tests/server/test_resource_prefix_formats.py
Normal file
65
tests/server/test_resource_prefix_formats.py
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
142
tests/utilities/test_mcp_config.py
Normal file
142
tests/utilities/test_mcp_config.py
Normal 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"
|
||||
|
|
@ -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
19
uv.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue