Remove custom names

This commit is contained in:
Jeremiah Lowin 2025-05-22 21:11:46 -04:00
commit 702412e28b
5 changed files with 29 additions and 341 deletions

View file

@ -41,52 +41,10 @@ mcp = FastMCP.from_openapi(
)
```
### Component Naming
## Route Mapping
<VersionBadge version="2.5.0" />
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
<VersionBadge version="2.5.0" />
FastMCP provides several shortcut functions to create common route maps more easily:

View file

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

View file

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