Jeremiah Lowin commited on
Commit
00da27f
·
1 Parent(s): 23dba76

Clean up route maps

Browse files
src/fastmcp/server/openapi.py CHANGED
@@ -103,15 +103,27 @@ class RouteType(enum.Enum):
103
  IGNORE = "IGNORE"
104
 
105
 
106
- @dataclass
107
  class RouteMap:
108
  """Mapping configuration for HTTP routes to FastMCP component types."""
109
 
110
  methods: list[HttpMethod] | Literal["*"] = field(default="*")
111
  pattern: Pattern[str] | str = field(default=r".*")
112
- mcp_type: MCPType | None = field(default=None)
113
  route_type: RouteType | MCPType | None = field(default=None)
114
- tags: set[str] = field(default_factory=set)
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
  def __post_init__(self):
117
  """Validate and process the route map after initialization."""
@@ -165,7 +177,7 @@ DEFAULT_ROUTE_MAPPINGS = [
165
  def _determine_route_type(
166
  route: openapi.HTTPRoute,
167
  mappings: list[RouteMap],
168
- ) -> MCPType:
169
  """
170
  Determines the FastMCP component type based on the route and mappings.
171
 
@@ -174,7 +186,7 @@ def _determine_route_type(
174
  mappings: List of RouteMap objects in priority order
175
 
176
  Returns:
177
- MCPType for this route
178
  """
179
  # Check mappings in priority order (first match wins)
180
  for route_map in mappings:
@@ -201,10 +213,10 @@ def _determine_route_type(
201
  logger.debug(
202
  f"Route {route.method} {route.path} matched mapping to {route_map.mcp_type.name}"
203
  )
204
- return route_map.mcp_type
205
 
206
  # Default fallback
207
- return MCPType.TOOL
208
 
209
 
210
  class OpenAPITool(Tool):
@@ -217,7 +229,7 @@ class OpenAPITool(Tool):
217
  name: str,
218
  description: str,
219
  parameters: dict[str, Any],
220
- tags: set[str] = set(),
221
  timeout: float | None = None,
222
  annotations: ToolAnnotations | None = None,
223
  serializer: Callable[[Any], str] | None = None,
@@ -226,7 +238,7 @@ class OpenAPITool(Tool):
226
  name=name,
227
  description=description,
228
  parameters=parameters,
229
- tags=tags,
230
  annotations=annotations,
231
  serializer=serializer,
232
  )
@@ -680,6 +692,8 @@ class FastMCPOpenAPI(FastMCP):
680
  route_map_fn: RouteMapFn | None = None,
681
  mcp_component_fn: ComponentFn | None = None,
682
  mcp_names: dict[str, str] | None = None,
 
 
683
  timeout: float | None = None,
684
  **settings: Any,
685
  ):
@@ -702,6 +716,11 @@ class FastMCPOpenAPI(FastMCP):
702
  operationId up to the first double underscore. If no operationId exists,
703
  falls back to slugified summary or path-based naming.
704
  All names are truncated to 56 characters maximum.
 
 
 
 
 
705
  timeout: Optional timeout (in seconds) for all requests
706
  **settings: Additional settings for FastMCP
707
  """
@@ -709,9 +728,7 @@ class FastMCPOpenAPI(FastMCP):
709
 
710
  self._client = client
711
  self._timeout = timeout
712
- self._route_map_fn = route_map_fn
713
  self._mcp_component_fn = mcp_component_fn
714
- self._mcp_names = mcp_names or {}
715
 
716
  # Keep track of names to detect collisions
717
  self._used_names = {
@@ -727,12 +744,16 @@ class FastMCPOpenAPI(FastMCP):
727
  route_maps = (route_maps or []) + DEFAULT_ROUTE_MAPPINGS
728
  for route in http_routes:
729
  # Determine route type based on mappings or default rules
730
- route_type = _determine_route_type(route, route_maps)
 
 
 
 
731
 
732
  # Call route_map_fn if provided
733
- if self._route_map_fn is not None:
734
  try:
735
- result = self._route_map_fn(route, route_type)
736
  if result is not None:
737
  route_type = result
738
  logger.debug(
@@ -746,29 +767,34 @@ class FastMCPOpenAPI(FastMCP):
746
  )
747
 
748
  # Generate a default name from the route
749
- component_name = self._generate_default_name(route, route_type)
 
 
 
 
750
 
751
  if route_type == MCPType.TOOL:
752
- self._create_openapi_tool(route, component_name)
753
  elif route_type == MCPType.RESOURCE:
754
- self._create_openapi_resource(route, component_name)
755
  elif route_type == MCPType.RESOURCE_TEMPLATE:
756
- self._create_openapi_template(route, component_name)
757
  elif route_type == MCPType.EXCLUDE:
758
  logger.info(f"Excluding route: {route.method} {route.path}")
759
 
760
  logger.info(f"Created FastMCP OpenAPI server with {len(http_routes)} routes")
761
 
762
  def _generate_default_name(
763
- self, route: openapi.HTTPRoute, mcp_type: MCPType
764
  ) -> str:
765
  """Generate a default name from the route using the configured strategy."""
766
  name = ""
 
767
 
768
  # First check if there's a custom mapping for this operationId
769
  if route.operation_id:
770
- if route.operation_id in self._mcp_names:
771
- name = self._mcp_names[route.operation_id]
772
  else:
773
  # If there's a double underscore in the operationId, use the first part
774
  name = route.operation_id.split("__")[0]
@@ -813,7 +839,12 @@ class FastMCPOpenAPI(FastMCP):
813
 
814
  return new_name
815
 
816
- def _create_openapi_tool(self, route: openapi.HTTPRoute, name: str):
 
 
 
 
 
817
  """Creates and registers an OpenAPITool with enhanced description."""
818
  combined_schema = _combine_schemas(route)
819
 
@@ -840,7 +871,7 @@ class FastMCPOpenAPI(FastMCP):
840
  name=tool_name,
841
  description=enhanced_description,
842
  parameters=combined_schema,
843
- tags=set(route.tags or []),
844
  timeout=self._timeout,
845
  )
846
 
@@ -861,7 +892,12 @@ class FastMCPOpenAPI(FastMCP):
861
  f"Registered TOOL: {tool_name} ({route.method} {route.path}) with tags: {route.tags}"
862
  )
863
 
864
- def _create_openapi_resource(self, route: openapi.HTTPRoute, name: str):
 
 
 
 
 
865
  """Creates and registers an OpenAPIResource with enhanced description."""
866
  # Get a unique resource name
867
  resource_name = self._get_unique_name(name, "resource")
@@ -885,7 +921,7 @@ class FastMCPOpenAPI(FastMCP):
885
  uri=resource_uri,
886
  name=resource_name,
887
  description=enhanced_description,
888
- tags=set(route.tags or []),
889
  timeout=self._timeout,
890
  )
891
 
@@ -906,7 +942,12 @@ class FastMCPOpenAPI(FastMCP):
906
  f"Registered RESOURCE: {resource_uri} ({route.method} {route.path}) with tags: {route.tags}"
907
  )
908
 
909
- def _create_openapi_template(self, route: openapi.HTTPRoute, name: str):
 
 
 
 
 
910
  """Creates and registers an OpenAPIResourceTemplate with enhanced description."""
911
  # Get a unique template name
912
  template_name = self._get_unique_name(name, "resource_template")
@@ -959,7 +1000,7 @@ class FastMCPOpenAPI(FastMCP):
959
  name=template_name,
960
  description=enhanced_description,
961
  parameters=template_params_schema,
962
- tags=set(route.tags or []),
963
  timeout=self._timeout,
964
  )
965
 
 
103
  IGNORE = "IGNORE"
104
 
105
 
106
+ @dataclass(kw_only=True)
107
  class RouteMap:
108
  """Mapping configuration for HTTP routes to FastMCP component types."""
109
 
110
  methods: list[HttpMethod] | Literal["*"] = field(default="*")
111
  pattern: Pattern[str] | str = field(default=r".*")
 
112
  route_type: RouteType | MCPType | None = field(default=None)
113
+ tags: set[str] = field(
114
+ default_factory=set,
115
+ metadata={"description": "A set of tags to match. All tags must match."},
116
+ )
117
+ mcp_type: MCPType | None = field(
118
+ default=None,
119
+ metadata={"description": "The type of FastMCP component to create."},
120
+ )
121
+ mcp_tags: set[str] = field(
122
+ default_factory=set,
123
+ metadata={
124
+ "description": "A set of tags to apply to the generated FastMCP component."
125
+ },
126
+ )
127
 
128
  def __post_init__(self):
129
  """Validate and process the route map after initialization."""
 
177
  def _determine_route_type(
178
  route: openapi.HTTPRoute,
179
  mappings: list[RouteMap],
180
+ ) -> RouteMap:
181
  """
182
  Determines the FastMCP component type based on the route and mappings.
183
 
 
186
  mappings: List of RouteMap objects in priority order
187
 
188
  Returns:
189
+ The RouteMap that matches the route, or a catchall "Tool" RouteMap if no match is found.
190
  """
191
  # Check mappings in priority order (first match wins)
192
  for route_map in mappings:
 
213
  logger.debug(
214
  f"Route {route.method} {route.path} matched mapping to {route_map.mcp_type.name}"
215
  )
216
+ return route_map
217
 
218
  # Default fallback
219
+ return RouteMap(mcp_type=MCPType.TOOL)
220
 
221
 
222
  class OpenAPITool(Tool):
 
229
  name: str,
230
  description: str,
231
  parameters: dict[str, Any],
232
+ tags: set[str] | None = None,
233
  timeout: float | None = None,
234
  annotations: ToolAnnotations | None = None,
235
  serializer: Callable[[Any], str] | None = None,
 
238
  name=name,
239
  description=description,
240
  parameters=parameters,
241
+ tags=tags or set(),
242
  annotations=annotations,
243
  serializer=serializer,
244
  )
 
692
  route_map_fn: RouteMapFn | None = None,
693
  mcp_component_fn: ComponentFn | None = None,
694
  mcp_names: dict[str, str] | None = None,
695
+ mcp_tags: dict[str, set[str]] | None = None,
696
+ tags: set[str] | None = None,
697
  timeout: float | None = None,
698
  **settings: Any,
699
  ):
 
716
  operationId up to the first double underscore. If no operationId exists,
717
  falls back to slugified summary or path-based naming.
718
  All names are truncated to 56 characters maximum.
719
+ mcp_tags: Optional dictionary mapping operationId to set of tags.
720
+ If an operationId is not in the dictionary, falls back to using the
721
+ tags from the route.
722
+ tags: Optional set of tags to add to all components. Components always receive any tags
723
+ from the route.
724
  timeout: Optional timeout (in seconds) for all requests
725
  **settings: Additional settings for FastMCP
726
  """
 
728
 
729
  self._client = client
730
  self._timeout = timeout
 
731
  self._mcp_component_fn = mcp_component_fn
 
732
 
733
  # Keep track of names to detect collisions
