diff --git a/src/fastmcp/utilities/json_schema_type.py b/src/fastmcp/utilities/json_schema_type.py index 4e06c5987..c3603185c 100644 --- a/src/fastmcp/utilities/json_schema_type.py +++ b/src/fastmcp/utilities/json_schema_type.py @@ -36,6 +36,7 @@ from __future__ import annotations import hashlib import json +import keyword import re from collections.abc import Callable, Mapping from copy import deepcopy @@ -140,13 +141,14 @@ class JSONSchema(TypedDict): def json_schema_to_type( - schema: Mapping[str, Any], + schema: Mapping[str, Any] | bool, name: str | None = None, ) -> type: """Convert JSON schema to appropriate Python type with validation. Args: - schema: A JSON Schema dictionary defining the type structure and validation rules + schema: A JSON Schema dictionary defining the type structure and validation rules. + Boolean schemas are also accepted (``True`` = any type, ``False`` = unsatisfiable). name: Optional name for object schemas. Only allowed when schema type is "object". If not provided for objects, name will be inferred from schema's "title" property or default to "Root". @@ -197,6 +199,12 @@ def json_schema_to_type( name: NameType ``` """ + # Boolean schemas (JSON Schema 2020-12 ยง4.3.2; also valid since draft-06) + if schema is True: + return Any + if schema is False: + return _UnsatisfiableType # type: ignore[return-value] # ty:ignore[invalid-return-type] + # Normalise YAML-parsed types (datetime/date โ†’ str, non-str keys โ†’ str) # so that downstream json.dumps/hashing and default values work correctly. schema = _normalize_yaml_types(schema) @@ -301,6 +309,11 @@ def _create_numeric_type( def _create_enum(name: str, values: list[Any]) -> type: """Create enum type from list of values.""" + if not values: + # Empty enum means no value is valid (same semantics as ``false`` + # schema). Return the unsatisfiable type instead of ``Literal[()]`` + # which triggers an AssertionError in Pydantic. + return _UnsatisfiableType # type: ignore[return-value] # ty:ignore[invalid-return-type] # Always return Literal for enum fields to preserve the literal nature return Literal[tuple(values)] # type: ignore[return-value] # ty:ignore[invalid-type-form] @@ -466,6 +479,9 @@ def _sanitize_name(name: str) -> str: # Step 5: only strip trailing underscores if they weren't in the original name if not original_name.endswith("_"): cleaned = cleaned.rstrip("_") + # Step 6: if result is a Python keyword, append an underscore (PEP 8 convention) + if keyword.iskeyword(cleaned): + cleaned = f"{cleaned}_" return cleaned @@ -597,8 +613,17 @@ def _create_dataclass( required = schema.get("required", []) fields: list[tuple[Any, ...]] = [] + used_field_names: set[str] = set() for prop_name, prop_schema in properties.items(): field_name = _sanitize_name(prop_name) + # Deduplicate: if sanitized names collide (e.g. "foo-bar" and + # "foo_bar" both become "foo_bar"), append a numeric suffix. + base = field_name + counter = 2 + while field_name in used_field_names: + field_name = f"{base}_{counter}" + counter += 1 + used_field_names.add(field_name) # Boolean schemas (JSON Schema draft-06+): resolve type directly, # then use an empty dict for .get() calls below. 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 ec83f8b88..07c9351e9 100644 --- a/tests/utilities/json_schema_type/test_json_schema_type.py +++ b/tests/utilities/json_schema_type/test_json_schema_type.py @@ -1,8 +1,9 @@ """Core JSON schema type conversion tests.""" +import dataclasses from dataclasses import Field from enum import Enum -from typing import Literal +from typing import Any, Literal import pytest from pydantic import TypeAdapter, ValidationError @@ -239,3 +240,73 @@ class TestConstrainedTypes: assert TypeAdapter(type_).validate_python("y") == "y" with pytest.raises(ValidationError): TypeAdapter(type_).validate_python("z") + + +class TestCrashPrevention: + """Schemas that previously caused crashes should now be handled gracefully.""" + + def test_boolean_schema_true(self): + """Boolean schema True should return Any (JSON Schema draft-06+).""" + assert json_schema_to_type(True) is Any + + def test_boolean_schema_false(self): + """Boolean schema False should return an unsatisfiable type.""" + result = json_schema_to_type(False) + with pytest.raises(ValidationError): + TypeAdapter(result).validate_python("anything") + + def test_python_keyword_property_names(self): + """Properties named after Python keywords should not crash.""" + schema = { + "type": "object", + "properties": { + "class": {"type": "string"}, + "return": {"type": "integer"}, + "import": {"type": "boolean"}, + }, + "required": ["class"], + } + T = json_schema_to_type(schema) + ta = TypeAdapter(T) + result = ta.validate_python({"class": "A", "return": 1, "import": True}) + assert result.class_ == "A" # ty:ignore[unresolved-attribute] + + def test_empty_enum(self): + """Empty enum means no value is valid โ€” should reject like a false schema.""" + schema = { + "type": "object", + "properties": {"status": {"enum": []}}, + "required": ["status"], + } + T = json_schema_to_type(schema) + ta = TypeAdapter(T) + with pytest.raises(ValidationError): + ta.validate_python({"status": "anything"}) + + def test_sanitized_name_collision(self): + """Properties that collide after sanitization get deduplicated.""" + schema = { + "type": "object", + "properties": { + "foo-bar": {"type": "string"}, + "foo_bar": {"type": "string"}, + }, + } + T = json_schema_to_type(schema) + field_names = [f.name for f in dataclasses.fields(T)] + assert len(field_names) == 2 + assert len(set(field_names)) == 2 + + def test_empty_property_name(self): + """Empty and whitespace-only property names should not crash.""" + schema = { + "type": "object", + "properties": { + "": {"type": "string"}, + " ": {"type": "integer"}, + }, + } + T = json_schema_to_type(schema) + field_names = [f.name for f in dataclasses.fields(T)] + assert len(field_names) == 2 + assert len(set(field_names)) == 2