From e316fcc0f9f3c3d1eb3a7033a850bc8ccdb4de1d Mon Sep 17 00:00:00 2001 From: CodeAlex <59381946+CodeAlex52@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:02:48 +0800 Subject: [PATCH] fix(func_metadata): give bare dict/list/tuple returns a structured output model A tool annotated with a bare `dict` (or `list` / `tuple`) produced no output model: those classes have no type hints, so they reached the generic class branch, `get_type_hints()` came back empty and the model stayed None. The tool therefore advertised no outputSchema and returned no structuredContent, and `structured_output=True` raised InvalidSignature claiming the return type was not serializable for structured output. Treat bare containers like their parameterised equivalents (wrapped in {"result": ...}), which is what `dict[str, Any]` already did. Fixes #3725 --- .../mcpserver/utilities/func_metadata.py | 9 +++++++ tests/server/mcpserver/test_func_metadata.py | 27 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 96d25ec5ab..d59edd78fe 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -535,6 +535,15 @@ def _create_output_model(original_annotation: Any, type_expr: Any, func_name: st model = _create_wrapped_model(func_name, original_annotation) wrap_output = True + # Case 3b: Bare containers. A bare `dict` / `list` / `tuple` carries no type + # hints, so it used to fall through to Case 4, where `get_type_hints()` is + # empty and the model stayed None: the tool silently lost its outputSchema and + # structuredContent. Treat them as their parameterised equivalents, which is + # what the documented behaviour promises (`dict` == `dict[Any, Any]`). + elif type_annotation in (dict, list, tuple): + model = _create_wrapped_model(func_name, original_annotation) + wrap_output = True + # Case 4: Other class types (dataclasses, regular classes with annotations) else: type_hints = get_type_hints(type_annotation) diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index c707591287..c4c9ce2d36 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -1520,3 +1520,30 @@ def fn() -> StepA | StepB: ... # pragma: no branch meta = func_metadata(fn) assert meta.output_schema is None + +@pytest.mark.parametrize("annotation", [dict, list, tuple]) +def test_bare_container_return_annotations_get_a_wrapped_output_model(annotation: Any) -> None: + """A bare `dict` / `list` / `tuple` must not silently opt out of structured output. + + They carry no type hints, so the generic class branch produced no model at all: + no outputSchema, no structuredContent, and `structured_output=True` raised + InvalidSignature claiming the type was not serializable. + """ + + def func() -> annotation: # type: ignore[valid-type] + return {} if annotation is dict else [] + + metadata = func_metadata(func) + assert metadata.output_model is not None, "bare container annotation lost its output model" + assert metadata.wrap_output is True + + +def test_bare_dict_structured_output_roundtrips() -> None: + def func() -> dict: # type: ignore[type-arg] + return {"a": 1} + + metadata = func_metadata(func, structured_output=True) + assert metadata.output_model is not None + validated = metadata.output_model.model_validate(func()) + assert validated.model_dump()["result"] == {"a": 1} +