mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-11 08:09:10 +02:00
Resolves issue where tools modified by mcp_component_fn were registered with original names but accessible with modified names, causing "Unknown tool" errors. Now tools are registered using their final modified names. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude <noreply@anthropic.com>
452 lines
15 KiB
Python
452 lines
15 KiB
Python
"""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"}
|