From e5b96343d134ab3f4b3085377a3eca0f5fdde1be Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:55:51 -0400 Subject: [PATCH] Fix boolean property schemas in JSON Schema parsing (#3785) --- src/fastmcp/utilities/json_schema_type.py | 41 +++++++++-- .../json_schema_type/test_json_schema_type.py | 71 +++++++++++++++++++ 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/src/fastmcp/utilities/json_schema_type.py b/src/fastmcp/utilities/json_schema_type.py index e45bab71a..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, @@ -313,10 +323,18 @@ def _get_from_type_handler( def _schema_to_type( - schema: Mapping[str, Any], + schema: Mapping[str, Any] | bool, schemas: Mapping[str, Any], ) -> type | ForwardRef: """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" (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 @@ -475,7 +493,13 @@ def _create_pydantic_model( defaults = {} for prop_name, prop_schema in properties.items(): - field_type = _schema_to_type(prop_schema, schemas or {}) + # 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 = {} + else: + field_type = _schema_to_type(prop_schema, schemas or {}) # Handle defaults default_value = prop_schema.get("default", MISSING) @@ -540,8 +564,13 @@ def _create_dataclass( for prop_name, prop_schema in properties.items(): 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 {}) @@ -623,6 +652,10 @@ def _merge_defaults( # For each property in the schema for prop_name, prop_schema in schema.get("properties", {}).items(): + # Normalize boolean schemas (JSON Schema draft-06+) + if isinstance(prop_schema, bool): + continue + # If property is missing, apply defaults in priority order if prop_name not in result: if parent_default and prop_name in parent_default: 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 fc2226bdc..ec83f8b88 100644 --- a/tests/utilities/json_schema_type/test_json_schema_type.py +++ b/tests/utilities/json_schema_type/test_json_schema_type.py @@ -111,6 +111,77 @@ class TestSimpleTypes: validator.validate_python(False) +class TestBooleanSchemas: + """JSON Schema draft-06+ allows true/false as property schemas.""" + + def test_true_property_schema_accepts_any_value(self): + """A property with schema `true` should accept any value.""" + schema = { + "type": "object", + "properties": {"name": {"type": "string"}, "anything": True}, + "required": ["name", "anything"], + } + result = json_schema_to_type(schema) + validator = TypeAdapter(result) + obj = validator.validate_python({"name": "test", "anything": 42}) + 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_rejects_values(self): + """A property with schema `false` should reject any provided value.""" + schema = { + "type": "object", + "properties": {"name": {"type": "string"}, "never": False}, + "required": ["name"], + } + result = json_schema_to_type(schema) + validator = TypeAdapter(result) + 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 = { + "type": "object", + "properties": { + "known": {"type": "string"}, + "flexible": True, + }, + "required": ["known"], + "additionalProperties": True, + } + result = json_schema_to_type(schema) + validator = TypeAdapter(result) + obj = validator.validate_python( + {"known": "hello", "flexible": [1, 2, 3], "extra": "field"} + ) + assert obj.known == "hello" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + assert obj.flexible == [1, 2, 3] # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + + def test_issue_3783_boolean_property_schemas(self): + """Regression test for GitHub issue #3783.""" + schema = { + "type": "object", + "properties": { + "ts": {"type": "integer"}, + "level": True, + "app": True, + "tag": {"type": ["array", "null"], "items": {"type": "string"}}, + }, + "required": ["ts"], + "additionalProperties": True, + } + result = json_schema_to_type(schema) + validator = TypeAdapter(result) + obj = validator.validate_python({"ts": 123, "level": "info", "app": "myapp"}) + assert obj.ts == 123 # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + assert obj.level == "info" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + assert obj.app == "myapp" # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] + + class TestConstrainedTypes: def test_constant(self): validator = TypeAdapter(Literal["x"])