Spaces:
Running
Running
Jeremiah Lowin
Fix OpenAPI tool name registration when modified by mcp_component_fn (#1096)
25428f8 unverified | """Tests for the route_map_fn and component_fn functionality in FastMCPOpenAPI.""" | |
| from unittest.mock import AsyncMock | |
| import httpx | |
| import pytest | |
| from fastmcp.server.openapi import FastMCPOpenAPI, MCPType, RouteMap, RouteMapFn | |
| def sample_openapi_spec(): | |
| """Sample OpenAPI spec for testing.""" | |
| return { | |
| "openapi": "3.0.0", | |
| "info": {"title": "Test API", "version": "1.0.0"}, | |
| "paths": { | |
| "/users": { | |
| "get": { | |
| "summary": "List users", | |
| "operationId": "listUsers", | |
| "responses": {"200": {"description": "Success"}}, | |
| } | |
| }, | |
| "/users/{id}": { | |
| "get": { | |
| "summary": "Get user by ID", | |
| "operationId": "getUserById", | |
| "parameters": [ | |
| { | |
| "name": "id", | |
| "in": "path", | |
| "required": True, | |
| "schema": {"type": "string"}, | |
| } | |
| ], | |
| "responses": {"200": {"description": "Success"}}, | |
| } | |
| }, | |
| "/admin/settings": { | |
| "get": { | |
| "summary": "Get admin settings", | |
| "operationId": "getAdminSettings", | |
| "responses": {"200": {"description": "Success"}}, | |
| }, | |
| "post": { | |
| "summary": "Update admin settings", | |
| "operationId": "updateAdminSettings", | |
| "requestBody": { | |
| "content": {"application/json": {"schema": {"type": "object"}}} | |
| }, | |
| "responses": {"200": {"description": "Success"}}, | |
| }, | |
| }, | |
| "/api/data": { | |
| "get": { | |
| "summary": "Get data", | |
| "operationId": "getData", | |
| "responses": {"200": {"description": "Success"}}, | |
| } | |
| }, | |
| }, | |
| } | |
| def http_client(): | |
| """HTTP client for testing.""" | |
| return httpx.AsyncClient() | |
| def test_route_map_fn_none(sample_openapi_spec, http_client): | |
| """Test that server works correctly when route_map_fn is None.""" | |
| server = FastMCPOpenAPI( | |
| openapi_spec=sample_openapi_spec, | |
| client=http_client, | |
| name="Test Server", | |
| route_map_fn=None, # Explicitly set to None | |
| ) | |
| assert server.name == "Test Server" | |
| def test_route_map_fn_custom_type_conversion(sample_openapi_spec, http_client): | |
| """Test that route_map_fn can convert route types.""" | |
| def admin_routes_to_tools(route, mcp_type): | |
| """Convert all admin routes to tools.""" | |
| if "/admin/" in route.path: | |
| return MCPType.TOOL | |
| return None | |
| server = FastMCPOpenAPI( | |
| openapi_spec=sample_openapi_spec, | |
| client=http_client, | |
| name="Test Server", | |
| route_map_fn=admin_routes_to_tools, | |
| ) | |
| # Admin GET route should be converted to tool instead of resource | |
| tools = server._tool_manager._tools | |
| assert "getAdminSettings" in tools | |
| # Admin POST route should still be a tool (was already) | |
| assert "updateAdminSettings" in tools | |
| def test_component_fn_customization(sample_openapi_spec, http_client): | |
| """Test that component_fn can customize components.""" | |
| def customize_components(route, component): | |
| """Customize components based on route.""" | |
| from fastmcp.server.openapi import OpenAPIResource, OpenAPITool | |
| # Add custom tags to all components | |
| component.tags.add("custom") | |
| # Modify tool descriptions | |
| if isinstance(component, OpenAPITool): | |
| component.description = (component.description or "") + " [CUSTOMIZED TOOL]" | |
| # Modify resource descriptions | |
| if isinstance(component, OpenAPIResource): | |
| component.description = ( | |
| component.description or "" | |
| ) + " [CUSTOMIZED RESOURCE]" | |
| server = FastMCPOpenAPI( | |
| openapi_spec=sample_openapi_spec, | |
| client=http_client, | |
| name="Test Server", | |
| mcp_component_fn=customize_components, | |
| ) | |
| # Check that components were customized | |
| tools = server._tool_manager._tools | |
| resources = server._resource_manager._resources | |
| # Tools should have custom tags and modified descriptions | |
| for tool in tools.values(): | |
| assert "custom" in tool.tags | |
| assert "[CUSTOMIZED TOOL]" in (tool.description or "") | |
| # Resources should have custom tags and modified descriptions | |
| for resource in resources.values(): | |
| assert "custom" in resource.tags | |
| assert "[CUSTOMIZED RESOURCE]" in (resource.description or "") | |
| def test_route_map_fn_returns_none(sample_openapi_spec, http_client): | |
| """Test that route_map_fn returning None uses defaults.""" | |
| def always_return_none(route, mcp_type): | |
| """Always return None to use defaults.""" | |
| return None | |
| server = FastMCPOpenAPI( | |
| openapi_spec=sample_openapi_spec, | |
| client=http_client, | |
| name="Test Server", | |
| route_map_fn=always_return_none, | |
| ) | |
| # Should have default behavior | |
| assert server.name == "Test Server" | |
| # Check that components were created with default mapping | |
| tools = server._tool_manager._tools | |
| resources = server._resource_manager._resources | |
| templates = server._resource_manager._templates | |
| # Should have tools, resources, and templates based on default mapping | |
| assert len(tools) > 0 | |
| assert len(resources) == 0 | |
| assert len(templates) == 0 | |
| def test_route_map_fn_called_for_excluded_routes(sample_openapi_spec, http_client): | |
| """Test that route_map_fn is called for excluded routes and can rescue them.""" | |
| # Exclude all admin routes | |
| route_maps = [ | |
| RouteMap( | |
| methods=["GET", "POST"], pattern=r".*/admin/.*", mcp_type=MCPType.EXCLUDE | |
| ) | |
| ] | |
| called_routes = [] | |
| def track_calls_and_rescue(route, mcp_type): | |
| """Track which routes the function is called for and rescue some excluded routes.""" | |
| called_routes.append((route.method, route.path)) | |
| # Rescue the admin GET route by converting it to a tool | |
| if route.path == "/admin/settings" and route.method == "GET": | |
| return MCPType.TOOL | |
| return None # Accept the assignment for other routes | |
| server = FastMCPOpenAPI( | |
| openapi_spec=sample_openapi_spec, | |
| client=http_client, | |
| name="Test Server", | |
| route_maps=route_maps, | |
| route_map_fn=track_calls_and_rescue, | |
| ) | |
| # route_map_fn should now be called for all routes, including excluded admin routes | |
| assert ("GET", "/admin/settings") in called_routes | |
| assert ("GET", "/users") in called_routes | |
| assert ("GET", "/users/{id}") in called_routes | |
| assert ("GET", "/api/data") in called_routes | |
| assert ("POST", "/admin/settings") in called_routes | |
| # The rescued admin GET route should now be a tool | |
| tools = server._tool_manager._tools | |
| assert "getAdminSettings" in tools | |
| # The admin POST route should still be excluded (not rescued) | |
| assert "updateAdminSettings" not in tools | |
| def test_route_map_fn_error_handling(sample_openapi_spec, http_client): | |
| """Test that errors in route_map_fn are handled gracefully.""" | |
| def error_function(route, mcp_type): | |
| """Function that raises an error.""" | |
| if route.path == "/users": | |
| raise ValueError("Test error") | |
| return None | |
| # Should not raise an error, but log a warning | |
| server = FastMCPOpenAPI( | |
| openapi_spec=sample_openapi_spec, | |
| client=http_client, | |
| name="Test Server", | |
| route_map_fn=error_function, | |
| ) | |
| # Server should still be created successfully | |
| assert server.name == "Test Server" | |
| def test_component_fn_error_handling(sample_openapi_spec, http_client): | |
| """Test that errors in component_fn are handled gracefully.""" | |
| def error_function(route, component): | |
| """Function that raises an error.""" | |
| if route.path == "/users": | |
| raise ValueError("Test error in component_fn") | |
| # Should not raise an error, but log a warning | |
| server = FastMCPOpenAPI( | |
| openapi_spec=sample_openapi_spec, | |
| client=http_client, | |
| name="Test Server", | |
| mcp_component_fn=error_function, | |
| ) | |
| # Server should still be created successfully | |
| assert server.name == "Test Server" | |
| def test_combined_route_map_fn_and_component_fn(sample_openapi_spec, http_client): | |
| """Test using both route_map_fn and component_fn together.""" | |
| def route_mapper(route, mcp_type): | |
| """Convert admin routes to tools.""" | |
| if "/admin/" in route.path: | |
| return MCPType.TOOL | |
| return None | |
| def component_customizer(route, component): | |
| """Add admin tag to admin components.""" | |
| if "/admin/" in route.path: | |
| component.tags.add("admin") | |
| server = FastMCPOpenAPI( | |
| openapi_spec=sample_openapi_spec, | |
| client=http_client, | |
| name="Test Server", | |
| route_map_fn=route_mapper, | |
| mcp_component_fn=component_customizer, | |
| ) | |
| # Check that both functions worked | |
| tools = server._tool_manager._tools | |
| # Admin GET route should be converted to tool | |
| assert "getAdminSettings" in tools | |
| admin_tool = tools["getAdminSettings"] | |
| assert "admin" in admin_tool.tags | |
| # Admin POST route should have admin tag | |
| admin_post_tool = tools["updateAdminSettings"] | |
| assert "admin" in admin_post_tool.tags | |
| def test_route_map_fn_signature_validation(): | |
| """Test that route_map_fn has the correct signature.""" | |
| from fastmcp.utilities import openapi | |
| # This is more of a type checking test | |
| def valid_route_map_fn( | |
| route: openapi.HTTPRoute, mcp_type: MCPType | |
| ) -> MCPType | None: | |
| return None | |
| # Should be assignable to RouteMapFn type | |
| fn: RouteMapFn = valid_route_map_fn | |
| assert callable(fn) | |
| def test_component_fn_signature_validation(): | |
| """Test that component_fn has the correct signature.""" | |
| from fastmcp.server.openapi import ( | |
| ComponentFn, | |
| OpenAPIResource, | |
| OpenAPIResourceTemplate, | |
| OpenAPITool, | |
| ) | |
| from fastmcp.utilities import openapi | |
| # This is more of a type checking test | |
| def valid_component_fn( | |
| route: openapi.HTTPRoute, | |
| component: OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate, | |
| ) -> None: | |
| pass | |
| # Should be assignable to ComponentFn type | |
| fn: ComponentFn = valid_component_fn | |
| assert callable(fn) | |
| def test_route_map_fn_can_rescue_excluded_routes(sample_openapi_spec, http_client): | |
| """Test that route_map_fn can rescue routes that were excluded by RouteMap.""" | |
| # Exclude ALL routes by default | |
| route_maps = [ | |
| RouteMap(mcp_type=MCPType.EXCLUDE) # Catch-all exclusion | |
| ] | |
| def rescue_users_routes(route, mcp_type): | |
| """Rescue only user-related routes.""" | |
| if "/users" in route.path: | |
| # Rescue user routes as tools | |
| return MCPType.TOOL | |
| # Let everything else stay excluded | |
| return None | |
| server = FastMCPOpenAPI( | |
| openapi_spec=sample_openapi_spec, | |
| client=http_client, | |
| name="Test Server", | |
| route_maps=route_maps, | |
| route_map_fn=rescue_users_routes, | |
| ) | |
| # Only user routes should be rescued as tools | |
| tools = server._tool_manager._tools | |
| resources = server._resource_manager._resources | |
| templates = server._resource_manager._templates | |
| # Should have user-related tools | |
| assert "listUsers" in tools | |
| assert "getUserById" in tools | |
| # Should have no resources or templates (everything excluded except rescued tools) | |
| assert len(resources) == 0 | |
| assert len(templates) == 0 | |
| # Admin and API routes should still be excluded | |
| assert "getAdminSettings" not in tools | |
| assert "updateAdminSettings" not in tools | |
| assert "getData" not in tools | |
| class TestComponentFnToolNameModificationBug: | |
| """Test that mcp_component_fn can modify tool names without breaking access (Issue #1091).""" | |
| def mocked_http_client(self): | |
| """Mock HTTP client that returns successful responses.""" | |
| from unittest.mock import MagicMock | |
| mock_client = AsyncMock(spec=httpx.AsyncClient) | |
| # Mock a successful response | |
| mock_response = MagicMock() | |
| mock_response.status_code = 200 | |
| mock_response.json.return_value = {"result": "success"} | |
| mock_response.raise_for_status.return_value = None | |
| mock_client.request.return_value = mock_response | |
| return mock_client | |
| def server_with_modified_tool_names(self, sample_openapi_spec, mocked_http_client): | |
| """Server with tool names modified by mcp_component_fn.""" | |
| def modify_tool_names(route, component): | |
| """Modify tool names by adding v1_removed_ prefix.""" | |
| from fastmcp.server.openapi import OpenAPITool | |
| if isinstance(component, OpenAPITool): | |
| if component.name.startswith("get"): | |
| component.name = "v1_removed_" + component.name | |
| return FastMCPOpenAPI( | |
| openapi_spec=sample_openapi_spec, | |
| client=mocked_http_client, | |
| name="Test Server", | |
| mcp_component_fn=modify_tool_names, | |
| ) | |
| def test_registration(self, server_with_modified_tool_names): | |
| """Test that modified tool names are properly registered.""" | |
| tools = server_with_modified_tool_names._tool_manager._tools | |
| # Tool should be registered with the modified name | |
| assert "v1_removed_getUserById" in tools | |
| assert "v1_removed_getAdminSettings" in tools | |
| assert "v1_removed_getData" in tools | |
| # The tool object should have the same name as the registration key | |
| for key, tool in tools.items(): | |
| if key.startswith("v1_removed_"): | |
| assert tool.name == key | |
| async def test_client_access(self, server_with_modified_tool_names): | |
| """Test that modified tool names are accessible via client.""" | |
| from fastmcp.client import Client | |
| async with Client(server_with_modified_tool_names) as client: | |
| # List tools to verify they are exposed correctly | |
| available_tools = await client.list_tools() | |
| tool_names = [tool.name for tool in available_tools] | |
| # Verify the modified tool names are available | |
| assert "v1_removed_getUserById" in tool_names | |
| assert "v1_removed_getAdminSettings" in tool_names | |
| assert "v1_removed_getData" in tool_names | |
| async def test_client_call(self, server_with_modified_tool_names): | |
| """Test that modified tool names can be called via client.""" | |
| from fastmcp.client import Client | |
| async with Client(server_with_modified_tool_names) as client: | |
| # This should work without "Unknown tool" error | |
| result = await client.call_tool("v1_removed_getData", {}) | |
| assert result.data == {"result": "success"} | |