Jeremiah Lowin commited on
Commit
3993fde
·
1 Parent(s): d625dbe

Add route_map_fn for fine control

Browse files
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, RouteType
618
  import httpx
619
 
620
  # Define custom route mappings
@@ -633,12 +636,26 @@ class FastMCPOpenAPI(FastMCP):
633
  ),
634
  ]
635
 
636
- # Create server with custom mappings
 
 
 
 
 
 
 
 
 
 
 
 
 
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)