Skip to content

[DNM] Handle allOf and oneOf in json_schema_to_type#3840

Open
strawgate wants to merge 7 commits into
mainfrom
fix/json-schema-allof-oneof
Open

[DNM] Handle allOf and oneOf in json_schema_to_type#3840
strawgate wants to merge 7 commits into
mainfrom
fix/json-schema-allof-oneof

Conversation

@strawgate
Copy link
Copy Markdown
Collaborator

@strawgate strawgate commented Apr 11, 2026

Summary

json_schema_to_type() handles anyOf but silently returns Any for allOf and oneOf schemas, disabling all validation. This is the most common schema composition pattern in OpenAPI 3.x specs.

allOf (schema intersection) sub-schemas are now merged into a single object type. Properties and required fields from all sub-schemas are combined. $ref sub-schemas are resolved before merging. Nested allOf (including $refallOf) is flattened recursively.

oneOf (exactly-one-of) is now treated like anyOf — creates a Union type. Pydantic's "first match" semantics are a reasonable approximation.

# Before: both return typing.Any
json_schema_to_type({"allOf": [{"type": "object", "properties": {"name": {"type": "string"}}}, ...]})
json_schema_to_type({"oneOf": [{"type": "string"}, {"type": "integer"}]})

# After: proper types with validation
json_schema_to_type({"allOf": [...]})  # → dataclass with merged properties
json_schema_to_type({"oneOf": [...]})  # → Union[str, int]

Known limitations

  • Property constraint merging: if two allOf sub-schemas define the same property with different constraints, the second sub-schema's definition wins (last-writer from dict.update). Merging per-property constraints (e.g., combining minLength from one with maxLength from another) is not implemented. This is rare in practice — the standard pattern uses allOf to combine different properties.

  • Non-object allOf: allOf with scalar sub-schemas that add constraints (e.g., [{type: string}, {minLength: 3}]) doesn't merge the constraints. Object composition (the dominant real-world use case) works correctly.

Closes #3839

🤖 Generated with Claude Code

@marvin-context-protocol marvin-context-protocol Bot added bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. DON'T MERGE PR is not ready for merging. Used by authors to prevent premature merging. openapi Related to OpenAPI integration, parsing, or code generation features. labels Apr 11, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 62c3f79d06

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +447 to +449
merged: dict[str, Any] = {}
merged_properties: dict[str, Any] = {}
merged_required: list[str] = []
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 👍 / 👎.

Comment on lines +475 to +476
for subschema in schema["oneOf"]:
types.append(_schema_to_type(subschema, schemas))
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 map-like branches intact in oneOf unions

The oneOf loop always calls _schema_to_type(subschema, schemas) directly, unlike anyOf which special-cases object schemas with additionalProperties to produce dict[...]. As a result, a branch like {"type":"object","additionalProperties":{"type":"string"}} becomes an empty dataclass in oneOf, so arbitrary dict inputs validate as Root() and key/value data is discarded instead of being type-checked as a mapping.

Useful? React with 👍 / 👎.

@strawgate strawgate force-pushed the fix/json-schema-allof-oneof branch 2 times, most recently from 4112e18 to 39f8815 Compare April 11, 2026 18:48
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 39f88156e5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +453 to +454
if isinstance(sub, bool):
return
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 👍 / 👎.

Comment on lines +455 to +456
if "$ref" in sub:
sub = dict(_resolve_ref(sub["$ref"], schemas))
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 Avoid dict-casting boolean refs in allOf flattening

When an allOf member is a $ref to a boolean schema (valid in draft-06+), this code does dict(_resolve_ref(...)), which raises TypeError for true/false targets. That makes json_schema_to_type crash on valid schemas such as {"$defs": {"Never": false}, "allOf": [{"$ref": "#/$defs/Never"}]} instead of producing an unsatisfiable type.

Useful? React with 👍 / 👎.

Comment on lines +470 to +471
merged_properties.update(schema.get("properties", {}))
merged_required.extend(schema.get("required", []))
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 👍 / 👎.

@nidhishgajjar

This comment was marked as low quality.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fb5c0f536b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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:
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 Process property+oneOf schemas through oneOf handling

The early return for schemas with implicit objects ("type" missing + "properties" present) only excludes allOf, so schemas that also include oneOf never reach the new union logic. In those cases, only the top-level properties are enforced and branch-specific oneOf constraints are skipped, which can accept invalid payloads in discriminator-style schemas.

Useful? React with 👍 / 👎.

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 👍 / 👎.

isinstance(subschema, dict)
and subschema.get("type") == "object"
and not subschema.get("properties")
and subschema.get("additionalProperties")
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 Recognize empty additionalProperties maps in oneOf

The oneOf map-branch check relies on truthiness of additionalProperties; for additionalProperties: {} (valid and common for free-form maps), the condition is false and the branch is parsed as an object dataclass instead of dict[str, Any]. That causes oneOf unions to lose map semantics for unconstrained dictionary branches.

Useful? React with 👍 / 👎.

strawgate and others added 3 commits May 12, 2026 22:47
Previously these compositions silently resolved to Any, disabling
all validation for schemas that use them. allOf is now merged into
a single object schema; oneOf creates a Union type.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix dict() crash when $ref resolves to boolean schema (draft-06+)
- Treat allOf containing 'false' as unsatisfiable (reject all values)
- Add tests for boolean ref, false sub-schema, and false-only allOf
- additionalProperties preservation from parent noted as pre-existing limitation
- Guard property+oneOf schemas from premature dataclass return
- Recognize empty additionalProperties ({}) as allow-any in oneOf
- Boolean false allOf branches and sibling properties already handled

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@strawgate strawgate force-pushed the fix/json-schema-allof-oneof branch from fb5c0f5 to 08236c4 Compare May 13, 2026 03:48
@marvin-context-protocol
Copy link
Copy Markdown
Contributor

marvin-context-protocol Bot commented May 13, 2026

tl;dr (edited 2026-05-13 to reflect latest run): The latest CI run failed on the same unrelated issue — a pytest-timeout in tests/client/test_streamable_http.py on Windows Python 3.10 only. This PR only changes fastmcp_slim/fastmcp/utilities/json_schema_type.py and its tests, so the failure isn't caused by these changes. Safe to re-run; the earlier ty issue (below) still needs to be addressed when this comes off DNM.

Latest failure (run 25781683564, head f954396): Only Tests: Python 3.10 on windows-latest failed (job 75725563484). All other jobs passed: Linux Py 3.10, Linux Py 3.13, lowest-direct, integration, MCP conformance, and package install smoke. The Windows job hit pytest-timeout inside tests/client/test_streamable_http.py (immediately after tests/client/test_stdio.py finished at [ 18%]) with stderr ERROR: ASGI callable returned without completing response. and the stack trace ending in asyncio selectors.select — i.e. the test was waiting on a socket when the timeout fired. This file isn't touched by the PR (gh pr diff 3840 --name-only shows only json_schema_type.py + its test file), so this remains a Windows-only transport flake unrelated to the diff. A re-run should clear it.

Failure excerpt
tests\client\test_streamable_http.py +++ Timeout +++
~~~ Captured stderr ~~~
ERROR:    ASGI callable returned without completing response.
~~~ Stack of MainThread ~~~
  File "...\_pytest\runner.py", line 179, in pytest_runtest_call
    item.runtest()
  ...
  File "C:\hostedtoolcache\windows\Python\3.10.11\x64\lib\asyncio\base_events.py", line 1871, in _run_once
    event_list = self._selector.select(timeout)
  File "...\selectors.py", line 315, in _select
    r, w, x = select.select(r, w, w, timeout)
+++ Timeout +++
##[error]Process completed with exit code 1.

Previous analysis (kept for context — still applicable when this PR comes off DNM):

Static analysis (ty type-check) failed on 6 lines in tests/utilities/json_schema_type/test_json_schema_type.py. Six newly added tests access attributes on dynamically-typed validate_python(...) results without the # ty:ignore[unresolved-attribute] suppression that every other test in the file uses for the same pattern. The repo's prek config runs ty check as a gate, so this is what's blocking the prek/lint check.

Fix: Append # ty:ignore[unresolved-attribute] (the same comment used throughout this test file) to the lines ty flagged:

  • tests/utilities/json_schema_type/test_json_schema_type.py:622assert result.name == "Alice"
  • tests/utilities/json_schema_type/test_json_schema_type.py:623assert result.age == 30
  • tests/utilities/json_schema_type/test_json_schema_type.py:653assert result.id == "user-123"
  • tests/utilities/json_schema_type/test_json_schema_type.py:683assert result.id == "abc"
  • tests/utilities/json_schema_type/test_json_schema_type.py:741assert result.id == "abc"
  • tests/utilities/json_schema_type/test_json_schema_type.py:742assert result.value == 42

After that, run uv run prek run --all-files locally to confirm a clean pass before pushing.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 08236c4345

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@@ -453,7 +453,7 @@ 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 👍 / 👎.

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 👍 / 👎.

strawgate and others added 2 commits May 12, 2026 23:00
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 087d4b3b84

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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 👍 / 👎.

Comment on lines +594 to +596
if has_null:
return Union[(*types, type(None))] # type: ignore
return Union[tuple(types)] # type: ignore # noqa: UP007
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 👍 / 👎.

Comment on lines +585 to +586
else:
types.append(_schema_to_type(subschema, schemas))
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 👍 / 👎.

Bugs fixed:
- additionalProperties: {} now treated as true (per JSON Schema spec)
- Sibling properties override allOf children (local > inherited)
- additionalProperties: false wins when allOf children conflict
- anyOf guard added to typeless-properties shortcircuit

New test coverage:
- Empty additionalProperties schema (with and without properties)
- Sibling property precedence over allOf children
- additionalProperties intersection in allOf
- oneOf with null branch
- properties + anyOf not shortcircuited
- allOf with only required (no properties)
- oneOf with empty additionalProperties dict
- allOf with sibling additionalProperties: true

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 025ed3594f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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 👍 / 👎.

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.

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 👍 / 👎.

Comment on lines +567 to +571
if (
"additionalProperties" not in merged
and "additionalProperties" in schema
):
merged["additionalProperties"] = schema["additionalProperties"]
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 👍 / 👎.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f9543964de

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. DON'T MERGE PR is not ready for merging. Used by authors to prevent premature merging. openapi Related to OpenAPI integration, parsing, or code generation features.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

json_schema_to_type silently returns Any for allOf and oneOf schemas

2 participants