Skip to content

Commit f235992

Browse files
fix: resolve list[dict] return type producing Root() instead of dicts (#3880)
When a tool returns `list[dict]`, the client deserializes each dict as a `Root()` dataclass with no fields instead of preserving the original dict data. The root cause is in `_get_from_type_handler`: its `"object"` branch always fell through to `_create_dataclass` for schemas without `properties`, creating an empty dataclass named `Root`. The top-level `json_schema_to_type` already handled this case correctly (returning `dict[str, Any]`), but that logic was not shared with `_schema_to_type` which is used when converting nested schemas (e.g., array items). Extract `_object_schema_to_type` to unify the four object-schema cases (dict, typed dict, BaseModel with extra, dataclass) so both top-level and nested paths produce the correct type. Fixes #3867 Co-authored-by: Ke Wang <ke@pika.art>
1 parent 9a447cb commit f235992

3 files changed

Lines changed: 78 additions & 6 deletions

File tree

src/fastmcp/utilities/json_schema_type.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -350,23 +350,48 @@ def _return_Any() -> Any:
350350
return Any
351351

352352

353+
def _object_schema_to_type(
354+
schema: Mapping[str, Any], schemas: Mapping[str, Any]
355+
) -> type:
356+
"""Convert an object schema to the appropriate Python type.
357+
358+
Handles three cases that mirror the top-level ``json_schema_to_type`` logic:
359+
1. No ``properties`` with ``additionalProperties`` — return ``dict[str, T]``
360+
2. No ``properties`` and no ``additionalProperties`` — return ``dict[str, Any]``
361+
3. Has ``properties`` and ``additionalProperties is True`` — Pydantic model
362+
4. Has ``properties`` — dataclass
363+
"""
364+
has_properties = bool(schema.get("properties"))
365+
additional_props = schema.get("additionalProperties")
366+
367+
if not has_properties and additional_props:
368+
if additional_props is True:
369+
return dict[str, Any]
370+
value_type = _schema_to_type(additional_props, schemas)
371+
return cast(type[Any], dict[str, value_type]) # type: ignore[valid-type] # ty:ignore[invalid-type-form]
372+
373+
if not has_properties and not additional_props:
374+
return dict[str, Any]
375+
376+
if has_properties and additional_props is True:
377+
return _create_pydantic_model(schema, schema.get("title"), schemas)
378+
379+
return _create_dataclass(schema, schema.get("title"), schemas)
380+
381+
353382
def _get_from_type_handler(
354383
schema: Mapping[str, Any], schemas: Mapping[str, Any]
355384
) -> Callable[..., Any]:
356385
"""Get the appropriate type handler for the schema."""
357386

358-
type_handlers: dict[str, Callable[..., Any]] = { # TODO
387+
type_handlers: dict[str, Callable[..., Any]] = {
359388
"string": lambda s: _create_string_type(s),
360389
"integer": lambda s: _create_numeric_type(int, s),
361390
"number": lambda s: _create_numeric_type(float, s),
362391
"boolean": lambda _: bool,
363392
"null": lambda _: type(None),
364393
"array": lambda s: _create_array_type(s, schemas),
365-
"object": lambda s: (
366-
_create_pydantic_model(s, s.get("title"), schemas)
367-
if s.get("properties") and s.get("additionalProperties") is True
368-
else _create_dataclass(s, s.get("title"), schemas)
369-
),
394+
"object": lambda s: _object_schema_to_type(s, schemas),
370395
}
371396
return type_handlers.get(schema.get("type", None), _return_Any)
372397

tests/client/client/test_client.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,3 +749,21 @@ def dict_tool() -> dict[str, int]:
749749
assert result.structured_content == {"a": 1}
750750
assert result.data == {"a": 1}
751751
assert result.meta is None
752+
753+
754+
async def test_client_list_dict_return_type():
755+
"""list[dict] return type should produce list of dicts, not Root() objects (issue #3867)."""
756+
server = FastMCP()
757+
758+
@server.tool
759+
def get_temperatures() -> list[dict]:
760+
"""Get current temperatures for all cities"""
761+
return [
762+
{"city": "NYC", "temp": 72},
763+
{"city": "LA", "temp": 85},
764+
]
765+
766+
client = Client(transport=FastMCPTransport(server))
767+
async with client:
768+
result = await client.call_tool("get_temperatures", {})
769+
assert result.data == [{"city": "NYC", "temp": 72}, {"city": "LA", "temp": 85}]

tests/utilities/json_schema_type/test_containers.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,35 @@ def test_dict_types_are_generated_correctly(self, input_type, expected_type):
140140
generated_type = json_schema_to_type(schema)
141141
assert generated_type == expected_type
142142

143+
@pytest.mark.parametrize(
144+
"input_type, expected_type",
145+
[
146+
# list[dict] roundtrips correctly (not list[Root()])
147+
(list[dict], list[dict[str, Any]]),
148+
# list[dict[str, Any]] stays the same
149+
(list[dict[str, Any]], list[dict[str, Any]]),
150+
# list[dict[str, str]] preserves value type
151+
(list[dict[str, str]], list[dict[str, str]]),
152+
# list[dict[str, int]] preserves value type
153+
(list[dict[str, int]], list[dict[str, int]]),
154+
],
155+
)
156+
def test_list_of_dict_types_roundtrip(self, input_type, expected_type):
157+
"""Ensure list[dict] schemas produce dict types, not dataclasses (issue #3867)."""
158+
schema = TypeAdapter(input_type).json_schema()
159+
generated_type = json_schema_to_type(schema)
160+
assert generated_type == expected_type
161+
162+
def test_list_dict_validates_data(self):
163+
"""list[dict] schema should validate actual dict data, not produce Root() (issue #3867)."""
164+
schema = TypeAdapter(list[dict]).json_schema()
165+
generated_type = json_schema_to_type(schema)
166+
validator = TypeAdapter(generated_type)
167+
result = validator.validate_python(
168+
[{"city": "NYC", "temp": 72}, {"city": "LA", "temp": 85}]
169+
)
170+
assert result == [{"city": "NYC", "temp": 72}, {"city": "LA", "temp": 85}]
171+
143172
def test_object_accepts_valid(self, simple_object):
144173
validator = TypeAdapter(simple_object)
145174
result = validator.validate_python({"name": "test", "age": 30})

0 commit comments

Comments
 (0)