Merge branch 'main' into mask-errors

This commit is contained in:
Jeremiah Lowin 2025-05-23 08:36:10 -04:00 committed by GitHub
commit 4e6b1bf611
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1196 additions and 661 deletions

View file

@ -37,7 +37,7 @@ Clients must be initialized with a `transport`. You can either provide an alread
The following inference rules are used to determine the appropriate `ClientTransport` based on the input type:
1. **`ClientTransport` Instance**: If you provide an already instantiated transport object, it's used directly.
2. **`FastMCP` Instance**: Creates a `FastMCPTransport` for efficient in-memory communication (ideal for testing).
2. **`FastMCP` Instance**: Creates a `FastMCPTransport` for efficient in-memory communication (ideal for testing). This also works with a **FastMCP 1.0 server** created via `mcp.server.fastmcp.FastMCP`.
3. **`Path` or `str` pointing to an existing file**:
* If it ends with `.py`: Creates a `PythonStdioTransport` to run the script using `python`.
* If it ends with `.js`: Creates a `NodeStdioTransport` to run the script using `node`.
@ -91,7 +91,7 @@ For more control over connection details (like headers for SSE, environment vari
### Multi-Server Clients
<VersionBadge version="2.3.6" />
<VersionBadge version="2.4.0" />
FastMCP supports creating clients that connect to multiple MCP servers through a single client interface using a standard MCP configuration format (`MCPConfig`). This configuration approach makes it easy to connect to multiple specialized servers or create composable systems with a simple, declarative syntax.

View file

@ -290,8 +290,8 @@ asyncio.run(main())
### FastMCP Transport
- **Class:** `fastmcp.client.transports.FastMCPTransport`
- **Inferred From:** An instance of `fastmcp.server.FastMCP`
- **Use Case:** Connecting directly to a `FastMCP` server instance in the same Python process
- **Inferred From:** An instance of `fastmcp.server.FastMCP` or a **FastMCP 1.0 server** (`mcp.server.fastmcp.FastMCP`)
- **Use Case:** Connecting directly to a FastMCP server instance in the same Python process
This is extremely useful for testing your FastMCP servers.
@ -323,7 +323,7 @@ Communication happens through efficient in-memory queues, making it very fast an
### MCPConfig Transport
<VersionBadge version="2.3.6" />
<VersionBadge version="2.4.0" />
- **Class:** `fastmcp.client.transports.MCPConfigTransport`
- **Inferred From:** An instance of `MCPConfig` or a dictionary matching the MCPConfig schema

View file

@ -50,6 +50,7 @@
"servers/resources",
"servers/prompts",
"servers/context",
"servers/openapi",
"servers/proxy",
"servers/composition"
]
@ -76,8 +77,6 @@
"pages": [
"patterns/decorating-methods",
"patterns/http-requests",
"patterns/openapi",
"patterns/fastapi",
"patterns/contrib",
"patterns/testing"
]

View file

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

View file

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

View file

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

View file

