Skip to content

Bare dict / list / tuple return annotations silently produce no structured output #3725

Open

Description

@g-s-jithesh

Initial Checks

Release line

2.x (current stable)

Description

A tool annotated with a bare dict (or list / tuple) gets no outputSchema and no structuredContent. The result only arrives as JSON text. -> dict[str, Any] works, and -> str gets {"result": ...}. With structured_output=True the bare annotation raises InvalidSignature: Function f: return type <class 'dict'> is not serializable for structured output, which reads as if a dict weren't serializable.

Expected: -> dict behaves like dict[str, Any], and -> list / -> tuple like list[Any] (wrapped in {"result": ...}). Failing that, the structured-output docs could say that bare containers opt out.

Cause: _create_output_model sends bare dict / list / tuple to the "other class types" case. get_type_hints(dict) is empty, so the model stays None: the silent opt-out documented for classes without type hints, which isn't obvious for builtins. We hit it while building an MCP server: our tools were -> dict, and our client code expected structured_content that was always None.

Proposed fix: a case before the class fallback that sends bare dict down the dict[str, ...] path and wraps bare list / tuple like their generic forms, plus a test and one sentence in docs/servers/structured-output.md. It's about 10 lines of code, and the full suite passes (6206 passed). One behaviour change to weigh: existing bare -> dict / -> list tools would start publishing an outputSchema and validating their results.

I'd like to send this as a PR if you're open to it. It's on a branch: g-s-jithesh@6ecfa04

I used an AI coding assistant (Claude Code) to help investigate this and draft the fix. I reproduced the bug myself and reviewed the change.

Example Code

import asyncio
from typing import Any

from mcp import Client
from mcp.server.mcpserver import MCPServer

mcp = MCPServer("repro")


@mcp.tool()
def bare() -> dict:
    return {"id": 1}


@mcp.tool()
def typed() -> dict[str, Any]:
    return {"id": 1}


async def main() -> None:
    async with Client(mcp) as client:
        for name in ("bare", "typed"):
            print(name, (await client.call_tool(name, {})).structured_content)


asyncio.run(main())
# bare None
# typed {'id': 1}

Python & MCP Python SDK

Python 3.14, Windows 11; mcp 2.3.0, and main at 9f66f37 (src/mcp/server/mcpserver/utilities/func_metadata.py, _create_output_model)

Activity

  1. added
    bugSomething isn't working
    v2Affects the v2 line (2.x on main)
    v1Affects the v1.x maintenance line
    on Oct 17, 2026
  2. xuilen commented on Oct 17, 2026

    @xuilen

    I was able to reproduce this. In _create_output_model (src/mcp/server/mcpserver/utilities/func_metadata.py), bare dict, list, tuple, and set fall through to the ordinary class inspection where get_type_hints() returns {}, leaving output_model as None (or raising InvalidSignature when structured_output=True).

    I've verified a minimal fix and test coverage locally:

    • Treat bare dict (and typing.Dict) as Annotated[dict[str, Any], Field(title=f"{func_name}DictOutput")] without wrapping (matching dict[str, Any] behavior).
    • Route bare collections (list, tuple, set, frozenset, and typing.List) through _create_wrapped_model with wrap_output=True.

    All 642 existing mcpserver tests plus regression tests for all bare containers pass cleanly (uv run pytest tests/server/mcpserver/), and ruff check / ruff format are clean.

    Happy to open a PR for this if this matches the intended design and you'd like to assign the issue.

    (AI-assisted investigation & implementation)

  3. g-s-jithesh commented on Oct 17, 2026

    @g-s-jithesh
    Author

    Thanks for confirming the reproduction. Good catch on set / frozenset: they take the same path, and I've added them to the branch with tests (all 642 tests in tests/server/mcpserver/ pass): main...g-s-jithesh:python-sdk:fix/bare-container-structured-output

    typing.Dict / typing.List without parameters already produce structured output today (wrapped in {"result": ...}), so they aren't part of the silent None. What's left is an inconsistency: a bare typing.Dict is wrapped while dict / dict[str, Any] aren't. Changing that would alter existing output, so I've left it for maintainers to decide.

    As the reporter, I'd still like to send the PR from that branch if a maintainer is happy to assign this.

    (Investigated and drafted with an AI coding assistant.)

  4. xuilen commented on Oct 17, 2026

    @xuilen

    Sounds great! Glad the set / frozenset edge cases were helpful. Best of luck with the PR!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingv1Affects the v1.x maintenance linev2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions