Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/fastmcp/utilities/json_schema_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,56 @@ def _return_Any() -> Any:
return Any


def _merge_object_types(types: list[type]) -> type | None:
"""Try to merge multiple object types into one.

For allOf schemas where all sub-schemas are objects/dataclasses,
we can create a merged dataclass with combined properties.
Returns None if types can't be meaningfully merged.
"""
from dataclasses import Field as DataclassField, make_dataclass
from typing import get_origin, get_args, Union

# Filter to only types that have __annotations__ (dataclass-like)
object_types = []
for t in types:
origin = get_origin(t)
if origin is None and hasattr(t, "__annotations__"):
object_types.append(t)
elif origin is Union:
# Unwrap Union types to find object types
for arg in get_args(t):
if hasattr(arg, "__annotations__"):
object_types.append(arg)

if len(object_types) < 2:
return None

# Merge all properties from object types
merged_fields: dict[str, tuple[type, DataclassField]] = {}
for obj_type in object_types:
for field_name, field_type in obj_type.__annotations__.items():
if field_name not in merged_fields:
# Get default from the field
if hasattr(obj_type, "__dataclass_fields__"):
dc_field = obj_type.__dataclass_fields__.get(field_name)
if dc_field and dc_field.default is not DataclassField.MISSING:
merged_fields[field_name] = (field_type, field(default=dc_field.default))
elif dc_field and dc_field.default_factory is not DataclassField.MISSING:
merged_fields[field_name] = (field_type, field(default_factory=dc_field.default_factory))
Comment on lines +416 to +419
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use dataclasses.MISSING for merged field defaults

Checking dc_field.default and dc_field.default_factory against DataclassField.MISSING raises AttributeError because Field has no MISSING attribute. Any allOf schema that reaches _merge_object_types() with dataclass-backed object branches will crash during type generation instead of returning a merged type.

Useful? React with 👍 / 👎.

else:
merged_fields[field_name] = (field_type, field())
else:
merged_fields[field_name] = (field_type, field())

if not merged_fields:
return None

# Create merged dataclass
field_specs = [(name, ftype, fdef) for name, (ftype, fdef) in merged_fields.items()]
return make_dataclass("MergedAllOf", field_specs, kw_only=True)


def _object_schema_to_type(
schema: Mapping[str, Any],
schemas: Mapping[str, Any],
Expand Down Expand Up @@ -470,6 +520,34 @@ def _schema_to_type(
if "enum" in schema:
return _create_enum(f"Enum_{len(_classes)}", schema["enum"])

# Handle oneOf unions (similar to anyOf but with exactly-one semantics)
if "oneOf" in schema:
types: list[type | Any] = [
_schema_to_type(subschema, schemas) for subschema in schema["oneOf"]
]
types = [t for t in types if t is not type(None)]
if len(types) == 0:
return type(None)
elif len(types) == 1:
return types[0]
Comment on lines +528 to +532
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve null option when converting oneOf to a union

The oneOf branch drops type(None) and never reintroduces it, so schemas like {"oneOf": [{"type": "string"}, {"type": "null"}]} are converted to str instead of str | None. This rejects valid null values and produces an inaccurate Python type for nullable oneOf schemas.

Useful? React with 👍 / 👎.

else:
return Union[tuple(types)] # type: ignore # noqa: UP007

# Handle allOf (intersection - value must satisfy ALL sub-schemas)
if "allOf" in schema:
types: list[type | Any] = [
_schema_to_type(subschema, schemas) for subschema in schema["allOf"]
]
types = [t for t in types if t is not type(None)]
if len(types) == 0:
return type(None)
elif len(types) == 1:
return types[0]
else:
# For allOf intersection, try to merge object types
merged = _merge_object_types(types)
return merged if merged is not None else Union[tuple(types)] # type: ignore # noqa: UP007

# Handle anyOf unions
if "anyOf" in schema:
types: list[type | Any] = [
Expand Down
Loading