Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
3993fde
1
Parent(s): d625dbe
Add route_map_fn for fine control
Browse files- docs/servers/openapi.mdx +41 -0
- src/fastmcp/server/openapi.py +40 -2
- src/fastmcp/server/server.py +5 -1
- tests/server/openapi/test_route_map_fn.py +284 -0
docs/servers/openapi.mdx
CHANGED
|
@@ -118,6 +118,47 @@ To prevent the default mappings from being applied, add a catch-all exclusion ro
|
|
| 118 |
|
| 119 |
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.
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
## Request Parameter Handling
|
| 123 |
|
|
|
|
| 118 |
|
| 119 |
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.
|
| 120 |
|
| 121 |
+
### Advanced Route Mapping
|
| 122 |
+
|
| 123 |
+
<VersionBadge version="2.5.0" />
|
| 124 |
+
|
| 125 |
+
For advanced users who need fine-grained control over route mapping, you can provide a `route_map_fn` callable. This function receives each route that was matched by a route map (and wasn't excluded) along with the assigned MCP type and name, and can return either `None` to accept the defaults or a `(mcp_type, name)` tuple to override the type and/or object name.
|
| 126 |
+
|
| 127 |
+
```python
|
| 128 |
+
from fastmcp.server.openapi import MCPType
|
| 129 |
+
|
| 130 |
+
def custom_route_mapper(route, mcp_type, name):
|
| 131 |
+
"""Custom route mapping function for advanced control."""
|
| 132 |
+
# Convert all admin routes to tools regardless of HTTP method
|
| 133 |
+
if "/admin/" in route.path:
|
| 134 |
+
return MCPType.TOOL, f"admin_{name}"
|
| 135 |
+
|
| 136 |
+
# Rename all user-specific routes to have the prefix "user_"
|
| 137 |
+
if "/users/{id}" in route.path:
|
| 138 |
+
return mcp_type, f"user_{name}"
|
| 139 |
+
|
| 140 |
+
# Accept defaults for all other routes
|
| 141 |
+
return None
|
| 142 |
+
|
| 143 |
+
mcp = FastMCP.from_openapi(
|
| 144 |
+
openapi_spec=spec,
|
| 145 |
+
client=api_client,
|
| 146 |
+
route_map_fn=custom_route_mapper,
|
| 147 |
+
)
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
The `route_map_fn` receives:
|
| 151 |
+
- `route`: The OpenAPI route object with properties like `.method`, `.path`, `.operation_id`, etc.
|
| 152 |
+
- `mcp_type`: The assigned `MCPType` (based on route maps)
|
| 153 |
+
- `name`: The assigned component name (derived from operation ID or path)
|
| 154 |
+
|
| 155 |
+
It should return either:
|
| 156 |
+
- `None` to accept the defaults
|
| 157 |
+
- `(mcp_type, name)` tuple to override the type and/or name
|
| 158 |
+
|
| 159 |
+
<Warning>
|
| 160 |
+
The `route_map_fn` is only called for routes that matched a route map and were **not** excluded. It will not be called for routes with `MCPType.EXCLUDE`.
|
| 161 |
+
</Warning>
|
| 162 |
|
| 163 |
## Request Parameter Handling
|
| 164 |
|
src/fastmcp/server/openapi.py
CHANGED
|
@@ -33,6 +33,9 @@ logger = get_logger(__name__)
|
|
| 33 |
|
| 34 |
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
|
| 35 |
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
class MCPType(enum.Enum):
|
| 38 |
"""Type of FastMCP component to create from a route.
|
|
@@ -614,7 +617,7 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 614 |
|
| 615 |
Example:
|
| 616 |
```python
|
| 617 |
-
from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap,
|
| 618 |
import httpx
|
| 619 |
|
| 620 |
# Define custom route mappings
|
|
@@ -633,12 +636,26 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 633 |
),
|
| 634 |
]
|
| 635 |
|
| 636 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 637 |
server = FastMCPOpenAPI(
|
| 638 |
openapi_spec=spec,
|
| 639 |
client=httpx.AsyncClient(),
|
| 640 |
name="API Server",
|
| 641 |
route_maps=custom_mappings,
|
|
|
|
| 642 |
)
|
| 643 |
```
|
| 644 |
"""
|
|
@@ -649,6 +666,7 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 649 |
client: httpx.AsyncClient,
|
| 650 |
name: str | None = None,
|
| 651 |
route_maps: list[RouteMap] | None = None,
|
|
|
|
| 652 |
timeout: float | None = None,
|
| 653 |
**settings: Any,
|
| 654 |
):
|
|
@@ -660,6 +678,9 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 660 |
client: httpx AsyncClient for making HTTP requests
|
| 661 |
name: Optional name for the server
|
| 662 |
route_maps: Optional list of RouteMap objects defining route mappings
|
|
|
|
|
|
|
|
|
|
| 663 |
timeout: Optional timeout (in seconds) for all requests
|
| 664 |
**settings: Additional settings for FastMCP
|
| 665 |
"""
|
|
@@ -667,6 +688,7 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 667 |
|
| 668 |
self._client = client
|
| 669 |
self._timeout = timeout
|
|
|
|
| 670 |
|
| 671 |
# Keep track of names to detect collisions
|
| 672 |
self._used_names = {"tools": set(), "resources": set(), "templates": set()}
|
|
@@ -682,6 +704,22 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 682 |
# Generate a default name from the route
|
| 683 |
component_name = self._generate_default_name(route, route_type)
|
| 684 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 685 |
if route_type == MCPType.TOOL:
|
| 686 |
self._create_openapi_tool(route, component_name)
|
| 687 |
elif route_type == MCPType.RESOURCE:
|
|
|
|
| 33 |
|
| 34 |
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
|
| 35 |
|
| 36 |
+
# Type definition for the route mapping function
|
| 37 |
+
RouteMapFn = Callable[[openapi.HTTPRoute, "MCPType", str], tuple["MCPType", str] | None]
|
| 38 |
+
|
| 39 |
|
| 40 |
class MCPType(enum.Enum):
|
| 41 |
"""Type of FastMCP component to create from a route.
|
|
|
|
| 617 |
|
| 618 |
Example:
|
| 619 |
```python
|
| 620 |
+
from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap, MCPType
|
| 621 |
import httpx
|
| 622 |
|
| 623 |
# Define custom route mappings
|
|
|
|
| 636 |
),
|
| 637 |
]
|
| 638 |
|
| 639 |
+
# Advanced: Custom route mapping function for fine-grained control
|
| 640 |
+
def custom_route_mapper(route, mcp_type, name):
|
| 641 |
+
# Convert all admin routes to tools regardless of HTTP method
|
| 642 |
+
if "/admin/" in route.path:
|
| 643 |
+
return MCPType.TOOL, f"admin_{name}"
|
| 644 |
+
|
| 645 |
+
# Rename all user-specific routes to include "user_"
|
| 646 |
+
if "/users/{id}" in route.path:
|
| 647 |
+
return mcp_type, f"user_{name}"
|
| 648 |
+
|
| 649 |
+
# Accept defaults for all other routes
|
| 650 |
+
return None
|
| 651 |
+
|
| 652 |
+
# Create server with custom mappings and route mapper
|
| 653 |
server = FastMCPOpenAPI(
|
| 654 |
openapi_spec=spec,
|
| 655 |
client=httpx.AsyncClient(),
|
| 656 |
name="API Server",
|
| 657 |
route_maps=custom_mappings,
|
| 658 |
+
route_map_fn=custom_route_mapper,
|
| 659 |
)
|
| 660 |
```
|
| 661 |
"""
|
|
|
|
| 666 |
client: httpx.AsyncClient,
|
| 667 |
name: str | None = None,
|
| 668 |
route_maps: list[RouteMap] | None = None,
|
| 669 |
+
route_map_fn: RouteMapFn | None = None,
|
| 670 |
timeout: float | None = None,
|
| 671 |
**settings: Any,
|
| 672 |
):
|
|
|
|
| 678 |
client: httpx AsyncClient for making HTTP requests
|
| 679 |
name: Optional name for the server
|
| 680 |
route_maps: Optional list of RouteMap objects defining route mappings
|
| 681 |
+
route_map_fn: Optional callable for advanced users to customize route mapping.
|
| 682 |
+
Receives (route, mcp_type, name) and returns (mcp_type, name) tuple or None.
|
| 683 |
+
Only called on routes that matched a route_map and were not excluded.
|
| 684 |
timeout: Optional timeout (in seconds) for all requests
|
| 685 |
**settings: Additional settings for FastMCP
|
| 686 |
"""
|
|
|
|
| 688 |
|
| 689 |
self._client = client
|
| 690 |
self._timeout = timeout
|
| 691 |
+
self._route_map_fn = route_map_fn
|
| 692 |
|
| 693 |
# Keep track of names to detect collisions
|
| 694 |
self._used_names = {"tools": set(), "resources": set(), "templates": set()}
|
|
|
|
| 704 |
# Generate a default name from the route
|
| 705 |
component_name = self._generate_default_name(route, route_type)
|
| 706 |
|
| 707 |
+
# Call route_map_fn if provided and route is not excluded
|
| 708 |
+
if self._route_map_fn is not None and route_type != MCPType.EXCLUDE:
|
| 709 |
+
try:
|
| 710 |
+
result = self._route_map_fn(route, route_type, component_name)
|
| 711 |
+
if result is not None:
|
| 712 |
+
route_type, component_name = result
|
| 713 |
+
logger.debug(
|
| 714 |
+
f"Route {route.method} {route.path} mapping customized by route_map_fn: "
|
| 715 |
+
f"type={route_type.name}, name={component_name}"
|
| 716 |
+
)
|
| 717 |
+
except Exception as e:
|
| 718 |
+
logger.warning(
|
| 719 |
+
f"Error in route_map_fn for {route.method} {route.path}: {e}. "
|
| 720 |
+
f"Using default values."
|
| 721 |
+
)
|
| 722 |
+
|
| 723 |
if route_type == MCPType.TOOL:
|
| 724 |
self._create_openapi_tool(route, component_name)
|
| 725 |
elif route_type == MCPType.RESOURCE:
|
src/fastmcp/server/server.py
CHANGED
|
@@ -63,7 +63,7 @@ from fastmcp.utilities.mcp_config import MCPConfig
|
|
| 63 |
if TYPE_CHECKING:
|
| 64 |
from fastmcp.client import Client
|
| 65 |
from fastmcp.client.transports import ClientTransport
|
| 66 |
-
from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap
|
| 67 |
from fastmcp.server.proxy import FastMCPProxy
|
| 68 |
logger = get_logger(__name__)
|
| 69 |
|
|
@@ -1141,6 +1141,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1141 |
openapi_spec: dict[str, Any],
|
| 1142 |
client: httpx.AsyncClient,
|
| 1143 |
route_maps: list[RouteMap] | None = None,
|
|
|
|
| 1144 |
all_routes_as_tools: bool = False,
|
| 1145 |
**settings: Any,
|
| 1146 |
) -> FastMCPOpenAPI:
|
|
@@ -1168,6 +1169,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1168 |
openapi_spec=openapi_spec,
|
| 1169 |
client=client,
|
| 1170 |
route_maps=route_maps,
|
|
|
|
| 1171 |
**settings,
|
| 1172 |
)
|
| 1173 |
|
|
@@ -1177,6 +1179,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1177 |
app: Any,
|
| 1178 |
name: str | None = None,
|
| 1179 |
route_maps: list[RouteMap] | None = None,
|
|
|
|
| 1180 |
all_routes_as_tools: bool = False,
|
| 1181 |
**settings: Any,
|
| 1182 |
) -> FastMCPOpenAPI:
|
|
@@ -1212,6 +1215,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1212 |
client=client,
|
| 1213 |
name=name,
|
| 1214 |
route_maps=route_maps,
|
|
|
|
| 1215 |
**settings,
|
| 1216 |
)
|
| 1217 |
|
|
|
|
| 63 |
if TYPE_CHECKING:
|
| 64 |
from fastmcp.client import Client
|
| 65 |
from fastmcp.client.transports import ClientTransport
|
| 66 |
+
from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap, RouteMapFn
|
| 67 |
from fastmcp.server.proxy import FastMCPProxy
|
| 68 |
logger = get_logger(__name__)
|
| 69 |
|
|
|
|
| 1141 |
openapi_spec: dict[str, Any],
|
| 1142 |
client: httpx.AsyncClient,
|
| 1143 |
route_maps: list[RouteMap] | None = None,
|
| 1144 |
+
route_map_fn: RouteMapFn | None = None,
|
| 1145 |
all_routes_as_tools: bool = False,
|
| 1146 |
**settings: Any,
|
| 1147 |
) -> FastMCPOpenAPI:
|
|
|
|
| 1169 |
openapi_spec=openapi_spec,
|
| 1170 |
client=client,
|
| 1171 |
route_maps=route_maps,
|
| 1172 |
+
route_map_fn=route_map_fn,
|
| 1173 |
**settings,
|
| 1174 |
)
|
| 1175 |
|
|
|
|
| 1179 |
app: Any,
|
| 1180 |
name: str | None = None,
|
| 1181 |
route_maps: list[RouteMap] | None = None,
|
| 1182 |
+
route_map_fn: RouteMapFn | None = None,
|
| 1183 |
all_routes_as_tools: bool = False,
|
| 1184 |
**settings: Any,
|
| 1185 |
) -> FastMCPOpenAPI:
|
|
|
|
| 1215 |
client=client,
|
| 1216 |
name=name,
|
| 1217 |
route_maps=route_maps,
|
| 1218 |
+
route_map_fn=route_map_fn,
|
| 1219 |
**settings,
|
| 1220 |
)
|
| 1221 |
|
tests/server/openapi/test_route_map_fn.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the route_map_fn functionality in FastMCPOpenAPI."""
|
| 2 |
+
|
| 3 |
+
import httpx
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
from fastmcp.server.openapi import FastMCPOpenAPI, MCPType
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@pytest.fixture
|
| 10 |
+
def sample_openapi_spec():
|
| 11 |
+
"""Sample OpenAPI spec for testing."""
|
| 12 |
+
return {
|
| 13 |
+
"openapi": "3.0.0",
|
| 14 |
+
"info": {"title": "Test API", "version": "1.0.0"},
|
| 15 |
+
"paths": {
|
| 16 |
+
"/users": {
|
| 17 |
+
"get": {
|
| 18 |
+
"summary": "List users",
|
| 19 |
+
"operationId": "listUsers",
|
| 20 |
+
"responses": {"200": {"description": "Success"}},
|
| 21 |
+
}
|
| 22 |
+
},
|
| 23 |
+
"/users/{id}": {
|
| 24 |
+
"get": {
|
| 25 |
+
"summary": "Get user by ID",
|
| 26 |
+
"operationId": "getUserById",
|
| 27 |
+
"parameters": [
|
| 28 |
+
{
|
| 29 |
+
"name": "id",
|
| 30 |
+
"in": "path",
|
| 31 |
+
"required": True,
|
| 32 |
+
"schema": {"type": "string"},
|
| 33 |
+
}
|
| 34 |
+
],
|
| 35 |
+
"responses": {"200": {"description": "Success"}},
|
| 36 |
+
}
|
| 37 |
+
},
|
| 38 |
+
"/admin/settings": {
|
| 39 |
+
"get": {
|
| 40 |
+
"summary": "Get admin settings",
|
| 41 |
+
"operationId": "getAdminSettings",
|
| 42 |
+
"responses": {"200": {"description": "Success"}},
|
| 43 |
+
},
|
| 44 |
+
"post": {
|
| 45 |
+
"summary": "Update admin settings",
|
| 46 |
+
"operationId": "updateAdminSettings",
|
| 47 |
+
"requestBody": {
|
| 48 |
+
"content": {"application/json": {"schema": {"type": "object"}}}
|
| 49 |
+
},
|
| 50 |
+
"responses": {"200": {"description": "Success"}},
|
| 51 |
+
},
|
| 52 |
+
},
|
| 53 |
+
"/api/data": {
|
| 54 |
+
"get": {
|
| 55 |
+
"summary": "Get data",
|
| 56 |
+
"operationId": "getData",
|
| 57 |
+
"responses": {"200": {"description": "Success"}},
|
| 58 |
+
}
|
| 59 |
+
},
|
| 60 |
+
},
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@pytest.fixture
|
| 65 |
+
def http_client():
|
| 66 |
+
"""HTTP client for testing."""
|
| 67 |
+
return httpx.AsyncClient()
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_route_map_fn_none(sample_openapi_spec, http_client):
|
| 71 |
+
"""Test that server works correctly when route_map_fn is None."""
|
| 72 |
+
server = FastMCPOpenAPI(
|
| 73 |
+
openapi_spec=sample_openapi_spec,
|
| 74 |
+
client=http_client,
|
| 75 |
+
name="Test Server",
|
| 76 |
+
route_map_fn=None, # Explicitly set to None
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
assert server.name == "Test Server"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_route_map_fn_custom_type_conversion(sample_openapi_spec, http_client):
|
| 83 |
+
"""Test that route_map_fn can convert route types."""
|
| 84 |
+
|
| 85 |
+
def admin_routes_to_tools(route, mcp_type, name):
|
| 86 |
+
"""Convert all admin routes to tools."""
|
| 87 |
+
if "/admin/" in route.path:
|
| 88 |
+
return MCPType.TOOL, f"admin_{name}"
|
| 89 |
+
return None
|
| 90 |
+
|
| 91 |
+
server = FastMCPOpenAPI(
|
| 92 |
+
openapi_spec=sample_openapi_spec,
|
| 93 |
+
client=http_client,
|
| 94 |
+
name="Test Server",
|
| 95 |
+
route_map_fn=admin_routes_to_tools,
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# Admin GET route should be converted to tool instead of resource
|
| 99 |
+
tools = server._tool_manager._tools
|
| 100 |
+
assert "admin_getAdminSettings" in tools
|
| 101 |
+
|
| 102 |
+
# Admin POST route should be renamed
|
| 103 |
+
assert "admin_updateAdminSettings" in tools
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def test_route_map_fn_custom_naming(sample_openapi_spec, http_client):
|
| 107 |
+
"""Test that route_map_fn can customize naming."""
|
| 108 |
+
|
| 109 |
+
def prefix_user_routes(route, mcp_type, name):
|
| 110 |
+
"""Add user_ prefix to user-related routes."""
|
| 111 |
+
if "/users/" in route.path:
|
| 112 |
+
return mcp_type, f"user_{name}"
|
| 113 |
+
return None
|
| 114 |
+
|
| 115 |
+
server = FastMCPOpenAPI(
|
| 116 |
+
openapi_spec=sample_openapi_spec,
|
| 117 |
+
client=http_client,
|
| 118 |
+
name="Test Server",
|
| 119 |
+
route_map_fn=prefix_user_routes,
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# Check that user routes got renamed
|
| 123 |
+
templates = server._resource_manager._templates
|
| 124 |
+
template_names = list(templates.keys())
|
| 125 |
+
|
| 126 |
+
# The getUserById template should be renamed to user_getUserById
|
| 127 |
+
found_user_template = False
|
| 128 |
+
for uri in template_names:
|
| 129 |
+
if "user_getUserById" in uri:
|
| 130 |
+
found_user_template = True
|
| 131 |
+
break
|
| 132 |
+
assert found_user_template
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def test_route_map_fn_returns_none(sample_openapi_spec, http_client):
|
| 136 |
+
"""Test that route_map_fn returning None uses defaults."""
|
| 137 |
+
|
| 138 |
+
def always_return_none(route, mcp_type, name):
|
| 139 |
+
"""Always return None to use defaults."""
|
| 140 |
+
return None
|
| 141 |
+
|
| 142 |
+
server = FastMCPOpenAPI(
|
| 143 |
+
openapi_spec=sample_openapi_spec,
|
| 144 |
+
client=http_client,
|
| 145 |
+
name="Test Server",
|
| 146 |
+
route_map_fn=always_return_none,
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
# Should have default behavior
|
| 150 |
+
assert server.name == "Test Server"
|
| 151 |
+
# Check that components were created with default names
|
| 152 |
+
tools = server._tool_manager._tools
|
| 153 |
+
resources = server._resource_manager._resources
|
| 154 |
+
templates = server._resource_manager._templates
|
| 155 |
+
|
| 156 |
+
# Should have tools, resources, and templates based on default mapping
|
| 157 |
+
assert len(tools) > 0
|
| 158 |
+
assert len(resources) > 0
|
| 159 |
+
assert len(templates) > 0
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def test_route_map_fn_not_called_for_excluded_routes(sample_openapi_spec, http_client):
|
| 163 |
+
"""Test that route_map_fn is not called for excluded routes."""
|
| 164 |
+
|
| 165 |
+
from fastmcp.server.openapi import RouteMap
|
| 166 |
+
|
| 167 |
+
# Exclude all admin routes
|
| 168 |
+
route_maps = [
|
| 169 |
+
RouteMap(
|
| 170 |
+
methods=["GET", "POST"], pattern=r".*/admin/.*", mcp_type=MCPType.EXCLUDE
|
| 171 |
+
)
|
| 172 |
+
]
|
| 173 |
+
|
| 174 |
+
called_routes = []
|
| 175 |
+
|
| 176 |
+
def track_calls(route, mcp_type, name):
|
| 177 |
+
"""Track which routes the function is called for."""
|
| 178 |
+
called_routes.append(route.path)
|
| 179 |
+
return None
|
| 180 |
+
|
| 181 |
+
FastMCPOpenAPI(
|
| 182 |
+
openapi_spec=sample_openapi_spec,
|
| 183 |
+
client=http_client,
|
| 184 |
+
name="Test Server",
|
| 185 |
+
route_maps=route_maps,
|
| 186 |
+
route_map_fn=track_calls,
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
# route_map_fn should not be called for excluded admin routes
|
| 190 |
+
assert "/admin/settings" not in called_routes
|
| 191 |
+
# But should be called for other routes
|
| 192 |
+
assert "/users" in called_routes
|
| 193 |
+
assert "/users/{id}" in called_routes
|
| 194 |
+
assert "/api/data" in called_routes
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def test_route_map_fn_error_handling(sample_openapi_spec, http_client):
|
| 198 |
+
"""Test that errors in route_map_fn are handled gracefully."""
|
| 199 |
+
|
| 200 |
+
def error_function(route, mcp_type, name):
|
| 201 |
+
"""Function that raises an error."""
|
| 202 |
+
if route.path == "/users":
|
| 203 |
+
raise ValueError("Test error")
|
| 204 |
+
return None
|
| 205 |
+
|
| 206 |
+
# Should not raise an error, but log a warning
|
| 207 |
+
server = FastMCPOpenAPI(
|
| 208 |
+
openapi_spec=sample_openapi_spec,
|
| 209 |
+
client=http_client,
|
| 210 |
+
name="Test Server",
|
| 211 |
+
route_map_fn=error_function,
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
# Server should still be created successfully
|
| 215 |
+
assert server.name == "Test Server"
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def test_route_map_fn_with_complex_logic(sample_openapi_spec, http_client):
|
| 219 |
+
"""Test route_map_fn with complex conditional logic."""
|
| 220 |
+
|
| 221 |
+
def complex_mapper(route, mcp_type, name):
|
| 222 |
+
"""Complex mapping logic."""
|
| 223 |
+
# Convert admin routes to tools
|
| 224 |
+
if "/admin/" in route.path:
|
| 225 |
+
return MCPType.TOOL, f"admin_{name}"
|
| 226 |
+
|
| 227 |
+
# Convert user parameter routes to templates with custom naming
|
| 228 |
+
if "/users/{" in route.path:
|
| 229 |
+
return MCPType.RESOURCE_TEMPLATE, f"user_template_{name}"
|
| 230 |
+
|
| 231 |
+
# Convert list routes to resources with custom naming
|
| 232 |
+
if route.path.endswith("/users") or route.path.endswith("/data"):
|
| 233 |
+
return MCPType.RESOURCE, f"list_{name}"
|
| 234 |
+
|
| 235 |
+
# Use defaults for everything else
|
| 236 |
+
return None
|
| 237 |
+
|
| 238 |
+
server = FastMCPOpenAPI(
|
| 239 |
+
openapi_spec=sample_openapi_spec,
|
| 240 |
+
client=http_client,
|
| 241 |
+
name="Test Server",
|
| 242 |
+
route_map_fn=complex_mapper,
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
# Check that the complex logic was applied correctly
|
| 246 |
+
tools = server._tool_manager._tools
|
| 247 |
+
resources = server._resource_manager._resources
|
| 248 |
+
templates = server._resource_manager._templates
|
| 249 |
+
|
| 250 |
+
# Admin routes should be tools
|
| 251 |
+
assert "admin_getAdminSettings" in tools
|
| 252 |
+
assert "admin_updateAdminSettings" in tools
|
| 253 |
+
|
| 254 |
+
# List routes should be resources with custom names
|
| 255 |
+
found_list_resource = False
|
| 256 |
+
for uri in resources.keys():
|
| 257 |
+
if "list_listUsers" in uri or "list_getData" in uri:
|
| 258 |
+
found_list_resource = True
|
| 259 |
+
break
|
| 260 |
+
assert found_list_resource
|
| 261 |
+
|
| 262 |
+
# User parameter route should be template with custom name
|
| 263 |
+
found_user_template = False
|
| 264 |
+
for uri in templates.keys():
|
| 265 |
+
if "user_template_getUserById" in uri:
|
| 266 |
+
found_user_template = True
|
| 267 |
+
break
|
| 268 |
+
assert found_user_template
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def test_route_map_fn_signature_validation():
|
| 272 |
+
"""Test that route_map_fn has the correct signature."""
|
| 273 |
+
from fastmcp.server.openapi import RouteMapFn
|
| 274 |
+
from fastmcp.utilities import openapi
|
| 275 |
+
|
| 276 |
+
# This is more of a type checking test
|
| 277 |
+
def valid_route_map_fn(
|
| 278 |
+
route: openapi.HTTPRoute, mcp_type: MCPType, name: str
|
| 279 |
+
) -> tuple[MCPType, str] | None:
|
| 280 |
+
return None
|
| 281 |
+
|
| 282 |
+
# Should be assignable to RouteMapFn type
|
| 283 |
+
fn: RouteMapFn = valid_route_map_fn
|
| 284 |
+
assert callable(fn)
|