Spaces:
Running
Running
Merge pull request #578 from jlowin/headers
Browse filesPermit more flexible name generation for OpenAPI servers
docs/servers/openapi.mdx
CHANGED
|
@@ -231,6 +231,38 @@ mcp = FastMCP.from_openapi(
|
|
| 231 |
|
| 232 |
## Customizing MCP Components
|
| 233 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
<VersionBadge version="2.5.0" />
|
| 235 |
|
| 236 |
By default, FastMCP creates MCP components using a variety of metadata from the OpenAPI spec, such as incorporating the OpenAPI description into the MCP component description.
|
|
@@ -271,7 +303,6 @@ mcp = FastMCP.from_openapi(
|
|
| 271 |
mcp_component_fn=customize_components,
|
| 272 |
)
|
| 273 |
```
|
| 274 |
-
|
| 275 |
## Request Parameter Handling
|
| 276 |
|
| 277 |
FastMCP intelligently handles different types of parameters in OpenAPI requests:
|
|
@@ -376,15 +407,15 @@ from fastmcp import FastMCP
|
|
| 376 |
# Your FastAPI app
|
| 377 |
app = FastAPI(title="My API", version="1.0.0")
|
| 378 |
|
| 379 |
-
@app.get("/items", tags=["items"])
|
| 380 |
def list_items():
|
| 381 |
return [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}]
|
| 382 |
|
| 383 |
-
@app.get("/items/{item_id}", tags=["items", "detail"])
|
| 384 |
def get_item(item_id: int):
|
| 385 |
return {"id": item_id, "name": f"Item {item_id}"}
|
| 386 |
|
| 387 |
-
@app.post("/items", tags=["items", "create"])
|
| 388 |
def create_item(name: str):
|
| 389 |
return {"id": 3, "name": name}
|
| 390 |
|
|
@@ -395,6 +426,8 @@ if __name__ == "__main__":
|
|
| 395 |
mcp.run() # Run as MCP server
|
| 396 |
```
|
| 397 |
|
|
|
|
|
|
|
| 398 |
<Warning>
|
| 399 |
FastMCP servers are not FastAPI apps, even when created from one. To learn how to deploy them as an ASGI app, see the [ASGI Integration](/deployment/asgi) documentation.
|
| 400 |
</Warning>
|
|
@@ -413,6 +446,7 @@ mcp = FastMCP.from_fastapi(
|
|
| 413 |
app=app,
|
| 414 |
name="My Custom Server",
|
| 415 |
timeout=5.0,
|
|
|
|
| 416 |
route_maps=[
|
| 417 |
# Admin endpoints become tools
|
| 418 |
RouteMap(methods="*", pattern=r"^/admin/.*", mcp_type=MCPType.TOOL),
|
|
@@ -421,6 +455,9 @@ mcp = FastMCP.from_fastapi(
|
|
| 421 |
],
|
| 422 |
route_map_fn=my_route_mapper,
|
| 423 |
mcp_component_fn=my_component_customizer,
|
|
|
|
|
|
|
|
|
|
| 424 |
)
|
| 425 |
```
|
| 426 |
|
|
@@ -430,4 +467,3 @@ mcp = FastMCP.from_fastapi(
|
|
| 430 |
- **Schema inheritance**: Pydantic models and validation are preserved
|
| 431 |
- **ASGI transport**: Direct in-memory communication (no HTTP overhead)
|
| 432 |
- **Full FastAPI features**: Dependencies, middleware, authentication all work
|
| 433 |
-
|
|
|
|
| 231 |
|
| 232 |
## Customizing MCP Components
|
| 233 |
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
### Component Names
|
| 237 |
+
|
| 238 |
+
<VersionBadge version="2.5.0" />
|
| 239 |
+
|
| 240 |
+
FastMCP automatically generates names for MCP components based on the OpenAPI specification. By default, it uses the `operationId` from your OpenAPI spec, up to the first double underscore (`__`).
|
| 241 |
+
|
| 242 |
+
All component names are automatically:
|
| 243 |
+
- **Slugified**: Spaces and special characters are converted to underscores or removed
|
| 244 |
+
- **Truncated**: Limited to 56 characters maximum to ensure compatibility
|
| 245 |
+
- **Unique**: If multiple components have the same name, a number is automatically appended to make them unique
|
| 246 |
+
|
| 247 |
+
For more control over component names, you can provide an `mcp_names` dictionary that maps `operationId` values to your desired names. The `operationId` must be exactly as it appears in the OpenAPI spec. The provided name will always be slugified and truncated.
|
| 248 |
+
|
| 249 |
+
```python {5-9}
|
| 250 |
+
from fastmcp import FastMCP
|
| 251 |
+
|
| 252 |
+
mcp = FastMCP.from_openapi(
|
| 253 |
+
...
|
| 254 |
+
mcp_names={
|
| 255 |
+
"list_users__with_pagination": "user_list",
|
| 256 |
+
"create_user__admin_required": "create_user",
|
| 257 |
+
"get_user_details__admin_required": "user_detail",
|
| 258 |
+
}
|
| 259 |
+
)
|
| 260 |
+
```
|
| 261 |
+
|
| 262 |
+
Any `operationId` not found in `mcp_names` will use the default strategy (operationId up to the first `__`).
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
### Advanced Customization
|
| 266 |
<VersionBadge version="2.5.0" />
|
| 267 |
|
| 268 |
By default, FastMCP creates MCP components using a variety of metadata from the OpenAPI spec, such as incorporating the OpenAPI description into the MCP component description.
|
|
|
|
| 303 |
mcp_component_fn=customize_components,
|
| 304 |
)
|
| 305 |
```
|
|
|
|
| 306 |
## Request Parameter Handling
|
| 307 |
|
| 308 |
FastMCP intelligently handles different types of parameters in OpenAPI requests:
|
|
|
|
| 407 |
# Your FastAPI app
|
| 408 |
app = FastAPI(title="My API", version="1.0.0")
|
| 409 |
|
| 410 |
+
@app.get("/items", tags=["items"], operation_id="list_items")
|
| 411 |
def list_items():
|
| 412 |
return [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}]
|
| 413 |
|
| 414 |
+
@app.get("/items/{item_id}", tags=["items", "detail"], operation_id="get_item")
|
| 415 |
def get_item(item_id: int):
|
| 416 |
return {"id": item_id, "name": f"Item {item_id}"}
|
| 417 |
|
| 418 |
+
@app.post("/items", tags=["items", "create"], operation_id="create_item")
|
| 419 |
def create_item(name: str):
|
| 420 |
return {"id": 3, "name": name}
|
| 421 |
|
|
|
|
| 426 |
mcp.run() # Run as MCP server
|
| 427 |
```
|
| 428 |
|
| 429 |
+
Note that operation ids are optional, but are used to create component names. You can also provide custom names, just like with OpenAPI specs.
|
| 430 |
+
|
| 431 |
<Warning>
|
| 432 |
FastMCP servers are not FastAPI apps, even when created from one. To learn how to deploy them as an ASGI app, see the [ASGI Integration](/deployment/asgi) documentation.
|
| 433 |
</Warning>
|
|
|
|
| 446 |
app=app,
|
| 447 |
name="My Custom Server",
|
| 448 |
timeout=5.0,
|
| 449 |
+
mcp_names={"operationId": "friendly_name"}, # Custom component names
|
| 450 |
route_maps=[
|
| 451 |
# Admin endpoints become tools
|
| 452 |
RouteMap(methods="*", pattern=r"^/admin/.*", mcp_type=MCPType.TOOL),
|
|
|
|
| 455 |
],
|
| 456 |
route_map_fn=my_route_mapper,
|
| 457 |
mcp_component_fn=my_component_customizer,
|
| 458 |
+
mcp_names={
|
| 459 |
+
"get_user_details_users__user_id__get": "get_user_details",
|
| 460 |
+
}
|
| 461 |
)
|
| 462 |
```
|
| 463 |
|
|
|
|
| 467 |
- **Schema inheritance**: Pydantic models and validation are preserved
|
| 468 |
- **ASGI transport**: Direct in-memory communication (no HTTP overhead)
|
| 469 |
- **Full FastAPI features**: Dependencies, middleware, authentication all work
|
|
|
src/fastmcp/server/openapi.py
CHANGED
|
@@ -6,6 +6,7 @@ import enum
|
|
| 6 |
import json
|
| 7 |
import re
|
| 8 |
import warnings
|
|
|
|
| 9 |
from collections.abc import Callable
|
| 10 |
from dataclasses import dataclass, field
|
| 11 |
from re import Pattern
|
|
@@ -36,6 +37,29 @@ logger = get_logger(__name__)
|
|
| 36 |
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
|
| 37 |
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
def _get_mcp_client_headers() -> dict[str, str]:
|
| 40 |
"""
|
| 41 |
Extract headers from the current MCP client HTTP request if available.
|
|
@@ -695,6 +719,7 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 695 |
route_maps: list[RouteMap] | None = None,
|
| 696 |
route_map_fn: RouteMapFn | None = None,
|
| 697 |
mcp_component_fn: ComponentFn | None = None,
|
|
|
|
| 698 |
timeout: float | None = None,
|
| 699 |
**settings: Any,
|
| 700 |
):
|
|
@@ -712,6 +737,11 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 712 |
mcp_component_fn: Optional callable for component customization.
|
| 713 |
Receives (route, component) and can modify the component in-place.
|
| 714 |
Called on every created component.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 715 |
timeout: Optional timeout (in seconds) for all requests
|
| 716 |
**settings: Additional settings for FastMCP
|
| 717 |
"""
|
|
@@ -721,9 +751,15 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 721 |
self._timeout = timeout
|
| 722 |
self._route_map_fn = route_map_fn
|
| 723 |
self._mcp_component_fn = mcp_component_fn
|
|
|
|
| 724 |
|
| 725 |
# Keep track of names to detect collisions
|
| 726 |
-
self._used_names = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 727 |
|
| 728 |
http_routes = openapi.parse_openapi_to_http_routes(openapi_spec)
|
| 729 |
|
|
@@ -766,40 +802,31 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 766 |
def _generate_default_name(
|
| 767 |
self, route: openapi.HTTPRoute, mcp_type: MCPType
|
| 768 |
) -> str:
|
| 769 |
-
"""Generate a default name from the route
|
| 770 |
-
|
| 771 |
|
|
|
|
| 772 |
if route.operation_id:
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
# For path-based naming, clean up the path
|
| 776 |
-
path_parts = route.path.strip("/").split("/")
|
| 777 |
-
|
| 778 |
-
# Remove path parameters (parts with {})
|
| 779 |
-
clean_parts = []
|
| 780 |
-
for part in path_parts:
|
| 781 |
-
if part.startswith("{") and part.endswith("}"):
|
| 782 |
-
# For templates, include parameter name without braces
|
| 783 |
-
if mcp_type == MCPType.RESOURCE_TEMPLATE:
|
| 784 |
-
param_name = part[1:-1] # Remove braces
|
| 785 |
-
clean_parts.append(param_name)
|
| 786 |
else:
|
| 787 |
-
|
|
|
|
|
|
|
|
|
|
| 788 |
|
| 789 |
-
|
| 790 |
-
resource_name = "_".join(clean_parts)
|
| 791 |
|
| 792 |
-
#
|
| 793 |
-
if
|
| 794 |
-
|
| 795 |
-
# For GET we don't need the method as it's implied for resources
|
| 796 |
-
if route.method != "GET":
|
| 797 |
-
resource_name = f"{route.method.lower()}_{resource_name}"
|
| 798 |
|
| 799 |
-
return
|
| 800 |
|
| 801 |
def _get_unique_name(
|
| 802 |
-
self,
|
|
|
|
|
|
|
| 803 |
) -> str:
|
| 804 |
"""
|
| 805 |
Ensure the name is unique within its component type by appending numbers if needed.
|
|
@@ -812,23 +839,18 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 812 |
str: A unique name for the component
|
| 813 |
"""
|
| 814 |
# Check if the name is already used
|
| 815 |
-
|
| 816 |
-
|
| 817 |
return name
|
| 818 |
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
logger.debug(
|
| 827 |
-
f"Name collision detected: '{name}' already exists as a {component_type[:-1]}. "
|
| 828 |
-
f"Using '{new_name}' instead."
|
| 829 |
-
)
|
| 830 |
|
| 831 |
-
self._used_names[component_type].add(new_name)
|
| 832 |
return new_name
|
| 833 |
|
| 834 |
def _create_openapi_tool(self, route: openapi.HTTPRoute, name: str):
|
|
@@ -836,7 +858,7 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 836 |
combined_schema = _combine_schemas(route)
|
| 837 |
|
| 838 |
# Get a unique tool name
|
| 839 |
-
tool_name = self._get_unique_name(name, "
|
| 840 |
|
| 841 |
base_description = (
|
| 842 |
route.description
|
|
@@ -882,7 +904,7 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 882 |
def _create_openapi_resource(self, route: openapi.HTTPRoute, name: str):
|
| 883 |
"""Creates and registers an OpenAPIResource with enhanced description."""
|
| 884 |
# Get a unique resource name
|
| 885 |
-
resource_name = self._get_unique_name(name, "
|
| 886 |
|
| 887 |
resource_uri = f"resource://{resource_name}"
|
| 888 |
base_description = (
|
|
@@ -927,7 +949,7 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 927 |
def _create_openapi_template(self, route: openapi.HTTPRoute, name: str):
|
| 928 |
"""Creates and registers an OpenAPIResourceTemplate with enhanced description."""
|
| 929 |
# Get a unique template name
|
| 930 |
-
template_name = self._get_unique_name(name, "
|
| 931 |
|
| 932 |
path_params = [p.name for p in route.parameters if p.location == "path"]
|
| 933 |
path_params.sort() # Sort for consistent URIs
|
|
|
|
| 6 |
import json
|
| 7 |
import re
|
| 8 |
import warnings
|
| 9 |
+
from collections import Counter
|
| 10 |
from collections.abc import Callable
|
| 11 |
from dataclasses import dataclass, field
|
| 12 |
from re import Pattern
|
|
|
|
| 37 |
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
|
| 38 |
|
| 39 |
|
| 40 |
+
def _slugify(text: str) -> str:
|
| 41 |
+
"""
|
| 42 |
+
Convert text to a URL-friendly slug format that only contains lowercase
|
| 43 |
+
letters, uppercase letters, numbers, and underscores.
|
| 44 |
+
"""
|
| 45 |
+
if not text:
|
| 46 |
+
return ""
|
| 47 |
+
|
| 48 |
+
# Replace spaces and common separators with underscores
|
| 49 |
+
slug = re.sub(r"[\s\-\.]+", "_", text)
|
| 50 |
+
|
| 51 |
+
# Remove non-alphanumeric characters except underscores
|
| 52 |
+
slug = re.sub(r"[^a-zA-Z0-9_]", "", slug)
|
| 53 |
+
|
| 54 |
+
# Remove multiple consecutive underscores
|
| 55 |
+
slug = re.sub(r"_+", "_", slug)
|
| 56 |
+
|
| 57 |
+
# Remove leading/trailing underscores
|
| 58 |
+
slug = slug.strip("_")
|
| 59 |
+
|
| 60 |
+
return slug
|
| 61 |
+
|
| 62 |
+
|
| 63 |
def _get_mcp_client_headers() -> dict[str, str]:
|
| 64 |
"""
|
| 65 |
Extract headers from the current MCP client HTTP request if available.
|
|
|
|
| 719 |
route_maps: list[RouteMap] | None = None,
|
| 720 |
route_map_fn: RouteMapFn | None = None,
|
| 721 |
mcp_component_fn: ComponentFn | None = None,
|
| 722 |
+
mcp_names: dict[str, str] | None = None,
|
| 723 |
timeout: float | None = None,
|
| 724 |
**settings: Any,
|
| 725 |
):
|
|
|
|
| 737 |
mcp_component_fn: Optional callable for component customization.
|
| 738 |
Receives (route, component) and can modify the component in-place.
|
| 739 |
Called on every created component.
|
| 740 |
+
mcp_names: Optional dictionary mapping operationId to desired component names.
|
| 741 |
+
If an operationId is not in the dictionary, falls back to using the
|
| 742 |
+
operationId up to the first double underscore. If no operationId exists,
|
| 743 |
+
falls back to slugified summary or path-based naming.
|
| 744 |
+
All names are truncated to 56 characters maximum.
|
| 745 |
timeout: Optional timeout (in seconds) for all requests
|
| 746 |
**settings: Additional settings for FastMCP
|
| 747 |
"""
|
|
|
|
| 751 |
self._timeout = timeout
|
| 752 |
self._route_map_fn = route_map_fn
|
| 753 |
self._mcp_component_fn = mcp_component_fn
|
| 754 |
+
self._mcp_names = mcp_names or {}
|
| 755 |
|
| 756 |
# Keep track of names to detect collisions
|
| 757 |
+
self._used_names = {
|
| 758 |
+
"tool": Counter(),
|
| 759 |
+
"resource": Counter(),
|
| 760 |
+
"resource_template": Counter(),
|
| 761 |
+
"prompt": Counter(),
|
| 762 |
+
}
|
| 763 |
|
| 764 |
http_routes = openapi.parse_openapi_to_http_routes(openapi_spec)
|
| 765 |
|
|
|
|
| 802 |
def _generate_default_name(
|
| 803 |
self, route: openapi.HTTPRoute, mcp_type: MCPType
|
| 804 |
) -> str:
|
| 805 |
+
"""Generate a default name from the route using the configured strategy."""
|
| 806 |
+
name = ""
|
| 807 |
|
| 808 |
+
# First check if there's a custom mapping for this operationId
|
| 809 |
if route.operation_id:
|
| 810 |
+
if route.operation_id in self._mcp_names:
|
| 811 |
+
name = self._mcp_names[route.operation_id]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 812 |
else:
|
| 813 |
+
# If there's a double underscore in the operationId, use the first part
|
| 814 |
+
name = route.operation_id.split("__")[0]
|
| 815 |
+
else:
|
| 816 |
+
name = route.summary or f"{route.method}_{route.path}"
|
| 817 |
|
| 818 |
+
name = _slugify(name)
|
|
|
|
| 819 |
|
| 820 |
+
# Truncate to 56 characters maximum
|
| 821 |
+
if len(name) > 56:
|
| 822 |
+
name = name[:56]
|
|
|
|
|
|
|
|
|
|
| 823 |
|
| 824 |
+
return name
|
| 825 |
|
| 826 |
def _get_unique_name(
|
| 827 |
+
self,
|
| 828 |
+
name: str,
|
| 829 |
+
component_type: Literal["tool", "resource", "resource_template", "prompt"],
|
| 830 |
) -> str:
|
| 831 |
"""
|
| 832 |
Ensure the name is unique within its component type by appending numbers if needed.
|
|
|
|
| 839 |
str: A unique name for the component
|
| 840 |
"""
|
| 841 |
# Check if the name is already used
|
| 842 |
+
self._used_names[component_type][name] += 1
|
| 843 |
+
if self._used_names[component_type][name] == 1:
|
| 844 |
return name
|
| 845 |
|
| 846 |
+
else:
|
| 847 |
+
# Create the new name
|
| 848 |
+
new_name = f"{name}_{self._used_names[component_type][name]}"
|
| 849 |
+
logger.debug(
|
| 850 |
+
f"Name collision detected: '{name}' already exists as a {component_type[:-1]}. "
|
| 851 |
+
f"Using '{new_name}' instead."
|
| 852 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 853 |
|
|
|
|
| 854 |
return new_name
|
| 855 |
|
| 856 |
def _create_openapi_tool(self, route: openapi.HTTPRoute, name: str):
|
|
|
|
| 858 |
combined_schema = _combine_schemas(route)
|
| 859 |
|
| 860 |
# Get a unique tool name
|
| 861 |
+
tool_name = self._get_unique_name(name, "tool")
|
| 862 |
|
| 863 |
base_description = (
|
| 864 |
route.description
|
|
|
|
| 904 |
def _create_openapi_resource(self, route: openapi.HTTPRoute, name: str):
|
| 905 |
"""Creates and registers an OpenAPIResource with enhanced description."""
|
| 906 |
# Get a unique resource name
|
| 907 |
+
resource_name = self._get_unique_name(name, "resource")
|
| 908 |
|
| 909 |
resource_uri = f"resource://{resource_name}"
|
| 910 |
base_description = (
|
|
|
|
| 949 |
def _create_openapi_template(self, route: openapi.HTTPRoute, name: str):
|
| 950 |
"""Creates and registers an OpenAPIResourceTemplate with enhanced description."""
|
| 951 |
# Get a unique template name
|
| 952 |
+
template_name = self._get_unique_name(name, "resource_template")
|
| 953 |
|
| 954 |
path_params = [p.name for p in route.parameters if p.location == "path"]
|
| 955 |
path_params.sort() # Sort for consistent URIs
|
src/fastmcp/server/server.py
CHANGED
|
@@ -1170,6 +1170,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1170 |
route_maps: list[RouteMap] | None = None,
|
| 1171 |
route_map_fn: OpenAPIRouteMapFn | None = None,
|
| 1172 |
mcp_component_fn: OpenAPIComponentFn | None = None,
|
|
|
|
| 1173 |
all_routes_as_tools: bool = False,
|
| 1174 |
**settings: Any,
|
| 1175 |
) -> FastMCPOpenAPI:
|
|
@@ -1199,6 +1200,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1199 |
route_maps=route_maps,
|
| 1200 |
route_map_fn=route_map_fn,
|
| 1201 |
mcp_component_fn=mcp_component_fn,
|
|
|
|
| 1202 |
**settings,
|
| 1203 |
)
|
| 1204 |
|
|
@@ -1210,6 +1212,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1210 |
route_maps: list[RouteMap] | None = None,
|
| 1211 |
route_map_fn: OpenAPIRouteMapFn | None = None,
|
| 1212 |
mcp_component_fn: OpenAPIComponentFn | None = None,
|
|
|
|
| 1213 |
all_routes_as_tools: bool = False,
|
| 1214 |
httpx_client_kwargs: dict[str, Any] | None = None,
|
| 1215 |
**settings: Any,
|
|
@@ -1253,6 +1256,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 1253 |
route_maps=route_maps,
|
| 1254 |
route_map_fn=route_map_fn,
|
| 1255 |
mcp_component_fn=mcp_component_fn,
|
|
|
|
| 1256 |
**settings,
|
| 1257 |
)
|
| 1258 |
|
|
|
|
| 1170 |
route_maps: list[RouteMap] | None = None,
|
| 1171 |
route_map_fn: OpenAPIRouteMapFn | None = None,
|
| 1172 |
mcp_component_fn: OpenAPIComponentFn | None = None,
|
| 1173 |
+
mcp_names: dict[str, str] | None = None,
|
| 1174 |
all_routes_as_tools: bool = False,
|
| 1175 |
**settings: Any,
|
| 1176 |
) -> FastMCPOpenAPI:
|
|
|
|
| 1200 |
route_maps=route_maps,
|
| 1201 |
route_map_fn=route_map_fn,
|
| 1202 |
mcp_component_fn=mcp_component_fn,
|
| 1203 |
+
mcp_names=mcp_names,
|
| 1204 |
**settings,
|
| 1205 |
)
|
| 1206 |
|
|
|
|
| 1212 |
route_maps: list[RouteMap] | None = None,
|
| 1213 |
route_map_fn: OpenAPIRouteMapFn | None = None,
|
| 1214 |
mcp_component_fn: OpenAPIComponentFn | None = None,
|
| 1215 |
+
mcp_names: dict[str, str] | None = None,
|
| 1216 |
all_routes_as_tools: bool = False,
|
| 1217 |
httpx_client_kwargs: dict[str, Any] | None = None,
|
| 1218 |
**settings: Any,
|
|
|
|
| 1256 |
route_maps=route_maps,
|
| 1257 |
route_map_fn=route_map_fn,
|
| 1258 |
mcp_component_fn=mcp_component_fn,
|
| 1259 |
+
mcp_names=mcp_names,
|
| 1260 |
**settings,
|
| 1261 |
)
|
| 1262 |
|
tests/client/test_openapi.py
CHANGED
|
@@ -130,7 +130,7 @@ class TestClientHeaders:
|
|
| 130 |
transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
|
| 131 |
) as client:
|
| 132 |
result = await client.read_resource(
|
| 133 |
-
"resource://
|
| 134 |
)
|
| 135 |
assert isinstance(result[0], TextResourceContents)
|
| 136 |
header = json.loads(result[0].text)
|
|
@@ -143,7 +143,7 @@ class TestClientHeaders:
|
|
| 143 |
)
|
| 144 |
) as client:
|
| 145 |
result = await client.read_resource(
|
| 146 |
-
"resource://
|
| 147 |
)
|
| 148 |
assert isinstance(result[0], TextResourceContents)
|
| 149 |
header = json.loads(result[0].text)
|
|
|
|
| 130 |
transport=SSETransport(sse_server, headers={"X-TEST": "test-123"})
|
| 131 |
) as client:
|
| 132 |
result = await client.read_resource(
|
| 133 |
+
"resource://get_header_by_name_headers/x-test"
|
| 134 |
)
|
| 135 |
assert isinstance(result[0], TextResourceContents)
|
| 136 |
header = json.loads(result[0].text)
|
|
|
|
| 143 |
)
|
| 144 |
) as client:
|
| 145 |
result = await client.read_resource(
|
| 146 |
+
"resource://get_header_by_name_headers/x-test"
|
| 147 |
)
|
| 148 |
assert isinstance(result[0], TextResourceContents)
|
| 149 |
header = json.loads(result[0].text)
|
tests/server/openapi/test_openapi.py
CHANGED
|
@@ -208,7 +208,7 @@ class TestTools:
|
|
| 208 |
},
|
| 209 |
)
|
| 210 |
assert tools[1].model_dump() == dict(
|
| 211 |
-
name="
|
| 212 |
annotations=None,
|
| 213 |
description=IsStr(
|
| 214 |
regex=r"^Update a user's name\..*$", regex_flags=re.DOTALL
|
|
@@ -248,9 +248,7 @@ class TestTools:
|
|
| 248 |
|
| 249 |
# Check that the user was created via MCP
|
| 250 |
async with Client(fastmcp_openapi_server) as client:
|
| 251 |
-
user_response = await client.read_resource(
|
| 252 |
-
"resource://get_user_users__user_id__get/4"
|
| 253 |
-
)
|
| 254 |
assert isinstance(user_response[0], TextResourceContents)
|
| 255 |
response_text = user_response[0].text
|
| 256 |
user = json.loads(response_text)
|
|
@@ -264,7 +262,7 @@ class TestTools:
|
|
| 264 |
"""
|
| 265 |
async with Client(fastmcp_openapi_server) as client:
|
| 266 |
tool_response = await client.call_tool(
|
| 267 |
-
"
|
| 268 |
{"user_id": 1, "name": "XYZ"},
|
| 269 |
)
|
| 270 |
|
|
@@ -282,9 +280,7 @@ class TestTools:
|
|
| 282 |
|
| 283 |
# Check that the user was updated via MCP
|
| 284 |
async with Client(fastmcp_openapi_server) as client:
|
| 285 |
-
user_response = await client.read_resource(
|
| 286 |
-
"resource://get_user_users__user_id__get/1"
|
| 287 |
-
)
|
| 288 |
assert isinstance(user_response[0], TextResourceContents)
|
| 289 |
response_text = user_response[0].text
|
| 290 |
user = json.loads(response_text)
|
|
@@ -387,18 +383,14 @@ class TestResourceTemplates:
|
|
| 387 |
async with Client(fastmcp_openapi_server) as client:
|
| 388 |
resource_templates = await client.list_resource_templates()
|
| 389 |
assert len(resource_templates) == 2
|
| 390 |
-
assert resource_templates[0].name == "
|
| 391 |
assert (
|
| 392 |
-
resource_templates[0].uriTemplate
|
| 393 |
-
== r"resource://get_user_users__user_id__get/{user_id}"
|
| 394 |
-
)
|
| 395 |
-
assert (
|
| 396 |
-
resource_templates[1].name
|
| 397 |
-
== "get_user_active_state_users__user_id___is_active__get"
|
| 398 |
)
|
|
|
|
| 399 |
assert (
|
| 400 |
resource_templates[1].uriTemplate
|
| 401 |
-
== r"resource://
|
| 402 |
)
|
| 403 |
|
| 404 |
async def test_get_resource_template(
|
|
@@ -413,7 +405,7 @@ class TestResourceTemplates:
|
|
| 413 |
user_id = 2
|
| 414 |
async with Client(fastmcp_openapi_server) as client:
|
| 415 |
resource_response = await client.read_resource(
|
| 416 |
-
f"resource://
|
| 417 |
)
|
| 418 |
assert isinstance(resource_response[0], TextResourceContents)
|
| 419 |
response_text = resource_response[0].text
|
|
@@ -436,7 +428,7 @@ class TestResourceTemplates:
|
|
| 436 |
is_active = True
|
| 437 |
async with Client(fastmcp_openapi_server) as client:
|
| 438 |
resource_response = await client.read_resource(
|
| 439 |
-
f"resource://
|
| 440 |
)
|
| 441 |
assert isinstance(resource_response[0], TextResourceContents)
|
| 442 |
response_text = resource_response[0].text
|
|
@@ -472,11 +464,7 @@ class TestTagTransfer:
|
|
| 472 |
(t for t in tools if t.name == "create_user_users_post"), None
|
| 473 |
)
|
| 474 |
update_user_tool = next(
|
| 475 |
-
(
|
| 476 |
-
t
|
| 477 |
-
for t in tools
|
| 478 |
-
if t.name == "update_user_name_users__user_id__name_patch"
|
| 479 |
-
),
|
| 480 |
None,
|
| 481 |
)
|
| 482 |
|
|
@@ -524,7 +512,7 @@ class TestTagTransfer:
|
|
| 524 |
|
| 525 |
# Find the get_user template
|
| 526 |
get_user_template = next(
|
| 527 |
-
(t for t in templates if t.name == "
|
| 528 |
)
|
| 529 |
|
| 530 |
assert get_user_template is not None
|
|
@@ -545,7 +533,7 @@ class TestTagTransfer:
|
|
| 545 |
|
| 546 |
# Find the get_user template
|
| 547 |
get_user_template = next(
|
| 548 |
-
(t for t in templates if t.name == "
|
| 549 |
)
|
| 550 |
|
| 551 |
assert get_user_template is not None
|
|
@@ -553,7 +541,7 @@ class TestTagTransfer:
|
|
| 553 |
# Manually create a resource from template
|
| 554 |
params = {"user_id": 1}
|
| 555 |
resource = await get_user_template.create_resource(
|
| 556 |
-
"resource://
|
| 557 |
)
|
| 558 |
|
| 559 |
# Verify tags are preserved from template to resource
|
|
@@ -997,7 +985,7 @@ async def test_none_path_parameters_rejected(
|
|
| 997 |
# get_user has a required path parameter user_id
|
| 998 |
with pytest.raises(ToolError, match="Missing required path parameters"):
|
| 999 |
await client.call_tool(
|
| 1000 |
-
"
|
| 1001 |
{
|
| 1002 |
"user_id": None, # This should cause an error
|
| 1003 |
"name": "New Name",
|
|
@@ -1560,9 +1548,7 @@ class TestFastAPIDescriptionPropagation:
|
|
| 1560 |
async def test_template_includes_function_docstring(self, fastapi_server):
|
| 1561 |
"""Test that a ResourceTemplate includes the function docstring."""
|
| 1562 |
templates = list(fastapi_server._resource_manager.get_templates().values())
|
| 1563 |
-
get_template = next(
|
| 1564 |
-
(t for t in templates if "items__item_id__get" in t.name), None
|
| 1565 |
-
)
|
| 1566 |
|
| 1567 |
assert get_template is not None, "GET /items/{item_id} template wasn't created"
|
| 1568 |
description = get_template.description or ""
|
|
@@ -1577,9 +1563,7 @@ class TestFastAPIDescriptionPropagation:
|
|
| 1577 |
are not properly propagated to the OpenAPI schema. The parameters appear but without the description.
|
| 1578 |
"""
|
| 1579 |
templates = list(fastapi_server._resource_manager.get_templates().values())
|
| 1580 |
-
get_template = next(
|
| 1581 |
-
(t for t in templates if "items__item_id__get" in t.name), None
|
| 1582 |
-
)
|
| 1583 |
|
| 1584 |
assert get_template is not None, "GET /items/{item_id} template wasn't created"
|
| 1585 |
description = get_template.description or ""
|
|
@@ -1599,9 +1583,7 @@ class TestFastAPIDescriptionPropagation:
|
|
| 1599 |
are not properly propagated to the OpenAPI schema. The parameters appear but without the description.
|
| 1600 |
"""
|
| 1601 |
templates = list(fastapi_server._resource_manager.get_templates().values())
|
| 1602 |
-
get_template = next(
|
| 1603 |
-
(t for t in templates if "items__item_id__get" in t.name), None
|
| 1604 |
-
)
|
| 1605 |
|
| 1606 |
assert get_template is not None, "GET /items/{item_id} template wasn't created"
|
| 1607 |
description = get_template.description or ""
|
|
@@ -1617,9 +1599,7 @@ class TestFastAPIDescriptionPropagation:
|
|
| 1617 |
async def test_template_parameter_schema_includes_description(self, fastapi_server):
|
| 1618 |
"""Test that a ResourceTemplate's parameter schema includes parameter descriptions."""
|
| 1619 |
templates = list(fastapi_server._resource_manager.get_templates().values())
|
| 1620 |
-
get_template = next(
|
| 1621 |
-
(t for t in templates if "items__item_id__get" in t.name), None
|
| 1622 |
-
)
|
| 1623 |
|
| 1624 |
assert get_template is not None, "GET /items/{item_id} template wasn't created"
|
| 1625 |
assert "properties" in get_template.parameters, (
|
|
@@ -1691,7 +1671,7 @@ class TestFastAPIDescriptionPropagation:
|
|
| 1691 |
async with Client(fastapi_server) as client:
|
| 1692 |
templates = await client.list_resource_templates()
|
| 1693 |
get_template = next(
|
| 1694 |
-
(t for t in templates if "
|
| 1695 |
)
|
| 1696 |
|
| 1697 |
assert get_template is not None, (
|
|
@@ -1821,9 +1801,7 @@ class TestEnumHandling:
|
|
| 1821 |
tools = server._tool_manager.list_tools()
|
| 1822 |
|
| 1823 |
# Find the read_item tool
|
| 1824 |
-
read_item_tool = next(
|
| 1825 |
-
(t for t in tools if t.name == "read_item_items__item_id__post"), None
|
| 1826 |
-
)
|
| 1827 |
|
| 1828 |
# Verify the tool exists
|
| 1829 |
assert read_item_tool is not None, "read_item tool wasn't created"
|
|
@@ -2136,3 +2114,287 @@ class TestRouteMapTags:
|
|
| 2136 |
"getMetrics",
|
| 2137 |
}
|
| 2138 |
assert tool_names == expected_tools
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
},
|
| 209 |
)
|
| 210 |
assert tools[1].model_dump() == dict(
|
| 211 |
+
name="update_user_name_users",
|
| 212 |
annotations=None,
|
| 213 |
description=IsStr(
|
| 214 |
regex=r"^Update a user's name\..*$", regex_flags=re.DOTALL
|
|
|
|
| 248 |
|
| 249 |
# Check that the user was created via MCP
|
| 250 |
async with Client(fastmcp_openapi_server) as client:
|
| 251 |
+
user_response = await client.read_resource("resource://get_user_users/4")
|
|
|
|
|
|
|
| 252 |
assert isinstance(user_response[0], TextResourceContents)
|
| 253 |
response_text = user_response[0].text
|
| 254 |
user = json.loads(response_text)
|
|
|
|
| 262 |
"""
|
| 263 |
async with Client(fastmcp_openapi_server) as client:
|
| 264 |
tool_response = await client.call_tool(
|
| 265 |
+
"update_user_name_users",
|
| 266 |
{"user_id": 1, "name": "XYZ"},
|
| 267 |
)
|
| 268 |
|
|
|
|
| 280 |
|
| 281 |
# Check that the user was updated via MCP
|
| 282 |
async with Client(fastmcp_openapi_server) as client:
|
| 283 |
+
user_response = await client.read_resource("resource://get_user_users/1")
|
|
|
|
|
|
|
| 284 |
assert isinstance(user_response[0], TextResourceContents)
|
| 285 |
response_text = user_response[0].text
|
| 286 |
user = json.loads(response_text)
|
|
|
|
| 383 |
async with Client(fastmcp_openapi_server) as client:
|
| 384 |
resource_templates = await client.list_resource_templates()
|
| 385 |
assert len(resource_templates) == 2
|
| 386 |
+
assert resource_templates[0].name == "get_user_users"
|
| 387 |
assert (
|
| 388 |
+
resource_templates[0].uriTemplate == r"resource://get_user_users/{user_id}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
)
|
| 390 |
+
assert resource_templates[1].name == "get_user_active_state_users"
|
| 391 |
assert (
|
| 392 |
resource_templates[1].uriTemplate
|
| 393 |
+
== r"resource://get_user_active_state_users/{is_active}/{user_id}"
|
| 394 |
)
|
| 395 |
|
| 396 |
async def test_get_resource_template(
|
|
|
|
| 405 |
user_id = 2
|
| 406 |
async with Client(fastmcp_openapi_server) as client:
|
| 407 |
resource_response = await client.read_resource(
|
| 408 |
+
f"resource://get_user_users/{user_id}"
|
| 409 |
)
|
| 410 |
assert isinstance(resource_response[0], TextResourceContents)
|
| 411 |
response_text = resource_response[0].text
|
|
|
|
| 428 |
is_active = True
|
| 429 |
async with Client(fastmcp_openapi_server) as client:
|
| 430 |
resource_response = await client.read_resource(
|
| 431 |
+
f"resource://get_user_active_state_users/{is_active}/{user_id}"
|
| 432 |
)
|
| 433 |
assert isinstance(resource_response[0], TextResourceContents)
|
| 434 |
response_text = resource_response[0].text
|
|
|
|
| 464 |
(t for t in tools if t.name == "create_user_users_post"), None
|
| 465 |
)
|
| 466 |
update_user_tool = next(
|
| 467 |
+
(t for t in tools if t.name == "update_user_name_users"),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 468 |
None,
|
| 469 |
)
|
| 470 |
|
|
|
|
| 512 |
|
| 513 |
# Find the get_user template
|
| 514 |
get_user_template = next(
|
| 515 |
+
(t for t in templates if t.name == "get_user_users"), None
|
| 516 |
)
|
| 517 |
|
| 518 |
assert get_user_template is not None
|
|
|
|
| 533 |
|
| 534 |
# Find the get_user template
|
| 535 |
get_user_template = next(
|
| 536 |
+
(t for t in templates if t.name == "get_user_users"), None
|
| 537 |
)
|
| 538 |
|
| 539 |
assert get_user_template is not None
|
|
|
|
| 541 |
# Manually create a resource from template
|
| 542 |
params = {"user_id": 1}
|
| 543 |
resource = await get_user_template.create_resource(
|
| 544 |
+
"resource://get_user_users/1", params
|
| 545 |
)
|
| 546 |
|
| 547 |
# Verify tags are preserved from template to resource
|
|
|
|
| 985 |
# get_user has a required path parameter user_id
|
| 986 |
with pytest.raises(ToolError, match="Missing required path parameters"):
|
| 987 |
await client.call_tool(
|
| 988 |
+
"update_user_name_users",
|
| 989 |
{
|
| 990 |
"user_id": None, # This should cause an error
|
| 991 |
"name": "New Name",
|
|
|
|
| 1548 |
async def test_template_includes_function_docstring(self, fastapi_server):
|
| 1549 |
"""Test that a ResourceTemplate includes the function docstring."""
|
| 1550 |
templates = list(fastapi_server._resource_manager.get_templates().values())
|
| 1551 |
+
get_template = next((t for t in templates if "get_item_items" in t.name), None)
|
|
|
|
|
|
|
| 1552 |
|
| 1553 |
assert get_template is not None, "GET /items/{item_id} template wasn't created"
|
| 1554 |
description = get_template.description or ""
|
|
|
|
| 1563 |
are not properly propagated to the OpenAPI schema. The parameters appear but without the description.
|
| 1564 |
"""
|
| 1565 |
templates = list(fastapi_server._resource_manager.get_templates().values())
|
| 1566 |
+
get_template = next((t for t in templates if "get_item_items" in t.name), None)
|
|
|
|
|
|
|
| 1567 |
|
| 1568 |
assert get_template is not None, "GET /items/{item_id} template wasn't created"
|
| 1569 |
description = get_template.description or ""
|
|
|
|
| 1583 |
are not properly propagated to the OpenAPI schema. The parameters appear but without the description.
|
| 1584 |
"""
|
| 1585 |
templates = list(fastapi_server._resource_manager.get_templates().values())
|
| 1586 |
+
get_template = next((t for t in templates if "get_item_items" in t.name), None)
|
|
|
|
|
|
|
| 1587 |
|
| 1588 |
assert get_template is not None, "GET /items/{item_id} template wasn't created"
|
| 1589 |
description = get_template.description or ""
|
|
|
|
| 1599 |
async def test_template_parameter_schema_includes_description(self, fastapi_server):
|
| 1600 |
"""Test that a ResourceTemplate's parameter schema includes parameter descriptions."""
|
| 1601 |
templates = list(fastapi_server._resource_manager.get_templates().values())
|
| 1602 |
+
get_template = next((t for t in templates if "get_item_items" in t.name), None)
|
|
|
|
|
|
|
| 1603 |
|
| 1604 |
assert get_template is not None, "GET /items/{item_id} template wasn't created"
|
| 1605 |
assert "properties" in get_template.parameters, (
|
|
|
|
| 1671 |
async with Client(fastapi_server) as client:
|
| 1672 |
templates = await client.list_resource_templates()
|
| 1673 |
get_template = next(
|
| 1674 |
+
(t for t in templates if "get_item_items" in t.name), None
|
| 1675 |
)
|
| 1676 |
|
| 1677 |
assert get_template is not None, (
|
|
|
|
| 1801 |
tools = server._tool_manager.list_tools()
|
| 1802 |
|
| 1803 |
# Find the read_item tool
|
| 1804 |
+
read_item_tool = next((t for t in tools if t.name == "read_item_items"), None)
|
|
|
|
|
|
|
| 1805 |
|
| 1806 |
# Verify the tool exists
|
| 1807 |
assert read_item_tool is not None, "read_item tool wasn't created"
|
|
|
|
| 2114 |
"getMetrics",
|
| 2115 |
}
|
| 2116 |
assert tool_names == expected_tools
|
| 2117 |
+
|
| 2118 |
+
|
| 2119 |
+
class TestMCPNames:
|
| 2120 |
+
"""Tests for the mcp_names dictionary functionality."""
|
| 2121 |
+
|
| 2122 |
+
@pytest.fixture
|
| 2123 |
+
def mcp_names_openapi_spec(self) -> dict:
|
| 2124 |
+
"""OpenAPI spec with various operationIds for testing naming strategies."""
|
| 2125 |
+
return {
|
| 2126 |
+
"openapi": "3.1.0",
|
| 2127 |
+
"info": {"title": "MCP Names Test API", "version": "1.0.0"},
|
| 2128 |
+
"paths": {
|
| 2129 |
+
"/users": {
|
| 2130 |
+
"get": {
|
| 2131 |
+
"operationId": "list_users__with_pagination",
|
| 2132 |
+
"summary": "Get All Users",
|
| 2133 |
+
"responses": {"200": {"description": "Success"}},
|
| 2134 |
+
},
|
| 2135 |
+
"post": {
|
| 2136 |
+
"operationId": "create_user_admin__special_permissions",
|
| 2137 |
+
"summary": "Create New User",
|
| 2138 |
+
"requestBody": {
|
| 2139 |
+
"required": True,
|
| 2140 |
+
"content": {
|
| 2141 |
+
"application/json": {
|
| 2142 |
+
"schema": {
|
| 2143 |
+
"type": "object",
|
| 2144 |
+
"properties": {"name": {"type": "string"}},
|
| 2145 |
+
"required": ["name"],
|
| 2146 |
+
}
|
| 2147 |
+
}
|
| 2148 |
+
},
|
| 2149 |
+
},
|
| 2150 |
+
"responses": {"201": {"description": "Created"}},
|
| 2151 |
+
},
|
| 2152 |
+
},
|
| 2153 |
+
"/users/{id}": {
|
| 2154 |
+
"get": {
|
| 2155 |
+
"operationId": "get_user_by_id__admin_only",
|
| 2156 |
+
"summary": "Fetch Single User Profile",
|
| 2157 |
+
"parameters": [
|
| 2158 |
+
{
|
| 2159 |
+
"name": "id",
|
| 2160 |
+
"in": "path",
|
| 2161 |
+
"required": True,
|
| 2162 |
+
"schema": {"type": "integer"},
|
| 2163 |
+
}
|
| 2164 |
+
],
|
| 2165 |
+
"responses": {"200": {"description": "Success"}},
|
| 2166 |
+
}
|
| 2167 |
+
},
|
| 2168 |
+
"/very-long-endpoint-name": {
|
| 2169 |
+
"get": {
|
| 2170 |
+
"operationId": "this_is_a_very_long_operation_id_that_exceeds_fifty_six_characters_and_should_be_truncated",
|
| 2171 |
+
"summary": "This Is A Very Long Summary That Should Also Be Truncated When Used As Name",
|
| 2172 |
+
"responses": {"200": {"description": "Success"}},
|
| 2173 |
+
}
|
| 2174 |
+
},
|
| 2175 |
+
"/special": {
|
| 2176 |
+
"get": {
|
| 2177 |
+
"operationId": "special-chars@and#spaces in$operation%id",
|
| 2178 |
+
"summary": "Special Chars & Spaces In Summary!",
|
| 2179 |
+
"responses": {"200": {"description": "Success"}},
|
| 2180 |
+
}
|
| 2181 |
+
},
|
| 2182 |
+
},
|
| 2183 |
+
}
|
| 2184 |
+
|
| 2185 |
+
@pytest.fixture
|
| 2186 |
+
async def mock_client(self) -> httpx.AsyncClient:
|
| 2187 |
+
"""Mock client for testing."""
|
| 2188 |
+
|
| 2189 |
+
async def _responder(request):
|
| 2190 |
+
return httpx.Response(200, json={"status": "ok"})
|
| 2191 |
+
|
| 2192 |
+
transport = httpx.MockTransport(_responder)
|
| 2193 |
+
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
| 2194 |
+
|
| 2195 |
+
async def test_mcp_names_custom_mapping(self, mcp_names_openapi_spec, mock_client):
|
| 2196 |
+
"""Test that mcp_names dictionary provides custom names for components."""
|
| 2197 |
+
mcp_names = {
|
| 2198 |
+
"list_users__with_pagination": "user_list",
|
| 2199 |
+
"create_user_admin__special_permissions": "admin_create_user",
|
| 2200 |
+
"get_user_by_id__admin_only": "user_detail",
|
| 2201 |
+
}
|
| 2202 |
+
|
| 2203 |
+
server = FastMCPOpenAPI(
|
| 2204 |
+
openapi_spec=mcp_names_openapi_spec,
|
| 2205 |
+
client=mock_client,
|
| 2206 |
+
mcp_names=mcp_names,
|
| 2207 |
+
)
|
| 2208 |
+
|
| 2209 |
+
# Check tools use custom names
|
| 2210 |
+
tools = server._tool_manager.list_tools()
|
| 2211 |
+
tool_names = {tool.name for tool in tools}
|
| 2212 |
+
assert "admin_create_user" in tool_names
|
| 2213 |
+
|
| 2214 |
+
# Check resource templates use custom names
|
| 2215 |
+
templates = list(server._resource_manager.get_templates().values())
|
| 2216 |
+
template_names = {template.name for template in templates}
|
| 2217 |
+
assert "user_detail" in template_names
|
| 2218 |
+
|
| 2219 |
+
# Check resources use custom names
|
| 2220 |
+
resources = list(server._resource_manager.get_resources().values())
|
| 2221 |
+
resource_names = {resource.name for resource in resources}
|
| 2222 |
+
assert "user_list" in resource_names
|
| 2223 |
+
|
| 2224 |
+
async def test_mcp_names_fallback_to_operation_id_short(
|
| 2225 |
+
self, mcp_names_openapi_spec, mock_client
|
| 2226 |
+
):
|
| 2227 |
+
"""Test fallback to operationId up to double underscore when not in mcp_names."""
|
| 2228 |
+
# Only provide mapping for one operationId
|
| 2229 |
+
mcp_names = {
|
| 2230 |
+
"list_users__with_pagination": "custom_user_list",
|
| 2231 |
+
}
|
| 2232 |
+
|
| 2233 |
+
server = FastMCPOpenAPI(
|
| 2234 |
+
openapi_spec=mcp_names_openapi_spec,
|
| 2235 |
+
client=mock_client,
|
| 2236 |
+
mcp_names=mcp_names,
|
| 2237 |
+
)
|
| 2238 |
+
|
| 2239 |
+
tools = server._tool_manager.list_tools()
|
| 2240 |
+
tool_names = {tool.name for tool in tools}
|
| 2241 |
+
|
| 2242 |
+
templates = list(server._resource_manager.get_templates().values())
|
| 2243 |
+
template_names = {template.name for template in templates}
|
| 2244 |
+
|
| 2245 |
+
resources = list(server._resource_manager.get_resources().values())
|
| 2246 |
+
resource_names = {resource.name for resource in resources}
|
| 2247 |
+
|
| 2248 |
+
# Custom mapped name should be used
|
| 2249 |
+
assert "custom_user_list" in resource_names
|
| 2250 |
+
|
| 2251 |
+
# Unmapped operationIds should use short version (up to __)
|
| 2252 |
+
assert "create_user_admin" in tool_names
|
| 2253 |
+
assert "get_user_by_id" in template_names
|
| 2254 |
+
|
| 2255 |
+
async def test_names_are_slugified(self, mcp_names_openapi_spec, mock_client):
|
| 2256 |
+
"""Test that names are properly slugified (spaces, special chars removed)."""
|
| 2257 |
+
server = FastMCPOpenAPI(
|
| 2258 |
+
openapi_spec=mcp_names_openapi_spec,
|
| 2259 |
+
client=mock_client,
|
| 2260 |
+
)
|
| 2261 |
+
|
| 2262 |
+
resources = list(server._resource_manager.get_resources().values())
|
| 2263 |
+
resource_names = {
|
| 2264 |
+
resource.name for resource in resources if resource.name is not None
|
| 2265 |
+
}
|
| 2266 |
+
|
| 2267 |
+
# Special chars and spaces should be slugified
|
| 2268 |
+
slugified_name = next(
|
| 2269 |
+
(name for name in resource_names if "special" in name), None
|
| 2270 |
+
)
|
| 2271 |
+
assert slugified_name is not None
|
| 2272 |
+
# Should not contain special characters or spaces
|
| 2273 |
+
assert "@" not in slugified_name
|
| 2274 |
+
assert "#" not in slugified_name
|
| 2275 |
+
assert "$" not in slugified_name
|
| 2276 |
+
assert "%" not in slugified_name
|
| 2277 |
+
assert " " not in slugified_name
|
| 2278 |
+
|
| 2279 |
+
async def test_names_are_truncated_to_56_chars(
|
| 2280 |
+
self, mcp_names_openapi_spec, mock_client
|
| 2281 |
+
):
|
| 2282 |
+
"""Test that names are truncated to 56 characters maximum."""
|
| 2283 |
+
server = FastMCPOpenAPI(
|
| 2284 |
+
openapi_spec=mcp_names_openapi_spec,
|
| 2285 |
+
client=mock_client,
|
| 2286 |
+
)
|
| 2287 |
+
|
| 2288 |
+
# Check all component types
|
| 2289 |
+
all_names = []
|
| 2290 |
+
|
| 2291 |
+
tools = server._tool_manager.list_tools()
|
| 2292 |
+
all_names.extend(tool.name for tool in tools)
|
| 2293 |
+
|
| 2294 |
+
resources = list(server._resource_manager.get_resources().values())
|
| 2295 |
+
all_names.extend(resource.name for resource in resources)
|
| 2296 |
+
|
| 2297 |
+
templates = list(server._resource_manager.get_templates().values())
|
| 2298 |
+
all_names.extend(template.name for template in templates)
|
| 2299 |
+
|
| 2300 |
+
# All names should be 56 characters or less
|
| 2301 |
+
for name in all_names:
|
| 2302 |
+
assert len(name) <= 56, (
|
| 2303 |
+
f"Name '{name}' exceeds 56 characters (length: {len(name)})"
|
| 2304 |
+
)
|
| 2305 |
+
|
| 2306 |
+
# Verify that the long operationId was actually truncated
|
| 2307 |
+
long_name = next((name for name in all_names if len(name) > 50), None)
|
| 2308 |
+
assert long_name is not None, "Expected to find a truncated name for testing"
|
| 2309 |
+
|
| 2310 |
+
async def test_mcp_names_with_from_openapi_classmethod(
|
| 2311 |
+
self, mcp_names_openapi_spec, mock_client
|
| 2312 |
+
):
|
| 2313 |
+
"""Test mcp_names works with FastMCP.from_openapi() classmethod."""
|
| 2314 |
+
mcp_names = {
|
| 2315 |
+
"list_users__with_pagination": "openapi_user_list",
|
| 2316 |
+
}
|
| 2317 |
+
|
| 2318 |
+
server = FastMCP.from_openapi(
|
| 2319 |
+
openapi_spec=mcp_names_openapi_spec,
|
| 2320 |
+
client=mock_client,
|
| 2321 |
+
mcp_names=mcp_names,
|
| 2322 |
+
)
|
| 2323 |
+
|
| 2324 |
+
resources = list(server._resource_manager.get_resources().values())
|
| 2325 |
+
resource_names = {resource.name for resource in resources}
|
| 2326 |
+
assert "openapi_user_list" in resource_names
|
| 2327 |
+
|
| 2328 |
+
async def test_mcp_names_with_from_fastapi_classmethod(self):
|
| 2329 |
+
"""Test mcp_names works with FastMCP.from_fastapi() classmethod."""
|
| 2330 |
+
from fastapi import FastAPI
|
| 2331 |
+
from pydantic import BaseModel
|
| 2332 |
+
|
| 2333 |
+
app = FastAPI(title="FastAPI MCP Names Test")
|
| 2334 |
+
|
| 2335 |
+
class User(BaseModel):
|
| 2336 |
+
name: str
|
| 2337 |
+
|
| 2338 |
+
@app.get("/users", operation_id="list_users__with_filters")
|
| 2339 |
+
async def get_users() -> list[User]:
|
| 2340 |
+
return [User(name="test")]
|
| 2341 |
+
|
| 2342 |
+
@app.post("/users", operation_id="create_user__admin_required")
|
| 2343 |
+
async def create_user(user: User) -> User:
|
| 2344 |
+
return user
|
| 2345 |
+
|
| 2346 |
+
mcp_names = {
|
| 2347 |
+
"list_users__with_filters": "fastapi_user_list",
|
| 2348 |
+
"create_user__admin_required": "fastapi_create_user",
|
| 2349 |
+
}
|
| 2350 |
+
|
| 2351 |
+
server = FastMCP.from_fastapi(
|
| 2352 |
+
app=app,
|
| 2353 |
+
mcp_names=mcp_names,
|
| 2354 |
+
)
|
| 2355 |
+
|
| 2356 |
+
tools = server._tool_manager.list_tools()
|
| 2357 |
+
tool_names = {tool.name for tool in tools}
|
| 2358 |
+
|
| 2359 |
+
resources = list(server._resource_manager.get_resources().values())
|
| 2360 |
+
resource_names = {resource.name for resource in resources}
|
| 2361 |
+
|
| 2362 |
+
assert "fastapi_create_user" in tool_names
|
| 2363 |
+
assert "fastapi_user_list" in resource_names
|
| 2364 |
+
|
| 2365 |
+
async def test_mcp_names_custom_names_are_also_truncated(
|
| 2366 |
+
self, mcp_names_openapi_spec, mock_client
|
| 2367 |
+
):
|
| 2368 |
+
"""Test that custom names in mcp_names are also truncated to 56 characters."""
|
| 2369 |
+
# Provide a custom name that's longer than 56 characters
|
| 2370 |
+
very_long_custom_name = "this_is_a_very_long_custom_name_that_exceeds_fifty_six_characters_and_should_be_truncated"
|
| 2371 |
+
|
| 2372 |
+
mcp_names = {
|
| 2373 |
+
"list_users__with_pagination": very_long_custom_name,
|
| 2374 |
+
}
|
| 2375 |
+
|
| 2376 |
+
server = FastMCPOpenAPI(
|
| 2377 |
+
openapi_spec=mcp_names_openapi_spec,
|
| 2378 |
+
client=mock_client,
|
| 2379 |
+
mcp_names=mcp_names,
|
| 2380 |
+
)
|
| 2381 |
+
|
| 2382 |
+
resources = list(server._resource_manager.get_resources().values())
|
| 2383 |
+
resource_names = {
|
| 2384 |
+
resource.name for resource in resources if resource.name is not None
|
| 2385 |
+
}
|
| 2386 |
+
|
| 2387 |
+
# Find the resource that should have the custom name
|
| 2388 |
+
truncated_name = next(
|
| 2389 |
+
(
|
| 2390 |
+
name
|
| 2391 |
+
for name in resource_names
|
| 2392 |
+
if "this_is_a_very_long_custom_name" in name
|
| 2393 |
+
),
|
| 2394 |
+
None,
|
| 2395 |
+
)
|
| 2396 |
+
assert truncated_name is not None
|
| 2397 |
+
assert len(truncated_name) <= 56
|
| 2398 |
+
assert (
|
| 2399 |
+
len(truncated_name) == 56
|
| 2400 |
+
) # Should be exactly 56 since original was longer
|
tests/server/openapi/test_openapi_path_parameters.py
CHANGED
|
@@ -84,7 +84,7 @@ async def test_fastmcp_from_openapi(array_path_spec, mock_client):
|
|
| 84 |
# Verify the tool was created using the MCP protocol method
|
| 85 |
tools_result = await mcp.get_tools()
|
| 86 |
tool_names = [tool.name for tool in tools_result.values()]
|
| 87 |
-
assert "
|
| 88 |
|
| 89 |
|
| 90 |
async def test_array_path_parameter_handling(mock_client):
|
|
@@ -93,7 +93,7 @@ async def test_array_path_parameter_handling(mock_client):
|
|
| 93 |
route = HTTPRoute(
|
| 94 |
path="/select/{days}",
|
| 95 |
method="PUT",
|
| 96 |
-
operation_id="
|
| 97 |
parameters=[
|
| 98 |
ParameterInfo(
|
| 99 |
name="days",
|
|
@@ -122,7 +122,7 @@ async def test_array_path_parameter_handling(mock_client):
|
|
| 122 |
tool = OpenAPITool(
|
| 123 |
client=mock_client,
|
| 124 |
route=route,
|
| 125 |
-
name="
|
| 126 |
description="Test operation",
|
| 127 |
parameters={},
|
| 128 |
)
|
|
@@ -163,7 +163,7 @@ async def test_integration_array_path_parameter(array_path_spec, mock_client):
|
|
| 163 |
mcp = FastMCP.from_openapi(array_path_spec, client=mock_client)
|
| 164 |
|
| 165 |
# Call the tool with a single value
|
| 166 |
-
await mcp._mcp_call_tool("
|
| 167 |
|
| 168 |
# Check the request was made correctly
|
| 169 |
mock_client.request.assert_called_with(
|
|
@@ -177,7 +177,7 @@ async def test_integration_array_path_parameter(array_path_spec, mock_client):
|
|
| 177 |
mock_client.request.reset_mock()
|
| 178 |
|
| 179 |
# Call the tool with multiple values
|
| 180 |
-
await mcp._mcp_call_tool("
|
| 181 |
|
| 182 |
# Check the request was made correctly
|
| 183 |
mock_client.request.assert_called_with(
|
|
|
|
| 84 |
# Verify the tool was created using the MCP protocol method
|
| 85 |
tools_result = await mcp.get_tools()
|
| 86 |
tool_names = [tool.name for tool in tools_result.values()]
|
| 87 |
+
assert "test_operation" in tool_names
|
| 88 |
|
| 89 |
|
| 90 |
async def test_array_path_parameter_handling(mock_client):
|
|
|
|
| 93 |
route = HTTPRoute(
|
| 94 |
path="/select/{days}",
|
| 95 |
method="PUT",
|
| 96 |
+
operation_id="test_operation",
|
| 97 |
parameters=[
|
| 98 |
ParameterInfo(
|
| 99 |
name="days",
|
|
|
|
| 122 |
tool = OpenAPITool(
|
| 123 |
client=mock_client,
|
| 124 |
route=route,
|
| 125 |
+
name="test_operation",
|
| 126 |
description="Test operation",
|
| 127 |
parameters={},
|
| 128 |
)
|
|
|
|
| 163 |
mcp = FastMCP.from_openapi(array_path_spec, client=mock_client)
|
| 164 |
|
| 165 |
# Call the tool with a single value
|
| 166 |
+
await mcp._mcp_call_tool("test_operation", {"days": ["monday"]})
|
| 167 |
|
| 168 |
# Check the request was made correctly
|
| 169 |
mock_client.request.assert_called_with(
|
|
|
|
| 177 |
mock_client.request.reset_mock()
|
| 178 |
|
| 179 |
# Call the tool with multiple values
|
| 180 |
+
await mcp._mcp_call_tool("test_operation", {"days": ["monday", "tuesday"]})
|
| 181 |
|
| 182 |
# Check the request was made correctly
|
| 183 |
mock_client.request.assert_called_with(
|