Skip to content
Open
Show file tree
Hide file tree
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
115 changes: 114 additions & 1 deletion fastmcp_slim/fastmcp/utilities/json_schema_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,10 @@ def _object_schema_to_type(
"""
has_properties = bool(schema.get("properties"))
additional_props = schema.get("additionalProperties")
# Per JSON Schema, an empty schema {} means "accept anything" —
# equivalent to additionalProperties: true.
if isinstance(additional_props, dict) and not additional_props:
additional_props = True
class_name = name if name is not None else schema.get("title")

if not has_properties and additional_props:
Expand Down Expand Up @@ -453,7 +457,13 @@ def _schema_to_type(
if not schema:
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 Route typed object compositions through composition handling

For schemas that include the standard OpenAPI form {"type": "object", "allOf": [...]} or {"type": "object", "oneOf": [...]}, the top-level json_schema_to_type() returns through _object_schema_to_type() before this _schema_to_type() guard and the new composition handlers are never reached. That still accepts invalid payloads as dict[str, Any] or a plain object model instead of enforcing the composed branches, so typed object compositions need to be routed through the allOf/oneOf logic before the object shortcut.

Useful? React with 👍 / 👎.

return object

if "type" not in schema and "properties" in schema:
if (
"type" not in schema
and "properties" in schema
and "allOf" not in schema
and "oneOf" not in schema
and "anyOf" not in schema
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 Preserve sibling constraints when routing anyOf schemas

When an implicit object schema has sibling properties/required plus anyOf, this new guard sends it to the anyOf handler, but that handler builds a union only from the child schemas and drops the sibling object constraints. For example, { "required": ["id"], "properties": {"id": {"type": "string"}}, "anyOf": [{"properties": {"a": {"type": "integer"}}}] } now accepts {"a": 1} with no id, whereas the sibling keywords must still be evaluated alongside anyOf.

Useful? React with 👍 / 👎.

):
return _create_dataclass(schema, schema.get("title", "<unknown>"), schemas)

# Handle references first
Expand Down Expand Up @@ -493,6 +503,109 @@ def _schema_to_type(
else:
return Union[tuple(types)] # type: ignore # noqa: UP007

# Handle allOf (schema intersection / composition).
# Flatten all sub-schemas into a single merged object schema.
if "allOf" in schema:
merged: dict[str, Any] = {}
merged_properties: dict[str, Any] = {}
merged_required: list[str] = []
Comment on lines +509 to +511
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 Preserve sibling fields when flattening allOf

The allOf merge path initializes merged/merged_properties from empty dicts and only copies fields from each allOf child, so any sibling properties/required already present on the current schema are dropped. In nested schemas that combine local fields with allOf, this now silently stops validating previously-required local fields (and omits them from the parsed object), which is a regression in validation behavior for composed objects.

Useful? React with 👍 / 👎.

has_false = False

def _collect_allof(sub: Any) -> None:
"""Recursively collect properties from a sub-schema."""
nonlocal has_false
if sub is False:
has_false = True
return
if sub is True:
return
if isinstance(sub, bool):
return
Comment on lines +522 to +523
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 Treat false branches in allOf as unsatisfiable

Returning early for boolean sub-schemas silently ignores false inside allOf, but JSON Schema semantics require allOf to fail if any member is false. With the current logic, a schema like {"allOf": [false, {"type":"object", ...}]} still validates inputs against the object branch, so invalid payloads are accepted instead of being rejected.

Useful? React with 👍 / 👎.

if "$ref" in sub:
resolved = _resolve_ref(sub["$ref"], schemas)
if isinstance(resolved, bool):
if resolved is False:
has_false = True
return
sub = dict(resolved)
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 Merge $ref siblings when flattening allOf branches

When an allOf member contains $ref plus sibling keywords, sub = dict(resolved) overwrites the original branch and drops those siblings. Under JSON Schema 2020-12, adjacent keywords with $ref are evaluated together, so this can silently remove required fields or other constraints from referenced branches and under-validate inputs.

Useful? React with 👍 / 👎.

# Recurse into nested allOf
if "allOf" in sub:
for nested in sub["allOf"]:
_collect_allof(nested)
merged_properties.update(sub.get("properties", {}))
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 Preserve overlapping allOf property constraints

When the same property appears in multiple allOf branches, update() keeps only the last branch's schema instead of requiring the value to satisfy both schemas. For example, allOf with x constrained to enum: ["a"] in one branch and type: string in a later branch becomes just string, so { "x": "b" } is accepted even though it fails the composed schema; branch order can now silently broaden or narrow validation.

Useful? React with 👍 / 👎.

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 Keep closed allOf branches closed

When an allOf child has additionalProperties: false, unioning every child’s properties into one merged schema makes that closed child allow properties that were only declared by sibling branches. For example, allOf with one branch declaring only a plus additionalProperties: false and another branch declaring b should reject {"a":"x","b":1} because b is an extra property for the closed branch, but the merged model includes both a and b and validates it, so these composed closed schemas are under-enforced.

Useful? React with 👍 / 👎.

merged_required.extend(sub.get("required", []))
for key in ("title", "description"):
if key in sub and key not in merged:
merged[key] = sub[key]
# Intersect additionalProperties: false is most restrictive and wins.
if "additionalProperties" in sub:
existing = merged.get("additionalProperties")
if existing is None:
merged["additionalProperties"] = sub["additionalProperties"]
elif sub["additionalProperties"] is False:
merged["additionalProperties"] = False

# Collect from allOf children first, then overlay sibling
# properties so local definitions take precedence over inherited.
for sub in schema["allOf"]:
_collect_allof(sub)

merged_properties.update(schema.get("properties", {}))
merged_required.extend(schema.get("required", []))
Comment on lines +553 to +554
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 top-level additionalProperties in allOf merge

The allOf merge only copies sibling properties/required from the parent schema, so parent-level additionalProperties is dropped. For schemas that combine allOf with additionalProperties: true, this changes behavior from the normal object path (which builds a BaseModel with extras allowed) to a dataclass path that drops unknown keys, causing silent data loss for extra fields.

Useful? React with 👍 / 👎.


if has_false:
return _UnsatisfiableType # type: ignore[return-value]

if merged_properties:
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 map-only allOf object branches

When an allOf branch is an object map such as {"type": "object", "additionalProperties": {"type": "integer"}}, it has no properties, so merged_properties stays empty and the function falls through to Any. In that case TypeAdapter(json_schema_to_type(schema)).validate_python({"x": "bad"}) is accepted even though the composed map branch should enforce integer values.

Useful? React with 👍 / 👎.

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 Enforce allOf branches without object properties

This allOf implementation only returns a merged schema when it collected object properties; if the branches are scalar, array, or map-only schemas, merged_properties stays empty and the function falls through to Any when there is no top-level type. A valid schema like {"allOf": [{"type": "string", "minLength": 2}]} therefore validates 1 or any other value, so non-object allOf compositions are completely under-validated.

Useful? React with 👍 / 👎.

merged["type"] = "object"
merged["properties"] = merged_properties
if merged_required:
merged["required"] = list(dict.fromkeys(merged_required))
# Preserve additionalProperties from the parent schema, not just
# from allOf children, so schemas like
# {"additionalProperties": true, "allOf": [...]} allow extra keys.
if (
"additionalProperties" not in merged
and "additionalProperties" in schema
):
merged["additionalProperties"] = schema["additionalProperties"]
Comment on lines +567 to +571
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 Let parent additionalProperties false override children

When an allOf child sets additionalProperties: true and the parent schema sets additionalProperties: false, this condition skips the parent value because merged already has the child's setting. JSON Schema applies the sibling keyword too, so a parent-level false should still forbid extras; currently the merged model allows and preserves unknown keys for schemas such as {"additionalProperties": false, "allOf": [{"type": "object", "properties": {"a": {"type": "string"}}, "additionalProperties": true}]}.

Useful? React with 👍 / 👎.

return _schema_to_type(merged, schemas)
# allOf with no mergeable properties — fall through to Any

# Handle oneOf (exactly-one-of union).
# Treat like anyOf for type construction — Pydantic's Union
# does "first match" which is a reasonable approximation.
if "oneOf" in schema:
types: list[type | Any] = []
for subschema in schema["oneOf"]:
# Same dict special-case as the anyOf handler: detect
# map-like objects so they become dict[str, X] instead
# of empty dataclasses that discard key/value data.
if (
isinstance(subschema, dict)
and subschema.get("type") == "object"
and not subschema.get("properties")
and "additionalProperties" in subschema
):
additional_props = subschema["additionalProperties"]
if additional_props is True or additional_props == {}:
types.append(dict[str, Any])
else:
value_type = _schema_to_type(additional_props, schemas)
types.append(dict[str, value_type]) # type: ignore
else:
types.append(_schema_to_type(subschema, schemas))
Comment on lines +596 to +597
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 Intersect oneOf with sibling object constraints

When a schema has sibling properties/required next to oneOf, this loop builds the result only from each child schema and drops the parent constraints. For a schema like { "required": ["id"], "properties": {"id": ...}, "oneOf": [...] }, any payload matching a child branch validates even when id is missing, but JSON Schema evaluates the sibling keywords together with the oneOf.

Useful? React with 👍 / 👎.

has_null = type(None) in types
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] | None if has_null else types[0] # type: ignore
else:
if has_null:
return Union[(*types, type(None))] # type: ignore
return Union[tuple(types)] # type: ignore # noqa: UP007
Comment on lines +605 to +607
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 Enforce oneOf's exactly-one semantics

This returns a plain Union, which accepts values that match more than one branch instead of rejecting them as JSON Schema oneOf requires. In overlapping cases such as oneOf: [{"type":"integer"}, {"type":"number"}], the value 1 validates successfully even though it matches both branches, so schemas using oneOf for mutual exclusion are under-validated.

Useful? React with 👍 / 👎.


schema_type = schema.get("type")
if not schema_type:
return Any
Expand Down
Loading
Loading