From a3927d556dcc50ab8aae2bd6b8aeb3f7c96d1807 Mon Sep 17 00:00:00 2001 From: strawgate Date: Sat, 25 Apr 2026 18:17:44 -0500 Subject: [PATCH] fix: handle boolean schemas and false branches in allOf/oneOf - 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 --- .../fastmcp/utilities/json_schema_type.py | 26 ++++++++++++- .../json_schema_type/test_json_schema_type.py | 38 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/fastmcp_slim/fastmcp/utilities/json_schema_type.py b/fastmcp_slim/fastmcp/utilities/json_schema_type.py index a079fd9a3..3bd70c04a 100644 --- a/fastmcp_slim/fastmcp/utilities/json_schema_type.py +++ b/fastmcp_slim/fastmcp/utilities/json_schema_type.py @@ -499,13 +499,25 @@ def _schema_to_type( merged: dict[str, Any] = {} merged_properties: dict[str, Any] = {} merged_required: list[str] = [] + 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 if "$ref" in sub: - sub = dict(_resolve_ref(sub["$ref"], schemas)) + resolved = _resolve_ref(sub["$ref"], schemas) + if isinstance(resolved, bool): + if resolved is False: + has_false = True + return + sub = dict(resolved) # Recurse into nested allOf if "allOf" in sub: for nested in sub["allOf"]: @@ -524,11 +536,23 @@ def _schema_to_type( for sub in schema["allOf"]: _collect_allof(sub) + + if has_false: + return _UnsatisfiableType # type: ignore[return-value] + if merged_properties: 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"] return _schema_to_type(merged, schemas) # allOf with no mergeable properties — fall through to Any diff --git a/tests/utilities/json_schema_type/test_json_schema_type.py b/tests/utilities/json_schema_type/test_json_schema_type.py index e46193305..842cf53d1 100644 --- a/tests/utilities/json_schema_type/test_json_schema_type.py +++ b/tests/utilities/json_schema_type/test_json_schema_type.py @@ -610,3 +610,41 @@ class TestAllOfOneOf: assert ta.validate_python("hello") == "hello" assert ta.validate_python({"x": 1, "y": 2}) == {"x": 1, "y": 2} + + def test_allof_false_sub_schema_is_unsatisfiable(self): + """allOf containing `false` should produce an unsatisfiable type.""" + schema = { + "allOf": [ + {"type": "object", "properties": {"name": {"type": "string"}}}, + False, + ] + } + T = json_schema_to_type(schema) + ta = TypeAdapter(T) + + with pytest.raises(ValidationError): + ta.validate_python({"name": "Alice"}) + + def test_allof_only_false_is_unsatisfiable(self): + """allOf with only `false` should produce an unsatisfiable type.""" + schema = {"allOf": [False]} + T = json_schema_to_type(schema) + ta = TypeAdapter(T) + + with pytest.raises(ValidationError): + ta.validate_python({"any": "value"}) + + def test_allof_ref_to_boolean_schema(self): + """allOf with a $ref resolving to `false` should be unsatisfiable.""" + schema = { + "allOf": [ + {"$ref": "#/$defs/Never"}, + {"type": "object", "properties": {"name": {"type": "string"}}}, + ], + "$defs": {"Never": False}, + } + T = json_schema_to_type(schema) + ta = TypeAdapter(T) + + with pytest.raises(ValidationError): + ta.validate_python({"name": "Alice"})