Merge pull request #564 from jlowin/custom-routes

Enhance route map logic for include/exclude OpenAPI routes
This commit is contained in:
Jeremiah Lowin 2025-05-23 08:27:22 -04:00 committed by GitHub
commit 30aef0a4a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 876 additions and 244 deletions

View file

@ -31,21 +31,20 @@ if __name__ == "__main__":
### Timeout
You can set a timeout for all API requests:
You can set a timeout for all requests by providing a `timeout` parameter (in seconds):
```python
# Set a 5 second timeout for all requests
mcp = FastMCP.from_openapi(
openapi_spec=spec,
client=api_client,
timeout=5.0
client=api_client,
timeout=30.0 # 30 second timeout
)
```
This timeout is applied to all requests made by tools, resources, and resource templates.
## 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 | Notes |
@ -54,7 +53,6 @@ By default, OpenAPI routes are mapped to MCP components based on these rules:
| `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
@ -64,75 +62,195 @@ DEFAULT_ROUTE_MAPPINGS = [
RouteMap(
methods=["GET"],
pattern=r".*\{.*\}.*",
route_type=RouteType.RESOURCE_TEMPLATE,
mcp_type=MCPType.RESOURCE_TEMPLATE,
),
# GET without path parameters -> Resource
RouteMap(
methods=["GET"],
pattern=r".*",
route_type=RouteType.RESOURCE,
mcp_type=MCPType.RESOURCE,
),
# All other methods -> Tool
RouteMap(
methods="*",
pattern=r".*",
route_type=RouteType.TOOL,
),
ALL_TOOLS(),
]
```
### Custom Route Maps
#### 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
from fastmcp.server.openapi import RouteMap, MCPType
# Custom mapping rules
custom_maps = [
# Force all analytics endpoints to be Tools
RouteMap(methods=["GET"],
pattern=r"^/analytics/.*",
route_type=RouteType.TOOL)
mcp_type=MCPType.TOOL)
]
# Apply custom mappings
mcp = await FastMCP.from_openapi(
mcp = FastMCP.from_openapi(
openapi_spec=spec,
client=api_client,
route_maps=custom_maps
)
```
<Info>
For backward compatibility, FastMCP still supports the `route_type` parameter and `RouteType` enum, but they are deprecated and will be removed in a future version. You will see deprecation warnings if you use them.
</Info>
### All Routes as Tools
#### 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:
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_TOOLS()` shortcut or create a custom route map:
```python
# Make all endpoints tools, regardless of HTTP method
# Make all endpoints tools using the shortcut
mcp = FastMCP.from_openapi(
openapi_spec=spec,
client=api_client,
all_routes_as_tools=True
route_maps=[ALL_TOOLS()]
)
```
This is equivalent to defining a single route map that matches all routes:
```python
# Same effect as all_routes_as_tools=True
# Same effect using a custom route map
mcp = FastMCP.from_openapi(
openapi_spec=spec,
client=api_client,
route_maps=[
RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.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.
#### Excluding Routes
If you want to exclude certain routes from being converted to MCP components, you can map them to `MCPType.EXCLUDE`. This is useful for endpoints that should not be accessible to the agent.
```python
from fastmcp.server.openapi import RouteMap, MCPType
# Custom mapping rules to exclude specific routes
custom_maps = [
# Exclude all admin endpoints
RouteMap(
methods="*",
pattern=r"^/admin/.*",
mcp_type=MCPType.EXCLUDE
),
# Exclude analytics GET endpoints
RouteMap(
methods=["GET"],
pattern=r"^/analytics/.*",
mcp_type=MCPType.EXCLUDE
)
]
# Apply custom mappings
mcp = FastMCP.from_openapi(
openapi_spec=spec,
client=api_client,
route_maps=custom_maps
)
```
When a route is mapped to `MCPType.EXCLUDE`, FastMCP will log its presence but won't create any MCP component for it, effectively making it invisible to clients and agents using the MCP server.
You can customize this behavior by providing a list of `RouteMap` objects:
```python
from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap, MCPType
# Custom route mappings
custom_mappings = [
# Convert all user-related routes to tools
RouteMap(
methods=["GET", "POST", "PUT", "DELETE"],
pattern=r"^/users.*",
mcp_type=MCPType.TOOL
),
# Exclude analytics routes
RouteMap(
methods=["*"], # All methods
pattern=r"^/analytics.*",
mcp_type=MCPType.EXCLUDE
),
]
# Create server with custom mappings
mcp = FastMCPOpenAPI(
openapi_spec=spec,
client=httpx.AsyncClient(),
route_maps=custom_mappings,
)
```
#### Route Map Shortcuts
FastMCP provides several shortcut functions to create common route maps more easily:
```python
from fastmcp.server.openapi import (
ALL_TOOLS,
EXCLUDE_ALL,
EXCLUDE_PATTERN,
PATTERN_AS_TOOLS,
)
# Create an MCP server with custom route maps using shortcuts
mcp = FastMCP.from_openapi(
openapi_spec=spec,
client=api_client,
route_maps=[
# First exclude all admin endpoints
EXCLUDE_PATTERN(r"^/admin/.*"),
# Make all /api/v1 endpoints tools
PATTERN_AS_TOOLS(r"^/api/v1/.*"),
# Make all remaining routes tools
ALL_TOOLS(),
]
)
```
Available shortcuts:
| Shortcut Function | Description |
|------------------|-------------|
| `ALL_TOOLS()` | Converts all matching routes to tools |
| `EXCLUDE_ALL()` | Excludes all matching routes from being converted to any component |
| `PATTERN_AS_TOOLS(pattern)` | Converts routes matching a specific pattern to tools |
| `EXCLUDE_PATTERN(pattern)` | Excludes routes matching a specific pattern |
These shortcuts are particularly useful for:
1. Converting all remaining unmatched routes to tools (use `ALL_TOOLS()`)
2. Excluding whole sections of your API (use `EXCLUDE_PATTERN("/path/.*")`)
3. Converting routes matching specific patterns to tools (use `PATTERN_AS_TOOLS("/path/.*")`)
<Tip>
You can use `EXCLUDE_ALL()` as the last entry in your custom route maps to completely ignore the default route maps. Since custom route maps are applied first and default maps are appended afterward, having `EXCLUDE_ALL()` at the end of your custom maps will match any routes that your earlier custom rules didn't match, preventing the default maps from having any effect.
```python
# Create server that only uses custom route maps, ignoring defaults
mcp = FastMCP.from_openapi(
openapi_spec=spec,
client=api_client,
route_maps=[
# Routes to keep as tools
PATTERN_AS_TOOLS(r"^/api/v1/.*"),
# Exclude everything else (ignores default route maps)
EXCLUDE_ALL(),
]
)
```
</Tip>
## How It Works
@ -175,89 +293,20 @@ await client.call_tool("get_product", {"product_id": 123})
await client.call_tool("get_product", {"product_id": None})
```
## Complete Example
## Example: Custom Authentication
```python [expandable]
import asyncio
If your API requires authentication, you can set headers on the client:
```python
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"},
},
}
},
},
}
# Create a client with authentication
api_client = httpx.AsyncClient(
base_url="https://api.example.com",
headers={"Authorization": "Bearer YOUR_TOKEN"}
)
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()
# Create an MCP server from your OpenAPI spec
mcp = FastMCP.from_openapi(openapi_spec=spec, client=api_client)
```

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,121 @@ 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)
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
# Common route map pattern functions
def EXCLUDE_ALL() -> RouteMap:
"""
Create a RouteMap that excludes all routes that haven't been matched by earlier rules.
This is useful as the last route map to exclude any routes that don't match specific patterns.
Returns:
RouteMap: A route map that excludes all routes
"""
return RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.EXCLUDE)
def ALL_TOOLS() -> RouteMap:
"""
Create a RouteMap that converts all routes to tools that haven't been matched by earlier rules.
This is useful to replace the last item in the default route mappings to make all unmatched routes tools.
Returns:
RouteMap: A route map that converts all routes to tools
"""
return RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)
def PATTERN_AS_TOOLS(pattern: str) -> RouteMap:
"""
Create a RouteMap that converts routes matching a specific pattern to tools.
Args:
pattern: Regex pattern to match routes
Returns:
RouteMap: A route map that converts routes matching the pattern to tools
"""
return RouteMap(methods="*", pattern=pattern, mcp_type=MCPType.TOOL)
def EXCLUDE_PATTERN(pattern: str) -> RouteMap:
"""
Create a RouteMap that excludes routes matching a specific pattern.
Args:
pattern: Regex pattern to match routes to exclude
Returns:
RouteMap: A route map that excludes routes matching the pattern
"""
return RouteMap(methods="*", pattern=pattern, mcp_type=MCPType.EXCLUDE)
# 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,
),
ALL_TOOLS(),
]
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 +195,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 +208,15 @@ def _determine_route_type(
pattern_matches = re.search(route_map.pattern, route.path)
if pattern_matches:
# 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 +664,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 +708,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 +720,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 +842,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 +875,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

@ -1147,19 +1147,22 @@ class FastMCP(Generic[LifespanResultT]):
"""
Create a FastMCP server from an OpenAPI specification.
"""
from .openapi import FastMCPOpenAPI, RouteMap, RouteType
from .openapi import ALL_TOOLS, FastMCPOpenAPI
# 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=[ALL_TOOLS()]' 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 = [ALL_TOOLS()]
return FastMCPOpenAPI(
openapi_spec=openapi_spec,
@ -1181,15 +1184,21 @@ class FastMCP(Generic[LifespanResultT]):
Create a FastMCP server from a FastAPI application.
"""
from .openapi import FastMCPOpenAPI, RouteMap, RouteType
from .openapi import ALL_TOOLS, FastMCPOpenAPI
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=[ALL_TOOLS()]' 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 = [ALL_TOOLS()]
client = httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://fastapi"

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

@ -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,
@ -1947,9 +1943,9 @@ class TestRouteMapWildcard:
# 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),
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
# All other operations should be mapped to tools
RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL),
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL),
]
mcp = FastMCPOpenAPI(
@ -1977,9 +1973,9 @@ class TestRouteMapWildcard:
# Create route maps with wildcard first, then specific methods
route_maps = [
# Wildcard first matches everything
RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL),
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL),
# This should never be reached
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE),
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
]
mcp = FastMCPOpenAPI(
@ -2002,9 +1998,9 @@ class TestRouteMapWildcard:
"""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),
RouteMap(methods="*", pattern=r".*/users$", mcp_type=MCPType.RESOURCE),
# All methods on /posts path -> Tools
RouteMap(methods="*", pattern=r".*/posts$", route_type=RouteType.TOOL),
RouteMap(methods="*", pattern=r".*/posts$", mcp_type=MCPType.TOOL),
]
mcp = FastMCPOpenAPI(
@ -2063,92 +2059,169 @@ class TestAllRoutesAsTools:
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
)
# All operations (GET and POST) should be mapped to tools
tools = server._tool_manager.list_tools()
tool_names = {t.name for t in tools}
with pytest.warns(DeprecationWarning, match="all_routes_as_tools.*deprecated"):
server = FastMCP.from_openapi(
openapi_spec=simple_api_spec,
client=mock_client,
all_routes_as_tools=True,
)
assert "getItems" in tool_names
assert "createItem" in tool_names
assert len(tools) == 2
# Check that all routes are tools
tools = await server.get_tools()
assert len(tools) >= 2 # Should have at least the two endpoints as tools
# No resources or templates should be created
resources = server._resource_manager.get_resources()
templates = server._resource_manager.get_templates()
# Should have no resources since all routes are tools
resources = await server.get_resources()
assert len(resources) == 0
# Should have no resource templates since all routes are tools
templates = await server.get_resource_templates()
assert len(templates) == 0
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
)
],
)
with pytest.warns(
DeprecationWarning, match="all_routes_as_tools.*deprecated"
):
FastMCP.from_openapi(
openapi_spec=simple_api_spec,
client=mock_client,
route_maps=[
RouteMap(
methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE
)
],
all_routes_as_tools=True,
)
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")
try:
import fastapi
except ImportError:
pytest.skip("FastAPI not available")
app = fastapi.FastAPI()
@app.get("/items")
async def get_items():
return [{"id": 1, "name": "Item 1"}]
def get_items():
return {"items": []}
@app.post("/items")
async def create_item(item: dict):
return {"id": 2, **item}
def create_item():
return {"item": "created"}
# Create server with all routes as tools
server = FastMCP.from_fastapi(app=app, all_routes_as_tools=True)
with pytest.warns(DeprecationWarning, match="all_routes_as_tools.*deprecated"):
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()
# Check that all routes are tools
tools = await server.get_tools()
assert len(tools) >= 2 # Should have at least the two endpoints as 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
resources = server._resource_manager.get_resources()
templates = server._resource_manager.get_templates()
# Should have no resources since all routes are tools
resources = await server.get_resources()
assert len(resources) == 0
# Should have no resource templates since all routes are tools
templates = await server.get_resource_templates()
assert len(templates) == 0
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")
try:
import fastapi
except ImportError:
pytest.skip("FastAPI not available")
app = fastapi.FastAPI()
# 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
)
],
)
with pytest.warns(
DeprecationWarning, match="all_routes_as_tools.*deprecated"
):
FastMCP.from_fastapi(
app=app,
route_maps=[
RouteMap(
methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE
)
],
all_routes_as_tools=True,
)
class TestRouteTypeExclude:
@pytest.fixture
def basic_openapi_spec(self) -> dict:
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"}},
}
},
"/users": {
"get": {
"operationId": "get_users",
"summary": "Get all users",
"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:
async def _responder(request):
return httpx.Response(200, json={"success": True})
return httpx.AsyncClient(transport=httpx.MockTransport(_responder))
async def test_exclude_routes(self, basic_openapi_spec, mock_client):
# Create a server with custom mappings that exclude specific routes
server = FastMCPOpenAPI(
openapi_spec=basic_openapi_spec,
client=mock_client,
route_maps=[
# Exclude analytics endpoints
RouteMap(
methods=["GET"],
pattern=r"^/analytics$",
mcp_type=MCPType.EXCLUDE,
),
# Make everything else a resource
RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
],
)
# Check that resources were created for non-excluded routes
resources = await server.get_resources()
resource_uris = [str(r.uri) for r in resources.values()]
# The /analytics endpoint should be excluded
assert "resource://openapi/get_items" in resource_uris
assert "resource://openapi/get_users" in resource_uris
assert "resource://openapi/get_analytics" not in resource_uris
# Should only have 2 resources (analytics is excluded)
assert len(resources) == 2

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

@ -0,0 +1,208 @@
"""Tests for the route map shortcut functions."""
import httpx
import pytest
from fastmcp.server.openapi import (
ALL_TOOLS,
EXCLUDE_ALL,
EXCLUDE_PATTERN,
PATTERN_AS_TOOLS,
FastMCPOpenAPI,
MCPType,
RouteMap,
)
class TestRouteMapShortcuts:
"""Tests for the route map shortcut functions."""
def test_functions_return_correct_route_maps(self):
"""Test that each shortcut function returns a RouteMap with the expected properties."""
# Test EXCLUDE_ALL
exclude_all = EXCLUDE_ALL()
assert isinstance(exclude_all, RouteMap)
assert exclude_all.methods == "*"
assert exclude_all.pattern == ".*"
assert exclude_all.mcp_type == MCPType.EXCLUDE
# Test ALL_TOOLS
all_tools = ALL_TOOLS()
assert isinstance(all_tools, RouteMap)
assert all_tools.methods == "*"
assert all_tools.pattern == ".*"
assert all_tools.mcp_type == MCPType.TOOL
# Test PATTERN_AS_TOOLS
pattern = r"^/api/.*"
pattern_as_tools = PATTERN_AS_TOOLS(pattern)
assert isinstance(pattern_as_tools, RouteMap)
assert pattern_as_tools.methods == "*"
assert pattern_as_tools.pattern == pattern
assert pattern_as_tools.mcp_type == MCPType.TOOL
# Test EXCLUDE_PATTERN
pattern = r"^/admin/.*"
exclude_pattern = EXCLUDE_PATTERN(pattern)
assert isinstance(exclude_pattern, RouteMap)
assert exclude_pattern.methods == "*"
assert exclude_pattern.pattern == pattern
assert exclude_pattern.mcp_type == MCPType.EXCLUDE
def test_backward_compatibility(self):
"""Test that backward compatibility with RouteType and route_type works."""
from fastmcp.server.openapi import RouteType
# Test creating a RouteMap with route_type
with pytest.warns(DeprecationWarning):
route_map = RouteMap(
methods=["GET"], pattern=r".*", route_type=RouteType.TOOL
)
assert route_map.mcp_type == MCPType.TOOL
# Test accessing fields on RouteType directly
# Note: importing RouteType already causes the deprecation warning,
# so we don't need to check for it again here
rt = RouteType.RESOURCE
assert rt.value == "RESOURCE"
assert rt.name == "RESOURCE"
class TestRouteMapShortcutsIntegration:
"""Integration tests for the route map shortcut functions with FastMCPOpenAPI."""
@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"}},
},
"post": {
"operationId": "create_item",
"summary": "Create an item",
"responses": {"201": {"description": "Created"}},
},
},
"/users": {
"get": {
"operationId": "get_users",
"summary": "Get all users",
"responses": {"200": {"description": "Success"}},
},
},
"/admin": {
"get": {
"operationId": "get_admin",
"summary": "Admin endpoint",
"responses": {"200": {"description": "Success"}},
},
},
"/items/{item_id}": {
"get": {
"operationId": "get_item",
"summary": "Get an item by ID",
"parameters": [
{
"name": "item_id",
"in": "path",
"required": True,
"schema": {"type": "string"},
}
],
"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_all_tools(self, basic_openapi_spec, mock_client):
"""Test using ALL_TOOLS() to convert all routes to tools."""
server = FastMCPOpenAPI(
openapi_spec=basic_openapi_spec,
client=mock_client,
route_maps=[ALL_TOOLS()],
)
# Check that all routes are tools
tools = await server.get_tools()
resources = await server.get_resources()
templates = await server.get_resource_templates()
# All 5 routes should be tools
assert len(tools) == 5
assert len(resources) == 0
assert len(templates) == 0
# Check that all expected tools exist
tool_names = [t.name for t in tools.values()]
assert "get_items" in tool_names
assert "create_item" in tool_names
assert "get_users" in tool_names
assert "get_admin" in tool_names
assert "get_item" in tool_names
async def test_exclude_pattern(self, basic_openapi_spec, mock_client):
"""Test using EXCLUDE_PATTERN() to exclude specific routes."""
server = FastMCPOpenAPI(
openapi_spec=basic_openapi_spec,
client=mock_client,
route_maps=[
# Exclude admin endpoints
EXCLUDE_PATTERN(r"^/admin"),
# Make everything else a tool
ALL_TOOLS(),
],
)
# Check that admin route is excluded
tools = await server.get_tools()
tool_names = [t.name for t in tools.values()]
# All routes except admin should be tools
assert "get_items" in tool_names
assert "create_item" in tool_names
assert "get_users" in tool_names
assert "get_item" in tool_names
assert "get_admin" not in tool_names # This should be excluded
async def test_pattern_as_tools(self, basic_openapi_spec, mock_client):
"""Test using PATTERN_AS_TOOLS() to convert routes matching a pattern to tools."""
server = FastMCPOpenAPI(
openapi_spec=basic_openapi_spec,
client=mock_client,
route_maps=[
# Make /items routes tools regardless of method
PATTERN_AS_TOOLS(r"^/items"),
# Make everything else a resource
RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.RESOURCE),
],
)
# Check that /items routes are tools
tools = await server.get_tools()
tool_names = [t.name for t in tools.values()]
assert "get_items" in tool_names
assert "create_item" in tool_names
assert "get_item" in tool_names
# Check that other routes are resources
resources = await server.get_resources()
resource_names = [r.name for r in resources.values()]
assert "get_users" in resource_names
assert "get_admin" in resource_names