From d69a86dccfe2a21615d020985ddba0654e4dcf13 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:47:27 -0400 Subject: [PATCH] fix: enforce false boolean schemas as unsatisfiable `true` returns Any (any value valid), but `false` now returns an unsatisfiable type that rejects all values during Pydantic validation. --- src/fastmcp/utilities/json_schema_type.py | 37 +++++++++++++------ .../json_schema_type/test_json_schema_type.py | 7 +++- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/fastmcp/utilities/json_schema_type.py b/src/fastmcp/utilities/json_schema_type.py index a975f8e90..8b50ff568 100644 --- a/src/fastmcp/utilities/json_schema_type.py +++ b/src/fastmcp/utilities/json_schema_type.py @@ -53,6 +53,7 @@ from typing import ( from pydantic import ( AnyUrl, BaseModel, + BeforeValidator, ConfigDict, EmailStr, Field, @@ -65,6 +66,15 @@ from typing_extensions import NotRequired, TypedDict __all__ = ["JSONSchema", "json_schema_to_type"] +def _reject_all(v: Any) -> Any: + """Validator that rejects every value, implementing JSON Schema `false`.""" + raise ValueError("No value is valid against a false schema") + + +# JSON Schema `false` means no value is valid. This type rejects everything +# during Pydantic validation. +_UnsatisfiableType = Annotated[Any, BeforeValidator(_reject_all)] + FORMAT_TYPES: dict[str, Any] = { "date-time": datetime, "email": EmailStr, @@ -319,9 +329,11 @@ def _schema_to_type( """Convert schema to appropriate Python type.""" # Boolean schemas are valid in JSON Schema draft-06+: # true means "any value is valid" (equivalent to {}), - # false means "no value is valid". - if isinstance(schema, bool): + # false means "no value is valid" (unsatisfiable). + if schema is True: return Any + if schema is False: + return _UnsatisfiableType # type: ignore[return-value] # ty:ignore[invalid-return-type] if not schema: return object @@ -481,11 +493,13 @@ def _create_pydantic_model( defaults = {} for prop_name, prop_schema in properties.items(): - # Normalize boolean schemas (JSON Schema draft-06+) + # Boolean schemas (JSON Schema draft-06+): resolve type directly, + # then use an empty dict for .get() calls below. if isinstance(prop_schema, bool): + field_type = _schema_to_type(prop_schema, schemas or {}) prop_schema = {} - - field_type = _schema_to_type(prop_schema, schemas or {}) + else: + field_type = _schema_to_type(prop_schema, schemas or {}) # Handle defaults default_value = prop_schema.get("default", MISSING) @@ -548,14 +562,15 @@ def _create_dataclass( fields: list[tuple[Any, ...]] = [] for prop_name, prop_schema in properties.items(): - # Normalize boolean schemas (JSON Schema draft-06+) - if isinstance(prop_schema, bool): - prop_schema = {} - field_name = _sanitize_name(prop_name) - # Check for self-reference in property - if prop_schema.get("$ref") == "#": + # Boolean schemas (JSON Schema draft-06+): resolve type directly, + # then use an empty dict for .get() calls below. + if isinstance(prop_schema, bool): + field_type = _schema_to_type(prop_schema, schemas or {}) + prop_schema = {} + elif prop_schema.get("$ref") == "#": + # Check for self-reference in property field_type = ForwardRef(sanitized_name) else: field_type = _schema_to_type(prop_schema, schemas or {}) 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 6905eed14..ec83f8b88 100644 --- a/tests/utilities/json_schema_type/test_json_schema_type.py +++ b/tests/utilities/json_schema_type/test_json_schema_type.py @@ -127,8 +127,8 @@ class TestBooleanSchemas: assert obj.name == "test" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] assert obj.anything == 42 # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] - def test_false_property_schema_does_not_crash(self): - """A property with schema `false` should not crash parsing.""" + def test_false_property_schema_rejects_values(self): + """A property with schema `false` should reject any provided value.""" schema = { "type": "object", "properties": {"name": {"type": "string"}, "never": False}, @@ -139,6 +139,9 @@ class TestBooleanSchemas: obj = validator.validate_python({"name": "test"}) assert obj.name == "test" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + with pytest.raises(ValidationError): + validator.validate_python({"name": "test", "never": "anything"}) + def test_boolean_schema_in_object_with_additional_properties(self): """Boolean property schemas work alongside additionalProperties=True.""" schema = {