mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-19 20:14:17 +02:00
Remove custom names
This commit is contained in:
parent
4c3bf80652
commit
702412e28b
5 changed files with 29 additions and 341 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue