diff --git a/src/fastmcp/utilities/json_schema_type.py b/src/fastmcp/utilities/json_schema_type.py index c3603185c..f5e27797a 100644 --- a/src/fastmcp/utilities/json_schema_type.py +++ b/src/fastmcp/utilities/json_schema_type.py @@ -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) diff --git a/tests/client/client/test_client.py b/tests/client/client/test_client.py index d0d44bd2a..b513b5367 100644 --- a/tests/client/client/test_client.py +++ b/tests/client/client/test_client.py @@ -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}] diff --git a/tests/utilities/json_schema_type/test_containers.py b/tests/utilities/json_schema_type/test_containers.py index 9b10b5c37..139124533 100644 --- a/tests/utilities/json_schema_type/test_containers.py +++ b/tests/utilities/json_schema_type/test_containers.py @@ -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})