"""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 @pytest.fixture 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"}}, } }, }, } @pytest.fixture 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).""" @pytest.fixture 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 @pytest.fixture 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"}