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>
This commit is contained in:
Ke Wang 2026-04-12 16:54:00 -05:00 committed by GitHub
commit f23599283c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 78 additions and 6 deletions

View file

@ -350,23 +350,48 @@ def _return_Any() -> Any:
return Any
def _object_schema_to_type(
schema: Mapping[str, Any], schemas: Mapping[str, Any]
) -> type:
"""Convert an object schema to the appropriate Python type.
Handles three cases that mirror the top-level ``json_schema_to_type`` logic:
1. No ``properties`` with ``additionalProperties`` return ``dict[str, T]``
2. No ``properties`` and no ``additionalProperties`` return ``dict[str, Any]``
3. Has ``properties`` and ``additionalProperties is True`` Pydantic model
4. Has ``properties`` dataclass
"""
has_properties = bool(schema.get("properties"))
additional_props = schema.get("additionalProperties")
if not has_properties and additional_props:
if additional_props is True:
return dict[str, Any]
value_type = _schema_to_type(additional_props, schemas)
return cast(type[Any], dict[str, value_type]) # type: ignore[valid-type] # ty:ignore[invalid-type-form]
if not has_properties and not additional_props:
return dict[str, Any]
if has_properties and additional_props is True:
return _create_pydantic_model(schema, schema.get("title"), schemas)
return _create_dataclass(schema, schema.get("title"), schemas)
def _get_from_type_handler(
schema: Mapping[str, Any], schemas: Mapping[str, Any]
) -> Callable[..., Any]:
"""Get the appropriate type handler for the schema."""
type_handlers: dict[str, Callable[..., Any]] = { # TODO
type_handlers: dict[str, Callable[..., Any]] = {
"string": lambda s: _create_string_type(s),
"integer": lambda s: _create_numeric_type(int, s),
"number": lambda s: _create_numeric_type(float, s),
"boolean": lambda _: bool,
"null": lambda _: type(None),
"array": lambda s: _create_array_type(s, schemas),
"object": lambda s: (
_create_pydantic_model(s, s.get("title"), schemas)
if s.get("properties") and s.get("additionalProperties") is True
else _create_dataclass(s, s.get("title"), schemas)
),
"object": lambda s: _object_schema_to_type(s, schemas),
}
return type_handlers.get(schema.get("type", None), _return_Any)

View file

@ -749,3 +749,21 @@ async def test_client_does_not_unwrap_dict_result():
assert result.structured_content == {"a": 1}
assert result.data == {"a": 1}
assert result.meta is None
async def test_client_list_dict_return_type():
"""list[dict] return type should produce list of dicts, not Root() objects (issue #3867)."""
server = FastMCP()
@server.tool
def get_temperatures() -> list[dict]:
"""Get current temperatures for all cities"""
return [
{"city": "NYC", "temp": 72},
{"city": "LA", "temp": 85},
]
client = Client(transport=FastMCPTransport(server))
async with client:
result = await client.call_tool("get_temperatures", {})
assert result.data == [{"city": "NYC", "temp": 72}, {"city": "LA", "temp": 85}]

View file

@ -140,6 +140,35 @@ class TestObjectTypes:
generated_type = json_schema_to_type(schema)
assert generated_type == expected_type
@pytest.mark.parametrize(
"input_type, expected_type",
[
# list[dict] roundtrips correctly (not list[Root()])
(list[dict], list[dict[str, Any]]),
# list[dict[str, Any]] stays the same
(list[dict[str, Any]], list[dict[str, Any]]),
# list[dict[str, str]] preserves value type
(list[dict[str, str]], list[dict[str, str]]),
# list[dict[str, int]] preserves value type
(list[dict[str, int]], list[dict[str, int]]),
],
)
def test_list_of_dict_types_roundtrip(self, input_type, expected_type):
"""Ensure list[dict] schemas produce dict types, not dataclasses (issue #3867)."""
schema = TypeAdapter(input_type).json_schema()
generated_type = json_schema_to_type(schema)
assert generated_type == expected_type
def test_list_dict_validates_data(self):
"""list[dict] schema should validate actual dict data, not produce Root() (issue #3867)."""
schema = TypeAdapter(list[dict]).json_schema()
generated_type = json_schema_to_type(schema)
validator = TypeAdapter(generated_type)
result = validator.validate_python(
[{"city": "NYC", "temp": 72}, {"city": "LA", "temp": 85}]
)
assert result == [{"city": "NYC", "temp": 72}, {"city": "LA", "temp": 85}]
def test_object_accepts_valid(self, simple_object):
validator = TypeAdapter(simple_object)
result = validator.validate_python({"name": "test", "age": 30})