Skip to content

Commit 193a166

Browse files
DrewWhittleNZclaudeVidit-Ostwal
authored
fix(schema): support list-form type arrays in JSON schema conversion (#7281)
* fix(schema): support list-form "type" arrays in JSON schema conversion _json_schema_to_pydantic_type already handles anyOf/oneOf for nullable unions -- the form Pydantic's own schema generation produces for Optional[T] fields -- but had no handling for the other, equally valid JSON Schema way of expressing the same thing: a list-form type array, e.g. {"type": ["string", "null"]}. This is what .NET/System.Text.Json -based schema generators produce instead, so any MCP tool schema from a non-Python server using this form crashed create_model_from_schema outright with "Unsupported JSON schema type: ['string', 'null']" -- taking down the entire MCPServerAdapter connection, not just the one affected tool. Confirmed against a real self-hosted MCP server (Equibles, github.com/daniel3303/Equibles): several of its tools (e.g. ListCompanyDocuments's startDate/endDate filters) use exactly this pattern, and MCPServerAdapter couldn't connect to it at all as a result -- reproduced identically on both Windows and macOS. Fix mirrors the existing anyOf/oneOf handling: treat each entry in a list-form type the same way an anyOf member is handled, building a Union of the corresponding Python types. A single-element list collapses to that one type via typing.Union's own behavior, and "null" entries resolve to None (matching how the type == "null" branch already behaves), producing the same Optional[T] shape as the anyOf case would. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(schema): preserve union members when applying FORMAT_TYPE_MAP CodeRabbit flagged this reviewing #7058: the format override in _json_schema_to_pydantic_field replaced the whole resolved type with FORMAT_TYPE_MAP[format_], even when that type was a Union built from a list-form `type` (or anyOf/oneOf) rather than a plain `str`. For a schema like {"type": ["string", "null"], "format": "date-time"}, this collapsed Union[str, None] down to plain datetime, silently dropping the null option -- masked for non-required fields by the Optional-rewrap at the end of the same function, but not for a required-but-nullable field (a valid, if unusual, JSON Schema shape). The same override also drops any non-string members of a multi-type array (e.g. ["string", "integer", "null"]) regardless of required status, since nothing rewraps those. Narrow the override to the `str` member specifically: replace `type_` outright when it's already plain `str`, or substitute only the `str` element inside a Union via get_origin/get_args, leaving null and other type-array members untouched. Added two tests covering the previously-broken cases: a required nullable formatted field, and a multi-type array (string/integer/null) with a format. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com>
1 parent 7fe8317 commit 193a166

2 files changed

Lines changed: 154 additions & 1 deletion

File tree

lib/crewai/src/crewai/utilities/pydantic_schema_utils.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
TypedDict,
3131
Union,
3232
cast,
33+
get_args,
34+
get_origin,
3335
)
3436
import uuid
3537

