mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 13:34:17 +02:00
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:
parent
9a447cb08d
commit
f23599283c
3 changed files with 78 additions and 6 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}]
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue