Skip to content

Commit 62c3f79

Browse files
strawgateclaude
andcommitted
Handle allOf and oneOf in json_schema_to_type
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>
1 parent 95102c7 commit 62c3f79

File tree

2 files changed

+161
-0
lines changed

2 files changed

+161
-0
lines changed

src/fastmcp/utilities/json_schema_type.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,50 @@ def _schema_to_type(
441441
else:
442442
return Union[tuple(types)] # type: ignore # noqa: UP007
443443

444+
# Handle allOf (schema intersection / composition).
445+
# Merge all sub-schemas into a single object schema, then process it.
446+
if "allOf" in schema:
447+
merged: dict[str, Any] = {}
448+
merged_properties: dict[str, Any] = {}
449+
merged_required: list[str] = []
450+
for sub in schema["allOf"]:
451+
if isinstance(sub, bool):
452+
continue
453+
# Resolve $ref before merging
454+
if "$ref" in sub:
455+
sub = dict(_resolve_ref(sub["$ref"], schemas))
456+
merged_properties.update(sub.get("properties", {}))
457+
merged_required.extend(sub.get("required", []))
458+
# Carry over other keys from the first sub-schema that has them
459+
for key in ("title", "description", "additionalProperties"):
460+
if key in sub and key not in merged:
461+
merged[key] = sub[key]
462+
if merged_properties:
463+
merged["type"] = "object"
464+
merged["properties"] = merged_properties
465+
if merged_required:
466+
merged["required"] = list(dict.fromkeys(merged_required))
467+
return _schema_to_type(merged, schemas)
468+
# allOf with no mergeable properties — fall through to Any
469+
470+
# Handle oneOf (exactly-one-of union).
471+
# Treat like anyOf for type construction — Pydantic's Union
472+
# does "first match" which is a reasonable approximation.
473+
if "oneOf" in schema:
474+
types: list[type | Any] = []
475+
for subschema in schema["oneOf"]:
476+
types.append(_schema_to_type(subschema, schemas))
477+
has_null = type(None) in types
478+
types = [t for t in types if t is not type(None)]
479+
if len(types) == 0:
480+
return type(None)
481+
elif len(types) == 1:
482+
return types[0] | None if has_null else types[0] # type: ignore
483+
else:
484+
if has_null:
485+
return Union[(*types, type(None))] # type: ignore
486+
return Union[tuple(types)] # type: ignore # noqa: UP007
487+
444488
schema_type = schema.get("type")
445489
if not schema_type:
446490
return Any

tests/utilities/json_schema_type/test_json_schema_type.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,3 +310,120 @@ def test_empty_property_name(self):
310310
field_names = [f.name for f in dataclasses.fields(T)]
311311
assert len(field_names) == 2
312312
assert len(set(field_names)) == 2
313+
314+
315+
class TestAllOfOneOf:
316+
"""allOf and oneOf composition, previously returning Any."""
317+
318+
def test_allof_merges_properties(self):
319+
"""allOf sub-schemas should be merged into a single object type."""
320+
schema = {
321+
"allOf": [
322+
{
323+
"type": "object",
324+
"properties": {"name": {"type": "string"}},
325+
"required": ["name"],
326+
},
327+
{
328+
"type": "object",
329+
"properties": {"age": {"type": "integer"}},
330+
},
331+
]
332+
}
333+
T = json_schema_to_type(schema)
334+
ta = TypeAdapter(T)
335+
336+
result = ta.validate_python({"name": "Alice", "age": 30})
337+
assert result.name == "Alice" # ty:ignore[unresolved-attribute]
338+
assert result.age == 30 # ty:ignore[unresolved-attribute]
339+
340+
def test_allof_preserves_required(self):
341+
"""Required fields from all allOf sub-schemas should be enforced."""
342+
schema = {
343+
"allOf": [
344+
{
345+
"type": "object",
346+
"properties": {"name": {"type": "string"}},
347+
"required": ["name"],
348+
},
349+
{
350+
"type": "object",
351+
"properties": {"age": {"type": "integer"}},
352+
},
353+
]
354+
}
355+
T = json_schema_to_type(schema)
356+
ta = TypeAdapter(T)
357+
358+
with pytest.raises(ValidationError):
359+
ta.validate_python({"age": 30}) # missing required 'name'
360+
361+
def test_allof_with_ref(self):
362+
"""allOf with $ref should resolve and merge the referenced schema."""
363+
schema = {
364+
"allOf": [
365+
{"$ref": "#/$defs/Base"},
366+
{
367+
"type": "object",
368+
"properties": {"extra": {"type": "string"}},
369+
},
370+
],
371+
"$defs": {
372+
"Base": {
373+
"type": "object",
374+
"properties": {"id": {"type": "integer"}},
375+
"required": ["id"],
376+
}
377+
},
378+
}
379+
T = json_schema_to_type(schema)
380+
ta = TypeAdapter(T)
381+
382+
result = ta.validate_python({"id": 1, "extra": "hello"})
383+
assert result.id == 1 # ty:ignore[unresolved-attribute]
384+
assert result.extra == "hello" # ty:ignore[unresolved-attribute]
385+
386+
def test_oneof_creates_union(self):
387+
"""oneOf should create a Union type that accepts any sub-schema."""
388+
schema = {
389+
"oneOf": [
390+
{
391+
"type": "object",
392+
"properties": {
393+
"kind": {"const": "dog"},
394+
"breed": {"type": "string"},
395+
},
396+
"required": ["kind", "breed"],
397+
},
398+
{
399+
"type": "object",
400+
"properties": {
401+
"kind": {"const": "cat"},
402+
"indoor": {"type": "boolean"},
403+
},
404+
"required": ["kind", "indoor"],
405+
},
406+
]
407+
}
408+
T = json_schema_to_type(schema)
409+
ta = TypeAdapter(T)
410+
411+
dog = ta.validate_python({"kind": "dog", "breed": "lab"})
412+
assert dog.kind == "dog" # ty:ignore[unresolved-attribute]
413+
414+
cat = ta.validate_python({"kind": "cat", "indoor": True})
415+
assert cat.indoor is True # ty:ignore[unresolved-attribute]
416+
417+
def test_oneof_with_scalars(self):
418+
"""oneOf with scalar types should create a Union."""
419+
schema = {
420+
"oneOf": [
421+
{"type": "string"},
422+
{"type": "integer"},
423+
]
424+
}
425+
T = json_schema_to_type(schema)
426+
ta = TypeAdapter(T)
427+
428+
assert ta.validate_python("hello") == "hello"
429+
assert ta.validate_python(42) == 42

0 commit comments

Comments
 (0)