Spaces:
Running
Running
Jeremiah Lowin commited on
Improve OpenAPI-to-JSONSchema conversion utilities (#1283)
Browse files- src/fastmcp/experimental/server/openapi/server.py +3 -3
- src/fastmcp/experimental/utilities/openapi/README.md +5 -5
- src/fastmcp/experimental/utilities/openapi/__init__.py +9 -2
- src/fastmcp/experimental/utilities/openapi/director.py +4 -4
- src/fastmcp/experimental/utilities/openapi/json_schema_converter.py +339 -0
- src/fastmcp/experimental/utilities/openapi/models.py +1 -0
- src/fastmcp/experimental/utilities/openapi/parser.py +5 -0
- src/fastmcp/experimental/utilities/openapi/schemas.py +22 -151
- src/fastmcp/server/openapi.py +1 -1
- src/fastmcp/utilities/openapi.py +18 -3
- tests/experimental/utilities/openapi/test_director.py +7 -7
- tests/experimental/utilities/openapi/test_nullable_fields.py +27 -25
src/fastmcp/experimental/server/openapi/server.py
CHANGED
|
@@ -5,7 +5,7 @@ from collections import Counter
|
|
| 5 |
from typing import Any, Literal
|
| 6 |
|
| 7 |
import httpx
|
| 8 |
-
from
|
| 9 |
|
| 10 |
# Import from our new utilities and components
|
| 11 |
from fastmcp.experimental.utilities.openapi import (
|
|
@@ -149,7 +149,7 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 149 |
|
| 150 |
# Create openapi-core Spec and RequestDirector for stateless request building
|
| 151 |
try:
|
| 152 |
-
self._spec =
|
| 153 |
self._director = RequestDirector(self._spec)
|
| 154 |
logger.debug(
|
| 155 |
"Initialized OpenAPI RequestDirector for stateless request building"
|
|
@@ -270,7 +270,7 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 270 |
|
| 271 |
# Extract output schema from OpenAPI responses
|
| 272 |
output_schema = extract_output_schema_from_responses(
|
| 273 |
-
route.responses, route.schema_definitions
|
| 274 |
)
|
| 275 |
|
| 276 |
# Get a unique tool name
|
|
|
|
| 5 |
from typing import Any, Literal
|
| 6 |
|
| 7 |
import httpx
|
| 8 |
+
from jsonschema_path import SchemaPath
|
| 9 |
|
| 10 |
# Import from our new utilities and components
|
| 11 |
from fastmcp.experimental.utilities.openapi import (
|
|
|
|
| 149 |
|
| 150 |
# Create openapi-core Spec and RequestDirector for stateless request building
|
| 151 |
try:
|
| 152 |
+
self._spec = SchemaPath.from_dict(openapi_spec) # type: ignore[arg-type]
|
| 153 |
self._director = RequestDirector(self._spec)
|
| 154 |
logger.debug(
|
| 155 |
"Initialized OpenAPI RequestDirector for stateless request building"
|
|
|
|
| 270 |
|
| 271 |
# Extract output schema from OpenAPI responses
|
| 272 |
output_schema = extract_output_schema_from_responses(
|
| 273 |
+
route.responses, route.schema_definitions, route.openapi_version
|
| 274 |
)
|
| 275 |
|
| 276 |
# Get a unique tool name
|
src/fastmcp/experimental/utilities/openapi/README.md
CHANGED
|
@@ -36,13 +36,13 @@ The new implementation follows a **stateless request building strategy** using `
|
|
| 36 |
### Initialization Process
|
| 37 |
|
| 38 |
```
|
| 39 |
-
OpenAPI Spec → Parser → HTTPRoute with Pre-calculated Fields → RequestDirector +
|
| 40 |
```
|
| 41 |
|
| 42 |
1. **Input**: Raw OpenAPI specification (dict)
|
| 43 |
2. **Parsing**: Extract operations to `HTTPRoute` models
|
| 44 |
3. **Pre-calculation**: Generate combined schemas and parameter maps during parsing
|
| 45 |
-
4. **Director Setup**: Create `RequestDirector` with `
|
| 46 |
|
| 47 |
### Request Processing
|
| 48 |
|
|
@@ -125,10 +125,10 @@ async with httpx.AsyncClient() as client:
|
|
| 125 |
|
| 126 |
```python
|
| 127 |
from fastmcp.experimental.utilities.openapi.director import RequestDirector
|
| 128 |
-
from
|
| 129 |
|
| 130 |
# Create RequestDirector manually
|
| 131 |
-
spec =
|
| 132 |
director = RequestDirector(spec)
|
| 133 |
|
| 134 |
# Build HTTP request
|
|
@@ -210,7 +210,7 @@ Tests are located in `/tests/server/openapi_new/`:
|
|
| 210 |
### Common Issues
|
| 211 |
|
| 212 |
1. **RequestDirector Initialization Fails**
|
| 213 |
-
- Check OpenAPI spec validity with `
|
| 214 |
- Verify spec format is correct JSON/YAML
|
| 215 |
- Ensure all required OpenAPI fields are present
|
| 216 |
|
|
|
|
| 36 |
### Initialization Process
|
| 37 |
|
| 38 |
```
|
| 39 |
+
OpenAPI Spec → Parser → HTTPRoute with Pre-calculated Fields → RequestDirector + SchemaPath
|
| 40 |
```
|
| 41 |
|
| 42 |
1. **Input**: Raw OpenAPI specification (dict)
|
| 43 |
2. **Parsing**: Extract operations to `HTTPRoute` models
|
| 44 |
3. **Pre-calculation**: Generate combined schemas and parameter maps during parsing
|
| 45 |
+
4. **Director Setup**: Create `RequestDirector` with `SchemaPath` for request building
|
| 46 |
|
| 47 |
### Request Processing
|
| 48 |
|
|
|
|
| 125 |
|
| 126 |
```python
|
| 127 |
from fastmcp.experimental.utilities.openapi.director import RequestDirector
|
| 128 |
+
from jsonschema_path import SchemaPath
|
| 129 |
|
| 130 |
# Create RequestDirector manually
|
| 131 |
+
spec = SchemaPath.from_dict(openapi_spec)
|
| 132 |
director = RequestDirector(spec)
|
| 133 |
|
| 134 |
# Build HTTP request
|
|
|
|
| 210 |
### Common Issues
|
| 211 |
|
| 212 |
1. **RequestDirector Initialization Fails**
|
| 213 |
+
- Check OpenAPI spec validity with `jsonschema-path`
|
| 214 |
- Verify spec format is correct JSON/YAML
|
| 215 |
- Ensure all required OpenAPI fields are present
|
| 216 |
|
src/fastmcp/experimental/utilities/openapi/__init__.py
CHANGED
|
@@ -30,7 +30,12 @@ from .schemas import (
|
|
| 30 |
clean_schema_for_display,
|
| 31 |
_replace_ref_with_defs,
|
| 32 |
_make_optional_parameter_nullable,
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
)
|
| 35 |
|
| 36 |
# Export public symbols - maintaining backward compatibility
|
|
@@ -57,5 +62,7 @@ __all__ = [
|
|
| 57 |
"clean_schema_for_display",
|
| 58 |
"_replace_ref_with_defs",
|
| 59 |
"_make_optional_parameter_nullable",
|
| 60 |
-
|
|
|
|
|
|
|
| 61 |
]
|
|
|
|
| 30 |
clean_schema_for_display,
|
| 31 |
_replace_ref_with_defs,
|
| 32 |
_make_optional_parameter_nullable,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# Import from json_schema_converter
|
| 36 |
+
from .json_schema_converter import (
|
| 37 |
+
convert_openapi_schema_to_json_schema,
|
| 38 |
+
convert_schema_definitions,
|
| 39 |
)
|
| 40 |
|
| 41 |
# Export public symbols - maintaining backward compatibility
|
|
|
|
| 62 |
"clean_schema_for_display",
|
| 63 |
"_replace_ref_with_defs",
|
| 64 |
"_make_optional_parameter_nullable",
|
| 65 |
+
# JSON Schema Converter
|
| 66 |
+
"convert_openapi_schema_to_json_schema",
|
| 67 |
+
"convert_schema_definitions",
|
| 68 |
]
|
src/fastmcp/experimental/utilities/openapi/director.py
CHANGED
|
@@ -4,7 +4,7 @@ from typing import Any
|
|
| 4 |
from urllib.parse import urljoin
|
| 5 |
|
| 6 |
import httpx
|
| 7 |
-
from
|
| 8 |
|
| 9 |
from fastmcp.utilities.logging import get_logger
|
| 10 |
|
|
@@ -16,8 +16,8 @@ logger = get_logger(__name__)
|
|
| 16 |
class RequestDirector:
|
| 17 |
"""Builds httpx.Request objects from HTTPRoute and arguments using openapi-core."""
|
| 18 |
|
| 19 |
-
def __init__(self, spec:
|
| 20 |
-
"""Initialize with a parsed
|
| 21 |
self._spec = spec
|
| 22 |
|
| 23 |
def build(
|
|
@@ -66,7 +66,7 @@ class RequestDirector:
|
|
| 66 |
if isinstance(body, dict) or isinstance(body, list):
|
| 67 |
request_data["json"] = body
|
| 68 |
else:
|
| 69 |
-
request_data["
|
| 70 |
|
| 71 |
# Step 5: Create httpx.Request
|
| 72 |
return httpx.Request(**{k: v for k, v in request_data.items() if v is not None})
|
|
|
|
| 4 |
from urllib.parse import urljoin
|
| 5 |
|
| 6 |
import httpx
|
| 7 |
+
from jsonschema_path import SchemaPath
|
| 8 |
|
| 9 |
from fastmcp.utilities.logging import get_logger
|
| 10 |
|
|
|
|
| 16 |
class RequestDirector:
|
| 17 |
"""Builds httpx.Request objects from HTTPRoute and arguments using openapi-core."""
|
| 18 |
|
| 19 |
+
def __init__(self, spec: SchemaPath):
|
| 20 |
+
"""Initialize with a parsed SchemaPath object."""
|
| 21 |
self._spec = spec
|
| 22 |
|
| 23 |
def build(
|
|
|
|
| 66 |
if isinstance(body, dict) or isinstance(body, list):
|
| 67 |
request_data["json"] = body
|
| 68 |
else:
|
| 69 |
+
request_data["content"] = body
|
| 70 |
|
| 71 |
# Step 5: Create httpx.Request
|
| 72 |
return httpx.Request(**{k: v for k, v in request_data.items() if v is not None})
|
src/fastmcp/experimental/utilities/openapi/json_schema_converter.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Clean OpenAPI 3.0 to JSON Schema converter for the experimental parser.
|
| 3 |
+
|
| 4 |
+
This module provides a systematic approach to converting OpenAPI 3.0 schemas
|
| 5 |
+
to JSON Schema, inspired by py-openapi-schema-to-json-schema but optimized
|
| 6 |
+
for our specific use case.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
# OpenAPI-specific fields that should be removed from JSON Schema
|
| 15 |
+
OPENAPI_SPECIFIC_FIELDS = {
|
| 16 |
+
"nullable", # Handled by converting to type arrays
|
| 17 |
+
"discriminator", # OpenAPI-specific
|
| 18 |
+
"readOnly", # OpenAPI-specific metadata
|
| 19 |
+
"writeOnly", # OpenAPI-specific metadata
|
| 20 |
+
"xml", # OpenAPI-specific metadata
|
| 21 |
+
"externalDocs", # OpenAPI-specific metadata
|
| 22 |
+
"deprecated", # Can be kept but not part of JSON Schema core
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
# Fields that should be recursively processed
|
| 26 |
+
RECURSIVE_FIELDS = {
|
| 27 |
+
"properties": dict,
|
| 28 |
+
"items": dict,
|
| 29 |
+
"additionalProperties": dict,
|
| 30 |
+
"allOf": list,
|
| 31 |
+
"anyOf": list,
|
| 32 |
+
"oneOf": list,
|
| 33 |
+
"not": dict,
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def convert_openapi_schema_to_json_schema(
|
| 38 |
+
schema: dict[str, Any],
|
| 39 |
+
openapi_version: str | None = None,
|
| 40 |
+
remove_read_only: bool = False,
|
| 41 |
+
remove_write_only: bool = False,
|
| 42 |
+
convert_one_of_to_any_of: bool = True,
|
| 43 |
+
) -> dict[str, Any]:
|
| 44 |
+
"""
|
| 45 |
+
Convert an OpenAPI schema to JSON Schema format.
|
| 46 |
+
|
| 47 |
+
This is a clean, systematic approach that:
|
| 48 |
+
1. Removes OpenAPI-specific fields
|
| 49 |
+
2. Converts nullable fields to type arrays (for OpenAPI 3.0 only)
|
| 50 |
+
3. Converts oneOf to anyOf for overlapping union handling
|
| 51 |
+
4. Recursively processes nested schemas
|
| 52 |
+
5. Optionally removes readOnly/writeOnly properties
|
| 53 |
+
|
| 54 |
+
Args:
|
| 55 |
+
schema: OpenAPI schema dictionary
|
| 56 |
+
openapi_version: OpenAPI version for optimization
|
| 57 |
+
remove_read_only: Whether to remove readOnly properties
|
| 58 |
+
remove_write_only: Whether to remove writeOnly properties
|
| 59 |
+
convert_one_of_to_any_of: Whether to convert oneOf to anyOf
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
JSON Schema compatible dictionary
|
| 63 |
+
"""
|
| 64 |
+
if not isinstance(schema, dict):
|
| 65 |
+
return schema
|
| 66 |
+
|
| 67 |
+
# Early exit optimization - check if conversion is needed
|
| 68 |
+
needs_conversion = (
|
| 69 |
+
any(field in schema for field in OPENAPI_SPECIFIC_FIELDS)
|
| 70 |
+
or (remove_read_only and _has_read_only_properties(schema))
|
| 71 |
+
or (remove_write_only and _has_write_only_properties(schema))
|
| 72 |
+
or (convert_one_of_to_any_of and "oneOf" in schema)
|
| 73 |
+
or _needs_recursive_processing(
|
| 74 |
+
schema,
|
| 75 |
+
openapi_version,
|
| 76 |
+
remove_read_only,
|
| 77 |
+
remove_write_only,
|
| 78 |
+
convert_one_of_to_any_of,
|
| 79 |
+
)
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
if not needs_conversion:
|
| 83 |
+
return schema
|
| 84 |
+
|
| 85 |
+
# Work on a copy to avoid mutation
|
| 86 |
+
result = schema.copy()
|
| 87 |
+
|
| 88 |
+
# Step 1: Handle nullable field conversion (OpenAPI 3.0 only)
|
| 89 |
+
if openapi_version and openapi_version.startswith("3.0"):
|
| 90 |
+
result = _convert_nullable_field(result)
|
| 91 |
+
|
| 92 |
+
# Step 2: Convert oneOf to anyOf if requested
|
| 93 |
+
if convert_one_of_to_any_of and "oneOf" in result:
|
| 94 |
+
result["anyOf"] = result.pop("oneOf")
|
| 95 |
+
|
| 96 |
+
# Step 3: Remove OpenAPI-specific fields
|
| 97 |
+
for field in OPENAPI_SPECIFIC_FIELDS:
|
| 98 |
+
result.pop(field, None)
|
| 99 |
+
|
| 100 |
+
# Step 4: Handle readOnly/writeOnly property removal
|
| 101 |
+
if remove_read_only or remove_write_only:
|
| 102 |
+
result = _filter_properties_by_access(
|
| 103 |
+
result, remove_read_only, remove_write_only
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
# Step 5: Recursively process nested schemas
|
| 107 |
+
for field_name, field_type in RECURSIVE_FIELDS.items():
|
| 108 |
+
if field_name in result:
|
| 109 |
+
if field_type is dict and isinstance(result[field_name], dict):
|
| 110 |
+
if field_name == "properties":
|
| 111 |
+
# Handle properties specially - each property is a schema
|
| 112 |
+
result[field_name] = {
|
| 113 |
+
prop_name: convert_openapi_schema_to_json_schema(
|
| 114 |
+
prop_schema,
|
| 115 |
+
openapi_version,
|
| 116 |
+
remove_read_only,
|
| 117 |
+
remove_write_only,
|
| 118 |
+
convert_one_of_to_any_of,
|
| 119 |
+
)
|
| 120 |
+
if isinstance(prop_schema, dict)
|
| 121 |
+
else prop_schema
|
| 122 |
+
for prop_name, prop_schema in result[field_name].items()
|
| 123 |
+
}
|
| 124 |
+
else:
|
| 125 |
+
result[field_name] = convert_openapi_schema_to_json_schema(
|
| 126 |
+
result[field_name],
|
| 127 |
+
openapi_version,
|
| 128 |
+
remove_read_only,
|
| 129 |
+
remove_write_only,
|
| 130 |
+
convert_one_of_to_any_of,
|
| 131 |
+
)
|
| 132 |
+
elif field_type is list and isinstance(result[field_name], list):
|
| 133 |
+
result[field_name] = [
|
| 134 |
+
convert_openapi_schema_to_json_schema(
|
| 135 |
+
item,
|
| 136 |
+
openapi_version,
|
| 137 |
+
remove_read_only,
|
| 138 |
+
remove_write_only,
|
| 139 |
+
convert_one_of_to_any_of,
|
| 140 |
+
)
|
| 141 |
+
if isinstance(item, dict)
|
| 142 |
+
else item
|
| 143 |
+
for item in result[field_name]
|
| 144 |
+
]
|
| 145 |
+
|
| 146 |
+
return result
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _convert_nullable_field(schema: dict[str, Any]) -> dict[str, Any]:
|
| 150 |
+
"""Convert OpenAPI nullable field to JSON Schema type array."""
|
| 151 |
+
if "nullable" not in schema:
|
| 152 |
+
return schema
|
| 153 |
+
|
| 154 |
+
result = schema.copy()
|
| 155 |
+
nullable_value = result.pop("nullable")
|
| 156 |
+
|
| 157 |
+
# Only convert if nullable is True and we have a type structure
|
| 158 |
+
if not nullable_value:
|
| 159 |
+
return result
|
| 160 |
+
|
| 161 |
+
if "type" in result:
|
| 162 |
+
current_type = result["type"]
|
| 163 |
+
if isinstance(current_type, str):
|
| 164 |
+
result["type"] = [current_type, "null"]
|
| 165 |
+
elif isinstance(current_type, list) and "null" not in current_type:
|
| 166 |
+
result["type"] = current_type + ["null"]
|
| 167 |
+
elif "oneOf" in result:
|
| 168 |
+
# Convert oneOf to anyOf with null
|
| 169 |
+
result["anyOf"] = result.pop("oneOf") + [{"type": "null"}]
|
| 170 |
+
elif "anyOf" in result:
|
| 171 |
+
# Add null to anyOf if not present
|
| 172 |
+
if not any(item.get("type") == "null" for item in result["anyOf"]):
|
| 173 |
+
result["anyOf"].append({"type": "null"})
|
| 174 |
+
elif "allOf" in result:
|
| 175 |
+
# Wrap allOf in anyOf with null option
|
| 176 |
+
result["anyOf"] = [{"allOf": result.pop("allOf")}, {"type": "null"}]
|
| 177 |
+
|
| 178 |
+
return result
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _has_read_only_properties(schema: dict[str, Any]) -> bool:
|
| 182 |
+
"""Quick check if schema has any readOnly properties."""
|
| 183 |
+
if "properties" not in schema:
|
| 184 |
+
return False
|
| 185 |
+
return any(
|
| 186 |
+
isinstance(prop, dict) and prop.get("readOnly")
|
| 187 |
+
for prop in schema["properties"].values()
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _has_write_only_properties(schema: dict[str, Any]) -> bool:
|
| 192 |
+
"""Quick check if schema has any writeOnly properties."""
|
| 193 |
+
if "properties" not in schema:
|
| 194 |
+
return False
|
| 195 |
+
return any(
|
| 196 |
+
isinstance(prop, dict) and prop.get("writeOnly")
|
| 197 |
+
for prop in schema["properties"].values()
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def _needs_recursive_processing(
|
| 202 |
+
schema: dict[str, Any],
|
| 203 |
+
openapi_version: str | None,
|
| 204 |
+
remove_read_only: bool,
|
| 205 |
+
remove_write_only: bool,
|
| 206 |
+
convert_one_of_to_any_of: bool,
|
| 207 |
+
) -> bool:
|
| 208 |
+
"""Check if the schema needs recursive processing (smarter than just checking for recursive fields)."""
|
| 209 |
+
for field_name, field_type in RECURSIVE_FIELDS.items():
|
| 210 |
+
if field_name in schema:
|
| 211 |
+
if field_type is dict and isinstance(schema[field_name], dict):
|
| 212 |
+
if field_name == "properties":
|
| 213 |
+
# Check if any property needs conversion
|
| 214 |
+
for prop_schema in schema[field_name].values():
|
| 215 |
+
if isinstance(prop_schema, dict):
|
| 216 |
+
nested_needs_conversion = (
|
| 217 |
+
any(
|
| 218 |
+
field in prop_schema
|
| 219 |
+
for field in OPENAPI_SPECIFIC_FIELDS
|
| 220 |
+
)
|
| 221 |
+
or (remove_read_only and prop_schema.get("readOnly"))
|
| 222 |
+
or (remove_write_only and prop_schema.get("writeOnly"))
|
| 223 |
+
or (convert_one_of_to_any_of and "oneOf" in prop_schema)
|
| 224 |
+
or _needs_recursive_processing(
|
| 225 |
+
prop_schema,
|
| 226 |
+
openapi_version,
|
| 227 |
+
remove_read_only,
|
| 228 |
+
remove_write_only,
|
| 229 |
+
convert_one_of_to_any_of,
|
| 230 |
+
)
|
| 231 |
+
)
|
| 232 |
+
if nested_needs_conversion:
|
| 233 |
+
return True
|
| 234 |
+
else:
|
| 235 |
+
# Check if nested schema needs conversion
|
| 236 |
+
nested_needs_conversion = (
|
| 237 |
+
any(
|
| 238 |
+
field in schema[field_name]
|
| 239 |
+
for field in OPENAPI_SPECIFIC_FIELDS
|
| 240 |
+
)
|
| 241 |
+
or (
|
| 242 |
+
remove_read_only
|
| 243 |
+
and _has_read_only_properties(schema[field_name])
|
| 244 |
+
)
|
| 245 |
+
or (
|
| 246 |
+
remove_write_only
|
| 247 |
+
and _has_write_only_properties(schema[field_name])
|
| 248 |
+
)
|
| 249 |
+
or (convert_one_of_to_any_of and "oneOf" in schema[field_name])
|
| 250 |
+
or _needs_recursive_processing(
|
| 251 |
+
schema[field_name],
|
| 252 |
+
openapi_version,
|
| 253 |
+
remove_read_only,
|
| 254 |
+
remove_write_only,
|
| 255 |
+
convert_one_of_to_any_of,
|
| 256 |
+
)
|
| 257 |
+
)
|
| 258 |
+
if nested_needs_conversion:
|
| 259 |
+
return True
|
| 260 |
+
elif field_type is list and isinstance(schema[field_name], list):
|
| 261 |
+
# Check if any list item needs conversion
|
| 262 |
+
for item in schema[field_name]:
|
| 263 |
+
if isinstance(item, dict):
|
| 264 |
+
nested_needs_conversion = (
|
| 265 |
+
any(field in item for field in OPENAPI_SPECIFIC_FIELDS)
|
| 266 |
+
or (remove_read_only and _has_read_only_properties(item))
|
| 267 |
+
or (remove_write_only and _has_write_only_properties(item))
|
| 268 |
+
or (convert_one_of_to_any_of and "oneOf" in item)
|
| 269 |
+
or _needs_recursive_processing(
|
| 270 |
+
item,
|
| 271 |
+
openapi_version,
|
| 272 |
+
remove_read_only,
|
| 273 |
+
remove_write_only,
|
| 274 |
+
convert_one_of_to_any_of,
|
| 275 |
+
)
|
| 276 |
+
)
|
| 277 |
+
if nested_needs_conversion:
|
| 278 |
+
return True
|
| 279 |
+
return False
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def _filter_properties_by_access(
|
| 283 |
+
schema: dict[str, Any], remove_read_only: bool, remove_write_only: bool
|
| 284 |
+
) -> dict[str, Any]:
|
| 285 |
+
"""Remove readOnly and/or writeOnly properties from schema."""
|
| 286 |
+
if "properties" not in schema:
|
| 287 |
+
return schema
|
| 288 |
+
|
| 289 |
+
result = schema.copy()
|
| 290 |
+
filtered_properties = {}
|
| 291 |
+
|
| 292 |
+
for prop_name, prop_schema in result["properties"].items():
|
| 293 |
+
if not isinstance(prop_schema, dict):
|
| 294 |
+
filtered_properties[prop_name] = prop_schema
|
| 295 |
+
continue
|
| 296 |
+
|
| 297 |
+
should_remove = (remove_read_only and prop_schema.get("readOnly")) or (
|
| 298 |
+
remove_write_only and prop_schema.get("writeOnly")
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
if not should_remove:
|
| 302 |
+
filtered_properties[prop_name] = prop_schema
|
| 303 |
+
|
| 304 |
+
result["properties"] = filtered_properties
|
| 305 |
+
|
| 306 |
+
# Clean up required array if properties were removed
|
| 307 |
+
if "required" in result and filtered_properties:
|
| 308 |
+
result["required"] = [
|
| 309 |
+
prop for prop in result["required"] if prop in filtered_properties
|
| 310 |
+
]
|
| 311 |
+
if not result["required"]:
|
| 312 |
+
result.pop("required")
|
| 313 |
+
|
| 314 |
+
return result
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def convert_schema_definitions(
|
| 318 |
+
schema_definitions: dict[str, Any] | None,
|
| 319 |
+
openapi_version: str | None = None,
|
| 320 |
+
**kwargs,
|
| 321 |
+
) -> dict[str, Any]:
|
| 322 |
+
"""
|
| 323 |
+
Convert a dictionary of OpenAPI schema definitions to JSON Schema.
|
| 324 |
+
|
| 325 |
+
Args:
|
| 326 |
+
schema_definitions: Dictionary of schema definitions
|
| 327 |
+
openapi_version: OpenAPI version for optimization
|
| 328 |
+
**kwargs: Additional arguments passed to convert_openapi_schema_to_json_schema
|
| 329 |
+
|
| 330 |
+
Returns:
|
| 331 |
+
Dictionary of converted schema definitions
|
| 332 |
+
"""
|
| 333 |
+
if not schema_definitions:
|
| 334 |
+
return {}
|
| 335 |
+
|
| 336 |
+
return {
|
| 337 |
+
name: convert_openapi_schema_to_json_schema(schema, openapi_version, **kwargs)
|
| 338 |
+
for name, schema in schema_definitions.items()
|
| 339 |
+
}
|
src/fastmcp/experimental/utilities/openapi/models.py
CHANGED
|
@@ -62,6 +62,7 @@ class HTTPRoute(FastMCPBaseModel):
|
|
| 62 |
default_factory=dict
|
| 63 |
) # Store component schemas
|
| 64 |
extensions: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
| 65 |
|
| 66 |
# Pre-calculated fields for performance
|
| 67 |
flat_param_schema: JsonSchema = Field(
|
|
|
|
| 62 |
default_factory=dict
|
| 63 |
) # Store component schemas
|
| 64 |
extensions: dict[str, Any] = Field(default_factory=dict)
|
| 65 |
+
openapi_version: str | None = None
|
| 66 |
|
| 67 |
# Pre-calculated fields for performance
|
| 68 |
flat_param_schema: JsonSchema = Field(
|
src/fastmcp/experimental/utilities/openapi/parser.py
CHANGED
|
@@ -74,6 +74,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
|
|
| 74 |
Response_30,
|
| 75 |
Operation_30,
|
| 76 |
PathItem_30,
|
|
|
|
| 77 |
)
|
| 78 |
return parser.parse()
|
| 79 |
else:
|
|
@@ -91,6 +92,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
|
|
| 91 |
Response,
|
| 92 |
Operation,
|
| 93 |
PathItem,
|
|
|
|
| 94 |
)
|
| 95 |
return parser.parse()
|
| 96 |
except ValidationError as e:
|
|
@@ -124,6 +126,7 @@ class OpenAPIParser(
|
|
| 124 |
response_cls: type[TResponse],
|
| 125 |
operation_cls: type[TOperation],
|
| 126 |
path_item_cls: type[TPathItem],
|
|
|
|
| 127 |
):
|
| 128 |
"""Initialize the parser with the OpenAPI schema and type classes."""
|
| 129 |
self.openapi = openapi
|
|
@@ -134,6 +137,7 @@ class OpenAPIParser(
|
|
| 134 |
self.response_cls = response_cls
|
| 135 |
self.operation_cls = operation_cls
|
| 136 |
self.path_item_cls = path_item_cls
|
|
|
|
| 137 |
|
| 138 |
def _convert_to_parameter_location(self, param_in: str) -> ParameterLocation:
|
| 139 |
"""Convert string parameter location to our ParameterLocation type."""
|
|
@@ -560,6 +564,7 @@ class OpenAPIParser(
|
|
| 560 |
responses=responses,
|
| 561 |
schema_definitions=schema_definitions,
|
| 562 |
extensions=extensions,
|
|
|
|
| 563 |
)
|
| 564 |
|
| 565 |
# Pre-calculate schema and parameter mapping for performance
|
|
|
|
| 74 |
Response_30,
|
| 75 |
Operation_30,
|
| 76 |
PathItem_30,
|
| 77 |
+
openapi_version,
|
| 78 |
)
|
| 79 |
return parser.parse()
|
| 80 |
else:
|
|
|
|
| 92 |
Response,
|
| 93 |
Operation,
|
| 94 |
PathItem,
|
| 95 |
+
openapi_version,
|
| 96 |
)
|
| 97 |
return parser.parse()
|
| 98 |
except ValidationError as e:
|
|
|
|
| 126 |
response_cls: type[TResponse],
|
| 127 |
operation_cls: type[TOperation],
|
| 128 |
path_item_cls: type[TPathItem],
|
| 129 |
+
openapi_version: str,
|
| 130 |
):
|
| 131 |
"""Initialize the parser with the OpenAPI schema and type classes."""
|
| 132 |
self.openapi = openapi
|
|
|
|
| 137 |
self.response_cls = response_cls
|
| 138 |
self.operation_cls = operation_cls
|
| 139 |
self.path_item_cls = path_item_cls
|
| 140 |
+
self.openapi_version = openapi_version
|
| 141 |
|
| 142 |
def _convert_to_parameter_location(self, param_in: str) -> ParameterLocation:
|
| 143 |
"""Convert string parameter location to our ParameterLocation type."""
|
|
|
|
| 564 |
responses=responses,
|
| 565 |
schema_definitions=schema_definitions,
|
| 566 |
extensions=extensions,
|
| 567 |
+
openapi_version=self.openapi_version,
|
| 568 |
)
|
| 569 |
|
| 570 |
# Pre-calculate schema and parameter mapping for performance
|
src/fastmcp/experimental/utilities/openapi/schemas.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""Schema manipulation utilities for OpenAPI operations."""
|
| 2 |
|
| 3 |
import logging
|
| 4 |
-
from typing import Any
|
| 5 |
|
| 6 |
from .models import HTTPRoute, JsonSchema, ResponseInfo
|
| 7 |
|
|
@@ -199,86 +199,6 @@ def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]:
|
|
| 199 |
return schema
|
| 200 |
|
| 201 |
|
| 202 |
-
def _add_null_to_type(schema: dict[str, Any]) -> None:
|
| 203 |
-
"""Add 'null' to the schema's type field or handle oneOf/anyOf/allOf constructs if not already present."""
|
| 204 |
-
if "type" in schema:
|
| 205 |
-
current_type = schema["type"]
|
| 206 |
-
|
| 207 |
-
if isinstance(current_type, str):
|
| 208 |
-
# Convert string type to array with null
|
| 209 |
-
schema["type"] = [current_type, "null"]
|
| 210 |
-
elif isinstance(current_type, list):
|
| 211 |
-
# Add null to array if not already present
|
| 212 |
-
if "null" not in current_type:
|
| 213 |
-
schema["type"] = current_type + ["null"]
|
| 214 |
-
elif "oneOf" in schema:
|
| 215 |
-
# Convert oneOf to anyOf with null type
|
| 216 |
-
schema["anyOf"] = schema.pop("oneOf") + [{"type": "null"}]
|
| 217 |
-
elif "anyOf" in schema:
|
| 218 |
-
# Add null type to anyOf if not already present
|
| 219 |
-
if not any(item.get("type") == "null" for item in schema["anyOf"]):
|
| 220 |
-
schema["anyOf"].append({"type": "null"})
|
| 221 |
-
elif "allOf" in schema:
|
| 222 |
-
# For allOf, wrap in anyOf with null - this means (all conditions) OR null
|
| 223 |
-
schema["anyOf"] = [{"allOf": schema.pop("allOf")}, {"type": "null"}]
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
def _handle_nullable_fields(schema: dict[str, Any] | Any) -> dict[str, Any] | Any:
|
| 227 |
-
"""Convert OpenAPI nullable fields to JSON Schema format: {"type": "string",
|
| 228 |
-
"nullable": true} -> {"type": ["string", "null"]}"""
|
| 229 |
-
|
| 230 |
-
if not isinstance(schema, dict):
|
| 231 |
-
return schema
|
| 232 |
-
|
| 233 |
-
# Check if we need to modify anything first to avoid unnecessary copying
|
| 234 |
-
has_root_nullable_field = "nullable" in schema
|
| 235 |
-
has_root_nullable_true = (
|
| 236 |
-
has_root_nullable_field
|
| 237 |
-
and schema["nullable"]
|
| 238 |
-
and (
|
| 239 |
-
"type" in schema
|
| 240 |
-
or "oneOf" in schema
|
| 241 |
-
or "anyOf" in schema
|
| 242 |
-
or "allOf" in schema
|
| 243 |
-
)
|
| 244 |
-
)
|
| 245 |
-
|
| 246 |
-
has_property_nullable_field = False
|
| 247 |
-
if "properties" in schema:
|
| 248 |
-
for prop_schema in schema["properties"].values():
|
| 249 |
-
if isinstance(prop_schema, dict) and "nullable" in prop_schema:
|
| 250 |
-
has_property_nullable_field = True
|
| 251 |
-
break
|
| 252 |
-
|
| 253 |
-
# If no nullable fields at all, return original schema unchanged
|
| 254 |
-
if not has_root_nullable_field and not has_property_nullable_field:
|
| 255 |
-
return schema
|
| 256 |
-
|
| 257 |
-
# Only copy if we need to modify
|
| 258 |
-
result = schema.copy()
|
| 259 |
-
|
| 260 |
-
# Handle root level nullable - always remove the field, convert type if true
|
| 261 |
-
if has_root_nullable_field:
|
| 262 |
-
result.pop("nullable")
|
| 263 |
-
if has_root_nullable_true:
|
| 264 |
-
_add_null_to_type(result)
|
| 265 |
-
|
| 266 |
-
# Handle properties nullable fields
|
| 267 |
-
if has_property_nullable_field and "properties" in result:
|
| 268 |
-
for prop_name, prop_schema in result["properties"].items():
|
| 269 |
-
if isinstance(prop_schema, dict) and "nullable" in prop_schema:
|
| 270 |
-
nullable_value = prop_schema.pop("nullable")
|
| 271 |
-
if nullable_value and (
|
| 272 |
-
"type" in prop_schema
|
| 273 |
-
or "oneOf" in prop_schema
|
| 274 |
-
or "anyOf" in prop_schema
|
| 275 |
-
or "allOf" in prop_schema
|
| 276 |
-
):
|
| 277 |
-
_add_null_to_type(prop_schema)
|
| 278 |
-
|
| 279 |
-
return result
|
| 280 |
-
|
| 281 |
-
|
| 282 |
def _combine_schemas_and_map_params(
|
| 283 |
route: HTTPRoute,
|
| 284 |
) -> tuple[dict[str, Any], dict[str, dict[str, str]]]:
|
|
@@ -450,68 +370,10 @@ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
|
|
| 450 |
return schema
|
| 451 |
|
| 452 |
|
| 453 |
-
def _has_one_of(obj: dict[str, Any] | list[Any]) -> bool:
|
| 454 |
-
"""Quickly check if schema contains any 'oneOf' keys without deep traversal."""
|
| 455 |
-
if isinstance(obj, dict):
|
| 456 |
-
if "oneOf" in obj:
|
| 457 |
-
return True
|
| 458 |
-
# Only check likely schema containers, skip examples/defaults
|
| 459 |
-
for k, v in obj.items():
|
| 460 |
-
if k in [
|
| 461 |
-
"properties",
|
| 462 |
-
"items",
|
| 463 |
-
"allOf",
|
| 464 |
-
"anyOf",
|
| 465 |
-
"additionalProperties",
|
| 466 |
-
] and isinstance(v, dict | list):
|
| 467 |
-
if _has_one_of(v):
|
| 468 |
-
return True
|
| 469 |
-
elif isinstance(obj, list):
|
| 470 |
-
for item in obj:
|
| 471 |
-
if isinstance(item, dict | list) and _has_one_of(item):
|
| 472 |
-
return True
|
| 473 |
-
return False
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
def _adjust_union_types(
|
| 477 |
-
schema: dict[str, Any] | list[Any], _depth: int = 0
|
| 478 |
-
) -> dict[str, Any] | list[Any]:
|
| 479 |
-
"""Recursively replace 'oneOf' with 'anyOf' in schema to handle overlapping unions."""
|
| 480 |
-
# MAJOR OPTIMIZATION: Skip entirely if schema has no oneOf keys
|
| 481 |
-
if _depth == 0 and not _has_one_of(schema):
|
| 482 |
-
return schema
|
| 483 |
-
|
| 484 |
-
# OPTIMIZATION: Early termination for very deep structures to prevent exponential slowdown
|
| 485 |
-
if _depth > 30: # Reduced from 50 for better performance
|
| 486 |
-
return schema
|
| 487 |
-
|
| 488 |
-
if isinstance(schema, dict):
|
| 489 |
-
# Work on a copy to avoid mutating the input
|
| 490 |
-
result = schema.copy()
|
| 491 |
-
if "oneOf" in result:
|
| 492 |
-
result["anyOf"] = result.pop("oneOf")
|
| 493 |
-
# OPTIMIZATION: Only recurse into values that could contain more schemas
|
| 494 |
-
for k, v in result.items():
|
| 495 |
-
if isinstance(v, dict | list) and k not in [
|
| 496 |
-
"examples",
|
| 497 |
-
"example",
|
| 498 |
-
"default",
|
| 499 |
-
]:
|
| 500 |
-
result[k] = _adjust_union_types(v, _depth + 1)
|
| 501 |
-
return result
|
| 502 |
-
elif isinstance(schema, list):
|
| 503 |
-
# Process list items without mutating the input list
|
| 504 |
-
return [
|
| 505 |
-
_adjust_union_types(item, _depth + 1)
|
| 506 |
-
if isinstance(item, dict | list)
|
| 507 |
-
else item
|
| 508 |
-
for item in schema
|
| 509 |
-
]
|
| 510 |
-
return schema
|
| 511 |
-
|
| 512 |
-
|
| 513 |
def extract_output_schema_from_responses(
|
| 514 |
-
responses: dict[str, ResponseInfo],
|
|
|
|
|
|
|
| 515 |
) -> dict[str, Any] | None:
|
| 516 |
"""
|
| 517 |
Extract output schema from OpenAPI responses for use as MCP tool output schema.
|
|
@@ -523,6 +385,7 @@ def extract_output_schema_from_responses(
|
|
| 523 |
Args:
|
| 524 |
responses: Dictionary of ResponseInfo objects keyed by status code
|
| 525 |
schema_definitions: Optional schema definitions to include in the output schema
|
|
|
|
| 526 |
|
| 527 |
Returns:
|
| 528 |
dict: MCP-compliant output schema with potential wrapping, or None if no suitable schema found
|
|
@@ -588,9 +451,14 @@ def extract_output_schema_from_responses(
|
|
| 588 |
# Replace $ref with the actual schema definition
|
| 589 |
output_schema = schema_definitions[schema_name].copy()
|
| 590 |
|
| 591 |
-
#
|
| 592 |
-
#
|
| 593 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 594 |
|
| 595 |
# MCP requires output schemas to be objects. If this schema is not an object,
|
| 596 |
# we need to wrap it similar to how ParsedFunction.from_function() does it
|
|
@@ -609,7 +477,15 @@ def extract_output_schema_from_responses(
|
|
| 609 |
if schema_definitions and "$ref" not in schema.copy():
|
| 610 |
processed_defs = {}
|
| 611 |
for def_name, def_schema in schema_definitions.items():
|
| 612 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 613 |
output_schema["$defs"] = processed_defs
|
| 614 |
|
| 615 |
# Use lightweight compression - prune additionalProperties and unused definitions
|
|
@@ -647,9 +523,6 @@ def extract_output_schema_from_responses(
|
|
| 647 |
else:
|
| 648 |
output_schema.pop("$defs")
|
| 649 |
|
| 650 |
-
# Adjust union types to handle overlapping unions
|
| 651 |
-
output_schema = cast(dict[str, Any], _adjust_union_types(output_schema))
|
| 652 |
-
|
| 653 |
return output_schema
|
| 654 |
|
| 655 |
|
|
@@ -661,6 +534,4 @@ __all__ = [
|
|
| 661 |
"extract_output_schema_from_responses",
|
| 662 |
"_replace_ref_with_defs",
|
| 663 |
"_make_optional_parameter_nullable",
|
| 664 |
-
"_handle_nullable_fields",
|
| 665 |
-
"_adjust_union_types",
|
| 666 |
]
|
|
|
|
| 1 |
"""Schema manipulation utilities for OpenAPI operations."""
|
| 2 |
|
| 3 |
import logging
|
| 4 |
+
from typing import Any
|
| 5 |
|
| 6 |
from .models import HTTPRoute, JsonSchema, ResponseInfo
|
| 7 |
|
|
|
|
| 199 |
return schema
|
| 200 |
|
| 201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
def _combine_schemas_and_map_params(
|
| 203 |
route: HTTPRoute,
|
| 204 |
) -> tuple[dict[str, Any], dict[str, dict[str, str]]]:
|
|
|
|
| 370 |
return schema
|
| 371 |
|
| 372 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
def extract_output_schema_from_responses(
|
| 374 |
+
responses: dict[str, ResponseInfo],
|
| 375 |
+
schema_definitions: dict[str, Any] | None = None,
|
| 376 |
+
openapi_version: str | None = None,
|
| 377 |
) -> dict[str, Any] | None:
|
| 378 |
"""
|
| 379 |
Extract output schema from OpenAPI responses for use as MCP tool output schema.
|
|
|
|
| 385 |
Args:
|
| 386 |
responses: Dictionary of ResponseInfo objects keyed by status code
|
| 387 |
schema_definitions: Optional schema definitions to include in the output schema
|
| 388 |
+
openapi_version: OpenAPI version string, used to optimize nullable field handling
|
| 389 |
|
| 390 |
Returns:
|
| 391 |
dict: MCP-compliant output schema with potential wrapping, or None if no suitable schema found
|
|
|
|
| 451 |
# Replace $ref with the actual schema definition
|
| 452 |
output_schema = schema_definitions[schema_name].copy()
|
| 453 |
|
| 454 |
+
# Convert OpenAPI schema to JSON Schema format
|
| 455 |
+
# Only needed for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types
|
| 456 |
+
if openapi_version and openapi_version.startswith("3.0"):
|
| 457 |
+
from .json_schema_converter import convert_openapi_schema_to_json_schema
|
| 458 |
+
|
| 459 |
+
output_schema = convert_openapi_schema_to_json_schema(
|
| 460 |
+
output_schema, openapi_version
|
| 461 |
+
)
|
| 462 |
|
| 463 |
# MCP requires output schemas to be objects. If this schema is not an object,
|
| 464 |
# we need to wrap it similar to how ParsedFunction.from_function() does it
|
|
|
|
| 477 |
if schema_definitions and "$ref" not in schema.copy():
|
| 478 |
processed_defs = {}
|
| 479 |
for def_name, def_schema in schema_definitions.items():
|
| 480 |
+
# Convert OpenAPI schema definitions to JSON Schema format
|
| 481 |
+
if openapi_version and openapi_version.startswith("3.0"):
|
| 482 |
+
from .json_schema_converter import convert_openapi_schema_to_json_schema
|
| 483 |
+
|
| 484 |
+
processed_defs[def_name] = convert_openapi_schema_to_json_schema(
|
| 485 |
+
def_schema, openapi_version
|
| 486 |
+
)
|
| 487 |
+
else:
|
| 488 |
+
processed_defs[def_name] = def_schema
|
| 489 |
output_schema["$defs"] = processed_defs
|
| 490 |
|
| 491 |
# Use lightweight compression - prune additionalProperties and unused definitions
|
|
|
|
| 523 |
else:
|
| 524 |
output_schema.pop("$defs")
|
| 525 |
|
|
|
|
|
|
|
|
|
|
| 526 |
return output_schema
|
| 527 |
|
| 528 |
|
|
|
|
| 534 |
"extract_output_schema_from_responses",
|
| 535 |
"_replace_ref_with_defs",
|
| 536 |
"_make_optional_parameter_nullable",
|
|
|
|
|
|
|
| 537 |
]
|
src/fastmcp/server/openapi.py
CHANGED
|
@@ -892,7 +892,7 @@ class FastMCPOpenAPI(FastMCP):
|
|
| 892 |
|
| 893 |
# Extract output schema from OpenAPI responses
|
| 894 |
output_schema = extract_output_schema_from_responses(
|
| 895 |
-
route.responses, route.schema_definitions
|
| 896 |
)
|
| 897 |
|
| 898 |
# Get a unique tool name
|
|
|
|
| 892 |
|
| 893 |
# Extract output schema from OpenAPI responses
|
| 894 |
output_schema = extract_output_schema_from_responses(
|
| 895 |
+
route.responses, route.schema_definitions, route.openapi_version
|
| 896 |
)
|
| 897 |
|
| 898 |
# Get a unique tool name
|
src/fastmcp/utilities/openapi.py
CHANGED
|
@@ -173,6 +173,7 @@ class HTTPRoute(FastMCPBaseModel):
|
|
| 173 |
default_factory=dict
|
| 174 |
) # Store component schemas
|
| 175 |
extensions: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
| 176 |
|
| 177 |
|
| 178 |
# Export public symbols
|
|
@@ -227,6 +228,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
|
|
| 227 |
Response_30,
|
| 228 |
Operation_30,
|
| 229 |
PathItem_30,
|
|
|
|
| 230 |
)
|
| 231 |
return parser.parse()
|
| 232 |
else:
|
|
@@ -244,6 +246,7 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute
|
|
| 244 |
Response,
|
| 245 |
Operation,
|
| 246 |
PathItem,
|
|
|
|
| 247 |
)
|
| 248 |
return parser.parse()
|
| 249 |
except ValidationError as e:
|
|
@@ -277,6 +280,7 @@ class OpenAPIParser(
|
|
| 277 |
response_cls: type[TResponse],
|
| 278 |
operation_cls: type[TOperation],
|
| 279 |
path_item_cls: type[TPathItem],
|
|
|
|
| 280 |
):
|
| 281 |
"""Initialize the parser with the OpenAPI schema and type classes."""
|
| 282 |
self.openapi = openapi
|
|
@@ -287,6 +291,7 @@ class OpenAPIParser(
|
|
| 287 |
self.response_cls = response_cls
|
| 288 |
self.operation_cls = operation_cls
|
| 289 |
self.path_item_cls = path_item_cls
|
|
|
|
| 290 |
|
| 291 |
def _convert_to_parameter_location(self, param_in: str) -> ParameterLocation:
|
| 292 |
"""Convert string parameter location to our ParameterLocation type."""
|
|
@@ -709,6 +714,7 @@ class OpenAPIParser(
|
|
| 709 |
responses=responses,
|
| 710 |
schema_definitions=schema_definitions,
|
| 711 |
extensions=extensions,
|
|
|
|
| 712 |
)
|
| 713 |
routes.append(route)
|
| 714 |
logger.info(
|
|
@@ -1401,7 +1407,9 @@ def _adjust_union_types(
|
|
| 1401 |
|
| 1402 |
|
| 1403 |
def extract_output_schema_from_responses(
|
| 1404 |
-
responses: dict[str, ResponseInfo],
|
|
|
|
|
|
|
| 1405 |
) -> dict[str, Any] | None:
|
| 1406 |
"""
|
| 1407 |
Extract output schema from OpenAPI responses for use as MCP tool output schema.
|
|
@@ -1413,6 +1421,7 @@ def extract_output_schema_from_responses(
|
|
| 1413 |
Args:
|
| 1414 |
responses: Dictionary of ResponseInfo objects keyed by status code
|
| 1415 |
schema_definitions: Optional schema definitions to include in the output schema
|
|
|
|
| 1416 |
|
| 1417 |
Returns:
|
| 1418 |
dict: MCP-compliant output schema with potential wrapping, or None if no suitable schema found
|
|
@@ -1480,7 +1489,9 @@ def extract_output_schema_from_responses(
|
|
| 1480 |
|
| 1481 |
# Handle OpenAPI nullable fields by converting them to JSON Schema format
|
| 1482 |
# This prevents "None is not of type 'string'" validation errors
|
| 1483 |
-
|
|
|
|
|
|
|
| 1484 |
|
| 1485 |
# MCP requires output schemas to be objects. If this schema is not an object,
|
| 1486 |
# we need to wrap it similar to how ParsedFunction.from_function() does it
|
|
@@ -1499,7 +1510,11 @@ def extract_output_schema_from_responses(
|
|
| 1499 |
if schema_definitions and "$ref" not in schema.copy():
|
| 1500 |
processed_defs = {}
|
| 1501 |
for def_name, def_schema in schema_definitions.items():
|
| 1502 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1503 |
output_schema["$defs"] = processed_defs
|
| 1504 |
|
| 1505 |
# Use lightweight compression - prune additionalProperties and unused definitions
|
|
|
|
| 173 |
default_factory=dict
|
| 174 |
) # Store component schemas
|
| 175 |
extensions: dict[str, Any] = Field(default_factory=dict)
|
| 176 |
+
openapi_version: str | None = None
|
| 177 |
|
| 178 |
|
| 179 |
# Export public symbols
|
|
|
|
| 228 |
Response_30,
|
| 229 |
Operation_30,
|
| 230 |
PathItem_30,
|
| 231 |
+
openapi_version,
|
| 232 |
)
|
| 233 |
return parser.parse()
|
| 234 |
else:
|
|
|
|
| 246 |
Response,
|
| 247 |
Operation,
|
| 248 |
PathItem,
|
| 249 |
+
openapi_version,
|
| 250 |
)
|
| 251 |
return parser.parse()
|
| 252 |
except ValidationError as e:
|
|
|
|
| 280 |
response_cls: type[TResponse],
|
| 281 |
operation_cls: type[TOperation],
|
| 282 |
path_item_cls: type[TPathItem],
|
| 283 |
+
openapi_version: str,
|
| 284 |
):
|
| 285 |
"""Initialize the parser with the OpenAPI schema and type classes."""
|
| 286 |
self.openapi = openapi
|
|
|
|
| 291 |
self.response_cls = response_cls
|
| 292 |
self.operation_cls = operation_cls
|
| 293 |
self.path_item_cls = path_item_cls
|
| 294 |
+
self.openapi_version = openapi_version
|
| 295 |
|
| 296 |
def _convert_to_parameter_location(self, param_in: str) -> ParameterLocation:
|
| 297 |
"""Convert string parameter location to our ParameterLocation type."""
|
|
|
|
| 714 |
responses=responses,
|
| 715 |
schema_definitions=schema_definitions,
|
| 716 |
extensions=extensions,
|
| 717 |
+
openapi_version=self.openapi_version,
|
| 718 |
)
|
| 719 |
routes.append(route)
|
| 720 |
logger.info(
|
|
|
|
| 1407 |
|
| 1408 |
|
| 1409 |
def extract_output_schema_from_responses(
|
| 1410 |
+
responses: dict[str, ResponseInfo],
|
| 1411 |
+
schema_definitions: dict[str, Any] | None = None,
|
| 1412 |
+
openapi_version: str | None = None,
|
| 1413 |
) -> dict[str, Any] | None:
|
| 1414 |
"""
|
| 1415 |
Extract output schema from OpenAPI responses for use as MCP tool output schema.
|
|
|
|
| 1421 |
Args:
|
| 1422 |
responses: Dictionary of ResponseInfo objects keyed by status code
|
| 1423 |
schema_definitions: Optional schema definitions to include in the output schema
|
| 1424 |
+
openapi_version: OpenAPI version string, used to optimize nullable field handling
|
| 1425 |
|
| 1426 |
Returns:
|
| 1427 |
dict: MCP-compliant output schema with potential wrapping, or None if no suitable schema found
|
|
|
|
| 1489 |
|
| 1490 |
# Handle OpenAPI nullable fields by converting them to JSON Schema format
|
| 1491 |
# This prevents "None is not of type 'string'" validation errors
|
| 1492 |
+
# Only needed for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types
|
| 1493 |
+
if openapi_version and openapi_version.startswith("3.0"):
|
| 1494 |
+
output_schema = _handle_nullable_fields(output_schema)
|
| 1495 |
|
| 1496 |
# MCP requires output schemas to be objects. If this schema is not an object,
|
| 1497 |
# we need to wrap it similar to how ParsedFunction.from_function() does it
|
|
|
|
| 1510 |
if schema_definitions and "$ref" not in schema.copy():
|
| 1511 |
processed_defs = {}
|
| 1512 |
for def_name, def_schema in schema_definitions.items():
|
| 1513 |
+
# Only handle nullable fields for OpenAPI 3.0 - 3.1 uses standard JSON Schema null types
|
| 1514 |
+
if openapi_version and openapi_version.startswith("3.0"):
|
| 1515 |
+
processed_defs[def_name] = _handle_nullable_fields(def_schema)
|
| 1516 |
+
else:
|
| 1517 |
+
processed_defs[def_name] = def_schema
|
| 1518 |
output_schema["$defs"] = processed_defs
|
| 1519 |
|
| 1520 |
# Use lightweight compression - prune additionalProperties and unused definitions
|
tests/experimental/utilities/openapi/test_director.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""Unit tests for RequestDirector."""
|
| 2 |
|
| 3 |
import pytest
|
| 4 |
-
from
|
| 5 |
|
| 6 |
from fastmcp.experimental.utilities.openapi.director import RequestDirector
|
| 7 |
from fastmcp.experimental.utilities.openapi.models import (
|
|
@@ -148,12 +148,12 @@ class TestRequestDirector:
|
|
| 148 |
@pytest.fixture
|
| 149 |
def director(self, basic_openapi_30_spec):
|
| 150 |
"""Create a RequestDirector instance."""
|
| 151 |
-
spec =
|
| 152 |
return RequestDirector(spec)
|
| 153 |
|
| 154 |
def test_director_initialization(self, basic_openapi_30_spec):
|
| 155 |
"""Test RequestDirector initialization."""
|
| 156 |
-
spec =
|
| 157 |
director = RequestDirector(spec)
|
| 158 |
|
| 159 |
assert director._spec is not None
|
|
@@ -350,7 +350,7 @@ class TestRequestDirector:
|
|
| 350 |
request = director.build(route, flat_args, "https://api.example.com")
|
| 351 |
|
| 352 |
assert request.method == "POST"
|
| 353 |
-
# For non-JSON content, httpx uses '
|
| 354 |
assert request.content == b"Hello, World!"
|
| 355 |
|
| 356 |
def test_body_construction_multiple_properties_non_object_schema(self, director):
|
|
@@ -392,7 +392,7 @@ class TestRequestDirectorIntegration:
|
|
| 392 |
assert len(routes) == 1
|
| 393 |
|
| 394 |
route = routes[0]
|
| 395 |
-
spec =
|
| 396 |
director = RequestDirector(spec)
|
| 397 |
|
| 398 |
flat_args = {"id": 42}
|
|
@@ -407,7 +407,7 @@ class TestRequestDirectorIntegration:
|
|
| 407 |
assert len(routes) == 1
|
| 408 |
|
| 409 |
route = routes[0]
|
| 410 |
-
spec =
|
| 411 |
director = RequestDirector(spec)
|
| 412 |
|
| 413 |
# Use the parameter names from the actual parameter map
|
|
@@ -440,7 +440,7 @@ class TestRequestDirectorIntegration:
|
|
| 440 |
assert len(routes) == 1
|
| 441 |
|
| 442 |
route = routes[0]
|
| 443 |
-
spec =
|
| 444 |
director = RequestDirector(spec)
|
| 445 |
|
| 446 |
# DeepObject parameters should be flattened in the parameter map
|
|
|
|
| 1 |
"""Unit tests for RequestDirector."""
|
| 2 |
|
| 3 |
import pytest
|
| 4 |
+
from jsonschema_path import SchemaPath
|
| 5 |
|
| 6 |
from fastmcp.experimental.utilities.openapi.director import RequestDirector
|
| 7 |
from fastmcp.experimental.utilities.openapi.models import (
|
|
|
|
| 148 |
@pytest.fixture
|
| 149 |
def director(self, basic_openapi_30_spec):
|
| 150 |
"""Create a RequestDirector instance."""
|
| 151 |
+
spec = SchemaPath.from_dict(basic_openapi_30_spec)
|
| 152 |
return RequestDirector(spec)
|
| 153 |
|
| 154 |
def test_director_initialization(self, basic_openapi_30_spec):
|
| 155 |
"""Test RequestDirector initialization."""
|
| 156 |
+
spec = SchemaPath.from_dict(basic_openapi_30_spec)
|
| 157 |
director = RequestDirector(spec)
|
| 158 |
|
| 159 |
assert director._spec is not None
|
|
|
|
| 350 |
request = director.build(route, flat_args, "https://api.example.com")
|
| 351 |
|
| 352 |
assert request.method == "POST"
|
| 353 |
+
# For non-JSON content, httpx uses 'content' parameter which becomes bytes
|
| 354 |
assert request.content == b"Hello, World!"
|
| 355 |
|
| 356 |
def test_body_construction_multiple_properties_non_object_schema(self, director):
|
|
|
|
| 392 |
assert len(routes) == 1
|
| 393 |
|
| 394 |
route = routes[0]
|
| 395 |
+
spec = SchemaPath.from_dict(basic_openapi_30_spec)
|
| 396 |
director = RequestDirector(spec)
|
| 397 |
|
| 398 |
flat_args = {"id": 42}
|
|
|
|
| 407 |
assert len(routes) == 1
|
| 408 |
|
| 409 |
route = routes[0]
|
| 410 |
+
spec = SchemaPath.from_dict(collision_spec)
|
| 411 |
director = RequestDirector(spec)
|
| 412 |
|
| 413 |
# Use the parameter names from the actual parameter map
|
|
|
|
| 440 |
assert len(routes) == 1
|
| 441 |
|
| 442 |
route = routes[0]
|
| 443 |
+
spec = SchemaPath.from_dict(deepobject_spec)
|
| 444 |
director = RequestDirector(spec)
|
| 445 |
|
| 446 |
# DeepObject parameters should be flattened in the parameter map
|
tests/experimental/utilities/openapi/test_nullable_fields.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
"""Tests for nullable field handling in OpenAPI schemas."""
|
| 2 |
|
| 3 |
-
from fastmcp.experimental.utilities.openapi.
|
|
|
|
|
|
|
| 4 |
|
| 5 |
|
| 6 |
class TestHandleNullableFields:
|
|
@@ -10,21 +12,21 @@ class TestHandleNullableFields:
|
|
| 10 |
"""Test nullable string at root level."""
|
| 11 |
input_schema = {"type": "string", "nullable": True}
|
| 12 |
expected = {"type": ["string", "null"]}
|
| 13 |
-
result =
|
| 14 |
assert result == expected
|
| 15 |
|
| 16 |
def test_root_level_nullable_integer(self):
|
| 17 |
"""Test nullable integer at root level."""
|
| 18 |
input_schema = {"type": "integer", "nullable": True}
|
| 19 |
expected = {"type": ["integer", "null"]}
|
| 20 |
-
result =
|
| 21 |
assert result == expected
|
| 22 |
|
| 23 |
def test_root_level_nullable_boolean(self):
|
| 24 |
"""Test nullable boolean at root level."""
|
| 25 |
input_schema = {"type": "boolean", "nullable": True}
|
| 26 |
expected = {"type": ["boolean", "null"]}
|
| 27 |
-
result =
|
| 28 |
assert result == expected
|
| 29 |
|
| 30 |
def test_property_level_nullable_fields(self):
|
|
@@ -47,7 +49,7 @@ class TestHandleNullableFields:
|
|
| 47 |
"active": {"type": ["boolean", "null"]},
|
| 48 |
},
|
| 49 |
}
|
| 50 |
-
result =
|
| 51 |
assert result == expected
|
| 52 |
|
| 53 |
def test_mixed_nullable_and_non_nullable(self):
|
|
@@ -70,14 +72,14 @@ class TestHandleNullableFields:
|
|
| 70 |
},
|
| 71 |
"required": ["required_field"],
|
| 72 |
}
|
| 73 |
-
result =
|
| 74 |
assert result == expected
|
| 75 |
|
| 76 |
def test_nullable_false_ignored(self):
|
| 77 |
"""Test that nullable: false is ignored (removed but no type change)."""
|
| 78 |
input_schema = {"type": "string", "nullable": False}
|
| 79 |
expected = {"type": "string"}
|
| 80 |
-
result =
|
| 81 |
assert result == expected
|
| 82 |
|
| 83 |
def test_no_nullable_field_unchanged(self):
|
|
@@ -87,14 +89,14 @@ class TestHandleNullableFields:
|
|
| 87 |
"properties": {"name": {"type": "string"}},
|
| 88 |
}
|
| 89 |
expected = input_schema.copy()
|
| 90 |
-
result =
|
| 91 |
assert result == expected
|
| 92 |
|
| 93 |
def test_nullable_without_type_removes_nullable(self):
|
| 94 |
"""Test that nullable field is removed even without type."""
|
| 95 |
input_schema = {"nullable": True, "description": "Some field"}
|
| 96 |
expected = {"description": "Some field"}
|
| 97 |
-
result =
|
| 98 |
assert result == expected
|
| 99 |
|
| 100 |
def test_preserves_other_fields(self):
|
|
@@ -112,15 +114,15 @@ class TestHandleNullableFields:
|
|
| 112 |
"example": "test",
|
| 113 |
"format": "email",
|
| 114 |
}
|
| 115 |
-
result =
|
| 116 |
assert result == expected
|
| 117 |
|
| 118 |
def test_non_dict_input_unchanged(self):
|
| 119 |
"""Test that non-dict inputs are returned unchanged."""
|
| 120 |
-
assert
|
| 121 |
-
assert
|
| 122 |
-
assert
|
| 123 |
-
assert
|
| 124 |
|
| 125 |
def test_performance_optimization_no_copy_when_unchanged(self):
|
| 126 |
"""Test that schemas without nullable fields return the same object (no copy)."""
|
|
@@ -128,7 +130,7 @@ class TestHandleNullableFields:
|
|
| 128 |
"type": "object",
|
| 129 |
"properties": {"name": {"type": "string"}},
|
| 130 |
}
|
| 131 |
-
result =
|
| 132 |
# Should return the exact same object, not a copy
|
| 133 |
assert result is input_schema
|
| 134 |
|
|
@@ -136,14 +138,14 @@ class TestHandleNullableFields:
|
|
| 136 |
"""Test nullable handling with existing union types (type as array)."""
|
| 137 |
input_schema = {"type": ["string", "integer"], "nullable": True}
|
| 138 |
expected = {"type": ["string", "integer", "null"]}
|
| 139 |
-
result =
|
| 140 |
assert result == expected
|
| 141 |
|
| 142 |
def test_already_nullable_union_unchanged(self):
|
| 143 |
"""Test that union types already containing null are not modified."""
|
| 144 |
input_schema = {"type": ["string", "null"], "nullable": True}
|
| 145 |
expected = {"type": ["string", "null"]}
|
| 146 |
-
result =
|
| 147 |
assert result == expected
|
| 148 |
|
| 149 |
def test_property_level_union_with_nullable(self):
|
|
@@ -156,19 +158,19 @@ class TestHandleNullableFields:
|
|
| 156 |
"type": "object",
|
| 157 |
"properties": {"value": {"type": ["string", "integer", "null"]}},
|
| 158 |
}
|
| 159 |
-
result =
|
| 160 |
assert result == expected
|
| 161 |
|
| 162 |
def test_complex_union_nullable_scenarios(self):
|
| 163 |
"""Test various complex union type scenarios."""
|
| 164 |
# Already has null in different position
|
| 165 |
input1 = {"type": ["null", "string", "integer"], "nullable": True}
|
| 166 |
-
result1 =
|
| 167 |
assert result1 == {"type": ["null", "string", "integer"]}
|
| 168 |
|
| 169 |
# Single item array
|
| 170 |
input2 = {"type": ["string"], "nullable": True}
|
| 171 |
-
result2 =
|
| 172 |
assert result2 == {"type": ["string", "null"]}
|
| 173 |
|
| 174 |
def test_oneof_with_nullable(self):
|
|
@@ -180,7 +182,7 @@ class TestHandleNullableFields:
|
|
| 180 |
expected = {
|
| 181 |
"anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]
|
| 182 |
}
|
| 183 |
-
result =
|
| 184 |
assert result == expected
|
| 185 |
|
| 186 |
def test_anyof_with_nullable(self):
|
|
@@ -192,7 +194,7 @@ class TestHandleNullableFields:
|
|
| 192 |
expected = {
|
| 193 |
"anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]
|
| 194 |
}
|
| 195 |
-
result =
|
| 196 |
assert result == expected
|
| 197 |
|
| 198 |
def test_anyof_already_nullable(self):
|
|
@@ -202,7 +204,7 @@ class TestHandleNullableFields:
|
|
| 202 |
"nullable": True,
|
| 203 |
}
|
| 204 |
expected = {"anyOf": [{"type": "string"}, {"type": "null"}]}
|
| 205 |
-
result =
|
| 206 |
assert result == expected
|
| 207 |
|
| 208 |
def test_allof_with_nullable(self):
|
|
@@ -217,7 +219,7 @@ class TestHandleNullableFields:
|
|
| 217 |
{"type": "null"},
|
| 218 |
]
|
| 219 |
}
|
| 220 |
-
result =
|
| 221 |
assert result == expected
|
| 222 |
|
| 223 |
def test_property_level_oneof_with_nullable(self):
|
|
@@ -239,5 +241,5 @@ class TestHandleNullableFields:
|
|
| 239 |
}
|
| 240 |
},
|
| 241 |
}
|
| 242 |
-
result =
|
| 243 |
assert result == expected
|
|
|
|
| 1 |
"""Tests for nullable field handling in OpenAPI schemas."""
|
| 2 |
|
| 3 |
+
from fastmcp.experimental.utilities.openapi.json_schema_converter import (
|
| 4 |
+
convert_openapi_schema_to_json_schema,
|
| 5 |
+
)
|
| 6 |
|
| 7 |
|
| 8 |
class TestHandleNullableFields:
|
|
|
|
| 12 |
"""Test nullable string at root level."""
|
| 13 |
input_schema = {"type": "string", "nullable": True}
|
| 14 |
expected = {"type": ["string", "null"]}
|
| 15 |
+
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
|
| 16 |
assert result == expected
|
| 17 |
|
| 18 |
def test_root_level_nullable_integer(self):
|
| 19 |
"""Test nullable integer at root level."""
|
| 20 |
input_schema = {"type": "integer", "nullable": True}
|
| 21 |
expected = {"type": ["integer", "null"]}
|
| 22 |
+
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
|
| 23 |
assert result == expected
|
| 24 |
|
| 25 |
def test_root_level_nullable_boolean(self):
|
| 26 |
"""Test nullable boolean at root level."""
|
| 27 |
input_schema = {"type": "boolean", "nullable": True}
|
| 28 |
expected = {"type": ["boolean", "null"]}
|
| 29 |
+
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
|
| 30 |
assert result == expected
|
| 31 |
|
| 32 |
def test_property_level_nullable_fields(self):
|
|
|
|
| 49 |
"active": {"type": ["boolean", "null"]},
|
| 50 |
},
|
| 51 |
}
|
| 52 |
+
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
|
| 53 |
assert result == expected
|
| 54 |
|
| 55 |
def test_mixed_nullable_and_non_nullable(self):
|
|
|
|
| 72 |
},
|
| 73 |
"required": ["required_field"],
|
| 74 |
}
|
| 75 |
+
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
|
| 76 |
assert result == expected
|
| 77 |
|
| 78 |
def test_nullable_false_ignored(self):
|
| 79 |
"""Test that nullable: false is ignored (removed but no type change)."""
|
| 80 |
input_schema = {"type": "string", "nullable": False}
|
| 81 |
expected = {"type": "string"}
|
| 82 |
+
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
|
| 83 |
assert result == expected
|
| 84 |
|
| 85 |
def test_no_nullable_field_unchanged(self):
|
|
|
|
| 89 |
"properties": {"name": {"type": "string"}},
|
| 90 |
}
|
| 91 |
expected = input_schema.copy()
|
| 92 |
+
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
|
| 93 |
assert result == expected
|
| 94 |
|
| 95 |
def test_nullable_without_type_removes_nullable(self):
|
| 96 |
"""Test that nullable field is removed even without type."""
|
| 97 |
input_schema = {"nullable": True, "description": "Some field"}
|
| 98 |
expected = {"description": "Some field"}
|
| 99 |
+
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
|
| 100 |
assert result == expected
|
| 101 |
|
| 102 |
def test_preserves_other_fields(self):
|
|
|
|
| 114 |
"example": "test",
|
| 115 |
"format": "email",
|
| 116 |
}
|
| 117 |
+
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
|
| 118 |
assert result == expected
|
| 119 |
|
| 120 |
def test_non_dict_input_unchanged(self):
|
| 121 |
"""Test that non-dict inputs are returned unchanged."""
|
| 122 |
+
assert convert_openapi_schema_to_json_schema("string", "3.0.0") == "string" # type: ignore[arg-type]
|
| 123 |
+
assert convert_openapi_schema_to_json_schema(123, "3.0.0") == 123 # type: ignore[arg-type]
|
| 124 |
+
assert convert_openapi_schema_to_json_schema(None, "3.0.0") is None # type: ignore[arg-type]
|
| 125 |
+
assert convert_openapi_schema_to_json_schema([1, 2, 3], "3.0.0") == [1, 2, 3] # type: ignore[arg-type]
|
| 126 |
|
| 127 |
def test_performance_optimization_no_copy_when_unchanged(self):
|
| 128 |
"""Test that schemas without nullable fields return the same object (no copy)."""
|
|
|
|
| 130 |
"type": "object",
|
| 131 |
"properties": {"name": {"type": "string"}},
|
| 132 |
}
|
| 133 |
+
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
|
| 134 |
# Should return the exact same object, not a copy
|
| 135 |
assert result is input_schema
|
| 136 |
|
|
|
|
| 138 |
"""Test nullable handling with existing union types (type as array)."""
|
| 139 |
input_schema = {"type": ["string", "integer"], "nullable": True}
|
| 140 |
expected = {"type": ["string", "integer", "null"]}
|
| 141 |
+
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
|
| 142 |
assert result == expected
|
| 143 |
|
| 144 |
def test_already_nullable_union_unchanged(self):
|
| 145 |
"""Test that union types already containing null are not modified."""
|
| 146 |
input_schema = {"type": ["string", "null"], "nullable": True}
|
| 147 |
expected = {"type": ["string", "null"]}
|
| 148 |
+
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
|
| 149 |
assert result == expected
|
| 150 |
|
| 151 |
def test_property_level_union_with_nullable(self):
|
|
|
|
| 158 |
"type": "object",
|
| 159 |
"properties": {"value": {"type": ["string", "integer", "null"]}},
|
| 160 |
}
|
| 161 |
+
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
|
| 162 |
assert result == expected
|
| 163 |
|
| 164 |
def test_complex_union_nullable_scenarios(self):
|
| 165 |
"""Test various complex union type scenarios."""
|
| 166 |
# Already has null in different position
|
| 167 |
input1 = {"type": ["null", "string", "integer"], "nullable": True}
|
| 168 |
+
result1 = convert_openapi_schema_to_json_schema(input1, "3.0.0")
|
| 169 |
assert result1 == {"type": ["null", "string", "integer"]}
|
| 170 |
|
| 171 |
# Single item array
|
| 172 |
input2 = {"type": ["string"], "nullable": True}
|
| 173 |
+
result2 = convert_openapi_schema_to_json_schema(input2, "3.0.0")
|
| 174 |
assert result2 == {"type": ["string", "null"]}
|
| 175 |
|
| 176 |
def test_oneof_with_nullable(self):
|
|
|
|
| 182 |
expected = {
|
| 183 |
"anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]
|
| 184 |
}
|
| 185 |
+
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
|
| 186 |
assert result == expected
|
| 187 |
|
| 188 |
def test_anyof_with_nullable(self):
|
|
|
|
| 194 |
expected = {
|
| 195 |
"anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]
|
| 196 |
}
|
| 197 |
+
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
|
| 198 |
assert result == expected
|
| 199 |
|
| 200 |
def test_anyof_already_nullable(self):
|
|
|
|
| 204 |
"nullable": True,
|
| 205 |
}
|
| 206 |
expected = {"anyOf": [{"type": "string"}, {"type": "null"}]}
|
| 207 |
+
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
|
| 208 |
assert result == expected
|
| 209 |
|
| 210 |
def test_allof_with_nullable(self):
|
|
|
|
| 219 |
{"type": "null"},
|
| 220 |
]
|
| 221 |
}
|
| 222 |
+
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
|
| 223 |
assert result == expected
|
| 224 |
|
| 225 |
def test_property_level_oneof_with_nullable(self):
|
|
|
|
| 241 |
}
|
| 242 |
},
|
| 243 |
}
|
| 244 |
+
result = convert_openapi_schema_to_json_schema(input_schema, "3.0.0")
|
| 245 |
assert result == expected
|