mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 22:14:18 +02:00
Merge pull request #520 from jlowin/star-routes
Allow * Methods and all routes as tools shortcuts
This commit is contained in:
commit
40b45997ff
4 changed files with 376 additions and 16 deletions
|
|
@ -61,19 +61,27 @@ Internally, FastMCP uses a priority-ordered set of `RouteMap` objects to determi
|
|||
# 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),
|
||||
RouteMap(
|
||||
methods=["GET"],
|
||||
pattern=r".*\{.*\}.*",
|
||||
route_type=RouteType.RESOURCE_TEMPLATE,
|
||||
),
|
||||
|
||||
# GET without path parameters -> Resource
|
||||
RouteMap(methods=["GET"], pattern=r".*",
|
||||
route_type=RouteType.RESOURCE),
|
||||
RouteMap(
|
||||
methods=["GET"],
|
||||
pattern=r".*",
|
||||
route_type=RouteType.RESOURCE,
|
||||
),
|
||||
|
||||
# All other methods -> Tool
|
||||
RouteMap(methods=["POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
|
||||
pattern=r".*", route_type=RouteType.TOOL),
|
||||
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.
|
||||
|
|
@ -97,6 +105,35 @@ mcp = await FastMCP.from_openapi(
|
|||
)
|
||||
```
|
||||
|
||||
|
||||
### 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
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ class RouteType(enum.Enum):
|
|||
class RouteMap:
|
||||
"""Mapping configuration for HTTP routes to FastMCP component types."""
|
||||
|
||||
methods: list[HttpMethod]
|
||||
methods: list[HttpMethod] | Literal["*"]
|
||||
pattern: Pattern[str] | str
|
||||
route_type: RouteType
|
||||
|
||||
|
|
@ -86,7 +86,7 @@ def _determine_route_type(
|
|||
# Check mappings in priority order (first match wins)
|
||||
for route_map in mappings:
|
||||
# Check if the HTTP method matches
|
||||
if route.method in route_map.methods:
|
||||
if route_map.methods == "*" or route.method in route_map.methods:
|
||||
# Handle both string patterns and compiled Pattern objects
|
||||
if isinstance(route_map.pattern, Pattern):
|
||||
pattern_matches = route_map.pattern.search(route.path)
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ from fastmcp.utilities.logging import get_logger
|
|||
if TYPE_CHECKING:
|
||||
from fastmcp.client import Client
|
||||
from fastmcp.client.transports import ClientTransport
|
||||
from fastmcp.server.openapi import FastMCPOpenAPI
|
||||
from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap
|
||||
from fastmcp.server.proxy import FastMCPProxy
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
|
@ -1082,24 +1082,59 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
|
||||
@classmethod
|
||||
def from_openapi(
|
||||
cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, **settings: Any
|
||||
cls,
|
||||
openapi_spec: dict[str, Any],
|
||||
client: httpx.AsyncClient,
|
||||
route_maps: list[RouteMap] | None = None,
|
||||
all_routes_as_tools: bool = False,
|
||||
**settings: Any,
|
||||
) -> FastMCPOpenAPI:
|
||||
"""
|
||||
Create a FastMCP server from an OpenAPI specification.
|
||||
"""
|
||||
from .openapi import FastMCPOpenAPI
|
||||
from .openapi import FastMCPOpenAPI, RouteMap, RouteType
|
||||
|
||||
return FastMCPOpenAPI(openapi_spec=openapi_spec, client=client, **settings)
|
||||
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,
|
||||
)
|
||||
]
|
||||
|
||||
return FastMCPOpenAPI(
|
||||
openapi_spec=openapi_spec,
|
||||
client=client,
|
||||
route_maps=route_maps,
|
||||
**settings,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_fastapi(
|
||||
cls, app: Any, name: str | None = None, **settings: Any
|
||||
cls,
|
||||
app: Any,
|
||||
name: str | None = None,
|
||||
route_maps: list[RouteMap] | None = None,
|
||||
all_routes_as_tools: bool = False,
|
||||
**settings: Any,
|
||||
) -> FastMCPOpenAPI:
|
||||
"""
|
||||
Create a FastMCP server from a FastAPI application.
|
||||
"""
|
||||
|
||||
from .openapi import FastMCPOpenAPI
|
||||
from .openapi import FastMCPOpenAPI, RouteMap, RouteType
|
||||
|
||||
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)
|
||||
]
|
||||
|
||||
client = httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://fastapi"
|
||||
|
|
@ -1108,7 +1143,11 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
name = name or app.title
|
||||
|
||||
return FastMCPOpenAPI(
|
||||
openapi_spec=app.openapi(), client=client, name=name, **settings
|
||||
openapi_spec=app.openapi(),
|
||||
client=client,
|
||||
name=name,
|
||||
route_maps=route_maps,
|
||||
**settings,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -1890,3 +1890,287 @@ class TestEnumHandling:
|
|||
assert "enum" in enum_def
|
||||
assert enum_def["enum"] == ["foo", "bar", "baz"]
|
||||
assert enum_def["type"] == "string"
|
||||
|
||||
|
||||
class TestRouteMapWildcard:
|
||||
"""Tests for wildcard RouteMap methods functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def basic_openapi_spec(self) -> dict:
|
||||
"""Create a minimal OpenAPI spec with different HTTP methods."""
|
||||
return {
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "Test API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/users": {
|
||||
"get": {
|
||||
"operationId": "getUsers",
|
||||
"responses": {"200": {"description": "Success"}},
|
||||
},
|
||||
"post": {
|
||||
"operationId": "createUser",
|
||||
"responses": {"201": {"description": "Created"}},
|
||||
},
|
||||
},
|
||||
"/posts": {
|
||||
"get": {
|
||||
"operationId": "getPosts",
|
||||
"responses": {"200": {"description": "Success"}},
|
||||
},
|
||||
"post": {
|
||||
"operationId": "createPost",
|
||||
"responses": {"201": {"description": "Created"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_basic_client(self) -> httpx.AsyncClient:
|
||||
"""Create a simple mock client."""
|
||||
|
||||
async def _responder(request):
|
||||
return httpx.Response(200, json={"status": "ok"})
|
||||
|
||||
transport = httpx.MockTransport(_responder)
|
||||
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
|
||||
async def test_wildcard_matches_all_methods(
|
||||
self, basic_openapi_spec, mock_basic_client
|
||||
):
|
||||
"""Test that a RouteMap with methods='*' matches all HTTP methods."""
|
||||
# Create a single route map with wildcard method
|
||||
route_maps = [RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)]
|
||||
|
||||
mcp = FastMCPOpenAPI(
|
||||
openapi_spec=basic_openapi_spec,
|
||||
client=mock_basic_client,
|
||||
route_maps=route_maps,
|
||||
)
|
||||
|
||||
# All operations should be mapped to tools
|
||||
tools = mcp._tool_manager.list_tools()
|
||||
tool_names = {tool.name for tool in tools}
|
||||
|
||||
# Check that all operations were mapped as 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."""
|
||||
|
||||
@pytest.fixture
|
||||
def simple_api_spec(self) -> dict:
|
||||
"""A simple OpenAPI spec with both GET and POST methods."""
|
||||
return {
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "Test API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/items": {
|
||||
"get": {
|
||||
"operationId": "getItems",
|
||||
"responses": {"200": {"description": "Success"}},
|
||||
},
|
||||
"post": {
|
||||
"operationId": "createItem",
|
||||
"responses": {"201": {"description": "Created"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_client(self) -> httpx.AsyncClient:
|
||||
"""Simple mock client for testing."""
|
||||
|
||||
async def _responder(request):
|
||||
return httpx.Response(200, json={"result": "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
|
||||
)
|
||||
|
||||
# All operations (GET and POST) should be mapped to tools
|
||||
tools = server._tool_manager.list_tools()
|
||||
tool_names = {t.name for t in tools}
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
@app.get("/items")
|
||||
async def get_items():
|
||||
return [{"id": 1, "name": "Item 1"}]
|
||||
|
||||
@app.post("/items")
|
||||
async def create_item(item: dict):
|
||||
return {"id": 2, **item}
|
||||
|
||||
# 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
|
||||
resources = server._resource_manager.get_resources()
|
||||
templates = server._resource_manager.get_templates()
|
||||
assert len(resources) == 0
|
||||
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 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
|
||||
)
|
||||
],
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue