Merge branch 'main' into McpRegisterable-example

This commit is contained in:
William Easton 2025-04-16 23:30:21 -05:00 committed by GitHub
commit 8a19eb7804
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 2399 additions and 2338 deletions

View file

@ -93,7 +93,7 @@ FastMCP provides a high-level, Pythonic interface for building and interacting w
## Why FastMCP?
The MCP protocol is powerful but implementing it involves a lot of boilerplate - server setup, protocol handlers, content types, error management. FastMCP handles all the complex protocol details and server management, so you can focus on building great tools. Its designed to be high-level and Pythonic; in most cases, decorating a function is all you need.
The MCP protocol is powerful but implementing it involves a lot of boilerplate - server setup, protocol handlers, content types, error management. FastMCP handles all the complex protocol details and server management, so you can focus on building great tools. It's designed to be high-level and Pythonic; in most cases, decorating a function is all you need.
FastMCP aims to be:
@ -552,13 +552,13 @@ proxy_client = Client(
)
# Create a proxy server that connects to the client and exposes its capabilities
proxy = FastMCP.as_proxy(proxy_client, name="Stdio-to-SSE Proxy")
proxy = FastMCP.from_client(proxy_client, name="Stdio-to-SSE Proxy")
if __name__ == "__main__":
proxy.run(transport='sse')
```
`FastMCP.as_proxy` is an `async` classmethod. It connects to the target, discovers its capabilities, and dynamically builds the proxy server instance.
`FastMCP.from_client` is a class method that connects to the target, discovers its capabilities, and dynamically builds the proxy server instance.

View file

@ -5,6 +5,10 @@ description: Learn how to use the FastMCP Client to interact with MCP servers.
icon: user-robot
---
import { VersionBadge } from '/snippets/version-badge.mdx'
<VersionBadge version="2.0.0" />
The `fastmcp.Client` provides a high-level, asynchronous interface for interacting with any Model Context Protocol (MCP) server, whether it's built with FastMCP or another implementation. It simplifies communication by handling protocol details and connection management.
## FastMCP Client
@ -248,5 +252,5 @@ async def safe_call_tool():
Other errors, like connection failures, will raise standard Python exceptions (e.g., `ConnectionError`, `TimeoutError`).
<Tip>
The client transport often has its own error-handling mechanisms, so you can not always trap errors like those raised by `call_tool` outside of the `async with` block. Instead, you can call `call_tools(..., return_raw_result=True)` to get the raw result object and handle errors yourself by checking its `isError` attribute.
The client transport often has its own error-handling mechanisms, so you can not always trap errors like those raised by `call_tool` outside of the `async with` block. Instead, you can call `call_tool(..., _return_raw_result=True)` to get the raw `mcp.types.CallToolResult` object and handle errors yourself by checking its `isError` attribute.
</Tip>

View file

@ -7,7 +7,7 @@ icon: link
The FastMCP `Client` relies on a `ClientTransport` object to handle the specifics of connecting to and communicating with an MCP server. FastMCP provides several built-in transport implementations for common connection methods.
While the `Client` often infers the correct transport automatically (see [Client Overview](/clients/overview#transport-inference)), you can also instantiate transports explicitly for more control.
While the `Client` often infers the correct transport automatically (see [Client Overview](/clients/client#transport-inference)), you can also instantiate transports explicitly for more control.
## Stdio Transports

View file

@ -50,14 +50,14 @@
{
"group": "Clients",
"pages": [
"clients/overview",
"clients/client",
"clients/transports"
]
},
{
"group": "Patterns",
"pages": [
"patterns/proxying",
"patterns/proxy",
"patterns/composition",
"patterns/decorating-methods",
"patterns/openapi",

View file

@ -1,11 +1,17 @@
---
title: Server Composition
sidebarTitle: Composition
description: Combine multiple FastMCP servers into a single, larger application using mounting.
description: Combine multiple FastMCP servers into a single, larger application using mounting and importing.
icon: puzzle-piece
---
import { VersionBadge } from '/snippets/version-badge.mdx'
As your MCP applications grow, you might want to organize your tools, resources, and prompts into logical modules or reuse existing server components. FastMCP supports composition through the `server.mount()` method, allowing you to combine multiple `FastMCP` instances into a single, unified server.
<VersionBadge version="2.2.0" />
As your MCP applications grow, you might want to organize your tools, resources, and prompts into logical modules or reuse existing server components. FastMCP supports composition through two methods:
- **`import_server`**: For a one-time copy of components with prefixing (static composition).
- **`mount`**: For creating a live link where the main server delegates requests to the subserver (dynamic composition).
## Why Compose Servers?
@ -14,13 +20,32 @@ As your MCP applications grow, you might want to organize your tools, resources,
- **Teamwork**: Different teams can work on separate FastMCP servers that are later combined.
- **Organization**: Keep related functionality grouped together logically.
## Mounting Subservers
### Importing vs Mounting
The `mount()` method attaches all components (tools, resources, templates, prompts) from one `FastMCP` instance (the *subserver*) onto another (the *main server*). A `prefix` is added to avoid naming conflicts.
The choice of importing or mounting depends on your use case and requirements. In general, importing is best for simpler cases because it copies the imported server's components into the main server, treating them as native integrations. Mounting is best for more complex cases where you need to delegate requests to the subserver at runtime.
| Feature | Importing | Mounting |
|---------|----------------|---------|
| **Method** | `FastMCP.import_server()` | `FastMCP.mount()` |
| **Composition Type** | One-time copy (static) | Live link (dynamic) |
| **Updates** | Changes to subserver NOT reflected | Changes to subserver immediately reflected |
| **Lifespan** | Not managed | Automatically managed |
| **Synchronicity** | Async (must be awaited) | Sync |
| **Best For** | Bundling finalized components | Modular runtime composition |
### Proxy Servers
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.
## 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.
```python
from fastmcp import FastMCP
from typing import dict, list
import asyncio
# --- Define Subservers ---
@ -53,14 +78,15 @@ def explain_addition() -> str:
# --- Define Main Server ---
main_mcp = FastMCP(name="MainApp")
# --- Mount Subservers ---
# Mount weather service with prefix "weather"
main_mcp.mount("weather", weather_mcp)
# --- Import Subservers ---
async def setup():
# Import weather service with prefix "weather"
await main_mcp.import_server("weather", weather_mcp)
# Mount calculator service with prefix "calc"
main_mcp.mount("calc", calc_mcp)
# Import calculator service with prefix "calc"
await main_mcp.import_server("calc", calc_mcp)
# --- Now, main_mcp contains combined components ---
# --- Now, main_mcp contains *copied* components ---
# Tools:
# - "weather_get_forecast"
# - "calc_add"
@ -70,13 +96,15 @@ main_mcp.mount("calc", calc_mcp)
# - "calc_explain_addition"
if __name__ == "__main__":
# In a real app, you might run this async or setup imports differently
asyncio.run(setup())
# Run the main server, which now includes components from both subservers
main_mcp.run()
```
### How Mounting Works
### How Importing Works
When you call `main_mcp.mount(prefix, subserver)`:
When you call `await main_mcp.import_server(prefix, subserver)`:
1. **Tools**: All tools from `subserver` are added to `main_mcp`. Their names are automatically prefixed using the `prefix` and a default separator (`_`).
- `subserver.tool(name="my_tool")` becomes `main_mcp.tool(name="{prefix}_my_tool")`.
@ -86,14 +114,15 @@ When you call `main_mcp.mount(prefix, subserver)`:
- `subserver.resource(uri="data://{id}")` becomes `main_mcp.resource(uri="{prefix}+data://{id}")`.
4. **Prompts**: All prompts from `subserver` are added, with names prefixed like tools.
- `subserver.prompt(name="my_prompt")` becomes `main_mcp.prompt(name="{prefix}_my_prompt")`.
5. **Lifespan Management**: If the `subserver` has a `lifespan` function defined, it will be automatically executed within the `main_mcp`'s lifespan context. This ensures that setup and teardown logic for the subserver runs correctly.
Note that `import_server` performs a **one-time copy** of components from the `subserver` into the `main_mcp` instance at the time the method is called. Changes made to the `subserver` *after* `import_server` is called **will not** be reflected in `main_mcp`. Also, the `subserver`'s `lifespan` context is **not** executed by the main server when using `import_server`.
### Customizing Separators
You might prefer different separators for the prefixed names and URIs. You can customize these when calling `mount()`:
You might prefer different separators for the prefixed names and URIs. You can customize these when calling `import_server()`:
```python
main_mcp.mount(
await main_mcp.import_server(
prefix="api",
app=some_subserver,
tool_separator="/", # Tool name becomes: "api/sub_tool_name"
@ -106,12 +135,125 @@ main_mcp.mount(
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>
## Example: Modular Application
## Mounting (Live Linking)
The `mount()` method creates a **live link** between the `main_mcp` server and the `subserver`. Instead of copying components, requests for components matching the `prefix` are **delegated** to the `subserver` at runtime.
```python
# modules/text_utils.py
import asyncio
from fastmcp import FastMCP, Client
# --- Define Subserver ---
dynamic_mcp = FastMCP(name="DynamicService")
@dynamic_mcp.tool()
def initial_tool(): return "Initial Tool Exists"
# --- Define Main Server ---
main_mcp = FastMCP(name="MainAppLive")
# --- Mount Subserver (Sync operation) ---
main_mcp.mount("dynamic", dynamic_mcp)
print("Mounted dynamic_mcp.")
# --- Add a tool AFTER mounting ---
@dynamic_mcp.tool()
def added_later(): return "Tool Added Dynamically!"
print("Added 'added_later' tool to dynamic_mcp.")
# --- Test Access ---
async def test_dynamic_mount():
# Need to use await for get_tools now
tools_before = await main_mcp.get_tools()
print("Tools available via main_mcp:", list(tools_before.keys()))
# Expected: ['dynamic_initial_tool', 'dynamic_added_later']
async with Client(main_mcp) as client:
# Call the dynamically added tool via the main server
result = await client.call_tool("dynamic_added_later")
print("Result of calling dynamic_added_later:", result[0].text)
# Expected: Tool Added Dynamically!
if __name__ == "__main__":
# Need async context to test
asyncio.run(test_dynamic_mount())
# To run the server itself:
# main_mcp.run()
```
### How Mounting Works
When you call `main_mcp.mount(prefix, server)`:
1. **Live Link**: A live connection is established between `main_mcp` and the `subserver`.
2. **Dynamic Updates**: Changes made to the `subserver` (e.g., adding new tools) **will be reflected** immediately when accessing components through `main_mcp`.
3. **Lifespan Management**: The `subserver`'s `lifespan` context **is automatically managed** and executed within the `main_mcp`'s lifespan.
4. **Delegation**: Requests for components matching the prefix are delegated to the subserver at runtime.
The same prefixing rules apply as with `import_server` for naming tools, resources, templates, and prompts.
### Customizing Separators
Similar to `import_server`, you can customize the separators for the prefixed names and URIs:
```python
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"
)
```
## Example: Modular Application
Here's how a modular application might use `import_server`:
<CodeGroup>
```python main.py
from fastmcp import FastMCP
import asyncio
# Import the servers (see other files)
from modules.text_server import text_mcp
from modules.data_server import data_mcp
app = FastMCP(name="MainApplication")
# Setup function for async imports
async def setup():
# Import the utility servers
await app.import_server("text", text_mcp)
await app.import_server("data", data_mcp)
@app.tool()
def process_and_analyze(record_id: int) -> str:
"""Fetches a record and analyzes its string representation."""
# In a real application, you'd use proper methods to interact between
# imported tools rather than accessing internal managers
# Get record data
record = {"id": record_id, "value": random.random()}
# Count words in the record string representation
word_count = len(str(record).split())
return (
f"Record {record_id} has {word_count} words in its string "
f"representation."
)
if __name__ == "__main__":
# Run async setup before starting the server
asyncio.run(setup())
# Run the server
app.run()
```
```python modules/text_server.py
from fastmcp import FastMCP
from typing import list
text_mcp = FastMCP(name="TextUtilities")
@ -124,9 +266,9 @@ def count_words(text: str) -> int:
def get_stopwords() -> list[str]:
"""Return a list of common stopwords."""
return ["the", "a", "is", "in"]
```
# ------------------------------
# modules/data_api.py
```python modules/data_server.py
from fastmcp import FastMCP
import random
from typing import dict
@ -142,41 +284,10 @@ def fetch_record(record_id: int) -> dict:
def get_table_schema(table: str) -> dict:
"""Provides a dummy schema for a table."""
return {"table": table, "columns": ["id", "value"]}
# ------------------------------
# main_app.py
from fastmcp import FastMCP
from modules.text_utils import text_mcp # Import server instances
from modules.data_api import data_mcp
app = FastMCP(name="MainApplication")
# Mount the utility servers
app.mount("text", text_mcp)
app.mount("data", data_mcp)
@app.tool()
def process_and_analyze(record_id: int) -> str:
"""Fetches a record and analyzes its string representation."""
# In a real application, you'd use proper methods to interact between
# mounted tools rather than accessing internal managers
# Get record data
record = {"id": record_id, "value": random.random()}
# Count words in the record string representation
word_count = len(str(record).split())
return (
f"Record {record_id} has {word_count} words in its string "
f"representation."
)
if __name__ == "__main__":
app.run()
```
Now, running `main_app.py` starts a server that exposes:
</CodeGroup>
Now, running `main.py` starts a server that exposes:
- `text_count_words`
- `data_fetch_record`
- `process_and_analyze`

View file

@ -1,114 +1,123 @@
---
title: FastAPI Integration
sidebarTitle: FastAPI
description: Automatically create FastMCP servers directly from FastAPI applications.
description: Generate MCP servers from FastAPI apps
icon: square-bolt
---
import { VersionBadge } from '/snippets/version-badge.mdx'
If you build your APIs using the popular [FastAPI](https://fastapi.tiangolo.com/) framework, FastMCP offers a seamless way to expose your FastAPI application as an MCP server. This leverages the OpenAPI integration internally but simplifies the setup significantly.
<VersionBadge version="2.0.0" />
## The Goal: FastAPI App -> MCP Server
FastAPI automatically generates an OpenAPI specification for your application. FastMCP uses this built-in capability to create an MCP server that mirrors your API routes.
FastMCP can automatically convert FastAPI applications into MCP servers.
- FastAPI path operations (`@app.get`, `@app.post`, etc.) become MCP tools, resources, or templates.
- Pydantic models used in FastAPI for request/response validation are used to generate MCP schemas.
- Communication happens directly in memory, making it very efficient.
<Tip>
FastMCP does *not* include FastAPI as a dependency; you must install it separately to run these examples.
</Tip>
## Creating from FastAPI App
Use the `FastMCP.from_fastapi()` class method. You only need your FastAPI `app` instance.
```python {2, 22, 25}
from fastapi import FastAPI
from fastmcp import FastMCP
# A FastAPI app
app = FastAPI()
@app.get("/items")
def list_items():
return [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}]
@app.get("/items/{item_id}")
def get_item(item_id: int):
return {"id": item_id, "name": f"Item {item_id}"}
@app.post("/items")
def create_item(name: str):
return {"id": 3, "name": name}
# Create an MCP server from your FastAPI app
mcp = FastMCP.from_fastapi(app=app)
if __name__ == "__main__":
mcp.run() # Start the MCP server
```
## 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
import asyncio
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from fastmcp import FastMCP, Client # Import FastMCP and Client
# 1. Define your FastAPI application
api_app = FastAPI(title="MyFastAPIApp")
from fastmcp import FastMCP, Client
# Define your Pydantic model
class Item(BaseModel):
name: str
price: float
is_offer: bool | None = None
@api_app.get("/")
def read_root():
return {"Hello": "World"}
# Create your FastAPI app
app = FastAPI()
items = {} # In-memory database
@api_app.get("/items/{item_id}") # -> Resource Template
def read_item(item_id: int, q: str | None = None):
# This will become resource://openapi/read_item_items__item_id__get/{item_id}
return {"item_id": item_id, "q": q, "description": f"Details for item {item_id}"}
@app.get("/items")
def list_items():
"""List all items"""
return list(items.values())
@api_app.post("/items/") # -> Tool
@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):
# This will become the 'create_item_items__post' tool
print(f"Creating item: {item.name}")
return {"item_name": item.name, "status": "created"}
"""Create a new item"""
item_id = len(items) + 1
items[item_id] = {"id": item_id, **item.model_dump()}
return items[item_id]
# 2. Create the FastMCP server directly from the FastAPI app
# This is an async class method
async def create_mcp_server_from_fastapi():
mcp_server = await FastMCP.from_fastapi(
app=api_app,
name="FastAPI_MCP_Bridge" # Optional name for the MCP server
)
return mcp_server
# 3. (Example) Run the MCP server and test with an in-memory client
async def run_and_test():
server = await create_mcp_server_from_fastapi()
print(f"Created MCP server '{server.name}' from FastAPI app '{api_app.title}'")
# List discovered components
tools = await server.list_tools()
templates = await server.list_resource_templates()
print("Discovered Tools:", [t.name for t in tools])
print("Discovered Templates:", [t.uriTemplate for t in templates])
# Test using an in-memory client
client = Client(server) # Uses FastMCPTransport
async with client:
# Call the tool derived from POST /items/
create_result = await client.call_tool(
"create_item_items__post",
{"name": "MCP Special", "price": 99.99} # Pydantic model fields become args
)
print("Create Item Tool Result:", create_result[0].text) # JSON string
# Read the resource derived from GET /items/{item_id}
read_result = await client.read_resource(
"resource://openapi/read_item_items__item_id__get/42" # Match template URI
)
print("Read Item Resource Result:", read_result[0].text) # JSON string
# In a real scenario, you might run the MCP server via stdio or sse
# print("Running MCP server via stdio...")
# server.run()
# Test your MCP server with a client
async def test():
# Create MCP server from FastAPI app
mcp = await FastMCP.from_fastapi(app=app)
# List the components that were created
tools = await mcp.list_tools()
resources = await mcp.list_resources()
templates = await mcp.list_resource_templates()
print(f"Generated {len(tools)} tools")
print(f"Generated {len(resources)} resources")
print(f"Generated {len(templates)} templates")
# In a real scenario, you would run the server:
# mcp.run()
if __name__ == "__main__":
# Requires fastapi, uvicorn, httpx:
# uv pip install "fastapi[all]" httpx
try:
asyncio.run(run_and_test())
except ImportError as e:
print(f"Error: {e}. Please install required packages: uv pip install \"fastapi[all]\" httpx")
# Example Output might include:
# Created MCP server 'FastAPI_MCP_Bridge' from FastAPI app 'MyFastAPIApp'
# Discovered Tools: ['read_root___get', 'create_item_items__post']
# Discovered Templates: ['resource://openapi/read_item_items__item_id__get/{item_id}']
# Create Item Tool Result: {"item_name": "MCP Special", "status": "created"}
# Read Item Resource Result: {"item_id": 42, "q": null, "description": "Details for item 42"}
asyncio.run(test())
```
### How it Works Internally
## Benefits
1. **OpenAPI Generation**: `from_fastapi` asks the FastAPI `app` for its OpenAPI schema dictionary (`app.openapi()`).
2. **In-Memory Client**: It creates an `httpx.AsyncClient` configured with an `ASGITransport`. This special transport allows `httpx` to call the FastAPI application directly in memory without needing a running web server process.
3. **OpenAPI Integration**: It calls `FastMCP.from_openapi()`, passing the generated schema and the in-memory `httpx` client.
4. **MCP Server Creation**: The standard OpenAPI integration logic then proceeds to parse the schema and create the `Tool`, `Resource`, and `ResourceTemplate` components that wrap calls to the in-memory FastAPI app.
This provides a highly efficient way to expose your FastAPI logic through the MCP protocol, leveraging FastAPI's routing, dependency injection, and validation features.
- **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

View file

@ -1,174 +1,156 @@
---
title: OpenAPI Integration
sidebarTitle: OpenAPI
description: Automatically create FastMCP servers from existing OpenAPI specifications.
description: Generate MCP servers from OpenAPI specs
icon: code-branch
---
import { VersionBadge } from '/snippets/version-badge.mdx'
If you have existing REST APIs documented with the OpenAPI Specification (OAS), FastMCP can automatically generate MCP tools, resources, and resource templates directly from that specification. This provides a quick way to make your existing HTTP APIs accessible to MCP clients and LLMs.
<VersionBadge version="2.0.0" />
FastMCP supports both OpenAPI 3.0 and 3.1 specifications for maximum compatibility with existing API definitions.
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.
## The Goal: API -> MCP Server
```python
import httpx
from fastmcp import FastMCP
The core idea is to map OpenAPI paths and operations (like `GET /users/{id}` or `POST /orders`) to their corresponding MCP components:
# Create a client for your API
api_client = httpx.AsyncClient(base_url="https://api.example.com")
- `GET` requests often map to MCP **Resources** (for fetching single items) or **Resource Templates** (if the path has parameters).
- `POST`, `PUT`, `PATCH`, `DELETE` requests typically map to MCP **Tools** (for actions that create or modify data).
# Load your OpenAPI spec
spec = {...}
FastMCP automates this mapping process.
# Create an MCP server from your OpenAPI spec
mcp = FastMCP.from_openapi(openapi_spec=spec, client=api_client)
## Creating from OpenAPI Spec
if __name__ == "__main__":
mcp.run()
```
Use the `FastMCP.from_openapi()` class method. You need:
## Route Mapping
1. The OpenAPI specification as a Python dictionary.
2. An `httpx.AsyncClient` configured to make requests to the actual API backend.
By default, OpenAPI routes are mapped to MCP components based on these rules:
<CodeGroup>
| 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 |
```python server.py
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
## Complete Example
```python
import asyncio
import httpx
from fastmcp import FastMCP
# load the OpenAPI specification from the openapi_spec.py file
petstore_spec = PETSTORE_SPEC
# Client to communicate with the actual Pet Store API backend
# The base_url should match the server URL in the OpenAPI spec
http_client = httpx.AsyncClient(base_url="http://petstore.example.com/api")
# Create the FastMCP server from the spec
# This is an async class method
async def create_openapi_server():
mcp_server = await FastMCP.from_openapi(
openapi_spec=petstore_spec,
client=http_client,
name="PetStoreMCP" # Optional name for the MCP server
)
return mcp_server
async def run_server():
server = await create_openapi_server()
print(f"Starting OpenAPI-based server '{server.name}'...")
# List discovered components
tools = await server.list_tools()
resources = await server.list_resources()
templates = await server.list_resource_templates()
print("Discovered Tools:", [t.name for t in tools])
print("Discovered Resources:", [r.uri for r in resources]) # Should be empty if no parameterless GETs
print("Discovered Templates:", [t.uriTemplate for t in templates])
# Run the server (e.g., via stdio)
# server.run()
if __name__ == "__main__":
# Example: Create the server and print discovered components
# Requires httpx: uv pip install httpx
asyncio.run(run_server())
# Expected Output might include:
# Discovered Tools: ['listPets', 'createPet']
# Discovered Resources: []
# Discovered Templates: ['resource://openapi/showPetById/{petId}']
```
```python openapi_spec.py
# Example OpenAPI Specification (simplified Pet Store)
PETSTORE_SPEC = {
"openapi": "3.1.0",
"info": {"title": "Simple Pet Store", "version": "1.0.0"},
"servers": [{"url": "http://petstore.example.com/api"}], # Base URL for API calls
# Sample OpenAPI spec for a Pet Store API
petstore_spec = {
"openapi": "3.0.0",
"paths": {
"/pets": {
"get": {
"summary": "List all pets",
"operationId": "listPets",
"tags": ["pets"],
"parameters": [{ # Query parameter -> Tool argument
"name": "limit", "in": "query", "schema": {"type": "integer"}
}],
"responses": {"200": {"description": "A list of pets."}},
"summary": "List all pets"
},
"post": { # POST -> Tool
"summary": "Create a pet",
"post": {
"operationId": "createPet",
"tags": ["pets"],
"requestBody": { # Request body -> Tool arguments
"required": True,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PetInput"}}}
},
"responses": {"201": {"description": "Pet created."}},
},
"summary": "Create a new pet"
}
},
"/pets/{petId}": { # Path parameter -> Resource Template
"get": { # GET with path param -> Resource Template / FunctionResource
"summary": "Info for a specific pet",
"operationId": "showPetById",
"tags": ["pets"],
"parameters": [{ # Path parameter -> Template function argument
"name": "petId", "in": "path", "required": True, "schema": {"type": "string"}
}],
"responses": {"200": {"description": "Information about the pet."}},
},
},
},
"components": {
"schemas": {
"PetInput": {"type": "object", "properties": {"name": {"type": "string"}, "tag": {"type": "string"}}},
"/pets/{petId}": {
"get": {
"operationId": "getPet",
"summary": "Get a pet by ID",
"parameters": [
{
"name": "petId",
"in": "path",
"required": True,
"schema": {"type": "string"}
}
]
}
}
}
}
```
</CodeGroup>
### How it Works Internally
1. **Parsing**: `from_openapi` parses the spec using utilities that leverage `openapi-pydantic`. It extracts paths, operations, parameters, request bodies, and responses.
2. **Mapping**: It applies mapping rules (see below) to decide whether each OpenAPI operation (`GET /pets`, `POST /pets`, `GET /pets/{petId}`) becomes an MCP `Tool`, `Resource`, or `ResourceTemplate`.
3. **Component Creation**: It creates specialized internal components (`OpenAPITool`, `OpenAPIResource`, `OpenAPIResourceTemplate`).
4. **HTTP Execution**: When an MCP client calls a tool or reads a resource from this server:
* The corresponding OpenAPI component constructs an HTTP request based on the OpenAPI definition and the arguments provided by the MCP client.
* It uses the provided `httpx.AsyncClient` to send the request to the backend API.
* It processes the HTTP response and returns it to the MCP client in the appropriate MCP format.
5. **Schema Generation**: The schemas for MCP tools are derived by combining OpenAPI parameters (path, query, header) and request body schemas. Resource template function arguments are derived from path parameters.
6. **Descriptions**: Tool/Resource descriptions are enhanced with information from OpenAPI responses to give the LLM more context about potential outcomes.
### Default Mapping Rules
FastMCP uses the following default rules to map OpenAPI operations:
- `GET` operation with path parameters (e.g., `/users/{id}`) -> **`ResourceTemplate`**
- `GET` operation without path parameters (e.g., `/users`) -> **`Resource`**
- `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `HEAD` -> **`Tool`**
### Customize Route Mapping
You can customize the mapping rules by providing a list of `RouteMap` objects directly to `FastMCP.from_openapi()` using the `route_maps` parameter:
```python
from fastmcp.server.openapi import RouteMap, RouteType
from fastmcp import FastMCP
# Custom mapping: Treat GET /admin/stats as a Tool, not a Resource
custom_maps = [
RouteMap(methods=["GET"], pattern=r"^/admin/stats$", route_type=RouteType.TOOL)
]
async def create_server_with_custom_mapping():
mcp_server = await FastMCP.from_openapi(
async def main():
# Client for the Pet Store API
client = httpx.AsyncClient(base_url="https://petstore.example.com/api")
# Create the MCP server
mcp = await FastMCP.from_openapi(
openapi_spec=petstore_spec,
client=http_client,
name="PetStoreMCP",
route_maps=custom_maps # Pass custom mapping rules
client=client,
name="PetStore"
)
return mcp_server
# List what components were created
tools = await mcp.list_tools()
resources = await mcp.list_resources()
templates = await mcp.list_resource_templates()
print(f"Tools: {len(tools)}") # Should include createPet
print(f"Resources: {len(resources)}") # Should include listPets
print(f"Templates: {len(templates)}") # Should include getPet
# Start the MCP server
mcp.run()
if __name__ == "__main__":
asyncio.run(main())
```
Each `RouteMap` maps one or more HTTP methods and a regular expression pattern for the route path to an MCP `RouteType`. Route maps are processed in order, and the first match wins.
All parameters passed to `FastMCP.from_openapi()` will be forwarded to the underlying `FastMCPOpenAPI` constructor, so you can customize any aspect of the OpenAPI integration directly through this method call.

View file

@ -4,8 +4,11 @@ sidebarTitle: Proxying
description: Use FastMCP to act as an intermediary or change transport for other MCP servers.
icon: arrows-retweet
---
import { VersionBadge } from '/snippets/version-badge.mdx'
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.
<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.
## What is Proxying?
@ -32,7 +35,7 @@ sequenceDiagram
## Creating a Proxy
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.
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.
```python
from fastmcp import FastMCP, Client
@ -41,8 +44,8 @@ from fastmcp import FastMCP, Client
# 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 as_proxy()
proxy_server = await FastMCP.as_proxy(
# Create the proxy server with from_client()
proxy_server = FastMCP.from_client(
backend_client,
name="MyProxyServer" # Optional settings for the proxy
)
@ -51,13 +54,17 @@ proxy_server = await FastMCP.as_proxy(
# with any transport (SSE, stdio, etc.) just like any other FastMCP server
```
**How `as_proxy` Works:**
**How `from_client` 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.
3. It creates corresponding "proxy" components that forward requests to the backend.
4. It returns a standard `FastMCP` server instance that can be used like any other.
<Note>
Currently, proxying focuses primarily on exposing the major MCP objects (tools, resources, templates, and prompts). Some advanced MCP features like notifications and sampling are not fully supported in proxies in the current version. Support for these additional features may be added in future releases.
</Note>
### Bridging Transports
A common use case is to bridge transports. For example, making a remote SSE server available locally via Stdio:
@ -69,7 +76,7 @@ from fastmcp import FastMCP, Client
client = Client("http://example.com/mcp/sse")
# Create a proxy server - it's just a regular FastMCP server
proxy = await FastMCP.as_proxy(client, name="SSE to Stdio Proxy")
proxy = FastMCP.from_client(client, name="SSE to Stdio Proxy")
# The proxy can now be used with any transport
# No special handling needed - it works like any FastMCP server
@ -90,7 +97,7 @@ def tool_a() -> str:
return "A"
# Create a proxy of the original server
proxy = await FastMCP.as_proxy(
proxy = FastMCP.from_client(
original_server,
name="Proxy Server"
)
@ -101,9 +108,6 @@ proxy = await FastMCP.as_proxy(
## `FastMCPProxy` Class
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. It has two primary async constructors:
* `FastMCPProxy.from_client(client: Client, **settings)`: Creates a proxy from a client instance.
* `FastMCPProxy.from_server(server: FastMCP, **settings)`: Creates a proxy from another FastMCP server instance.
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.
Using the class directly might be necessary for advanced scenarios, like subclassing `FastMCPProxy` to add custom logic before or after forwarding requests.

View file

@ -4,6 +4,7 @@ sidebarTitle: Context
description: Access MCP capabilities like logging, progress, and resources within your tools.
icon: rectangle-code
---
import { VersionBadge } from '/snippets/version-badge.mdx'
When defining FastMCP [tools](/servers/tools), your functions might need to interact with the underlying MCP session or access server capabilities. FastMCP provides the `Context` object for this purpose.
@ -174,6 +175,8 @@ The returned content is typically accessed via `content_list[0].content` and can
### LLM Sampling
<VersionBadge version="2.0.0" />
Request the client's LLM to generate text based on provided messages. This is useful when your tool needs to leverage the LLM's capabilities to process data or generate responses.
```python
@ -227,7 +230,7 @@ async def generate_example(concept: str, ctx: Context) -> str:
return f"```python\n{code_example}\n```"
```
See [Client Sampling](/clients/overview#llm-sampling) for more details on how clients handle these requests.
See [Client Sampling](/clients/client#llm-sampling) for more details on how clients handle these requests.
### Request Information

View file

@ -223,84 +223,40 @@ fastmcp run my_server.py:mcp --transport sse --log-level DEBUG
The CLI can dynamically find and run FastMCP server objects in your files, but including the `if __name__ == "__main__":` block ensures compatibility with all clients.
## Composing Servers
## Mounting Subservers
FastMCP supports composing multiple servers together using `import_server` (static copy) and `mount` (live link). This allows you to organize large applications into modular components or reuse existing servers.
FastMCP allows you to compose complex applications by mounting other FastMCP servers as subservers. This is useful for:
- Organizing large applications into logical components
- Reusing existing FastMCP servers as parts of a larger system
- Creating domain-specific servers that can be used independently or composed
See the [Server Composition](/patterns/composition) guide for full details, best practices, and examples.
```python
# Example: Importing a subserver
from fastmcp import FastMCP
import asyncio
# Create the main server
main_mcp = FastMCP(name="MainServer")
main = FastMCP(name="Main")
sub = FastMCP(name="Sub")
# Create a domain-specific subserver
weather_mcp = FastMCP(name="WeatherService")
@sub.tool()
def hello():
return "hi"
@weather_mcp.tool()
def get_forecast(city: str) -> dict:
"""Get the weather forecast for a city."""
return {"city": city, "forecast": "Sunny", "temperature": 72}
# Create another domain-specific subserver
calculator_mcp = FastMCP(name="CalculatorService")
@calculator_mcp.tool()
def add(a: float, b: float) -> float:
"""Add two numbers."""
return a + b
# Mount the subservers with prefixes
main_mcp.mount("weather", weather_mcp)
main_mcp.mount("calc", calculator_mcp)
# Now main_mcp has access to both subservers' tools:
# - "weather_get_forecast" (from weather_mcp)
# - "calc_add" (from calculator_mcp)
if __name__ == "__main__":
main_mcp.run()
main.mount("sub", sub)
```
### How Mounting Works
## Proxying Servers
When you mount a server with `main_mcp.mount(prefix, subserver)`:
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.
1. All tools from the subserver are imported with prefixed names:
- `tool_name` becomes `{prefix}_tool_name`
- Default separator is `_`, but can be customized
2. All resources and resource templates are imported with prefixed URIs:
- `resource://data` becomes `{prefix}+resource://data`
- Default separator is `+`, but can be customized
3. All prompts are imported with prefixed names:
- `prompt_name` becomes `{prefix}_prompt_name`
- Default separator is `_`, but can be customized
4. The subserver's lifespan is managed automatically when the main server starts and stops
### Customizing Separators
You can customize the separators used for naming:
See the [Proxying Servers](/patterns/proxy) guide for details and advanced usage.
```python
main_mcp.mount(
"weather",
weather_mcp,
tool_separator="-", # Use "weather-get_forecast" instead of "weather_get_forecast"
resource_separator=".", # Use "weather.resource://data" instead of "weather+resource://data"
prompt_separator=":" # Use "weather:prompt_name" instead of "weather_prompt_name"
)
```
from fastmcp import FastMCP, Client
<Warning>
Some MCP clients may reject certain separators as invalid. For example, Claude Desktop does not support `/` in tool names.
</Warning>
backend = Client("http://example.com/mcp/sse")
proxy = FastMCP.from_client(backend, name="ProxyServer")
# Now use the proxy like any FastMCP server
```
## Server Configuration

View file

@ -0,0 +1,8 @@
export const VersionBadge = ({ version }) => {
return (
<span className="version-badge">
<span className="badge-emoji" aria-hidden="true" style={{ marginRight: '0.3em', verticalAlign: 'middle' }}>✨</span>
New in version {version}
</span>
);
};

View file

@ -1,4 +1,4 @@
/* Target only inline code elements, not code blocks */
/* Code highlighting -- target only inline code elements, not code blocks */
p code:not(pre code),
table code:not(pre code),
li code:not(pre code),
@ -9,5 +9,45 @@ h4 code:not(pre code),
h5 code:not(pre code),
h6 code:not(pre code) {
color: #f72585 !important;
background-color: #ea54551a !important;
background-color: rgba(247, 37, 133, 0.09);
}
/* Version badge -- display a badge with the current version of the documentation */
.version-badge {
display: inline-flex;
align-items: center;
gap: 0.3em;
padding: 0.32em 1em;
font-size: 0.92em;
font-weight: 600;
letter-spacing: 0.01em;
color: #7417e5;
background: #f3e8ff;
border: 1.5px solid #c084fc;
border-radius: 6px;
box-shadow: none;
vertical-align: middle;
position: relative;
transition: box-shadow 0.2s, transform 0.15s;
}
.version-badge:hover {
box-shadow: 0 2px 8px 0 rgba(160, 132, 252, 0.1);
transform: translateY(-1px) scale(1.03);
}
.dark .version-badge {
color: #fff;
background: #312e81;
border: 1.5px solid #a78bfa;
}
.badge-emoji {
font-size: 1.15em;
line-height: 1;
text-shadow: 0 1px 2px #fff, 0 0px 2px #c084fc;
}
.dark .badge-emoji {
text-shadow: 0 1px 2px #312e81, 0 0px 2px #a78bfa;
}

View file

@ -5,12 +5,13 @@ description = "The fast, Pythonic way to build MCP servers."
authors = [{ name = "Jeremiah Lowin" }]
dependencies = [
"dotenv>=0.9.9",
"exceptiongroup>=1.2.2",
"httpx>=0.28.1",
"mcp>=1.6.0,<2.0.0",
"openapi-pydantic>=0.5.1",
"rich>=13.9.4",
"typer>=0.15.2",
"websockets>=15.0.1",
"fastapi>=0.115.12",
"openapi-pydantic>=0.5.1",
]
requires-python = ">=3.10"
readme = "README.md"
@ -37,6 +38,11 @@ classifiers = [
[dependency-groups]
dev = [
"copychat>=0.5.2",
"dirty-equals>=0.9.0",
"fastapi>=0.115.12",
"ipython>=8.12.3",
"pdbpp>=0.10.3",
"pre-commit",
"pyright>=1.1.389",
"pytest>=8.3.3",
@ -44,10 +50,6 @@ dev = [
"pytest-flakefinder",
"pytest-xdist>=3.6.1",
"ruff",
"copychat>=0.5.2",
"ipython>=8.12.3",
"pdbpp>=0.10.3",
"dirty-equals>=0.9.0",
]
[project.scripts]

View file

@ -17,6 +17,7 @@ from fastmcp.client.roots import (
create_roots_callback,
)
from fastmcp.client.sampling import SamplingHandler, create_sampling_callback
from fastmcp.exceptions import ClientError
from fastmcp.server import FastMCP
from .transports import ClientTransport, SessionKwargs, infer_transport
@ -24,10 +25,6 @@ from .transports import ClientTransport, SessionKwargs, infer_transport
__all__ = ["Client", "RootsHandler", "RootsList"]
class ClientError(ValueError):
"""Base class for errors raised by the client."""
class Client:
"""
MCP client that delegates connection management to a Transport instance.
@ -48,7 +45,7 @@ class Client:
):
self.transport = infer_transport(transport)
self._session: ClientSession | None = None
self._session_cm: AbstractAsyncContextManager[ClientSession] | None = None
self._session_cms: list[AbstractAsyncContextManager[ClientSession]] = []
self._session_kwargs: SessionKwargs = {
"sampling_callback": None,
@ -89,24 +86,28 @@ class Client:
async def __aenter__(self):
if self.is_connected():
raise RuntimeError("Client is already connected in an async context.")
# We're already connected, no need to add None to the session_cms list
return self
try:
self._session_cm = self.transport.connect_session(**self._session_kwargs)
self._session = await self._session_cm.__aenter__()
session_cm = self.transport.connect_session(**self._session_kwargs)
self._session_cms.append(session_cm)
self._session = await self._session_cms[-1].__aenter__()
return self
except Exception as e:
# Ensure cleanup if __aenter__ fails partially
self._session = None
self._session_cm = None
if self._session_cms:
self._session_cms.pop()
raise ConnectionError(
f"Failed to connect using {self.transport}: {e}"
) from e
async def __aexit__(self, exc_type, exc_val, exc_tb):
if self._session_cm:
await self._session_cm.__aexit__(exc_type, exc_val, exc_tb)
self._session = None
self._session_cm = None
if self._session_cms:
await self._session_cms[-1].__aexit__(exc_type, exc_val, exc_tb)
self._session = None
self._session_cms.pop()
# --- MCP Client Methods ---
async def ping(self) -> None:
@ -168,10 +169,10 @@ class Client:
async def get_prompt(
self, name: str, arguments: dict[str, str] | None = None
) -> mcp.types.GetPromptResult:
) -> list[mcp.types.PromptMessage]:
"""Send a prompts/get request."""
result = await self.session.get_prompt(name, arguments)
return result
return result.messages
async def complete(
self,

View file

@ -2,13 +2,15 @@ import abc
import contextlib
import datetime
import os
import shutil
from collections.abc import AsyncIterator
from pathlib import Path
from typing import (
TypedDict,
)
from mcp import ClientSession, StdioServerParameters
from exceptiongroup import BaseExceptionGroup, catch
from mcp import ClientSession, McpError, StdioServerParameters
from mcp.client.session import (
ListRootsFnT,
LoggingFnT,
@ -22,6 +24,7 @@ from mcp.shared.memory import create_connected_server_and_client_session
from pydantic import AnyUrl
from typing_extensions import Unpack
from fastmcp.exceptions import ClientError
from fastmcp.server import FastMCP as FastMCPServer
@ -341,6 +344,10 @@ class NpxStdioTransport(StdioTransport):
env_vars: Additional environment variables
use_package_lock: Whether to use package-lock.json (--prefer-offline)
"""
# verify npx is installed
if shutil.which("npx") is None:
raise ValueError("Command 'npx' not found")
# Basic validation
if project_directory and not Path(project_directory).exists():
raise NotADirectoryError(
@ -382,12 +389,26 @@ class FastMCPTransport(ClientTransport):
async def connect_session(
self, **session_kwargs: Unpack[SessionKwargs]
) -> 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,
**session_kwargs,
) as session:
yield session
def exception_handler(excgroup: BaseExceptionGroup):
for exc in excgroup.exceptions:
if isinstance(exc, BaseExceptionGroup):
exception_handler(exc)
raise exc
def mcperror_handler(excgroup: BaseExceptionGroup):
for exc in excgroup.exceptions:
if isinstance(exc, BaseExceptionGroup):
mcperror_handler(exc)
raise ClientError(exc)
# backport of 3.11's except* syntax
with catch({McpError: mcperror_handler, Exception: exception_handler}):
# create_connected_server_and_client_session manages the session lifecycle itself
async with create_connected_server_and_client_session(
server=self._fastmcp._mcp_server,
**session_kwargs,
) as session:
yield session
def __repr__(self) -> str:
return f"<FastMCP(server='{self._fastmcp.name}')>"

View file

@ -23,3 +23,11 @@ class PromptError(FastMCPError):
class InvalidSignature(Exception):
"""Invalid signature for use with FastMCP."""
class ClientError(Exception):
"""Error in client operations."""
class NotFoundError(Exception):
"""Object not found."""

View file

@ -115,7 +115,7 @@ class Prompt(BaseModel):
return cls(
name=func_name,
description=description or fn.__doc__ or "",
description=description or fn.__doc__,
arguments=arguments,
fn=fn,
tags=tags or set(),

View file

@ -3,8 +3,8 @@
from collections.abc import Awaitable, Callable
from typing import Any
from fastmcp.exceptions import PromptError
from fastmcp.prompts.prompt import MCPPrompt, Message, Prompt, PromptResult
from fastmcp.exceptions import NotFoundError
from fastmcp.prompts.prompt import Message, Prompt, PromptResult
from fastmcp.settings import DuplicateBehavior
from fastmcp.utilities.logging import get_logger
@ -37,16 +37,6 @@ class PromptManager:
"""Get all registered prompts, indexed by registered key."""
return self._prompts
def list_prompts(self) -> list[Prompt]:
"""List all registered prompts."""
return list(self.get_prompts().values())
def list_mcp_prompts(self) -> list[MCPPrompt]:
"""List all registered prompts in the format expected by the low-level MCP server."""
return [
prompt.to_mcp_prompt(name=key) for key, prompt in self.get_prompts().items()
]
def add_prompt_from_fn(
self,
fn: Callable[..., PromptResult | Awaitable[PromptResult]],
@ -84,28 +74,10 @@ class PromptManager:
"""Render a prompt by name with arguments."""
prompt = self.get_prompt(name)
if not prompt:
raise PromptError(f"Unknown prompt: {name}")
raise NotFoundError(f"Unknown prompt: {name}")
return await prompt.render(arguments)
def import_prompts(
self, manager: "PromptManager", prefix: str | None = None
) -> None:
"""
Import all prompts from another PromptManager with prefixed names.
Args:
manager: Another PromptManager instance to import prompts from
prefix: Prefix to add to prompt names. The resulting prompt key will
be in the format "{prefix}{original_name}" if prefix is provided,
otherwise the original name is used.
For example, with prefix "weather/" and prompt "forecast_prompt",
the imported prompt would be available as "weather/forecast_prompt"
"""
for name, prompt in manager._prompts.items():
# Create prefixed key
key = f"{prefix}{name}" if prefix else name
# Store the prompt with the prefixed key
self.add_prompt(prompt, key=key)
logger.debug(f'Imported prompt "{prompt.name}" as "{key}"')
def has_prompt(self, key: str) -> bool:
"""Check if a prompt exists."""
return key in self._prompts

View file

@ -6,11 +6,10 @@ from typing import Any
from pydantic import AnyUrl
from fastmcp.exceptions import ResourceError
from fastmcp.exceptions import NotFoundError
from fastmcp.resources import FunctionResource
from fastmcp.resources.resource import MCPResource, Resource
from fastmcp.resources.resource import Resource
from fastmcp.resources.template import (
MCPResourceTemplate,
ResourceTemplate,
match_uri_template,
)
@ -203,11 +202,21 @@ class ResourceManager:
self._templates[storage_key] = template
return template
def has_resource(self, uri: AnyUrl | str) -> bool:
"""Check if a resource exists."""
uri_str = str(uri)
if uri_str in self._resources:
return True
for template_key in self._templates.keys():
if match_uri_template(uri_str, template_key):
return True
return False
async def get_resource(self, uri: AnyUrl | str) -> Resource:
"""Get resource by URI, checking concrete resources first, then templates.
Raises:
ResourceError: If no resource or template matching the URI is found.
NotFoundError: If no resource or template matching the URI is found.
"""
uri_str = str(uri)
logger.debug("Getting resource", extra={"uri": uri_str})
@ -225,85 +234,12 @@ class ResourceManager:
except Exception as e:
raise ValueError(f"Error creating resource from template: {e}")
raise ResourceError(f"Unknown resource: {uri_str}")
raise NotFoundError(f"Unknown resource: {uri_str}")
def get_resources(self) -> dict[str, Resource]:
"""Get all registered resources, keyed by URI."""
return self._resources
def list_resources(self) -> list[Resource]:
"""List all registered resources."""
logger.debug("Listing resources", extra={"count": len(self._resources)})
return list(self._resources.values())
def list_mcp_resources(self) -> list[MCPResource]:
"""List all registered resources in the format expected by the low-level MCP server."""
return [
resource.to_mcp_resource(uri=key)
for key, resource in self._resources.items()
]
def list_mcp_resource_templates(self) -> list[MCPResourceTemplate]:
"""List all registered resource templates in the format expected by the low-level MCP server."""
return [
template.to_mcp_template(uriTemplate=key)
for key, template in self._templates.items()
]
def get_templates(self) -> dict[str, ResourceTemplate]:
"""Get all registered templates, keyed by URI template."""
return self._templates
def list_templates(self) -> list[ResourceTemplate]:
"""List all registered templates."""
logger.debug("Listing templates", extra={"count": len(self._templates)})
return list(self._templates.values())
def import_resources(
self, manager: "ResourceManager", prefix: str | None = None
) -> None:
"""Import resources from another resource manager.
Resources are imported with a prefixed URI if a prefix is provided. For example,
if a resource has URI "data://users" and you import it with prefix "app+", the
imported resource will have URI "app+data://users". If no prefix is provided,
the original URI is used.
Args:
manager: The ResourceManager to import from
prefix: A prefix to apply to the resource URIs, including the delimiter.
For example, "app+" would result in URIs like "app+data://users".
If None, the original URI is used.
"""
for uri, resource in manager._resources.items():
# Create prefixed URI and import the resource with the new URI as the storage key
prefixed_uri = f"{prefix}{uri}" if prefix else uri
self.add_resource(resource, key=prefixed_uri)
logger.debug(f'Imported resource "{uri}" as "{prefixed_uri}"')
def import_templates(
self, manager: "ResourceManager", prefix: str | None = None
) -> None:
"""Import resource templates from another resource manager.
Templates are imported with a prefixed URI template if a prefix is provided.
For example, if a template has URI template "data://users/{id}" and you import
it with prefix "app+", the imported template will have URI template
"app+data://users/{id}". If no prefix is provided, the original URI template is used.
Args:
manager: The ResourceManager to import templates from
prefix: A prefix to apply to the template URIs, including the delimiter.
For example, "app+" would result in URI templates like "app+data://users/{id}".
If None, the original URI template is used.
"""
for uri_template, template in manager._templates.items():
# Create prefixed URI template and import the template with the new URI as the storage key
prefixed_uri_template = (
f"{prefix}{uri_template}" if prefix else uri_template
)
self.add_template(template, key=prefixed_uri_template)
logger.debug(
f'Imported template "{uri_template}" as "{prefixed_uri_template}"'
)

View file

@ -8,6 +8,7 @@ from re import Pattern
from typing import Any, Literal
import httpx
from mcp.types import TextContent
from pydantic.networks import AnyUrl
from fastmcp.resources import Resource, ResourceTemplate
@ -613,25 +614,18 @@ class FastMCPOpenAPI(FastMCP):
f"Registered TEMPLATE: {uri_template_str} ({route.method} {route.path}) with tags: {route.tags}"
)
async def call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
"""Override the call_tool method to return the raw result without converting to content.
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."""
For testing purposes, if specific tools are called, we convert the result to the expected object.
"""
context = self.get_context()
result = await self._tool_manager.call_tool(name, arguments, context=context)
# For testing purposes, convert result to expected model based on tool name
if name == "create_user_users_post":
# Try to import User class from test module
try:
from tests.server.test_openapi import User
# Convert dict to User object
if isinstance(result, dict):
return User(**result)
except ImportError:
# If User class not found, just return the raw result
pass
# For other tools, ensure the response is wrapped in TextContent
if isinstance(result, dict | str):
if isinstance(result, dict):
result_text = json.dumps(result)
else:
result_text = result
return [TextContent(text=result_text, type="text")]
return result

View file

@ -2,10 +2,19 @@ from typing import Any, cast
from urllib.parse import quote
import mcp.types
from mcp.types import BlobResourceContents, TextResourceContents
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.types import (
BlobResourceContents,
EmbeddedResource,
GetPromptResult,
ImageContent,
TextContent,
TextResourceContents,
)
from pydantic.networks import AnyUrl
import fastmcp
from fastmcp.client import Client
from fastmcp.exceptions import NotFoundError
from fastmcp.prompts import Message, Prompt
from fastmcp.resources import Resource, ResourceTemplate
from fastmcp.server.context import Context
@ -152,79 +161,89 @@ class ProxyPrompt(Prompt):
async def render(self, arguments: dict[str, Any]) -> list[Message]:
async with self._client:
result = await self._client.get_prompt(self.name, arguments)
return [Message(role=m.role, content=m.content) for m in result.messages]
return [Message(role=m.role, content=m.content) for m in result]
class FastMCPProxy(FastMCP):
def __init__(self, _async_constructor: bool, **kwargs):
if not _async_constructor:
raise ValueError(
"FastMCPProxy() was initialied unexpectedly. Please use a constructor like `FastMCPProxy.from_client()` instead."
)
def __init__(self, client: "Client", **kwargs):
super().__init__(**kwargs)
self.client = client
@classmethod
async def from_client(
cls,
client: "Client",
name: str | None = None,
**settings: fastmcp.settings.ServerSettings,
) -> "FastMCPProxy":
"""Create a FastMCP proxy server from a client.
async def get_tools(self) -> dict[str, Tool]:
tools = await super().get_tools()
This method creates a new FastMCP server instance that proxies requests to the provided client.
It discovers the client's tools, resources, prompts, and templates, and creates corresponding
components in the server that forward requests to the client.
async with self.client:
for tool in await self.client.list_tools():
tool_proxy = await ProxyTool.from_client(self.client, tool)
tools[tool_proxy.name] = tool_proxy
Args:
client: The client to proxy requests to
name: Optional name for the new FastMCP server (defaults to client name if available)
**settings: Additional settings for the FastMCP server
return tools
Returns:
A FastMCP server that proxies requests to the client
"""
server = cls(name=name, **settings, _async_constructor=True)
async def get_resources(self) -> dict[str, Resource]:
resources = await super().get_resources()
async with client:
# Register proxies for client tools
tools = await client.list_tools()
for tool in tools:
tool_proxy = await ProxyTool.from_client(client, tool)
server._tool_manager._tools[tool_proxy.name] = tool_proxy
logger.debug(f"Created proxy for tool: {tool_proxy.name}")
async with self.client:
for resource in await self.client.list_resources():
resource_proxy = await ProxyResource.from_client(self.client, resource)
resources[str(resource_proxy.uri)] = resource_proxy
# Register proxies for client resources
resources = await client.list_resources()
for resource in resources:
resource_proxy = await ProxyResource.from_client(client, resource)
server._resource_manager._resources[str(resource_proxy.uri)] = (
resource_proxy
)
logger.debug(f"Created proxy for resource: {resource_proxy.uri}")
return resources
# Register proxies for client resource templates
templates = await client.list_resource_templates()
for template in templates:
template_proxy = await ProxyTemplate.from_client(client, template)
server._resource_manager._templates[template_proxy.uri_template] = (
template_proxy
)
logger.debug(
f"Created proxy for template: {template_proxy.uri_template}"
)
async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
templates = await super().get_resource_templates()
# Register proxies for client prompts
prompts = await client.list_prompts()
for prompt in prompts:
prompt_proxy = await ProxyPrompt.from_client(client, prompt)
server._prompt_manager._prompts[prompt_proxy.name] = prompt_proxy
logger.debug(f"Created proxy for prompt: {prompt_proxy.name}")
async with self.client:
for template in await self.client.list_resource_templates():
template_proxy = await ProxyTemplate.from_client(self.client, template)
templates[template_proxy.uri_template] = template_proxy
logger.info(f"Created server '{server.name}' proxying to client: {client}")
return server
return templates
@classmethod
async def from_server(cls, server: FastMCP, **settings: Any) -> "FastMCPProxy":
client = Client(transport=fastmcp.client.transports.FastMCPTransport(server))
return await cls.from_client(client, **settings)
async def get_prompts(self) -> dict[str, Prompt]:
prompts = await super().get_prompts()
async with self.client:
for prompt in await self.client.list_prompts():
prompt_proxy = await ProxyPrompt.from_client(self.client, prompt)
prompts[prompt_proxy.name] = prompt_proxy
return prompts
async def _mcp_call_tool(
self, key: str, arguments: dict[str, Any]
) -> list[TextContent | ImageContent | EmbeddedResource]:
try:
result = await super()._mcp_call_tool(key, arguments)
return result
except NotFoundError:
async with self.client:
result = await self.client.call_tool(key, arguments)
return result
async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
try:
result = await super()._mcp_read_resource(uri)
return result
except NotFoundError:
async with self.client:
resource = await self.client.read_resource(uri)
if isinstance(resource[0], TextResourceContents):
content = resource[0].text
elif isinstance(resource[0], BlobResourceContents):
content = resource[0].blob
else:
raise ValueError(f"Unsupported content type: {type(resource[0])}")
return [
ReadResourceContents(content=content, mime_type=resource[0].mimeType)
]
async def _mcp_get_prompt(
self, name: str, arguments: dict[str, Any] | None = None
) -> GetPromptResult:
try:
result = await super()._mcp_get_prompt(name, arguments)
return result
except NotFoundError:
async with self.client:
result = await self.client.get_prompt(name, arguments)
return GetPromptResult(messages=result)

View file

@ -1,6 +1,6 @@
"""FastMCP - A more ergonomic interface for MCP servers."""
import json
import datetime
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import (
AbstractAsyncContextManager,
@ -13,7 +13,6 @@ import anyio
import httpx
import pydantic_core
import uvicorn
from fastapi import FastAPI
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.server.lowlevel.server import LifespanResultT
from mcp.server.lowlevel.server import Server as MCPServer
@ -38,24 +37,115 @@ from starlette.routing import Mount, Route
import fastmcp
import fastmcp.settings
from fastmcp.exceptions import ResourceError
from fastmcp.exceptions import NotFoundError, ResourceError
from fastmcp.prompts import Prompt, PromptManager
from fastmcp.prompts.prompt import Message, PromptResult
from fastmcp.prompts.prompt import PromptResult
from fastmcp.resources import Resource, ResourceManager
from fastmcp.resources.template import ResourceTemplate
from fastmcp.tools import ToolManager
from fastmcp.tools.tool import Tool
from fastmcp.utilities.decorators import DecoratedFunction
from fastmcp.utilities.logging import configure_logging, get_logger
from fastmcp.utilities.types import Image
if TYPE_CHECKING:
from fastmcp.client import Client
from fastmcp.server.context import Context
from fastmcp.server.openapi import FastMCPOpenAPI
from fastmcp.server.proxy import FastMCPProxy
logger = get_logger(__name__)
NOT_FOUND = object()
class MountedServer:
def __init__(
self,
prefix: str,
server: "FastMCP",
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 = "_"
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()
}
async def get_resources(self) -> dict[str, Resource]:
resources = await self.server.get_resources()
return {
f"{self.prefix}{self.resource_separator}{key}": 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
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()
}
def match_tool(self, key: str) -> bool:
return key.startswith(f"{self.prefix}{self.tool_separator}")
def strip_tool_prefix(self, key: str) -> str:
return key.removeprefix(f"{self.prefix}{self.tool_separator}")
def match_resource(self, key: str) -> bool:
return key.startswith(f"{self.prefix}{self.resource_separator}")
def strip_resource_prefix(self, key: str) -> str:
return key.removeprefix(f"{self.prefix}{self.resource_separator}")
def match_prompt(self, key: str) -> bool:
return key.startswith(f"{self.prefix}{self.prompt_separator}")
def strip_prompt_prefix(self, key: str) -> str:
return key.removeprefix(f"{self.prefix}{self.prompt_separator}")
class TimedCache:
def __init__(self, expiration: datetime.timedelta):
self.expiration = expiration
self.cache: dict[Any, tuple[Any, datetime.datetime]] = {}
def set(self, key: Any, value: Any) -> None:
expires = datetime.datetime.now() + self.expiration
self.cache[key] = (value, expires)
def get(self, key: Any) -> Any:
value = self.cache.get(key)
if value is not None and value[1] > datetime.datetime.now():
return value[0]
else:
return NOT_FOUND
def clear(self) -> None:
self.cache.clear()
@asynccontextmanager
async def default_lifespan(server: "FastMCP") -> AsyncIterator[Any]:
@ -70,7 +160,7 @@ async def default_lifespan(server: "FastMCP") -> AsyncIterator[Any]:
yield {}
def lifespan_wrapper(
def _lifespan_wrapper(
app: "FastMCP",
lifespan: Callable[["FastMCP"], AbstractAsyncContextManager[LifespanResultT]],
) -> Callable[
@ -79,17 +169,7 @@ def lifespan_wrapper(
@asynccontextmanager
async def wrap(s: MCPServer[LifespanResultT]) -> AsyncIterator[LifespanResultT]:
async with AsyncExitStack() as stack:
# enter main app's lifespan
context = await stack.enter_async_context(lifespan(app))
# Enter all mounted app lifespans
for prefix, mounted_app in app._mounted_apps.items():
mounted_context = mounted_app._mcp_server.lifespan(
mounted_app._mcp_server
)
await stack.enter_async_context(mounted_context)
logger.debug(f"Prepared lifespan for mounted app '{prefix}'")
yield context
return wrap
@ -108,9 +188,13 @@ class FastMCP(Generic[LifespanResultT]):
):
self.tags: set[str] = tags or set()
self.settings = fastmcp.settings.ServerSettings(**settings)
self._cache = TimedCache(
expiration=datetime.timedelta(
seconds=self.settings.cache_expiration_seconds
)
)
# Setup for mounted apps - must be initialized before _mcp_server
self._mounted_apps: dict[str, FastMCP] = {}
self._mounted_servers: dict[str, MountedServer] = {}
if lifespan is None:
lifespan = default_lifespan
@ -118,7 +202,7 @@ class FastMCP(Generic[LifespanResultT]):
self._mcp_server = MCPServer[LifespanResultT](
name=name or "FastMCP",
instructions=instructions,
lifespan=lifespan_wrapper(self, lifespan),
lifespan=_lifespan_wrapper(self, lifespan),
)
self._tool_manager = ToolManager(
duplicate_behavior=self.settings.on_duplicate_tools
@ -137,6 +221,9 @@ class FastMCP(Generic[LifespanResultT]):
# Configure logging
configure_logging(self.settings.log_level)
def __repr__(self) -> str:
return f"{type(self).__name__}({self.name!r})"
@property
def name(self) -> str:
return self._mcp_server.name
@ -177,30 +264,13 @@ class FastMCP(Generic[LifespanResultT]):
def _setup_handlers(self) -> None:
"""Set up core MCP protocol handlers."""
self._mcp_server.list_tools()(self._mcp_list_tools)
self._mcp_server.call_tool()(self.call_tool)
self._mcp_server.call_tool()(self._mcp_call_tool)
self._mcp_server.list_resources()(self._mcp_list_resources)
self._mcp_server.read_resource()(self._mcp_read_resource)
self._mcp_server.list_prompts()(self._mcp_list_prompts)
self._mcp_server.get_prompt()(self._mcp_get_prompt)
self._mcp_server.list_resource_templates()(self._mcp_list_resource_templates)
def get_tools(self) -> dict[str, Tool]:
"""Get all registered tools, indexed by registered key."""
return self._tool_manager.get_tools()
def list_tools(self) -> list[Tool]:
"""List all registered tools."""
return self._tool_manager.list_tools()
async def _mcp_list_tools(self) -> list[MCPTool]:
"""
List all available tools, in the format expected by the low-level MCP
server.
See `list_tools` for a more ergonomic way to list tools.
"""
return self._tool_manager.list_mcp_tools()
def get_context(self) -> "Context[ServerSession, LifespanResultT]":
"""
Returns a Context object. Note that the context will only be valid
@ -215,71 +285,152 @@ class FastMCP(Generic[LifespanResultT]):
return Context(request_context=request_context, fastmcp=self)
async def call_tool(
self, key: str, arguments: dict[str, Any]
) -> list[TextContent | ImageContent | EmbeddedResource]:
"""Call a tool by name with arguments."""
context = self.get_context()
result = await self._tool_manager.call_tool(key, arguments, context=context)
converted_result = _convert_to_content(result)
return converted_result
async def get_tools(self) -> dict[str, Tool]:
"""Get all registered tools, indexed by registered key."""
if (tools := self._cache.get("tools")) is NOT_FOUND:
tools = {}
for server in self._mounted_servers.values():
server_tools = await server.get_tools()
tools.update(server_tools)
tools.update(self._tool_manager.get_tools())
self._cache.set("tools", tools)
return tools
def get_resources(self) -> dict[str, Resource]:
async def get_resources(self) -> dict[str, Resource]:
"""Get all registered resources, indexed by registered key."""
return self._resource_manager.get_resources()
if (resources := self._cache.get("resources")) is NOT_FOUND:
resources = {}
for server in self._mounted_servers.values():
server_resources = await server.get_resources()
resources.update(server_resources)
resources.update(self._resource_manager.get_resources())
self._cache.set("resources", resources)
return resources
def list_resources(self) -> list[Resource]:
"""List all registered resources."""
return self._resource_manager.list_resources()
async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
"""Get all registered resource templates, indexed by registered key."""
if (templates := self._cache.get("resource_templates")) is NOT_FOUND:
templates = {}
for server in self._mounted_servers.values():
server_templates = await server.get_resource_templates()
templates.update(server_templates)
templates.update(self._resource_manager.get_templates())
self._cache.set("resource_templates", templates)
return templates
async def get_prompts(self) -> dict[str, Prompt]:
"""
List all available prompts.
"""
if (prompts := self._cache.get("prompts")) is NOT_FOUND:
prompts = {}
for server in self._mounted_servers.values():
server_prompts = await server.get_prompts()
prompts.update(server_prompts)
prompts.update(self._prompt_manager.get_prompts())
self._cache.set("prompts", prompts)
return prompts
async def _mcp_list_tools(self) -> list[MCPTool]:
"""
List all available tools, in the format expected by the low-level MCP
server.
"""
tools = await self.get_tools()
return [tool.to_mcp_tool(name=key) for key, tool in tools.items()]
async def _mcp_list_resources(self) -> list[MCPResource]:
"""
List all available resources, in the format expected by the low-level MCP
server.
See `list_resources` for a more ergonomic way to list resources.
"""
return self._resource_manager.list_mcp_resources()
def list_resource_templates(self) -> list[ResourceTemplate]:
return self._resource_manager.list_templates()
resources = await self.get_resources()
return [
resource.to_mcp_resource(uri=key) for key, resource in resources.items()
]
async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]:
"""
List all available resource templates, in the format expected by the low-level
MCP server.
See `list_resource_templates` for a more ergonomic way to list resource
templates.
"""
return self._resource_manager.list_mcp_resource_templates()
templates = await self.get_resource_templates()
return [
template.to_mcp_template(uriTemplate=key)
for key, template in templates.items()
]
async def read_resource(self, uri: AnyUrl | str) -> str | bytes:
"""Read a resource by URI."""
resource = await self._resource_manager.get_resource(uri)
if not resource:
raise ResourceError(f"Unknown resource: {uri}")
return await resource.read()
async def _mcp_list_prompts(self) -> list[MCPPrompt]:
"""
List all available prompts, in the format expected by the low-level MCP
server.
"""
prompts = await self.get_prompts()
return [prompt.to_mcp_prompt(name=key) for key, prompt in prompts.items()]
async def _mcp_call_tool(
self, key: str, arguments: dict[str, Any]
) -> list[TextContent | ImageContent | EmbeddedResource]:
"""Call a tool by name with arguments."""
if self._tool_manager.has_tool(key):
context = self.get_context()
result = await self._tool_manager.call_tool(key, arguments, context=context)
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
async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]:
"""
Read a resource by URI, in the format expected by the low-level MCP
server.
See `read_resource` for a more ergonomic way to read resources.
"""
if self._resource_manager.has_resource(uri):
resource = await self._resource_manager.get_resource(uri)
try:
content = await resource.read()
return [
ReadResourceContents(content=content, mime_type=resource.mime_type)
]
except Exception as e:
logger.error(f"Error reading resource {uri}: {e}")
raise ResourceError(str(e))
else:
for server in self._mounted_servers.values():
if server.match_resource(str(uri)):
new_uri = server.strip_resource_prefix(str(uri))
return await server.server._mcp_read_resource(new_uri)
else:
raise NotFoundError(f"Unknown resource: {uri}")
resource = await self._resource_manager.get_resource(uri)
if not resource:
raise ResourceError(f"Unknown resource: {uri}")
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.
try:
content = await self.read_resource(uri)
return [ReadResourceContents(content=content, mime_type=resource.mime_type)]
except Exception as e:
logger.error(f"Error reading resource {uri}: {e}")
raise ResourceError(str(e))
"""
if self._prompt_manager.has_prompt(name):
messages = await self._prompt_manager.render_prompt(name, arguments)
return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages))
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}")
def add_tool(
self,
@ -302,6 +453,7 @@ class FastMCP(Generic[LifespanResultT]):
self._tool_manager.add_tool_from_fn(
fn, name=name, description=description, tags=tags
)
self._cache.clear()
def tool(
self,
@ -357,6 +509,7 @@ class FastMCP(Generic[LifespanResultT]):
"""
self._resource_manager.add_resource(resource, key=key)
self._cache.clear()
def add_resource_fn(
self,
@ -388,6 +541,7 @@ class FastMCP(Generic[LifespanResultT]):
mime_type=mime_type,
tags=tags,
)
self._cache.clear()
def resource(
self,
@ -443,7 +597,7 @@ class FastMCP(Generic[LifespanResultT]):
)
def decorator(fn: AnyFunction) -> AnyFunction:
self._resource_manager.add_resource_or_template_from_fn(
self.add_resource_fn(
fn=fn,
uri=uri,
name=name,
@ -473,6 +627,7 @@ class FastMCP(Generic[LifespanResultT]):
description=description,
tags=tags,
)
self._cache.clear()
def prompt(
self,
@ -578,72 +733,72 @@ class FastMCP(Generic[LifespanResultT]):
],
)
def list_prompts(self) -> list[Prompt]:
"""
List all available prompts.
"""
return self._prompt_manager.list_prompts()
async def _mcp_list_prompts(self) -> list[MCPPrompt]:
"""
List all available prompts, in the format expected by the low-level MCP
server.
See `list_prompts` for a more ergonomic way to list prompts.
"""
return self._prompt_manager.list_mcp_prompts()
async def get_prompt(
self, name: str, arguments: dict[str, Any] | None = None
) -> list[Message]:
"""Get a prompt by name with arguments."""
return await self._prompt_manager.render_prompt(name, arguments)
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.
See `get_prompt` for a more ergonomic way to get prompts.
"""
try:
messages = await self.get_prompt(name, arguments)
return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages))
except Exception as e:
logger.error(f"Error getting prompt {name}: {e}")
raise ValueError(str(e))
def mount(
self,
prefix: str,
app: "FastMCP",
server: "FastMCP",
tool_separator: str | None = None,
resource_separator: str | None = None,
prompt_separator: str | None = None,
) -> None:
"""Mount another FastMCP application with a given prefix.
"""
Mount another FastMCP server on a given prefix.
"""
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()
When an application is mounted:
- The tools are imported with prefixed names using the tool_separator
Example: If app 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 app 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 app 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 app has a prompt named "weather_prompt", it will be available as "weather_weather_prompt"
- The mounted app's lifespan will be executed when the parent app's lifespan runs,
ensuring that any setup needed by the mounted app is performed
def unmount(self, prefix: str) -> None:
self._mounted_servers.pop(prefix)
self._cache.clear()
async def import_server(
self,
prefix: str,
server: "FastMCP",
tool_separator: str | None = None,
resource_separator: str | None = None,
prompt_separator: str | None = None,
) -> None:
"""
Import the MCP objects from another FastMCP server into this one,
optionally with a given prefix.
Note that when a server is *imported*, its objects are immediately
registered to the importing server. This is a one-time operation and
future changes to the imported server will not be reflected in the
importing server. Server-level configurations and lifespans are not imported.
When an server is mounted: - The tools are imported with prefixed names
using the tool_separator
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
Args:
prefix: The prefix to use for the mounted application
app: The FastMCP application 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 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 "_")
"""
if tool_separator is None:
tool_separator = "_"
@ -652,58 +807,30 @@ class FastMCP(Generic[LifespanResultT]):
if prompt_separator is None:
prompt_separator = "_"
# Mount the app in the list of mounted apps
self._mounted_apps[prefix] = app
# Import tools from the mounted app
# Import tools from the mounted server
tool_prefix = f"{prefix}{tool_separator}"
self._tool_manager.import_tools(app._tool_manager, tool_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 app
# Import resources and templates from the mounted server
resource_prefix = f"{prefix}{resource_separator}"
self._resource_manager.import_resources(app._resource_manager, resource_prefix)
self._resource_manager.import_templates(app._resource_manager, resource_prefix)
for key, resource in (await server.get_resources()).items():
self._resource_manager.add_resource(resource, key=f"{resource_prefix}{key}")
for key, template in (await server.get_resource_templates()).items():
self._resource_manager.add_template(template, key=f"{resource_prefix}{key}")
# Import prompts from the mounted app
# Import prompts from the mounted server
prompt_prefix = f"{prefix}{prompt_separator}"
self._prompt_manager.import_prompts(app._prompt_manager, prompt_prefix)
for key, prompt in (await server.get_prompts()).items():
self._prompt_manager.add_prompt(prompt, key=f"{prompt_prefix}{key}")
logger.info(f"Mounted app with prefix '{prefix}'")
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 prompts with prefix '{prompt_prefix}'")
@classmethod
async def as_proxy(
cls, client: "Client | FastMCP", **settings: Any
) -> "FastMCPProxy":
"""
Create a FastMCP proxy server from a client.
This method creates a new FastMCP server instance that proxies requests to the provided client.
It discovers the client's tools, resources, prompts, and templates, and creates corresponding
components in the server that forward requests to the client.
Args:
client: The client to proxy requests to
**settings: Additional settings for the FastMCP server
Returns:
A FastMCP server that proxies requests to the client
"""
from fastmcp.client import Client
from .proxy import FastMCPProxy
if isinstance(client, Client):
return await FastMCPProxy.from_client(client=client, **settings)
elif isinstance(client, FastMCP):
return await FastMCPProxy.from_server(server=client, **settings)
else:
raise ValueError(f"Unknown client type: {type(client)}")
self._cache.clear()
@classmethod
def from_openapi(
@ -718,11 +845,12 @@ class FastMCP(Generic[LifespanResultT]):
@classmethod
def from_fastapi(
cls, app: FastAPI, name: str | None = None, **settings: Any
cls, app: "Any", name: str | None = None, **settings: Any
) -> "FastMCPOpenAPI":
"""
Create a FastMCP server from a FastAPI application.
"""
from .openapi import FastMCPOpenAPI
client = httpx.AsyncClient(
@ -735,47 +863,11 @@ class FastMCP(Generic[LifespanResultT]):
openapi_spec=app.openapi(), client=client, name=name, **settings
)
@classmethod
def from_client(cls, client: "Client", **settings: Any) -> "FastMCPProxy":
"""
Create a FastMCP proxy server from a FastMCP client.
"""
from fastmcp.server.proxy import FastMCPProxy
def _convert_to_content(
result: Any,
_process_as_single_item: bool = False,
) -> list[TextContent | ImageContent | EmbeddedResource]:
"""Convert a result to a sequence of content objects."""
if result is None:
return []
if isinstance(result, TextContent | ImageContent | EmbeddedResource):
return [result]
if isinstance(result, Image):
return [result.to_image_content()]
if isinstance(result, list | tuple) and not _process_as_single_item:
# if the result is a list, then it could either be a list of MCP types,
# or a "regular" list that the tool is returning, or a mix of both.
#
# so we extract all the MCP types / images and convert them as individual content elements,
# and aggregate the rest as a single content element
mcp_types = []
other_content = []
for item in result:
if isinstance(item, TextContent | ImageContent | EmbeddedResource | Image):
mcp_types.append(_convert_to_content(item)[0])
else:
other_content.append(item)
if other_content:
other_content = _convert_to_content(
other_content, _process_as_single_item=True
)
return other_content + mcp_types
if not isinstance(result, str):
try:
result = json.dumps(pydantic_core.to_jsonable_python(result))
except Exception:
result = str(result)
return [TextContent(type="text", text=result)]
return FastMCPProxy(client=client, **settings)

View file

@ -42,7 +42,7 @@ class ServerSettings(BaseSettings):
log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level)
# HTTP settings
host: str = "0.0.0.0"
host: str = "127.0.0.1"
port: int = 8000
sse_path: str = "/sse"
message_path: str = "/messages/"
@ -62,6 +62,9 @@ class ServerSettings(BaseSettings):
description="List of dependencies to install in the server environment",
)
# cache settings (for checking mounted servers)
cache_expiration_seconds: float = 0
class ClientSettings(BaseSettings):
"""FastMCP client settings."""

View file

@ -1,15 +1,18 @@
from __future__ import annotations as _annotations
from __future__ import annotations
import inspect
import json
from collections.abc import Callable
from typing import TYPE_CHECKING, Annotated, Any
import pydantic_core
from mcp.types import EmbeddedResource, ImageContent, TextContent
from mcp.types import Tool as MCPTool
from pydantic import BaseModel, BeforeValidator, Field
from fastmcp.exceptions import ToolError
from fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
from fastmcp.utilities.types import _convert_set_defaults
from fastmcp.utilities.types import Image, _convert_set_defaults
if TYPE_CHECKING:
from mcp.server.session import ServerSessionT
@ -58,7 +61,7 @@ class Tool(BaseModel):
is_async = inspect.iscoroutinefunction(fn)
if context_kwarg is None:
if isinstance(fn, classmethod):
if inspect.ismethod(fn) and hasattr(fn, "__func__"):
sig = inspect.signature(fn.__func__)
else:
sig = inspect.signature(fn)
@ -67,14 +70,16 @@ class Tool(BaseModel):
context_kwarg = param_name
break
# Use callable typing to ensure fn is treated as a callable despite being a classmethod
fn_callable: Callable[..., Any] = fn
func_arg_metadata = func_metadata(
fn,
fn_callable,
skip_names=[context_kwarg] if context_kwarg is not None else [],
)
parameters = func_arg_metadata.arg_model.model_json_schema()
return cls(
fn=fn,
fn=fn_callable,
name=func_name,
description=func_doc,
parameters=parameters,
@ -88,10 +93,10 @@ class Tool(BaseModel):
self,
arguments: dict[str, Any],
context: Context[ServerSessionT, LifespanContextT] | None = None,
) -> Any:
) -> list[TextContent | ImageContent | EmbeddedResource]:
"""Run the tool with arguments."""
try:
return await self.fn_metadata.call_fn_with_arg_validation(
result = await self.fn_metadata.call_fn_with_arg_validation(
self.fn,
self.is_async,
arguments,
@ -99,6 +104,7 @@ class Tool(BaseModel):
if self.context_kwarg is not None
else None,
)
return _convert_to_content(result)
except Exception as e:
raise ToolError(f"Error executing tool {self.name}: {e}") from e
@ -114,3 +120,48 @@ class Tool(BaseModel):
if not isinstance(other, Tool):
return False
return self.model_dump() == other.model_dump()
def _convert_to_content(
result: Any,
_process_as_single_item: bool = False,
) -> list[TextContent | ImageContent | EmbeddedResource]:
"""Convert a result to a sequence of content objects."""
if result is None:
return []
if isinstance(result, TextContent | ImageContent | EmbeddedResource):
return [result]
if isinstance(result, Image):
return [result.to_image_content()]
if isinstance(result, list | tuple) and not _process_as_single_item:
# if the result is a list, then it could either be a list of MCP types,
# or a "regular" list that the tool is returning, or a mix of both.
#
# so we extract all the MCP types / images and convert them as individual content elements,
# and aggregate the rest as a single content element
mcp_types = []
other_content = []
for item in result:
if isinstance(item, TextContent | ImageContent | EmbeddedResource | Image):
mcp_types.append(_convert_to_content(item)[0])
else:
other_content.append(item)
if other_content:
other_content = _convert_to_content(
other_content, _process_as_single_item=True
)
return other_content + mcp_types
if not isinstance(result, str):
try:
result = json.dumps(pydantic_core.to_jsonable_python(result))
except Exception:
result = str(result)
return [TextContent(type="text", text=result)]

View file

@ -4,10 +4,11 @@ from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from mcp.shared.context import LifespanContextT
from mcp.types import EmbeddedResource, ImageContent, TextContent
from fastmcp.exceptions import ToolError
from fastmcp.exceptions import NotFoundError
from fastmcp.settings import DuplicateBehavior
from fastmcp.tools.tool import MCPTool, Tool
from fastmcp.tools.tool import Tool
from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
@ -36,9 +37,15 @@ class ToolManager:
self.duplicate_behavior = duplicate_behavior
def get_tool(self, key: str) -> Tool | None:
def has_tool(self, key: str) -> bool:
"""Check if a tool exists."""
return key in self._tools
def get_tool(self, key: str) -> Tool:
"""Get tool by key."""
return self._tools.get(key)
if key in self._tools:
return self._tools[key]
raise NotFoundError(f"Unknown tool: {key}")
def get_tools(self) -> dict[str, Tool]:
"""Get all registered tools, indexed by registered key."""
@ -48,10 +55,6 @@ class ToolManager:
"""List all registered tools."""
return list(self.get_tools().values())
def list_mcp_tools(self) -> list[MCPTool]:
"""List all registered tools in the format expected by the low-level MCP server."""
return [tool.to_mcp_tool(name=key) for key, tool in self._tools.items()]
def add_tool_from_fn(
self,
fn: Callable[..., Any],
@ -86,29 +89,10 @@ class ToolManager:
key: str,
arguments: dict[str, Any],
context: Context[ServerSessionT, LifespanContextT] | None = None,
) -> Any:
) -> list[TextContent | ImageContent | EmbeddedResource]:
"""Call a tool by name with arguments."""
tool = self.get_tool(key)
if not tool:
raise ToolError(f"Unknown tool: {key}")
raise NotFoundError(f"Unknown tool: {key}")
return await tool.run(arguments, context=context)
def import_tools(
self, tool_manager: ToolManager, prefix: str | None = None
) -> None:
"""
Import all tools from another ToolManager with prefixed names.
Args:
tool_manager: Another ToolManager instance to import tools from
prefix: Prefix to add to tool names, including the delimiter.
The resulting tool name will be in the format "{prefix}{original_name}"
if prefix is provided, otherwise the original name is used.
For example, with prefix "weather/" and tool "forecast",
the imported tool would be available as "weather/forecast"
"""
for name, tool in tool_manager._tools.items():
key = f"{prefix}{name}" if prefix else name
self.add_tool(tool, key=key)
logger.debug(f'Imported tool "{tool.name}" as "{key}"')

View file

@ -67,36 +67,6 @@ def tagged_resources_server():
return server
@pytest.fixture
def mounted_resources_server():
"""Fixture that creates a FastMCP server with mounted resources."""
# Create the main server
main_server = FastMCP("MainServer")
# Create sub-app with its own resources
sub_app = FastMCP("SubAppServer")
# Add a resource to the sub-app
@sub_app.resource(uri="subapp://data", description="SubApp resource")
async def get_subapp_data():
return {"source": "subapp"}
# Add a template to the sub-app
@sub_app.resource(uri="subapp://{id}", description="SubApp template")
async def get_subapp_item(id: str):
return {"id": id, "source": "subapp"}
# Mount the sub-app to the main server with a prefix
main_server.mount("sub", sub_app)
# Add a resource to the main server
@main_server.resource(uri="main://data", description="Main resource")
async def get_main_data():
return {"source": "main"}
return main_server
async def test_list_tools(fastmcp_server):
"""Test listing tools with InMemoryClient."""
client = Client(transport=FastMCPTransport(fastmcp_server))
@ -296,61 +266,3 @@ async def test_tagged_template_functionality(tagged_resources_server):
content_str = str(result[0])
assert '"id": "123"' in content_str
assert '"type": "template_data"' in content_str
async def test_mounted_resources(mounted_resources_server):
"""Test that resources from mounted apps are correctly prefixed."""
client = Client(transport=FastMCPTransport(mounted_resources_server))
async with client:
resources = await client.list_resources()
# Should have two resources (one from main, one from sub)
assert len(resources) == 2
# Find resources by URI
main_resource = next(
(r for r in resources if str(r.uri) == "main://data"), None
)
sub_resource = next(
(r for r in resources if str(r.uri) == "sub+subapp://data"), None
)
# Both resources should exist
assert main_resource is not None
assert sub_resource is not None
# Check descriptions
assert main_resource.description == "Main resource"
assert sub_resource.description == "SubApp resource"
async def test_mounted_templates(mounted_resources_server):
"""Test that templates from mounted apps are correctly prefixed."""
client = Client(transport=FastMCPTransport(mounted_resources_server))
async with client:
templates = await client.list_resource_templates()
# Should have one template (from sub)
assert len(templates) == 1
# Check the template
template = templates[0]
assert "sub+subapp://{id}" in template.uriTemplate
assert template.description == "SubApp template"
async def test_mounted_template_functionality(mounted_resources_server):
"""Test that templates from mounted apps function correctly."""
client = Client(transport=FastMCPTransport(mounted_resources_server))
async with client:
# Use the prefixed template
uri = cast(AnyUrl, "sub+subapp://123")
result = await client.read_resource(uri)
content_str = str(result[0])
# Check the content
assert '"id": "123"' in content_str
assert '"source": "subapp"' in content_str

View file

@ -12,7 +12,6 @@ from fastmcp.prompts.prompt import (
class TestRenderPrompt:
@pytest.mark.anyio
async def test_basic_fn(self):
def fn() -> str:
return "Hello, world!"
@ -22,7 +21,6 @@ class TestRenderPrompt:
UserMessage(content=TextContent(type="text", text="Hello, world!"))
]
@pytest.mark.anyio
async def test_async_fn(self):
async def fn() -> str:
return "Hello, world!"
@ -32,7 +30,6 @@ class TestRenderPrompt:
UserMessage(content=TextContent(type="text", text="Hello, world!"))
]
@pytest.mark.anyio
async def test_fn_with_args(self):
async def fn(name: str, age: int = 30) -> str:
return f"Hello, {name}! You're {age} years old."
@ -46,7 +43,6 @@ class TestRenderPrompt:
)
]
@pytest.mark.anyio
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."
@ -55,7 +51,6 @@ class TestRenderPrompt:
with pytest.raises(ValueError):
await prompt.render(arguments=dict(age=40))
@pytest.mark.anyio
async def test_fn_returns_message(self):
async def fn() -> Message:
return UserMessage(content="Hello, world!")
@ -65,7 +60,6 @@ class TestRenderPrompt:
UserMessage(content=TextContent(type="text", text="Hello, world!"))
]
@pytest.mark.anyio
async def test_fn_returns_assistant_message(self):
async def fn() -> Message:
return AssistantMessage(
@ -77,7 +71,6 @@ class TestRenderPrompt:
AssistantMessage(content=TextContent(type="text", text="Hello, world!"))
]
@pytest.mark.anyio
async def test_fn_returns_multiple_messages(self):
expected = [
UserMessage("Hello, world!"),
@ -91,7 +84,6 @@ class TestRenderPrompt:
prompt = Prompt.from_function(fn)
assert await prompt.render() == expected
@pytest.mark.anyio
async def test_fn_returns_list_of_strings(self):
expected = [
"Hello, world!",
@ -104,7 +96,6 @@ class TestRenderPrompt:
prompt = Prompt.from_function(fn)
assert await prompt.render() == [UserMessage(t) for t in expected]
@pytest.mark.anyio
async def test_fn_returns_resource_content(self):
"""Test returning a message with resource content."""
@ -134,7 +125,6 @@ class TestRenderPrompt:
)
]
@pytest.mark.anyio
async def test_fn_returns_mixed_content(self):
"""Test returning messages with mixed content types."""
@ -174,7 +164,6 @@ class TestRenderPrompt:
),
]
@pytest.mark.anyio
async def test_fn_returns_dict_with_resource(self):
"""Test returning a dict with resource content."""

View file

@ -1,8 +1,8 @@
import pytest
from fastmcp.exceptions import PromptError
from fastmcp.exceptions import NotFoundError
from fastmcp.prompts import Prompt
from fastmcp.prompts.prompt import PromptArgument, TextContent, UserMessage
from fastmcp.prompts.prompt import TextContent, UserMessage
from fastmcp.prompts.prompt_manager import PromptManager
@ -119,8 +119,8 @@ class TestPromptManager:
# Result should be the original prompt
assert result.fn.__name__ == "original_fn"
def test_list_prompts(self):
"""Test listing all prompts."""
def test_get_prompts(self):
"""Test retrieving all prompts."""
def fn1() -> str:
return "Hello, world!"
@ -133,11 +133,11 @@ class TestPromptManager:
prompt2 = Prompt.from_function(fn2)
manager.add_prompt(prompt1)
manager.add_prompt(prompt2)
prompts = manager.list_prompts()
prompts = manager.get_prompts()
assert len(prompts) == 2
assert prompts == [prompt1, prompt2]
assert prompts["fn1"] == prompt1
assert prompts["fn2"] == prompt2
@pytest.mark.anyio
async def test_render_prompt(self):
"""Test rendering a prompt."""
@ -152,7 +152,6 @@ class TestPromptManager:
UserMessage(content=TextContent(type="text", text="Hello, world!"))
]
@pytest.mark.anyio
async def test_render_prompt_with_args(self):
"""Test rendering a prompt with arguments."""
@ -167,14 +166,12 @@ class TestPromptManager:
UserMessage(content=TextContent(type="text", text="Hello, World!"))
]
@pytest.mark.anyio
async def test_render_unknown_prompt(self):
"""Test rendering a non-existent prompt."""
manager = PromptManager()
with pytest.raises(PromptError, match="Unknown prompt: unknown"):
with pytest.raises(NotFoundError, match="Unknown prompt: unknown"):
await manager.render_prompt("unknown")
@pytest.mark.anyio
async def test_render_prompt_with_missing_args(self):
"""Test rendering a prompt with missing required arguments."""
@ -253,132 +250,12 @@ class TestPromptTags:
)
# Filter prompts by tags
simple_prompts = [p for p in manager.list_prompts() if "simple" in p.tags]
simple_prompts = [
p for p in manager.get_prompts().values() if "simple" in p.tags
]
assert len(simple_prompts) == 2
assert {p.name for p in simple_prompts} == {"greeting", "summary"}
nlp_prompts = [p for p in manager.list_prompts() if "nlp" in p.tags]
nlp_prompts = [p for p in manager.get_prompts().values() if "nlp" in p.tags]
assert len(nlp_prompts) == 1
assert nlp_prompts[0].name == "summary"
def test_import_prompts_preserves_tags(self):
"""Test that importing prompts preserves their tags."""
source_manager = PromptManager()
def sample_prompt() -> str:
return "Sample prompt"
source_manager.add_prompt(
Prompt.from_function(sample_prompt, tags={"example", "test"})
)
target_manager = PromptManager()
target_manager.import_prompts(source_manager, "imported/")
imported_prompt = target_manager.get_prompt("imported/sample_prompt")
assert imported_prompt is not None
assert imported_prompt.tags == {"example", "test"}
class TestImports:
def test_import_prompts(self):
"""Test importing prompts from one manager to another with a prefix."""
# Setup source manager with prompts
source_manager = PromptManager()
summary_prompt = Prompt(
name="summary",
description="Generate a summary of text",
arguments=[PromptArgument(name="text", description="Text to summarize")],
fn=lambda: None, # type: ignore
)
source_manager.add_prompt(summary_prompt)
translate_prompt = Prompt(
name="translate",
description="Translate text to another language",
arguments=[
PromptArgument(name="text", description="Text to translate"),
PromptArgument(name="language", description="Target language"),
],
fn=lambda: None, # type: ignore
)
source_manager.add_prompt(translate_prompt)
# Create target manager
target_manager = PromptManager()
# Import prompts from source to target
prefix = "nlp/"
target_manager.import_prompts(source_manager, prefix)
# Verify prompts were imported with prefixes
assert "nlp/summary" in target_manager._prompts
assert "nlp/translate" in target_manager._prompts
# Verify the original prompts still exist in source manager
assert "summary" in source_manager._prompts
assert "translate" in source_manager._prompts
assert target_manager._prompts["nlp/summary"].fn == summary_prompt.fn
assert target_manager._prompts["nlp/translate"].fn == translate_prompt.fn
def test_import_prompts_with_duplicates(self):
"""Test handling of duplicate prompts during import."""
# Setup source and target managers with same prompt names
source_manager = PromptManager()
target_manager = PromptManager()
source_prompt = Prompt(
name="common",
description="Source description",
arguments=None,
fn=lambda: None, # type: ignore
)
source_manager._prompts["common"] = source_prompt
target_prompt = Prompt(
name="common",
description="Target description",
arguments=None,
fn=lambda: None, # type: ignore
)
target_manager._prompts["common"] = target_prompt
# Import prompts with prefix
prefix = "external/"
target_manager.import_prompts(source_manager, prefix)
# Verify both prompts exist in target manager
assert "common" in target_manager._prompts
assert "external/common" in target_manager._prompts
assert target_manager._prompts["common"].fn == target_prompt.fn
assert target_manager._prompts["external/common"].fn == source_prompt.fn
def test_import_prompts_with_nested_prefixes(self):
"""Test importing already prefixed prompts."""
# Setup source manager with already prefixed prompts
first_manager = PromptManager()
second_manager = PromptManager()
third_manager = PromptManager()
original_prompt = Prompt(
name="analyze",
description="Analyze text",
arguments=[PromptArgument(name="text", description="Text to analyze")],
fn=lambda: None, # type: ignore
)
first_manager._prompts["analyze"] = original_prompt
# Import to second manager with prefix
second_manager.import_prompts(first_manager, "text/")
# Import from second to third with another prefix
third_manager.import_prompts(second_manager, "ai/")
# Verify the nested prefixing
assert "text/analyze" in second_manager._prompts
assert "ai/text/analyze" in third_manager._prompts
assert third_manager._prompts["ai/text/analyze"].fn == original_prompt.fn

View file

@ -53,7 +53,6 @@ class TestFileResource:
assert isinstance(resource.path, Path)
assert resource.path.is_absolute()
@pytest.mark.anyio
async def test_read_text_file(self, temp_file: Path):
"""Test reading a text file."""
resource = FileResource(
@ -65,7 +64,6 @@ class TestFileResource:
assert content == "test content"
assert resource.mime_type == "text/plain"
@pytest.mark.anyio
async def test_read_binary_file(self, temp_file: Path):
"""Test reading a file as binary."""
resource = FileResource(
@ -87,7 +85,6 @@ class TestFileResource:
path=Path("test.txt"),
)
@pytest.mark.anyio
async def test_missing_file_error(self, temp_file: Path):
"""Test error when file doesn't exist."""
# Create path to non-existent file
@ -103,7 +100,6 @@ class TestFileResource:
@pytest.mark.skipif(
os.name == "nt", reason="File permissions behave differently on Windows"
)
@pytest.mark.anyio
async def test_permission_error(self, temp_file: Path):
"""Test reading a file without permissions."""
temp_file.chmod(0o000) # Remove all permissions

View file

@ -25,7 +25,6 @@ class TestFunctionResource:
assert resource.mime_type == "text/plain" # default
assert resource.fn == my_func
@pytest.mark.anyio
async def test_read_text(self):
"""Test reading text from a FunctionResource."""
@ -41,7 +40,6 @@ class TestFunctionResource:
assert content == "Hello, world!"
assert resource.mime_type == "text/plain"
@pytest.mark.anyio
async def test_read_binary(self):
"""Test reading binary data from a FunctionResource."""
@ -56,7 +54,6 @@ class TestFunctionResource:
content = await resource.read()
assert content == b"Hello, world!"
@pytest.mark.anyio
async def test_json_conversion(self):
"""Test automatic JSON conversion of non-string results."""
@ -72,7 +69,6 @@ class TestFunctionResource:
assert isinstance(content, str)
assert '"key": "value"' in content
@pytest.mark.anyio
async def test_error_handling(self):
"""Test error handling in FunctionResource."""
@ -87,7 +83,6 @@ class TestFunctionResource:
with pytest.raises(ValueError, match="Error reading resource function://test"):
await resource.read()
@pytest.mark.anyio
async def test_basemodel_conversion(self):
"""Test handling of BaseModel types."""
@ -102,7 +97,6 @@ class TestFunctionResource:
content = await resource.read()
assert content == '{"name": "test"}'
@pytest.mark.anyio
async def test_custom_type_conversion(self):
"""Test handling of custom types."""
@ -121,7 +115,6 @@ class TestFunctionResource:
content = await resource.read()
assert isinstance(content, str)
@pytest.mark.anyio
async def test_async_read_text(self):
"""Test reading text from async FunctionResource."""

View file

@ -4,7 +4,7 @@ from tempfile import NamedTemporaryFile
import pytest
from pydantic import AnyUrl, FileUrl
from fastmcp.exceptions import ResourceError
from fastmcp.exceptions import NotFoundError
from fastmcp.resources import (
FileResource,
FunctionResource,
@ -36,34 +36,41 @@ class TestResourceManager:
def test_add_resource(self, temp_file: Path):
"""Test adding a resource."""
manager = ResourceManager()
file_url = "file://test-resource"
resource = FileResource(
uri=FileUrl(f"file://{temp_file}"),
uri=FileUrl(file_url),
name="test",
path=temp_file,
)
added = manager.add_resource(resource)
assert added == resource
assert manager.list_resources() == [resource]
# Get the actual key from the resource manager
assert len(manager.get_resources()) == 1
assert resource in manager.get_resources().values()
def test_add_duplicate_resource(self, temp_file: Path):
"""Test adding the same resource twice."""
manager = ResourceManager()
file_url = "file://test-resource"
resource = FileResource(
uri=FileUrl(f"file://{temp_file}"),
uri=FileUrl(file_url),
name="test",
path=temp_file,
)
first = manager.add_resource(resource)
second = manager.add_resource(resource)
assert first == second
assert manager.list_resources() == [resource]
# Check the resource is there
assert len(manager.get_resources()) == 1
assert resource in manager.get_resources().values()
def test_warn_on_duplicate_resources(self, temp_file: Path, caplog):
"""Test warning on duplicate resources."""
manager = ResourceManager(duplicate_behavior="warn")
file_url = "file://test-resource"
resource = FileResource(
uri=FileUrl(f"file://{temp_file}"),
uri=FileUrl(file_url),
name="test_resource",
path=temp_file,
)
@ -73,13 +80,14 @@ class TestResourceManager:
assert "Resource already exists" in caplog.text
# Should have the resource
assert len(manager.list_resources()) == 1
assert len(manager.get_resources()) == 1
assert resource in manager.get_resources().values()
def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog):
"""Test disabling warning on duplicate resources."""
manager = ResourceManager(duplicate_behavior="ignore")
resource = FileResource(
uri=FileUrl(f"file://{temp_file}"),
uri=FileUrl(f"file://{temp_file.name}"),
name="test",
path=temp_file,
)
@ -92,7 +100,7 @@ class TestResourceManager:
manager = ResourceManager(duplicate_behavior="error")
resource = FileResource(
uri=FileUrl(f"file://{temp_file}"),
uri=FileUrl(f"file://{temp_file.name}"),
name="test_resource",
path=temp_file,
)
@ -106,14 +114,15 @@ class TestResourceManager:
"""Test replacing duplicate resources."""
manager = ResourceManager(duplicate_behavior="replace")
file_url = "file://test-resource"
resource1 = FileResource(
uri=FileUrl(f"file://{temp_file}"),
uri=FileUrl(file_url),
name="original",
path=temp_file,
)
resource2 = FileResource(
uri=FileUrl(f"file://{temp_file}"),
uri=FileUrl(file_url),
name="replacement",
path=temp_file,
)
@ -122,7 +131,7 @@ class TestResourceManager:
manager.add_resource(resource2)
# Should have replaced with the new resource
resources = manager.list_resources()
resources = list(manager.get_resources().values())
assert len(resources) == 1
assert resources[0].name == "replacement"
@ -130,14 +139,15 @@ class TestResourceManager:
"""Test ignoring duplicate resources."""
manager = ResourceManager(duplicate_behavior="ignore")
file_url = "file://test-resource"
resource1 = FileResource(
uri=FileUrl(f"file://{temp_file}"),
uri=FileUrl(file_url),
name="original",
path=temp_file,
)
resource2 = FileResource(
uri=FileUrl(f"file://{temp_file}"),
uri=FileUrl(file_url),
name="replacement",
path=temp_file,
)
@ -146,7 +156,7 @@ class TestResourceManager:
result = manager.add_resource(resource2)
# Should keep the original
resources = manager.list_resources()
resources = list(manager.get_resources().values())
assert len(resources) == 1
assert resources[0].name == "original"
# Result should be the original resource
@ -170,7 +180,7 @@ class TestResourceManager:
assert "Template already exists" in caplog.text
# Should have the template
assert len(manager.list_templates()) == 1
assert manager.get_templates() == {"test://{id}": template}
def test_error_on_duplicate_templates(self):
"""Test error on duplicate templates."""
@ -216,7 +226,7 @@ class TestResourceManager:
manager.add_template(template2)
# Should have replaced with the new template
templates = manager.list_templates()
templates = list(manager.get_templates().values())
assert len(templates) == 1
assert templates[0].name == "replacement"
@ -246,18 +256,17 @@ class TestResourceManager:
result = manager.add_template(template2)
# Should keep the original
templates = manager.list_templates()
templates = list(manager.get_templates().values())
assert len(templates) == 1
assert templates[0].name == "original"
# Result should be the original template
assert result.name == "original"
@pytest.mark.anyio
async def test_get_resource(self, temp_file: Path):
"""Test getting a resource by URI."""
manager = ResourceManager()
resource = FileResource(
uri=FileUrl(f"file://{temp_file}"),
uri=FileUrl(f"file://{temp_file.name}"),
name="test",
path=temp_file,
)
@ -265,7 +274,6 @@ class TestResourceManager:
retrieved = await manager.get_resource(resource.uri)
assert retrieved == resource
@pytest.mark.anyio
async def test_get_resource_from_template(self):
"""Test getting a resource through a template."""
manager = ResourceManager()
@ -285,31 +293,34 @@ class TestResourceManager:
content = await resource.read()
assert content == "Hello, world!"
@pytest.mark.anyio
async def test_get_unknown_resource(self):
"""Test getting a non-existent resource."""
manager = ResourceManager()
with pytest.raises(ResourceError, match="Unknown resource"):
with pytest.raises(NotFoundError, match="Unknown resource"):
await manager.get_resource(AnyUrl("unknown://test"))
def test_list_resources(self, temp_file: Path):
"""Test listing all resources."""
def test_get_resources(self, temp_file: Path):
"""Test retrieving all resources."""
manager = ResourceManager()
file_url1 = "file://test-resource1"
resource1 = FileResource(
uri=FileUrl(f"file://{temp_file}"),
uri=FileUrl(file_url1),
name="test1",
path=temp_file,
)
file_url2 = "file://test-resource2"
resource2 = FileResource(
uri=FileUrl(f"file://{temp_file}2"),
uri=FileUrl(file_url2),
name="test2",
path=temp_file,
)
manager.add_resource(resource1)
manager.add_resource(resource2)
resources = manager.list_resources()
resources = manager.get_resources()
assert len(resources) == 2
assert resources == [resource1, resource2]
values = list(resources.values())
assert resource1 in values
assert resource2 in values
class TestResourceTags:
@ -319,7 +330,7 @@ class TestResourceTags:
"""Test adding a resource with tags."""
manager = ResourceManager()
resource = FileResource(
uri=FileUrl(f"file://{temp_file}"),
uri=FileUrl("file://weather-data"),
name="weather_data",
path=temp_file,
tags={"weather", "data"},
@ -327,7 +338,7 @@ class TestResourceTags:
manager.add_resource(resource)
# Check that tags are preserved
resources = manager.list_resources()
resources = list(manager.get_resources().values())
assert len(resources) == 1
assert resources[0].tags == {"weather", "data"}
@ -348,7 +359,7 @@ class TestResourceTags:
)
manager.add_resource(resource)
resources = manager.list_resources()
resources = list(manager.get_resources().values())
assert len(resources) == 1
assert resources[0].tags == {"sample", "test", "data"}
@ -368,7 +379,7 @@ class TestResourceTags:
)
manager.add_template(template)
templates = manager.list_templates()
templates = list(manager.get_templates().values())
assert len(templates) == 1
assert templates[0].tags == {"users", "template", "data"}
@ -378,7 +389,7 @@ class TestResourceTags:
# Create multiple resources with different tags
resource1 = FileResource(
uri=FileUrl(f"file://{temp_file}1"),
uri=FileUrl("file://weather-data"),
name="weather_data",
path=temp_file,
tags={"weather", "external"},
@ -410,285 +421,17 @@ class TestResourceTags:
# Filter resources by tags
internal_resources = [
r for r in manager.list_resources() if "internal" in r.tags
r for r in manager.get_resources().values() if "internal" in r.tags
]
assert len(internal_resources) == 2
assert {r.name for r in internal_resources} == {"user_data", "system_data"}
external_resources = [
r for r in manager.list_resources() if "external" in r.tags
r for r in manager.get_resources().values() if "external" in r.tags
]
assert len(external_resources) == 1
assert external_resources[0].name == "weather_data"
def test_import_resources_preserves_tags(self):
"""Test that importing resources preserves their tags."""
source_manager = ResourceManager()
async def get_data():
return "Tagged data"
resource = FunctionResource(
uri=AnyUrl("data://tagged"),
name="tagged_data",
fn=get_data,
tags={"test", "example", "data"},
)
source_manager.add_resource(resource)
target_manager = ResourceManager()
target_manager.import_resources(source_manager, "imported+")
imported_resources = target_manager.list_resources()
assert len(imported_resources) == 1
assert imported_resources[0].tags == {"test", "example", "data"}
def test_import_templates_preserves_tags(self):
"""Test that importing templates preserves their tags."""
source_manager = ResourceManager()
def user_template(user_id: str) -> str:
return f"User {user_id}"
template = ResourceTemplate.from_function(
fn=user_template,
uri_template="users://{user_id}",
name="user_template",
tags={"users", "template", "test"},
)
source_manager.add_template(template)
target_manager = ResourceManager()
target_manager.import_templates(source_manager, "imported+")
imported_templates = target_manager.list_templates()
assert len(imported_templates) == 1
assert imported_templates[0].tags == {"users", "template", "test"}
class TestImports:
def test_import_resources(self):
"""Test importing resources from one manager to another with a prefix."""
# Setup source manager with resources
source_manager = ResourceManager()
# Create mock resource functions
async def weather_fn():
return "Weather data"
async def traffic_fn():
return "Traffic data"
# Add resources to source manager
weather_resource = FunctionResource(
uri=AnyUrl("weather://forecast"),
name="weather_forecast",
description="Get weather forecast",
mime_type="application/json",
fn=weather_fn,
)
source_manager._resources["weather://forecast"] = weather_resource
traffic_resource = FunctionResource(
uri=AnyUrl("traffic://status"),
name="traffic_status",
description="Get traffic status",
mime_type="application/json",
fn=traffic_fn,
)
source_manager._resources["traffic://status"] = traffic_resource
# Create target manager
target_manager = ResourceManager()
# Import resources from source to target
prefix = "data+"
target_manager.import_resources(source_manager, prefix)
# Verify resources were imported with prefixes
assert "data+weather://forecast" in target_manager._resources
assert "data+traffic://status" in target_manager._resources
# Verify the original resources still exist in source manager
assert "weather://forecast" in source_manager._resources
assert "traffic://status" in source_manager._resources
# Verify the imported resources have the correct properties
assert (
target_manager._resources["data+weather://forecast"].name
== "weather_forecast"
)
assert (
target_manager._resources["data+weather://forecast"].description
== "Get weather forecast"
)
assert (
target_manager._resources["data+weather://forecast"].mime_type
== "application/json"
)
assert (
target_manager._resources["data+traffic://status"].name == "traffic_status"
)
assert (
target_manager._resources["data+traffic://status"].description
== "Get traffic status"
)
assert (
target_manager._resources["data+traffic://status"].mime_type
== "application/json"
)
# Since we're dealing with FunctionResource type, we can safely check function attributes
assert isinstance(
target_manager._resources["data+weather://forecast"], FunctionResource
)
assert isinstance(
target_manager._resources["data+traffic://status"], FunctionResource
)
weather_resource = target_manager._resources["data+weather://forecast"]
traffic_resource = target_manager._resources["data+traffic://status"]
if hasattr(weather_resource, "fn") and hasattr(traffic_resource, "fn"):
assert weather_resource.fn.__name__ == weather_fn.__name__
assert traffic_resource.fn.__name__ == traffic_fn.__name__
def test_import_templates(self):
"""Test importing resource templates from one manager to another with a prefix."""
# Setup source manager with templates
source_manager = ResourceManager()
# Create mock template functions
async def user_fn(**params):
return f"User data for id {params.get('id')}"
async def product_fn(**params):
return f"Product data for id {params.get('id')}"
# Add templates to source manager
user_template = ResourceTemplate(
uri_template="api://users/{id}",
name="user_template",
description="Get user by ID",
mime_type="application/json",
fn=user_fn,
parameters={"id": {"type": "string", "description": "User ID"}},
)
source_manager._templates["api://users/{id}"] = user_template
product_template = ResourceTemplate(
uri_template="api://products/{id}",
name="product_template",
description="Get product by ID",
mime_type="application/json",
fn=product_fn,
parameters={"id": {"type": "string", "description": "Product ID"}},
)
source_manager._templates["api://products/{id}"] = product_template
# Create target manager
target_manager = ResourceManager()
# Import templates from source to target
prefix = "shop+"
target_manager.import_templates(source_manager, prefix)
# Verify templates were imported with prefixes
assert "shop+api://users/{id}" in target_manager._templates
assert "shop+api://products/{id}" in target_manager._templates
# Verify the original templates still exist in source manager
assert "api://users/{id}" in source_manager._templates
assert "api://products/{id}" in source_manager._templates
# Verify the imported templates have the correct properties
assert (
target_manager._templates["shop+api://users/{id}"].name == "user_template"
)
assert (
target_manager._templates["shop+api://users/{id}"].description
== "Get user by ID"
)
assert (
target_manager._templates["shop+api://users/{id}"].mime_type
== "application/json"
)
assert target_manager._templates["shop+api://users/{id}"].parameters == {
"id": {"type": "string", "description": "User ID"}
}
assert (
target_manager._templates["shop+api://products/{id}"].name
== "product_template"
)
assert (
target_manager._templates["shop+api://products/{id}"].description
== "Get product by ID"
)
assert (
target_manager._templates["shop+api://products/{id}"].mime_type
== "application/json"
)
assert target_manager._templates["shop+api://products/{id}"].parameters == {
"id": {"type": "string", "description": "Product ID"}
}
# Verify the template functions were properly copied (only if the fn attribute exists)
user_template = target_manager._templates["shop+api://users/{id}"]
product_template = target_manager._templates["shop+api://products/{id}"]
if hasattr(user_template, "fn") and hasattr(product_template, "fn"):
assert user_template.fn.__name__ == user_fn.__name__
assert product_template.fn.__name__ == product_fn.__name__
def test_import_multiple_resource_types(self):
"""Test importing both resources and templates with the same prefix."""
# Setup source manager with both resources and templates
source_manager = ResourceManager()
# Create mock functions
async def resource_fn():
return "Resource data"
async def template_fn(**params):
return f"Template data for id {params.get('id')}"
# Add a resource to source manager
resource = FunctionResource(
uri=AnyUrl("data://resource"),
name="test_resource",
description="Test resource",
mime_type="application/json",
fn=resource_fn,
)
source_manager._resources["data://resource"] = resource
# Add a template to source manager
template = ResourceTemplate(
uri_template="data://template/{id}",
name="test_template",
description="Test template",
mime_type="application/json",
fn=template_fn,
parameters={"id": {"type": "string", "description": "ID parameter"}},
)
source_manager._templates["data://template/{id}"] = template
# Create target manager
target_manager = ResourceManager()
# Import both resources and templates
prefix = "test+"
target_manager.import_resources(source_manager, prefix)
target_manager.import_templates(source_manager, prefix)
# Verify both resource types were imported with prefixes
assert "test+data://resource" in target_manager._resources
assert "test+data://template/{id}" in target_manager._templates
class TestCustomResourceKeys:
"""Test adding resources and templates with custom keys."""
@ -743,7 +486,6 @@ class TestCustomResourceKeys:
# The template's internal URI template remains unchanged
assert str(manager._templates[custom_key].uri_template) == original_uri_template
@pytest.mark.anyio
async def test_get_resource_with_custom_key(self, temp_file: Path):
"""Test that get_resource works with resources added with custom keys."""
manager = ResourceManager()
@ -768,10 +510,9 @@ class TestCustomResourceKeys:
assert str(retrieved.uri) == original_uri
# Should NOT be retrievable by the original URI
with pytest.raises(ResourceError, match="Unknown resource"):
with pytest.raises(NotFoundError, match="Unknown resource"):
await manager.get_resource(original_uri)
@pytest.mark.anyio
async def test_get_resource_from_template_with_custom_key(self):
"""Test that templates with custom keys can create resources."""
manager = ResourceManager()
@ -797,63 +538,5 @@ class TestCustomResourceKeys:
assert content == "Hello, world!"
# Shouldn't work with the original template pattern
with pytest.raises(ResourceError, match="Unknown resource"):
with pytest.raises(NotFoundError, match="Unknown resource"):
await manager.get_resource("greet://world")
def test_import_resources_with_custom_keys(self):
"""Test that import_resources properly uses custom keys."""
source_manager = ResourceManager()
target_manager = ResourceManager()
# Add a resource to source manager
async def resource_fn():
return "Resource data"
original_uri = "data://original"
resource = FunctionResource(
uri=AnyUrl(original_uri),
name="original_resource",
fn=resource_fn,
)
source_manager.add_resource(resource)
# Import with prefix which creates a new key
prefix = "imported+"
target_manager.import_resources(source_manager, prefix)
# Resource should be in target manager with prefixed URI as key
prefixed_uri = f"{prefix}{original_uri}"
assert prefixed_uri in target_manager._resources
# The resource's internal URI should remain unchanged
stored_resource = target_manager._resources[prefixed_uri]
assert str(stored_resource.uri) == original_uri
def test_import_templates_with_custom_keys(self):
"""Test that import_templates properly uses custom keys."""
source_manager = ResourceManager()
target_manager = ResourceManager()
# Add a template to source manager
async def template_fn(id: str):
return f"Template {id}"
original_template = "template://{id}"
template = ResourceTemplate.from_function(
fn=template_fn,
uri_template=original_template,
name="original_template",
)
source_manager.add_template(template)
# Import with prefix which creates a new key
prefix = "imported+"
target_manager.import_templates(source_manager, prefix)
# Template should be in target manager with prefixed URI template as key
prefixed_template = f"{prefix}{original_template}"
assert prefixed_template in target_manager._templates
# The template's internal URI template should remain unchanged
stored_template = target_manager._templates[prefixed_template]
assert str(stored_template.uri_template) == original_template

View file

@ -162,7 +162,6 @@ class TestResourceTemplate:
name="test",
)
@pytest.mark.anyio
async def test_create_resource(self):
"""Test creating a resource from a template."""
@ -186,7 +185,6 @@ class TestResourceTemplate:
data = json.loads(content)
assert data == {"key": "foo", "value": 123}
@pytest.mark.anyio
async def test_template_error(self):
"""Test error handling in template resource creation."""
@ -202,7 +200,6 @@ class TestResourceTemplate:
with pytest.raises(ValueError, match="Error creating resource from template"):
await template.create_resource("fail://test", {"x": "test"})
@pytest.mark.anyio
async def test_async_text_resource(self):
"""Test creating a text resource from async function."""
@ -224,7 +221,6 @@ class TestResourceTemplate:
content = await resource.read()
assert content == "Hello, world!"
@pytest.mark.anyio
async def test_async_binary_resource(self):
"""Test creating a binary resource from async function."""
@ -246,7 +242,6 @@ class TestResourceTemplate:
content = await resource.read()
assert content == b"test"
@pytest.mark.anyio
async def test_basemodel_conversion(self):
"""Test handling of BaseModel types."""
@ -274,7 +269,6 @@ class TestResourceTemplate:
data = json.loads(content)
assert data == {"key": "foo", "value": 123}
@pytest.mark.anyio
async def test_custom_type_conversion(self):
"""Test handling of custom types."""

View file

@ -90,7 +90,6 @@ class TestResourceValidation:
)
assert resource.mime_type == "application/json"
@pytest.mark.anyio
async def test_resource_read_abstract(self):
"""Test that Resource.read() is abstract."""

View file

@ -73,7 +73,6 @@ def tools(mcp: FastMCP, test_dir: Path) -> FastMCP:
return mcp
@pytest.mark.anyio
async def test_list_resources(mcp: FastMCP):
resources = await mcp._mcp_list_resources()
assert len(resources) == 4
@ -86,7 +85,6 @@ async def test_list_resources(mcp: FastMCP):
]
@pytest.mark.anyio
async def test_read_resource_dir(mcp: FastMCP):
res_iter = await mcp._mcp_read_resource("dir://test_dir")
res_list = list(res_iter)
@ -103,7 +101,6 @@ async def test_read_resource_dir(mcp: FastMCP):
]
@pytest.mark.anyio
async def test_read_resource_file(mcp: FastMCP):
res_iter = await mcp._mcp_read_resource("file://test_dir/example.py")
res_list = list(res_iter)
@ -112,17 +109,15 @@ async def test_read_resource_file(mcp: FastMCP):
assert res.content == "print('hello world')"
@pytest.mark.anyio
async def test_delete_file(mcp: FastMCP, test_dir: Path):
await mcp.call_tool(
await mcp._mcp_call_tool(
"delete_file", arguments=dict(path=str(test_dir / "example.py"))
)
assert not (test_dir / "example.py").exists()
@pytest.mark.anyio
async def test_delete_file_and_check_resources(mcp: FastMCP, test_dir: Path):
await mcp.call_tool(
await mcp._mcp_call_tool(
"delete_file", arguments=dict(path=str(test_dir / "example.py"))
)
res_iter = await mcp._mcp_read_resource("file://test_dir/example.py")

View file

@ -0,0 +1,391 @@
import json
from urllib.parse import quote
from mcp.types import TextContent, TextResourceContents
from fastmcp.client.client import Client
from fastmcp.server.server import FastMCP
async def test_import_basic_functionality():
"""Test that the import method properly imports tools and other resources."""
# Create main app and sub-app
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
# Add a tool to the sub-app
@sub_app.tool()
def sub_tool() -> str:
return "This is from the sub app"
# Import the sub-app to the main app
await main_app.import_server("sub", sub_app)
# Verify the tool was imported with the prefix
assert "sub_sub_tool" in main_app._tool_manager._tools
assert "sub_tool" in sub_app._tool_manager._tools
# Verify the original tool still exists in the sub-app
tool = main_app._tool_manager.get_tool("sub_sub_tool")
assert tool is not None
assert tool.name == "sub_tool"
assert callable(tool.fn)
async def test_import_multiple_apps():
"""Test importing multiple apps to a main app."""
# Create main app and multiple sub-apps
main_app = FastMCP("MainApp")
weather_app = FastMCP("WeatherApp")
news_app = FastMCP("NewsApp")
# Add tools to each sub-app
@weather_app.tool()
def get_forecast() -> str:
return "Weather forecast"
@news_app.tool()
def get_headlines() -> str:
return "News headlines"
# Import both sub-apps to the main app
await main_app.import_server("weather", weather_app)
await main_app.import_server("news", news_app)
# Verify tools were imported with the correct prefixes
assert "weather_get_forecast" in main_app._tool_manager._tools
assert "news_get_headlines" in main_app._tool_manager._tools
async def test_import_combines_tools():
"""Test that importing preserves existing tools with the same prefix."""
# Create apps
main_app = FastMCP("MainApp")
first_app = FastMCP("FirstApp")
second_app = FastMCP("SecondApp")
# Add tools to each sub-app
@first_app.tool()
def first_tool() -> str:
return "First app tool"
@second_app.tool()
def second_tool() -> str:
return "Second app tool"
# Import first app
await main_app.import_server("api", first_app)
assert "api_first_tool" in main_app._tool_manager._tools
# Import second app to same prefix
await main_app.import_server("api", second_app)
# Verify second tool is there
assert "api_second_tool" in main_app._tool_manager._tools
# Tools from both imports are combined
assert "api_first_tool" in main_app._tool_manager._tools
async def test_import_with_resources():
"""Test importing with resources."""
# Create apps
main_app = FastMCP("MainApp")
data_app = FastMCP("DataApp")
# Add a resource to the data app
@data_app.resource(uri="data://users")
async def get_users():
return ["user1", "user2"]
# Import the data app
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
async def test_import_with_resource_templates():
"""Test importing with resource templates."""
# Create apps
main_app = FastMCP("MainApp")
user_app = FastMCP("UserApp")
# Add a resource template to the user app
@user_app.resource(uri="users://{user_id}/profile")
def get_user_profile(user_id: str) -> dict:
return {"id": user_id, "name": f"User {user_id}"}
# Import the user app
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
async def test_import_with_prompts():
"""Test importing with prompts."""
# Create apps
main_app = FastMCP("MainApp")
assistant_app = FastMCP("AssistantApp")
# Add a prompt to the assistant app
@assistant_app.prompt()
def greeting(name: str) -> str:
return f"Hello, {name}!"
# Import the assistant app
await main_app.import_server("assistant", assistant_app)
# Verify the prompt was imported with the prefix
assert "assistant_greeting" in main_app._prompt_manager._prompts
async def test_import_multiple_resource_templates():
"""Test importing multiple apps with resource templates."""
# Create apps
main_app = FastMCP("MainApp")
weather_app = FastMCP("WeatherApp")
news_app = FastMCP("NewsApp")
# Add templates to each app
@weather_app.resource(uri="weather://{city}")
def get_weather(city: str) -> str:
return f"Weather for {city}"
@news_app.resource(uri="news://{category}")
def get_news(category: str) -> str:
return f"News for {category}"
# Import both apps
await main_app.import_server("data", weather_app)
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
async def test_import_multiple_prompts():
"""Test importing multiple apps with prompts."""
# Create apps
main_app = FastMCP("MainApp")
python_app = FastMCP("PythonApp")
sql_app = FastMCP("SQLApp")
# Add prompts to each app
@python_app.prompt()
def review_python(code: str) -> str:
return f"Reviewing Python code:\n{code}"
@sql_app.prompt()
def explain_sql(query: str) -> str:
return f"Explaining SQL query:\n{query}"
# Import both apps
await main_app.import_server("python", python_app)
await main_app.import_server("sql", sql_app)
# Verify prompts were imported with correct prefixes
assert "python_review_python" in main_app._prompt_manager._prompts
assert "sql_explain_sql" in main_app._prompt_manager._prompts
async def test_tool_custom_name_preserved_when_imported():
"""Test that a tool's custom name is preserved when imported."""
main_app = FastMCP("MainApp")
api_app = FastMCP("APIApp")
def fetch_data(query: str) -> str:
return f"Data for query: {query}"
api_app.add_tool(fetch_data, name="get_data")
await main_app.import_server("api", api_app)
# Check that the tool is accessible by its prefixed name
tool = main_app._tool_manager.get_tool("api_get_data")
assert tool is not None
# Check that the function name is preserved
assert tool.fn.__name__ == "fetch_data"
async def test_call_imported_custom_named_tool():
"""Test calling an imported tool with a custom name."""
main_app = FastMCP("MainApp")
api_app = FastMCP("APIApp")
def fetch_data(query: str) -> str:
return f"Data for query: {query}"
api_app.add_tool(fetch_data, name="get_data")
await main_app.import_server("api", api_app)
async with Client(main_app) as client:
result = await client.call_tool("api_get_data", {"query": "test"})
assert isinstance(result[0], TextContent)
assert result[0].text == "Data for query: test"
async def test_first_level_importing_with_custom_name():
"""Test that a tool with a custom name is correctly imported at the first level."""
service_app = FastMCP("ServiceApp")
provider_app = FastMCP("ProviderApp")
def calculate_value(input: int) -> int:
return input * 2
provider_app.add_tool(calculate_value, name="compute")
await service_app.import_server("provider", provider_app)
# Tool is accessible in the service app with the first prefix
tool = service_app._tool_manager.get_tool("provider_compute")
assert tool is not None
assert tool.fn.__name__ == "calculate_value"
async def test_nested_importing_preserves_prefixes():
"""Test that importing a previously imported app preserves prefixes."""
main_app = FastMCP("MainApp")
service_app = FastMCP("ServiceApp")
provider_app = FastMCP("ProviderApp")
def calculate_value(input: int) -> int:
return input * 2
provider_app.add_tool(calculate_value, name="compute")
await service_app.import_server("provider", provider_app)
await main_app.import_server("service", service_app)
# Tool is accessible in the main app with both prefixes
tool = main_app._tool_manager.get_tool("service_provider_compute")
assert tool is not None
async def test_call_nested_imported_tool():
"""Test calling a tool through multiple levels of importing."""
main_app = FastMCP("MainApp")
service_app = FastMCP("ServiceApp")
provider_app = FastMCP("ProviderApp")
def calculate_value(input: int) -> int:
return input * 2
provider_app.add_tool(calculate_value, name="compute")
await service_app.import_server("provider", provider_app)
await main_app.import_server("service", service_app)
result = await main_app._tool_manager.call_tool(
"service_provider_compute", {"input": 21}
)
assert isinstance(result[0], TextContent)
assert result[0].text == "42"
async def test_import_with_proxy_tools():
"""
Test importing with tools that have custom names (proxy tools).
This tests that the tool's name doesn't change even though the registered
name does, which is important because we need to forward that name to the
proxy server correctly.
"""
# Create apps
main_app = FastMCP("MainApp")
api_app = FastMCP("APIApp")
@api_app.tool()
def get_data(query: str) -> str:
return f"Data for query: {query}"
proxy_app = FastMCP.from_client(Client(api_app))
await main_app.import_server("api", proxy_app)
result = await main_app._mcp_call_tool("api_get_data", {"query": "test"})
assert isinstance(result[0], TextContent)
assert result[0].text == "Data for query: test"
async def test_import_with_proxy_prompts():
"""
Test importing with prompts that have custom keys.
This tests that the prompt's name doesn't change even though the registered
key does, which is important for correct rendering.
"""
# Create apps
main_app = FastMCP("MainApp")
api_app = FastMCP("APIApp")
@api_app.prompt()
def greeting(name: str) -> str:
return f"Hello, {name} from API!"
proxy_app = FastMCP.from_client(Client(api_app))
await main_app.import_server("api", proxy_app)
result = await main_app._mcp_get_prompt("api_greeting", {"name": "World"})
assert result.messages is not None
# Check that the message contains our greeting
async def test_import_with_proxy_resources():
"""
Test importing with resources that have custom keys.
This tests that the resource's name doesn't change even though the registered
key does, which is important for correct access.
"""
# Create apps
main_app = FastMCP("MainApp")
api_app = FastMCP("APIApp")
# Create a resource in the API app
@api_app.resource(uri="config://settings")
def get_config():
return {
"api_key": "12345",
"base_url": "https://api.example.com",
}
proxy_app = FastMCP.from_client(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")
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"
async def test_import_with_proxy_resource_templates():
"""
Test importing with resource templates that have custom keys.
This tests that the template's name doesn't change even though the registered
key does, which is important for correct instantiation.
"""
# Create apps
main_app = FastMCP("MainApp")
api_app = FastMCP("APIApp")
# Create a resource template in the API app
@api_app.resource(uri="user://{name}/{email}")
def create_user(name: str, email: str):
return {"name": name, "email": email}
proxy_app = FastMCP.from_client(Client(api_app))
await main_app.import_server("api", proxy_app)
# Instantiate the template through the main app with the prefixed key
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}")
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"

View file

@ -4,7 +4,6 @@ from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import anyio
import pytest
from mcp.types import (
ClientCapabilities,
Implementation,
@ -18,7 +17,6 @@ from pydantic import TypeAdapter
from fastmcp import Context, FastMCP
@pytest.mark.anyio
async def test_fastmcp_server_lifespan():
"""Test that lifespan works in FastMCP server."""

View file

@ -1,428 +1,429 @@
import contextlib
import json
from urllib.parse import quote
from mcp.types import TextContent
import pytest
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.types import TextContent, TextResourceContents
from fastmcp.server.server import FastMCP
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.client.transports import FastMCPTransport
from fastmcp.exceptions import NotFoundError
async def test_mount_basic_functionality():
"""Test that the mount method properly imports tools and other resources."""
# Create main app and sub-app
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
class TestBasicMount:
"""Test basic mounting functionality."""
# Add a tool to the sub-app
@sub_app.tool()
def sub_tool() -> str:
return "This is from the sub app"
async def test_mount_simple_server(self):
"""Test mounting a simple server and accessing its tool."""
# Create main app and sub-app
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
# Mount the sub-app to the main app
main_app.mount("sub", sub_app)
# Add a tool to the sub-app
@sub_app.tool()
def sub_tool() -> str:
return "This is from the sub app"
# Verify the tool was imported with the prefix
assert "sub_sub_tool" in main_app._tool_manager._tools
assert "sub_tool" in sub_app._tool_manager._tools
# Mount the sub-app to the main app
main_app.mount("sub", sub_app)
# Verify the original tool still exists in the sub-app
tool = main_app._tool_manager.get_tool("sub_sub_tool")
assert tool is not None
assert tool.name == "sub_tool"
assert callable(tool.fn)
# Get tools from main app, should include sub_app's tools
tools = await main_app.get_tools()
assert "sub_sub_tool" in tools
async with Client(main_app) as client:
result = await client.call_tool("sub_sub_tool", {})
assert isinstance(result[0], TextContent)
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."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
@sub_app.tool()
def greet(name: str) -> str:
return f"Hello, {name}!"
# Mount with custom separator
main_app.mount("sub", sub_app, tool_separator="-")
# Tool should be accessible with custom separator
tools = await main_app.get_tools()
assert "sub-greet" in tools
# Call the tool
result = await main_app._mcp_call_tool("sub-greet", {"name": "World"})
assert isinstance(result[0], TextContent)
assert result[0].text == "Hello, World!"
async def test_unmount_server(self):
"""Test unmounting a server removes access to its tools."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
@sub_app.tool()
def sub_tool() -> str:
return "This is from the sub app"
# Mount the sub-app
main_app.mount("sub", sub_app)
# Verify it was mounted
tools = await main_app.get_tools()
assert "sub_sub_tool" in tools
# Unmount the sub-app
main_app.unmount("sub")
# Verify it was unmounted
tools = await main_app.get_tools()
assert "sub_sub_tool" not in tools
# Calling the tool should fail
with pytest.raises(NotFoundError, match="Unknown tool: sub_sub_tool"):
await main_app._mcp_call_tool("sub_sub_tool", {})
async def test_mount_multiple_apps():
"""Test mounting multiple apps to a main app."""
# Create main app and multiple sub-apps
main_app = FastMCP("MainApp")
weather_app = FastMCP("WeatherApp")
news_app = FastMCP("NewsApp")
class TestMultipleServerMount:
"""Test mounting multiple servers simultaneously."""
# Add tools to each sub-app
@weather_app.tool()
def get_forecast() -> str:
return "Weather forecast"
async def test_mount_multiple_servers(self):
"""Test mounting multiple servers with different prefixes."""
main_app = FastMCP("MainApp")
weather_app = FastMCP("WeatherApp")
news_app = FastMCP("NewsApp")
@news_app.tool()
def get_headlines() -> str:
return "News headlines"
@weather_app.tool()
def get_forecast() -> str:
return "Weather forecast"
# Mount both sub-apps to the main app
main_app.mount("weather", weather_app)
main_app.mount("news", news_app)
@news_app.tool()
def get_headlines() -> str:
return "News headlines"
# Verify tools were imported with the correct prefixes
assert "weather_get_forecast" in main_app._tool_manager._tools
assert "news_get_headlines" in main_app._tool_manager._tools
# Mount both apps
main_app.mount("weather", weather_app)
main_app.mount("news", news_app)
# Check both are accessible
tools = await main_app.get_tools()
assert "weather_get_forecast" in tools
assert "news_get_headlines" in tools
# Call tools from both mounted servers
result1 = await main_app._mcp_call_tool("weather_get_forecast", {})
assert isinstance(result1[0], TextContent)
assert result1[0].text == "Weather forecast"
result2 = await main_app._mcp_call_tool("news_get_headlines", {})
assert isinstance(result2[0], TextContent)
assert result2[0].text == "News headlines"
async def test_mount_same_prefix(self):
"""Test that mounting with the same prefix replaces the previous mount."""
main_app = FastMCP("MainApp")
first_app = FastMCP("FirstApp")
second_app = FastMCP("SecondApp")
@first_app.tool()
def first_tool() -> str:
return "First app tool"
@second_app.tool()
def second_tool() -> str:
return "Second app tool"
# Mount first app
main_app.mount("api", first_app)
tools = await main_app.get_tools()
assert "api_first_tool" in tools
# Mount second app with same prefix
main_app.mount("api", second_app)
tools = await main_app.get_tools()
# First app's tool should no longer be accessible
assert "api_first_tool" not in tools
# Second app's tool should be accessible
assert "api_second_tool" in tools
async def test_mount_combines_tools():
"""Test that mounting preserves existing tools with the same prefix."""
# Create apps
main_app = FastMCP("MainApp")
first_app = FastMCP("FirstApp")
second_app = FastMCP("SecondApp")
class TestDynamicChanges:
"""Test that changes to mounted servers are reflected dynamically."""
# Add tools to each sub-app
@first_app.tool()
def first_tool() -> str:
return "First app tool"
async def test_adding_tool_after_mounting(self):
"""Test that tools added after mounting are accessible."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
@second_app.tool()
def second_tool() -> str:
return "Second app tool"
# Mount the sub-app before adding any tools
main_app.mount("sub", sub_app)
# Mount first app
main_app.mount("api", first_app)
assert "api_first_tool" in main_app._tool_manager._tools
# Initially, there should be no tools from sub_app
tools = await main_app.get_tools()
assert not any(key.startswith("sub_") for key in tools)
# Mount second app to same prefix
main_app.mount("api", second_app)
# Add a tool to the sub-app after mounting
@sub_app.tool()
def dynamic_tool() -> str:
return "Added after mounting"
# Verify second tool is there
assert "api_second_tool" in main_app._tool_manager._tools
# The tool should be accessible through the main app
tools = await main_app.get_tools()
assert "sub_dynamic_tool" in tools
# Tools from both mounts are combined
assert "api_first_tool" in main_app._tool_manager._tools
# Call the dynamically added tool
result = await main_app._mcp_call_tool("sub_dynamic_tool", {})
assert isinstance(result[0], TextContent)
assert result[0].text == "Added after mounting"
async def test_removing_tool_after_mounting(self):
"""Test that tools removed from mounted servers are no longer accessible."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
@sub_app.tool()
def temp_tool() -> str:
return "Temporary tool"
# Mount the sub-app
main_app.mount("sub", sub_app)
# Initially, the tool should be accessible
tools = await main_app.get_tools()
assert "sub_temp_tool" in tools
# Remove the tool from sub_app
sub_app._tool_manager._tools.pop("temp_tool")
# The tool should no longer be accessible
# Refresh the cache by clearing it
main_app._cache.cache.clear()
tools = await main_app.get_tools()
assert "sub_temp_tool" not in tools
async def test_mount_with_resources():
"""Test mounting with resources."""
# Create apps
main_app = FastMCP("MainApp")
data_app = FastMCP("DataApp")
class TestResourcesAndTemplates:
"""Test mounting with resources and resource templates."""
# Add a resource to the data app
@data_app.resource(uri="data://users")
async def get_users():
return ["user1", "user2"]
async def test_mount_with_resources(self):
"""Test mounting a server with resources."""
main_app = FastMCP("MainApp")
data_app = FastMCP("DataApp")
# Mount the data app
main_app.mount("data", data_app)
@data_app.resource(uri="data://users")
async def get_users():
return ["user1", "user2"]
# Verify the resource was imported with the prefix
assert "data+data://users" in main_app._resource_manager._resources
# Mount the data app
main_app.mount("data", data_app)
# 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)
async with Client(main_app) as client:
resource = await client.read_resource("data+data://users")
assert isinstance(resource[0], TextResourceContents)
assert resource[0].text == '["user1", "user2"]'
async def test_mount_with_resource_templates(self):
"""Test mounting a server with resource templates."""
main_app = FastMCP("MainApp")
user_app = FastMCP("UserApp")
@user_app.resource(uri="users://{user_id}/profile")
def get_user_profile(user_id: str) -> dict:
return {"id": user_id, "name": f"User {user_id}"}
# Mount the user app
main_app.mount("api", user_app)
# 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)
# 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"
async def test_adding_resource_after_mounting(self):
"""Test adding a resource after mounting."""
main_app = FastMCP("MainApp")
data_app = FastMCP("DataApp")
# Mount the data app before adding resources
main_app.mount("data", data_app)
# Add a resource after mounting
@data_app.resource(uri="data://config")
def get_config():
return {"version": "1.0"}
# 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)
# 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"
async def test_mount_with_resource_templates():
"""Test mounting with resource templates."""
# Create apps
main_app = FastMCP("MainApp")
user_app = FastMCP("UserApp")
# Add a resource template to the user app
@user_app.resource(uri="users://{user_id}/profile")
def get_user_profile(user_id: str) -> dict:
return {"id": user_id, "name": f"User {user_id}"}
# Mount the user app
main_app.mount("api", user_app)
# Verify the template was imported with the prefix
assert "api+users://{user_id}/profile" in main_app._resource_manager._templates
async def test_mount_with_prompts():
class TestPrompts:
"""Test mounting with prompts."""
# Create apps
main_app = FastMCP("MainApp")
assistant_app = FastMCP("AssistantApp")
# Add a prompt to the assistant app
@assistant_app.prompt()
def greeting(name: str) -> str:
return f"Hello, {name}!"
# Mount the assistant app
main_app.mount("assistant", assistant_app)
# Verify the prompt was imported with the prefix
assert "assistant_greeting" in main_app._prompt_manager._prompts
async def test_mount_multiple_resource_templates():
"""Test mounting multiple apps with resource templates."""
# Create apps
main_app = FastMCP("MainApp")
weather_app = FastMCP("WeatherApp")
news_app = FastMCP("NewsApp")
# Add templates to each app
@weather_app.resource(uri="weather://{city}")
def get_weather(city: str) -> str:
return f"Weather for {city}"
@news_app.resource(uri="news://{category}")
def get_news(category: str) -> str:
return f"News for {category}"
# Mount both apps
main_app.mount("data", weather_app)
main_app.mount("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
async def test_mount_multiple_prompts():
"""Test mounting multiple apps with prompts."""
# Create apps
main_app = FastMCP("MainApp")
python_app = FastMCP("PythonApp")
sql_app = FastMCP("SQLApp")
# Add prompts to each app
@python_app.prompt()
def review_python(code: str) -> str:
return f"Reviewing Python code:\n{code}"
@sql_app.prompt()
def explain_sql(query: str) -> str:
return f"Explaining SQL query:\n{query}"
# Mount both apps
main_app.mount("python", python_app)
main_app.mount("sql", sql_app)
# Verify prompts were imported with correct prefixes
assert "python_review_python" in main_app._prompt_manager._prompts
assert "sql_explain_sql" in main_app._prompt_manager._prompts
async def test_mount_lifespan():
"""Test that the lifespan of a mounted app is properly handled."""
# Create apps
lifespan_checkpoints = []
@contextlib.asynccontextmanager
async def lifespan(app: FastMCP):
lifespan_checkpoints.append(f"enter {app.name}")
try:
yield
finally:
lifespan_checkpoints.append(f"exit {app.name}")
main_app = FastMCP("MainApp", lifespan=lifespan)
sub_app = FastMCP("SubApp", lifespan=lifespan)
sub_app_2 = FastMCP("SubApp2", lifespan=lifespan)
main_app.mount("sub", sub_app)
main_app.mount("sub2", sub_app_2)
low_level_server = main_app._mcp_server
async with contextlib.AsyncExitStack() as stack:
# Note: this imitates the way that lifespans are entered for mounted
# apps It is presently difficult to stop a running server
# programmatically without error in order to test the exit conditions,
# so this is the next best thing
await stack.enter_async_context(low_level_server.lifespan(low_level_server))
assert lifespan_checkpoints == [
"enter MainApp",
"enter SubApp",
"enter SubApp2",
]
assert lifespan_checkpoints == [
"enter MainApp",
"enter SubApp",
"enter SubApp2",
"exit SubApp2",
"exit SubApp",
"exit MainApp",
]
async def test_tool_custom_name_preserved_when_mounted():
"""Test that a tool's custom name is preserved when mounted."""
main_app = FastMCP("MainApp")
api_app = FastMCP("APIApp")
def fetch_data(query: str) -> str:
return f"Data for query: {query}"
api_app.add_tool(fetch_data, name="get_data")
main_app.mount("api", api_app)
# Check that the tool is accessible by its prefixed name
tool = main_app._tool_manager.get_tool("api_get_data")
assert tool is not None
# Check that the function name is preserved
assert tool.fn.__name__ == "fetch_data"
async def test_call_mounted_custom_named_tool():
"""Test calling a mounted tool with a custom name."""
main_app = FastMCP("MainApp")
api_app = FastMCP("APIApp")
def fetch_data(query: str) -> str:
return f"Data for query: {query}"
api_app.add_tool(fetch_data, name="get_data")
main_app.mount("api", api_app)
context = main_app.get_context()
result = await main_app._tool_manager.call_tool(
"api_get_data", {"query": "test"}, context=context
)
assert result == "Data for query: test"
async def test_first_level_mounting_with_custom_name():
"""Test that a tool with a custom name is correctly mounted at the first level."""
service_app = FastMCP("ServiceApp")
provider_app = FastMCP("ProviderApp")
def calculate_value(input: int) -> int:
return input * 2
provider_app.add_tool(calculate_value, name="compute")
service_app.mount("provider", provider_app)
# Tool is accessible in the service app with the first prefix
tool = service_app._tool_manager.get_tool("provider_compute")
assert tool is not None
assert tool.fn.__name__ == "calculate_value"
async def test_nested_mounting_preserves_prefixes():
"""Test that mounting a previously mounted app preserves prefixes."""
main_app = FastMCP("MainApp")
service_app = FastMCP("ServiceApp")
provider_app = FastMCP("ProviderApp")
def calculate_value(input: int) -> int:
return input * 2
provider_app.add_tool(calculate_value, name="compute")
service_app.mount("provider", provider_app)
main_app.mount("service", service_app)
# Tool is accessible in the main app with both prefixes
tool = main_app._tool_manager.get_tool("service_provider_compute")
assert tool is not None
async def test_call_nested_mounted_tool():
"""Test calling a tool through multiple levels of mounting."""
main_app = FastMCP("MainApp")
service_app = FastMCP("ServiceApp")
provider_app = FastMCP("ProviderApp")
def calculate_value(input: int) -> int:
return input * 2
provider_app.add_tool(calculate_value, name="compute")
service_app.mount("provider", provider_app)
main_app.mount("service", service_app)
result = await main_app._tool_manager.call_tool(
"service_provider_compute", {"input": 21}
)
assert result == 42
async def test_mount_with_proxy_tools():
"""
Test mounting with tools that have custom names (proxy tools).
This tests that the tool's name doesn't change even though the registered
name does, which is important because we need to forward that name to the
proxy server correctly.
"""
# Create apps
main_app = FastMCP("MainApp")
api_app = FastMCP("APIApp")
@api_app.tool()
def get_data(query: str) -> str:
return f"Data for query: {query}"
main_app.mount("api", await FastMCP.as_proxy(api_app))
result = await main_app.call_tool("api_get_data", {"query": "test"})
assert isinstance(result[0], TextContent)
assert result[0].text == "Data for query: test"
async def test_mount_with_proxy_prompts():
"""
Test mounting with prompts that have custom keys.
This tests that the prompt's name doesn't change even though the registered
key does, which is important for correct rendering.
"""
# Create apps
main_app = FastMCP("MainApp")
api_app = FastMCP("APIApp")
@api_app.prompt()
def greeting(name: str) -> str:
return f"Hello, {name} from API!"
main_app.mount("api", await FastMCP.as_proxy(api_app))
result = await main_app.get_prompt("api_greeting", {"name": "World"})
assert len(result) > 0
assert isinstance(result[0].content, TextContent)
assert result[0].content.text == "Hello, World from API!"
async def test_mount_with_proxy_resources():
"""
Test mounting with resources that have custom keys.
This tests that the resource's name doesn't change even though the registered
key does, which is important for correct access.
"""
# Create apps
main_app = FastMCP("MainApp")
api_app = FastMCP("APIApp")
# Create a resource in the API app
@api_app.resource(uri="config://settings")
def get_config():
return {
"api_key": "12345",
"base_url": "https://api.example.com",
}
main_app.mount("api", await FastMCP.as_proxy(api_app))
# Access the resource through the main app with the prefixed key
resource = await main_app.read_resource("api+config://settings")
assert resource is not None
resource = json.loads(resource)
assert resource["api_key"] == "12345"
assert resource["base_url"] == "https://api.example.com"
async def test_mount_with_proxy_resource_templates():
"""
Test mounting with resource templates that have custom keys.
This tests that the template's name doesn't change even though the registered
key does, which is important for correct instantiation.
"""
# Create apps
main_app = FastMCP("MainApp")
api_app = FastMCP("APIApp")
# Create a resource template in the API app
@api_app.resource(uri="user://{name}/{email}")
def create_user(name: str, email: str):
return {"name": name, "email": email}
main_app.mount("api", await FastMCP.as_proxy(api_app))
# Instantiate the template through the main app with the prefixed key
quoted_name = quote("John Doe", safe="")
quoted_email = quote("john@example.com", safe="")
user = await main_app.read_resource(f"api+user://{quoted_name}/{quoted_email}")
assert user is not None
user = json.loads(user)
assert user["name"] == "John Doe"
assert user["email"] == "john@example.com"
async def test_mount_with_prompts(self):
"""Test mounting a server with prompts."""
main_app = FastMCP("MainApp")
assistant_app = FastMCP("AssistantApp")
@assistant_app.prompt()
def greeting(name: str) -> str:
return f"Hello, {name}!"
# Mount the assistant app
main_app.mount("assistant", assistant_app)
# Prompt should be accessible through main app
prompts = await main_app.get_prompts()
assert "assistant_greeting" in prompts
# Render the prompt
result = await main_app._mcp_get_prompt("assistant_greeting", {"name": "World"})
assert result.messages is not None
# The message should contain our greeting text
async def test_adding_prompt_after_mounting(self):
"""Test adding a prompt after mounting."""
main_app = FastMCP("MainApp")
assistant_app = FastMCP("AssistantApp")
# Mount the assistant app before adding prompts
main_app.mount("assistant", assistant_app)
# Add a prompt after mounting
@assistant_app.prompt()
def farewell(name: str) -> str:
return f"Goodbye, {name}!"
# Prompt should be accessible through main app
prompts = await main_app.get_prompts()
assert "assistant_farewell" in prompts
# Render the prompt
result = await main_app._mcp_get_prompt("assistant_farewell", {"name": "World"})
assert result.messages is not None
# The message should contain our farewell text
class TestProxyServer:
"""Test mounting a proxy server."""
async def test_mount_proxy_server(self):
"""Test mounting a proxy server."""
# Create original server
original_server = FastMCP("OriginalServer")
@original_server.tool()
def get_data(query: str) -> str:
return f"Data for {query}"
# Create proxy server
proxy_server = FastMCP.from_client(
Client(transport=FastMCPTransport(original_server))
)
# Mount proxy server
main_app = FastMCP("MainApp")
main_app.mount("proxy", proxy_server)
# Tool should be accessible through main app
tools = await main_app.get_tools()
assert "proxy_get_data" in tools
# Call the tool
result = await main_app._mcp_call_tool("proxy_get_data", {"query": "test"})
assert isinstance(result[0], TextContent)
assert result[0].text == "Data for test"
async def test_dynamically_adding_to_proxied_server(self):
"""Test that changes to the original server are reflected in the mounted proxy."""
# Create original server
original_server = FastMCP("OriginalServer")
# Create proxy server
proxy_server = FastMCP.from_client(
Client(transport=FastMCPTransport(original_server))
)
# Mount proxy server
main_app = FastMCP("MainApp")
main_app.mount("proxy", proxy_server)
# Add a tool to the original server
@original_server.tool()
def dynamic_data() -> str:
return "Dynamic data"
# Tool should be accessible through main app via proxy
tools = await main_app.get_tools()
assert "proxy_dynamic_data" in tools
# Call the tool
result = await main_app._mcp_call_tool("proxy_dynamic_data", {})
assert isinstance(result[0], TextContent)
assert result[0].text == "Dynamic data"
async def test_proxy_server_with_resources(self):
"""Test mounting a proxy server with resources."""
# Create original server
original_server = FastMCP("OriginalServer")
@original_server.resource(uri="config://settings")
def get_config():
return {"api_key": "12345"}
# Create proxy server
proxy_server = FastMCP.from_client(
Client(transport=FastMCPTransport(original_server))
)
# Mount proxy server
main_app = FastMCP("MainApp")
main_app.mount("proxy", proxy_server)
# Resource should be accessible through main app
result = await main_app._mcp_read_resource("proxy+config://settings")
assert isinstance(result[0], ReadResourceContents)
config = json.loads(result[0].content)
assert config["api_key"] == "12345"
async def test_proxy_server_with_prompts(self):
"""Test mounting a proxy server with prompts."""
# Create original server
original_server = FastMCP("OriginalServer")
@original_server.prompt()
def welcome(name: str) -> str:
return f"Welcome, {name}!"
# Create proxy server
proxy_server = FastMCP.from_client(
Client(transport=FastMCPTransport(original_server))
)
# Mount proxy server
main_app = FastMCP("MainApp")
main_app.mount("proxy", proxy_server)
# Prompt should be accessible through main app
result = await main_app._mcp_get_prompt("proxy_welcome", {"name": "World"})
assert result.messages is not None
# The message should contain our welcome text

View file

@ -1,3 +1,4 @@
import json
import re
import httpx
@ -5,10 +6,12 @@ import pytest
from dirty_equals import IsStr
from fastapi import FastAPI, HTTPException
from httpx import ASGITransport, AsyncClient
from mcp.types import TextContent
from pydantic import BaseModel, TypeAdapter
from pydantic.networks import AnyUrl
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.server.openapi import FastMCPOpenAPI
@ -153,13 +156,19 @@ class TestTools:
"""
The tool created by the OpenAPI server should be the same as the original
"""
tool_response = await fastmcp_openapi_server.call_tool(
tool_response = await fastmcp_openapi_server._mcp_call_tool(
"create_user_users_post", {"name": "David", "active": False}
)
assert tool_response == User(id=4, name="David", active=False)
# Convert TextContent to dict for comparison
assert isinstance(tool_response, list) and len(tool_response) == 1
assert isinstance(tool_response[0], TextContent)
response_data = json.loads(tool_response[0].text)
expected_user = User(id=4, name="David", active=False).model_dump()
assert response_data == expected_user
# Check that the user was created via API
response = await api_client.get("/users")
assert len(response.json()) == 4
@ -168,7 +177,7 @@ class TestTools:
"resource://openapi/get_user_users__user_id__get/4"
)
user = user_response[0].content
assert user == tool_response.model_dump()
assert user == expected_user
async def test_call_update_user_name_tool(
self, fastmcp_openapi_server: FastMCPOpenAPI, api_client
@ -176,21 +185,28 @@ class TestTools:
"""
The tool created by the OpenAPI server should be the same as the original
"""
tool_response = await fastmcp_openapi_server.call_tool(
tool_response = await fastmcp_openapi_server._mcp_call_tool(
"update_user_name_users__user_id__name_patch", {"user_id": 1, "name": "XYZ"}
)
assert tool_response == dict(id=1, name="XYZ", active=True)
# Convert TextContent to dict for comparison
assert isinstance(tool_response, list) and len(tool_response) == 1
assert isinstance(tool_response[0], TextContent)
response_data = json.loads(tool_response[0].text)
expected_data = dict(id=1, name="XYZ", active=True)
assert response_data == expected_data
# Check that the user was updated via API
response = await api_client.get("/users")
assert dict(id=1, name="XYZ", active=True) in response.json()
assert expected_data in response.json()
# Check that the user was updated via MCP
user_response = await fastmcp_openapi_server._mcp_read_resource(
"resource://openapi/get_user_users__user_id__get/1"
)
user = user_response[0].content
assert user == tool_response
assert user == expected_data
class TestResources:
@ -308,7 +324,9 @@ class TestTagTransfer:
):
"""Test that tags from OpenAPI routes are correctly transferred to Resources."""
# Get internal resources directly
resources = fastmcp_openapi_server._resource_manager.list_resources()
resources = list(
fastmcp_openapi_server._resource_manager.get_resources().values()
)
# Find the get_users resource
get_users_resource = next(
@ -327,7 +345,9 @@ class TestTagTransfer:
):
"""Test that tags from OpenAPI routes are correctly transferred to ResourceTemplates."""
# Get internal resource templates directly
templates = fastmcp_openapi_server._resource_manager.list_templates()
templates = list(
fastmcp_openapi_server._resource_manager.get_templates().values()
)
# Find the get_user template
get_user_template = next(
@ -346,7 +366,9 @@ class TestTagTransfer:
):
"""Test that tags are preserved when creating resources from templates."""
# Get internal resource templates directly
templates = fastmcp_openapi_server._resource_manager.list_templates()
templates = list(
fastmcp_openapi_server._resource_manager.get_templates().values()
)
# Find the get_user template
get_user_template = next(
@ -513,12 +535,17 @@ class TestOpenAPI30Compatibility:
async def test_tool_execution(self, openapi_30_server):
"""Test executing a tool from an OpenAPI 3.0 server."""
tool_response = await openapi_30_server.call_tool(
"createProduct", {"name": "New Product", "price": 39.99}
)
assert tool_response["id"] == "p3"
assert tool_response["name"] == "New Product"
assert tool_response["price"] == 39.99
async with Client(openapi_30_server) as client:
result = await client.call_tool(
"createProduct", {"name": "New Product", "price": 39.99}
)
# Result should be a text content
assert len(result) == 1
assert isinstance(result[0], TextContent)
product = json.loads(result[0].text)
assert product["id"] == "p3"
assert product["name"] == "New Product"
assert product["price"] == 39.99
class TestOpenAPI31Compatibility:
@ -679,12 +706,17 @@ class TestOpenAPI31Compatibility:
async def test_tool_execution(self, openapi_31_server):
"""Test executing a tool from an OpenAPI 3.1 server."""
tool_response = await openapi_31_server.call_tool(
"createOrder", {"customer": "Charlie", "items": ["item4", "item5"]}
)
assert tool_response["id"] == "o3"
assert tool_response["customer"] == "Charlie"
assert tool_response["items"] == ["item4", "item5"]
async with Client(openapi_31_server) as client:
result = await client.call_tool(
"createOrder", {"customer": "Charlie", "items": ["item4", "item5"]}
)
# Result should be a text content
assert len(result) == 1
assert isinstance(result[0], TextContent)
order = json.loads(result[0].text)
assert order["id"] == "o3"
assert order["customer"] == "Charlie"
assert order["items"] == ["item4", "item5"]
class TestMountFastMCP:
@ -694,7 +726,7 @@ class TestMountFastMCP:
"""Test mounting an OpenAPI server."""
mcp = FastMCP("MainApp")
mcp.mount("fastapi", fastmcp_openapi_server)
await mcp.import_server("fastapi", fastmcp_openapi_server)
# Check that resources are available with prefixed URIs
resources = await mcp._mcp_list_resources()

View file

@ -1,13 +1,14 @@
import json
from typing import Any
import mcp.types
import pytest
from dirty_equals import Contains
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.client.transports import FastMCPTransport
from fastmcp.exceptions import ResourceError
from fastmcp.exceptions import ClientError
from fastmcp.server.proxy import FastMCPProxy
USERS = [
@ -64,7 +65,7 @@ def fastmcp_server():
@pytest.fixture
async def proxy_server(fastmcp_server):
"""Fixture that creates a FastMCP proxy server."""
return await FastMCP.as_proxy(Client(transport=FastMCPTransport(fastmcp_server)))
return FastMCP.from_client(Client(transport=FastMCPTransport(fastmcp_server)))
async def test_create_proxy(fastmcp_server):
@ -72,7 +73,7 @@ async def test_create_proxy(fastmcp_server):
# Create a client
client = Client(transport=FastMCPTransport(fastmcp_server))
server = await FastMCPProxy.from_client(client)
server = FastMCPProxy.from_client(client)
assert isinstance(server, FastMCPProxy)
assert isinstance(server, FastMCP)
@ -80,9 +81,11 @@ async def test_create_proxy(fastmcp_server):
class TestTools:
async def test_list_tools(self, proxy_server):
tools = proxy_server.list_tools()
assert [t.name for t in tools] == Contains("greet", "add", "error_tool")
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
async def test_list_tools_same_as_original(self, fastmcp_server, proxy_server):
assert (
@ -93,25 +96,28 @@ class TestTools:
async def test_call_tool_result_same_as_original(
self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy
):
result = await fastmcp_server.call_tool("greet", {"name": "Alice"})
proxy_result = await proxy_server.call_tool("greet", {"name": "Alice"})
result = await fastmcp_server._mcp_call_tool("greet", {"name": "Alice"})
proxy_result = await proxy_server._mcp_call_tool("greet", {"name": "Alice"})
assert result == proxy_result
async def test_call_tool_calls_tool(self, proxy_server):
proxy_result = await proxy_server.call_tool("add", {"a": 1, "b": 2})
async with Client(proxy_server) as client:
proxy_result = await client.call_tool("add", {"a": 1, "b": 2})
assert isinstance(proxy_result[0], mcp.types.TextContent)
assert proxy_result[0].text == "3"
async def test_error_tool_raises_error(self, proxy_server):
with pytest.raises(ValueError, match="This is a test error"):
await proxy_server.call_tool("error_tool", {})
with pytest.raises(ClientError, match=""):
async with Client(proxy_server) as client:
await client.call_tool("error_tool", {})
class TestResources:
async def test_list_resources(self, proxy_server):
resources = proxy_server.list_resources()
assert [r.name for r in resources] == Contains(
async def test_get_resources(self, proxy_server):
resources = await proxy_server.get_resources()
assert [r.name for r in resources.values()] == Contains(
"data://users", "resource://wave"
)
@ -122,29 +128,36 @@ class TestResources:
)
async def test_read_resource(self, proxy_server: FastMCPProxy):
result = await proxy_server.read_resource("resource://wave")
assert result == "👋"
async with Client(proxy_server) as client:
result = await client.read_resource("resource://wave")
assert isinstance(result[0], mcp.types.TextResourceContents)
assert result[0].text == "👋"
async def test_read_resource_same_as_original(self, fastmcp_server, proxy_server):
result = await fastmcp_server.read_resource("resource://wave")
proxy_result = await proxy_server.read_resource("resource://wave")
async with Client(fastmcp_server) as client:
result = await client.read_resource("resource://wave")
async with Client(proxy_server) as client:
proxy_result = await client.read_resource("resource://wave")
assert proxy_result == result
async def test_read_json_resource(self, proxy_server: FastMCPProxy):
result = await proxy_server.read_resource("data://users")
assert json.loads(result) == USERS
async with Client(proxy_server) as client:
result = await client.read_resource("data://users")
assert isinstance(result[0], mcp.types.TextResourceContents)
assert json.loads(result[0].text) == USERS
async def test_read_resource_returns_none_if_not_found(self, proxy_server):
with pytest.raises(
ResourceError, match="Unknown resource: resource://nonexistent"
ClientError, match="Unknown resource: resource://nonexistent"
):
await proxy_server.read_resource("resource://nonexistent")
async with Client(proxy_server) as client:
await client.read_resource("resource://nonexistent")
class TestResourceTemplates:
async def test_list_resource_templates(self, proxy_server):
templates = proxy_server.list_resource_templates()
assert [t.name for t in templates] == Contains("get_user")
async def test_get_resource_templates(self, proxy_server):
templates = await proxy_server.get_resource_templates()
assert [t.name for t in templates.values()] == Contains("get_user")
async def test_list_resource_templates_same_as_original(
self, fastmcp_server, proxy_server
@ -155,35 +168,46 @@ class TestResourceTemplates:
@pytest.mark.parametrize("id", [1, 2, 3])
async def test_read_resource_template(self, proxy_server: FastMCPProxy, id: int):
result = await proxy_server.read_resource(f"data://user/{id}")
assert json.loads(result) == USERS[id - 1]
async with Client(proxy_server) as client:
result = await client.read_resource(f"data://user/{id}")
assert isinstance(result[0], mcp.types.TextResourceContents)
assert json.loads(result[0].text) == USERS[id - 1]
async def test_read_resource_template_same_as_original(
self, fastmcp_server, proxy_server
):
result = await fastmcp_server.read_resource("data://user/1")
proxy_result = await proxy_server.read_resource("data://user/1")
async with Client(fastmcp_server) as client:
result = await client.read_resource("data://user/1")
async with Client(proxy_server) as client:
proxy_result = await client.read_resource("data://user/1")
assert proxy_result == result
class TestPrompts:
async def test_list_prompts(self, proxy_server):
prompts = proxy_server.list_prompts()
assert [p.name for p in prompts] == Contains("welcome")
async def test_get_prompts_server_method(self, proxy_server: FastMCPProxy):
prompts = await proxy_server.get_prompts()
assert [p.name for p in prompts.values()] == Contains("welcome")
async def test_list_prompts_same_as_original(self, fastmcp_server, proxy_server):
assert (
await proxy_server._mcp_list_prompts()
== await fastmcp_server._mcp_list_prompts()
)
async with Client(fastmcp_server) as client:
result = await client.list_prompts()
async with Client(proxy_server) as client:
proxy_result = await client.list_prompts()
assert proxy_result == result
async def test_render_prompt_same_as_original(
self, fastmcp_server: FastMCP, proxy_server
self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy
):
result = await fastmcp_server.get_prompt("welcome", {"name": "Alice"})
proxy_result = await proxy_server.get_prompt("welcome", {"name": "Alice"})
async with Client(fastmcp_server) as client:
result = await client.get_prompt("welcome", {"name": "Alice"})
async with Client(proxy_server) as client:
proxy_result = await client.get_prompt("welcome", {"name": "Alice"})
assert proxy_result == result
async def test_render_prompt_calls_prompt(self, proxy_server):
result = await proxy_server.get_prompt("welcome", {"name": "Alice"})
async with Client(proxy_server) as client:
result = await client.get_prompt("welcome", {"name": "Alice"})
assert isinstance(result[0], mcp.types.PromptMessage)
assert result[0].role == "user"
assert isinstance(result[0].content, mcp.types.TextContent)
assert result[0].content.text == "Welcome to FastMCP, Alice!"

View file

@ -93,6 +93,6 @@
# class TestRunServerSSE:
# @pytest.mark.anyio
#
# async def test_run_server_sse(self, fastmcp_server: FastMCP):
# pass

View file

@ -4,7 +4,6 @@ from pathlib import Path
from typing import TYPE_CHECKING
import pytest
from mcp.shared.exceptions import McpError
from mcp.types import (
BlobResourceContents,
ImageContent,
@ -14,7 +13,7 @@ from mcp.types import (
from pydantic import AnyUrl, Field
from fastmcp import Client, Context, FastMCP
from fastmcp.exceptions import ResourceError, ToolError
from fastmcp.exceptions import ClientError, NotFoundError, ToolError
from fastmcp.prompts.prompt import EmbeddedResource, Message, UserMessage
from fastmcp.resources import FileResource, FunctionResource
from fastmcp.utilities.types import Image
@ -57,12 +56,40 @@ class TestCreateServer:
assert "¡Hola, 世界! 👋" == content.text
class TestTools:
async def test_mcp_tool_name(self):
"""Test MCPTool name for add_tool (key != tool.name)."""
mcp = FastMCP()
@mcp.tool()
def fn(x: int) -> int:
return x + 1
mcp_tools = await mcp._mcp_list_tools()
assert len(mcp_tools) == 1
assert mcp_tools[0].name == "fn"
async def test_mcp_tool_custom_name(self):
"""Test MCPTool name for add_tool (key != tool.name)."""
mcp = FastMCP()
@mcp.tool(name="custom_name")
def fn(x: int) -> int:
return x + 1
mcp_tools = await mcp._mcp_list_tools()
assert len(mcp_tools) == 1
assert mcp_tools[0].name == "custom_name"
class TestToolDecorator:
async def test_no_tools_before_decorator(self):
mcp = FastMCP()
with pytest.raises(ToolError, match="Unknown tool: add"):
await mcp.call_tool("add", {"x": 1, "y": 2})
with pytest.raises(NotFoundError, match="Unknown tool: add"):
await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
async def test_tool_decorator(self):
mcp = FastMCP()
@ -71,7 +98,7 @@ class TestToolDecorator:
def add(x: int, y: int) -> int:
return x + y
result = await mcp.call_tool("add", {"x": 1, "y": 2})
result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
@ -91,7 +118,7 @@ class TestToolDecorator:
def add(x: int, y: int) -> int:
return x + y
result = await mcp.call_tool("custom-add", {"x": 1, "y": 2})
result = await mcp._mcp_call_tool("custom-add", {"x": 1, "y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
@ -120,7 +147,7 @@ class TestToolDecorator:
obj = MyClass(10)
mcp.add_tool(obj.add)
result = await mcp.call_tool("add", {"y": 2})
result = await mcp._mcp_call_tool("add", {"y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "12"
@ -135,7 +162,7 @@ class TestToolDecorator:
return cls.x + y
mcp.add_tool(MyClass.add)
result = await mcp.call_tool("add", {"y": 2})
result = await mcp._mcp_call_tool("add", {"y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "12"
@ -148,7 +175,7 @@ class TestToolDecorator:
def add(x: int, y: int) -> int:
return x + y
result = await mcp.call_tool("add", {"x": 1, "y": 2})
result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
@ -159,7 +186,7 @@ class TestToolDecorator:
async def add(x: int, y: int) -> int:
return x + y
result = await mcp.call_tool("add", {"x": 1, "y": 2})
result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
@ -174,7 +201,7 @@ class TestToolDecorator:
return cls.x + y
mcp.add_tool(MyClass.add)
result = await mcp.call_tool("add", {"y": 2})
result = await mcp._mcp_call_tool("add", {"y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "12"
@ -187,7 +214,7 @@ class TestToolDecorator:
return x + y
mcp.add_tool(MyClass.add)
result = await mcp.call_tool("add", {"x": 1, "y": 2})
result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
@ -212,29 +239,28 @@ class TestToolDecorator:
"""Multiply two numbers."""
return a * b
# Add the tool with a custom name
mcp.add_tool(multiply, name="custom_multiply")
# Check that the tool is registered with the custom name
tools = mcp.list_tools()
tool_names = [t.name for t in tools]
assert "custom_multiply" in tool_names
tools = await mcp.get_tools()
assert "custom_multiply" in tools
# Call the tool by its custom name
result = await mcp.call_tool("custom_multiply", {"a": 5, "b": 3})
result = await mcp._mcp_call_tool("custom_multiply", {"a": 5, "b": 3})
assert isinstance(result[0], TextContent)
assert result[0].text == "15"
# Original name should not be registered
assert "multiply" not in tool_names
assert "multiply" not in tools
class TestResourceDecorator:
async def test_no_resources_before_decorator(self):
mcp = FastMCP()
with pytest.raises(ResourceError, match="Unknown resource"):
await mcp.read_resource("resource://data")
with pytest.raises(ClientError, match="Unknown resource"):
async with Client(mcp) as client:
await client.read_resource("resource://data")
async def test_resource_decorator(self):
mcp = FastMCP()
@ -243,8 +269,10 @@ class TestResourceDecorator:
def get_data() -> str:
return "Hello, world!"
result = await mcp.read_resource("resource://data")
assert result == "Hello, world!"
async with Client(mcp) as client:
result = await client.read_resource("resource://data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Hello, world!"
async def test_resource_decorator_incorrect_usage(self):
mcp = FastMCP()
@ -264,12 +292,15 @@ class TestResourceDecorator:
def get_data() -> str:
return "Hello, world!"
resources = mcp.list_resources()
resources_dict = await mcp.get_resources()
resources = list(resources_dict.values())
assert len(resources) == 1
assert resources[0].name == "custom-data"
result = await mcp.read_resource("resource://data")
assert result == "Hello, world!"
async with Client(mcp) as client:
result = await client.read_resource("resource://data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Hello, world!"
async def test_resource_decorator_with_description(self):
mcp = FastMCP()
@ -278,10 +309,24 @@ class TestResourceDecorator:
def get_data() -> str:
return "Hello, world!"
resources = mcp.list_resources()
resources_dict = await mcp.get_resources()
resources = list(resources_dict.values())
assert len(resources) == 1
assert resources[0].description == "Data resource"
async def test_resource_decorator_with_tags(self):
"""Test that the resource decorator properly sets tags."""
mcp = FastMCP()
@mcp.resource("resource://data", tags={"example", "test-tag"})
def get_data() -> str:
return "Hello, world!"
resources_dict = await mcp.get_resources()
resources = list(resources_dict.values())
assert len(resources) == 1
assert resources[0].tags == {"example", "test-tag"}
async def test_resource_decorator_instance_method(self):
mcp = FastMCP()
@ -297,8 +342,10 @@ class TestResourceDecorator:
obj.get_data, uri="resource://data", name="instance-resource"
)
result = await mcp.read_resource("resource://data")
assert result == "My prefix: Hello, world!"
async with Client(mcp) as client:
result = await client.read_resource("resource://data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "My prefix: Hello, world!"
async def test_resource_decorator_classmethod(self):
mcp = FastMCP()
@ -314,8 +361,10 @@ class TestResourceDecorator:
MyClass.get_data, uri="resource://data", name="class-resource"
)
result = await mcp.read_resource("resource://data")
assert result == "Class prefix: Hello, world!"
async with Client(mcp) as client:
result = await client.read_resource("resource://data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Class prefix: Hello, world!"
async def test_resource_decorator_staticmethod(self):
mcp = FastMCP()
@ -326,8 +375,10 @@ class TestResourceDecorator:
def get_data() -> str:
return "Static Hello, world!"
result = await mcp.read_resource("resource://data")
assert result == "Static Hello, world!"
async with Client(mcp) as client:
result = await client.read_resource("resource://data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Static Hello, world!"
async def test_resource_decorator_async_function(self):
mcp = FastMCP()
@ -336,19 +387,10 @@ class TestResourceDecorator:
async def get_data() -> str:
return "Async Hello, world!"
result = await mcp.read_resource("resource://data")
assert result == "Async Hello, world!"
async def test_resource_decorator_with_tags(self):
mcp = FastMCP()
@mcp.resource("resource://data", tags={"example", "test-tag"})
def get_data() -> str:
return "Hello, world!"
resources = mcp.list_resources()
assert len(resources) == 1
assert resources[0].tags == {"example", "test-tag"}
async with Client(mcp) as client:
result = await client.read_resource("resource://data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Async Hello, world!"
class TestTemplateDecorator:
@ -359,12 +401,16 @@ class TestTemplateDecorator:
def get_data(name: str) -> str:
return f"Data for {name}"
templates = mcp.list_resource_templates()
templates_dict = await mcp.get_resource_templates()
templates = list(templates_dict.values())
assert len(templates) == 1
assert templates[0].name == "get_data"
assert templates[0].uri_template == "resource://{name}/data"
result = await mcp.read_resource("resource://test/data")
assert result == "Data for test"
async with Client(mcp) as client:
result = await client.read_resource("resource://test/data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Data for test"
async def test_template_decorator_incorrect_usage(self):
mcp = FastMCP()
@ -384,12 +430,15 @@ class TestTemplateDecorator:
def get_data(name: str) -> str:
return f"Data for {name}"
templates = mcp.list_resource_templates()
templates_dict = await mcp.get_resource_templates()
templates = list(templates_dict.values())
assert len(templates) == 1
assert templates[0].name == "custom-template"
result = await mcp.read_resource("resource://test/data")
assert result == "Data for test"
async with Client(mcp) as client:
result = await client.read_resource("resource://test/data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Data for test"
async def test_template_decorator_with_description(self):
mcp = FastMCP()
@ -398,7 +447,8 @@ class TestTemplateDecorator:
def get_data(name: str) -> str:
return f"Data for {name}"
templates = mcp.list_resource_templates()
templates_dict = await mcp.get_resource_templates()
templates = list(templates_dict.values())
assert len(templates) == 1
assert templates[0].description == "Template description"
@ -413,13 +463,14 @@ class TestTemplateDecorator:
return f"{self.prefix} Data for {name}"
obj = MyClass("My prefix:")
mcp.add_resource_fn(
obj.get_data, uri="resource://{name}/data", name="instance-template"
)
result = await mcp.read_resource("resource://test/data")
assert result == "My prefix: Data for test"
async with Client(mcp) as client:
result = await client.read_resource("resource://test/data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "My prefix: Data for test"
async def test_template_decorator_classmethod(self):
mcp = FastMCP()
@ -432,11 +483,15 @@ class TestTemplateDecorator:
return f"{cls.prefix} Data for {name}"
mcp.add_resource_fn(
MyClass.get_data, uri="resource://{name}/data", name="class-template"
MyClass.get_data,
uri="resource://{name}/data",
name="class-template",
)
result = await mcp.read_resource("resource://test/data")
assert result == "Class prefix: Data for test"
async with Client(mcp) as client:
result = await client.read_resource("resource://test/data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Class prefix: Data for test"
async def test_template_decorator_staticmethod(self):
mcp = FastMCP()
@ -447,8 +502,10 @@ class TestTemplateDecorator:
def get_data(name: str) -> str:
return f"Static Data for {name}"
result = await mcp.read_resource("resource://test/data")
assert result == "Static Data for test"
async with Client(mcp) as client:
result = await client.read_resource("resource://test/data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Static Data for test"
async def test_template_decorator_async_function(self):
mcp = FastMCP()
@ -457,19 +514,22 @@ class TestTemplateDecorator:
async def get_data(name: str) -> str:
return f"Async Data for {name}"
result = await mcp.read_resource("resource://test/data")
assert result == "Async Data for test"
async with Client(mcp) as client:
result = await client.read_resource("resource://test/data")
assert isinstance(result[0], TextResourceContents)
assert result[0].text == "Async Data for test"
async def test_template_decorator_with_tags(self):
"""Test that the template decorator properly sets tags."""
mcp = FastMCP()
@mcp.resource("resource://{name}/data", tags={"template", "test-tag"})
def get_data(name: str) -> str:
return f"Data for {name}"
@mcp.resource("resource://{param}", tags={"template", "test-tag"})
def template_resource(param: str) -> str:
return f"Template resource: {param}"
templates = mcp.list_resource_templates()
assert len(templates) == 1
assert templates[0].tags == {"template", "test-tag"}
templates_dict = await mcp.get_resource_templates()
template = templates_dict["resource://{param}"]
assert template.tags == {"template", "test-tag"}
class TestPromptDecorator:
@ -477,18 +537,17 @@ class TestPromptDecorator:
mcp = FastMCP()
@mcp.prompt()
def test_prompt() -> str:
def fn() -> str:
return "Hello, world!"
prompts = mcp.list_prompts()
assert len(prompts) == 1
assert prompts[0].name == "test_prompt"
result = await mcp.get_prompt("test_prompt")
assert len(result) == 1
message = result[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "Hello, world!"
prompts_dict = await mcp.get_prompts()
assert len(prompts_dict) == 1
prompt = prompts_dict["fn"]
assert prompt.name == "fn"
# Don't compare functions directly since validate_call wraps them
content = await prompt.render()
assert isinstance(content[0].content, TextContent)
assert content[0].content.text == "Hello, world!"
async def test_prompt_decorator_incorrect_usage(self):
mcp = FastMCP()
@ -498,36 +557,38 @@ class TestPromptDecorator:
):
@mcp.prompt # Missing parentheses #type: ignore
def test_prompt() -> str:
def fn() -> str:
return "Hello, world!"
async def test_prompt_decorator_with_name(self):
mcp = FastMCP()
@mcp.prompt(name="custom-prompt")
def test_prompt() -> str:
@mcp.prompt(name="custom_name")
def fn() -> str:
return "Hello, world!"
prompts = mcp.list_prompts()
assert len(prompts) == 1
assert prompts[0].name == "custom-prompt"
result = await mcp.get_prompt("custom-prompt")
assert len(result) == 1
message = result[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "Hello, world!"
prompts_dict = await mcp.get_prompts()
assert len(prompts_dict) == 1
prompt = prompts_dict["custom_name"]
assert prompt.name == "custom_name"
content = await prompt.render()
assert isinstance(content[0].content, TextContent)
assert content[0].content.text == "Hello, world!"
async def test_prompt_decorator_with_description(self):
mcp = FastMCP()
@mcp.prompt(description="Test prompt description")
def test_prompt() -> str:
@mcp.prompt(description="A custom description")
def fn() -> str:
return "Hello, world!"
prompts = mcp.list_prompts()
assert len(prompts) == 1
assert prompts[0].description == "Test prompt description"
prompts_dict = await mcp.get_prompts()
assert len(prompts_dict) == 1
prompt = prompts_dict["fn"]
assert prompt.description == "A custom description"
content = await prompt.render()
assert isinstance(content[0].content, TextContent)
assert content[0].content.text == "Hello, world!"
async def test_prompt_decorator_with_parameters(self):
mcp = FastMCP()
@ -536,28 +597,30 @@ class TestPromptDecorator:
def test_prompt(name: str, greeting: str = "Hello") -> str:
return f"{greeting}, {name}!"
prompts = mcp.list_prompts()
assert len(prompts) == 1
assert prompts[0].arguments is not None
assert len(prompts[0].arguments) == 2
assert prompts[0].arguments[0].name == "name"
assert prompts[0].arguments[0].required is True
assert prompts[0].arguments[1].name == "greeting"
assert prompts[0].arguments[1].required is False
prompts_dict = await mcp.get_prompts()
assert len(prompts_dict) == 1
prompt = prompts_dict["test_prompt"]
assert prompt.arguments is not None
assert len(prompt.arguments) == 2
assert prompt.arguments[0].name == "name"
assert prompt.arguments[0].required is True
assert prompt.arguments[1].name == "greeting"
assert prompt.arguments[1].required is False
result = await mcp.get_prompt("test_prompt", {"name": "World"})
assert len(result) == 1
message = result[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "Hello, World!"
async with Client(mcp) as client:
result = await client.get_prompt("test_prompt", {"name": "World"})
assert len(result) == 1
message = result[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "Hello, World!"
result = await mcp.get_prompt(
"test_prompt", {"name": "World", "greeting": "Hi"}
)
assert len(result) == 1
message = result[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "Hi, World!"
result = await client.get_prompt(
"test_prompt", {"name": "World", "greeting": "Hi"}
)
assert len(result) == 1
message = result[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "Hi, World!"
async def test_prompt_decorator_instance_method(self):
mcp = FastMCP()
@ -572,11 +635,12 @@ class TestPromptDecorator:
obj = MyClass("My prefix:")
mcp.add_prompt(obj.test_prompt, name="test_prompt")
result = await mcp.get_prompt("test_prompt")
assert len(result) == 1
message = result[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "My prefix: Hello, world!"
async with Client(mcp) as client:
result = await client.get_prompt("test_prompt")
assert len(result) == 1
message = result[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "My prefix: Hello, world!"
async def test_prompt_decorator_classmethod(self):
mcp = FastMCP()
@ -590,11 +654,12 @@ class TestPromptDecorator:
mcp.add_prompt(MyClass.test_prompt, name="test_prompt")
result = await mcp.get_prompt("test_prompt")
assert len(result) == 1
message = result[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "Class prefix: Hello, world!"
async with Client(mcp) as client:
result = await client.get_prompt("test_prompt")
assert len(result) == 1
message = result[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "Class prefix: Hello, world!"
async def test_prompt_decorator_staticmethod(self):
mcp = FastMCP()
@ -605,11 +670,12 @@ class TestPromptDecorator:
def test_prompt() -> str:
return "Static Hello, world!"
result = await mcp.get_prompt("test_prompt")
assert len(result) == 1
message = result[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "Static Hello, world!"
async with Client(mcp) as client:
result = await client.get_prompt("test_prompt")
assert len(result) == 1
message = result[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "Static Hello, world!"
async def test_prompt_decorator_async_function(self):
mcp = FastMCP()
@ -618,22 +684,25 @@ class TestPromptDecorator:
async def test_prompt() -> str:
return "Async Hello, world!"
result = await mcp.get_prompt("test_prompt")
assert len(result) == 1
message = result[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "Async Hello, world!"
async with Client(mcp) as client:
result = await client.get_prompt("test_prompt")
assert len(result) == 1
message = result[0]
assert isinstance(message.content, TextContent)
assert message.content.text == "Async Hello, world!"
async def test_prompt_decorator_with_tags(self):
"""Test that the prompt decorator properly sets tags."""
mcp = FastMCP()
@mcp.prompt(tags={"example", "test-tag"})
def test_prompt() -> str:
def sample_prompt() -> str:
return "Hello, world!"
prompts = mcp.list_prompts()
assert len(prompts) == 1
assert prompts[0].tags == {"example", "test-tag"}
prompts_dict = await mcp.get_prompts()
assert len(prompts_dict) == 1
prompt = prompts_dict["sample_prompt"]
assert prompt.tags == {"example", "test-tag"}
@pytest.fixture
@ -683,7 +752,7 @@ class TestServerTools:
assert len(await tool_server._mcp_list_tools()) == 6
async def test_call_tool(self, tool_server: FastMCP):
result = await tool_server.call_tool("add", {"x": 1, "y": 2})
result = await tool_server._mcp_call_tool("add", {"x": 1, "y": 2})
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
@ -695,7 +764,7 @@ class TestServerTools:
async def test_call_tool_error(self, tool_server: FastMCP):
with pytest.raises(ToolError):
await tool_server.call_tool("error_tool", {})
await tool_server._mcp_call_tool("error_tool", {})
async def test_call_tool_error_as_client(self, tool_server: FastMCP):
async with Client(tool_server) as client:
@ -710,7 +779,7 @@ class TestServerTools:
assert "Test error" in result.content[0].text
async def test_tool_returns_list(self, tool_server: FastMCP):
result = await tool_server.call_tool("list_tool", {})
result = await tool_server._mcp_call_tool("list_tool", {})
assert isinstance(result[0], TextContent)
assert result[0].text == '["x", 2]'
@ -719,7 +788,9 @@ class TestServerTools:
image_path = tmp_path / "test.png"
image_path.write_bytes(b"fake png data")
result = await tool_server.call_tool("image_tool", {"path": str(image_path)})
result = await tool_server._mcp_call_tool(
"image_tool", {"path": str(image_path)}
)
content = result[0]
assert isinstance(content, ImageContent)
assert content.type == "image"
@ -729,7 +800,7 @@ class TestServerTools:
assert decoded == b"fake png data"
async def test_tool_mixed_content(self, tool_server: FastMCP):
result = await tool_server.call_tool("mixed_content_tool", {})
result = await tool_server._mcp_call_tool("mixed_content_tool", {})
assert len(result) == 2
content1 = result[0]
content2 = result[1]
@ -748,7 +819,7 @@ class TestServerTools:
image_path = tmp_path / "test.png"
image_path.write_bytes(b"test image data")
result = await tool_server.call_tool(
result = await tool_server._mcp_call_tool(
"mixed_list_fn", {"image_path": str(image_path)}
)
assert len(result) == 3
@ -966,8 +1037,7 @@ class TestServerResourceTemplates:
assert result[0].text == "Static data"
async def test_template_with_default_params(self):
"""Test that a template with default function parameters works when those parameters
are not in the URI template"""
"""Test that a template can have default parameters."""
mcp = FastMCP()
@mcp.resource("math://add/{x}")
@ -975,7 +1045,8 @@ class TestServerResourceTemplates:
return x + y
# Verify it's registered as a template
templates = mcp.list_resource_templates()
templates_dict = await mcp.get_resource_templates()
templates = list(templates_dict.values())
assert len(templates) == 1
assert templates[0].uri_template == "math://add/{x}"
@ -992,16 +1063,18 @@ class TestServerResourceTemplates:
assert result == "17" # 7 + default 10
async def test_template_to_resource_conversion(self):
"""Test that templates are properly converted to resources when accessed"""
"""Test that a template can be converted to a resource."""
mcp = FastMCP()
@mcp.resource("resource://{name}/data")
def get_data(name: str) -> str:
return f"Data for {name}"
# Should be registered as a template
assert len(mcp._resource_manager._templates) == 1
assert len(await mcp._mcp_list_resources()) == 0
# Verify it's registered as a template
templates_dict = await mcp.get_resource_templates()
templates = list(templates_dict.values())
assert len(templates) == 1
assert templates[0].uri_template == "resource://{name}/data"
# When accessed, should create a concrete resource
resource = await mcp._resource_manager.get_resource("resource://test/data")
@ -1010,30 +1083,32 @@ class TestServerResourceTemplates:
assert result == "Data for test"
async def test_stacked_resource_template_decorators(self):
"""Test that multiple resource decorators can be stacked on the same function."""
"""Test that resource template decorators can be stacked."""
mcp = FastMCP()
# Define a function with multiple stacked resource decorators
@mcp.resource("users://email/{email}")
@mcp.resource("users://name/{name}")
def lookup_user(name: str | None = None, email: str | None = None) -> dict:
"""Look up a user by either name or email."""
# In a real implementation, this would query a database
if email:
if name:
return {
"found_by": "email",
"name": f"User for {email}",
"id": "123",
"name": name,
"email": "dummy@example.com",
"lookup": "name",
}
elif email:
return {
"id": "123",
"name": "Test User",
"email": email,
"lookup": "email",
}
else:
return {
"found_by": "name",
"name": name,
"email": f"{name.lower()}@example.com" if name else None,
}
raise ValueError("Either name or email must be provided")
# Verify both templates are registered
templates = mcp.list_resource_templates()
templates_dict = await mcp.get_resource_templates()
templates = list(templates_dict.values())
assert len(templates) == 2
template_uris = {t.uri_template for t in templates}
assert "users://email/{email}" in template_uris
@ -1046,16 +1121,27 @@ class TestServerResourceTemplates:
)
assert isinstance(email_result[0], TextResourceContents)
email_data = json.loads(email_result[0].text)
assert email_data["found_by"] == "email"
assert email_data["lookup"] == "email"
assert email_data["email"] == "user@example.com"
# Test lookup by name
name_result = await client.read_resource(AnyUrl("users://name/John"))
assert isinstance(name_result[0], TextResourceContents)
name_data = json.loads(name_result[0].text)
assert name_data["found_by"] == "name"
assert name_data["lookup"] == "name"
assert name_data["name"] == "John"
assert name_data["email"] == "john@example.com"
assert name_data["email"] == "dummy@example.com"
async def test_template_decorator_with_tags(self):
mcp = FastMCP()
@mcp.resource("resource://{param}", tags={"template", "test-tag"})
def template_resource(param: str) -> str:
return f"Template resource: {param}"
templates_dict = await mcp.get_resource_templates()
template = templates_dict["resource://{param}"]
assert template.tags == {"template", "test-tag"}
class TestContextInjection:
@ -1192,11 +1278,12 @@ class TestServerPrompts:
def fn() -> str:
return "Hello, world!"
prompts = mcp._prompt_manager.list_prompts()
assert len(prompts) == 1
assert prompts[0].name == "fn"
prompts_dict = await mcp.get_prompts()
assert len(prompts_dict) == 1
prompt = prompts_dict["fn"]
assert prompt.name == "fn"
# Don't compare functions directly since validate_call wraps them
content = await prompts[0].render()
content = await prompt.render()
assert isinstance(content[0].content, TextContent)
assert content[0].content.text == "Hello, world!"
@ -1208,10 +1295,11 @@ class TestServerPrompts:
def fn() -> str:
return "Hello, world!"
prompts = mcp._prompt_manager.list_prompts()
assert len(prompts) == 1
assert prompts[0].name == "custom_name"
content = await prompts[0].render()
prompts_dict = await mcp.get_prompts()
assert len(prompts_dict) == 1
prompt = prompts_dict["custom_name"]
assert prompt.name == "custom_name"
content = await prompt.render()
assert isinstance(content[0].content, TextContent)
assert content[0].content.text == "Hello, world!"
@ -1223,10 +1311,11 @@ class TestServerPrompts:
def fn() -> str:
return "Hello, world!"
prompts = mcp._prompt_manager.list_prompts()
assert len(prompts) == 1
assert prompts[0].description == "A custom description"
content = await prompts[0].render()
prompts_dict = await mcp.get_prompts()
assert len(prompts_dict) == 1
prompt = prompts_dict["fn"]
assert prompt.description == "A custom description"
content = await prompt.render()
assert isinstance(content[0].content, TextContent)
assert content[0].content.text == "Hello, world!"
@ -1245,20 +1334,22 @@ class TestServerPrompts:
@mcp.prompt()
def fn(name: str, optional: str = "default") -> str:
return f"Hello, {name}!"
return f"Hello, {name}! {optional}"
prompts_dict = await mcp.get_prompts()
assert len(prompts_dict) == 1
async with Client(mcp) as client:
result = await client.list_prompts()
assert result is not None
assert len(result) == 1
prompt = result[0]
assert prompt.name == "fn"
assert prompt.arguments is not None
assert len(prompt.arguments) == 2
assert prompt.arguments[0].name == "name"
assert prompt.arguments[0].required is True
assert prompt.arguments[1].name == "optional"
assert prompt.arguments[1].required is False
prompts = await client.list_prompts()
assert len(prompts) == 1
assert prompts[0].name == "fn"
assert prompts[0].description is None
assert prompts[0].arguments is not None
assert len(prompts[0].arguments) == 2
assert prompts[0].arguments[0].name == "name"
assert prompts[0].arguments[0].required is True
assert prompts[0].arguments[1].name == "optional"
assert prompts[0].arguments[1].required is False
async def test_get_prompt(self):
"""Test getting a prompt through MCP protocol."""
@ -1270,8 +1361,8 @@ class TestServerPrompts:
async with Client(mcp) as client:
result = await client.get_prompt("fn", {"name": "World"})
assert len(result.messages) == 1
message = result.messages[0]
assert len(result) == 1
message = result[0]
assert message.role == "user"
content = message.content
assert isinstance(content, TextContent)
@ -1296,8 +1387,8 @@ class TestServerPrompts:
async with Client(mcp) as client:
result = await client.get_prompt("fn")
assert result.messages[0].role == "user"
content = result.messages[0].content
assert result[0].role == "user"
content = result[0].content
assert isinstance(content, EmbeddedResource)
resource = content.resource
assert isinstance(resource, TextResourceContents)
@ -1307,8 +1398,8 @@ class TestServerPrompts:
async def test_get_unknown_prompt(self):
"""Test error when getting unknown prompt."""
mcp = FastMCP()
async with Client(mcp) as client:
with pytest.raises(McpError, match="Unknown prompt"):
with pytest.raises(ClientError, match="Unknown prompt"):
async with Client(mcp) as client:
await client.get_prompt("unknown")
async def test_get_prompt_missing_args(self):
@ -1319,8 +1410,8 @@ class TestServerPrompts:
def prompt_fn(name: str) -> str:
return f"Hello, {name}!"
async with Client(mcp) as client:
with pytest.raises(McpError, match="Missing required arguments"):
with pytest.raises(ClientError, match="Missing required arguments"):
async with Client(mcp) as client:
await client.get_prompt("prompt_fn")
async def test_tool_decorator_with_tags(self):
@ -1337,15 +1428,15 @@ class TestServerPrompts:
assert tools[0].tags == {"example", "test-tag"}
async def test_resource_decorator_with_tags(self):
"""Test that the resource decorator properly sets tags."""
"""Test that the resource decorator supports tags."""
mcp = FastMCP()
@mcp.resource("resource://sample", tags={"example", "test-tag"})
def sample_resource() -> str:
return "Sample resource"
@mcp.resource("resource://data", tags={"example", "test-tag"})
def get_data() -> str:
return "Hello, world!"
# Verify the tags were set correctly
resources = mcp._resource_manager.list_resources()
resources_dict = await mcp.get_resources()
resources = list(resources_dict.values())
assert len(resources) == 1
assert resources[0].tags == {"example", "test-tag"}
@ -1357,9 +1448,9 @@ class TestServerPrompts:
def template_resource(param: str) -> str:
return f"Template resource: {param}"
templates = mcp._resource_manager.list_templates()
assert len(templates) == 1
assert templates[0].tags == {"template", "test-tag"}
templates_dict = await mcp.get_resource_templates()
template = templates_dict["resource://{param}"]
assert template.tags == {"template", "test-tag"}
async def test_prompt_decorator_with_tags(self):
"""Test that the prompt decorator properly sets tags."""
@ -1367,9 +1458,9 @@ class TestServerPrompts:
@mcp.prompt(tags={"example", "test-tag"})
def sample_prompt() -> str:
return "Sample prompt"
return "Hello, world!"
# Verify the tags were set correctly
prompts = mcp._prompt_manager.list_prompts()
assert len(prompts) == 1
assert prompts[0].tags == {"example", "test-tag"}
prompts_dict = await mcp.get_prompts()
assert len(prompts_dict) == 1
prompt = prompts_dict["sample_prompt"]
assert prompt.tags == {"example", "test-tag"}

View file

@ -4,7 +4,7 @@ import logging
import pytest
from pydantic import BaseModel
from fastmcp.exceptions import ToolError
from fastmcp.exceptions import NotFoundError, ToolError
from fastmcp.tools import ToolManager
from fastmcp.tools.tool import Tool
@ -235,23 +235,6 @@ class TestToolTags:
assert len(utility_tools) == 2
assert {tool.name for tool in utility_tools} == {"string_tool", "mixed_tool"}
def test_import_tools_preserves_tags(self):
"""Test that importing tools preserves their tags."""
def tagged_tool(x: int) -> int:
"""A tool with tags."""
return x
source_manager = ToolManager()
source_manager.add_tool_from_fn(tagged_tool, tags={"test", "example"})
target_manager = ToolManager()
target_manager.import_tools(source_manager, "source/")
imported_tool = target_manager.get_tool("source/tagged_tool")
assert imported_tool is not None
assert imported_tool.tags == {"test", "example"}
class TestCallTools:
async def test_call_tool(self):
@ -262,7 +245,13 @@ class TestCallTools:
manager = ToolManager()
manager.add_tool_from_fn(add)
result = await manager.call_tool("add", {"a": 1, "b": 2})
assert result == 3
assert isinstance(result, list)
assert len(result) == 1
from mcp.types import TextContent
assert isinstance(result[0], TextContent)
assert result[0].text == "3"
assert json.loads(result[0].text) == 3
async def test_call_async_tool(self):
async def double(n: int) -> int:
@ -272,7 +261,13 @@ class TestCallTools:
manager = ToolManager()
manager.add_tool_from_fn(double)
result = await manager.call_tool("double", {"n": 5})
assert result == 10
assert isinstance(result, list)
assert len(result) == 1
from mcp.types import TextContent
assert isinstance(result[0], TextContent)
assert result[0].text == "10"
assert json.loads(result[0].text) == 10
async def test_call_tool_with_default_args(self):
def add(a: int, b: int = 1) -> int:
@ -282,7 +277,13 @@ class TestCallTools:
manager = ToolManager()
manager.add_tool_from_fn(add)
result = await manager.call_tool("add", {"a": 1})
assert result == 2
assert isinstance(result, list)
assert len(result) == 1
from mcp.types import TextContent
assert isinstance(result[0], TextContent)
assert result[0].text == "2"
assert json.loads(result[0].text) == 2
async def test_call_tool_with_missing_args(self):
def add(a: int, b: int) -> int:
@ -296,7 +297,7 @@ class TestCallTools:
async def test_call_unknown_tool(self):
manager = ToolManager()
with pytest.raises(ToolError):
with pytest.raises(NotFoundError, match="Unknown tool: unknown"):
await manager.call_tool("unknown", {"a": 1})
async def test_call_tool_with_list_int_input(self):
@ -306,10 +307,21 @@ class TestCallTools:
manager = ToolManager()
manager.add_tool_from_fn(sum_vals)
# Try both with plain list and with JSON list
from mcp.types import TextContent
result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"})
assert result == 6
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "6"
assert json.loads(result[0].text) == 6
result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]})
assert result == 6
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "6"
assert json.loads(result[0].text) == 6
async def test_call_tool_with_list_str_or_str_input(self):
def concat_strs(vals: list[str] | str) -> str:
@ -317,17 +329,36 @@ class TestCallTools:
manager = ToolManager()
manager.add_tool_from_fn(concat_strs)
from mcp.types import TextContent
# Try both with plain python object and with JSON list
result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]})
assert result == "abc"
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "abc"
result = await manager.call_tool("concat_strs", {"vals": '["a", "b", "c"]'})
assert result == "abc"
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "abc"
result = await manager.call_tool("concat_strs", {"vals": "a"})
assert result == "a"
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "a"
result = await manager.call_tool("concat_strs", {"vals": '"a"'})
assert result == '"a"'
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == '"a"'
async def test_call_tool_with_complex_model(self):
from mcp.types import TextContent
from fastmcp import Context
class MyShrimpTank(BaseModel):
@ -342,16 +373,26 @@ class TestCallTools:
manager = ToolManager()
manager.add_tool_from_fn(name_shrimp)
result = await manager.call_tool(
"name_shrimp",
{"tank": {"x": None, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}},
)
assert result == ["rex", "gertrude"]
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == '["rex", "gertrude"]'
assert json.loads(result[0].text) == ["rex", "gertrude"]
result = await manager.call_tool(
"name_shrimp",
{"tank": '{"x": null, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}'},
)
assert result == ["rex", "gertrude"]
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == '["rex", "gertrude"]'
assert json.loads(result[0].text) == ["rex", "gertrude"]
class TestToolSchema:
@ -391,6 +432,8 @@ class TestContextHandling:
async def test_context_injection(self):
"""Test that context is properly injected during tool execution."""
from mcp.types import TextContent
from fastmcp import Context, FastMCP
def tool_with_context(x: int, ctx: Context) -> str:
@ -403,10 +446,15 @@ class TestContextHandling:
mcp = FastMCP()
ctx = mcp.get_context()
result = await manager.call_tool("tool_with_context", {"x": 42}, context=ctx)
assert result == "42"
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "42"
async def test_context_injection_async(self):
"""Test that context is properly injected in async tools."""
from mcp.types import TextContent
from fastmcp import Context, FastMCP
async def async_tool(x: int, ctx: Context) -> str:
@ -419,10 +467,15 @@ class TestContextHandling:
mcp = FastMCP()
ctx = mcp.get_context()
result = await manager.call_tool("async_tool", {"x": 42}, context=ctx)
assert result == "42"
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "42"
async def test_context_optional(self):
"""Test that context is optional when calling tools."""
from mcp.types import TextContent
from fastmcp import Context
def tool_with_context(x: int, ctx: Context | None = None) -> str:
@ -432,7 +485,10 @@ class TestContextHandling:
manager.add_tool_from_fn(tool_with_context)
# Should not raise an error when context is not provided
result = await manager.call_tool("tool_with_context", {"x": 42})
assert result == "42"
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "42"
async def test_context_error_handling(self):
"""Test error handling when context injection fails."""
@ -450,112 +506,6 @@ class TestContextHandling:
await manager.call_tool("tool_with_context", {"x": 42}, context=ctx)
class TestImportTools:
def test_import_tools(self):
"""Test importing tools from one manager to another with a prefix."""
# Setup source manager with tools
source_manager = ToolManager()
# Create some test tools
def tool1_fn():
return "Tool 1 result"
def tool2_fn():
return "Tool 2 result"
# Add tools to source manager
source_manager.add_tool_from_fn(
tool1_fn, name="get_data", description="Get some data"
)
source_manager.add_tool_from_fn(
tool2_fn, name="process_data", description="Process the data"
)
# Create target manager
target_manager = ToolManager()
# Import tools from source to target
prefix = "source/"
target_manager.import_tools(source_manager, prefix)
# Verify tools were imported with prefixes
assert "source/get_data" in target_manager._tools
assert "source/process_data" in target_manager._tools
# Verify the original tools still exist in source manager
assert "get_data" in source_manager._tools
assert "process_data" in source_manager._tools
# Verify the imported tools have the correct descriptions
assert target_manager._tools["source/get_data"].description == "Get some data"
assert (
target_manager._tools["source/process_data"].description
== "Process the data"
)
# Verify the tool functions were properly copied
# We can't directly compare functions, so we'll check their __name__ attribute
assert target_manager._tools["source/get_data"].fn == tool1_fn
assert target_manager._tools["source/process_data"].fn == tool2_fn
def test_tool_duplicate_behavior(self):
"""Test the behavior when importing tools with duplicate names."""
# Setup source and target managers
source_manager = ToolManager()
target_manager = ToolManager()
# Add the same tool name to both managers
def source_fn():
return "Source result"
def target_fn():
return "Target result"
source_manager.add_tool_from_fn(source_fn, name="common_tool")
target_manager.add_tool_from_fn(
target_fn, name="source/common_tool"
) # Pre-create with the prefixed name
# Import tools from source to target
target_manager.import_tools(source_manager, "source/")
# The original tool in the target manager is replaced by the imported one
assert target_manager._tools["source/common_tool"].fn == source_fn
def test_import_tools_with_multiple_prefixes(self):
"""Test importing tools from multiple managers with different prefixes."""
# Setup source managers
weather_manager = ToolManager()
news_manager = ToolManager()
# Add tools to source managers
def forecast_fn():
return "Weather forecast"
def headlines_fn():
return "News headlines"
weather_manager.add_tool_from_fn(forecast_fn, name="forecast")
news_manager.add_tool_from_fn(headlines_fn, name="headlines")
# Create target manager and import from both sources
main_manager = ToolManager()
main_manager.import_tools(weather_manager, "weather/")
main_manager.import_tools(news_manager, "news/")
# Verify tools were imported with correct prefixes
assert "weather/forecast" in main_manager._tools
assert "news/headlines" in main_manager._tools
# Verify the tools are accessible and functioning
assert (
main_manager._tools["weather/forecast"].fn.__name__ == forecast_fn.__name__
)
assert (
main_manager._tools["news/headlines"].fn.__name__ == headlines_fn.__name__
)
class TestCustomToolNames:
"""Test adding tools with custom names that differ from their function names."""
@ -573,7 +523,8 @@ class TestCustomToolNames:
assert tool.name == "custom_name"
assert tool.fn.__name__ == "original_fn"
# The tool should not be accessible via its original function name
assert manager.get_tool("original_fn") is None
with pytest.raises(NotFoundError, match="Unknown tool: original_fn"):
manager.get_tool("original_fn")
def test_add_tool_object_with_custom_key(self):
"""Test adding a Tool object with a custom key using add_tool()."""
@ -592,10 +543,12 @@ class TestCustomToolNames:
# But the tool's .name is unchanged
assert stored.name == "my_tool"
# The tool is not accessible under its original name
assert manager.get_tool("my_tool") is None
with pytest.raises(NotFoundError, match="Unknown tool: my_tool"):
manager.get_tool("my_tool")
async def test_call_tool_with_custom_name(self):
"""Test calling a tool added with a custom name."""
from mcp.types import TextContent
def multiply(a: int, b: int) -> int:
"""Multiply two numbers."""
@ -606,59 +559,16 @@ class TestCustomToolNames:
# Tool should be callable by its custom name
result = await manager.call_tool("custom_multiply", {"a": 5, "b": 3})
assert result == 15
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "15"
assert json.loads(result[0].text) == 15
# Original name should not be registered
with pytest.raises(ToolError):
with pytest.raises(NotFoundError, match="Unknown tool: multiply"):
await manager.call_tool("multiply", {"a": 5, "b": 3})
def test_tool_to_mcp_tool(self):
"""Test that to_mcp_tool uses the key, not the internal name."""
def some_function(x: int) -> int:
return x
manager = ToolManager()
tool = Tool.from_function(some_function, name="api_function")
manager.add_tool(tool)
mcp_tools = manager.list_mcp_tools()
assert len(mcp_tools) == 1
assert mcp_tools[0].name == "api_function"
def test_tool_to_mcp_tool_with_custom_key(self):
"""Test that to_mcp_tool uses the key, not the internal name."""
def some_function(x: int) -> int:
return x
manager = ToolManager()
tool = Tool.from_function(some_function, name="api_function")
manager.add_tool(tool, key="custom-key")
# When listing tools for MCP, the key should be used
mcp_tools = manager.list_mcp_tools()
assert len(mcp_tools) == 1
assert mcp_tools[0].name == "custom-key"
def test_import_tools_with_custom_names(self):
"""Test importing tools with custom names."""
def source_fn(x: int) -> int:
return x * 2
# Create a source manager with a tool using custom name
source_manager = ToolManager()
source_manager.add_tool_from_fn(source_fn, name="custom_source")
# Import the tools to a target manager with a prefix
target_manager = ToolManager()
target_manager.import_tools(source_manager, "prefix/")
# The tool should be imported with the prefixed custom name
assert target_manager.get_tool("prefix/custom_source") is not None
assert target_manager.get_tool("prefix/source_fn") is None
def test_replace_tool_keeps_original_name(self):
"""Test that replacing a tool with "replace" keeps the original name."""
@ -688,28 +598,3 @@ class TestCustomToolNames:
# But the function is different
assert stored_tool.fn.__name__ == "replacement_fn"
def test_mcp_tool_name_for_add_tool(self):
"""Test MCPTool name for add_tool (key != tool.name)."""
def fn(x: int) -> int:
return x + 1
tool = Tool.from_function(fn, name="my_tool")
manager = ToolManager()
manager.add_tool(tool, key="proxy_tool")
mcp_tools = manager.list_mcp_tools()
assert len(mcp_tools) == 1
assert mcp_tools[0].name == "proxy_tool"
def test_mcp_tool_name_for_add_tool_from_fn(self):
"""Test MCPTool name for add_tool_from_fn (key == tool.name)."""
def fn(x: int) -> int:
return x + 1
manager = ToolManager()
manager.add_tool_from_fn(fn, name="custom_fn")
mcp_tools = manager.list_mcp_tools()
assert len(mcp_tools) == 1
assert mcp_tools[0].name == "custom_fn"

View file

@ -85,7 +85,6 @@ def complex_arguments_fn(
return "ok!"
@pytest.mark.anyio
async def test_complex_function_runtime_arg_validation_non_json():
"""Test that basic non-JSON arguments are validated correctly"""
meta = func_metadata(complex_arguments_fn)
@ -122,7 +121,6 @@ async def test_complex_function_runtime_arg_validation_non_json():
)
@pytest.mark.anyio
async def test_complex_function_runtime_arg_validation_with_json():
"""Test that JSON string arguments are parsed and validated correctly"""
meta = func_metadata(complex_arguments_fn)
@ -199,7 +197,6 @@ def test_skip_names():
assert model.also_keep == 2.5 # type: ignore
@pytest.mark.anyio
async def test_lambda_function():
"""Test lambda function schema and validation"""
fn = lambda x, y=5: x # noqa: E731

10
uv.lock generated
View file

@ -254,11 +254,12 @@ wheels = [
[[package]]
name = "fastmcp"
version = "2.1.2.dev9+55f3666"
version = "2.1.3.dev42+be2ccc6"
source = { editable = "." }
dependencies = [
{ name = "dotenv" },
{ name = "fastapi" },
{ name = "exceptiongroup" },
{ name = "httpx" },
{ name = "mcp" },
{ name = "openapi-pydantic" },
{ name = "rich" },
@ -270,6 +271,7 @@ dependencies = [
dev = [
{ name = "copychat" },
{ name = "dirty-equals" },
{ name = "fastapi" },
{ name = "ipython" },
{ name = "pdbpp" },
{ name = "pre-commit" },
@ -284,7 +286,8 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "dotenv", specifier = ">=0.9.9" },
{ name = "fastapi", specifier = ">=0.115.12" },
{ name = "exceptiongroup", specifier = ">=1.2.2" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "mcp", specifier = ">=1.6.0,<2.0.0" },
{ name = "openapi-pydantic", specifier = ">=0.5.1" },
{ name = "rich", specifier = ">=13.9.4" },
@ -296,6 +299,7 @@ requires-dist = [
dev = [
{ name = "copychat", specifier = ">=0.5.2" },
{ name = "dirty-equals", specifier = ">=0.9.0" },
{ name = "fastapi", specifier = ">=0.115.12" },
{ name = "ipython", specifier = ">=8.12.3" },
{ name = "pdbpp", specifier = ">=0.10.3" },
{ name = "pre-commit" },