734
  self._used_names = {
 
744
  route_maps = (route_maps or []) + DEFAULT_ROUTE_MAPPINGS
745
  for route in http_routes:
746
  # Determine route type based on mappings or default rules
747
+ route_map = _determine_route_type(route, route_maps)
748
+
749
+ # TODO: remove this once RouteType is removed and mcp_type is typed as MCPType without | None
750
+ assert route_map.mcp_type is not None
751
+ route_type = route_map.mcp_type
752
 
753
  # Call route_map_fn if provided
754
+ if route_map_fn is not None:
755
  try:
756
+ result = route_map_fn(route, route_type)
757
  if result is not None:
758
  route_type = result
759
  logger.debug(
 
767
  )
768
 
769
  # Generate a default name from the route
770
+ component_name = self._generate_default_name(route, mcp_names)
771
+
772
+ route_tags = set(route.tags) | route_map.mcp_tags | (tags or set())
773
+ if route.operation_id:
774
+ route_tags |= (mcp_tags or {}).get(route.operation_id, set())
775
 
776
  if route_type == MCPType.TOOL:
777
+ self._create_openapi_tool(route, component_name, tags=route_tags)
778
  elif route_type == MCPType.RESOURCE:
779
+ self._create_openapi_resource(route, component_name, tags=route_tags)
780
  elif route_type == MCPType.RESOURCE_TEMPLATE:
781
+ self._create_openapi_template(route, component_name, tags=route_tags)
782
  elif route_type == MCPType.EXCLUDE:
783
  logger.info(f"Excluding route: {route.method} {route.path}")
784
 
785
  logger.info(f"Created FastMCP OpenAPI server with {len(http_routes)} routes")
786
 
787
  def _generate_default_name(
788
+ self, route: openapi.HTTPRoute, mcp_names_map: dict[str, str] | None = None
789
  ) -> str:
790
  """Generate a default name from the route using the configured strategy."""
791
  name = ""
792
+ mcp_names_map = mcp_names_map or {}
793
 
794
  # First check if there's a custom mapping for this operationId
795
  if route.operation_id:
796
+ if route.operation_id in mcp_names_map:
797
+ name = mcp_names_map[route.operation_id]
798
  else:
799
  # If there's a double underscore in the operationId, use the first part
800
  name = route.operation_id.split("__")[0]
 
839
 
840
  return new_name
841
 
842
+ def _create_openapi_tool(
843
+ self,
844
+ route: openapi.HTTPRoute,
845
+ name: str,
846
+ tags: set[str],
847
+ ):
848
  """Creates and registers an OpenAPITool with enhanced description."""
849
  combined_schema = _combine_schemas(route)
850
 
 
871
  name=tool_name,
872
  description=enhanced_description,
873
  parameters=combined_schema,
874
+ tags=set(route.tags or []) | tags,
875
  timeout=self._timeout,
876
  )
877
 
 
892
  f"Registered TOOL: {tool_name} ({route.method} {route.path}) with tags: {route.tags}"
893
  )
894
 
895
+ def _create_openapi_resource(
896
+ self,
897
+ route: openapi.HTTPRoute,
898
+ name: str,
899
+ tags: set[str],
900
+ ):
901
  """Creates and registers an OpenAPIResource with enhanced description."""
902
  # Get a unique resource name
903
  resource_name = self._get_unique_name(name, "resource")
 
921
  uri=resource_uri,
922
  name=resource_name,
923
  description=enhanced_description,
924
+ tags=set(route.tags or []) | tags,
925
  timeout=self._timeout,
926
  )
927
 
 
942
  f"Registered RESOURCE: {resource_uri} ({route.method} {route.path}) with tags: {route.tags}"
943
  )
944
 
945
+ def _create_openapi_template(
946
+ self,
947
+ route: openapi.HTTPRoute,
948
+ name: str,
949
+ tags: set[str],
950
+ ):
951
  """Creates and registers an OpenAPIResourceTemplate with enhanced description."""
952
  # Get a unique template name
953
  template_name = self._get_unique_name(name, "resource_template")
 
1000
  name=template_name,
1001
  description=enhanced_description,
1002
  parameters=template_params_schema,
1003
+ tags=set(route.tags or []) | tags,
1004
  timeout=self._timeout,
1005
  )
1006
 
tests/server/openapi/test_route_map_fn.py CHANGED
@@ -3,7 +3,7 @@
3
  import httpx
4
  import pytest
5
 
6
- from fastmcp.server.openapi import FastMCPOpenAPI, MCPType
7
 
8
 
9
  @pytest.fixture
@@ -175,8 +175,6 @@ def test_route_map_fn_returns_none(sample_openapi_spec, http_client):
175
  def test_route_map_fn_called_for_excluded_routes(sample_openapi_spec, http_client):
