1569 lines
62 KiB
Python
1569 lines
62 KiB
Python
import json
|
|
from typing import Any, Generic, Literal, TypeVar, cast
|
|
|
|
from openapi_pydantic import (
|
|
OpenAPI,
|
|
Operation,
|
|
Parameter,
|
|
PathItem,
|
|
Reference,
|
|
RequestBody,
|
|
Response,
|
|
Schema,
|
|
)
|
|
|
|
# Import OpenAPI 3.0 models as well
|
|
from openapi_pydantic.v3.v3_0 import OpenAPI as OpenAPI_30
|
|
from openapi_pydantic.v3.v3_0 import Operation as Operation_30
|
|
from openapi_pydantic.v3.v3_0 import Parameter as Parameter_30
|
|
from openapi_pydantic.v3.v3_0 import PathItem as PathItem_30
|
|
from openapi_pydantic.v3.v3_0 import Reference as Reference_30
|
|
from openapi_pydantic.v3.v3_0 import RequestBody as RequestBody_30
|
|
from openapi_pydantic.v3.v3_0 import Response as Response_30
|
|
from openapi_pydantic.v3.v3_0 import Schema as Schema_30
|
|
from pydantic import BaseModel, Field, ValidationError
|
|
|
|
from fastmcp.utilities.logging import get_logger
|
|
from fastmcp.utilities.types import FastMCPBaseModel
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
# --- Intermediate Representation (IR) Definition ---
|
|
# (IR models remain the same)
|
|
|
|
HttpMethod = Literal[
|
|
"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD", "TRACE"
|
|
]
|
|
ParameterLocation = Literal["path", "query", "header", "cookie"]
|
|
JsonSchema = dict[str, Any]
|
|
|
|
|
|
def format_array_parameter(
|
|
values: list, parameter_name: str, is_query_parameter: bool = False
|
|
) -> str | list:
|
|
"""
|
|
Format an array parameter according to OpenAPI specifications.
|
|
|
|
Args:
|
|
values: List of values to format
|
|
parameter_name: Name of the parameter (for error messages)
|
|
is_query_parameter: If True, can return list for explode=True behavior
|
|
|
|
Returns:
|
|
String (comma-separated) or list (for query params with explode=True)
|
|
"""
|
|
# For arrays of simple types (strings, numbers, etc.), join with commas
|
|
if all(isinstance(item, str | int | float | bool) for item in values):
|
|
return ",".join(str(v) for v in values)
|
|
|
|
# For complex types, try to create a simpler representation
|
|
try:
|
|
# Try to create a simple string representation
|
|
formatted_parts = []
|
|
for item in values:
|
|
if isinstance(item, dict):
|
|
# For objects, serialize key-value pairs
|
|
item_parts = []
|
|
for k, v in item.items():
|
|
item_parts.append(f"{k}:{v}")
|
|
formatted_parts.append(".".join(item_parts))
|
|
else:
|
|
formatted_parts.append(str(item))
|
|
|
|
return ",".join(formatted_parts)
|
|
except Exception as e:
|
|
param_type = "query" if is_query_parameter else "path"
|
|
logger.warning(
|
|
f"Failed to format complex array {param_type} parameter '{parameter_name}': {e}"
|
|
)
|
|
|
|
if is_query_parameter:
|
|
# For query parameters, fallback to original list
|
|
return values
|
|
else:
|
|
# For path parameters, fallback to string representation without Python syntax
|
|
# Use str.translate() for efficient character removal
|
|
translation_table = str.maketrans("", "", "[]'\"")
|
|
str_value = str(values).translate(translation_table)
|
|
return str_value
|
|
|
|
|
|
def format_deep_object_parameter(
|
|
param_value: dict, parameter_name: str
|
|
) -> dict[str, str]:
|
|
"""
|
|
Format a dictionary parameter for deepObject style serialization.
|
|
|
|
According to OpenAPI 3.0 spec, deepObject style with explode=true serializes
|
|
object properties as separate query parameters with bracket notation.
|
|
|
|
For example: `{"id": "123", "type": "user"}` becomes `param[id]=123¶m[type]=user`.
|
|
|
|
Args:
|
|
param_value: Dictionary value to format
|
|
parameter_name: Name of the parameter
|
|
|
|
Returns:
|
|
Dictionary with bracketed parameter names as keys
|
|
"""
|
|
if not isinstance(param_value, dict):
|
|
logger.warning(
|
|
f"deepObject style parameter '{parameter_name}' expected dict, got {type(param_value)}"
|
|
)
|
|
return {}
|
|
|
|
result = {}
|
|
for key, value in param_value.items():
|
|
# Format as param[key]=value
|
|
bracketed_key = f"{parameter_name}[{key}]"
|
|
result[bracketed_key] = str(value)
|
|
|
|
return result
|
|
|
|
|
|
class ParameterInfo(FastMCPBaseModel):
|
|
"""Represents a single parameter for an HTTP operation in our IR."""
|
|
|
|
name: str
|
|
location: ParameterLocation # Mapped from 'in' field of openapi-pydantic Parameter
|
|
required: bool = False
|
|
schema_: JsonSchema = Field(..., alias="schema") # Target name in IR
|
|
description: str | None = None
|
|
explode: bool | None = None # OpenAPI explode property for array parameters
|
|
style: str | None = None # OpenAPI style property for parameter serialization
|
|
|
|
|
|
class RequestBodyInfo(FastMCPBaseModel):
|
|
"""Represents the request body for an HTTP operation in our IR."""
|
|
|
|
required: bool = False
|
|
content_schema: dict[str, JsonSchema] = Field(
|
|
default_factory=dict
|
|
) # Key: media type
|
|
description: str | None = None
|
|
|
|
|
|
class ResponseInfo(FastMCPBaseModel):
|
|
"""Represents response information in our IR."""
|
|
|
|
description: str | None = None
|
|
# Store schema per media type, key is media type
|
|
content_schema: dict[str, JsonSchema] = Field(default_factory=dict)
|
|
|
|
|
|
class HTTPRoute(FastMCPBaseModel):
|
|
"""Intermediate Representation for a single OpenAPI operation."""
|
|
|
|
path: str
|
|
method: HttpMethod
|
|
operation_id: str | None = None
|
|
summary: str | None = None
|
|
description: str | None = None
|
|
tags: list[str] = Field(default_factory=list)
|
|
parameters: list[ParameterInfo] = Field(default_factory=list)
|
|
request_body: RequestBodyInfo | None = None
|
|
responses: dict[str, ResponseInfo] = Field(
|
|
default_factory=dict
|
|
) # Key: status code str
|
|
schema_definitions: dict[str, JsonSchema] = Field(
|
|
default_factory=dict
|
|
) # Store component schemas
|
|
extensions: dict[str, Any] = Field(default_factory=dict)
|
|
openapi_version: str | None = None
|
|
|
|
|
|
# Export public symbols
|
|
__all__ = [
|
|
"HTTPRoute",
|
|
"ParameterInfo",
|
|
"RequestBodyInfo",
|
|
"ResponseInfo",
|
|
"HttpMethod",
|
|
"ParameterLocation",
|
|
"JsonSchema",
|
|
"parse_openapi_to_http_routes",
|
|
"extract_output_schema_from_responses",
|
|
"format_deep_object_parameter",
|
|
"_handle_nullable_fields",
|
|
]
|
|
|
|
# Type variables for generic parser
|
|
TOpenAPI = TypeVar("TOpenAPI", OpenAPI, OpenAPI_30)
|
|
TSchema = TypeVar("TSchema", Schema, Schema_30)
|
|
TReference = TypeVar("TReference", Reference, Reference_30)
|
|
TParameter = TypeVar("TParameter", Parameter, Parameter_30)
|
|
TRequestBody = TypeVar("TRequestBody", RequestBody, RequestBody_30)
|
|
TResponse = TypeVar("TResponse", Response, Response_30)
|
|
TOperation = TypeVar("TOperation", Operation, Operation_30)
|
|
TPathItem = TypeVar("TPathItem", PathItem, PathItem_30)
|
|
|
|
|
|
def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute]:
|
|
"""
|
|
Parses an OpenAPI schema dictionary into a list of HTTPRoute objects
|
|
using the openapi-pydantic library.
|
|
|
|
Supports both OpenAPI 3.0.x and 3.1.x versions.
|
|
"""
|
|
# Check OpenAPI version to use appropriate model
|
|
openapi_version = openapi_dict.get("openapi", "")
|
|
|
|
try:
|
|
if openapi_version.startswith("3.0"):
|
|
# Use OpenAPI 3.0 models
|
|
openapi_30 = OpenAPI_30.model_validate(openapi_dict)
|
|
logger.debug(
|
|
f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}"
|
|
)
|
|
parser = OpenAPIParser(
|
|
openapi_30,
|
|
Reference_30,
|
|
Schema_30,
|
|
Parameter_30,
|
|
RequestBody_30,
|
|
Response_30,
|
|
Operation_30,
|
|
PathItem_30,
|
|
openapi_version,
|
|
)
|
|
return parser.parse()
|
|
else:
|
|
# Default to OpenAPI 3.1 models
|
|
openapi_31 = OpenAPI.model_validate(openapi_dict)
|
|
logger.debug(
|
|
f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}"
|
|
)
|
|
parser = OpenAPIParser(
|
|
openapi_31,
|
|
Reference,
|
|
Schema,
|
|
Parameter,
|
|
RequestBody,
|
|
Response,
|
|
Operation,
|
|
PathItem,
|
|
openapi_version,
|
|
)
|
|
return parser.parse()
|
|
except ValidationError as e:
|
|
logger.error(f"OpenAPI schema validation failed: {e}")
|
|
error_details = e.errors()
|
|
logger.error(f"Validation errors: {error_details}")
|
|
raise ValueError(f"Invalid OpenAPI schema: {error_details}") from e
|
|
|
|
|
|
class OpenAPIParser(
|
|
Generic[
|
|
TOpenAPI,
|
|
TReference,
|
|
TSchema,
|
|
TParameter,
|
|
TRequestBody,
|
|
TResponse,
|
|
TOperation,
|
|
TPathItem,
|
|
]
|
|
):
|
|
"""Unified parser for OpenAPI schemas with generic type parameters to handle both 3.0 and 3.1."""
|
|
|
|
def __init__(
|
|
self,
|
|
openapi: TOpenAPI,
|
|
reference_cls: type[TReference],
|
|
schema_cls: type[TSchema],
|
|
parameter_cls: type[TParameter],
|
|
request_body_cls: type[TRequestBody],
|
|
response_cls: type[TResponse],
|
|
operation_cls: type[TOperation],
|
|
path_item_cls: type[TPathItem],
|
|
openapi_version: str,
|
|
):
|
|
"""Initialize the parser with the OpenAPI schema and type classes."""
|
|
self.openapi = openapi
|
|
self.reference_cls = reference_cls
|
|
self.schema_cls = schema_cls
|
|
self.parameter_cls = parameter_cls
|
|
self.request_body_cls = request_body_cls
|
|
self.response_cls = response_cls
|
|
self.operation_cls = operation_cls
|
|
self.path_item_cls = path_item_cls
|
|
self.openapi_version = openapi_version
|
|
|
|
def _convert_to_parameter_location(self, param_in: str) -> ParameterLocation:
|
|
"""Convert string parameter location to our ParameterLocation type."""
|
|
if param_in in ["path", "query", "header", "cookie"]:
|
|
return param_in # type: ignore[return-value] # Safe cast since we checked values
|
|
logger.warning(f"Unknown parameter location: {param_in}, defaulting to 'query'")
|
|
return "query" # type: ignore[return-value] # Safe cast to default value
|
|
|
|
def _resolve_ref(self, item: Any) -> Any:
|
|
"""Resolves a reference to its target definition."""
|
|
if isinstance(item, self.reference_cls):
|
|
ref_str = item.ref
|
|
try:
|
|
if not ref_str.startswith("#/"):
|
|
raise ValueError(
|
|
f"External or non-local reference not supported: {ref_str}"
|
|
)
|
|
|
|
parts = ref_str.strip("#/").split("/")
|
|
target = self.openapi
|
|
|
|
for part in parts:
|
|
if part.isdigit() and isinstance(target, list):
|
|
target = target[int(part)]
|
|
elif isinstance(target, BaseModel):
|
|
# Check class fields first, then model_extra
|
|
if part in target.__class__.model_fields:
|
|
target = getattr(target, part, None)
|
|
elif target.model_extra and part in target.model_extra:
|
|
target = target.model_extra[part]
|
|
else:
|
|
# Special handling for components
|
|
if part == "components" and hasattr(target, "components"):
|
|
target = getattr(target, "components")
|
|
elif hasattr(target, part): # Fallback check
|
|
target = getattr(target, part, None)
|
|
else:
|
|
target = None # Part not found
|
|
elif isinstance(target, dict):
|
|
target = target.get(part)
|
|
else:
|
|
raise ValueError(
|
|
f"Cannot traverse part '{part}' in reference '{ref_str}'"
|
|
)
|
|
|
|
if target is None:
|
|
raise ValueError(
|
|
f"Reference part '{part}' not found in path '{ref_str}'"
|
|
)
|
|
|
|
# Handle nested references
|
|
if isinstance(target, self.reference_cls):
|
|
return self._resolve_ref(target)
|
|
|
|
return target
|
|
except (AttributeError, KeyError, IndexError, TypeError, ValueError) as e:
|
|
raise ValueError(f"Failed to resolve reference '{ref_str}': {e}") from e
|
|
|
|
return item
|
|
|
|
def _extract_schema_as_dict(self, schema_obj: Any) -> JsonSchema:
|
|
"""Resolves a schema and returns it as a dictionary."""
|
|
try:
|
|
resolved_schema = self._resolve_ref(schema_obj)
|
|
|
|
if isinstance(resolved_schema, (self.schema_cls)):
|
|
# Convert schema to dictionary
|
|
result = resolved_schema.model_dump(
|
|
mode="json", by_alias=True, exclude_none=True
|
|
)
|
|
elif isinstance(resolved_schema, dict):
|
|
result = resolved_schema
|
|
else:
|
|
logger.warning(
|
|
f"Expected Schema after resolving, got {type(resolved_schema)}. Returning empty dict."
|
|
)
|
|
result = {}
|
|
|
|
return _replace_ref_with_defs(result)
|
|
except ValueError as e:
|
|
# Re-raise ValueError for external reference errors and other validation issues
|
|
if "External or non-local reference not supported" in str(e):
|
|
raise
|
|
logger.error(f"Failed to extract schema as dict: {e}", exc_info=False)
|
|
return {}
|
|
except Exception as e:
|
|
logger.error(f"Failed to extract schema as dict: {e}", exc_info=False)
|
|
return {}
|
|
|
|
def _extract_parameters(
|
|
self,
|
|
operation_params: list[Any] | None = None,
|
|
path_item_params: list[Any] | None = None,
|
|
) -> list[ParameterInfo]:
|
|
"""Extract and resolve parameters from operation and path item."""
|
|
extracted_params: list[ParameterInfo] = []
|
|
seen_params: dict[
|
|
tuple[str, str], bool
|
|
] = {} # Use tuple of (name, location) as key
|
|
all_params = (operation_params or []) + (path_item_params or [])
|
|
|
|
for param_or_ref in all_params:
|
|
try:
|
|
parameter = self._resolve_ref(param_or_ref)
|
|
|
|
if not isinstance(parameter, self.parameter_cls):
|
|
logger.warning(
|
|
f"Expected Parameter after resolving, got {type(parameter)}. Skipping."
|
|
)
|
|
continue
|
|
|
|
# Extract parameter info - handle both 3.0 and 3.1 parameter models
|
|
param_in = parameter.param_in # Both use param_in
|
|
# Handle enum or string parameter locations
|
|
from enum import Enum
|
|
|
|
param_in_str = (
|
|
param_in.value if isinstance(param_in, Enum) else param_in
|
|
)
|
|
param_location = self._convert_to_parameter_location(param_in_str)
|
|
param_schema_obj = parameter.param_schema # Both use param_schema
|
|
|
|
# Skip duplicate parameters (same name and location)
|
|
param_key = (parameter.name, param_in_str)
|
|
if param_key in seen_params:
|
|
continue
|
|
seen_params[param_key] = True
|
|
|
|
# Extract schema
|
|
param_schema_dict = {}
|
|
if param_schema_obj:
|
|
# Process schema object
|
|
param_schema_dict = self._extract_schema_as_dict(param_schema_obj)
|
|
|
|
# Handle default value
|
|
resolved_schema = self._resolve_ref(param_schema_obj)
|
|
if (
|
|
not isinstance(resolved_schema, self.reference_cls)
|
|
and hasattr(resolved_schema, "default")
|
|
and resolved_schema.default is not None
|
|
):
|
|
param_schema_dict["default"] = resolved_schema.default
|
|
|
|
elif hasattr(parameter, "content") and parameter.content:
|
|
# Handle content-based parameters
|
|
first_media_type = next(iter(parameter.content.values()), None)
|
|
if (
|
|
first_media_type
|
|
and hasattr(first_media_type, "media_type_schema")
|
|
and first_media_type.media_type_schema
|
|
):
|
|
media_schema = first_media_type.media_type_schema
|
|
param_schema_dict = self._extract_schema_as_dict(media_schema)
|
|
|
|
# Handle default value in content schema
|
|
resolved_media_schema = self._resolve_ref(media_schema)
|
|
if (
|
|
not isinstance(resolved_media_schema, self.reference_cls)
|
|
and hasattr(resolved_media_schema, "default")
|
|
and resolved_media_schema.default is not None
|
|
):
|
|
param_schema_dict["default"] = resolved_media_schema.default
|
|
|
|
# Extract explode and style properties if present
|
|
explode = getattr(parameter, "explode", None)
|
|
style = getattr(parameter, "style", None)
|
|
|
|
# Create parameter info object
|
|
param_info = ParameterInfo(
|
|
name=parameter.name,
|
|
location=param_location,
|
|
required=parameter.required,
|
|
schema=param_schema_dict,
|
|
description=parameter.description,
|
|
explode=explode,
|
|
style=style,
|
|
)
|
|
extracted_params.append(param_info)
|
|
except Exception as e:
|
|
param_name = getattr(
|
|
param_or_ref, "name", getattr(param_or_ref, "ref", "unknown")
|
|
)
|
|
logger.error(
|
|
f"Failed to extract parameter '{param_name}': {e}", exc_info=False
|
|
)
|
|
|
|
return extracted_params
|
|
|
|
def _extract_request_body(self, request_body_or_ref: Any) -> RequestBodyInfo | None:
|
|
"""Extract and resolve request body information."""
|
|
if not request_body_or_ref:
|
|
return None
|
|
|
|
try:
|
|
request_body = self._resolve_ref(request_body_or_ref)
|
|
|
|
if not isinstance(request_body, self.request_body_cls):
|
|
logger.warning(
|
|
f"Expected RequestBody after resolving, got {type(request_body)}. Returning None."
|
|
)
|
|
return None
|
|
|
|
# Create request body info
|
|
request_body_info = RequestBodyInfo(
|
|
required=request_body.required,
|
|
description=request_body.description,
|
|
)
|
|
|
|
# Extract content schemas
|
|
if hasattr(request_body, "content") and request_body.content:
|
|
for media_type_str, media_type_obj in request_body.content.items():
|
|
if (
|
|
media_type_obj
|
|
and hasattr(media_type_obj, "media_type_schema")
|
|
and media_type_obj.media_type_schema
|
|
):
|
|
try:
|
|
schema_dict = self._extract_schema_as_dict(
|
|
media_type_obj.media_type_schema
|
|
)
|
|
request_body_info.content_schema[media_type_str] = (
|
|
schema_dict
|
|
)
|
|
except ValueError as e:
|
|
# Re-raise ValueError for external reference errors
|
|
if "External or non-local reference not supported" in str(
|
|
e
|
|
):
|
|
raise
|
|
logger.error(
|
|
f"Failed to extract schema for media type '{media_type_str}': {e}"
|
|
)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Failed to extract schema for media type '{media_type_str}': {e}"
|
|
)
|
|
|
|
return request_body_info
|
|
except ValueError as e:
|
|
# Re-raise ValueError for external reference errors
|
|
if "External or non-local reference not supported" in str(e):
|
|
raise
|
|
ref_name = getattr(request_body_or_ref, "ref", "unknown")
|
|
logger.error(
|
|
f"Failed to extract request body '{ref_name}': {e}", exc_info=False
|
|
)
|
|
return None
|
|
except Exception as e:
|
|
ref_name = getattr(request_body_or_ref, "ref", "unknown")
|
|
logger.error(
|
|
f"Failed to extract request body '{ref_name}': {e}", exc_info=False
|
|
)
|
|
return None
|
|
|
|
def _extract_responses(
|
|
self, operation_responses: dict[str, Any] | None
|
|
) -> dict[str, ResponseInfo]:
|
|
"""Extract and resolve response information."""
|
|
extracted_responses: dict[str, ResponseInfo] = {}
|
|
|
|
if not operation_responses:
|
|
return extracted_responses
|
|
|
|
for status_code, resp_or_ref in operation_responses.items():
|
|
try:
|
|
response = self._resolve_ref(resp_or_ref)
|
|
|
|
if not isinstance(response, self.response_cls):
|
|
logger.warning(
|
|
f"Expected Response after resolving for status code {status_code}, "
|
|
f"got {type(response)}. Skipping."
|
|
)
|
|
continue
|
|
|
|
# Create response info
|
|
resp_info = ResponseInfo(description=response.description)
|
|
|
|
# Extract content schemas
|
|
if hasattr(response, "content") and response.content:
|
|
for media_type_str, media_type_obj in response.content.items():
|
|
if (
|
|
media_type_obj
|
|
and hasattr(media_type_obj, "media_type_schema")
|
|
and media_type_obj.media_type_schema
|
|
):
|
|
try:
|
|
schema_dict = self._extract_schema_as_dict(
|
|
media_type_obj.media_type_schema
|
|
)
|
|
resp_info.content_schema[media_type_str] = schema_dict
|
|
except ValueError as e:
|
|
# Re-raise ValueError for external reference errors
|
|
if (
|
|
"External or non-local reference not supported"
|
|
in str(e)
|
|
):
|
|
raise
|
|
logger.error(
|
|
f"Failed to extract schema for media type '{media_type_str}' "
|
|
f"in response {status_code}: {e}"
|
|
)
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Failed to extract schema for media type '{media_type_str}' "
|
|
f"in response {status_code}: {e}"
|
|
)
|
|
|
|
extracted_responses[str(status_code)] = resp_info
|
|
except ValueError as e:
|
|
# Re-raise ValueError for external reference errors
|
|
if "External or non-local reference not supported" in str(e):
|
|
raise
|
|
ref_name = getattr(resp_or_ref, "ref", "unknown")
|
|
logger.error(
|
|
f"Failed to extract response for status code {status_code} "
|
|
f"from reference '{ref_name}': {e}",
|
|
exc_info=False,
|
|
)
|
|
except Exception as e:
|
|
ref_name = getattr(resp_or_ref, "ref", "unknown")
|
|
logger.error(
|
|
f"Failed to extract response for status code {status_code} "
|
|
f"from reference '{ref_name}': {e}",
|
|
exc_info=False,
|
|
)
|
|
|
|
return extracted_responses
|
|
|
|
def parse(self) -> list[HTTPRoute]:
|
|
"""Parse the OpenAPI schema into HTTP routes."""
|
|
routes: list[HTTPRoute] = []
|
|
|
|
if not hasattr(self.openapi, "paths") or not self.openapi.paths:
|
|
logger.warning("OpenAPI schema has no paths defined.")
|
|
return []
|
|
|
|
# Extract component schemas
|
|
schema_definitions = {}
|
|
if hasattr(self.openapi, "components") and self.openapi.components:
|
|
components = self.openapi.components
|
|
if hasattr(components, "schemas") and components.schemas:
|
|
for name, schema in components.schemas.items():
|
|
try:
|
|
if isinstance(schema, self.reference_cls):
|
|
resolved_schema = self._resolve_ref(schema)
|
|
schema_definitions[name] = self._extract_schema_as_dict(
|
|
resolved_schema
|
|
)
|
|
else:
|
|
schema_definitions[name] = self._extract_schema_as_dict(
|
|
schema
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Failed to extract schema definition '{name}': {e}"
|
|
)
|
|
|
|
# Process paths and operations
|
|
for path_str, path_item_obj in self.openapi.paths.items():
|
|
if not isinstance(path_item_obj, self.path_item_cls):
|
|
logger.warning(
|
|
f"Skipping invalid path item for path '{path_str}' (type: {type(path_item_obj)})"
|
|
)
|
|
continue
|
|
|
|
path_level_params = (
|
|
path_item_obj.parameters
|
|
if hasattr(path_item_obj, "parameters")
|
|
else None
|
|
)
|
|
|
|
# Get HTTP methods from the path item class fields
|
|
http_methods = [
|
|
"get",
|
|
"put",
|
|
"post",
|
|
"delete",
|
|
"options",
|
|
"head",
|
|
"patch",
|
|
"trace",
|
|
]
|
|
for method_lower in http_methods:
|
|
operation = getattr(path_item_obj, method_lower, None)
|
|
|
|
if operation and isinstance(operation, self.operation_cls):
|
|
# Cast method to HttpMethod - safe since we only use valid HTTP methods
|
|
method_upper = method_lower.upper()
|
|
|
|
try:
|
|
parameters = self._extract_parameters(
|
|
getattr(operation, "parameters", None), path_level_params
|
|
)
|
|
|
|
request_body_info = self._extract_request_body(
|
|
getattr(operation, "requestBody", None)
|
|
)
|
|
|
|
responses = self._extract_responses(
|
|
getattr(operation, "responses", None)
|
|
)
|
|
|
|
extensions = {}
|
|
if hasattr(operation, "model_extra") and operation.model_extra:
|
|
extensions = {
|
|
k: v
|
|
for k, v in operation.model_extra.items()
|
|
if k.startswith("x-")
|
|
}
|
|
|
|
route = HTTPRoute(
|
|
path=path_str,
|
|
method=method_upper, # type: ignore[arg-type] # Known valid HTTP method
|
|
operation_id=getattr(operation, "operationId", None),
|
|
summary=getattr(operation, "summary", None),
|
|
description=getattr(operation, "description", None),
|
|
tags=getattr(operation, "tags", []) or [],
|
|
parameters=parameters,
|
|
request_body=request_body_info,
|
|
responses=responses,
|
|
schema_definitions=schema_definitions,
|
|
extensions=extensions,
|
|
openapi_version=self.openapi_version,
|
|
)
|
|
routes.append(route)
|
|
logger.debug(
|
|
f"Successfully extracted route: {method_upper} {path_str}"
|
|
)
|
|
except ValueError as op_error:
|
|
# Re-raise ValueError for external reference errors
|
|
if "External or non-local reference not supported" in str(
|
|
op_error
|
|
):
|
|
raise
|
|
op_id = getattr(operation, "operationId", "unknown")
|
|
logger.error(
|
|
f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}",
|
|
exc_info=True,
|
|
)
|
|
except Exception as op_error:
|
|
op_id = getattr(operation, "operationId", "unknown")
|
|
logger.error(
|
|
f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}",
|
|
exc_info=True,
|
|
)
|
|
|
|
logger.debug(f"Finished parsing. Extracted {len(routes)} HTTP routes.")
|
|
return routes
|
|
|
|
|
|
def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None:
|
|
"""
|
|
Clean up a schema dictionary for display by removing internal/complex fields.
|
|
"""
|
|
if not schema or not isinstance(schema, dict):
|
|
return schema
|
|
|
|
# Make a copy to avoid modifying the input schema
|
|
cleaned = schema.copy()
|
|
|
|
# Fields commonly removed for simpler display to LLMs or users
|
|
fields_to_remove = [
|
|
"allOf",
|
|
"anyOf",
|
|
"oneOf",
|
|
"not", # Composition keywords
|
|
"nullable", # Handled by type unions usually
|
|
"discriminator",
|
|
"readOnly",
|
|
"writeOnly",
|
|
"deprecated",
|
|
"xml",
|
|
"externalDocs",
|
|
# Can be verbose, maybe remove based on flag?
|
|
# "pattern", "minLength", "maxLength",
|
|
# "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum",
|
|
# "multipleOf", "minItems", "maxItems", "uniqueItems",
|
|
# "minProperties", "maxProperties"
|
|
]
|
|
|
|
for field in fields_to_remove:
|
|
if field in cleaned:
|
|
cleaned.pop(field)
|
|
|
|
# Recursively clean properties and items
|
|
if "properties" in cleaned:
|
|
cleaned["properties"] = {
|
|
k: clean_schema_for_display(v) for k, v in cleaned["properties"].items()
|
|
}
|
|
# Remove properties section if empty after cleaning
|
|
if not cleaned["properties"]:
|
|
cleaned.pop("properties")
|
|
|
|
if "items" in cleaned:
|
|
cleaned["items"] = clean_schema_for_display(cleaned["items"])
|
|
# Remove items section if empty after cleaning
|
|
if not cleaned["items"]:
|
|
cleaned.pop("items")
|
|
|
|
if "additionalProperties" in cleaned:
|
|
# Often verbose, can be simplified
|
|
if isinstance(cleaned["additionalProperties"], dict):
|
|
cleaned["additionalProperties"] = clean_schema_for_display(
|
|
cleaned["additionalProperties"]
|
|
)
|
|
elif cleaned["additionalProperties"] is True:
|
|
# Maybe keep 'true' or represent as 'Allows additional properties' text?
|
|
pass # Keep simple boolean for now
|
|
|
|
|
|
def generate_example_from_schema(schema: JsonSchema | None) -> Any:
|
|
"""
|
|
Generate a simple example value from a JSON schema dictionary.
|
|
Very basic implementation focusing on types.
|
|
"""
|
|
if not schema or not isinstance(schema, dict):
|
|
return "unknown" # Or None?
|
|
|
|
# Use default value if provided
|
|
if "default" in schema:
|
|
return schema["default"]
|
|
# Use first enum value if provided
|
|
if "enum" in schema and isinstance(schema["enum"], list) and schema["enum"]:
|
|
return schema["enum"][0]
|
|
# Use first example if provided
|
|
if (
|
|
"examples" in schema
|
|
and isinstance(schema["examples"], list)
|
|
and schema["examples"]
|
|
):
|
|
return schema["examples"][0]
|
|
if "example" in schema:
|
|
return schema["example"]
|
|
|
|
schema_type = schema.get("type")
|
|
|
|
if schema_type == "object":
|
|
result = {}
|
|
properties = schema.get("properties", {})
|
|
if isinstance(properties, dict):
|
|
# Generate example for first few properties or required ones? Limit complexity.
|
|
required_props = set(schema.get("required", []))
|
|
props_to_include = list(properties.keys())[
|
|
:3
|
|
] # Limit to first 3 for brevity
|
|
for prop_name in props_to_include:
|
|
if prop_name in properties:
|
|
result[prop_name] = generate_example_from_schema(
|
|
properties[prop_name]
|
|
)
|
|
# Ensure required props are present if possible
|
|
for req_prop in required_props:
|
|
if req_prop not in result and req_prop in properties:
|
|
result[req_prop] = generate_example_from_schema(
|
|
properties[req_prop]
|
|
)
|
|
return result if result else {"key": "value"} # Basic object if no props
|
|
|
|
elif schema_type == "array":
|
|
items_schema = schema.get("items")
|
|
if isinstance(items_schema, dict):
|
|
# Generate one example item
|
|
item_example = generate_example_from_schema(items_schema)
|
|
return [item_example] if item_example is not None else []
|
|
return ["example_item"] # Fallback
|
|
|
|
elif schema_type == "string":
|
|
format_type = schema.get("format")
|
|
if format_type == "date-time":
|
|
return "2024-01-01T12:00:00Z"
|
|
if format_type == "date":
|
|
return "2024-01-01"
|
|
if format_type == "email":
|
|
return "user@example.com"
|
|
if format_type == "uuid":
|
|
return "123e4567-e89b-12d3-a456-426614174000"
|
|
if format_type == "byte":
|
|
return "ZXhhbXBsZQ==" # "example" base64
|
|
return "string"
|
|
|
|
elif schema_type == "integer":
|
|
return 1
|
|
elif schema_type == "number":
|
|
return 1.5
|
|
elif schema_type == "boolean":
|
|
return True
|
|
elif schema_type == "null":
|
|
return None
|
|
|
|
# Fallback if type is unknown or missing
|
|
return "unknown_type"
|
|
|
|
|
|
def format_json_for_description(data: Any, indent: int = 2) -> str:
|
|
"""Formats Python data as a JSON string block for markdown."""
|
|
try:
|
|
json_str = json.dumps(data, indent=indent)
|
|
return f"```json\n{json_str}\n```"
|
|
except TypeError:
|
|
return f"```\nCould not serialize to JSON: {data}\n```"
|
|
|
|
|
|
def format_description_with_responses(
|
|
base_description: str,
|
|
responses: dict[
|
|
str, Any
|
|
], # Changed from specific ResponseInfo type to avoid circular imports
|
|
parameters: list[ParameterInfo] | None = None, # Add parameters parameter
|
|
request_body: RequestBodyInfo | None = None, # Add request_body parameter
|
|
) -> str:
|
|
"""
|
|
Formats the base description string with response, parameter, and request body information.
|
|
|
|
Args:
|
|
base_description (str): The initial description to be formatted.
|
|
responses (dict[str, Any]): A dictionary of response information, keyed by status code.
|
|
parameters (list[ParameterInfo] | None, optional): A list of parameter information,
|
|
including path and query parameters. Each parameter includes details such as name,
|
|
location, whether it is required, and a description.
|
|
request_body (RequestBodyInfo | None, optional): Information about the request body,
|
|
including its description, whether it is required, and its content schema.
|
|
|
|
Returns:
|
|
str: The formatted description string with additional details about responses, parameters,
|
|
and the request body.
|
|
"""
|
|
desc_parts = [base_description]
|
|
|
|
# Add parameter information
|
|
if parameters:
|
|
# Process path parameters
|
|
path_params = [p for p in parameters if p.location == "path"]
|
|
if path_params:
|
|
param_section = "\n\n**Path Parameters:**"
|
|
desc_parts.append(param_section)
|
|
for param in path_params:
|
|
required_marker = " (Required)" if param.required else ""
|
|
param_desc = f"\n- **{param.name}**{required_marker}: {param.description or 'No description.'}"
|
|
desc_parts.append(param_desc)
|
|
|
|
# Process query parameters
|
|
query_params = [p for p in parameters if p.location == "query"]
|
|
if query_params:
|
|
param_section = "\n\n**Query Parameters:**"
|
|
desc_parts.append(param_section)
|
|
for param in query_params:
|
|
required_marker = " (Required)" if param.required else ""
|
|
param_desc = f"\n- **{param.name}**{required_marker}: {param.description or 'No description.'}"
|
|
desc_parts.append(param_desc)
|
|
|
|
# Add request body information if present
|
|
if request_body and request_body.description:
|
|
req_body_section = "\n\n**Request Body:**"
|
|
desc_parts.append(req_body_section)
|
|
required_marker = " (Required)" if request_body.required else ""
|
|
desc_parts.append(f"\n{request_body.description}{required_marker}")
|
|
|
|
# Add request body property descriptions if available
|
|
if request_body.content_schema:
|
|
media_type = (
|
|
"application/json"
|
|
if "application/json" in request_body.content_schema
|
|
else next(iter(request_body.content_schema), None)
|
|
)
|
|
if media_type:
|
|
schema = request_body.content_schema.get(media_type, {})
|
|
if isinstance(schema, dict) and "properties" in schema:
|
|
desc_parts.append("\n\n**Request Properties:**")
|
|
for prop_name, prop_schema in schema["properties"].items():
|
|
if (
|
|
isinstance(prop_schema, dict)
|
|
and "description" in prop_schema
|
|
):
|
|
required = prop_name in schema.get("required", [])
|
|
req_mark = " (Required)" if required else ""
|
|
desc_parts.append(
|
|
f"\n- **{prop_name}**{req_mark}: {prop_schema['description']}"
|
|
)
|
|
|
|
# Add response information
|
|
if responses:
|
|
response_section = "\n\n**Responses:**"
|
|
added_response_section = False
|
|
|
|
# Determine success codes (common ones)
|
|
success_codes = {"200", "201", "202", "204"} # As strings
|
|
success_status = next((s for s in success_codes if s in responses), None)
|
|
|
|
# Process all responses
|
|
responses_to_process = responses.items()
|
|
|
|
for status_code, resp_info in sorted(responses_to_process):
|
|
if not added_response_section:
|
|
desc_parts.append(response_section)
|
|
added_response_section = True
|
|
|
|
status_marker = " (Success)" if status_code == success_status else ""
|
|
desc_parts.append(
|
|
f"\n- **{status_code}**{status_marker}: {resp_info.description or 'No description.'}"
|
|
)
|
|
|
|
# Process content schemas for this response
|
|
if resp_info.content_schema:
|
|
# Prioritize json, then take first available
|
|
media_type = (
|
|
"application/json"
|
|
if "application/json" in resp_info.content_schema
|
|
else next(iter(resp_info.content_schema), None)
|
|
)
|
|
|
|
if media_type:
|
|
schema = resp_info.content_schema.get(media_type)
|
|
desc_parts.append(f" - Content-Type: `{media_type}`")
|
|
|
|
# Add response property descriptions
|
|
if isinstance(schema, dict):
|
|
# Handle array responses
|
|
if schema.get("type") == "array" and "items" in schema:
|
|
items_schema = schema["items"]
|
|
if (
|
|
isinstance(items_schema, dict)
|
|
and "properties" in items_schema
|
|
):
|
|
desc_parts.append("\n - **Response Item Properties:**")
|
|
for prop_name, prop_schema in items_schema[
|
|
"properties"
|
|
].items():
|
|
if (
|
|
isinstance(prop_schema, dict)
|
|
and "description" in prop_schema
|
|
):
|
|
desc_parts.append(
|
|
f"\n - **{prop_name}**: {prop_schema['description']}"
|
|
)
|
|
# Handle object responses
|
|
elif "properties" in schema:
|
|
desc_parts.append("\n - **Response Properties:**")
|
|
for prop_name, prop_schema in schema["properties"].items():
|
|
if (
|
|
isinstance(prop_schema, dict)
|
|
and "description" in prop_schema
|
|
):
|
|
desc_parts.append(
|
|
f"\n - **{prop_name}**: {prop_schema['description']}"
|
|
)
|
|
|
|
# Generate Example
|
|
if schema:
|
|
example = generate_example_from_schema(schema)
|
|
if example != "unknown_type" and example is not None:
|
|
desc_parts.append("\n - **Example:**")
|
|
desc_parts.append(
|
|
format_json_for_description(example, indent=2)
|
|
)
|
|
|
|
return "\n".join(desc_parts)
|
|
|
|
|
|
def _replace_ref_with_defs(
|
|
info: dict[str, Any], description: str | None = None
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Replace openapi $ref with jsonschema $defs
|
|
|
|
Examples:
|
|
- {"type": "object", "properties": {"$ref": "#/components/schemas/..."}}
|
|
- {"$ref": "#/components/schemas/..."}
|
|
- {"items": {"$ref": "#/components/schemas/..."}}
|
|
- {"anyOf": [{"$ref": "#/components/schemas/..."}]}
|
|
- {"allOf": [{"$ref": "#/components/schemas/..."}]}
|
|
- {"oneOf": [{"$ref": "#/components/schemas/..."}]}
|
|
|
|
Args:
|
|
info: dict[str, Any]
|
|
description: str | None
|
|
|
|
Returns:
|
|
dict[str, Any]
|
|
"""
|
|
schema = info.copy()
|
|
if ref_path := schema.get("$ref"):
|
|
if isinstance(ref_path, str):
|
|
if ref_path.startswith("#/components/schemas/"):
|
|
schema_name = ref_path.split("/")[-1]
|
|
schema["$ref"] = f"#/$defs/{schema_name}"
|
|
elif not ref_path.startswith("#/"):
|
|
raise ValueError(
|
|
f"External or non-local reference not supported: {ref_path}. "
|
|
f"FastMCP only supports local schema references starting with '#/'. "
|
|
f"Please include all schema definitions within the OpenAPI document."
|
|
)
|
|
elif properties := schema.get("properties"):
|
|
if "$ref" in properties:
|
|
schema["properties"] = _replace_ref_with_defs(properties)
|
|
else:
|
|
schema["properties"] = {
|
|
prop_name: _replace_ref_with_defs(prop_schema)
|
|
for prop_name, prop_schema in properties.items()
|
|
}
|
|
elif item_schema := schema.get("items"):
|
|
schema["items"] = _replace_ref_with_defs(item_schema)
|
|
for section in ["anyOf", "allOf", "oneOf"]:
|
|
for i, item in enumerate(schema.get(section, [])):
|
|
schema[section][i] = _replace_ref_with_defs(item)
|
|
if info.get("description", description) and not schema.get("description"):
|
|
schema["description"] = description
|
|
return schema
|
|
|
|
|
|
def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
|
|
"""
|
|
Make an optional parameter schema nullable to allow None values.
|
|
|
|
For optional parameters, we need to allow null values in addition to the
|
|
specified type to handle cases where None is passed for optional parameters.
|
|
"""
|
|
# If schema already has multiple types or is already nullable, don't modify
|
|
if "anyOf" in schema or "oneOf" in schema or "allOf" in schema:
|
|
return schema
|
|
|
|
# If it's already nullable (type includes null), don't modify
|
|
if isinstance(schema.get("type"), list) and "null" in schema["type"]:
|
|
return schema
|
|
|
|
# Create a new schema that allows null in addition to the original type
|
|
if "type" in schema:
|
|
original_type = schema["type"]
|
|
|
|
if isinstance(original_type, str):
|
|
# Single type - make it a union with null
|
|
# Optimize: avoid full schema copy by building directly
|
|
nested_non_nullable_schema = {
|
|
"type": original_type,
|
|
}
|
|
nullable_schema = {}
|
|
|
|
# Define type-specific properties that should move to nested schema
|
|
type_specific_properties = set()
|
|
if original_type == "array":
|
|
# https://json-schema.org/understanding-json-schema/reference/array
|
|
type_specific_properties = {
|
|
"items",
|
|
"prefixItems",
|
|
"unevaluatedItems",
|
|
"contains",
|
|
"minContains",
|
|
"maxContains",
|
|
"minItems",
|
|
"maxItems",
|
|
"uniqueItems",
|
|
}
|
|
elif original_type == "object":
|
|
# https://json-schema.org/understanding-json-schema/reference/object
|
|
type_specific_properties = {
|
|
"properties",
|
|
"patternProperties",
|
|
"additionalProperties",
|
|
"unevaluatedProperties",
|
|
"required",
|
|
"propertyNames",
|
|
"minProperties",
|
|
"maxProperties",
|
|
}
|
|
|
|
# Efficiently distribute properties without copying the entire schema
|
|
for key, value in schema.items():
|
|
if key == "type":
|
|
continue # Already handled
|
|
elif key in type_specific_properties:
|
|
nested_non_nullable_schema[key] = value
|
|
else:
|
|
nullable_schema[key] = value
|
|
|
|
nullable_schema["anyOf"] = [nested_non_nullable_schema, {"type": "null"}]
|
|
return nullable_schema
|
|
|
|
return schema
|
|
|
|
|
|
def _add_null_to_type(schema: dict[str, Any]) -> None:
|
|
"""Add 'null' to the schema's type field or handle oneOf/anyOf/allOf constructs if not already present."""
|
|
if "type" in schema:
|
|
current_type = schema["type"]
|
|
|
|
if isinstance(current_type, str):
|
|
# Convert string type to array with null
|
|
schema["type"] = [current_type, "null"]
|
|
elif isinstance(current_type, list):
|
|
# Add null to array if not already present
|
|
if "null" not in current_type:
|
|
schema["type"] = current_type + ["null"]
|
|
elif "oneOf" in schema:
|
|
# Convert oneOf to anyOf with null type
|
|
schema["anyOf"] = schema.pop("oneOf") + [{"type": "null"}]
|
|
elif "anyOf" in schema:
|
|
# Add null type to anyOf if not already present
|
|
if not any(item.get("type") == "null" for item in schema["anyOf"]):
|
|
schema["anyOf"].append({"type": "null"})
|
|
elif "allOf" in schema:
|
|
# For allOf, wrap in anyOf with null - this means (all conditions) OR null
|
|
schema["anyOf"] = [{"allOf": schema.pop("allOf")}, {"type": "null"}]
|
|
|
|
|
|
def _handle_nullable_fields(schema: dict[str, Any] | Any) -> dict[str, Any] | Any:
|
|
"""Convert OpenAPI nullable fields to JSON Schema format: {"type": "string",
|
|
"nullable": true} -> {"type": ["string", "null"]}"""
|
|
|
|
if not isinstance(schema, dict):
|
|
return schema
|
|
|
|
# Check if we need to modify anything first to avoid unnecessary copying
|
|
has_root_nullable_field = "nullable" in schema
|
|
has_root_nullable_true = (
|
|
has_root_nullable_field
|
|
and schema["nullable"]
|
|
and (
|
|
"type" in schema
|
|
or "oneOf" in schema
|
|
or "anyOf" in schema
|
|
or "allOf" in schema
|
|
)
|
|
)
|
|
|
|
has_property_nullable_field = False
|
|
if "properties" in schema:
|
|
for prop_schema in schema["properties"].values():
|
|
if isinstance(prop_schema, dict) and "nullable" in prop_schema:
|
|
has_property_nullable_field = True
|
|
break
|
|
|
|
# If no nullable fields at all, return original schema unchanged
|
|
if not has_root_nullable_field and not has_property_nullable_field:
|
|
return schema
|
|
|
|
# Only copy if we need to modify
|
|
result = schema.copy()
|
|
|
|
# Handle root level nullable - always remove the field, convert type if true
|
|
if has_root_nullable_field:
|
|
result.pop("nullable")
|
|
if has_root_nullable_true:
|
|
_add_null_to_type(result)
|
|
|
|
# Handle properties nullable fields
|
|
if has_property_nullable_field and "properties" in result:
|
|
for prop_name, prop_schema in result["properties"].items():
|
|
if isinstance(prop_schema, dict) and "nullable" in prop_schema:
|
|
nullable_value = prop_schema.pop("nullable")
|
|
if nullable_value and (
|
|
"type" in prop_schema
|
|
or "oneOf" in prop_schema
|
|
or "anyOf" in prop_schema
|
|
or "allOf" in prop_schema
|
|
):
|
|
_add_null_to_type(prop_schema)
|
|
|
|
return result
|
|
|
|
|
|
def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
|
|
"""
|
|
Combines parameter and request body schemas into a single schema.
|
|
Handles parameter name collisions by adding location suffixes.
|
|
|
|
Args:
|
|
route: HTTPRoute object
|
|
|
|
Returns:
|
|
Combined schema dictionary
|
|
"""
|
|
properties = {}
|
|
required = []
|
|
|
|
# First pass: collect parameter names by location and body properties
|
|
param_names_by_location = {
|
|
"path": set(),
|
|
"query": set(),
|
|
"header": set(),
|
|
"cookie": set(),
|
|
}
|
|
body_props = {}
|
|
|
|
for param in route.parameters:
|
|
param_names_by_location[param.location].add(param.name)
|
|
|
|
if route.request_body and route.request_body.content_schema:
|
|
content_type = next(iter(route.request_body.content_schema))
|
|
body_schema = _replace_ref_with_defs(
|
|
route.request_body.content_schema[content_type].copy(),
|
|
route.request_body.description,
|
|
)
|
|
body_props = body_schema.get("properties", {})
|
|
|
|
# Detect collisions: parameters that exist in both body and path/query/header
|
|
all_non_body_params = set()
|
|
for location_params in param_names_by_location.values():
|
|
all_non_body_params.update(location_params)
|
|
|
|
body_param_names = set(body_props.keys())
|
|
colliding_params = all_non_body_params & body_param_names
|
|
|
|
# Add parameters with suffixes for collisions
|
|
for param in route.parameters:
|
|
if param.name in colliding_params:
|
|
# Add suffix for non-body parameters when collision detected
|
|
suffixed_name = f"{param.name}__{param.location}"
|
|
if param.required:
|
|
required.append(suffixed_name)
|
|
|
|
# Add location info to description
|
|
param_schema = _replace_ref_with_defs(
|
|
param.schema_.copy(), param.description
|
|
)
|
|
original_desc = param_schema.get("description", "")
|
|
location_desc = f"({param.location.capitalize()} parameter)"
|
|
if original_desc:
|
|
param_schema["description"] = f"{original_desc} {location_desc}"
|
|
else:
|
|
param_schema["description"] = location_desc
|
|
|
|
# Don't make optional parameters nullable - they can simply be omitted
|
|
# The OpenAPI specification doesn't require optional parameters to accept null values
|
|
|
|
properties[suffixed_name] = param_schema
|
|
else:
|
|
# No collision, use original name
|
|
if param.required:
|
|
required.append(param.name)
|
|
param_schema = _replace_ref_with_defs(
|
|
param.schema_.copy(), param.description
|
|
)
|
|
|
|
# Don't make optional parameters nullable - they can simply be omitted
|
|
# The OpenAPI specification doesn't require optional parameters to accept null values
|
|
|
|
properties[param.name] = param_schema
|
|
|
|
# Add request body properties (no suffixes for body parameters)
|
|
if route.request_body and route.request_body.content_schema:
|
|
for prop_name, prop_schema in body_props.items():
|
|
properties[prop_name] = prop_schema
|
|
|
|
if route.request_body.required:
|
|
required.extend(body_schema.get("required", []))
|
|
|
|
result = {
|
|
"type": "object",
|
|
"properties": properties,
|
|
"required": required,
|
|
}
|
|
# Add schema definitions if available
|
|
if route.schema_definitions:
|
|
result["$defs"] = route.schema_definitions.copy()
|
|
|
|
# Use lightweight compression - prune additionalProperties and unused definitions
|
|
if result.get("additionalProperties") is False:
|
|
result.pop("additionalProperties")
|
|
|
|
# Remove unused definitions (lightweight approach - just check direct $ref usage)
|
|
if "$defs" in result:
|
|
used_refs = set()
|
|
|
|
def find_refs_in_value(value):
|
|
if isinstance(value, dict):
|
|
if "$ref" in value and isinstance(value["$ref"], str):
|
|
ref = value["$ref"]
|
|
if ref.startswith("#/$defs/"):
|
|
used_refs.add(ref.split("/")[-1])
|
|
for v in value.values():
|
|
find_refs_in_value(v)
|
|
elif isinstance(value, list):
|
|
for item in value:
|
|
find_refs_in_value(item)
|
|
|
|
# Find refs in the main schema (excluding $defs section)
|
|
for key, value in result.items():
|
|
if key != "$defs":
|
|
find_refs_in_value(value)
|
|
|
|
# Remove unused definitions
|
|
if used_refs:
|
|
result["$defs"] = {
|
|
name: def_schema
|
|
for name, def_schema in result["$defs"].items()
|
|
if name in used_refs
|
|
}
|
|
else:
|
|
result.pop("$defs")
|
|
|
|
return result
|
|
|
|
|
|
def _adjust_union_types(
|
|
schema: dict[str, Any] | list[Any],
|
|
) -> dict[str, Any] | list[Any]:
|
|
"""Recursively replace 'oneOf' with 'anyOf' in schema to handle overlapping unions."""
|
|
if isinstance(schema, dict):
|
|
# Optimize: only copy if we need to modify something
|
|
has_one_of = "oneOf" in schema
|
|
needs_recursive_processing = False
|
|
|
|
# Check if we need recursive processing
|
|
for v in schema.values():
|
|
if isinstance(v, dict | list):
|
|
needs_recursive_processing = True
|
|
break
|
|
|
|
# If nothing to change, return original
|
|
if not has_one_of and not needs_recursive_processing:
|
|
return schema
|
|
|
|
# Work on a copy only when modification is needed
|
|
result = schema.copy()
|
|
if has_one_of:
|
|
result["anyOf"] = result.pop("oneOf")
|
|
|
|
# Only recurse where needed
|
|
if needs_recursive_processing:
|
|
for k, v in result.items():
|
|
if isinstance(v, dict | list):
|
|
result[k] = _adjust_union_types(v)
|
|
|
|
return result
|
|
elif isinstance(schema, list):
|
|
return [_adjust_union_types(item) for item in schema]
|
|
return schema
|
|
|
|
|
|
def extract_output_schema_from_responses(
|
|
responses: dict[str, ResponseInfo],
|
|
schema_definitions: dict[str, Any] | None = None,
|
|
openapi_version: str | None = None,
|
|
) -> dict[str, Any] | None:
|
|
"""
|
|
Extract output schema from OpenAPI responses for use as MCP tool output schema.
|
|
|
|
This function finds the first successful response (200, 201, 202, 204) with a
|
|
JSON-compatible content type and extracts its schema. If the schema is not an
|
|
object type, it wraps it to comply with MCP requirements.
|
|
|
|
Args:
|
|
responses: Dictionary of ResponseInfo objects keyed by status code
|
|
schema_definitions: Optional schema definitions to include in the output schema
|
|
openapi_version: OpenAPI version string, used to optimize nullable field handling
|
|
|
|
Returns:
|
|
dict: MCP-compliant output schema with potential wrapping, or None if no suitable schema found
|
|
"""
|
|
if not responses:
|
|
return None
|
|
|
|
# Priority order for success status codes
|
|
success_codes = ["200", "201", "202", "204"]
|
|
|
|
# Find the first successful response
|
|
response_info = None
|
|
for status_code in success_codes:
|
|
if status_code in responses:
|
|
response_info = responses[status_code]
|
|
break
|
|
|
|
# If no explicit success codes, try any 2xx response
|
|
if response_info is None:
|
|
for status_code, resp_info in responses.items():
|
|
if status_code.startswith("2"):
|
|
response_info = resp_info
|
|
break
|
|
|
|
if response_info is None or not response_info.content_schema:
|
|
return None
|
|
|
|
# Prefer application/json, then fall back to other JSON-compatible types
|
|
json_compatible_types = [
|
|
"application/json",
|
|
"application/vnd.api+json",
|
|
"application/hal+json",
|
|
"application/ld+json",
|
|
"text/json",
|
|
]
|
|
|
|
schema = None
|
|
for content_type in json_compatible_types:
|
|
if content_type in response_info.content_schema:
|
|
schema = response_info.content_schema[content_type]
|
|
break
|
|
|
|
# If no JSON-compatible type found, try the first available content type
|
|
if schema is None and response_info.content_schema:
|
|
first_content_type = next(iter(response_info.content_schema))
|
|
schema = response_info.content_schema[first_content_type]
|
|
logger.debug(
|
|
f"Using non-JSON content type for output schema: {first_content_type}"
|
|
)
|
|
|
|
if not schema or not isinstance(schema, dict):
|
|
return None
|
|
|
|
# Clean and copy the schema
|
|
output_schema = schema.copy()
|
|
|
|
# If schema has a $ref, resolve it first before processing nullable fields
|
|
if "$ref" in output_schema and schema_definitions:
|
|
ref_path = output_schema["$ref"]
|
|
if ref_path.startswith("#/components/schemas/"):
|
|
schema_name = ref_path.split("/")[-1]
|
|
if schema_name in schema_definitions:
|
|
# Replace $ref with the actual schema definition
|
|
output_schema = schema_definitions[schema_name].copy()
|
|
|
|
# Handle OpenAPI nullable fields by converting them to JSON Schema format
|
|
# This prevents "None is not of type 'string'" validation errors
|
|
# Only needed for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types
|
|
if openapi_version and openapi_version.startswith("3.0"):
|
|
output_schema = _handle_nullable_fields(output_schema)
|
|
|
|
# MCP requires output schemas to be objects. If this schema is not an object,
|
|
# we need to wrap it similar to how ParsedFunction.from_function() does it
|
|
if output_schema.get("type") != "object":
|
|
# Create a wrapped schema that contains the original schema under a "result" key
|
|
wrapped_schema = {
|
|
"type": "object",
|
|
"properties": {"result": output_schema},
|
|
"required": ["result"],
|
|
"x-fastmcp-wrap-result": True,
|
|
}
|
|
output_schema = wrapped_schema
|
|
|
|
# Add schema definitions if available and handle nullable fields in them
|
|
# Only add $defs if we didn't resolve the $ref inline above
|
|
if schema_definitions and "$ref" not in schema.copy():
|
|
processed_defs = {}
|
|
for def_name, def_schema in schema_definitions.items():
|
|
# Only handle nullable fields for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types
|
|
if openapi_version and openapi_version.startswith("3.0"):
|
|
processed_defs[def_name] = _handle_nullable_fields(def_schema)
|
|
else:
|
|
processed_defs[def_name] = def_schema
|
|
output_schema["$defs"] = processed_defs
|
|
|
|
# Use lightweight compression - prune additionalProperties and unused definitions
|
|
if output_schema.get("additionalProperties") is False:
|
|
output_schema.pop("additionalProperties")
|
|
|
|
# Remove unused definitions (lightweight approach - just check direct $ref usage)
|
|
if "$defs" in output_schema:
|
|
used_refs = set()
|
|
|
|
def find_refs_in_value(value):
|
|
if isinstance(value, dict):
|
|
if "$ref" in value and isinstance(value["$ref"], str):
|
|
ref = value["$ref"]
|
|
if ref.startswith("#/$defs/"):
|
|
used_refs.add(ref.split("/")[-1])
|
|
for v in value.values():
|
|
find_refs_in_value(v)
|
|
elif isinstance(value, list):
|
|
for item in value:
|
|
find_refs_in_value(item)
|
|
|
|
# Find refs in the main schema (excluding $defs section)
|
|
for key, value in output_schema.items():
|
|
if key != "$defs":
|
|
find_refs_in_value(value)
|
|
|
|
# Remove unused definitions
|
|
if used_refs:
|
|
output_schema["$defs"] = {
|
|
name: def_schema
|
|
for name, def_schema in output_schema["$defs"].items()
|
|
if name in used_refs
|
|
}
|
|
else:
|
|
output_schema.pop("$defs")
|
|
|
|
# Adjust union types to handle overlapping unions
|
|
output_schema = cast(dict[str, Any], _adjust_union_types(output_schema))
|
|
|
|
return output_schema
|