Jeremiah Lowin commited on
Commit
0410d0d
·
1 Parent(s): f084918

Allow * Methods and all routes as tools shortcuts

Browse files
docs/patterns/openapi.mdx CHANGED
@@ -61,19 +61,27 @@ Internally, FastMCP uses a priority-ordered set of `RouteMap` objects to determi
61
  # Simplified version of the actual mapping rules
62
  DEFAULT_ROUTE_MAPPINGS = [
63
  # GET with path parameters -> ResourceTemplate
64
- RouteMap(methods=["GET"], pattern=r".*\{.*\}.*",
65
- route_type=RouteType.RESOURCE_TEMPLATE),
 
 
 
66
 
67
  # GET without path parameters -> Resource
68
- RouteMap(methods=["GET"], pattern=r".*",
69
- route_type=RouteType.RESOURCE),
 
 
 
70
 
71
  # All other methods -> Tool
72
- RouteMap(methods=["POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
73
- pattern=r".*", route_type=RouteType.TOOL),
 
 
 
74
  ]
75
  ```
76
-
77
  ### Custom Route Maps
78
 
79
  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(
97
  )
98
  ```
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  ## How It Works
101
 
102
  1. FastMCP parses your OpenAPI spec to extract routes and schemas
 
61
  # Simplified version of the actual mapping rules
62
  DEFAULT_ROUTE_MAPPINGS = [
63
  # GET with path parameters -> ResourceTemplate
64
+ RouteMap(
65
+ methods=["GET"],
66
+ pattern=r".*\{.*\}.*",
67
+ route_type=RouteType.RESOURCE_TEMPLATE,
68
+ ),
69
 
70
  # GET without path parameters -> Resource
71
+ RouteMap(
72
+ methods=["GET"],
73
+ pattern=r".*",
74
+ route_type=RouteType.RESOURCE,
75
+ ),
76
 
77
  # All other methods -> Tool
78
+ RouteMap(
79
+ methods="*",
80
+ pattern=r".*",
81
+ route_type=RouteType.TOOL,
82
+ ),
83
  ]
84
  ```
 
85
  ### Custom Route Maps
86
 
87
  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.
 
105
  )
106
  ```
107
 
108
+
109
+ ### All Routes as Tools
110
+
111
+ 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:
112
+
113
+ ```python
114
+ # Make all endpoints tools, regardless of HTTP method
115
+ mcp = FastMCP.from_openapi(
116
+ openapi_spec=spec,
117
+ client=api_client,
118
+ all_routes_as_tools=True
119
+ )
120
+ ```
121
+
122
+ This is equivalent to defining a single route map that matches all routes:
123
+
124
+ ```python
125
+ # Same effect as all_routes_as_tools=True
126
+ mcp = FastMCP.from_openapi(
127
+ openapi_spec=spec,
128
+ client=api_client,
129
+ route_maps=[
130
+ RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)
131
+ ]
132
+ )
133
+ ```
134
+
135
+ Note that `all_routes_as_tools` and `route_maps` cannot be used together - if you need more complex mapping rules, use `route_maps` instead.
136
+
137
  ## How It Works
138
 
139
  1. FastMCP parses your OpenAPI spec to extract routes and schemas
src/fastmcp/server/openapi.py CHANGED
@@ -47,7 +47,7 @@ class RouteType(enum.Enum):
47
  class RouteMap:
48
  """Mapping configuration for HTTP routes to FastMCP component types."""
49
 
50
- methods: list[HttpMethod]
51
  pattern: Pattern[str] | str
52
  route_type: RouteType
53
 
@@ -86,7 +86,7 @@ def _determine_route_type(
86
  # Check mappings in priority order (first match wins)
87
  for route_map in mappings:
88
  # Check if the HTTP method matches
89
- if route.method in route_map.methods:
90
  # Handle both string patterns and compiled Pattern objects
91
  if isinstance(route_map.pattern, Pattern):
92
  pattern_matches = route_map.pattern.search(route.path)
 
47
  class RouteMap:
48
  """Mapping configuration for HTTP routes to FastMCP component types."""
49
 
50
+ methods: list[HttpMethod] | Literal["*"]
51
  pattern: Pattern[str] | str
52
  route_type: RouteType
53
 
 
86
  # Check mappings in priority order (first match wins)
87
  for route_map in mappings:
88
  # Check if the HTTP method matches
89
+ if route_map.methods == "*" or route.method in route_map.methods:
90
  # Handle both string patterns and compiled Pattern objects
91
  if isinstance(route_map.pattern, Pattern):
92
  pattern_matches = route_map.pattern.search(route.path)
src/fastmcp/server/server.py CHANGED
@@ -62,7 +62,7 @@ from fastmcp.utilities.logging import get_logger
62
  if TYPE_CHECKING:
63
  from fastmcp.client import Client
64
  from fastmcp.client.transports import ClientTransport
65
- from fastmcp.server.openapi import FastMCPOpenAPI
66
  from fastmcp.server.proxy import FastMCPProxy
67
  logger = get_logger(__name__)
68
 
@@ -1082,24 +1082,59 @@ class FastMCP(Generic[LifespanResultT]):
1082
 
1083
  @classmethod
1084
  def from_openapi(
1085
- cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, **settings: Any
 
 
 
 
 
1086
  ) -> FastMCPOpenAPI:
1087
  """
1088
  Create a FastMCP server from an OpenAPI specification.
1089
  """
1090
- from .openapi import FastMCPOpenAPI
 
 
 
 
 
 
 
 
 
 
 
 
1091
 
1092
- return FastMCPOpenAPI(openapi_spec=openapi_spec, client=client, **settings)
 
 
 
 
 
1093
 
1094
  @classmethod
1095
  def from_fastapi(
1096
- cls, app: Any, name: str | None = None, **settings: Any
 
 
 
 
 
1097
  ) -> FastMCPOpenAPI:
1098
  """
1099
  Create a FastMCP server from a FastAPI application.
1100
  """
1101
 
1102
- from .openapi import FastMCPOpenAPI
 
 
 
 
 
 
 
 
1103
 
1104
  client = httpx.AsyncClient(
1105
  transport=httpx.ASGITransport(app=app), base_url="http://fastapi"
@@ -1108,7 +1143,11 @@ class FastMCP(Generic[LifespanResultT]):
1108
  name = name or app.title
1109
 
1110
  return FastMCPOpenAPI(
1111
- openapi_spec=app.openapi(), client=client, name=name, **settings
 
 
 
 
1112
  )
1113
 
1114
  @classmethod
 
62
  if TYPE_CHECKING:
63
  from fastmcp.client import Client
64
  from fastmcp.client.transports import ClientTransport
65
+ from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap
66
  from fastmcp.server.proxy import FastMCPProxy
67
  logger = get_logger(__name__)
68
 
 
1082
 
1083
  @classmethod
1084
  def from_openapi(
1085
+ cls,
1086
+ openapi_spec: dict[str, Any],
1087
+ client: httpx.AsyncClient,
1088
+ route_maps: list[RouteMap] | None = None,
1089
+ all_routes_as_tools: bool = False,
1090
+ **settings: Any,
1091
  ) -> FastMCPOpenAPI:
1092
  """
1093
  Create a FastMCP server from an OpenAPI specification.
1094
  """
1095
+ from .openapi import FastMCPOpenAPI, RouteMap, RouteType
1096
+
1097
+ if all_routes_as_tools and route_maps:
1098
+ raise ValueError("Cannot specify both all_routes_as_tools and route_maps")
1099
+
1100
+ elif all_routes_as_tools:
1101
+ route_maps = [
1102
+ RouteMap(
1103
+ methods="*",
1104
+ pattern=r".*",
1105
+ route_type=RouteType.TOOL,
1106
+ )
1107
+ ]
1108
 
1109
+ return FastMCPOpenAPI(
1110
+ openapi_spec=openapi_spec,
1111
+ client=client,
1112
+ route_maps=route_maps,
1113
+ **settings,
1114
+ )
1115
 
1116
  @classmethod
1117
  def from_fastapi(
1118
+ cls,
1119
+ app: Any,
1120
+ name: str | None = None,
1121
+ route_maps: list[RouteMap] | None = None,
1122
+ all_routes_as_tools: bool = False,
1123
+ **settings: Any,
1124
  ) -> FastMCPOpenAPI:
1125
  """
1126
  Create a FastMCP server from a FastAPI application.
1127
  """
1128
 
1129
+ from .openapi import FastMCPOpenAPI, RouteMap, RouteType
1130
+
1131
+ if all_routes_as_tools and route_maps:
1132
+ raise ValueError("Cannot specify both all_routes_as_tools and route_maps")
1133
+
1134
+ elif all_routes_as_tools:
1135
+ route_maps = [
1136
+ RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)
1137
+ ]
1138
 
1139
  client = httpx.AsyncClient(
1140
  transport=httpx.ASGITransport(app=app), base_url="http://fastapi"
 
1143
  name = name or app.title
1144
 
1145
  return FastMCPOpenAPI(
1146
+ openapi_spec=app.openapi(),
1147
+ client=client,
1148
+ name=name,
1149
+ route_maps=route_maps,
1150
+ **settings,
1151
  )
1152
 
1153
  @classmethod
tests/server/test_openapi.py CHANGED
@@ -1890,3 +1890,287 @@ class TestEnumHandling:
1890
  assert "enum" in enum_def
1891
  assert enum_def["enum"] == ["foo", "bar", "baz"]
1892
  assert enum_def["type"] == "string"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1890
  assert "enum" in enum_def
1891
  assert enum_def["enum"] == ["foo", "bar", "baz"]
1892
  assert enum_def["type"] == "string"
1893
+
1894
+
1895
+ class TestRouteMapWildcard:
1896
+ """Tests for wildcard RouteMap methods functionality."""
1897
+
1898
+ @pytest.fixture
1899
+ def basic_openapi_spec(self) -> dict:
1900
+ """Create a minimal OpenAPI spec with different HTTP methods."""
1901
+ return {
1902
+ "openapi": "3.1.0",
1903
+ "info": {"title": "Test API", "version": "1.0.0"},
1904
+ "paths": {
1905
+ "/users": {
1906
+ "get": {
1907
+ "operationId": "getUsers",
1908
+ "responses": {"200": {"description": "Success"}},
1909
+ },
1910
+ "post": {
1911
+ "operationId": "createUser",
1912
+ "responses": {"201": {"description": "Created"}},
1913
+ },
1914
+ },
1915
+ "/posts": {
1916
+ "get": {
1917
+ "operationId": "getPosts",
1918
+ "responses": {"200": {"description": "Success"}},
1919
+ },
1920
+ "post": {
1921
+ "operationId": "createPost",
1922
+ "responses": {"201": {"description": "Created"}},
1923
+ },
1924
+ },
1925
+ },
1926
+ }
1927
+
1928
+ @pytest.fixture
1929
+ async def mock_basic_client(self) -> httpx.AsyncClient:
1930
+ """Create a simple mock client."""
1931
+
1932
+ async def _responder(request):
1933
+ return httpx.Response(200, json={"status": "ok"})
1934
+
1935
+ transport = httpx.MockTransport(_responder)
1936
+ return httpx.AsyncClient(transport=transport, base_url="http://test")
1937
+
1938
+ async def test_wildcard_matches_all_methods(
1939
+ self, basic_openapi_spec, mock_basic_client
1940
+ ):
1941
+ """Test that a RouteMap with methods='*' matches all HTTP methods."""
1942
+ # Create a single route map with wildcard method
1943
+ route_maps = [RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)]
1944
+
1945
+ mcp = FastMCPOpenAPI(
1946
+ openapi_spec=basic_openapi_spec,
1947
+ client=mock_basic_client,
1948
+ route_maps=route_maps,
1949
+ )
1950
+
1951
+ # All operations should be mapped to tools
1952
+ tools = mcp._tool_manager.list_tools()
1953
+ tool_names = {tool.name for tool in tools}
1954
+
1955
+ # Check that all operations were mapped as tools
1956
+ expected_tools = {"getUsers", "createUser", "getPosts", "createPost"}
1957
+ assert tool_names == expected_tools
1958
+
1959
+ # No resources or templates should be created
1960
+ resources = mcp._resource_manager.get_resources()
1961
+ templates = mcp._resource_manager.get_templates()
1962
+ assert len(resources) == 0
1963
+ assert len(templates) == 0
1964
+
1965
+ async def test_priority_specific_over_wildcard(
1966
+ self, basic_openapi_spec, mock_basic_client
1967
+ ):
1968
+ """Test that specific method maps take priority over wildcard."""
1969
+ # Create route maps with specific method first, then wildcard
1970
+ route_maps = [
1971
+ # GET operations should be mapped to resources
1972
+ RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE),
1973
+ # All other operations should be mapped to tools
1974
+ RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL),
1975
+ ]
1976
+
1977
+ mcp = FastMCPOpenAPI(
1978
+ openapi_spec=basic_openapi_spec,
1979
+ client=mock_basic_client,
1980
+ route_maps=route_maps,
1981
+ )
1982
+
1983
+ # Check GET operations went to resources
1984
+ resources = mcp._resource_manager.get_resources()
1985
+ resource_names = {r.name for r in resources.values()}
1986
+ assert "getUsers" in resource_names
1987
+ assert "getPosts" in resource_names
1988
+ assert len(resources) == 2
1989
+
1990
+ # Check other operations went to tools
1991
+ tools = mcp._tool_manager.list_tools()
1992
+ tool_names = {tool.name for tool in tools}
1993
+ assert "createUser" in tool_names
1994
+ assert "createPost" in tool_names
1995
+ assert len(tools) == 2
1996
+
1997
+ async def test_priority_wildcard_first(self, basic_openapi_spec, mock_basic_client):
1998
+ """Test that when wildcard is first, it matches everything."""
1999
+ # Create route maps with wildcard first, then specific methods
2000
+ route_maps = [
2001
+ # Wildcard first matches everything
2002
+ RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL),
2003
+ # This should never be reached
2004
+ RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE),
2005
+ ]
2006
+
2007
+ mcp = FastMCPOpenAPI(
2008
+ openapi_spec=basic_openapi_spec,
2009
+ client=mock_basic_client,
2010
+ route_maps=route_maps,
2011
+ )
2012
+
2013
+ # All operations should be tools
2014
+ tools = mcp._tool_manager.list_tools()
2015
+ assert len(tools) == 4
2016
+
2017
+ # No resources should be created
2018
+ resources = mcp._resource_manager.get_resources()
2019
+ assert len(resources) == 0
2020
+
2021
+ async def test_wildcard_with_specific_paths(
2022
+ self, basic_openapi_spec, mock_basic_client
2023
+ ):
2024
+ """Test wildcard methods combined with specific path patterns."""
2025
+ route_maps = [
2026
+ # All methods on /users path -> Resources
2027
+ RouteMap(methods="*", pattern=r".*/users$", route_type=RouteType.RESOURCE),
2028
+ # All methods on /posts path -> Tools
2029
+ RouteMap(methods="*", pattern=r".*/posts$", route_type=RouteType.TOOL),
2030
+ ]
2031
+
2032
+ mcp = FastMCPOpenAPI(
2033
+ openapi_spec=basic_openapi_spec,
2034
+ client=mock_basic_client,
2035
+ route_maps=route_maps,
2036
+ )
2037
+
2038
+ # Check /users operations went to resources
2039
+ resources = mcp._resource_manager.get_resources()
2040
+ resource_names = {r.name for r in resources.values()}
2041
+ assert "getUsers" in resource_names
2042
+ assert "createUser" in resource_names
2043
+ assert len(resources) == 2
2044
+
2045
+ # Check /posts operations went to tools
2046
+ tools = mcp._tool_manager.list_tools()
2047
+ tool_names = {tool.name for tool in tools}
2048
+ assert "getPosts" in tool_names
2049
+ assert "createPost" in tool_names
2050
+ assert len(tools) == 2
2051
+
2052
+
2053
+ class TestAllRoutesAsTools:
2054
+ """Tests for the all_routes_as_tools parameter in FastMCP class methods."""
2055
+
2056
+ @pytest.fixture
2057
+ def simple_api_spec(self) -> dict:
2058
+ """A simple OpenAPI spec with both GET and POST methods."""
2059
+ return {
2060
+ "openapi": "3.1.0",
2061
+ "info": {"title": "Test API", "version": "1.0.0"},
2062
+ "paths": {
2063
+ "/items": {
2064
+ "get": {
2065
+ "operationId": "getItems",
2066
+ "responses": {"200": {"description": "Success"}},
2067
+ },
2068
+ "post": {
2069
+ "operationId": "createItem",
2070
+ "responses": {"201": {"description": "Created"}},
2071
+ },
2072
+ },
2073
+ },
2074
+ }
2075
+
2076
+ @pytest.fixture
2077
+ async def mock_client(self) -> httpx.AsyncClient:
2078
+ """Simple mock client for testing."""
2079
+
2080
+ async def _responder(request):
2081
+ return httpx.Response(200, json={"result": "ok"})
2082
+
2083
+ transport = httpx.MockTransport(_responder)
2084
+ return httpx.AsyncClient(transport=transport, base_url="http://test")
2085
+
2086
+ async def test_from_openapi_all_routes_as_tools(self, simple_api_spec, mock_client):
2087
+ """Test FastMCP.from_openapi with all_routes_as_tools=True."""
2088
+ # Create server with all routes as tools
2089
+ server = FastMCP.from_openapi(
2090
+ openapi_spec=simple_api_spec, client=mock_client, all_routes_as_tools=True
2091
+ )
2092
+
2093
+ # All operations (GET and POST) should be mapped to tools
2094
+ tools = server._tool_manager.list_tools()
2095
+ tool_names = {t.name for t in tools}
2096
+
2097
+ assert "getItems" in tool_names
2098
+ assert "createItem" in tool_names
2099
+ assert len(tools) == 2
2100
+
2101
+ # No resources or templates should be created
2102
+ resources = server._resource_manager.get_resources()
2103
+ templates = server._resource_manager.get_templates()
2104
+ assert len(resources) == 0
2105
+ assert len(templates) == 0
2106
+
2107
+ async def test_from_openapi_all_routes_as_tools_conflicting_args(
2108
+ self, simple_api_spec, mock_client
2109
+ ):
2110
+ """Test FastMCP.from_openapi raises error when both route_maps and all_routes_as_tools are provided."""
2111
+ # Try to create server with conflicting args
2112
+ with pytest.raises(
2113
+ ValueError, match="Cannot specify both all_routes_as_tools and route_maps"
2114
+ ):
2115
+ FastMCP.from_openapi(
2116
+ openapi_spec=simple_api_spec,
2117
+ client=mock_client,
2118
+ all_routes_as_tools=True,
2119
+ route_maps=[
2120
+ RouteMap(
2121
+ methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE
2122
+ )
2123
+ ],
2124
+ )
2125
+
2126
+ async def test_from_fastapi_all_routes_as_tools(self):
2127
+ """Test FastMCP.from_fastapi with all_routes_as_tools=True."""
2128
+ # Create a simple FastAPI app
2129
+ app = FastAPI(title="Test FastAPI")
2130
+
2131
+ @app.get("/items")
2132
+ async def get_items():
2133
+ return [{"id": 1, "name": "Item 1"}]
2134
+
2135
+ @app.post("/items")
2136
+ async def create_item(item: dict):
2137
+ return {"id": 2, **item}
2138
+
2139
+ # Create server with all routes as tools
2140
+ server = FastMCP.from_fastapi(app=app, all_routes_as_tools=True)
2141
+
2142
+ # Both GET and POST operations should be mapped to tools
2143
+ tools = server._tool_manager.list_tools()
2144
+
2145
+ # Get tool names from the generated operation IDs
2146
+ tool_names = {t.name for t in tools}
2147
+
2148
+ # Check that both routes were mapped to tools
2149
+ # The exact names depend on FastAPI's operation ID generation
2150
+ assert len(tools) == 2
2151
+ assert any("get" in name.lower() for name in tool_names)
2152
+ assert any("post" in name.lower() for name in tool_names)
2153
+
2154
+ # No resources or templates should be created
2155
+ resources = server._resource_manager.get_resources()
2156
+ templates = server._resource_manager.get_templates()
2157
+ assert len(resources) == 0
2158
+ assert len(templates) == 0
2159
+
2160
+ async def test_from_fastapi_all_routes_as_tools_conflicting_args(self):
2161
+ """Test FastMCP.from_fastapi raises error when both route_maps and all_routes_as_tools are provided."""
2162
+ app = FastAPI(title="Test FastAPI")
2163
+
2164
+ # Try to create server with conflicting args
2165
+ with pytest.raises(
2166
+ ValueError, match="Cannot specify both all_routes_as_tools and route_maps"
2167
+ ):
2168
+ FastMCP.from_fastapi(
2169
+ app=app,
2170
+ all_routes_as_tools=True,
2171
+ route_maps=[
2172
+ RouteMap(
2173
+ methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE
2174
+ )
2175
+ ],
2176
+ )