diff --git a/fastmcp_slim/fastmcp/utilities/json_schema_type.py b/fastmcp_slim/fastmcp/utilities/json_schema_type.py index de1db2900..a2dc98580 100644 --- a/fastmcp_slim/fastmcp/utilities/json_schema_type.py +++ b/fastmcp_slim/fastmcp/utilities/json_schema_type.py @@ -269,12 +269,14 @@ def _create_string_type(schema: Mapping[str, Any]) -> type | Annotated[Any, ...] if "const" in schema: return Literal[schema["const"]] # type: ignore + base: Any = str if fmt := schema.get("format"): if fmt == "uri": return AnyUrl elif fmt == "uri-reference": - return str - return FORMAT_TYPES.get(fmt, str) + base = str + else: + base = FORMAT_TYPES.get(fmt, str) constraints = { k: v @@ -286,10 +288,15 @@ def _create_string_type(schema: Mapping[str, Any]) -> type | Annotated[Any, ...] if v is not None } - if not constraints: - return str + # StringConstraints (min/max length, pattern) only apply to str-based types. + # Non-str formats (e.g. date-time -> datetime, json -> Json) carry no such + # constraints. Previously *any* format made this function return early, + # silently dropping minLength/maxLength/pattern even for plain-string + # formats (e.g. a custom format that falls back to str). + if not constraints or not (isinstance(base, type) and issubclass(base, str)): + return base - annotated: Any = Annotated[str, StringConstraints(**constraints)] + annotated: Any = Annotated[base, StringConstraints(**constraints)] if "pattern" in constraints: try: @@ -307,10 +314,10 @@ def _create_string_type(schema: Mapping[str, Any]) -> type | Annotated[Any, ...] pattern_field = Field(json_schema_extra={"x-unsupported-pattern": pattern}) if constraints: annotated = Annotated[ - str, StringConstraints(**constraints), pattern_field + base, StringConstraints(**constraints), pattern_field ] # type: ignore[valid-type] else: - annotated = Annotated[str, pattern_field] # type: ignore[valid-type] + annotated = Annotated[base, pattern_field] # type: ignore[valid-type] return annotated diff --git a/tests/utilities/json_schema_type/test_constraints.py b/tests/utilities/json_schema_type/test_constraints.py index 53a1d9a1e..fbb196eb9 100644 --- a/tests/utilities/json_schema_type/test_constraints.py +++ b/tests/utilities/json_schema_type/test_constraints.py @@ -130,3 +130,39 @@ class TestNumberConstraints: validator = TypeAdapter(exclusive_max_number) with pytest.raises(ValidationError): validator.validate_python(100) + + +class TestStringFormatConstraints: + """A ``format`` must not silently drop string length/pattern constraints.""" + + def test_custom_format_keeps_max_length(self): + # An unrecognized format falls back to ``str``; maxLength must still apply. + t = json_schema_to_type({"type": "string", "format": "phone", "maxLength": 3}) + validator = TypeAdapter(t) + assert validator.validate_python("abc") == "abc" + with pytest.raises(ValidationError): + validator.validate_python("abcd") + + def test_custom_format_keeps_min_length(self): + t = json_schema_to_type({"type": "string", "format": "phone", "minLength": 3}) + validator = TypeAdapter(t) + assert validator.validate_python("abc") == "abc" + with pytest.raises(ValidationError): + validator.validate_python("ab") + + def test_uri_reference_format_keeps_max_length(self): + t = json_schema_to_type( + {"type": "string", "format": "uri-reference", "maxLength": 5} + ) + validator = TypeAdapter(t) + assert validator.validate_python("a/b") == "a/b" + with pytest.raises(ValidationError): + validator.validate_python("too/long/path") + + def test_non_string_format_ignores_length(self): + # date-time resolves to ``datetime`` (not str), so length constraints do + # not apply and the type must stay datetime (regression guard). + import datetime + + t = json_schema_to_type({"type": "string", "format": "date-time"}) + assert t is datetime.datetime