Jeremiah Lowin commited on
Commit
2281d7e
·
1 Parent(s): 00da27f

Add tests

Browse files
src/fastmcp/server/openapi.py CHANGED
@@ -692,7 +692,6 @@ class FastMCPOpenAPI(FastMCP):
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,
@@ -716,9 +715,6 @@ class FastMCPOpenAPI(FastMCP):
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
@@ -770,8 +766,6 @@ class FastMCPOpenAPI(FastMCP):
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)
 
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,
 
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
 
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)
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