Spaces:
Running
Running
Magnus Jeremiah Lowin commited on
fix: replace oneOf with anyOf in OpenAPI schemas to handle overlapping unions (#1119)
Browse files
src/fastmcp/utilities/openapi.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import json
|
| 2 |
import logging
|
| 3 |
-
from typing import Any, Generic, Literal, TypeVar
|
| 4 |
|
| 5 |
from openapi_pydantic import (
|
| 6 |
OpenAPI,
|
|
@@ -1188,6 +1188,20 @@ def _combine_schemas(route: HTTPRoute) -> dict[str, Any]:
|
|
| 1188 |
return result
|
| 1189 |
|
| 1190 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1191 |
def extract_output_schema_from_responses(
|
| 1192 |
responses: dict[str, ResponseInfo], schema_definitions: dict[str, Any] | None = None
|
| 1193 |
) -> dict[str, Any] | None:
|
|
@@ -1276,4 +1290,7 @@ def extract_output_schema_from_responses(
|
|
| 1276 |
# Use compress_schema to remove unused definitions
|
| 1277 |
output_schema = compress_schema(output_schema)
|
| 1278 |
|
|
|
|
|
|
|
|
|
|
| 1279 |
return output_schema
|
|
|
|
| 1 |
import json
|
| 2 |
import logging
|
| 3 |
+
from typing import Any, Generic, Literal, TypeVar, cast
|
| 4 |
|
| 5 |
from openapi_pydantic import (
|
| 6 |
OpenAPI,
|
|
|
|
| 1188 |
return result
|
| 1189 |
|
| 1190 |
|
| 1191 |
+
def _adjust_union_types(
|
| 1192 |
+
schema: dict[str, Any] | list[Any],
|
| 1193 |
+
) -> dict[str, Any] | list[Any]:
|
| 1194 |
+
"""Recursively replace 'oneOf' with 'anyOf' in schema to handle overlapping unions."""
|
| 1195 |
+
if isinstance(schema, dict):
|
| 1196 |
+
if "oneOf" in schema:
|
| 1197 |
+
schema["anyOf"] = schema.pop("oneOf")
|
| 1198 |
+
for k, v in schema.items():
|
| 1199 |
+
schema[k] = _adjust_union_types(v)
|
| 1200 |
+
elif isinstance(schema, list):
|
| 1201 |
+
return [_adjust_union_types(item) for item in schema]
|
| 1202 |
+
return schema
|
| 1203 |
+
|
| 1204 |
+
|
| 1205 |
def extract_output_schema_from_responses(
|
| 1206 |
responses: dict[str, ResponseInfo], schema_definitions: dict[str, Any] | None = None
|
| 1207 |
) -> dict[str, Any] | None:
|
|
|
|
| 1290 |
# Use compress_schema to remove unused definitions
|
| 1291 |
output_schema = compress_schema(output_schema)
|
| 1292 |
|
| 1293 |
+
# Adjust union types to handle overlapping unions
|
| 1294 |
+
output_schema = cast(dict[str, Any], _adjust_union_types(output_schema))
|
| 1295 |
+
|
| 1296 |
return output_schema
|
tests/utilities/openapi/test_openapi_output_schemas.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
from fastmcp.utilities.openapi import (
|
| 4 |
ResponseInfo,
|
|
|
|
| 5 |
extract_output_schema_from_responses,
|
| 6 |
)
|
| 7 |
|
|
@@ -234,3 +235,42 @@ class TestExtractOutputSchema:
|
|
| 234 |
assert "User" in result["$defs"]
|
| 235 |
assert result["properties"]["result"]["type"] == "array"
|
| 236 |
assert result["properties"]["result"]["items"]["$ref"] == "#/$defs/User"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from fastmcp.utilities.openapi import (
|
| 4 |
ResponseInfo,
|
| 5 |
+
_adjust_union_types,
|
| 6 |
extract_output_schema_from_responses,
|
| 7 |
)
|
| 8 |
|
|
|
|
| 235 |
assert "User" in result["$defs"]
|
| 236 |
assert result["properties"]["result"]["type"] == "array"
|
| 237 |
assert result["properties"]["result"]["items"]["$ref"] == "#/$defs/User"
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def test_adjust_union_types():
|
| 241 |
+
"""Test that oneOf is replaced with anyOf in schemas."""
|
| 242 |
+
schema = {"oneOf": [{"type": "string"}, {"type": "number"}]}
|
| 243 |
+
result = _adjust_union_types(schema)
|
| 244 |
+
assert isinstance(result, dict)
|
| 245 |
+
assert "anyOf" in result
|
| 246 |
+
assert "oneOf" not in result
|
| 247 |
+
assert len(result["anyOf"]) == 2
|
| 248 |
+
assert result["anyOf"][0] == {"type": "string"}
|
| 249 |
+
assert result["anyOf"][1] == {"type": "number"}
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def test_extract_output_schema_converts_oneOf_to_anyOf():
|
| 253 |
+
"""Test that extracted schema converts oneOf to anyOf."""
|
| 254 |
+
responses = {
|
| 255 |
+
"200": ResponseInfo(
|
| 256 |
+
description="Success",
|
| 257 |
+
content_schema={
|
| 258 |
+
"application/json": {
|
| 259 |
+
"type": "object",
|
| 260 |
+
"properties": {
|
| 261 |
+
"result": {
|
| 262 |
+
"oneOf": [
|
| 263 |
+
{"$ref": "#/$defs/TypeA"},
|
| 264 |
+
{"$ref": "#/$defs/TypeB"},
|
| 265 |
+
]
|
| 266 |
+
}
|
| 267 |
+
},
|
| 268 |
+
}
|
| 269 |
+
},
|
| 270 |
+
)
|
| 271 |
+
}
|
| 272 |
+
schema_definitions = {"TypeA": {"type": "string"}, "TypeB": {"type": "number"}}
|
| 273 |
+
result = extract_output_schema_from_responses(responses, schema_definitions)
|
| 274 |
+
assert result is not None
|
| 275 |
+
assert "oneOf" not in str(result) # Ensure no oneOf remains
|
| 276 |
+
assert "anyOf" in str(result) # Ensure anyOf is present
|