1080 lines
41 KiB
Python
1080 lines
41 KiB
Python
"""FastMCP server implementation for OpenAPI integration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import enum
|
|
import json
|
|
import re
|
|
import warnings
|
|
from collections import Counter
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, field
|
|
from re import Pattern
|
|
from typing import TYPE_CHECKING, Any, Literal
|
|
|
|
import httpx
|
|
from mcp.types import ToolAnnotations
|
|
from pydantic.networks import AnyUrl
|
|
|
|
import fastmcp
|
|
from fastmcp.exceptions import ToolError
|
|
from fastmcp.resources import Resource, ResourceTemplate
|
|
from fastmcp.server.dependencies import get_http_headers
|
|
from fastmcp.server.server import FastMCP
|
|
from fastmcp.tools.tool import Tool, ToolResult
|
|
from fastmcp.utilities import openapi
|
|
from fastmcp.utilities.logging import get_logger
|
|
from fastmcp.utilities.openapi import (
|
|
HTTPRoute,
|
|
_combine_schemas,
|
|
extract_output_schema_from_responses,
|
|
format_array_parameter,
|
|
format_deep_object_parameter,
|
|
format_description_with_responses,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from fastmcp.server import Context
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]
|
|
|
|
|
|
def _slugify(text: str) -> str:
|
|
"""
|
|
Convert text to a URL-friendly slug format that only contains lowercase
|
|
letters, uppercase letters, numbers, and underscores.
|
|
"""
|
|
if not text:
|
|
return ""
|
|
|
|
# Replace spaces and common separators with underscores
|
|
slug = re.sub(r"[\s\-\.]+", "_", text)
|
|
|
|
# Remove non-alphanumeric characters except underscores
|
|
slug = re.sub(r"[^a-zA-Z0-9_]", "", slug)
|
|
|
|
# Remove multiple consecutive underscores
|
|
slug = re.sub(r"_+", "_", slug)
|
|
|
|
# Remove leading/trailing underscores
|
|
slug = slug.strip("_")
|
|
|
|
return slug
|
|
|
|
|
|
# Type definitions for the mapping functions
|
|
RouteMapFn = Callable[[HTTPRoute, "MCPType"], "MCPType | None"]
|
|
ComponentFn = Callable[
|
|
[
|
|
HTTPRoute,
|
|
"OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate",
|
|
],
|
|
None,
|
|
]
|
|
|
|
|
|
class MCPType(enum.Enum):
|
|
"""Type of FastMCP component to create from a route.
|
|
|
|
Enum values:
|
|
TOOL: Convert the route to a callable Tool
|
|
RESOURCE: Convert the route to a Resource (typically GET endpoints)
|
|
RESOURCE_TEMPLATE: Convert the route to a ResourceTemplate (typically GET with path params)
|
|
EXCLUDE: Exclude the route from being converted to any MCP component
|
|
IGNORE: Deprecated, use EXCLUDE instead
|
|
"""
|
|
|
|
TOOL = "TOOL"
|
|
RESOURCE = "RESOURCE"
|
|
RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE"
|
|
# PROMPT = "PROMPT"
|
|
EXCLUDE = "EXCLUDE"
|
|
|
|
|
|
# Keep RouteType as an alias to MCPType for backward compatibility
|
|
class RouteType(enum.Enum):
|
|
"""
|
|
Deprecated: Use MCPType instead.
|
|
|
|
This enum is kept for backward compatibility and will be removed in a future version.
|
|
"""
|
|
|
|
TOOL = "TOOL"
|
|
RESOURCE = "RESOURCE"
|
|
RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE"
|
|
IGNORE = "IGNORE"
|
|
|
|
|
|
@dataclass(kw_only=True)
|
|
class RouteMap:
|
|
"""Mapping configuration for HTTP routes to FastMCP component types."""
|
|
|
|
methods: list[HttpMethod] | Literal["*"] = field(default="*")
|
|
pattern: Pattern[str] | str = field(default=r".*")
|
|
route_type: RouteType | MCPType | None = field(default=None)
|
|
tags: set[str] = field(
|
|
default_factory=set,
|
|
metadata={"description": "A set of tags to match. All tags must match."},
|
|
)
|
|
mcp_type: MCPType | None = field(
|
|
default=None,
|
|
metadata={"description": "The type of FastMCP component to create."},
|
|
)
|
|
mcp_tags: set[str] = field(
|
|
default_factory=set,
|
|
metadata={
|
|
"description": "A set of tags to apply to the generated FastMCP component."
|
|
},
|
|
)
|
|
|
|
def __post_init__(self):
|
|
"""Validate and process the route map after initialization."""
|
|
# Handle backward compatibility for route_type, deprecated in 2.5.0
|
|
if self.mcp_type is None and self.route_type is not None:
|
|
if fastmcp.settings.deprecation_warnings:
|
|
warnings.warn(
|
|
"The 'route_type' parameter is deprecated and will be removed in a future version. "
|
|
"Use 'mcp_type' instead with the appropriate MCPType value.",
|
|
DeprecationWarning,
|
|
stacklevel=2,
|
|
)
|
|
if isinstance(self.route_type, RouteType):
|
|
if fastmcp.settings.deprecation_warnings:
|
|
warnings.warn(
|
|
"The RouteType class is deprecated and will be removed in a future version. "
|
|
"Use MCPType instead.",
|
|
DeprecationWarning,
|
|
stacklevel=2,
|
|
)
|
|
# Check for the deprecated IGNORE value
|
|
if self.route_type == RouteType.IGNORE:
|
|
if fastmcp.settings.deprecation_warnings:
|
|
warnings.warn(
|
|
"RouteType.IGNORE is deprecated and will be removed in a future version. "
|
|
"Use MCPType.EXCLUDE instead.",
|
|
DeprecationWarning,
|
|
stacklevel=2,
|
|
)
|
|
|
|
# Convert from RouteType to MCPType if needed
|
|
if isinstance(self.route_type, RouteType):
|
|
route_type_name = self.route_type.name
|
|
if route_type_name == "IGNORE":
|
|
route_type_name = "EXCLUDE"
|
|
self.mcp_type = getattr(MCPType, route_type_name)
|
|
else:
|
|
self.mcp_type = self.route_type
|
|
elif self.mcp_type is None:
|
|
raise ValueError("`mcp_type` must be provided")
|
|
|
|
# Set route_type to match mcp_type for backward compatibility
|
|
if self.route_type is None:
|
|
self.route_type = self.mcp_type
|
|
|
|
|
|
# Default route mapping: all routes become tools.
|
|
# Users can provide custom route_maps to override this behavior.
|
|
DEFAULT_ROUTE_MAPPINGS = [
|
|
RouteMap(mcp_type=MCPType.TOOL),
|
|
]
|
|
|
|
|
|
def _determine_route_type(
|
|
route: openapi.HTTPRoute,
|
|
mappings: list[RouteMap],
|
|
) -> RouteMap:
|
|
"""
|
|
Determines the FastMCP component type based on the route and mappings.
|
|
|
|
Args:
|
|
route: HTTPRoute object
|
|
mappings: List of RouteMap objects in priority order
|
|
|
|
Returns:
|
|
The RouteMap that matches the route, or a catchall "Tool" RouteMap if no match is found.
|
|
"""
|
|
# Check mappings in priority order (first match wins)
|
|
for route_map in mappings:
|
|
# Check if the HTTP method matches
|
|
if route_map.methods == "*" or route.method in route_map.methods:
|
|
# Handle both string patterns and compiled Pattern objects
|
|
if isinstance(route_map.pattern, Pattern):
|
|
pattern_matches = route_map.pattern.search(route.path)
|
|
else:
|
|
pattern_matches = re.search(route_map.pattern, route.path)
|
|
|
|
if pattern_matches:
|
|
# Check if tags match (if specified)
|
|
# If route_map.tags is empty, tags are not matched
|
|
# If route_map.tags is non-empty, all tags must be present in route.tags (AND condition)
|
|
if route_map.tags:
|
|
route_tags_set = set(route.tags or [])
|
|
if not route_map.tags.issubset(route_tags_set):
|
|
# Tags don't match, continue to next mapping
|
|
continue
|
|
|
|
# We know mcp_type is not None here due to post_init validation
|
|
assert route_map.mcp_type is not None
|
|
logger.debug(
|
|
f"Route {route.method} {route.path} matched mapping to {route_map.mcp_type.name}"
|
|
)
|
|
return route_map
|
|
|
|
# Default fallback
|
|
return RouteMap(mcp_type=MCPType.TOOL)
|
|
|
|
|
|
class OpenAPITool(Tool):
|
|
"""Tool implementation for OpenAPI endpoints."""
|
|
|
|
def __init__(
|
|
self,
|
|
client: httpx.AsyncClient,
|
|
route: openapi.HTTPRoute,
|
|
name: str,
|
|
description: str,
|
|
parameters: dict[str, Any],
|
|
output_schema: dict[str, Any] | None = None,
|
|
tags: set[str] | None = None,
|
|
timeout: float | None = None,
|
|
annotations: ToolAnnotations | None = None,
|
|
serializer: Callable[[Any], str] | None = None,
|
|
):
|
|
super().__init__(
|
|
name=name,
|
|
description=description,
|
|
parameters=parameters,
|
|
output_schema=output_schema,
|
|
tags=tags or set(),
|
|
annotations=annotations,
|
|
serializer=serializer,
|
|
)
|
|
self._client = client
|
|
self._route = route
|
|
self._timeout = timeout
|
|
|
|
def __repr__(self) -> str:
|
|
"""Custom representation to prevent recursion errors when printing."""
|
|
return f"OpenAPITool(name={self.name!r}, method={self._route.method}, path={self._route.path})"
|
|
|
|
async def run(self, arguments: dict[str, Any]) -> ToolResult:
|
|
"""Execute the HTTP request based on the route configuration."""
|
|
|
|
# Create mapping from suffixed parameter names back to original names and locations
|
|
# This handles parameter collisions where suffixes were added during schema generation
|
|
param_mapping = {} # suffixed_name -> (original_name, location)
|
|
|
|
# First, check if we have request body properties to detect collisions
|
|
body_props = set()
|
|
if self._route.request_body and self._route.request_body.content_schema:
|
|
content_type = next(iter(self._route.request_body.content_schema))
|
|
body_schema = self._route.request_body.content_schema[content_type]
|
|
body_props = set(body_schema.get("properties", {}).keys())
|
|
|
|
# Build parameter mapping for potentially suffixed parameters
|
|
for param in self._route.parameters:
|
|
original_name = param.name
|
|
suffixed_name = f"{param.name}__{param.location}"
|
|
|
|
# If parameter name collides with body property, it would have been suffixed
|
|
if param.name in body_props:
|
|
param_mapping[suffixed_name] = (original_name, param.location)
|
|
# Also map original name for backward compatibility when no collision
|
|
param_mapping[original_name] = (original_name, param.location)
|
|
|
|
# Prepare URL
|
|
path = self._route.path
|
|
|
|
# Replace path parameters with values from arguments
|
|
# Look for both original and suffixed parameter names
|
|
path_params = {}
|
|
for p in self._route.parameters:
|
|
if p.location == "path":
|
|
# Try suffixed name first, then original name
|
|
suffixed_name = f"{p.name}__{p.location}"
|
|
if (
|
|
suffixed_name in arguments
|
|
and arguments.get(suffixed_name) is not None
|
|
):
|
|
path_params[p.name] = arguments[suffixed_name]
|
|
elif p.name in arguments and arguments.get(p.name) is not None:
|
|
path_params[p.name] = arguments[p.name]
|
|
|
|
# Ensure all path parameters are provided
|
|
required_path_params = {
|
|
p.name
|
|
for p in self._route.parameters
|
|
if p.location == "path" and p.required
|
|
}
|
|
missing_params = required_path_params - path_params.keys()
|
|
if missing_params:
|
|
raise ToolError(f"Missing required path parameters: {missing_params}")
|
|
|
|
for param_name, param_value in path_params.items():
|
|
# Handle array path parameters with style 'simple' (comma-separated)
|
|
# In OpenAPI, 'simple' is the default style for path parameters
|
|
param_info = next(
|
|
(p for p in self._route.parameters if p.name == param_name), None
|
|
)
|
|
|
|
if param_info and isinstance(param_value, list):
|
|
# Check if schema indicates an array type
|
|
schema = param_info.schema_
|
|
is_array = schema.get("type") == "array"
|
|
|
|
if is_array:
|
|
# Format array values as comma-separated string
|
|
# This follows the OpenAPI 'simple' style (default for path)
|
|
formatted_value = format_array_parameter(
|
|
param_value, param_name, is_query_parameter=False
|
|
)
|
|
path = path.replace(f"{{{param_name}}}", str(formatted_value))
|
|
continue
|
|
|
|
# Default handling for non-array parameters or non-array schemas
|
|
path = path.replace(f"{{{param_name}}}", str(param_value))
|
|
|
|
# Prepare query parameters - filter out None and empty strings
|
|
query_params = {}
|
|
for p in self._route.parameters:
|
|
if p.location == "query":
|
|
# Try suffixed name first, then original name
|
|
suffixed_name = f"{p.name}__{p.location}"
|
|
param_value = None
|
|
|
|
suffixed_value = arguments.get(suffixed_name)
|
|
if (
|
|
suffixed_name in arguments
|
|
and suffixed_value is not None
|
|
and suffixed_value != ""
|
|
and not (
|
|
isinstance(suffixed_value, list | dict)
|
|
and len(suffixed_value) == 0
|
|
)
|
|
):
|
|
param_value = arguments[suffixed_name]
|
|
else:
|
|
name_value = arguments.get(p.name)
|
|
if (
|
|
p.name in arguments
|
|
and name_value is not None
|
|
and name_value != ""
|
|
and not (
|
|
isinstance(name_value, list | dict) and len(name_value) == 0
|
|
)
|
|
):
|
|
param_value = arguments[p.name]
|
|
|
|
if param_value is not None:
|
|
# Handle different parameter styles and types
|
|
param_style = (
|
|
p.style or "form"
|
|
) # Default style for query parameters is "form"
|
|
param_explode = (
|
|
p.explode if p.explode is not None else True
|
|
) # Default explode for query is True
|
|
|
|
# Handle deepObject style for object parameters
|
|
if (
|
|
param_style == "deepObject"
|
|
and isinstance(param_value, dict)
|
|
and len(param_value) > 0
|
|
):
|
|
if param_explode:
|
|
# deepObject with explode=true: object properties become separate parameters
|
|
# e.g., target[id]=123&target[type]=user
|
|
deep_obj_params = format_deep_object_parameter(
|
|
param_value, p.name
|
|
)
|
|
query_params.update(deep_obj_params)
|
|
else:
|
|
# deepObject with explode=false is not commonly used, fallback to JSON
|
|
logger.warning(
|
|
f"deepObject style with explode=false for parameter '{p.name}' is not standard. "
|
|
f"Using JSON serialization fallback."
|
|
)
|
|
query_params[p.name] = json.dumps(param_value)
|
|
# Handle array parameters with form style (default)
|
|
elif (
|
|
isinstance(param_value, list)
|
|
and p.schema_.get("type") == "array"
|
|
and len(param_value) > 0
|
|
):
|
|
if param_explode:
|
|
# When explode=True, we pass the array directly, which HTTPX will serialize
|
|
# as multiple parameters with the same name
|
|
query_params[p.name] = param_value
|
|
else:
|
|
# Format array as comma-separated string when explode=False
|
|
formatted_value = format_array_parameter(
|
|
param_value, p.name, is_query_parameter=True
|
|
)
|
|
query_params[p.name] = formatted_value
|
|
else:
|
|
# Non-array, non-deepObject parameters are passed as is
|
|
query_params[p.name] = param_value
|
|
|
|
# Prepare headers - fix typing by ensuring all values are strings
|
|
headers = {}
|
|
|
|
# Start with OpenAPI-defined header parameters
|
|
openapi_headers = {}
|
|
for p in self._route.parameters:
|
|
if p.location == "header":
|
|
# Try suffixed name first, then original name
|
|
suffixed_name = f"{p.name}__{p.location}"
|
|
param_value = None
|
|
|
|
if (
|
|
suffixed_name in arguments
|
|
and arguments.get(suffixed_name) is not None
|
|
):
|
|
param_value = arguments[suffixed_name]
|
|
elif p.name in arguments and arguments.get(p.name) is not None:
|
|
param_value = arguments[p.name]
|
|
|
|
if param_value is not None:
|
|
openapi_headers[p.name.lower()] = str(param_value)
|
|
headers.update(openapi_headers)
|
|
|
|
# Add headers from the current MCP client HTTP request (these take precedence)
|
|
mcp_headers = get_http_headers()
|
|
headers.update(mcp_headers)
|
|
|
|
# Prepare request body
|
|
json_data = None
|
|
if self._route.request_body and self._route.request_body.content_schema:
|
|
# Extract body parameters with collision-aware logic
|
|
# Exclude all parameter names that belong to path/query/header locations
|
|
params_to_exclude = set()
|
|
|
|
for p in self._route.parameters:
|
|
if (
|
|
p.name in body_props
|
|
): # This parameter had a collision, so it was suffixed
|
|
params_to_exclude.add(f"{p.name}__{p.location}")
|
|
else: # No collision, parameter keeps original name but should still be excluded from body
|
|
params_to_exclude.add(p.name)
|
|
|
|
body_params = {
|
|
k: v for k, v in arguments.items() if k not in params_to_exclude
|
|
}
|
|
|
|
if body_params:
|
|
json_data = body_params
|
|
|
|
# Execute the request
|
|
try:
|
|
response = await self._client.request(
|
|
method=self._route.method,
|
|
url=path,
|
|
params=query_params,
|
|
headers=headers,
|
|
json=json_data,
|
|
timeout=self._timeout,
|
|
)
|
|
|
|
# Raise for 4xx/5xx responses
|
|
response.raise_for_status()
|
|
|
|
# Try to parse as JSON first
|
|
try:
|
|
result = response.json()
|
|
|
|
# Handle structured content based on output schema, if any
|
|
structured_output = None
|
|
if self.output_schema is not None:
|
|
if self.output_schema.get("x-fastmcp-wrap-result"):
|
|
# Schema says wrap - always wrap in result key
|
|
structured_output = {"result": result}
|
|
else:
|
|
structured_output = result
|
|
# If no output schema, use fallback logic for backward compatibility
|
|
elif not isinstance(result, dict):
|
|
structured_output = {"result": result}
|
|
else:
|
|
structured_output = result
|
|
|
|
return ToolResult(structured_content=structured_output)
|
|
except json.JSONDecodeError:
|
|
return ToolResult(content=response.text)
|
|
|
|
except httpx.HTTPStatusError as e:
|
|
# Handle HTTP errors (4xx, 5xx)
|
|
error_message = (
|
|
f"HTTP error {e.response.status_code}: {e.response.reason_phrase}"
|
|
)
|
|
try:
|
|
error_data = e.response.json()
|
|
error_message += f" - {error_data}"
|
|
except (json.JSONDecodeError, ValueError):
|
|
if e.response.text:
|
|
error_message += f" - {e.response.text}"
|
|
|
|
raise ValueError(error_message)
|
|
|
|
except httpx.RequestError as e:
|
|
# Handle request errors (connection, timeout, etc.)
|
|
raise ValueError(f"Request error: {str(e)}")
|
|
|
|
|
|
class OpenAPIResource(Resource):
|
|
"""Resource implementation for OpenAPI endpoints."""
|
|
|
|
def __init__(
|
|
self,
|
|
client: httpx.AsyncClient,
|
|
route: openapi.HTTPRoute,
|
|
uri: str,
|
|
name: str,
|
|
description: str,
|
|
mime_type: str = "application/json",
|
|
tags: set[str] = set(),
|
|
timeout: float | None = None,
|
|
):
|
|
super().__init__(
|
|
uri=AnyUrl(uri), # Convert string to AnyUrl
|
|
name=name,
|
|
description=description,
|
|
mime_type=mime_type,
|
|
tags=tags,
|
|
)
|
|
self._client = client
|
|
self._route = route
|
|
self._timeout = timeout
|
|
|
|
def __repr__(self) -> str:
|
|
"""Custom representation to prevent recursion errors when printing."""
|
|
return f"OpenAPIResource(name={self.name!r}, uri={self.uri!r}, path={self._route.path})"
|
|
|
|
async def read(self) -> str | bytes:
|
|
"""Fetch the resource data by making an HTTP request."""
|
|
try:
|
|
# Extract path parameters from the URI if present
|
|
path = self._route.path
|
|
resource_uri = str(self.uri)
|
|
|
|
# If this is a templated resource, extract path parameters from the URI
|
|
if "{" in path and "}" in path:
|
|
# Extract the resource ID from the URI (the last part after the last slash)
|
|
parts = resource_uri.split("/")
|
|
|
|
if len(parts) > 1:
|
|
# Find all path parameters in the route path
|
|
path_params = {}
|
|
|
|
# Find the path parameter names from the route path
|
|
param_matches = re.findall(r"\{([^}]+)\}", path)
|
|
if param_matches:
|
|
# Reverse sorting from creation order (traversal is backwards)
|
|
param_matches.sort(reverse=True)
|
|
# Number of sent parameters is number of parts -1 (assuming first part is resource identifier)
|
|
expected_param_count = len(parts) - 1
|
|
# Map parameters from the end of the URI to the parameters in the path
|
|
# Last parameter in URI (parts[-1]) maps to last parameter in path, and so on
|
|
for i, param_name in enumerate(param_matches):
|
|
# Ensure we don't use resource identifier as parameter
|
|
if i < expected_param_count:
|
|
# Get values from the end of parts
|
|
param_value = parts[-1 - i]
|
|
path_params[param_name] = param_value
|
|
|
|
# Replace path parameters with their values
|
|
for param_name, param_value in path_params.items():
|
|
path = path.replace(f"{{{param_name}}}", str(param_value))
|
|
|
|
# Filter any query parameters - get query parameters and filter out None/empty values
|
|
query_params = {}
|
|
for param in self._route.parameters:
|
|
if param.location == "query" and hasattr(self, f"_{param.name}"):
|
|
value = getattr(self, f"_{param.name}")
|
|
if value is not None and value != "":
|
|
query_params[param.name] = value
|
|
|
|
# Prepare headers from MCP client request if available
|
|
headers = {}
|
|
mcp_headers = get_http_headers()
|
|
headers.update(mcp_headers)
|
|
|
|
response = await self._client.request(
|
|
method=self._route.method,
|
|
url=path,
|
|
params=query_params,
|
|
headers=headers,
|
|
timeout=self._timeout,
|
|
)
|
|
|
|
# Raise for 4xx/5xx responses
|
|
response.raise_for_status()
|
|
|
|
# Determine content type and return appropriate format
|
|
content_type = response.headers.get("content-type", "").lower()
|
|
|
|
if "application/json" in content_type:
|
|
result = response.json()
|
|
return json.dumps(result)
|
|
elif any(ct in content_type for ct in ["text/", "application/xml"]):
|
|
return response.text
|
|
else:
|
|
return response.content
|
|
|
|
except httpx.HTTPStatusError as e:
|
|
# Handle HTTP errors (4xx, 5xx)
|
|
error_message = (
|
|
f"HTTP error {e.response.status_code}: {e.response.reason_phrase}"
|
|
)
|
|
try:
|
|
error_data = e.response.json()
|
|
error_message += f" - {error_data}"
|
|
except (json.JSONDecodeError, ValueError):
|
|
if e.response.text:
|
|
error_message += f" - {e.response.text}"
|
|
|
|
raise ValueError(error_message)
|
|
|
|
except httpx.RequestError as e:
|
|
# Handle request errors (connection, timeout, etc.)
|
|
raise ValueError(f"Request error: {str(e)}")
|
|
|
|
|
|
class OpenAPIResourceTemplate(ResourceTemplate):
|
|
"""Resource template implementation for OpenAPI endpoints."""
|
|
|
|
def __init__(
|
|
self,
|
|
client: httpx.AsyncClient,
|
|
route: openapi.HTTPRoute,
|
|
uri_template: str,
|
|
name: str,
|
|
description: str,
|
|
parameters: dict[str, Any],
|
|
tags: set[str] = set(),
|
|
timeout: float | None = None,
|
|
):
|
|
super().__init__(
|
|
uri_template=uri_template,
|
|
name=name,
|
|
description=description,
|
|
parameters=parameters,
|
|
tags=tags,
|
|
)
|
|
self._client = client
|
|
self._route = route
|
|
self._timeout = timeout
|
|
|
|
def __repr__(self) -> str:
|
|
"""Custom representation to prevent recursion errors when printing."""
|
|
return f"OpenAPIResourceTemplate(name={self.name!r}, uri_template={self.uri_template!r}, path={self._route.path})"
|
|
|
|
async def create_resource(
|
|
self,
|
|
uri: str,
|
|
params: dict[str, Any],
|
|
context: Context | None = None,
|
|
) -> Resource:
|
|
"""Create a resource with the given parameters."""
|
|
# Generate a URI for this resource instance
|
|
uri_parts = []
|
|
for key, value in params.items():
|
|
uri_parts.append(f"{key}={value}")
|
|
|
|
# Create and return a resource
|
|
return OpenAPIResource(
|
|
client=self._client,
|
|
route=self._route,
|
|
uri=uri,
|
|
name=f"{self.name}-{'-'.join(uri_parts)}",
|
|
description=self.description or f"Resource for {self._route.path}",
|
|
mime_type="application/json",
|
|
tags=set(self._route.tags or []),
|
|
timeout=self._timeout,
|
|
)
|
|
|
|
|
|
class FastMCPOpenAPI(FastMCP):
|
|
"""
|
|
FastMCP server implementation that creates components from an OpenAPI schema.
|
|
|
|
This class parses an OpenAPI specification and creates appropriate FastMCP components
|
|
(Tools, Resources, ResourceTemplates) based on route mappings.
|
|
|
|
Example:
|
|
```python
|
|
from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap, MCPType
|
|
import httpx
|
|
|
|
# Define custom route mappings
|
|
custom_mappings = [
|
|
# Map all user-related endpoints to ResourceTemplate
|
|
RouteMap(
|
|
methods=["GET", "POST", "PATCH"],
|
|
pattern=r".*/users/.*",
|
|
mcp_type=MCPType.RESOURCE_TEMPLATE
|
|
),
|
|
# Map all analytics endpoints to Tool
|
|
RouteMap(
|
|
methods=["GET"],
|
|
pattern=r".*/analytics/.*",
|
|
mcp_type=MCPType.TOOL
|
|
),
|
|
]
|
|
|
|
# Create server with custom mappings and route mapper
|
|
server = FastMCPOpenAPI(
|
|
openapi_spec=spec,
|
|
client=httpx.AsyncClient(),
|
|
name="API Server",
|
|
route_maps=custom_mappings,
|
|
)
|
|
```
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
openapi_spec: dict[str, Any],
|
|
client: httpx.AsyncClient,
|
|
name: str | None = None,
|
|
route_maps: list[RouteMap] | None = None,
|
|
route_map_fn: RouteMapFn | None = None,
|
|
mcp_component_fn: ComponentFn | None = None,
|
|
mcp_names: dict[str, str] | None = None,
|
|
tags: set[str] | None = None,
|
|
timeout: float | None = None,
|
|
**settings: Any,
|
|
):
|
|
"""
|
|
Initialize a FastMCP server from an OpenAPI schema.
|
|
|
|
Args:
|
|
openapi_spec: OpenAPI schema as a dictionary or file path
|
|
client: httpx AsyncClient for making HTTP requests
|
|
name: Optional name for the server
|
|
route_maps: Optional list of RouteMap objects defining route mappings
|
|
route_map_fn: Optional callable for advanced route type mapping.
|
|
Receives (route, mcp_type) and returns MCPType or None.
|
|
Called on every route, including excluded ones.
|
|
mcp_component_fn: Optional callable for component customization.
|
|
Receives (route, component) and can modify the component in-place.
|
|
Called on every created component.
|
|
mcp_names: Optional dictionary mapping operationId to desired component names.
|
|
If an operationId is not in the dictionary, falls back to using the
|
|
operationId up to the first double underscore. If no operationId exists,
|
|
falls back to slugified summary or path-based naming.
|
|
All names are truncated to 56 characters maximum.
|
|
tags: Optional set of tags to add to all components. Components always receive any tags
|
|
from the route.
|
|
timeout: Optional timeout (in seconds) for all requests
|
|
**settings: Additional settings for FastMCP
|
|
"""
|
|
super().__init__(name=name or "OpenAPI FastMCP", **settings)
|
|
|
|
self._client = client
|
|
self._timeout = timeout
|
|
self._mcp_component_fn = mcp_component_fn
|
|
|
|
# Keep track of names to detect collisions
|
|
self._used_names = {
|
|
"tool": Counter(),
|
|
"resource": Counter(),
|
|
"resource_template": Counter(),
|
|
"prompt": Counter(),
|
|
}
|
|
|
|
http_routes = openapi.parse_openapi_to_http_routes(openapi_spec)
|
|
|
|
# Process routes
|
|
route_maps = (route_maps or []) + DEFAULT_ROUTE_MAPPINGS
|
|
for route in http_routes:
|
|
# Determine route type based on mappings or default rules
|
|
route_map = _determine_route_type(route, route_maps)
|
|
|
|
# TODO: remove this once RouteType is removed and mcp_type is typed as MCPType without | None
|
|
assert route_map.mcp_type is not None
|
|
route_type = route_map.mcp_type
|
|
|
|
# Call route_map_fn if provided
|
|
if route_map_fn is not None:
|
|
try:
|
|
result = route_map_fn(route, route_type)
|
|
if result is not None:
|
|
route_type = result
|
|
logger.debug(
|
|
f"Route {route.method} {route.path} mapping customized by route_map_fn: "
|
|
f"type={route_type.name}"
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Error in route_map_fn for {route.method} {route.path}: {e}. "
|
|
f"Using default values."
|
|
)
|
|
|
|
# Generate a default name from the route
|
|
component_name = self._generate_default_name(route, mcp_names)
|
|
|
|
route_tags = set(route.tags) | route_map.mcp_tags | (tags or set())
|
|
|
|
if route_type == MCPType.TOOL:
|
|
self._create_openapi_tool(route, component_name, tags=route_tags)
|
|
elif route_type == MCPType.RESOURCE:
|
|
self._create_openapi_resource(route, component_name, tags=route_tags)
|
|
elif route_type == MCPType.RESOURCE_TEMPLATE:
|
|
self._create_openapi_template(route, component_name, tags=route_tags)
|
|
elif route_type == MCPType.EXCLUDE:
|
|
logger.info(f"Excluding route: {route.method} {route.path}")
|
|
|
|
logger.info(f"Created FastMCP OpenAPI server with {len(http_routes)} routes")
|
|
|
|
def _generate_default_name(
|
|
self, route: openapi.HTTPRoute, mcp_names_map: dict[str, str] | None = None
|
|
) -> str:
|
|
"""Generate a default name from the route using the configured strategy."""
|
|
name = ""
|
|
mcp_names_map = mcp_names_map or {}
|
|
|
|
# First check if there's a custom mapping for this operationId
|
|
if route.operation_id:
|
|
if route.operation_id in mcp_names_map:
|
|
name = mcp_names_map[route.operation_id]
|
|
else:
|
|
# If there's a double underscore in the operationId, use the first part
|
|
name = route.operation_id.split("__")[0]
|
|
else:
|
|
name = route.summary or f"{route.method}_{route.path}"
|
|
|
|
name = _slugify(name)
|
|
|
|
# Truncate to 56 characters maximum
|
|
if len(name) > 56:
|
|
name = name[:56]
|
|
|
|
return name
|
|
|
|
def _get_unique_name(
|
|
self,
|
|
name: str,
|
|
component_type: Literal["tool", "resource", "resource_template", "prompt"],
|
|
) -> str:
|
|
"""
|
|
Ensure the name is unique within its component type by appending numbers if needed.
|
|
|
|
Args:
|
|
name: The proposed name
|
|
component_type: The type of component ("tools", "resources", or "templates")
|
|
|
|
Returns:
|
|
str: A unique name for the component
|
|
"""
|
|
# Check if the name is already used
|
|
self._used_names[component_type][name] += 1
|
|
if self._used_names[component_type][name] == 1:
|
|
return name
|
|
|
|
else:
|
|
# Create the new name
|
|
new_name = f"{name}_{self._used_names[component_type][name]}"
|
|
logger.debug(
|
|
f"Name collision detected: '{name}' already exists as a {component_type[:-1]}. "
|
|
f"Using '{new_name}' instead."
|
|
)
|
|
|
|
return new_name
|
|
|
|
def _create_openapi_tool(
|
|
self,
|
|
route: openapi.HTTPRoute,
|
|
name: str,
|
|
tags: set[str],
|
|
):
|
|
"""Creates and registers an OpenAPITool with enhanced description."""
|
|
combined_schema = _combine_schemas(route)
|
|
|
|
# Extract output schema from OpenAPI responses
|
|
output_schema = extract_output_schema_from_responses(
|
|
route.responses, route.schema_definitions, route.openapi_version
|
|
)
|
|
|
|
# Get a unique tool name
|
|
tool_name = self._get_unique_name(name, "tool")
|
|
|
|
base_description = (
|
|
route.description
|
|
or route.summary
|
|
or f"Executes {route.method} {route.path}"
|
|
)
|
|
|
|
# Format enhanced description with parameters and request body
|
|
enhanced_description = format_description_with_responses(
|
|
base_description=base_description,
|
|
responses=route.responses,
|
|
parameters=route.parameters,
|
|
request_body=route.request_body,
|
|
)
|
|
|
|
tool = OpenAPITool(
|
|
client=self._client,
|
|
route=route,
|
|
name=tool_name,
|
|
description=enhanced_description,
|
|
parameters=combined_schema,
|
|
output_schema=output_schema,
|
|
tags=set(route.tags or []) | tags,
|
|
timeout=self._timeout,
|
|
)
|
|
|
|
# Call component_fn if provided
|
|
if self._mcp_component_fn is not None:
|
|
try:
|
|
self._mcp_component_fn(route, tool)
|
|
logger.debug(f"Tool {tool_name} customized by component_fn")
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Error in component_fn for tool {tool_name}: {e}. "
|
|
f"Using component as-is."
|
|
)
|
|
|
|
# Use the potentially modified tool name as the registration key
|
|
final_tool_name = tool.name
|
|
|
|
# Register the tool by directly assigning to the tools dictionary
|
|
self._tool_manager._tools[final_tool_name] = tool
|
|
logger.debug(
|
|
f"Registered TOOL: {final_tool_name} ({route.method} {route.path}) with tags: {route.tags}"
|
|
)
|
|
|
|
def _create_openapi_resource(
|
|
self,
|
|
route: openapi.HTTPRoute,
|
|
name: str,
|
|
tags: set[str],
|
|
):
|
|
"""Creates and registers an OpenAPIResource with enhanced description."""
|
|
# Get a unique resource name
|
|
resource_name = self._get_unique_name(name, "resource")
|
|
|
|
resource_uri = f"resource://{resource_name}"
|
|
base_description = (
|
|
route.description or route.summary or f"Represents {route.path}"
|
|
)
|
|
|
|
# Format enhanced description with parameters and request body
|
|
enhanced_description = format_description_with_responses(
|
|
base_description=base_description,
|
|
responses=route.responses,
|
|
parameters=route.parameters,
|
|
request_body=route.request_body,
|
|
)
|
|
|
|
resource = OpenAPIResource(
|
|
client=self._client,
|
|
route=route,
|
|
uri=resource_uri,
|
|
name=resource_name,
|
|
description=enhanced_description,
|
|
tags=set(route.tags or []) | tags,
|
|
timeout=self._timeout,
|
|
)
|
|
|
|
# Call component_fn if provided
|
|
if self._mcp_component_fn is not None:
|
|
try:
|
|
self._mcp_component_fn(route, resource)
|
|
logger.debug(f"Resource {resource_uri} customized by component_fn")
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Error in component_fn for resource {resource_uri}: {e}. "
|
|
f"Using component as-is."
|
|
)
|
|
|
|
# Use the potentially modified resource URI as the registration key
|
|
final_resource_uri = str(resource.uri)
|
|
|
|
# Register the resource by directly assigning to the resources dictionary
|
|
self._resource_manager._resources[final_resource_uri] = resource
|
|
logger.debug(
|
|
f"Registered RESOURCE: {final_resource_uri} ({route.method} {route.path}) with tags: {route.tags}"
|
|
)
|
|
|
|
def _create_openapi_template(
|
|
self,
|
|
route: openapi.HTTPRoute,
|
|
name: str,
|
|
tags: set[str],
|
|
):
|
|
"""Creates and registers an OpenAPIResourceTemplate with enhanced description."""
|
|
# Get a unique template name
|
|
template_name = self._get_unique_name(name, "resource_template")
|
|
|
|
path_params = [p.name for p in route.parameters if p.location == "path"]
|
|
path_params.sort() # Sort for consistent URIs
|
|
|
|
uri_template_str = f"resource://{template_name}"
|
|
if path_params:
|
|
uri_template_str += "/" + "/".join(f"{{{p}}}" for p in path_params)
|
|
|
|
base_description = (
|
|
route.description or route.summary or f"Template for {route.path}"
|
|
)
|
|
|
|
# Format enhanced description with parameters and request body
|
|
enhanced_description = format_description_with_responses(
|
|
base_description=base_description,
|
|
responses=route.responses,
|
|
parameters=route.parameters,
|
|
request_body=route.request_body,
|
|
)
|
|
|
|
template_params_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
p.name: {
|
|
**(p.schema_.copy() if isinstance(p.schema_, dict) else {}),
|
|
**(
|
|
{"description": p.description}
|
|
if p.description
|
|
and not (
|
|
isinstance(p.schema_, dict) and "description" in p.schema_
|
|
)
|
|
else {}
|
|
),
|
|
}
|
|
for p in route.parameters
|
|
if p.location == "path"
|
|
},
|
|
"required": [
|
|
p.name for p in route.parameters if p.location == "path" and p.required
|
|
],
|
|
}
|
|
|
|
template = OpenAPIResourceTemplate(
|
|
client=self._client,
|
|
route=route,
|
|
uri_template=uri_template_str,
|
|
name=template_name,
|
|
description=enhanced_description,
|
|
parameters=template_params_schema,
|
|
tags=set(route.tags or []) | tags,
|
|
timeout=self._timeout,
|
|
)
|
|
|
|
# Call component_fn if provided
|
|
if self._mcp_component_fn is not None:
|
|
try:
|
|
self._mcp_component_fn(route, template)
|
|
logger.debug(f"Template {uri_template_str} customized by component_fn")
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Error in component_fn for template {uri_template_str}: {e}. "
|
|
f"Using component as-is."
|
|
)
|
|
|
|
# Use the potentially modified template URI as the registration key
|
|
final_template_uri = template.uri_template
|
|
|
|
# Register the template by directly assigning to the templates dictionary
|
|
self._resource_manager._templates[final_template_uri] = template
|
|
logger.debug(
|
|
f"Registered TEMPLATE: {final_template_uri} ({route.method} {route.path}) with tags: {route.tags}"
|
|
)
|