Spaces:
Running
Running
File size: 15,326 Bytes
ca8cdf5 3993fde 25428f8 3993fde 00da27f 3993fde ca8cdf5 3993fde ca8cdf5 3993fde ca8cdf5 3993fde ca8cdf5 3993fde ca8cdf5 3993fde ca8cdf5 3993fde ca8cdf5 3993fde ca8cdf5 3993fde ca8cdf5 3993fde ca8cdf5 3993fde 996e149 3993fde 996e149 3993fde ca8cdf5 3993fde ca8cdf5 00da27f 3993fde ca8cdf5 3993fde ca8cdf5 3993fde ca8cdf5 00da27f 3993fde ca8cdf5 3993fde ca8cdf5 3993fde ca8cdf5 3993fde ca8cdf5 3993fde ca8cdf5 3993fde ca8cdf5 3993fde ca8cdf5 3993fde ca8cdf5 3993fde ca8cdf5 3993fde ca8cdf5 3993fde ca8cdf5 3993fde 00da27f 3993fde ca8cdf5 3993fde ca8cdf5 25428f8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 | """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"}
|