176
  """Test that route_map_fn is called for excluded routes and can rescue them."""
177
 
178
- from fastmcp.server.openapi import RouteMap
179
-
180
  # Exclude all admin routes
181
  route_maps = [
182
  RouteMap(
@@ -188,7 +186,7 @@ def test_route_map_fn_called_for_excluded_routes(sample_openapi_spec, http_clien
188
 
189
  def track_calls_and_rescue(route, mcp_type):
190
  """Track which routes the function is called for and rescue some excluded routes."""
191
- called_routes.append(route.path)
192
 
193
  # Rescue the admin GET route by converting it to a tool
194
  if route.path == "/admin/settings" and route.method == "GET":
@@ -205,10 +203,11 @@ def test_route_map_fn_called_for_excluded_routes(sample_openapi_spec, http_clien
205
  )
206
 
207
  # route_map_fn should now be called for all routes, including excluded admin routes
208
- assert "/admin/settings" in called_routes
209
- assert "/users" in called_routes
210
- assert "/users/{id}" in called_routes
211
- assert "/api/data" in called_routes
 
212
 
213
  # The rescued admin GET route should now be a tool
214
  tools = server._tool_manager._tools
@@ -296,7 +295,7 @@ def test_combined_route_map_fn_and_component_fn(sample_openapi_spec, http_client
296
 
297
  def test_route_map_fn_signature_validation():
298
  """Test that route_map_fn has the correct signature."""
299
- from fastmcp.server.openapi import RouteMapFn
300
  from fastmcp.utilities import openapi
301
 
302
  # This is more of a type checking test
@@ -335,8 +334,6 @@ def test_component_fn_signature_validation():
335
  def test_route_map_fn_can_rescue_excluded_routes(sample_openapi_spec, http_client):
336
  """Test that route_map_fn can rescue routes that were excluded by RouteMap."""
337
 
338
- from fastmcp.server.openapi import RouteMap
339
-
340
  # Exclude ALL routes by default
341
  route_maps = [
342
  RouteMap(mcp_type=MCPType.EXCLUDE) # Catch-all exclusion
 
3
  import httpx
4
  import pytest
5
 
6
+ from fastmcp.server.openapi import FastMCPOpenAPI, MCPType, RouteMap, RouteMapFn
7
 
8
 
9
  @pytest.fixture
 
175
  def test_route_map_fn_called_for_excluded_routes(sample_openapi_spec, http_client):
176
  """Test that route_map_fn is called for excluded routes and can rescue them."""
177
 
 
 
178
  # Exclude all admin routes
179
  route_maps = [
180
  RouteMap(
 
186
 
187
  def track_calls_and_rescue(route, mcp_type):
188
  """Track which routes the function is called for and rescue some excluded routes."""
189
+ called_routes.append((route.method, route.path))
190
 
191
  # Rescue the admin GET route by converting it to a tool
192
  if route.path == "/admin/settings" and route.method == "GET":
 
203
  )
204
 
205
  # route_map_fn should now be called for all routes, including excluded admin routes
206
+ assert ("GET", "/admin/settings") in called_routes
207
+ assert ("GET", "/users") in called_routes
208
+ assert ("GET", "/users/{id}") in called_routes
209
+ assert ("GET", "/api/data") in called_routes
210
+ assert ("POST", "/admin/settings") in called_routes
211
 
212
  # The rescued admin GET route should now be a tool
213
  tools = server._tool_manager._tools
 
295
 
296
  def test_route_map_fn_signature_validation():
297
  """Test that route_map_fn has the correct signature."""
298
+
299
  from fastmcp.utilities import openapi
300
 
301
  # This is more of a type checking test
 
334
  def test_route_map_fn_can_rescue_excluded_routes(sample_openapi_spec, http_client):
335
  """Test that route_map_fn can rescue routes that were excluded by RouteMap."""
336
 
 
 
337
  # Exclude ALL routes by default
338
  route_maps = [
339
  RouteMap(mcp_type=MCPType.EXCLUDE) # Catch-all exclusion