@@ -1042,7 +1044,20 @@ def _json_schema_to_pydantic_field(
10421044
elif len(allowed_schemes) == 1 and allowed_schemes[0] == "file":
10431045
pydantic_type = FileUrl
10441046

1045-
type_ = pydantic_type
1047+
# `type_` can be a Union built from a list-form `type` (or anyOf/oneOf)
1048+
# rather than a plain `str`, e.g. `{"type": ["string", "null"],
1049+
# "format": "date-time"}`. Replacing the whole thing with
1050+
# `pydantic_type` would silently drop the other members (null,
1051+
# non-string alternatives) instead of just narrowing the string one.
1052+
if type_ is str:
1053+
type_ = pydantic_type
1054+
elif get_origin(type_) is Union:
1055+
type_ = Union[ # noqa: UP007
1056+
tuple(
1057+
pydantic_type if member is str else member
1058+
for member in get_args(type_)
1059+
)
1060+
]
10461061

10471062
if isinstance(type_, type) and issubclass(type_, str):
10481063
if "minLength" in json_schema:
@@ -1215,6 +1230,28 @@ def _json_schema_to_pydantic_type(
12151230

12161231
type_ = json_schema.get("type")
12171232

1233+
if isinstance(type_, list):
1234+
# JSON Schema also allows "type" to be an array, e.g.
1235+
# {"type": ["string", "null"]} -- the .NET/System.Text.Json-style
1236+
# way of expressing a nullable field. Pydantic's own schema
1237+
# generation instead uses anyOf/oneOf for this (handled above), so
1238+
# external tool schemas (e.g. from a non-Python MCP server) are the
1239+
# main source of this form. Treat each entry the same way anyOf's
1240+
# members are handled just above: build a Union of the
1241+
# corresponding Python types. A single-element list collapses to
1242+
# that one type, matching typing.Union's own behavior.
1243+
member_types = [
1244+
_json_schema_to_pydantic_type(
1245+
{**json_schema, "type": member},
1246+
root_schema,
1247+
name_=f"{name_ or 'Union'}Option{i}",
1248+
enrich_descriptions=enrich_descriptions,
1249+
in_progress=in_progress,
1250+
)
1251+
for i, member in enumerate(type_)
1252+
]
1253+
return Union[tuple(member_types)] # noqa: UP007
1254+
12181255
if type_ == "string":
12191256
return str
12201257
if type_ == "integer":

lib/crewai/tests/utilities/test_pydantic_schema_utils.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,122 @@ def test_oneof(self) -> None:
303303
assert Model(value="hello").value == "hello"
304304
assert Model(value=3.14).value == pytest.approx(3.14)
305305

306+
def test_type_array_nullable_string_with_format(self) -> None:
307+
"""type: ["string", "null"] -- the .NET/System.Text.Json-style way
308+
of expressing an optional field, as opposed to Pydantic's own
309+
anyOf-based form. Seen in real MCP tool schemas from non-Python
310+
servers (e.g. Equibles' ListCompanyDocuments startDate/endDate
311+
filters). The format="date-time" here (also straight from that
312+
real schema) is applied by the existing FORMAT_TYPE_MAP logic once
313+
the list-form type no longer raises, so the field lands as a real
314+
datetime rather than str -- that's the pre-existing, correct
315+
behavior for any date-time-formatted field, not something this fix
316+
changes."""
317+
schema = {
318+
"type": "object",
319+
"properties": {
320+
"startDate": {
321+
"description": "Optional start date filter in YYYY-MM-DD format",
322+
"type": ["string", "null"],
323+
"format": "date-time",
324+
"default": None,
325+
},
326+
},
327+
}
328+
Model = create_model_from_schema(schema)
329+
assert Model(startDate="2026-01-01").startDate == datetime.datetime(
330+
2026, 1, 1
331+
)
332+
assert Model(startDate=None).startDate is None
333+
assert Model().startDate is None
334+
335+
def test_type_array_nullable_string_no_format(self) -> None:
336+
schema = {
337+
"type": "object",
338+
"properties": {
339+
"note": {"type": ["string", "null"]},
340+
},
341+
}
342+
Model = create_model_from_schema(schema)
343+
assert Model(note="hello").note == "hello"
344+
assert Model(note=None).note is None
345+
assert Model().note is None
346+
347+
def test_type_array_multiple_non_null(self) -> None:
348+
schema = {
349+
"type": "object",
350+
"properties": {
351+
"value": {"type": ["string", "integer", "null"]},
352+
},
353+
}
354+
Model = create_model_from_schema(schema)
355+
assert Model(value="hello").value == "hello"
356+
assert Model(value=42).value == 42
357+
assert Model(value=None).value is None
358+
359+
def test_type_array_single_element(self) -> None:
360+
schema = {
361+
"type": "object",
362+
"properties": {"value": {"type": ["string"]}},
363+
"required": ["value"],
364+
}
365+
Model = create_model_from_schema(schema)
366+
assert Model(value="hello").value == "hello"
367+
368+
def test_type_array_required_nullable_string_with_format(self) -> None:
369+
"""A required-but-nullable formatted field, e.g. `{"type":
370+
["string", "null"], "format": "date-time"}` inside a "required"
371+
list -- a valid JSON Schema shape meaning the key must be present
372+
but its value may be null. Before this fix, the FORMAT_TYPE_MAP
373+
override in `_json_schema_to_pydantic_field` replaced the whole
374+
`Union[datetime, None]` with plain `datetime`, so passing `None`
375+
would fail validation even though the schema explicitly allows it.
376+
The `not is_required` Optional-rewrap at the end of that function
377+
doesn't fire for required fields, so this case wasn't masked the
378+
way the non-required version (test above) was.
379+
"""
380+
schema = {
381+
"type": "object",
382+
"properties": {
383+
"startDate": {
384+
"type": ["string", "null"],
385+
"format": "date-time",
386+
},
387+
},
388+
"required": ["startDate"],
389+
}
390+
Model = create_model_from_schema(schema)
391+
assert Model(startDate="2026-01-01").startDate == datetime.datetime(
392+
2026, 1, 1
393+
)
394+
assert Model(startDate=None).startDate is None
395+
with pytest.raises(Exception):
396+
Model()
397+
398+
def test_type_array_multiple_non_null_with_format(self) -> None:
399+
"""A list-form type with more than one non-null member plus a
400+
recognized format, e.g. `{"type": ["string", "integer", "null"],
401+
"format": "date-time"}`. Before this fix, the FORMAT_TYPE_MAP
402+
override collapsed the entire Union down to plain `datetime`,
403+
silently dropping the `integer` alternative regardless of whether
404+
the field was required. The fix narrows only the `str` member of
405+
the union to the formatted type, leaving `integer` and `None`
406+
alone.
407+
"""
408+
schema = {
409+
"type": "object",
410+
"properties": {
411+
"value": {
412+
"type": ["string", "integer", "null"],
413+
"format": "date-time",
414+
},
415+
},
416+
}
417+
Model = create_model_from_schema(schema)
418+
assert Model(value="2026-01-01").value == datetime.datetime(2026, 1, 1)
419+
assert Model(value=42).value == 42
420+
assert Model(value=None).value is None
421+
306422

307423
class TestAllOfMerging:
308424
def test_allof_merges_properties(self) -> None:

0 commit comments

Comments
 (0)