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"]