@ -35,7 +35,7 @@ The choice of importing or mounting depends on your use case and requirements.
FastMCP supports [MCP proxying](/patterns/proxy), which allows you to mirror a local or remote server in a local FastMCP instance. Proxies are fully compatible with both importing and mounting.
<VersionBadge version="2.3.6" />
<VersionBadge version="2.4.0" />
You can also create proxies from configuration dictionaries that follow the MCPConfig schema, which is useful for quickly connecting to one or more remote servers. See the [Proxy Servers documentation](/servers/proxy#configuration-based-proxies) for details on configuration-based proxying. Note that MCPConfig follows an emerging standard and its format may evolve over time.

View file

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

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

@ -0,0 +1,250 @@
---
title: OpenAPI Integration
sidebarTitle: OpenAPI Integration
description: Generate MCP servers from OpenAPI specs
icon: code-branch
---
import { VersionBadge } from '/snippets/version-badge.mdx'
<VersionBadge version="2.0.0" />
FastMCP can automatically generate an MCP server from an OpenAPI specification or FastAPI app. Users only need to provide an OpenAPI specification (3.0 or 3.1) and an API client, or their FastAPI app.
```python
import httpx
from fastmcp import FastMCP
# Create a client for your API
api_client = httpx.AsyncClient(base_url="https://api.example.com")
# Load your OpenAPI spec
spec = {...}
# Create an MCP server from your OpenAPI spec
mcp = FastMCP.from_openapi(openapi_spec=spec, client=api_client)
if __name__ == "__main__":
mcp.run()
```
## Route Mapping
<VersionBadge version="2.5.0" />
By default, OpenAPI routes are mapped to MCP components based on these rules:
| OpenAPI Route | Example |MCP Component |
| - | - | - |
| `GET` with path params | `GET /users/{id}` | Resource Template |
| `GET` without path params | `GET /stats` | Resource |
| `POST`, `PUT`, `PATCH`, `DELETE`, etc. | `POST /users` | Tool |
Internally, FastMCP uses a priority-ordered list of `RouteMap` objects to determine the component type for each route. Each `RouteMap` specifies:
- **Methods**: HTTP methods to match (e.g. `["GET", "POST"]` or `"*"` for all)
- **Pattern**: Regex pattern to match the route path (e.g. `r"^/users/.*"` or `r".*"` for all)
- **Tags**: A set of OpenAPI tags that must all be present. An empty set (`{}`) means no tag filtering, so the route matches regardless of its tags.
- **MCP type**: What MCP component type to create (the options are `TOOL`, `RESOURCE`, `RESOURCE_TEMPLATE`, `PROMPT`, or `EXCLUDE` to exclude the route from the MCP server)
Each OpenAPI route is matched against `RouteMap` objects in order, and the **first match wins** to determine the MCP component type. For example, here are the default route mappings, expressed as `RouteMap` objects in priority order:
```python
from fastmcp.server.openapi import RouteMap, MCPType
# Default route mappings
DEFAULT_ROUTE_MAPPINGS = [
# GET with path parameters -> ResourceTemplate
RouteMap(
methods=["GET"],
pattern=r".*\{.*\}.*",
tags={},
mcp_type=MCPType.RESOURCE_TEMPLATE
),
# GET without path parameters -> Resource
RouteMap(
methods=["GET"],
pattern=r".*",
tags={},
mcp_type=MCPType.RESOURCE
),
# All other methods -> Tool
RouteMap(
methods="*",
pattern=r".*",
tags={},
mcp_type=MCPType.TOOL
),
]
```
### Custom Route Maps
You can override the default behavior by providing custom route maps when creating your MCP server. Custom maps are processed **before** the default maps, so they take priority. Each OpenAPI route will be matched against your custom route maps in order, and the first match will determine the MCP component type (or exclusion!).
```python {1, 6-18}
from fastmcp.server.openapi import RouteMap, MCPType
mcp = FastMCP.from_openapi(
openapi_spec=spec,
client=api_client,
route_maps=[
# All GET analytics endpoints should be tools
RouteMap(
methods=["GET"],
pattern=r"^/analytics/.*",
mcp_type=MCPType.TOOL,
),
# Exclude all admin endpoints
RouteMap(
pattern=r"^/admin/.*",
mcp_type=MCPType.EXCLUDE,
)
]
)
```
### Treat All Routes as Tools
To treat all routes as tools, use `RouteMap(mcp_type=MCPType.TOOL)` as your only route map. It will match all routes and create a tool for each.
### Prevent Default Mappings
To prevent the default mappings from being applied, add a catch-all exclusion routemap at the end of your custom route maps: `RouteMap(mcp_type=MCPType.EXCLUDE)`. Since it will match all routes, it will exclude any that weren't match by your previous rules and short-circuit the default mappings.
### Tag-Based Routing
<VersionBadge version="2.5.0" />
To filter routes by OpenAPI tags, use `RouteMap(tags={...})`. The route must have ALL of the specified tags to be matched. If no tags are specified, all routes will be matched.
## Request Parameter Handling
FastMCP carefully handles different types of parameters in OpenAPI requests:
### Query Parameters
By default, FastMCP will only include query parameters that have non-empty values. Parameters with `None` values or empty strings (`""`) are automatically filtered out of requests. This ensures that API servers don't receive unnecessary empty parameters that might cause issues.
For example, if you call a tool with these parameters:
```python
await client.call_tool("search_products", {
"category": "electronics", # Will be included
"min_price": 100, # Will be included
"max_price": None, # Will be excluded
"brand": "", # Will be excluded
})
```
The resulting HTTP request will only include `category=electronics&min_price=100`.
### Path Parameters
For path parameters, which are typically required by REST APIs, FastMCP filters out `None` values and checks that all required path parameters are provided. If a required path parameter is missing or `None`, an error will be raised.
```python
# This will work
await client.call_tool("get_product", {"product_id": 123})
# This will raise ValueError: "Missing required path parameters: {'product_id'}"
await client.call_tool("get_product", {"product_id": None})
```
## Authorization
If your API requires authentication, set headers on the client before creating the MCP server.
```python
import httpx
from fastmcp import FastMCP
# Create a client with authentication
api_client = httpx.AsyncClient(
base_url="https://api.example.com",
headers={"Authorization": "Bearer YOUR_TOKEN"}
)
# Create an MCP server from your OpenAPI spec
mcp = FastMCP.from_openapi(openapi_spec=spec, client=api_client)
```
## Timeouts
You can set a timeout for all requests by providing a `timeout` parameter (in seconds):
```python
mcp = FastMCP.from_openapi(
openapi_spec=spec,
client=api_client,
timeout=30.0 # 30 second timeout
)
```
## FastAPI Integration
<VersionBadge version="2.0.0" />
FastMCP can automatically convert FastAPI applications into MCP servers by extracting their OpenAPI specifications. A special client will be created that uses an in-memory ASGI transport to avoid network calls to your FastAPI app. Note that the resulting MCP server is *not* a FastAPI app itself, but can be added to one (see [ASGI integration](/deployment/asgi)).
<Tip>
FastMCP does *not* include FastAPI as a dependency; you must install it separately to use this integration.
</Tip>
```python
from fastapi import FastAPI
from fastmcp import FastMCP
# A FastAPI app
app = FastAPI()
@app.get("/items", tags=["items"])
def list_items():
return [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}]
@app.get("/items/{item_id}", tags=["items", "detail"])
def get_item(item_id: int):
return {"id": item_id, "name": f"Item {item_id}"}
@app.post("/items", tags=["items", "create"])
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
```
### Configuration Options
**Timeout**: You can set a timeout for all API requests:
```python
# Set a 5 second timeout for all requests
mcp = FastMCP.from_fastapi(app=app, timeout=5.0)
```
**Route Mapping**: All the route mapping features (including tags) work with FastAPI apps:
```python
from fastmcp.server.openapi import RouteMap, MCPType
# Use tag-based routing with FastAPI
mcp = FastMCP.from_fastapi(
app=app,
route_maps=[
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags={"admin"}),
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.EXCLUDE, tags={"internal"}),
]
)
```
### Benefits
- **Leverage existing FastAPI apps** - No need to rewrite your API logic
- **Schema reuse** - FastAPI's Pydantic models and validation are inherited
- **Full feature support** - Works with FastAPI's authentication, dependencies, etc.
- **ASGI transport** - Direct communication without additional HTTP overhead

View file

@ -106,7 +106,7 @@ proxy = FastMCP.as_proxy(
### Configuration-Based Proxies
<VersionBadge version="2.3.6" />
<VersionBadge version="2.4.0" />
You can create a proxy directly from a configuration dictionary that follows the MCPConfig schema. This is useful for quickly setting up proxies to remote servers without manually configuring each connection detail.

141
examples/tags_example.py Normal file
View file

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

View file

@ -210,6 +210,23 @@ class Client:
result = await self.session.send_ping()
return isinstance(result, mcp.types.EmptyResult)
async def cancel(
self,
request_id: str | int,
reason: str | None = None,
) -> None:
"""Send a cancellation notification for an in-progress request."""
notification = mcp.types.ClientNotification(
mcp.types.CancelledNotification(
method="notifications/cancelled",
params=mcp.types.CancelledNotificationParams(
requestId=request_id,
reason=reason,
),
)
)
await self.session.send_notification(notification)
async def progress(
self,
progress_token: str | int,

View file

@ -19,6 +19,7 @@ from mcp.client.sse import sse_client
from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import streamablehttp_client
from mcp.client.websocket import websocket_client
from mcp.server.fastmcp import FastMCP as FastMCP1Server
from mcp.shared.memory import create_connected_server_and_client_session
from pydantic import AnyUrl
from typing_extensions import Unpack
@ -448,15 +449,21 @@ class NpxStdioTransport(StdioTransport):
class FastMCPTransport(ClientTransport):
"""
Special transport for in-memory connections to an MCP server.
"""In-memory transport for FastMCP servers.
This is particularly useful for testing or when client and server
are in the same process.
This transport connects directly to a FastMCP server instance in the same
Python process. It works with both FastMCP 2.x servers and FastMCP 1.0
servers from the low-level MCP SDK. This is particularly useful for unit
tests or scenarios where client and server run in the same runtime.
"""
def __init__(self, mcp: FastMCPServer):
self.server = mcp # Can be FastMCP or MCPServer
def __init__(self, mcp: FastMCPServer | FastMCP1Server):
"""Initialize a FastMCPTransport from a FastMCP server instance."""
# Accept both FastMCP 2.x and FastMCP 1.0 servers. Both expose a
# ``_mcp_server`` attribute pointing to the underlying MCP server
# implementation, so we can treat them identically.
self.server = mcp
@contextlib.asynccontextmanager
async def connect_session(
@ -528,8 +535,12 @@ class MCPConfigTransport(ClientTransport):
config = MCPConfig.from_dict(config)
self.config = config
# if there are no servers, raise an error
if len(self.config.mcpServers) == 0:
raise ValueError("No MCP servers defined in the config")
# if there's exactly one server, create a client for that server
if len(self.config.mcpServers) == 1:
elif len(self.config.mcpServers) == 1:
self.transport = list(self.config.mcpServers.values())[0].to_transport()
# otherwise create a composite client
@ -558,6 +569,7 @@ class MCPConfigTransport(ClientTransport):
def infer_transport(
transport: ClientTransport
| FastMCPServer
| FastMCP1Server
| AnyUrl
| Path
| MCPConfig
@ -573,7 +585,7 @@ def infer_transport(
The function supports these input types:
- ClientTransport: Used directly without modification
- FastMCPServer: Creates an in-memory FastMCPTransport
- FastMCPServer or FastMCP1Server: Creates an in-memory FastMCPTransport
- Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js)
- AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints)
- MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers
@ -610,8 +622,8 @@ def infer_transport(
if isinstance(transport, ClientTransport):
return transport
# the transport is a FastMCP server
elif isinstance(transport, FastMCPServer):
# the transport is a FastMCP server (2.x or 1.0)
elif isinstance(transport, FastMCPServer | FastMCP1Server):
inferred_transport = FastMCPTransport(mcp=transport)
# the transport is a path to a script

View file

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

View file

@ -241,6 +241,7 @@ def create_sse_app(
# Add custom routes with lowest precedence
if routes:
server_routes.extend(routes)
server_routes.extend(server._additional_http_routes)
# Add middleware
if middleware:
@ -359,6 +360,7 @@ def create_streamable_http_app(
# Add custom routes with lowest precedence
if routes:
server_routes.extend(routes)
server_routes.extend(server._additional_http_routes)
# Add middleware
if middleware:

View file

@ -5,8 +5,9 @@ from __future__ import annotations
import enum
import json
import re
import warnings
from collections.abc import Callable
from dataclasses import dataclass
from dataclasses import dataclass, field
from re import Pattern
from typing import TYPE_CHECKING, Any, Literal
@ -33,8 +34,32 @@ logger = get_logger(__name__)
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
class MCPType(enum.Enum):
"""Type of FastMCP component to create from a route.
Enum values:
TOOL: Convert the route to a callable Tool
RESOURCE: Convert the route to a Resource (typically GET endpoints)
RESOURCE_TEMPLATE: Convert the route to a ResourceTemplate (typically GET with path params)
PROMPT: Convert the route to a Prompt (not yet implemented)
EXCLUDE: Exclude the route from being converted to any MCP component
IGNORE: Deprecated, use EXCLUDE instead
"""
TOOL = "TOOL"
RESOURCE = "RESOURCE"
RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE"
PROMPT = "PROMPT"
EXCLUDE = "EXCLUDE"
# Keep RouteType as an alias to MCPType for backward compatibility
class RouteType(enum.Enum):
"""Type of FastMCP component to create from a route."""
"""
Deprecated: Use MCPType instead.
This enum is kept for backward compatibility and will be removed in a future version.
"""
TOOL = "TOOL"
RESOURCE = "RESOURCE"
@ -47,32 +72,71 @@ class RouteType(enum.Enum):
class RouteMap:
"""Mapping configuration for HTTP routes to FastMCP component types."""
methods: list[HttpMethod] | Literal["*"]
pattern: Pattern[str] | str
route_type: RouteType
methods: list[HttpMethod] | Literal["*"] = field(default="*")
pattern: Pattern[str] | str = field(default=r".*")
mcp_type: MCPType | None = field(default=None)
route_type: RouteType | MCPType | None = field(default=None)
tags: set[str] = field(default_factory=set)
def __post_init__(self):
"""Validate and process the route map after initialization."""
# Handle backward compatibility for route_type, deprecated in 2.5.0
if self.mcp_type is None and self.route_type is not None:
warnings.warn(
"The 'route_type' parameter is deprecated and will be removed in a future version. "
"Use 'mcp_type' instead with the appropriate MCPType value.",
DeprecationWarning,
stacklevel=2,
)
if isinstance(self.route_type, RouteType):
warnings.warn(
"The RouteType class is deprecated and will be removed in a future version. "
"Use MCPType instead.",
DeprecationWarning,
stacklevel=2,
)
# Check for the deprecated IGNORE value
if self.route_type == RouteType.IGNORE:
warnings.warn(
"RouteType.IGNORE is deprecated and will be removed in a future version. "
"Use MCPType.EXCLUDE instead.",
DeprecationWarning,
stacklevel=2,
)
# Convert from RouteType to MCPType if needed
if isinstance(self.route_type, RouteType):
route_type_name = self.route_type.name
if route_type_name == "IGNORE":
route_type_name = "EXCLUDE"
self.mcp_type = getattr(MCPType, route_type_name)
else:
self.mcp_type = self.route_type
elif self.mcp_type is None:
raise ValueError("`mcp_type` must be provided")
# Set route_type to match mcp_type for backward compatibility
if self.route_type is None:
self.route_type = self.mcp_type
# Default route mappings as a list, where order determines priority
DEFAULT_ROUTE_MAPPINGS = [
# GET requests with path parameters go to ResourceTemplate
RouteMap(
methods=["GET"], pattern=r".*\{.*\}.*", route_type=RouteType.RESOURCE_TEMPLATE
methods=["GET"], pattern=r".*\{.*\}.*", mcp_type=MCPType.RESOURCE_TEMPLATE
),
# GET requests without path parameters go to Resource
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE),
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
# All other HTTP methods go to Tool
RouteMap(
methods=["POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
pattern=r".*",
route_type=RouteType.TOOL,
),
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL),
]
def _determine_route_type(
route: openapi.HTTPRoute,
mappings: list[RouteMap],
) -> RouteType:
) -> MCPType:
"""
Determines the FastMCP component type based on the route and mappings.
@ -81,7 +145,7 @@ def _determine_route_type(
mappings: List of RouteMap objects in priority order
Returns:
RouteType for this route
MCPType for this route
"""
# Check mappings in priority order (first match wins)
for route_map in mappings:
@ -94,20 +158,24 @@ def _determine_route_type(
pattern_matches = re.search(route_map.pattern, route.path)
if pattern_matches:
# Check if tags match (if specified)
# If route_map.tags is empty, tags are not matched
# If route_map.tags is non-empty, all tags must be present in route.tags (AND condition)
if route_map.tags:
route_tags_set = set(route.tags or [])
if not route_map.tags.issubset(route_tags_set):
# Tags don't match, continue to next mapping
continue
# We know mcp_type is not None here due to post_init validation
assert route_map.mcp_type is not None
logger.debug(
f"Route {route.method} {route.path} matched mapping to {route_map.route_type.name}"
f"Route {route.method} {route.path} matched mapping to {route_map.mcp_type.name}"
)
return route_map.route_type
return route_map.mcp_type
# Default fallback
return RouteType.TOOL
# Placeholder function to provide function metadata
async def _openapi_passthrough(*args, **kwargs):
"""Placeholder function for OpenAPI endpoints."""
# This is kept for metadata generation purposes
pass
return MCPType.TOOL
class OpenAPITool(Tool):
@ -555,13 +623,13 @@ class FastMCPOpenAPI(FastMCP):
RouteMap(
methods=["GET", "POST", "PATCH"],
pattern=r".*/users/.*",
route_type=RouteType.RESOURCE_TEMPLATE
mcp_type=MCPType.RESOURCE_TEMPLATE
),
# Map all analytics endpoints to Tool
RouteMap(
methods=["GET"],
pattern=r".*/analytics/.*",
route_type=RouteType.TOOL
mcp_type=MCPType.TOOL
),
]
@ -599,6 +667,10 @@ class FastMCPOpenAPI(FastMCP):
self._client = client
self._timeout = timeout
# Keep track of names to detect collisions
self._used_names = {"tools": set(), "resources": set(), "templates": set()}
http_routes = openapi.parse_openapi_to_http_routes(openapi_spec)
# Process routes
@ -607,34 +679,99 @@ class FastMCPOpenAPI(FastMCP):
# Determine route type based on mappings or default rules
route_type = _determine_route_type(route, route_maps)
# Use operation_id if available, otherwise generate a name
operation_id = route.operation_id
if not operation_id:
# Generate operation ID from method and path
path_parts = route.path.strip("/").split("/")
path_name = "_".join(p for p in path_parts if not p.startswith("{"))
operation_id = f"{route.method.lower()}_{path_name}"
# Generate a default name from the route
component_name = self._generate_default_name(route, route_type)
if route_type == RouteType.TOOL:
self._create_openapi_tool(route, operation_id)
elif route_type == RouteType.RESOURCE:
self._create_openapi_resource(route, operation_id)
elif route_type == RouteType.RESOURCE_TEMPLATE:
self._create_openapi_template(route, operation_id)
elif route_type == RouteType.PROMPT:
if route_type == MCPType.TOOL:
self._create_openapi_tool(route, component_name)
elif route_type == MCPType.RESOURCE:
self._create_openapi_resource(route, component_name)
elif route_type == MCPType.RESOURCE_TEMPLATE:
self._create_openapi_template(route, component_name)
elif route_type == MCPType.PROMPT:
# Not implemented yet
logger.warning(
f"PROMPT route type not implemented: {route.method} {route.path}"
)
elif route_type == RouteType.IGNORE:
logger.info(f"Ignoring route: {route.method} {route.path}")
elif route_type == MCPType.EXCLUDE:
logger.info(f"Excluding route: {route.method} {route.path}")
logger.info(f"Created FastMCP OpenAPI server with {len(http_routes)} routes")
def _create_openapi_tool(self, route: openapi.HTTPRoute, operation_id: str):
def _generate_default_name(
self, route: openapi.HTTPRoute, mcp_type: MCPType
) -> str:
"""Generate a default name from the route path."""
# First check for OpenAPI operationId which takes precedence
if route.operation_id:
return route.operation_id
# For path-based naming, clean up the path
path_parts = route.path.strip("/").split("/")
# Remove path parameters (parts with {})
clean_parts = []
for part in path_parts:
if part.startswith("{") and part.endswith("}"):
# For templates, include parameter name without braces
if mcp_type == MCPType.RESOURCE_TEMPLATE:
param_name = part[1:-1] # Remove braces
clean_parts.append(param_name)
else:
clean_parts.append(part)
# Join the parts
resource_name = "_".join(clean_parts)
# For tools, might be useful to keep the method for clarity on what it does
if mcp_type == MCPType.TOOL:
# Only include method if it helps distinguish (POST, PUT, PATCH, DELETE)
# For GET we don't need the method as it's implied for resources
if route.method != "GET":
resource_name = f"{route.method.lower()}_{resource_name}"
return resource_name
def _get_unique_name(
self, name: str, component_type: Literal["tools", "resources", "templates"]
) -> str:
"""
Ensure the name is unique within its component type by appending numbers if needed.
Args:
name: The proposed name
component_type: The type of component ("tools", "resources", or "templates")
Returns:
str: A unique name for the component
"""
# Check if the name is already used
if name not in self._used_names[component_type]:
self._used_names[component_type].add(name)
return name
# Find the next available number suffix
counter = 2
while f"{name}_{counter}" in self._used_names[component_type]:
counter += 1
# Create the new name
new_name = f"{name}_{counter}"
logger.debug(
f"Name collision detected: '{name}' already exists as a {component_type[:-1]}. "
f"Using '{new_name}' instead."
)
self._used_names[component_type].add(new_name)
return new_name
def _create_openapi_tool(self, route: openapi.HTTPRoute, name: str):
"""Creates and registers an OpenAPITool with enhanced description."""
combined_schema = _combine_schemas(route)
tool_name = operation_id
# Get a unique tool name
tool_name = self._get_unique_name(name, "tools")
base_description = (
route.description
or route.summary
@ -664,9 +801,11 @@ class FastMCPOpenAPI(FastMCP):
f"Registered TOOL: {tool_name} ({route.method} {route.path}) with tags: {route.tags}"
)
def _create_openapi_resource(self, route: openapi.HTTPRoute, operation_id: str):
def _create_openapi_resource(self, route: openapi.HTTPRoute, name: str):
"""Creates and registers an OpenAPIResource with enhanced description."""
resource_name = operation_id
# Get a unique resource name
resource_name = self._get_unique_name(name, "resources")
resource_uri = f"resource://openapi/{resource_name}"
base_description = (
route.description or route.summary or f"Represents {route.path}"
@ -695,9 +834,11 @@ class FastMCPOpenAPI(FastMCP):
f"Registered RESOURCE: {resource_uri} ({route.method} {route.path}) with tags: {route.tags}"
)
def _create_openapi_template(self, route: openapi.HTTPRoute, operation_id: str):
def _create_openapi_template(self, route: openapi.HTTPRoute, name: str):
"""Creates and registers an OpenAPIResourceTemplate with enhanced description."""
template_name = operation_id
# Get a unique template name
template_name = self._get_unique_name(name, "templates")
path_params = [p.name for p in route.parameters if p.location == "path"]
path_params.sort() # Sort for consistent URIs

View file

@ -879,7 +879,6 @@ class FastMCP(Generic[LifespanResultT]):
auth_server_provider=self._auth_server_provider,
auth_settings=self.settings.auth,
debug=self.settings.debug,
routes=self._additional_http_routes,
middleware=middleware,
)
@ -930,7 +929,6 @@ class FastMCP(Generic[LifespanResultT]):
json_response=self.settings.json_response,
stateless_http=self.settings.stateless_http,
debug=self.settings.debug,
routes=self._additional_http_routes,
middleware=middleware,
)
elif transport == "sse":
@ -941,7 +939,6 @@ class FastMCP(Generic[LifespanResultT]):
auth_server_provider=self._auth_server_provider,
auth_settings=self.settings.auth,
debug=self.settings.debug,
routes=self._additional_http_routes,
middleware=middleware,
)
@ -1026,7 +1023,7 @@ class FastMCP(Generic[LifespanResultT]):
from fastmcp.server.proxy import FastMCPProxy
if tool_separator is not None:
# Deprecated since 2.3.6
# Deprecated since 2.4.0
warnings.warn(
"The tool_separator parameter is deprecated and will be removed in a future version. "
"Tools are now prefixed using 'prefix_toolname' format.",
@ -1035,7 +1032,7 @@ class FastMCP(Generic[LifespanResultT]):
)
if resource_separator is not None:
# Deprecated since 2.3.6
# Deprecated since 2.4.0
warnings.warn(
"The resource_separator parameter is deprecated and ignored. "
"Resource prefixes are now added using the protocol://prefix/path format.",
@ -1044,7 +1041,7 @@ class FastMCP(Generic[LifespanResultT]):
)
if prompt_separator is not None:
# Deprecated since 2.3.6
# Deprecated since 2.4.0
warnings.warn(
"The prompt_separator parameter is deprecated and will be removed in a future version. "
"Prompts are now prefixed using 'prefix_promptname' format.",
@ -1111,7 +1108,7 @@ class FastMCP(Generic[LifespanResultT]):
prompt_separator: Deprecated. Separator for prompt names.
"""
if tool_separator is not None:
# Deprecated since 2.3.6
# Deprecated since 2.4.0
warnings.warn(
"The tool_separator parameter is deprecated and will be removed in a future version. "
"Tools are now prefixed using 'prefix_toolname' format.",
@ -1120,7 +1117,7 @@ class FastMCP(Generic[LifespanResultT]):
)
if resource_separator is not None:
# Deprecated since 2.3.6
# Deprecated since 2.4.0
warnings.warn(
"The resource_separator parameter is deprecated and ignored. "
"Resource prefixes are now added using the protocol://prefix/path format.",
@ -1129,7 +1126,7 @@ class FastMCP(Generic[LifespanResultT]):
)
if prompt_separator is not None:
# Deprecated since 2.3.6
# Deprecated since 2.4.0
warnings.warn(
"The prompt_separator parameter is deprecated and will be removed in a future version. "
"Prompts are now prefixed using 'prefix_promptname' format.",
@ -1175,19 +1172,22 @@ class FastMCP(Generic[LifespanResultT]):
"""
Create a FastMCP server from an OpenAPI specification.
"""
from .openapi import FastMCPOpenAPI, RouteMap, RouteType
from .openapi import FastMCPOpenAPI, MCPType, RouteMap
# Deprecated since 2.5.0
if all_routes_as_tools:
warnings.warn(
"The 'all_routes_as_tools' parameter is deprecated and will be removed in a future version. "
'Use \'route_maps=[RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]\' instead.',
DeprecationWarning,
stacklevel=2,
)
if all_routes_as_tools and route_maps:
raise ValueError("Cannot specify both all_routes_as_tools and route_maps")
elif all_routes_as_tools:
route_maps = [
RouteMap(
methods="*",
pattern=r".*",
route_type=RouteType.TOOL,
)
]
route_maps = [RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]
return FastMCPOpenAPI(
openapi_spec=openapi_spec,
@ -1209,15 +1209,22 @@ class FastMCP(Generic[LifespanResultT]):
Create a FastMCP server from a FastAPI application.
"""
from .openapi import FastMCPOpenAPI, RouteMap, RouteType
from .openapi import FastMCPOpenAPI, MCPType, RouteMap
# Deprecated since 2.5.0
if all_routes_as_tools:
warnings.warn(
"The 'all_routes_as_tools' parameter is deprecated and will be removed in a future version. "
'Use \'route_maps=[RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]\' instead.',
DeprecationWarning,
stacklevel=2,
)
if all_routes_as_tools and route_maps:
raise ValueError("Cannot specify both all_routes_as_tools and route_maps")
elif all_routes_as_tools:
route_maps = [
RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)
]
route_maps = [RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]
client = httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://fastapi"

View file

@ -32,11 +32,12 @@ def infer_transport_type_from_url(
return "streamable-http"
class LocalMCPServer(BaseModel):
class StdioMCPServer(BaseModel):
command: str
args: list[str] = Field(default_factory=list)
env: dict[str, Any] = Field(default_factory=dict)
cwd: str | None = None
transport: Literal["stdio"] = "stdio"
def to_transport(self) -> StdioTransport:
from fastmcp.client.transports import StdioTransport
@ -51,8 +52,8 @@ class LocalMCPServer(BaseModel):
class RemoteMCPServer(BaseModel):
url: str
transport: Literal["streamable-http", "sse", "http"] | None = None
headers: dict[str, str] = Field(default_factory=dict)
transport: Literal["streamable-http", "sse", "http"] | None = None
def to_transport(self) -> StreamableHttpTransport | SSETransport:
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
@ -69,7 +70,7 @@ class RemoteMCPServer(BaseModel):
class MCPConfig(BaseModel):
mcpServers: dict[str, LocalMCPServer | RemoteMCPServer]
mcpServers: dict[str, StdioMCPServer | RemoteMCPServer]
@classmethod
def from_dict(cls, config: dict[str, Any]) -> MCPConfig:

View file

@ -728,7 +728,19 @@ class TestInferTransport:
assert transport.transport.command == "echo"
assert transport.transport.args == ["hello"]
def test_infer_composite_client(config):
def test_config_with_no_servers(self):
"""Test that an empty MCPConfig raises a ValueError."""
config = {"mcpServers": {}}
with pytest.raises(ValueError, match="No MCP servers defined in the config"):
infer_transport(config)
def test_mcpconfigtransport_with_no_servers(self):
"""Test that MCPConfigTransport raises a ValueError when initialized with an empty config."""
config = {"mcpServers": {}}
with pytest.raises(ValueError, match="No MCP servers defined in the config"):
MCPConfigTransport(config=config)
def test_infer_composite_client(self):
config = {
"mcpServers": {
"local": {
@ -744,4 +756,17 @@ class TestInferTransport:
transport = infer_transport(config)
assert isinstance(transport, MCPConfigTransport)
assert isinstance(transport.transport, FastMCPTransport)
assert len(transport.transport.server._mounted_servers) == 2
assert len(cast(FastMCP, transport.transport.server)._mounted_servers) == 2
def test_infer_fastmcp_server(self, fastmcp_server):
"""FastMCP server instances should infer to FastMCPTransport."""
transport = infer_transport(fastmcp_server)
assert isinstance(transport, FastMCPTransport)
def test_infer_fastmcp_v1_server(self):
"""FastMCP 1.0 server instances should infer to FastMCPTransport."""
from mcp.server.fastmcp import FastMCP as FastMCP1
server = FastMCP1()
transport = infer_transport(server)
assert isinstance(transport, FastMCPTransport)

View file

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

View file

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

View file

@ -18,11 +18,11 @@ from fastmcp.client import Client
from fastmcp.exceptions import ToolError
from fastmcp.server.openapi import (
FastMCPOpenAPI,
MCPType,
OpenAPIResource,
OpenAPIResourceTemplate,
OpenAPITool,
RouteMap,
RouteType,
)
@ -304,7 +304,7 @@ class TestTools:
openapi_spec=openapi_spec,
client=api_client,
route_maps=[
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.TOOL)
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)
],
)
async with Client(mcp_server) as client:
@ -956,9 +956,7 @@ async def test_empty_query_parameters_not_sent(
mcp_server = FastMCPOpenAPI(
openapi_spec=openapi_spec,
client=api_client,
route_maps=[
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.TOOL)
],
route_maps=[RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)],
)
# Call the search tool with mixed parameter values
@ -1499,17 +1497,15 @@ class TestFastAPIDescriptionPropagation:
# Create custom route mappings
route_maps = [
# Map GET /items to Resource
RouteMap(
methods=["GET"], pattern=r"^/items$", route_type=RouteType.RESOURCE
),
RouteMap(methods=["GET"], pattern=r"^/items$", mcp_type=MCPType.RESOURCE),
# Map GET /items/{item_id} to ResourceTemplate
RouteMap(
methods=["GET"],
pattern=r"^/items/\{.*\}$",
route_type=RouteType.RESOURCE_TEMPLATE,
mcp_type=MCPType.RESOURCE_TEMPLATE,
),
# Map POST /items to Tool
RouteMap(methods=["POST"], pattern=r"^/items$", route_type=RouteType.TOOL),
RouteMap(methods=["POST"], pattern=r"^/items$", mcp_type=MCPType.TOOL),
]
# Create FastMCP server with the OpenAPI spec and custom route mappings
@ -1918,7 +1914,7 @@ class TestRouteMapWildcard:
):
"""Test that a RouteMap with methods='*' matches all HTTP methods."""
# Create a single route map with wildcard method
route_maps = [RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)]
route_maps = [RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]
mcp = FastMCPOpenAPI(
openapi_spec=basic_openapi_spec,
@ -1930,225 +1926,223 @@ class TestRouteMapWildcard:
tools = mcp._tool_manager.list_tools()
tool_names = {tool.name for tool in tools}
# Check that all operations were mapped as tools
# Check that all 4 operations became tools
expected_tools = {"getUsers", "createUser", "getPosts", "createPost"}
assert tool_names == expected_tools
# No resources or templates should be created
resources = mcp._resource_manager.get_resources()
templates = mcp._resource_manager.get_templates()
assert len(resources) == 0
assert len(templates) == 0
async def test_priority_specific_over_wildcard(
self, basic_openapi_spec, mock_basic_client
):
"""Test that specific method maps take priority over wildcard."""
# Create route maps with specific method first, then wildcard
route_maps = [
# GET operations should be mapped to resources
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE),
# All other operations should be mapped to tools
RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL),
]
mcp = FastMCPOpenAPI(
openapi_spec=basic_openapi_spec,
client=mock_basic_client,
route_maps=route_maps,
)
# Check GET operations went to resources
resources = mcp._resource_manager.get_resources()
resource_names = {r.name for r in resources.values()}
assert "getUsers" in resource_names
assert "getPosts" in resource_names
assert len(resources) == 2
# Check other operations went to tools
tools = mcp._tool_manager.list_tools()
tool_names = {tool.name for tool in tools}
assert "createUser" in tool_names
assert "createPost" in tool_names
assert len(tools) == 2
async def test_priority_wildcard_first(self, basic_openapi_spec, mock_basic_client):
"""Test that when wildcard is first, it matches everything."""
# Create route maps with wildcard first, then specific methods
route_maps = [
# Wildcard first matches everything
RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL),
# This should never be reached
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE),
]
mcp = FastMCPOpenAPI(
openapi_spec=basic_openapi_spec,
client=mock_basic_client,
route_maps=route_maps,
)
# All operations should be tools
tools = mcp._tool_manager.list_tools()
assert len(tools) == 4
# No resources should be created
resources = mcp._resource_manager.get_resources()
assert len(resources) == 0
async def test_wildcard_with_specific_paths(
self, basic_openapi_spec, mock_basic_client
):
"""Test wildcard methods combined with specific path patterns."""
route_maps = [
# All methods on /users path -> Resources
RouteMap(methods="*", pattern=r".*/users$", route_type=RouteType.RESOURCE),
# All methods on /posts path -> Tools
RouteMap(methods="*", pattern=r".*/posts$", route_type=RouteType.TOOL),
]
mcp = FastMCPOpenAPI(
openapi_spec=basic_openapi_spec,
client=mock_basic_client,
route_maps=route_maps,
)
# Check /users operations went to resources
resources = mcp._resource_manager.get_resources()
resource_names = {r.name for r in resources.values()}
assert "getUsers" in resource_names
assert "createUser" in resource_names
assert len(resources) == 2
# Check /posts operations went to tools
tools = mcp._tool_manager.list_tools()
tool_names = {tool.name for tool in tools}
assert "getPosts" in tool_names
assert "createPost" in tool_names
assert len(tools) == 2
class TestAllRoutesAsTools:
"""Tests for the all_routes_as_tools parameter in FastMCP class methods."""
class TestRouteMapTags:
"""Tests for RouteMap tags functionality."""
@pytest.fixture
def simple_api_spec(self) -> dict:
"""A simple OpenAPI spec with both GET and POST methods."""
def tagged_openapi_spec(self) -> dict:
"""Create an OpenAPI spec with various tags for testing."""
return {
"openapi": "3.1.0",
"info": {"title": "Test API", "version": "1.0.0"},
"info": {"title": "Tagged API", "version": "1.0.0"},
"paths": {
"/items": {
"/users": {
"get": {
"operationId": "getItems",
"operationId": "getUsers",
"tags": ["users", "public"],
"responses": {"200": {"description": "Success"}},
},
"post": {
"operationId": "createItem",
"operationId": "createUser",
"tags": ["users", "admin"],
"responses": {"201": {"description": "Created"}},
},
},
"/admin/stats": {
"get": {
"operationId": "getAdminStats",
"tags": ["admin", "internal"],
"responses": {"200": {"description": "Success"}},
}
},
"/health": {
"get": {
"operationId": "getHealth",
"tags": ["public"],
"responses": {"200": {"description": "Success"}},
}
},
"/metrics": {
"get": {
"operationId": "getMetrics",
"responses": {"200": {"description": "Success"}},
}
},
},
}
@pytest.fixture
async def mock_client(self) -> httpx.AsyncClient:
"""Simple mock client for testing."""
"""Create a simple mock client."""
async def _responder(request):
return httpx.Response(200, json={"result": "ok"})
return httpx.Response(200, json={"status": "ok"})
transport = httpx.MockTransport(_responder)
return httpx.AsyncClient(transport=transport, base_url="http://test")
async def test_from_openapi_all_routes_as_tools(self, simple_api_spec, mock_client):
"""Test FastMCP.from_openapi with all_routes_as_tools=True."""
# Create server with all routes as tools
server = FastMCP.from_openapi(
openapi_spec=simple_api_spec, client=mock_client, all_routes_as_tools=True
async def test_tags_as_tools(self, tagged_openapi_spec, mock_client):
"""Test that routes with specific tags are converted to tools."""
# Convert routes with "admin" tag to tools
route_maps = [
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags={"admin"}),
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
]
server = FastMCPOpenAPI(
openapi_spec=tagged_openapi_spec,
client=mock_client,
route_maps=route_maps,
)
# All operations (GET and POST) should be mapped to tools
tools = server._tool_manager.list_tools()
tool_names = {t.name for t in tools}
# Check that admin-tagged routes are tools
tools = server._tool_manager.get_tools()
tool_names = {t.name for t in tools.values()}
assert "getItems" in tool_names
assert "createItem" in tool_names
assert len(tools) == 2
# No resources or templates should be created
resources = server._resource_manager.get_resources()
templates = server._resource_manager.get_templates()
assert len(resources) == 0
assert len(templates) == 0
resource_names = {r.name for r in resources.values()}
async def test_from_openapi_all_routes_as_tools_conflicting_args(
self, simple_api_spec, mock_client
):
"""Test FastMCP.from_openapi raises error when both route_maps and all_routes_as_tools are provided."""
# Try to create server with conflicting args
with pytest.raises(
ValueError, match="Cannot specify both all_routes_as_tools and route_maps"
):
FastMCP.from_openapi(
openapi_spec=simple_api_spec,
client=mock_client,
all_routes_as_tools=True,
route_maps=[
RouteMap(
methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE
)
],
)
# Routes with "admin" tag should be tools
assert "createUser" in tool_names
assert "getAdminStats" in tool_names
async def test_from_fastapi_all_routes_as_tools(self):
"""Test FastMCP.from_fastapi with all_routes_as_tools=True."""
# Create a simple FastAPI app
app = FastAPI(title="Test FastAPI")
# Routes without "admin" tag should be resources
assert "getUsers" in resource_names
assert "getHealth" in resource_names
assert "getMetrics" in resource_names
@app.get("/items")
async def get_items():
return [{"id": 1, "name": "Item 1"}]
async def test_exclude_tags(self, tagged_openapi_spec, mock_client):
"""Test that routes with specific tags are excluded."""
# Exclude routes with "internal" tag
route_maps = [
RouteMap(
methods="*", pattern=r".*", mcp_type=MCPType.EXCLUDE, tags={"internal"}
),
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL),
]
@app.post("/items")
async def create_item(item: dict):
return {"id": 2, **item}
server = FastMCPOpenAPI(
openapi_spec=tagged_openapi_spec,
client=mock_client,
route_maps=route_maps,
)
# Create server with all routes as tools
server = FastMCP.from_fastapi(app=app, all_routes_as_tools=True)
# Both GET and POST operations should be mapped to tools
tools = server._tool_manager.list_tools()
# Get tool names from the generated operation IDs
tool_names = {t.name for t in tools}
# Check that both routes were mapped to tools
# The exact names depend on FastAPI's operation ID generation
assert len(tools) == 2
assert any("get" in name.lower() for name in tool_names)
assert any("post" in name.lower() for name in tool_names)
# No resources or templates should be created
# Check that internal-tagged routes are excluded
resources = server._resource_manager.get_resources()
templates = server._resource_manager.get_templates()
assert len(resources) == 0
assert len(templates) == 0
resource_names = {r.name for r in resources.values()}
async def test_from_fastapi_all_routes_as_tools_conflicting_args(self):
"""Test FastMCP.from_fastapi raises error when both route_maps and all_routes_as_tools are provided."""
app = FastAPI(title="Test FastAPI")
tools = server._tool_manager.get_tools()
tool_names = {t.name for t in tools.values()}
# Try to create server with conflicting args
with pytest.raises(
ValueError, match="Cannot specify both all_routes_as_tools and route_maps"
):
FastMCP.from_fastapi(
app=app,
all_routes_as_tools=True,
route_maps=[
RouteMap(
methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE
)
],
)
# Internal-tagged route should be excluded
assert "getAdminStats" not in resource_names
assert "getAdminStats" not in tool_names
# Other routes should still be present
assert "getUsers" in resource_names
assert "getHealth" in resource_names
assert "getMetrics" in resource_names
assert "createUser" in tool_names
async def test_multiple_tags_and_condition(self, tagged_openapi_spec, mock_client):
"""Test that routes must have ALL specified tags (AND condition)."""
# Routes must have BOTH "users" AND "admin" tags
route_maps = [
RouteMap(
methods="*",
pattern=r".*",
mcp_type=MCPType.TOOL,
tags={"users", "admin"},
),
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
]
server = FastMCPOpenAPI(
openapi_spec=tagged_openapi_spec,
client=mock_client,
route_maps=route_maps,
)
tools = server._tool_manager.get_tools()
tool_names = {t.name for t in tools.values()}
resources = server._resource_manager.get_resources()
resource_names = {r.name for r in resources.values()}
# Only createUser has both "users" AND "admin" tags
assert "createUser" in tool_names
# Other routes should be resources
assert "getUsers" in resource_names # has "users" but not "admin"
assert "getAdminStats" in resource_names # has "admin" but not "users"
assert "getHealth" in resource_names
assert "getMetrics" in resource_names
async def test_pattern_and_tags_combination(self, tagged_openapi_spec, mock_client):
"""Test that both pattern and tags must be satisfied."""
# Routes matching pattern AND having specific tags
route_maps = [
RouteMap(
methods="*",
pattern=r".*/admin/.*",
mcp_type=MCPType.TOOL,
tags={"admin"},
),
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL),
]
server = FastMCPOpenAPI(
openapi_spec=tagged_openapi_spec,
client=mock_client,
route_maps=route_maps,
)
tools = server._tool_manager.get_tools()
tool_names = {t.name for t in tools.values()}
resources = server._resource_manager.get_resources()
resource_names = {r.name for r in resources.values()}
# Only getAdminStats matches both /admin/ pattern AND "admin" tag
assert "getAdminStats" in tool_names
# createUser has "admin" tag but doesn't match pattern, so it becomes a tool via POST rule
assert "createUser" in tool_names
# Other routes should be resources (GET)
assert "getUsers" in resource_names
assert "getHealth" in resource_names
assert "getMetrics" in resource_names
async def test_empty_tags_ignored(self, tagged_openapi_spec, mock_client):
"""Test that empty tags set is ignored (matches all routes)."""
# Empty tags should match all routes
route_maps = [
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags=set()),
]
server = FastMCPOpenAPI(
openapi_spec=tagged_openapi_spec,
client=mock_client,
route_maps=route_maps,
)
tools = server._tool_manager.get_tools()
tool_names = {t.name for t in tools.values()}
# All routes should be tools since empty tags matches everything
expected_tools = {
"getUsers",
"createUser",
"getAdminStats",
"getHealth",
"getMetrics",
}
assert tool_names == expected_tools

View file

@ -6,7 +6,7 @@ import pytest
from fastapi import FastAPI, Query
from fastmcp import Client, FastMCP
from fastmcp.server.openapi import OpenAPITool, RouteMap, RouteType
from fastmcp.server.openapi import MCPType, OpenAPITool, RouteMap
from fastmcp.utilities.openapi import HTTPRoute, ParameterInfo
@ -286,9 +286,7 @@ async def test_array_query_param_with_fastapi():
# Create a FastMCP server from the FastAPI app
mcp = FastMCP.from_fastapi(
app,
route_maps=[
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.TOOL)
],
route_maps=[RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)],
)
# Test with the client

View file

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

View file

@ -9,7 +9,7 @@ from fastmcp.client.transports import (
StdioTransport,
StreamableHttpTransport,
)
from fastmcp.utilities.mcp_config import LocalMCPServer, MCPConfig, RemoteMCPServer
from fastmcp.utilities.mcp_config import MCPConfig, RemoteMCPServer, StdioMCPServer
def test_parse_single_stdio_config():
@ -89,7 +89,7 @@ def test_parse_multiple_servers():
assert isinstance(mcp_config.mcpServers["test_server"], RemoteMCPServer)
assert isinstance(mcp_config.mcpServers["test_server"].to_transport(), SSETransport)
assert isinstance(mcp_config.mcpServers["test_server_2"], LocalMCPServer)
assert isinstance(mcp_config.mcpServers["test_server_2"], StdioMCPServer)
assert isinstance(
mcp_config.mcpServers["test_server_2"].to_transport(), StdioTransport
)