Jeremiah Lowin commited on
Commit
05164d1
·
unverified ·
2 Parent(s): 55aaa0ddfb8d09

Merge pull request #791 from jlowin/tags

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,7 @@ 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 +715,8 @@ 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 +724,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 +740,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 +763,32 @@ 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 +833,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 +865,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 +886,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 +915,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 +936,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 +994,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
+ tags: set[str] | None = None,
696
  timeout: float | None = None,
697
  **settings: Any,
698
  ):
 
715
  operationId up to the first double underscore. If no operationId exists,
716
  falls back to slugified summary or path-based naming.
717
  All names are truncated to 56 characters maximum.
718
+ tags: Optional set of tags to add to all components. Components always receive any tags
719
+ from the route.
720
  timeout: Optional timeout (in seconds) for all requests
721
  **settings: Additional settings for FastMCP
722
  """
 
724
 
725
  self._client = client
726
  self._timeout = timeout
 
727
  self._mcp_component_fn = mcp_component_fn
 
728
 
729
  # Keep track of names to detect collisions
730
  self._used_names = {
 
740
  route_maps = (route_maps or []) + DEFAULT_ROUTE_MAPPINGS
741
  for route in http_routes:
742
  # Determine route type based on mappings or default rules
743
+ route_map = _determine_route_type(route, route_maps)
744
+
745
+ # TODO: remove this once RouteType is removed and mcp_type is typed as MCPType without | None
746
+ assert route_map.mcp_type is not None
747
+ route_type = route_map.mcp_type
748
 
749
  # Call route_map_fn if provided
750
+ if route_map_fn is not None:
751
  try:
752
+ result = route_map_fn(route, route_type)
753
  if result is not None:
754
  route_type = result
755
  logger.debug(
 
763
  )
764
 
765
  # Generate a default name from the route
766
+ component_name = self._generate_default_name(route, mcp_names)
767
+
768
+ route_tags = set(route.tags) | route_map.mcp_tags | (tags or set())
769
 
770
  if route_type == MCPType.TOOL:
771
+ self._create_openapi_tool(route, component_name, tags=route_tags)
772
  elif route_type == MCPType.RESOURCE:
773
+ self._create_openapi_resource(route, component_name, tags=route_tags)
774
  elif route_type == MCPType.RESOURCE_TEMPLATE:
775
+ self._create_openapi_template(route, component_name, tags=route_tags)
776
  elif route_type == MCPType.EXCLUDE:
777
  logger.info(f"Excluding route: {route.method} {route.path}")
778
 
779
  logger.info(f"Created FastMCP OpenAPI server with {len(http_routes)} routes")
780
 
781
  def _generate_default_name(
782
+ self, route: openapi.HTTPRoute, mcp_names_map: dict[str, str] | None = None
783
  ) -> str:
784
  """Generate a default name from the route using the configured strategy."""
785
  name = ""
786
+ mcp_names_map = mcp_names_map or {}
787
 
788
  # First check if there's a custom mapping for this operationId
789
  if route.operation_id:
790
+ if route.operation_id in mcp_names_map:
791
+ name = mcp_names_map[route.operation_id]
792
  else:
793
  # If there's a double underscore in the operationId, use the first part
794
  name = route.operation_id.split("__")[0]
 
833
 
834
  return new_name
835
 
836
+ def _create_openapi_tool(
837
+ self,
838
+ route: openapi.HTTPRoute,
839
+ name: str,
840
+ tags: set[str],
841
+ ):
842
  """Creates and registers an OpenAPITool with enhanced description."""
843
  combined_schema = _combine_schemas(route)
844
 
 
865
  name=tool_name,
866
  description=enhanced_description,
867
  parameters=combined_schema,
868
+ tags=set(route.tags or []) | tags,
869
  timeout=self._timeout,
870
  )
871
 
 
886
  f"Registered TOOL: {tool_name} ({route.method} {route.path}) with tags: {route.tags}"
887
  )
888
 
889
+ def _create_openapi_resource(
890
+ self,
891
+ route: openapi.HTTPRoute,
892
+ name: str,
893
+ tags: set[str],
894
+ ):
895
  """Creates and registers an OpenAPIResource with enhanced description."""
896
  # Get a unique resource name
897
  resource_name = self._get_unique_name(name, "resource")
 
915
  uri=resource_uri,
916
  name=resource_name,
917
  description=enhanced_description,
918
+ tags=set(route.tags or []) | tags,
919
  timeout=self._timeout,
920
  )
921
 
 
936
  f"Registered RESOURCE: {resource_uri} ({route.method} {route.path}) with tags: {route.tags}"
937
  )
938
 
939
+ def _create_openapi_template(
940
+ self,
941
+ route: openapi.HTTPRoute,
942
+ name: str,
943
+ tags: set[str],
944
+ ):
945
  """Creates and registers an OpenAPIResourceTemplate with enhanced description."""
946
  # Get a unique template name
947
  template_name = self._get_unique_name(name, "resource_template")
 
994
  name=template_name,
995
  description=enhanced_description,
996
  parameters=template_params_schema,
997
+ tags=set(route.tags or []) | tags,
998
  timeout=self._timeout,
999
  )
1000
 
tests/server/openapi/test_openapi.py CHANGED
@@ -2460,3 +2460,357 @@ class TestMCPNames:
2460
  assert (
2461
  len(truncated_name) == 56
2462
  ) # Should be exactly 56 since original was longer
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2460
  assert (
2461
  len(truncated_name) == 56
2462
  ) # Should be exactly 56 since original was longer
2463
+
2464
+
2465
+ class TestRouteMapMCPTags:
2466
+ """Tests for RouteMap mcp_tags functionality."""
2467
+
2468
+ @pytest.fixture
2469
+ def simple_fastapi_app(self) -> FastAPI:
2470
+ """Create a simple FastAPI app for testing mcp_tags."""
2471
+ app = FastAPI(title="MCP Tags Test API")
2472
+
2473
+ @app.get("/users", tags=["users"])
2474
+ async def get_users():
2475
+ """Get all users."""
2476
+ return [{"id": 1, "name": "Alice"}]
2477
+
2478
+ @app.get("/users/{user_id}", tags=["users"])
2479
+ async def get_user(user_id: int):
2480
+ """Get user by ID."""
2481
+ return {"id": user_id, "name": f"User {user_id}"}
2482
+
2483
+ @app.post("/users", tags=["users"])
2484
+ async def create_user(name: str):
2485
+ """Create a new user."""
2486
+ return {"id": 99, "name": name}
2487
+
2488
+ return app
2489
+
2490
+ @pytest.fixture
2491
+ async def mock_client(self) -> httpx.AsyncClient:
2492
+ """Mock client for testing."""
2493
+
2494
+ async def _responder(request):
2495
+ return httpx.Response(200, json={"status": "ok"})
2496
+
2497
+ transport = httpx.MockTransport(_responder)
2498
+ return httpx.AsyncClient(transport=transport, base_url="http://test")
2499
+
2500
+ async def test_mcp_tags_added_to_tools(self, simple_fastapi_app, mock_client):
2501
+ """Test that mcp_tags are added to Tools created from routes."""
2502
+ # Create route map that adds custom tags to POST endpoints
2503
+ route_maps = [
2504
+ RouteMap(
2505
+ methods=["POST"],
2506
+ pattern=r".*",
2507
+ mcp_type=MCPType.TOOL,
2508
+ mcp_tags={"custom", "api-write"},
2509
+ ),
2510
+ # Default mapping for other routes
2511
+ RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
2512
+ ]
2513
+
2514
+ server = FastMCPOpenAPI(
2515
+ openapi_spec=simple_fastapi_app.openapi(),
2516
+ client=mock_client,
2517
+ route_maps=route_maps,
2518
+ )
2519
+
2520
+ # Get the POST tool
2521
+ tools = server._tool_manager.list_tools()
2522
+ create_user_tool = next((t for t in tools if "create_user" in t.name), None)
2523
+
2524
+ assert create_user_tool is not None, "create_user tool not found"
2525
+
2526
+ # Check that both original tags and mcp_tags are present
2527
+ assert "users" in create_user_tool.tags # Original OpenAPI tag
2528
+ assert "custom" in create_user_tool.tags # Added via mcp_tags
2529
+ assert "api-write" in create_user_tool.tags # Added via mcp_tags
2530
+
2531
+ async def test_mcp_tags_added_to_resources(self, simple_fastapi_app, mock_client):
2532
+ """Test that mcp_tags are added to Resources created from routes."""
2533
+ # Create route map that adds custom tags to GET endpoints without path params
2534
+ route_maps = [
2535
+ RouteMap(
2536
+ methods=["GET"],
2537
+ pattern=r"^/users$", # Only match /users, not /users/{id}
2538
+ mcp_type=MCPType.RESOURCE,
2539
+ mcp_tags={"list-data", "public-api"},
2540
+ ),
2541
+ # Default mapping for other routes
2542
+ RouteMap(
2543
+ methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE_TEMPLATE
2544
+ ),
2545
+ RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL),
2546
+ ]
2547
+
2548
+ server = FastMCPOpenAPI(
2549
+ openapi_spec=simple_fastapi_app.openapi(),
2550
+ client=mock_client,
2551
+ route_maps=route_maps,
2552
+ )
2553
+
2554
+ # Get the resource
2555
+ resources = list(server._resource_manager.get_resources().values())
2556
+ get_users_resource = next((r for r in resources if "get_users" in r.name), None)
2557
+
2558
+ assert get_users_resource is not None, "get_users resource not found"
2559
+
2560
+ # Check that both original tags and mcp_tags are present
2561
+ assert "users" in get_users_resource.tags # Original OpenAPI tag
2562
+ assert "list-data" in get_users_resource.tags # Added via mcp_tags
2563
+ assert "public-api" in get_users_resource.tags # Added via mcp_tags
2564
+
2565
+ async def test_mcp_tags_added_to_resource_templates(
2566
+ self, simple_fastapi_app, mock_client
2567
+ ):
2568
+ """Test that mcp_tags are added to ResourceTemplates created from routes."""
2569
+ # Create route map that adds custom tags to GET endpoints with path params
2570
+ route_maps = [
2571
+ RouteMap(
2572
+ methods=["GET"],
2573
+ pattern=r".*\{.*\}.*", # Match routes with path parameters
2574
+ mcp_type=MCPType.RESOURCE_TEMPLATE,
2575
+ mcp_tags={"detail-view", "parameterized"},
2576
+ ),
2577
+ # Default mapping for other routes
2578
+ RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
2579
+ RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL),
2580
+ ]
2581
+
2582
+ server = FastMCPOpenAPI(
2583
+ openapi_spec=simple_fastapi_app.openapi(),
2584
+ client=mock_client,
2585
+ route_maps=route_maps,
2586
+ )
2587
+
2588
+ # Get the resource template
2589
+ templates = list(server._resource_manager.get_templates().values())
2590
+ get_user_template = next((t for t in templates if "get_user" in t.name), None)
2591
+
2592
+ assert get_user_template is not None, "get_user template not found"
2593
+
2594
+ # Check that both original tags and mcp_tags are present
2595
+ assert "users" in get_user_template.tags # Original OpenAPI tag
2596
+ assert "detail-view" in get_user_template.tags # Added via mcp_tags
2597
+ assert "parameterized" in get_user_template.tags # Added via mcp_tags
2598
+
2599
+ async def test_multiple_route_maps_with_different_mcp_tags(
2600
+ self, simple_fastapi_app, mock_client
2601
+ ):
2602
+ """Test that different route maps can add different mcp_tags."""
2603
+ # Multiple route maps with different mcp_tags
2604
+ route_maps = [
2605
+ # First priority: POST requests get write-related tags
2606
+ RouteMap(
2607
+ methods=["POST"],
2608
+ pattern=r".*",
2609
+ mcp_type=MCPType.TOOL,
2610
+ mcp_tags={"write-operation", "mutation"},
2611
+ ),
2612
+ # Second priority: GET with path params get detail tags
2613
+ RouteMap(
2614
+ methods=["GET"],
2615
+ pattern=r".*\{.*\}.*",
2616
+ mcp_type=MCPType.RESOURCE_TEMPLATE,
2617
+ mcp_tags={"detail", "single-item"},
2618
+ ),
2619
+ # Third priority: Other GET requests get list tags
2620
+ RouteMap(
2621
+ methods=["GET"],
2622
+ pattern=r".*",
2623
+ mcp_type=MCPType.RESOURCE,
2624
+ mcp_tags={"list", "collection"},
2625
+ ),
2626
+ ]
2627
+
2628
+ server = FastMCPOpenAPI(
2629
+ openapi_spec=simple_fastapi_app.openapi(),
2630
+ client=mock_client,
2631
+ route_maps=route_maps,
2632
+ )
2633
+
2634
+ # Check tool tags
2635
+ tools = server._tool_manager.list_tools()
2636
+ create_tool = next((t for t in tools if "create_user" in t.name), None)
2637
+ assert create_tool is not None
2638
+ assert "write-operation" in create_tool.tags
2639
+ assert "mutation" in create_tool.tags
2640
+
2641
+ # Check resource template tags
2642
+ templates = list(server._resource_manager.get_templates().values())
2643
+ detail_template = next((t for t in templates if "get_user" in t.name), None)
2644
+ assert detail_template is not None
2645
+ assert "detail" in detail_template.tags
2646
+ assert "single-item" in detail_template.tags
2647
+
2648
+ # Check resource tags
2649
+ resources = list(server._resource_manager.get_resources().values())
2650
+ list_resource = next((r for r in resources if "get_users" in r.name), None)
2651
+ assert list_resource is not None
2652
+ assert "list" in list_resource.tags
2653
+ assert "collection" in list_resource.tags
2654
+
2655
+
2656
+ class TestGlobalTagsParameter:
2657
+ """Tests for the global tags parameter on from_openapi and from_fastapi class methods."""
2658
+
2659
+ @pytest.fixture
2660
+ def simple_fastapi_app(self) -> FastAPI:
2661
+ """Create a simple FastAPI app for testing global tags."""
2662
+ app = FastAPI(title="Global Tags Test API")
2663
+
2664
+ @app.get("/items", tags=["items"])
2665
+ async def get_items():
2666
+ """Get all items."""
2667
+ return [{"id": 1, "name": "Item 1"}]
2668
+
2669
+ @app.get("/items/{item_id}", tags=["items"])
2670
+ async def get_item(item_id: int):
2671
+ """Get item by ID."""
2672
+ return {"id": item_id, "name": f"Item {item_id}"}
2673
+
2674
+ @app.post("/items", tags=["items"])
2675
+ async def create_item(name: str):
2676
+ """Create a new item."""
2677
+ return {"id": 99, "name": name}
2678
+
2679
+ return app
2680
+
2681
+ @pytest.fixture
2682
+ async def mock_client(self) -> httpx.AsyncClient:
2683
+ """Mock client for testing."""
2684
+
2685
+ async def _responder(request):
2686
+ return httpx.Response(200, json={"status": "ok"})
2687
+
2688
+ transport = httpx.MockTransport(_responder)
2689
+ return httpx.AsyncClient(transport=transport, base_url="http://test")
2690
+
2691
+ async def test_from_fastapi_adds_global_tags(self, simple_fastapi_app):
2692
+ """Test that from_fastapi adds global tags to all components."""
2693
+ global_tags = {"global", "api-v1"}
2694
+
2695
+ server = FastMCP.from_fastapi(
2696
+ simple_fastapi_app,
2697
+ tags=global_tags,
2698
+ route_maps=[
2699
+ RouteMap(
2700
+ methods=["GET"], pattern=r"^/items$", mcp_type=MCPType.RESOURCE
2701
+ ),
2702
+ RouteMap(
2703
+ methods=["GET"],
2704
+ pattern=r".*\{.*\}.*",
2705
+ mcp_type=MCPType.RESOURCE_TEMPLATE,
2706
+ ),
2707
+ RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL),
2708
+ ],
2709
+ )
2710
+
2711
+ # Check tool has both original and global tags
2712
+ tools = server._tool_manager.list_tools()
2713
+ create_item_tool = next((t for t in tools if "create_item" in t.name), None)
2714
+ assert create_item_tool is not None
2715
+ assert "items" in create_item_tool.tags # Original OpenAPI tag
2716
+ assert "global" in create_item_tool.tags # Global tag
2717
+ assert "api-v1" in create_item_tool.tags # Global tag
2718
+
2719
+ # Check resource has both original and global tags
2720
+ resources = list(server._resource_manager.get_resources().values())
2721
+ get_items_resource = next((r for r in resources if "get_items" in r.name), None)
2722
+ assert get_items_resource is not None
2723
+ assert "items" in get_items_resource.tags # Original OpenAPI tag
2724
+ assert "global" in get_items_resource.tags # Global tag
2725
+ assert "api-v1" in get_items_resource.tags # Global tag
2726
+
2727
+ # Check resource template has both original and global tags
2728
+ templates = list(server._resource_manager.get_templates().values())
2729
+ get_item_template = next((t for t in templates if "get_item" in t.name), None)
2730
+ assert get_item_template is not None
2731
+ assert "items" in get_item_template.tags # Original OpenAPI tag
2732
+ assert "global" in get_item_template.tags # Global tag
2733
+ assert "api-v1" in get_item_template.tags # Global tag
2734
+
2735
+ async def test_from_openapi_adds_global_tags(self, simple_fastapi_app, mock_client):
2736
+ """Test that from_openapi adds global tags to all components."""
2737
+ global_tags = {"openapi-global", "service"}
2738
+
2739
+ server = FastMCP.from_openapi(
2740
+ openapi_spec=simple_fastapi_app.openapi(),
2741
+ client=mock_client,
2742
+ tags=global_tags,
2743
+ route_maps=[
2744
+ RouteMap(
2745
+ methods=["GET"], pattern=r"^/items$", mcp_type=MCPType.RESOURCE
2746
+ ),
2747
+ RouteMap(
2748
+ methods=["GET"],
2749
+ pattern=r".*\{.*\}.*",
2750
+ mcp_type=MCPType.RESOURCE_TEMPLATE,
2751
+ ),
2752
+ RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL),
2753
+ ],
2754
+ )
2755
+
2756
+ # Check tool has both original and global tags
2757
+ tools = server._tool_manager.list_tools()
2758
+ create_item_tool = next((t for t in tools if "create_item" in t.name), None)
2759
+ assert create_item_tool is not None
2760
+ assert "items" in create_item_tool.tags # Original OpenAPI tag
2761
+ assert "openapi-global" in create_item_tool.tags # Global tag
2762
+ assert "service" in create_item_tool.tags # Global tag
2763
+
2764
+ # Check resource has both original and global tags
2765
+ resources = list(server._resource_manager.get_resources().values())
2766
+ get_items_resource = next((r for r in resources if "get_items" in r.name), None)
2767
+ assert get_items_resource is not None
2768
+ assert "items" in get_items_resource.tags # Original OpenAPI tag
2769
+ assert "openapi-global" in get_items_resource.tags # Global tag
2770
+ assert "service" in get_items_resource.tags # Global tag
2771
+
2772
+ # Check resource template has both original and global tags
2773
+ templates = list(server._resource_manager.get_templates().values())
2774
+ get_item_template = next((t for t in templates if "get_item" in t.name), None)
2775
+ assert get_item_template is not None
2776
+ assert "items" in get_item_template.tags # Original OpenAPI tag
2777
+ assert "openapi-global" in get_item_template.tags # Global tag
2778
+ assert "service" in get_item_template.tags # Global tag
2779
+
2780
+ async def test_global_tags_combine_with_route_map_tags(
2781
+ self, simple_fastapi_app, mock_client
2782
+ ):
2783
+ """Test that global tags combine with both OpenAPI tags and RouteMap mcp_tags."""
2784
+ global_tags = {"global"}
2785
+ route_map_tags = {"route-specific"}
2786
+
2787
+ server = FastMCP.from_openapi(
2788
+ openapi_spec=simple_fastapi_app.openapi(),
2789
+ client=mock_client,
2790
+ tags=global_tags,
2791
+ route_maps=[
2792
+ RouteMap(
2793
+ methods=["POST"],
2794
+ pattern=r".*",
2795
+ mcp_type=MCPType.TOOL,
2796
+ mcp_tags=route_map_tags,
2797
+ ),
2798
+ RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE),
2799
+ ],
2800
+ )
2801
+
2802
+ # Check that all three types of tags are present on the tool
2803
+ tools = server._tool_manager.list_tools()
2804
+ create_item_tool = next((t for t in tools if "create_item" in t.name), None)
2805
+ assert create_item_tool is not None
2806
+ assert "items" in create_item_tool.tags # Original OpenAPI tag
2807
+ assert "global" in create_item_tool.tags # Global tag
2808
+ assert "route-specific" in create_item_tool.tags # RouteMap mcp_tag
2809
+
2810
+ # Check that resource only has OpenAPI and global tags (no route-specific since different RouteMap)
2811
+ resources = list(server._resource_manager.get_resources().values())
2812
+ get_items_resource = next((r for r in resources if "get_items" in r.name), None)
2813
+ assert get_items_resource is not None
2814
+ assert "items" in get_items_resource.tags # Original OpenAPI tag
2815
+ assert "global" in get_items_resource.tags # Global tag
2816
+ assert "route-specific" not in get_items_resource.tags # Not from this RouteMap
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