From e6b1c6984c22359df777f0a858b6780b45aec05e Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 22 May 2025 15:06:10 -0400 Subject: [PATCH 1/3] Update route map logic --- docs/patterns/openapi.mdx | 118 +++++++++++- src/fastmcp/server/openapi.py | 187 ++++++++++++++++--- tests/deprecated/test_route_type_ignore.py | 113 +++++++++++ tests/server/test_openapi.py | 68 +++++++ tests/server/test_route_map_shortcuts.py | 207 +++++++++++++++++++++ 5 files changed, 656 insertions(+), 37 deletions(-) create mode 100644 tests/deprecated/test_route_type_ignore.py create mode 100644 tests/server/test_route_map_shortcuts.py diff --git a/docs/patterns/openapi.mdx b/docs/patterns/openapi.mdx index 0934cec8e..cab51a4aa 100644 --- a/docs/patterns/openapi.mdx +++ b/docs/patterns/openapi.mdx @@ -64,37 +64,34 @@ DEFAULT_ROUTE_MAPPINGS = [ RouteMap( methods=["GET"], pattern=r".*\{.*\}.*", - route_type=RouteType.RESOURCE_TEMPLATE, + mcp_type=MCPType.RESOURCE_TEMPLATE, ), # GET without path parameters -> Resource RouteMap( methods=["GET"], pattern=r".*", - route_type=RouteType.RESOURCE, + mcp_type=MCPType.RESOURCE, ), # All other methods -> Tool - RouteMap( - methods="*", - pattern=r".*", - route_type=RouteType.TOOL, - ), + ALL_TOOLS(), ] ``` + ### Custom Route Maps Users can add custom route maps to override the default mapping behavior. User-supplied route maps are always applied first, before the default route maps. ```python -from fastmcp.server.openapi import RouteMap, RouteType +from fastmcp.server.openapi import RouteMap, MCPType # Custom mapping rules custom_maps = [ # Force all analytics endpoints to be Tools RouteMap(methods=["GET"], pattern=r"^/analytics/.*", - route_type=RouteType.TOOL) + mcp_type=MCPType.TOOL) ] # Apply custom mappings @@ -105,6 +102,9 @@ mcp = await FastMCP.from_openapi( ) ``` + +For backward compatibility, FastMCP still supports the `route_type` parameter and `RouteType` enum, but they are deprecated and will be removed in a future version. You will see deprecation warnings if you use them. + ### All Routes as Tools @@ -127,13 +127,46 @@ mcp = FastMCP.from_openapi( openapi_spec=spec, client=api_client, route_maps=[ - RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL) + RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL) ] ) ``` Note that `all_routes_as_tools` and `route_maps` cannot be used together - if you need more complex mapping rules, use `route_maps` instead. +### Excluding Routes + +If you want to exclude certain routes from being converted to MCP components, you can map them to `MCPType.EXCLUDE`. This is useful for endpoints that should not be accessible to the agent. + +```python +from fastmcp.server.openapi import RouteMap, MCPType + +# Custom mapping rules to exclude specific routes +custom_maps = [ + # Exclude all admin endpoints + RouteMap( + methods="*", + pattern=r"^/admin/.*", + mcp_type=MCPType.EXCLUDE + ), + # Exclude analytics GET endpoints + RouteMap( + methods=["GET"], + pattern=r"^/analytics/.*", + mcp_type=MCPType.EXCLUDE + ) +] + +# Apply custom mappings +mcp = FastMCP.from_openapi( + openapi_spec=spec, + client=api_client, + route_maps=custom_maps +) +``` + +When a route is mapped to `MCPType.EXCLUDE`, FastMCP will log its presence but won't create any MCP component for it, effectively making it invisible to clients and agents using the MCP server. + ## How It Works 1. FastMCP parses your OpenAPI spec to extract routes and schemas @@ -261,3 +294,68 @@ if __name__ == "__main__": mcp.run() ``` +### Route Map Shortcuts + +FastMCP provides several shortcut functions to create common route maps more easily: + +```python +from fastmcp.server.openapi import ( + ALL_TOOLS, + EXCLUDE_ALL, + EXCLUDE_PATTERN, + PATTERN_AS_TOOLS, +) + +# Create an MCP server with custom route maps using shortcuts +mcp = FastMCP.from_openapi( + openapi_spec=spec, + client=api_client, + route_maps=[ + # First exclude all admin endpoints + EXCLUDE_PATTERN(r"^/admin/.*"), + + # Make all /api/v1 endpoints tools + PATTERN_AS_TOOLS(r"^/api/v1/.*"), + + # Make all remaining routes tools + ALL_TOOLS(), + ] +) +``` + +Available shortcuts: + +| Shortcut Function | Description | +|------------------|-------------| +| `ALL_TOOLS()` | Converts all matching routes to tools | +| `EXCLUDE_ALL()` | Excludes all matching routes from being converted to any component | +| `PATTERN_AS_TOOLS(pattern)` | Converts routes matching a specific pattern to tools | +| `EXCLUDE_PATTERN(pattern)` | Excludes routes matching a specific pattern | + +These shortcuts are particularly useful for: + +1. Converting all remaining unmatched routes to tools (use `ALL_TOOLS()`) +2. Excluding whole sections of your API (use `EXCLUDE_PATTERN("/path/.*")`) +3. Converting routes matching specific patterns to tools (use `PATTERN_AS_TOOLS("/path/.*")`) + +The `all_routes_as_tools=True` parameter is equivalent to using just `[ALL_TOOLS()]` as your route maps. + + +You can use `EXCLUDE_ALL()` as the last entry in your custom route maps to completely ignore the default route maps. Since custom route maps are applied first and default maps are appended afterward, having `EXCLUDE_ALL()` at the end of your custom maps will match any routes that your earlier custom rules didn't match, preventing the default maps from having any effect. + +```python +# Create server that only uses custom route maps, ignoring defaults +mcp = FastMCP.from_openapi( + openapi_spec=spec, + client=api_client, + route_maps=[ + # Routes to keep as tools + PATTERN_AS_TOOLS(r"^/api/v1/.*"), + + # Exclude everything else (ignores default route maps) + EXCLUDE_ALL(), + ] +) +``` + + diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index 687790653..ed3e21aa1 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -5,8 +5,9 @@ from __future__ import annotations import enum import json import re +import warnings from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from re import Pattern from typing import TYPE_CHECKING, Any, Literal @@ -33,46 +34,176 @@ logger = get_logger(__name__) HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] -class RouteType(enum.Enum): - """Type of FastMCP component to create from a route.""" +class MCPType(enum.Enum): + """Type of FastMCP component to create from a route. + + Enum values: + TOOL: Convert the route to a callable Tool + RESOURCE: Convert the route to a Resource (typically GET endpoints) + RESOURCE_TEMPLATE: Convert the route to a ResourceTemplate (typically GET with path params) + PROMPT: Convert the route to a Prompt (not yet implemented) + EXCLUDE: Exclude the route from being converted to any MCP component + IGNORE: Deprecated, use EXCLUDE instead + """ TOOL = "TOOL" RESOURCE = "RESOURCE" RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE" PROMPT = "PROMPT" - IGNORE = "IGNORE" + EXCLUDE = "EXCLUDE" + + +# Keep RouteType as an alias to MCPType for backward compatibility +class RouteType(enum.Enum): + """ + Deprecated: Use MCPType instead. + + This enum is kept for backward compatibility and will be removed in a future version. + """ + + TOOL = "TOOL" + RESOURCE = "RESOURCE" + RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE" + PROMPT = "PROMPT" + EXCLUDE = "EXCLUDE" + IGNORE = "IGNORE" # Deprecated, use EXCLUDE instead + + def __new__(cls, value): + # Deprecated in 2.4.1 + warnings.warn( + "RouteType is deprecated and will be removed in a future version. " + "Use MCPType instead.", + DeprecationWarning, + stacklevel=2, + ) + + # Add a specific warning for the deprecated IGNORE value + if value == "IGNORE": + warnings.warn( + "RouteType.IGNORE is deprecated and will be removed in a future version. " + "Use MCPType.EXCLUDE instead.", + DeprecationWarning, + stacklevel=2, + ) + + instance = object.__new__(cls) + instance._value_ = value + return instance @dataclass class RouteMap: """Mapping configuration for HTTP routes to FastMCP component types.""" - methods: list[HttpMethod] | Literal["*"] - pattern: Pattern[str] | str - route_type: RouteType + methods: list[HttpMethod] | Literal["*"] = field(default="*") + pattern: Pattern[str] | str = field(default=r".*") + mcp_type: MCPType | None = field(default=None) + route_type: RouteType | MCPType | None = field(default=None) + + def __post_init__(self): + """Validate and process the route map after initialization.""" + # Handle backward compatibility for route_type + if self.mcp_type is None and self.route_type is not None: + warnings.warn( + "The 'route_type' parameter is deprecated and will be removed in a future version. " + "Use 'mcp_type' instead with the appropriate MCPType value.", + DeprecationWarning, + stacklevel=2, + ) + + # Check for the deprecated IGNORE value + if self.route_type == RouteType.IGNORE: + warnings.warn( + "RouteType.IGNORE is deprecated and will be removed in a future version. " + "Use MCPType.EXCLUDE instead.", + DeprecationWarning, + stacklevel=2, + ) + + # Convert from RouteType to MCPType if needed + if isinstance(self.route_type, RouteType): + route_type_name = self.route_type.name + if route_type_name == "IGNORE": + route_type_name = "EXCLUDE" + self.mcp_type = getattr(MCPType, route_type_name) + else: + self.mcp_type = self.route_type + elif self.mcp_type is None: + raise ValueError("`mcp_type` must be provided") + + # Set route_type to match mcp_type for backward compatibility + if self.route_type is None: + self.route_type = self.mcp_type + + +# Common route map pattern functions +def EXCLUDE_ALL() -> RouteMap: + """ + Create a RouteMap that excludes all routes that haven't been matched by earlier rules. + + This is useful as the last route map to exclude any routes that don't match specific patterns. + + Returns: + RouteMap: A route map that excludes all routes + """ + return RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.EXCLUDE) + + +def ALL_TOOLS() -> RouteMap: + """ + Create a RouteMap that converts all routes to tools that haven't been matched by earlier rules. + + This is useful to replace the last item in the default route mappings to make all unmatched routes tools. + + Returns: + RouteMap: A route map that converts all routes to tools + """ + return RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL) + + +def PATTERN_AS_TOOLS(pattern: str) -> RouteMap: + """ + Create a RouteMap that converts routes matching a specific pattern to tools. + + Args: + pattern: Regex pattern to match routes + + Returns: + RouteMap: A route map that converts routes matching the pattern to tools + """ + return RouteMap(methods="*", pattern=pattern, mcp_type=MCPType.TOOL) + + +def EXCLUDE_PATTERN(pattern: str) -> RouteMap: + """ + Create a RouteMap that excludes routes matching a specific pattern. + + Args: + pattern: Regex pattern to match routes to exclude + + Returns: + RouteMap: A route map that excludes routes matching the pattern + """ + return RouteMap(methods="*", pattern=pattern, mcp_type=MCPType.EXCLUDE) # Default route mappings as a list, where order determines priority DEFAULT_ROUTE_MAPPINGS = [ # GET requests with path parameters go to ResourceTemplate RouteMap( - methods=["GET"], pattern=r".*\{.*\}.*", route_type=RouteType.RESOURCE_TEMPLATE + methods=["GET"], pattern=r".*\{.*\}.*", mcp_type=MCPType.RESOURCE_TEMPLATE ), # GET requests without path parameters go to Resource - RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE), + RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), # All other HTTP methods go to Tool - RouteMap( - methods=["POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], - pattern=r".*", - route_type=RouteType.TOOL, - ), + ALL_TOOLS(), ] def _determine_route_type( route: openapi.HTTPRoute, mappings: list[RouteMap], -) -> RouteType: +) -> MCPType: """ Determines the FastMCP component type based on the route and mappings. @@ -81,7 +212,7 @@ def _determine_route_type( mappings: List of RouteMap objects in priority order Returns: - RouteType for this route + MCPType for this route """ # Check mappings in priority order (first match wins) for route_map in mappings: @@ -94,13 +225,15 @@ def _determine_route_type( pattern_matches = re.search(route_map.pattern, route.path) if pattern_matches: + # We know mcp_type is not None here due to post_init validation + assert route_map.mcp_type is not None logger.debug( - f"Route {route.method} {route.path} matched mapping to {route_map.route_type.name}" + f"Route {route.method} {route.path} matched mapping to {route_map.mcp_type.name}" ) - return route_map.route_type + return route_map.mcp_type # Default fallback - return RouteType.TOOL + return MCPType.TOOL # Placeholder function to provide function metadata @@ -555,13 +688,13 @@ class FastMCPOpenAPI(FastMCP): RouteMap( methods=["GET", "POST", "PATCH"], pattern=r".*/users/.*", - route_type=RouteType.RESOURCE_TEMPLATE + mcp_type=MCPType.RESOURCE_TEMPLATE ), # Map all analytics endpoints to Tool RouteMap( methods=["GET"], pattern=r".*/analytics/.*", - route_type=RouteType.TOOL + mcp_type=MCPType.TOOL ), ] @@ -615,19 +748,19 @@ class FastMCPOpenAPI(FastMCP): path_name = "_".join(p for p in path_parts if not p.startswith("{")) operation_id = f"{route.method.lower()}_{path_name}" - if route_type == RouteType.TOOL: + if route_type == MCPType.TOOL: self._create_openapi_tool(route, operation_id) - elif route_type == RouteType.RESOURCE: + elif route_type == MCPType.RESOURCE: self._create_openapi_resource(route, operation_id) - elif route_type == RouteType.RESOURCE_TEMPLATE: + elif route_type == MCPType.RESOURCE_TEMPLATE: self._create_openapi_template(route, operation_id) - elif route_type == RouteType.PROMPT: + elif route_type == MCPType.PROMPT: # Not implemented yet logger.warning( f"PROMPT route type not implemented: {route.method} {route.path}" ) - elif route_type == RouteType.IGNORE: - logger.info(f"Ignoring route: {route.method} {route.path}") + elif route_type == MCPType.EXCLUDE: + logger.info(f"Excluding route: {route.method} {route.path}") logger.info(f"Created FastMCP OpenAPI server with {len(http_routes)} routes") diff --git a/tests/deprecated/test_route_type_ignore.py b/tests/deprecated/test_route_type_ignore.py new file mode 100644 index 000000000..1382d7137 --- /dev/null +++ b/tests/deprecated/test_route_type_ignore.py @@ -0,0 +1,113 @@ +"""Tests for the deprecated RouteType.IGNORE.""" + +import warnings + +import httpx +import pytest + +from fastmcp.server.openapi import ( + FastMCPOpenAPI, + MCPType, + RouteMap, + RouteType, +) + + +def test_route_type_ignore_deprecation_warning(): + """Test that using RouteType.IGNORE emits a deprecation warning.""" + # Let's manually capture the warnings + + # Record all warnings + with warnings.catch_warnings(record=True) as recorded: + # Make sure warnings are always triggered + warnings.simplefilter("always") + + # Create a RouteMap with RouteType.IGNORE + route_map = RouteMap( + methods=["GET"], pattern=r"^/analytics$", route_type=RouteType.IGNORE + ) + + # Check for the expected warnings in the recorded warnings + route_type_warning = False + ignore_warning = False + + for w in recorded: + if issubclass(w.category, DeprecationWarning): + message = str(w.message) + if "route_type' parameter is deprecated" in message: + route_type_warning = True + if "RouteType.IGNORE is deprecated" in message: + ignore_warning = True + + # Make sure both warnings were triggered + assert route_type_warning, "Missing 'route_type' deprecation warning" + assert ignore_warning, "Missing 'RouteType.IGNORE' deprecation warning" + + # Verify that RouteType.IGNORE was converted to MCPType.EXCLUDE + assert route_map.mcp_type == MCPType.EXCLUDE + + +class TestRouteTypeIgnoreDeprecation: + """Test class for the deprecated RouteType.IGNORE.""" + + @pytest.fixture + def basic_openapi_spec(self) -> dict: + """Create a simple OpenAPI spec for testing.""" + return { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/items": { + "get": { + "operationId": "get_items", + "summary": "Get all items", + "responses": {"200": {"description": "Success"}}, + } + }, + "/analytics": { + "get": { + "operationId": "get_analytics", + "summary": "Get analytics data", + "responses": {"200": {"description": "Success"}}, + } + }, + }, + } + + @pytest.fixture + async def mock_client(self) -> httpx.AsyncClient: + """Create a mock client for testing.""" + + async def _responder(request): + return httpx.Response(200, json={"success": True}) + + return httpx.AsyncClient(transport=httpx.MockTransport(_responder)) + + async def test_route_type_ignore_conversion(self, basic_openapi_spec, mock_client): + """Test that routes with RouteType.IGNORE are properly excluded.""" + # Capture the deprecation warning without checking the exact message + with pytest.warns(DeprecationWarning): + server = FastMCPOpenAPI( + openapi_spec=basic_openapi_spec, + client=mock_client, + route_maps=[ + # Use the deprecated RouteType.IGNORE + RouteMap( + methods=["GET"], + pattern=r"^/analytics$", + route_type=RouteType.IGNORE, + ), + # Make everything else a resource + RouteMap( + methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE + ), + ], + ) + + # Check that the analytics route was excluded (converted from IGNORE to EXCLUDE) + resources = await server.get_resources() + resource_uris = [str(r.uri) for r in resources.values()] + + # Analytics should be excluded + assert "resource://openapi/get_items" in resource_uris + assert "resource://openapi/get_analytics" not in resource_uris diff --git a/tests/server/test_openapi.py b/tests/server/test_openapi.py index 3149ac19c..f38ae4acc 100644 --- a/tests/server/test_openapi.py +++ b/tests/server/test_openapi.py @@ -2152,3 +2152,71 @@ class TestAllRoutesAsTools: ) ], ) + + +class TestRouteTypeExclude: + @pytest.fixture + def basic_openapi_spec(self) -> dict: + return { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/items": { + "get": { + "operationId": "get_items", + "summary": "Get all items", + "responses": {"200": {"description": "Success"}}, + } + }, + "/users": { + "get": { + "operationId": "get_users", + "summary": "Get all users", + "responses": {"200": {"description": "Success"}}, + } + }, + "/analytics": { + "get": { + "operationId": "get_analytics", + "summary": "Get analytics data", + "responses": {"200": {"description": "Success"}}, + } + }, + }, + } + + @pytest.fixture + async def mock_client(self) -> httpx.AsyncClient: + async def _responder(request): + return httpx.Response(200, json={"success": True}) + + return httpx.AsyncClient(transport=httpx.MockTransport(_responder)) + + async def test_exclude_routes(self, basic_openapi_spec, mock_client): + # Create a server with custom mappings that exclude specific routes + server = FastMCPOpenAPI( + openapi_spec=basic_openapi_spec, + client=mock_client, + route_maps=[ + # Exclude analytics endpoints + RouteMap( + methods=["GET"], + pattern=r"^/analytics$", + route_type=RouteType.IGNORE, + ), + # Make everything else a resource + RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE), + ], + ) + + # Check that resources were created for non-excluded routes + resources = await server.get_resources() + resource_uris = [str(r.uri) for r in resources.values()] + + # The /analytics endpoint should be excluded + assert "resource://openapi/get_items" in resource_uris + assert "resource://openapi/get_users" in resource_uris + assert "resource://openapi/get_analytics" not in resource_uris + + # Should only have 2 resources (analytics is excluded) + assert len(resources) == 2 diff --git a/tests/server/test_route_map_shortcuts.py b/tests/server/test_route_map_shortcuts.py new file mode 100644 index 000000000..a64be9910 --- /dev/null +++ b/tests/server/test_route_map_shortcuts.py @@ -0,0 +1,207 @@ +"""Tests for the route map shortcut functions.""" + +import httpx +import pytest + +from fastmcp.server.openapi import ( + ALL_TOOLS, + EXCLUDE_ALL, + EXCLUDE_PATTERN, + PATTERN_AS_TOOLS, + FastMCPOpenAPI, + MCPType, + RouteMap, + RouteType, +) + + +class TestRouteMapShortcuts: + """Tests for the route map shortcut functions.""" + + def test_functions_return_correct_route_maps(self): + """Test that each shortcut function returns a RouteMap with the expected properties.""" + # Test EXCLUDE_ALL + exclude_all = EXCLUDE_ALL() + assert isinstance(exclude_all, RouteMap) + assert exclude_all.methods == "*" + assert exclude_all.pattern == ".*" + assert exclude_all.mcp_type == MCPType.EXCLUDE + + # Test ALL_TOOLS + all_tools = ALL_TOOLS() + assert isinstance(all_tools, RouteMap) + assert all_tools.methods == "*" + assert all_tools.pattern == ".*" + assert all_tools.mcp_type == MCPType.TOOL + + # Test PATTERN_AS_TOOLS + pattern = r"^/api/.*" + pattern_as_tools = PATTERN_AS_TOOLS(pattern) + assert isinstance(pattern_as_tools, RouteMap) + assert pattern_as_tools.methods == "*" + assert pattern_as_tools.pattern == pattern + assert pattern_as_tools.mcp_type == MCPType.TOOL + + # Test EXCLUDE_PATTERN + pattern = r"^/admin/.*" + exclude_pattern = EXCLUDE_PATTERN(pattern) + assert isinstance(exclude_pattern, RouteMap) + assert exclude_pattern.methods == "*" + assert exclude_pattern.pattern == pattern + assert exclude_pattern.mcp_type == MCPType.EXCLUDE + + def test_backward_compatibility(self): + """Test that backward compatibility with RouteType and route_type works.""" + # Test creating a RouteMap with route_type + with pytest.warns(DeprecationWarning): + route_map = RouteMap( + methods=["GET"], pattern=r".*", route_type=RouteType.TOOL + ) + assert route_map.mcp_type == MCPType.TOOL + + # Test accessing fields on RouteType directly + # Note: importing RouteType already causes the deprecation warning, + # so we don't need to check for it again here + rt = RouteType.RESOURCE + assert rt.value == "RESOURCE" + assert rt.name == "RESOURCE" + + +class TestRouteMapShortcutsIntegration: + """Integration tests for the route map shortcut functions with FastMCPOpenAPI.""" + + @pytest.fixture + def basic_openapi_spec(self) -> dict: + """Create a simple OpenAPI spec for testing.""" + return { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/items": { + "get": { + "operationId": "get_items", + "summary": "Get all items", + "responses": {"200": {"description": "Success"}}, + }, + "post": { + "operationId": "create_item", + "summary": "Create an item", + "responses": {"201": {"description": "Created"}}, + }, + }, + "/users": { + "get": { + "operationId": "get_users", + "summary": "Get all users", + "responses": {"200": {"description": "Success"}}, + }, + }, + "/admin": { + "get": { + "operationId": "get_admin", + "summary": "Admin endpoint", + "responses": {"200": {"description": "Success"}}, + }, + }, + "/items/{item_id}": { + "get": { + "operationId": "get_item", + "summary": "Get an item by ID", + "parameters": [ + { + "name": "item_id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + "responses": {"200": {"description": "Success"}}, + }, + }, + }, + } + + @pytest.fixture + async def mock_client(self) -> httpx.AsyncClient: + """Create a mock client for testing.""" + + async def _responder(request): + return httpx.Response(200, json={"success": True}) + + return httpx.AsyncClient(transport=httpx.MockTransport(_responder)) + + async def test_all_tools(self, basic_openapi_spec, mock_client): + """Test using ALL_TOOLS() to convert all routes to tools.""" + server = FastMCPOpenAPI( + openapi_spec=basic_openapi_spec, + client=mock_client, + route_maps=[ALL_TOOLS()], + ) + + # Check that all routes are tools + tools = await server.get_tools() + resources = await server.get_resources() + templates = await server.get_resource_templates() + + # All 5 routes should be tools + assert len(tools) == 5 + assert len(resources) == 0 + assert len(templates) == 0 + + # Check that all expected tools exist + tool_names = [t.name for t in tools.values()] + assert "get_items" in tool_names + assert "create_item" in tool_names + assert "get_users" in tool_names + assert "get_admin" in tool_names + assert "get_item" in tool_names + + async def test_exclude_pattern(self, basic_openapi_spec, mock_client): + """Test using EXCLUDE_PATTERN() to exclude specific routes.""" + server = FastMCPOpenAPI( + openapi_spec=basic_openapi_spec, + client=mock_client, + route_maps=[ + # Exclude admin endpoints + EXCLUDE_PATTERN(r"^/admin"), + # Make everything else a tool + ALL_TOOLS(), + ], + ) + + # Check that admin route is excluded + tools = await server.get_tools() + tool_names = [t.name for t in tools.values()] + + # All routes except admin should be tools + assert "get_items" in tool_names + assert "create_item" in tool_names + assert "get_users" in tool_names + assert "get_item" in tool_names + assert "get_admin" not in tool_names # This should be excluded + + async def test_pattern_as_tools(self, basic_openapi_spec, mock_client): + """Test using PATTERN_AS_TOOLS() to convert routes matching a pattern to tools.""" + server = FastMCPOpenAPI( + openapi_spec=basic_openapi_spec, + client=mock_client, + route_maps=[ + # Make /items routes tools regardless of method + PATTERN_AS_TOOLS(r"^/items"), + # Make everything else a resource + RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.RESOURCE), + ], + ) + + # Check that /items routes are tools + tools = await server.get_tools() + tool_names = [t.name for t in tools.values()] + assert "get_items" in tool_names + assert "create_item" in tool_names + assert "get_item" in tool_names + + # Check that other routes are resources + resources = await server.get_resources() + resource_names = [r.name for r in resources.values()] + assert "get_users" in resource_names + assert "get_admin" in resource_names From 4c3bf806523f5d86f3eed200f0f1161b6a114a95 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 22 May 2025 15:46:46 -0400 Subject: [PATCH 2/3] Add custom naming and deprecate all_routes_as_tools --- docs/patterns/openapi.mdx | 284 +++++++++---------- src/fastmcp/server/openapi.py | 185 ++++++++---- src/fastmcp/server/server.py | 33 ++- tests/server/test_openapi.py | 157 +++++----- tests/server/test_openapi_naming.py | 231 +++++++++++++++ tests/server/test_openapi_path_parameters.py | 6 +- tests/server/test_route_map_shortcuts.py | 3 +- 7 files changed, 612 insertions(+), 287 deletions(-) create mode 100644 tests/server/test_openapi_naming.py diff --git a/docs/patterns/openapi.mdx b/docs/patterns/openapi.mdx index cab51a4aa..68d5c6bd4 100644 --- a/docs/patterns/openapi.mdx +++ b/docs/patterns/openapi.mdx @@ -31,20 +31,61 @@ if __name__ == "__main__": ### Timeout -You can set a timeout for all API requests: +You can set a timeout for all requests by providing a `timeout` parameter (in seconds): ```python -# Set a 5 second timeout for all requests mcp = FastMCP.from_openapi( openapi_spec=spec, - client=api_client, - timeout=5.0 + client=api_client, + timeout=30.0 # 30 second timeout ) ``` -This timeout is applied to all requests made by tools, resources, and resource templates. +### Component Naming -## Route Mapping + + +You can customize how FastMCP names the components generated from your OpenAPI spec: + +```python +# Custom naming function +def my_component_namer(route, mcp_type, default_name): + # Create custom names based on the route and component type + if route.operation_id: + return route.operation_id + + # For example, prefix with component type + prefix = { + MCPType.TOOL: "tool_", + MCPType.RESOURCE: "resource_", + MCPType.RESOURCE_TEMPLATE: "template_", + }.get(mcp_type, "") + + path_name = route.path.replace("/", "_").strip("_") + return f"{prefix}{path_name}" + +mcp = FastMCP.from_openapi( + openapi_spec=spec, + client=api_client, + component_namer=my_component_namer +) +``` + +By default, FastMCP generates component names as follows: + +- If the route has an `operationId` in the OpenAPI spec, that is used +- Otherwise, the name is generated from the route path: + - For `GET` routes mapped to resources: Just the resource name (e.g., `/users` → `users`) + - For routes with path parameters mapped to templates: The path with parameter names (e.g., `/users/{id}` → `users_id`) + - For other methods mapped to tools: Method + resource name (e.g., `POST /users` → `post_users`) + +#### Handling Name Collisions + +When multiple routes would generate the same component name, FastMCP automatically appends a number suffix to ensure uniqueness (e.g., `users`, `users_2`, `users_3`). You'll see these numbered suffixes in the component names returned by `get_tools()`, `get_resources()`, etc. + +If you need more control over naming, you can provide a custom `component_namer` function that handles potential collisions in your own way. + +### Route Mapping By default, OpenAPI routes are mapped to MCP components based on these rules: @@ -54,7 +95,6 @@ By default, OpenAPI routes are mapped to MCP components based on these rules: | `GET` with path params | `GET /users/{id}` | Resource Template | Path parameters become template parameters | | `POST`, `PUT`, `PATCH`, `DELETE`, etc. | `POST /users` | Tool | Operations that modify data | - Internally, FastMCP uses a priority-ordered set of `RouteMap` objects to determine the component type. Route maps indicate that a specific HTTP method (or methods) and path pattern should be treated as a specific component type. This is the default set of route maps: ```python @@ -79,7 +119,7 @@ DEFAULT_ROUTE_MAPPINGS = [ ] ``` -### Custom Route Maps +#### Custom Route Maps Users can add custom route maps to override the default mapping behavior. User-supplied route maps are always applied first, before the default route maps. @@ -95,7 +135,7 @@ custom_maps = [ ] # Apply custom mappings -mcp = await FastMCP.from_openapi( +mcp = FastMCP.from_openapi( openapi_spec=spec, client=api_client, route_maps=custom_maps @@ -106,23 +146,19 @@ mcp = await FastMCP.from_openapi( For backward compatibility, FastMCP still supports the `route_type` parameter and `RouteType` enum, but they are deprecated and will be removed in a future version. You will see deprecation warnings if you use them. -### All Routes as Tools +#### All Routes as Tools -When building AI agent backends, it's often useful to treat all routes as callable tools regardless of their HTTP method. You can use the `all_routes_as_tools` parameter to automatically map every route to a Tool: +When building AI agent backends, it's often useful to treat all routes as callable tools regardless of their HTTP method. You can use the `ALL_TOOLS()` shortcut or create a custom route map: ```python -# Make all endpoints tools, regardless of HTTP method +# Make all endpoints tools using the shortcut mcp = FastMCP.from_openapi( openapi_spec=spec, client=api_client, - all_routes_as_tools=True + route_maps=[ALL_TOOLS()] ) -``` -This is equivalent to defining a single route map that matches all routes: - -```python -# Same effect as all_routes_as_tools=True +# Same effect using a custom route map mcp = FastMCP.from_openapi( openapi_spec=spec, client=api_client, @@ -132,9 +168,7 @@ mcp = FastMCP.from_openapi( ) ``` -Note that `all_routes_as_tools` and `route_maps` cannot be used together - if you need more complex mapping rules, use `route_maps` instead. - -### Excluding Routes +#### Excluding Routes If you want to exclude certain routes from being converted to MCP components, you can map them to `MCPType.EXCLUDE`. This is useful for endpoints that should not be accessible to the agent. @@ -167,134 +201,38 @@ mcp = FastMCP.from_openapi( When a route is mapped to `MCPType.EXCLUDE`, FastMCP will log its presence but won't create any MCP component for it, effectively making it invisible to clients and agents using the MCP server. -## How It Works - -1. FastMCP parses your OpenAPI spec to extract routes and schemas -2. It applies mapping rules to categorize each route -3. When an MCP client calls a tool or accesses a resource: - - FastMCP constructs an HTTP request based on the OpenAPI definition - - It sends the request through the provided httpx client - - It translates the HTTP response to the appropriate MCP format - -### Request Parameter Handling - -FastMCP carefully handles different types of parameters in OpenAPI requests: - -#### Query Parameters - -By default, FastMCP will only include query parameters that have non-empty values. Parameters with `None` values or empty strings (`""`) are automatically filtered out of requests. This ensures that API servers don't receive unnecessary empty parameters that might cause issues. - -For example, if you call a tool with these parameters: -```python -await client.call_tool("search_products", { - "category": "electronics", # Will be included - "min_price": 100, # Will be included - "max_price": None, # Will be excluded - "brand": "", # Will be excluded -}) -``` - -The resulting HTTP request will only include `category=electronics&min_price=100`. - -#### Path Parameters - -For path parameters, which are typically required by REST APIs, FastMCP filters out `None` values and checks that all required path parameters are provided. If a required path parameter is missing or `None`, an error will be raised. +You can customize this behavior by providing a list of `RouteMap` objects: ```python -# This will work -await client.call_tool("get_product", {"product_id": 123}) +from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap, MCPType -# This will raise ValueError: "Missing required path parameters: {'product_id'}" -await client.call_tool("get_product", {"product_id": None}) +# Custom route mappings +custom_mappings = [ + # Convert all user-related routes to tools + RouteMap( + methods=["GET", "POST", "PUT", "DELETE"], + pattern=r"^/users.*", + mcp_type=MCPType.TOOL + ), + # Exclude analytics routes + RouteMap( + methods=["*"], # All methods + pattern=r"^/analytics.*", + mcp_type=MCPType.EXCLUDE + ), +] + +# Create server with custom mappings +mcp = FastMCPOpenAPI( + openapi_spec=spec, + client=httpx.AsyncClient(), + route_maps=custom_mappings, +) ``` -## Complete Example +#### Route Map Shortcuts -```python [expandable] -import asyncio - -import httpx - -from fastmcp import FastMCP - -# Sample OpenAPI spec for a Pet Store API -petstore_spec = { - "openapi": "3.0.0", - "info": { - "title": "Pet Store API", - "version": "1.0.0", - "description": "A sample API for managing pets", - }, - "paths": { - "/pets": { - "get": { - "operationId": "listPets", - "summary": "List all pets", - "responses": {"200": {"description": "A list of pets"}}, - }, - "post": { - "operationId": "createPet", - "summary": "Create a new pet", - "responses": {"201": {"description": "Pet created successfully"}}, - }, - }, - "/pets/{petId}": { - "get": { - "operationId": "getPet", - "summary": "Get a pet by ID", - "parameters": [ - { - "name": "petId", - "in": "path", - "required": True, - "schema": {"type": "string"}, - } - ], - "responses": { - "200": {"description": "Pet details"}, - "404": {"description": "Pet not found"}, - }, - } - }, - }, -} - - -async def check_mcp(mcp: FastMCP): - # List what components were created - tools = await mcp.get_tools() - resources = await mcp.get_resources() - templates = await mcp.get_resource_templates() - - print( - f"{len(tools)} Tool(s): {', '.join([t.name for t in tools.values()])}" - ) # Should include createPet - print( - f"{len(resources)} Resource(s): {', '.join([r.name for r in resources.values()])}" - ) # Should include listPets - print( - f"{len(templates)} Resource Template(s): {', '.join([t.name for t in templates.values()])}" - ) # Should include getPet - - return mcp - - -if __name__ == "__main__": - # Client for the Pet Store API - client = httpx.AsyncClient(base_url="https://petstore.example.com/api") - - # Create the MCP server - mcp = FastMCP.from_openapi( - openapi_spec=petstore_spec, client=client, name="PetStore" - ) - - asyncio.run(check_mcp(mcp)) - - # Start the MCP server - mcp.run() -``` - -### Route Map Shortcuts + FastMCP provides several shortcut functions to create common route maps more easily: @@ -338,8 +276,6 @@ These shortcuts are particularly useful for: 2. Excluding whole sections of your API (use `EXCLUDE_PATTERN("/path/.*")`) 3. Converting routes matching specific patterns to tools (use `PATTERN_AS_TOOLS("/path/.*")`) -The `all_routes_as_tools=True` parameter is equivalent to using just `[ALL_TOOLS()]` as your route maps. - You can use `EXCLUDE_ALL()` as the last entry in your custom route maps to completely ignore the default route maps. Since custom route maps are applied first and default maps are appended afterward, having `EXCLUDE_ALL()` at the end of your custom maps will match any routes that your earlier custom rules didn't match, preventing the default maps from having any effect. @@ -359,3 +295,61 @@ mcp = FastMCP.from_openapi( ``` +## How It Works + +1. FastMCP parses your OpenAPI spec to extract routes and schemas +2. It applies mapping rules to categorize each route +3. When an MCP client calls a tool or accesses a resource: + - FastMCP constructs an HTTP request based on the OpenAPI definition + - It sends the request through the provided httpx client + - It translates the HTTP response to the appropriate MCP format + +### Request Parameter Handling + +FastMCP carefully handles different types of parameters in OpenAPI requests: + +#### Query Parameters + +By default, FastMCP will only include query parameters that have non-empty values. Parameters with `None` values or empty strings (`""`) are automatically filtered out of requests. This ensures that API servers don't receive unnecessary empty parameters that might cause issues. + +For example, if you call a tool with these parameters: +```python +await client.call_tool("search_products", { + "category": "electronics", # Will be included + "min_price": 100, # Will be included + "max_price": None, # Will be excluded + "brand": "", # Will be excluded +}) +``` + +The resulting HTTP request will only include `category=electronics&min_price=100`. + +#### Path Parameters + +For path parameters, which are typically required by REST APIs, FastMCP filters out `None` values and checks that all required path parameters are provided. If a required path parameter is missing or `None`, an error will be raised. + +```python +# This will work +await client.call_tool("get_product", {"product_id": 123}) + +# This will raise ValueError: "Missing required path parameters: {'product_id'}" +await client.call_tool("get_product", {"product_id": None}) +``` + +## Example: Custom Authentication + +If your API requires authentication, you can set headers on the client: + +```python +import httpx +from fastmcp import FastMCP + +# Create a client with authentication +api_client = httpx.AsyncClient( + base_url="https://api.example.com", + headers={"Authorization": "Bearer YOUR_TOKEN"} +) + +# Create an MCP server from your OpenAPI spec +mcp = FastMCP.from_openapi(openapi_spec=spec, client=api_client) +``` diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index ed3e21aa1..9af330f35 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -53,6 +53,10 @@ class MCPType(enum.Enum): EXCLUDE = "EXCLUDE" +# Type for component naming function +ComponentNameFn = Callable[[openapi.HTTPRoute, MCPType, str], str] + + # Keep RouteType as an alias to MCPType for backward compatibility class RouteType(enum.Enum): """ @@ -65,30 +69,7 @@ class RouteType(enum.Enum): RESOURCE = "RESOURCE" RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE" PROMPT = "PROMPT" - EXCLUDE = "EXCLUDE" - IGNORE = "IGNORE" # Deprecated, use EXCLUDE instead - - def __new__(cls, value): - # Deprecated in 2.4.1 - warnings.warn( - "RouteType is deprecated and will be removed in a future version. " - "Use MCPType instead.", - DeprecationWarning, - stacklevel=2, - ) - - # Add a specific warning for the deprecated IGNORE value - if value == "IGNORE": - warnings.warn( - "RouteType.IGNORE is deprecated and will be removed in a future version. " - "Use MCPType.EXCLUDE instead.", - DeprecationWarning, - stacklevel=2, - ) - - instance = object.__new__(cls) - instance._value_ = value - return instance + IGNORE = "IGNORE" @dataclass @@ -102,7 +83,7 @@ class RouteMap: def __post_init__(self): """Validate and process the route map after initialization.""" - # Handle backward compatibility for route_type + # Handle backward compatibility for route_type, deprecated in 2.5.0 if self.mcp_type is None and self.route_type is not None: warnings.warn( "The 'route_type' parameter is deprecated and will be removed in a future version. " @@ -110,7 +91,13 @@ class RouteMap: DeprecationWarning, stacklevel=2, ) - + if isinstance(self.route_type, RouteType): + warnings.warn( + "The RouteType class is deprecated and will be removed in a future version. " + "Use MCPType instead.", + DeprecationWarning, + stacklevel=2, + ) # Check for the deprecated IGNORE value if self.route_type == RouteType.IGNORE: warnings.warn( @@ -236,13 +223,6 @@ def _determine_route_type( return MCPType.TOOL -# Placeholder function to provide function metadata -async def _openapi_passthrough(*args, **kwargs): - """Placeholder function for OpenAPI endpoints.""" - # This is kept for metadata generation purposes - pass - - class OpenAPITool(Tool): """Tool implementation for OpenAPI endpoints.""" @@ -670,6 +650,55 @@ class OpenAPIResourceTemplate(ResourceTemplate): ) +def default_component_name_fn( + route: openapi.HTTPRoute, mcp_type: MCPType, default_name: str +) -> str: + """ + Default function for generating component names from routes. + + This function creates simpler names than the original method: + - For resources and templates: Just uses the resource name without HTTP method + - For tools: Uses a simpler naming convention + + Args: + route: The OpenAPI route + mcp_type: The component type being created + default_name: The original default name that would be used + + Returns: + str: The component name to use + """ + # First check for OpenAPI operationId which takes precedence + if route.operation_id: + return route.operation_id + + # For path-based naming, clean up the path + path_parts = route.path.strip("/").split("/") + + # Remove path parameters (parts with {}) + clean_parts = [] + for part in path_parts: + if part.startswith("{") and part.endswith("}"): + # For templates, include parameter name without braces + if mcp_type == MCPType.RESOURCE_TEMPLATE: + param_name = part[1:-1] # Remove braces + clean_parts.append(param_name) + else: + clean_parts.append(part) + + # Join the parts + resource_name = "_".join(clean_parts) + + # For tools, might be useful to keep the method for clarity on what it does + if mcp_type == MCPType.TOOL: + # Only include method if it helps distinguish (POST, PUT, PATCH, DELETE) + # For GET we don't need the method as it's implied for resources + if route.method != "GET": + resource_name = f"{route.method.lower()}_{resource_name}" + + return resource_name + + class FastMCPOpenAPI(FastMCP): """ FastMCP server implementation that creates components from an OpenAPI schema. @@ -715,6 +744,7 @@ class FastMCPOpenAPI(FastMCP): name: str | None = None, route_maps: list[RouteMap] | None = None, timeout: float | None = None, + component_namer: ComponentNameFn | None = None, **settings: Any, ): """ @@ -726,12 +756,18 @@ class FastMCPOpenAPI(FastMCP): name: Optional name for the server route_maps: Optional list of RouteMap objects defining route mappings timeout: Optional timeout (in seconds) for all requests + component_namer: Optional function to customize component names **settings: Additional settings for FastMCP """ super().__init__(name=name or "OpenAPI FastMCP", **settings) self._client = client self._timeout = timeout + self._component_namer = component_namer or default_component_name_fn + + # Keep track of names to detect collisions + self._used_names = {"tools": set(), "resources": set(), "templates": set()} + http_routes = openapi.parse_openapi_to_http_routes(openapi_spec) # Process routes @@ -740,20 +776,18 @@ class FastMCPOpenAPI(FastMCP): # Determine route type based on mappings or default rules route_type = _determine_route_type(route, route_maps) - # Use operation_id if available, otherwise generate a name - operation_id = route.operation_id - if not operation_id: - # Generate operation ID from method and path - path_parts = route.path.strip("/").split("/") - path_name = "_".join(p for p in path_parts if not p.startswith("{")) - operation_id = f"{route.method.lower()}_{path_name}" + # Generate a default name from the route + default_name = self._generate_default_name(route) + + # Get the component name using the namer function + component_name = self._component_namer(route, route_type, default_name) if route_type == MCPType.TOOL: - self._create_openapi_tool(route, operation_id) + self._create_openapi_tool(route, component_name) elif route_type == MCPType.RESOURCE: - self._create_openapi_resource(route, operation_id) + self._create_openapi_resource(route, component_name) elif route_type == MCPType.RESOURCE_TEMPLATE: - self._create_openapi_template(route, operation_id) + self._create_openapi_template(route, component_name) elif route_type == MCPType.PROMPT: # Not implemented yet logger.warning( @@ -764,10 +798,59 @@ class FastMCPOpenAPI(FastMCP): logger.info(f"Created FastMCP OpenAPI server with {len(http_routes)} routes") - def _create_openapi_tool(self, route: openapi.HTTPRoute, operation_id: str): + def _generate_default_name(self, route: openapi.HTTPRoute) -> str: + """Generate a default name from the route path.""" + # Use OpenAPI operationId if available + if route.operation_id: + return route.operation_id + + # Generate a name from the path + path_parts = route.path.strip("/").split("/") + path_name = "_".join(p for p in path_parts if not p.startswith("{")) + + # The original default naming included the HTTP method + return f"{route.method.lower()}_{path_name}" + + def _get_unique_name( + self, name: str, component_type: Literal["tools", "resources", "templates"] + ) -> str: + """ + Ensure the name is unique within its component type by appending numbers if needed. + + Args: + name: The proposed name + component_type: The type of component ("tools", "resources", or "templates") + + Returns: + str: A unique name for the component + """ + # Check if the name is already used + if name not in self._used_names[component_type]: + self._used_names[component_type].add(name) + return name + + # Find the next available number suffix + counter = 2 + while f"{name}_{counter}" in self._used_names[component_type]: + counter += 1 + + # Create the new name + new_name = f"{name}_{counter}" + logger.debug( + f"Name collision detected: '{name}' already exists as a {component_type[:-1]}. " + f"Using '{new_name}' instead." + ) + + self._used_names[component_type].add(new_name) + return new_name + + def _create_openapi_tool(self, route: openapi.HTTPRoute, name: str): """Creates and registers an OpenAPITool with enhanced description.""" combined_schema = _combine_schemas(route) - tool_name = operation_id + + # Get a unique tool name + tool_name = self._get_unique_name(name, "tools") + base_description = ( route.description or route.summary @@ -797,9 +880,11 @@ class FastMCPOpenAPI(FastMCP): f"Registered TOOL: {tool_name} ({route.method} {route.path}) with tags: {route.tags}" ) - def _create_openapi_resource(self, route: openapi.HTTPRoute, operation_id: str): + def _create_openapi_resource(self, route: openapi.HTTPRoute, name: str): """Creates and registers an OpenAPIResource with enhanced description.""" - resource_name = operation_id + # Get a unique resource name + resource_name = self._get_unique_name(name, "resources") + resource_uri = f"resource://openapi/{resource_name}" base_description = ( route.description or route.summary or f"Represents {route.path}" @@ -828,9 +913,11 @@ class FastMCPOpenAPI(FastMCP): f"Registered RESOURCE: {resource_uri} ({route.method} {route.path}) with tags: {route.tags}" ) - def _create_openapi_template(self, route: openapi.HTTPRoute, operation_id: str): + def _create_openapi_template(self, route: openapi.HTTPRoute, name: str): """Creates and registers an OpenAPIResourceTemplate with enhanced description.""" - template_name = operation_id + # Get a unique template name + template_name = self._get_unique_name(name, "templates") + path_params = [p.name for p in route.parameters if p.location == "path"] path_params.sort() # Sort for consistent URIs diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 389ad9600..70fe8dbb0 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1147,19 +1147,22 @@ class FastMCP(Generic[LifespanResultT]): """ Create a FastMCP server from an OpenAPI specification. """ - from .openapi import FastMCPOpenAPI, RouteMap, RouteType + from .openapi import ALL_TOOLS, FastMCPOpenAPI + + # Deprecated since 2.5.0 + if all_routes_as_tools: + warnings.warn( + "The 'all_routes_as_tools' parameter is deprecated and will be removed in a future version. " + "Use 'route_maps=[ALL_TOOLS()]' instead.", + DeprecationWarning, + stacklevel=2, + ) if all_routes_as_tools and route_maps: raise ValueError("Cannot specify both all_routes_as_tools and route_maps") elif all_routes_as_tools: - route_maps = [ - RouteMap( - methods="*", - pattern=r".*", - route_type=RouteType.TOOL, - ) - ] + route_maps = [ALL_TOOLS()] return FastMCPOpenAPI( openapi_spec=openapi_spec, @@ -1181,15 +1184,21 @@ class FastMCP(Generic[LifespanResultT]): Create a FastMCP server from a FastAPI application. """ - from .openapi import FastMCPOpenAPI, RouteMap, RouteType + from .openapi import ALL_TOOLS, FastMCPOpenAPI + + if all_routes_as_tools: + warnings.warn( + "The 'all_routes_as_tools' parameter is deprecated and will be removed in a future version. " + "Use 'route_maps=[ALL_TOOLS()]' instead.", + DeprecationWarning, + stacklevel=2, + ) if all_routes_as_tools and route_maps: raise ValueError("Cannot specify both all_routes_as_tools and route_maps") elif all_routes_as_tools: - route_maps = [ - RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL) - ] + route_maps = [ALL_TOOLS()] client = httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://fastapi" diff --git a/tests/server/test_openapi.py b/tests/server/test_openapi.py index f38ae4acc..4dfb721e0 100644 --- a/tests/server/test_openapi.py +++ b/tests/server/test_openapi.py @@ -18,11 +18,11 @@ from fastmcp.client import Client from fastmcp.exceptions import ToolError from fastmcp.server.openapi import ( FastMCPOpenAPI, + MCPType, OpenAPIResource, OpenAPIResourceTemplate, OpenAPITool, RouteMap, - RouteType, ) @@ -304,7 +304,7 @@ class TestTools: openapi_spec=openapi_spec, client=api_client, route_maps=[ - RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.TOOL) + RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL) ], ) async with Client(mcp_server) as client: @@ -956,9 +956,7 @@ async def test_empty_query_parameters_not_sent( mcp_server = FastMCPOpenAPI( openapi_spec=openapi_spec, client=api_client, - route_maps=[ - RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.TOOL) - ], + route_maps=[RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)], ) # Call the search tool with mixed parameter values @@ -1499,17 +1497,15 @@ class TestFastAPIDescriptionPropagation: # Create custom route mappings route_maps = [ # Map GET /items to Resource - RouteMap( - methods=["GET"], pattern=r"^/items$", route_type=RouteType.RESOURCE - ), + RouteMap(methods=["GET"], pattern=r"^/items$", mcp_type=MCPType.RESOURCE), # Map GET /items/{item_id} to ResourceTemplate RouteMap( methods=["GET"], pattern=r"^/items/\{.*\}$", - route_type=RouteType.RESOURCE_TEMPLATE, + mcp_type=MCPType.RESOURCE_TEMPLATE, ), # Map POST /items to Tool - RouteMap(methods=["POST"], pattern=r"^/items$", route_type=RouteType.TOOL), + RouteMap(methods=["POST"], pattern=r"^/items$", mcp_type=MCPType.TOOL), ] # Create FastMCP server with the OpenAPI spec and custom route mappings @@ -1918,7 +1914,7 @@ class TestRouteMapWildcard: ): """Test that a RouteMap with methods='*' matches all HTTP methods.""" # Create a single route map with wildcard method - route_maps = [RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)] + route_maps = [RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)] mcp = FastMCPOpenAPI( openapi_spec=basic_openapi_spec, @@ -1947,9 +1943,9 @@ class TestRouteMapWildcard: # Create route maps with specific method first, then wildcard route_maps = [ # GET operations should be mapped to resources - RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE), + RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), # All other operations should be mapped to tools - RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL), + RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL), ] mcp = FastMCPOpenAPI( @@ -1977,9 +1973,9 @@ class TestRouteMapWildcard: # Create route maps with wildcard first, then specific methods route_maps = [ # Wildcard first matches everything - RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL), + RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL), # This should never be reached - RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE), + RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), ] mcp = FastMCPOpenAPI( @@ -2002,9 +1998,9 @@ class TestRouteMapWildcard: """Test wildcard methods combined with specific path patterns.""" route_maps = [ # All methods on /users path -> Resources - RouteMap(methods="*", pattern=r".*/users$", route_type=RouteType.RESOURCE), + RouteMap(methods="*", pattern=r".*/users$", mcp_type=MCPType.RESOURCE), # All methods on /posts path -> Tools - RouteMap(methods="*", pattern=r".*/posts$", route_type=RouteType.TOOL), + RouteMap(methods="*", pattern=r".*/posts$", mcp_type=MCPType.TOOL), ] mcp = FastMCPOpenAPI( @@ -2063,95 +2059,104 @@ class TestAllRoutesAsTools: async def test_from_openapi_all_routes_as_tools(self, simple_api_spec, mock_client): """Test FastMCP.from_openapi with all_routes_as_tools=True.""" - # Create server with all routes as tools - server = FastMCP.from_openapi( - openapi_spec=simple_api_spec, client=mock_client, all_routes_as_tools=True - ) - # All operations (GET and POST) should be mapped to tools - tools = server._tool_manager.list_tools() - tool_names = {t.name for t in tools} + with pytest.warns(DeprecationWarning, match="all_routes_as_tools.*deprecated"): + server = FastMCP.from_openapi( + openapi_spec=simple_api_spec, + client=mock_client, + all_routes_as_tools=True, + ) - assert "getItems" in tool_names - assert "createItem" in tool_names - assert len(tools) == 2 + # Check that all routes are tools + tools = await server.get_tools() + assert len(tools) >= 2 # Should have at least the two endpoints as tools - # No resources or templates should be created - resources = server._resource_manager.get_resources() - templates = server._resource_manager.get_templates() + # Should have no resources since all routes are tools + resources = await server.get_resources() assert len(resources) == 0 + + # Should have no resource templates since all routes are tools + templates = await server.get_resource_templates() assert len(templates) == 0 async def test_from_openapi_all_routes_as_tools_conflicting_args( self, simple_api_spec, mock_client ): """Test FastMCP.from_openapi raises error when both route_maps and all_routes_as_tools are provided.""" - # Try to create server with conflicting args with pytest.raises( ValueError, match="Cannot specify both all_routes_as_tools and route_maps" ): - FastMCP.from_openapi( - openapi_spec=simple_api_spec, - client=mock_client, - all_routes_as_tools=True, - route_maps=[ - RouteMap( - methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE - ) - ], - ) + with pytest.warns( + DeprecationWarning, match="all_routes_as_tools.*deprecated" + ): + FastMCP.from_openapi( + openapi_spec=simple_api_spec, + client=mock_client, + route_maps=[ + RouteMap( + methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE + ) + ], + all_routes_as_tools=True, + ) async def test_from_fastapi_all_routes_as_tools(self): """Test FastMCP.from_fastapi with all_routes_as_tools=True.""" - # Create a simple FastAPI app - app = FastAPI(title="Test FastAPI") + + try: + import fastapi + except ImportError: + pytest.skip("FastAPI not available") + + app = fastapi.FastAPI() @app.get("/items") - async def get_items(): - return [{"id": 1, "name": "Item 1"}] + def get_items(): + return {"items": []} @app.post("/items") - async def create_item(item: dict): - return {"id": 2, **item} + def create_item(): + return {"item": "created"} - # Create server with all routes as tools - server = FastMCP.from_fastapi(app=app, all_routes_as_tools=True) + with pytest.warns(DeprecationWarning, match="all_routes_as_tools.*deprecated"): + server = FastMCP.from_fastapi(app=app, all_routes_as_tools=True) - # Both GET and POST operations should be mapped to tools - tools = server._tool_manager.list_tools() + # Check that all routes are tools + tools = await server.get_tools() + assert len(tools) >= 2 # Should have at least the two endpoints as tools - # Get tool names from the generated operation IDs - tool_names = {t.name for t in tools} - - # Check that both routes were mapped to tools - # The exact names depend on FastAPI's operation ID generation - assert len(tools) == 2 - assert any("get" in name.lower() for name in tool_names) - assert any("post" in name.lower() for name in tool_names) - - # No resources or templates should be created - resources = server._resource_manager.get_resources() - templates = server._resource_manager.get_templates() + # Should have no resources since all routes are tools + resources = await server.get_resources() assert len(resources) == 0 + + # Should have no resource templates since all routes are tools + templates = await server.get_resource_templates() assert len(templates) == 0 async def test_from_fastapi_all_routes_as_tools_conflicting_args(self): """Test FastMCP.from_fastapi raises error when both route_maps and all_routes_as_tools are provided.""" - app = FastAPI(title="Test FastAPI") + try: + import fastapi + except ImportError: + pytest.skip("FastAPI not available") + + app = fastapi.FastAPI() - # Try to create server with conflicting args with pytest.raises( ValueError, match="Cannot specify both all_routes_as_tools and route_maps" ): - FastMCP.from_fastapi( - app=app, - all_routes_as_tools=True, - route_maps=[ - RouteMap( - methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE - ) - ], - ) + with pytest.warns( + DeprecationWarning, match="all_routes_as_tools.*deprecated" + ): + FastMCP.from_fastapi( + app=app, + route_maps=[ + RouteMap( + methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE + ) + ], + all_routes_as_tools=True, + ) class TestRouteTypeExclude: @@ -2202,10 +2207,10 @@ class TestRouteTypeExclude: RouteMap( methods=["GET"], pattern=r"^/analytics$", - route_type=RouteType.IGNORE, + mcp_type=MCPType.EXCLUDE, ), # Make everything else a resource - RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE), + RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), ], ) diff --git a/tests/server/test_openapi_naming.py b/tests/server/test_openapi_naming.py new file mode 100644 index 000000000..52ffe00e3 --- /dev/null +++ b/tests/server/test_openapi_naming.py @@ -0,0 +1,231 @@ +"""Tests for OpenAPI component naming in FastMCP.""" + +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from fastmcp.server.openapi import FastMCPOpenAPI, MCPType + + +@pytest.fixture +def simple_openapi_spec(): + """A simple OpenAPI spec with some routes for testing.""" + return { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/users": { + "get": { + "summary": "Get all users", + "responses": {"200": {"description": "OK"}}, + }, + "post": { + "summary": "Create a user", + "responses": {"201": {"description": "Created"}}, + }, + }, + "/users/{id}": { + "get": { + "summary": "Get a user", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + "responses": {"200": {"description": "OK"}}, + }, + "put": { + "summary": "Update a user", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + "responses": {"200": {"description": "OK"}}, + }, + }, + "/users/{id}/orders": { + "get": { + "summary": "Get user orders", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + "responses": {"200": {"description": "OK"}}, + } + }, + "/products": { + "get": { + "operationId": "listProducts", + "summary": "Get all products", + "responses": {"200": {"description": "OK"}}, + } + }, + }, + } + + +class TestOpenAPIComponentNaming: + """Tests for OpenAPI component naming functionality.""" + + @patch("fastmcp.server.openapi._combine_schemas") + def test_default_naming(self, mock_combine, simple_openapi_spec): + """Test the default component naming behavior.""" + # Mock the HTTP client + mock_client = MagicMock(spec=httpx.AsyncClient) + + # Mock the combine schemas function to return empty dict + mock_combine.return_value = {} + + # Create a server with the default naming + # Instead of mocking the creation methods, we'll just override them to + # add the names to _used_names without actually creating components + class TestServer(FastMCPOpenAPI): + def _create_openapi_tool(self, route, name): + _tool_name = self._get_unique_name(name, "tools") + # Don't actually create the tool, just record that the name was used + + def _create_openapi_resource(self, route, name): + _resource_name = self._get_unique_name(name, "resources") + # Don't actually create the resource, just record that the name was used + + def _create_openapi_template(self, route, name): + _template_name = self._get_unique_name(name, "templates") + # Don't actually create the template, just record that the name was used + + # Create the server with our test subclass + server = TestServer( + openapi_spec=simple_openapi_spec, + client=mock_client, + ) + + # Check that the correct names were generated + expected_names = { + "tools": {"post_users", "put_users"}, + "resources": { + "users", + "listProducts", + }, # GET /users, GET /products (from operationId) + "templates": { + "users_id", + "users_id_orders", + }, # GET /users/{id}, GET /users/{id}/orders + } + + # The "tools" set in the server might contain more than our expected names + # because all HTTP methods could be converted to tools - we just check for inclusion + assert expected_names["tools"].issubset(server._used_names["tools"]) + assert expected_names["resources"].issubset(server._used_names["resources"]) + assert expected_names["templates"].issubset(server._used_names["templates"]) + + # Check that the operationId is preferred for naming + assert "listProducts" in server._used_names["resources"] + + @patch("fastmcp.server.openapi._combine_schemas") + def test_custom_naming(self, mock_combine, simple_openapi_spec): + """Test custom component naming function.""" + # Mock the HTTP client + mock_client = MagicMock(spec=httpx.AsyncClient) + + # Mock the combine schemas function to return empty dict + mock_combine.return_value = {} + + # Create a custom naming function + def custom_namer(route, mcp_type, default_name): + # Always prefix with component type + if mcp_type == MCPType.TOOL: + prefix = "tool" + elif mcp_type == MCPType.RESOURCE: + prefix = "res" + elif mcp_type == MCPType.RESOURCE_TEMPLATE: + prefix = "tmpl" + else: + prefix = "other" + + # Use operationId if available + if route.operation_id: + return f"{prefix}_{route.operation_id}" + + # Otherwise use the path + path_name = route.path.replace("/", "_").replace("{", "").replace("}", "") + return f"{prefix}{path_name}" + + # Create a custom testing server subclass + class TestServer(FastMCPOpenAPI): + def _create_openapi_tool(self, route, name): + _tool_name = self._get_unique_name(name, "tools") + # Don't actually create the tool, just record that the name was used + + def _create_openapi_resource(self, route, name): + _resource_name = self._get_unique_name(name, "resources") + # Don't actually create the resource, just record that the name was used + + def _create_openapi_template(self, route, name): + _template_name = self._get_unique_name(name, "templates") + # Don't actually create the template, just record that the name was used + + # Create a server with the custom naming + server = TestServer( + openapi_spec=simple_openapi_spec, + client=mock_client, + component_namer=custom_namer, + ) + + # Check some of the generated names + assert "tool_users" in server._used_names["tools"] + assert "res_users" in server._used_names["resources"] + assert "tmpl_users_id" in server._used_names["templates"] + assert "res_listProducts" in server._used_names["resources"] + + @patch("fastmcp.server.openapi._combine_schemas") + def test_collision_handling(self, mock_combine, simple_openapi_spec): + """Test how name collisions are handled by appending numbers.""" + # Mock the HTTP client + mock_client = MagicMock(spec=httpx.AsyncClient) + + # Mock the combine schemas function to return empty dict + mock_combine.return_value = {} + + # Create a custom naming function that always returns the same name + def collision_namer(route, mcp_type, default_name): + return "same_name" + + # Create a custom testing server subclass + class TestServer(FastMCPOpenAPI): + def _create_openapi_tool(self, route, name): + _tool_name = self._get_unique_name(name, "tools") + # Don't actually create the tool, just record that the name was used + + def _create_openapi_resource(self, route, name): + _resource_name = self._get_unique_name(name, "resources") + # Don't actually create the resource, just record that the name was used + + def _create_openapi_template(self, route, name): + _template_name = self._get_unique_name(name, "templates") + # Don't actually create the template, just record that the name was used + + # Create a server with the collision namer + server = TestServer( + openapi_spec=simple_openapi_spec, + client=mock_client, + component_namer=collision_namer, + ) + + # Check that names were renamed with numbers + assert "same_name" in server._used_names["tools"] + assert "same_name_2" in server._used_names["tools"] + assert "same_name" in server._used_names["resources"] + assert "same_name_2" in server._used_names["resources"] + assert "same_name" in server._used_names["templates"] + assert "same_name_2" in server._used_names["templates"] diff --git a/tests/server/test_openapi_path_parameters.py b/tests/server/test_openapi_path_parameters.py index 9940594a8..517382a2c 100644 --- a/tests/server/test_openapi_path_parameters.py +++ b/tests/server/test_openapi_path_parameters.py @@ -6,7 +6,7 @@ import pytest from fastapi import FastAPI, Query from fastmcp import Client, FastMCP -from fastmcp.server.openapi import OpenAPITool, RouteMap, RouteType +from fastmcp.server.openapi import MCPType, OpenAPITool, RouteMap from fastmcp.utilities.openapi import HTTPRoute, ParameterInfo @@ -286,9 +286,7 @@ async def test_array_query_param_with_fastapi(): # Create a FastMCP server from the FastAPI app mcp = FastMCP.from_fastapi( app, - route_maps=[ - RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.TOOL) - ], + route_maps=[RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)], ) # Test with the client diff --git a/tests/server/test_route_map_shortcuts.py b/tests/server/test_route_map_shortcuts.py index a64be9910..92cc5465f 100644 --- a/tests/server/test_route_map_shortcuts.py +++ b/tests/server/test_route_map_shortcuts.py @@ -11,7 +11,6 @@ from fastmcp.server.openapi import ( FastMCPOpenAPI, MCPType, RouteMap, - RouteType, ) @@ -52,6 +51,8 @@ class TestRouteMapShortcuts: def test_backward_compatibility(self): """Test that backward compatibility with RouteType and route_type works.""" + from fastmcp.server.openapi import RouteType + # Test creating a RouteMap with route_type with pytest.warns(DeprecationWarning): route_map = RouteMap( From 702412e28b0b197502788a73cd652e4e2bbab783 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 22 May 2025 21:11:46 -0400 Subject: [PATCH 3/3] Remove custom names --- docs/patterns/openapi.mdx | 45 +--- src/fastmcp/server/openapi.py | 94 +++---- tests/server/{ => openapi}/test_openapi.py | 0 .../test_openapi_path_parameters.py | 0 tests/server/test_openapi_naming.py | 231 ------------------ 5 files changed, 29 insertions(+), 341 deletions(-) rename tests/server/{ => openapi}/test_openapi.py (100%) rename tests/server/{ => openapi}/test_openapi_path_parameters.py (100%) delete mode 100644 tests/server/test_openapi_naming.py diff --git a/docs/patterns/openapi.mdx b/docs/patterns/openapi.mdx index 68d5c6bd4..c2a586ccd 100644 --- a/docs/patterns/openapi.mdx +++ b/docs/patterns/openapi.mdx @@ -41,52 +41,10 @@ mcp = FastMCP.from_openapi( ) ``` -### Component Naming +## Route Mapping -You can customize how FastMCP names the components generated from your OpenAPI spec: - -```python -# Custom naming function -def my_component_namer(route, mcp_type, default_name): - # Create custom names based on the route and component type - if route.operation_id: - return route.operation_id - - # For example, prefix with component type - prefix = { - MCPType.TOOL: "tool_", - MCPType.RESOURCE: "resource_", - MCPType.RESOURCE_TEMPLATE: "template_", - }.get(mcp_type, "") - - path_name = route.path.replace("/", "_").strip("_") - return f"{prefix}{path_name}" - -mcp = FastMCP.from_openapi( - openapi_spec=spec, - client=api_client, - component_namer=my_component_namer -) -``` - -By default, FastMCP generates component names as follows: - -- If the route has an `operationId` in the OpenAPI spec, that is used -- Otherwise, the name is generated from the route path: - - For `GET` routes mapped to resources: Just the resource name (e.g., `/users` → `users`) - - For routes with path parameters mapped to templates: The path with parameter names (e.g., `/users/{id}` → `users_id`) - - For other methods mapped to tools: Method + resource name (e.g., `POST /users` → `post_users`) - -#### Handling Name Collisions - -When multiple routes would generate the same component name, FastMCP automatically appends a number suffix to ensure uniqueness (e.g., `users`, `users_2`, `users_3`). You'll see these numbered suffixes in the component names returned by `get_tools()`, `get_resources()`, etc. - -If you need more control over naming, you can provide a custom `component_namer` function that handles potential collisions in your own way. - -### Route Mapping - By default, OpenAPI routes are mapped to MCP components based on these rules: | OpenAPI Route | Example |MCP Component | Notes | @@ -232,7 +190,6 @@ mcp = FastMCPOpenAPI( #### Route Map Shortcuts - FastMCP provides several shortcut functions to create common route maps more easily: diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index 9af330f35..0bed5c537 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -53,10 +53,6 @@ class MCPType(enum.Enum): EXCLUDE = "EXCLUDE" -# Type for component naming function -ComponentNameFn = Callable[[openapi.HTTPRoute, MCPType, str], str] - - # Keep RouteType as an alias to MCPType for backward compatibility class RouteType(enum.Enum): """ @@ -650,55 +646,6 @@ class OpenAPIResourceTemplate(ResourceTemplate): ) -def default_component_name_fn( - route: openapi.HTTPRoute, mcp_type: MCPType, default_name: str -) -> str: - """ - Default function for generating component names from routes. - - This function creates simpler names than the original method: - - For resources and templates: Just uses the resource name without HTTP method - - For tools: Uses a simpler naming convention - - Args: - route: The OpenAPI route - mcp_type: The component type being created - default_name: The original default name that would be used - - Returns: - str: The component name to use - """ - # First check for OpenAPI operationId which takes precedence - if route.operation_id: - return route.operation_id - - # For path-based naming, clean up the path - path_parts = route.path.strip("/").split("/") - - # Remove path parameters (parts with {}) - clean_parts = [] - for part in path_parts: - if part.startswith("{") and part.endswith("}"): - # For templates, include parameter name without braces - if mcp_type == MCPType.RESOURCE_TEMPLATE: - param_name = part[1:-1] # Remove braces - clean_parts.append(param_name) - else: - clean_parts.append(part) - - # Join the parts - resource_name = "_".join(clean_parts) - - # For tools, might be useful to keep the method for clarity on what it does - if mcp_type == MCPType.TOOL: - # Only include method if it helps distinguish (POST, PUT, PATCH, DELETE) - # For GET we don't need the method as it's implied for resources - if route.method != "GET": - resource_name = f"{route.method.lower()}_{resource_name}" - - return resource_name - - class FastMCPOpenAPI(FastMCP): """ FastMCP server implementation that creates components from an OpenAPI schema. @@ -744,7 +691,6 @@ class FastMCPOpenAPI(FastMCP): name: str | None = None, route_maps: list[RouteMap] | None = None, timeout: float | None = None, - component_namer: ComponentNameFn | None = None, **settings: Any, ): """ @@ -756,14 +702,12 @@ class FastMCPOpenAPI(FastMCP): name: Optional name for the server route_maps: Optional list of RouteMap objects defining route mappings timeout: Optional timeout (in seconds) for all requests - component_namer: Optional function to customize component names **settings: Additional settings for FastMCP """ super().__init__(name=name or "OpenAPI FastMCP", **settings) self._client = client self._timeout = timeout - self._component_namer = component_namer or default_component_name_fn # Keep track of names to detect collisions self._used_names = {"tools": set(), "resources": set(), "templates": set()} @@ -777,10 +721,7 @@ class FastMCPOpenAPI(FastMCP): route_type = _determine_route_type(route, route_maps) # Generate a default name from the route - default_name = self._generate_default_name(route) - - # Get the component name using the namer function - component_name = self._component_namer(route, route_type, default_name) + component_name = self._generate_default_name(route, route_type) if route_type == MCPType.TOOL: self._create_openapi_tool(route, component_name) @@ -798,18 +739,39 @@ class FastMCPOpenAPI(FastMCP): logger.info(f"Created FastMCP OpenAPI server with {len(http_routes)} routes") - def _generate_default_name(self, route: openapi.HTTPRoute) -> str: + def _generate_default_name( + self, route: openapi.HTTPRoute, mcp_type: MCPType + ) -> str: """Generate a default name from the route path.""" - # Use OpenAPI operationId if available + # First check for OpenAPI operationId which takes precedence if route.operation_id: return route.operation_id - # Generate a name from the path + # For path-based naming, clean up the path path_parts = route.path.strip("/").split("/") - path_name = "_".join(p for p in path_parts if not p.startswith("{")) - # The original default naming included the HTTP method - return f"{route.method.lower()}_{path_name}" + # Remove path parameters (parts with {}) + clean_parts = [] + for part in path_parts: + if part.startswith("{") and part.endswith("}"): + # For templates, include parameter name without braces + if mcp_type == MCPType.RESOURCE_TEMPLATE: + param_name = part[1:-1] # Remove braces + clean_parts.append(param_name) + else: + clean_parts.append(part) + + # Join the parts + resource_name = "_".join(clean_parts) + + # For tools, might be useful to keep the method for clarity on what it does + if mcp_type == MCPType.TOOL: + # Only include method if it helps distinguish (POST, PUT, PATCH, DELETE) + # For GET we don't need the method as it's implied for resources + if route.method != "GET": + resource_name = f"{route.method.lower()}_{resource_name}" + + return resource_name def _get_unique_name( self, name: str, component_type: Literal["tools", "resources", "templates"] diff --git a/tests/server/test_openapi.py b/tests/server/openapi/test_openapi.py similarity index 100% rename from tests/server/test_openapi.py rename to tests/server/openapi/test_openapi.py diff --git a/tests/server/test_openapi_path_parameters.py b/tests/server/openapi/test_openapi_path_parameters.py similarity index 100% rename from tests/server/test_openapi_path_parameters.py rename to tests/server/openapi/test_openapi_path_parameters.py diff --git a/tests/server/test_openapi_naming.py b/tests/server/test_openapi_naming.py deleted file mode 100644 index 52ffe00e3..000000000 --- a/tests/server/test_openapi_naming.py +++ /dev/null @@ -1,231 +0,0 @@ -"""Tests for OpenAPI component naming in FastMCP.""" - -from unittest.mock import MagicMock, patch - -import httpx -import pytest - -from fastmcp.server.openapi import FastMCPOpenAPI, MCPType - - -@pytest.fixture -def simple_openapi_spec(): - """A simple OpenAPI spec with some routes for testing.""" - return { - "openapi": "3.0.0", - "info": {"title": "Test API", "version": "1.0.0"}, - "paths": { - "/users": { - "get": { - "summary": "Get all users", - "responses": {"200": {"description": "OK"}}, - }, - "post": { - "summary": "Create a user", - "responses": {"201": {"description": "Created"}}, - }, - }, - "/users/{id}": { - "get": { - "summary": "Get a user", - "parameters": [ - { - "name": "id", - "in": "path", - "required": True, - "schema": {"type": "string"}, - } - ], - "responses": {"200": {"description": "OK"}}, - }, - "put": { - "summary": "Update a user", - "parameters": [ - { - "name": "id", - "in": "path", - "required": True, - "schema": {"type": "string"}, - } - ], - "responses": {"200": {"description": "OK"}}, - }, - }, - "/users/{id}/orders": { - "get": { - "summary": "Get user orders", - "parameters": [ - { - "name": "id", - "in": "path", - "required": True, - "schema": {"type": "string"}, - } - ], - "responses": {"200": {"description": "OK"}}, - } - }, - "/products": { - "get": { - "operationId": "listProducts", - "summary": "Get all products", - "responses": {"200": {"description": "OK"}}, - } - }, - }, - } - - -class TestOpenAPIComponentNaming: - """Tests for OpenAPI component naming functionality.""" - - @patch("fastmcp.server.openapi._combine_schemas") - def test_default_naming(self, mock_combine, simple_openapi_spec): - """Test the default component naming behavior.""" - # Mock the HTTP client - mock_client = MagicMock(spec=httpx.AsyncClient) - - # Mock the combine schemas function to return empty dict - mock_combine.return_value = {} - - # Create a server with the default naming - # Instead of mocking the creation methods, we'll just override them to - # add the names to _used_names without actually creating components - class TestServer(FastMCPOpenAPI): - def _create_openapi_tool(self, route, name): - _tool_name = self._get_unique_name(name, "tools") - # Don't actually create the tool, just record that the name was used - - def _create_openapi_resource(self, route, name): - _resource_name = self._get_unique_name(name, "resources") - # Don't actually create the resource, just record that the name was used - - def _create_openapi_template(self, route, name): - _template_name = self._get_unique_name(name, "templates") - # Don't actually create the template, just record that the name was used - - # Create the server with our test subclass - server = TestServer( - openapi_spec=simple_openapi_spec, - client=mock_client, - ) - - # Check that the correct names were generated - expected_names = { - "tools": {"post_users", "put_users"}, - "resources": { - "users", - "listProducts", - }, # GET /users, GET /products (from operationId) - "templates": { - "users_id", - "users_id_orders", - }, # GET /users/{id}, GET /users/{id}/orders - } - - # The "tools" set in the server might contain more than our expected names - # because all HTTP methods could be converted to tools - we just check for inclusion - assert expected_names["tools"].issubset(server._used_names["tools"]) - assert expected_names["resources"].issubset(server._used_names["resources"]) - assert expected_names["templates"].issubset(server._used_names["templates"]) - - # Check that the operationId is preferred for naming - assert "listProducts" in server._used_names["resources"] - - @patch("fastmcp.server.openapi._combine_schemas") - def test_custom_naming(self, mock_combine, simple_openapi_spec): - """Test custom component naming function.""" - # Mock the HTTP client - mock_client = MagicMock(spec=httpx.AsyncClient) - - # Mock the combine schemas function to return empty dict - mock_combine.return_value = {} - - # Create a custom naming function - def custom_namer(route, mcp_type, default_name): - # Always prefix with component type - if mcp_type == MCPType.TOOL: - prefix = "tool" - elif mcp_type == MCPType.RESOURCE: - prefix = "res" - elif mcp_type == MCPType.RESOURCE_TEMPLATE: - prefix = "tmpl" - else: - prefix = "other" - - # Use operationId if available - if route.operation_id: - return f"{prefix}_{route.operation_id}" - - # Otherwise use the path - path_name = route.path.replace("/", "_").replace("{", "").replace("}", "") - return f"{prefix}{path_name}" - - # Create a custom testing server subclass - class TestServer(FastMCPOpenAPI): - def _create_openapi_tool(self, route, name): - _tool_name = self._get_unique_name(name, "tools") - # Don't actually create the tool, just record that the name was used - - def _create_openapi_resource(self, route, name): - _resource_name = self._get_unique_name(name, "resources") - # Don't actually create the resource, just record that the name was used - - def _create_openapi_template(self, route, name): - _template_name = self._get_unique_name(name, "templates") - # Don't actually create the template, just record that the name was used - - # Create a server with the custom naming - server = TestServer( - openapi_spec=simple_openapi_spec, - client=mock_client, - component_namer=custom_namer, - ) - - # Check some of the generated names - assert "tool_users" in server._used_names["tools"] - assert "res_users" in server._used_names["resources"] - assert "tmpl_users_id" in server._used_names["templates"] - assert "res_listProducts" in server._used_names["resources"] - - @patch("fastmcp.server.openapi._combine_schemas") - def test_collision_handling(self, mock_combine, simple_openapi_spec): - """Test how name collisions are handled by appending numbers.""" - # Mock the HTTP client - mock_client = MagicMock(spec=httpx.AsyncClient) - - # Mock the combine schemas function to return empty dict - mock_combine.return_value = {} - - # Create a custom naming function that always returns the same name - def collision_namer(route, mcp_type, default_name): - return "same_name" - - # Create a custom testing server subclass - class TestServer(FastMCPOpenAPI): - def _create_openapi_tool(self, route, name): - _tool_name = self._get_unique_name(name, "tools") - # Don't actually create the tool, just record that the name was used - - def _create_openapi_resource(self, route, name): - _resource_name = self._get_unique_name(name, "resources") - # Don't actually create the resource, just record that the name was used - - def _create_openapi_template(self, route, name): - _template_name = self._get_unique_name(name, "templates") - # Don't actually create the template, just record that the name was used - - # Create a server with the collision namer - server = TestServer( - openapi_spec=simple_openapi_spec, - client=mock_client, - component_namer=collision_namer, - ) - - # Check that names were renamed with numbers - assert "same_name" in server._used_names["tools"] - assert "same_name_2" in server._used_names["tools"] - assert "same_name" in server._used_names["resources"] - assert "same_name_2" in server._used_names["resources"] - assert "same_name" in server._used_names["templates"] - assert "same_name_2" in server._used_names["templates"]