Skip to content

Give bare dict, set and frozenset return annotations structured output - #3758

Merged
Kludex merged 2 commits into
mainfrom
structured-output-bare-containers
Oct 24, 2026
Merged

Kludex merged 2 commits into
mainfrom
structured-output-bare-containers

Conversation

@Kludex

@Kludex Kludex commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

Fixes #3725

A bare -> dict now gets the same structured output as -> dict[str, Any], and a bare -> set / -> frozenset is wrapped in {"result": ...} like their parameterized forms. A bare -> list / -> tuple deliberately stays unstructured, and structured_output=True now wraps it instead of raising.

Motivation and Context

Bare builtins fell through to the "other class" branch of _create_output_model, where get_type_hints(dict) is {}, so the tool silently got no outputSchema and no structuredContent. With structured_output=True it raised return type <class 'dict'> is not serializable, which reads as if a dict weren't serializable.

Annotation Before After
-> dict no schema {"type": "object", "additionalProperties": true}, unwrapped
-> set, -> frozenset no schema wrapped, {"type": "array", "uniqueItems": true, "items": {}}
-> list, -> tuple no schema no schema (on purpose); wrapped with structured_output=True

Why bare list / tuple stay unstructured

_convert_to_content renders content blocks found in a list or tuple as blocks, and _returns_content already opts list[ImageContent] and friends out of structured output so the image isn't also echoed into structuredContent. A bare list says nothing about its items, so it may hold blocks too: a -> list tool returning [Image(...), {...}] would send the image twice, once as a block and once as base64 in structuredContent. Some clients then render the JSON instead of the picture, others put the base64 in the model's context. set / frozenset can't hold content blocks (they aren't hashable), so wrapping them is safe.

FastMCP made the same call (function_parsing.py: bare list/tuple produce no output schema for this reason, while bare dict and set get one). list[X] and structured_output=True remain the explicit opt-ins.

How Has This Been Tested?

  • Bare list / tuple added to test_content_block_return_annotation_yields_no_output_schema.
  • test_structured_output_true_wraps_a_bare_list, test_a_bare_list_inside_a_union_keeps_its_schema, test_a_bare_dict_is_structured_like_dict_str_any and test_a_bare_set_is_wrapped snapshot both output_schema and the full CallToolResult.
  • Full suite passes; coverage 100%, strict-no-cover, ruff and pyright clean.

Breaking Changes

Tools annotated with a bare dict, set or frozenset start publishing an outputSchema and validating their result against it, exactly as dict[str, Any] / set[Any] do today. Values that used to be dumped as text with to_json(..., fallback=str) and now fail as a tool error (is_error=True):

  • a bare-dict tool returning non-str keys, e.g. {1: "a"};
  • a None return from a -> dict / -> set tool;
  • a value pydantic can't serialize to JSON, e.g. an instance of an arbitrary class inside the dict or set.

Bare list / tuple tools are unchanged unless they pass structured_output=True, which used to raise. Only a bare list / tuple as the whole annotation is opted out; nested ones (list | None, dict | list, list[list]) keep the wrapped schema they publish today.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I am assigned to the linked issue (or it is labeled help wanted, or I'm a maintainer)
  • I have disclosed any AI assistance and can explain the change in my own words
  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

This differs from the branch proposed in #3725, which also wraps bare list / tuple; the reason is the double-send above. Unparameterized typing.List / typing.Tuple are unchanged (still wrapped like list[Any]).

AI Disclaimer

This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-24T18:24:11.302269Z 17fa76e PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 17fa76e863

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tests/server/mcpserver/test_func_metadata.py Outdated
Comment thread tests/server/mcpserver/test_func_metadata.py Outdated
@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3758.mcp-python-docs.pages.dev
Deployment https://4f0a5f1d.mcp-python-docs.pages.dev
Commit 349a012
Triggered by @Kludex
Updated 2026-10-24 20:07:53 UTC

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files

Reply with feedback, questions, or to request a fix.

Turn on auto-fix | Re-trigger cubic

Comment thread src/mcp/server/mcpserver/utilities/func_metadata.py
Comment thread src/mcp/server/mcpserver/utilities/func_metadata.py
Comment thread src/mcp/server/mcpserver/utilities/func_metadata.py Outdated
Comment thread tests/server/mcpserver/test_func_metadata.py Outdated
Comment thread docs/servers/structured-output.md Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One optional note from this repository's REVIEW.md or CLAUDE.md checks was not posted as a comment, over this review's limit for such notes; it is on this commit's check card.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/mcp/server/mcpserver/utilities/func_metadata.py
Comment thread src/mcp/server/mcpserver/utilities/func_metadata.py Outdated
Comment thread docs/servers/structured-output.md Outdated
Comment thread tests/server/mcpserver/test_func_metadata.py Outdated
Comment thread tests/server/mcpserver/test_func_metadata.py Outdated
Comment thread tests/server/mcpserver/test_func_metadata.py
Comment thread src/mcp/server/mcpserver/utilities/func_metadata.py

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the follow-up commit and found no new bugs. Because the PR changes what existing bare dict, set and frozenset tools publish and validate on the stable line, a maintainer should still sign off on that behaviour change.

What was reviewed:

  • The bare list/tuple opt-out now sits at the call site (func_metadata.py:449), so it only fires for the whole annotation. list | None keeps its wrapped schema and has a test; Annotated[list, ...] and list | InputRequiredResult reduce to a bare list and are opted out consistently.
  • Cases 3 and 4 in _create_output_model: bare dict maps to dict[str, Any] unwrapped, and bare set/frozenset (plus list/tuple under structured_output=True) are wrapped. dict subclasses still take the class-with-type-hints path as before.
  • The docs now say that a forced wrapper fails on Image/Audio items and that only a whole-annotation bare list/tuple is opted out. The tests are split per annotation, with docstrings and full-result snapshots.
Extended reasoning...

The diff touches func_metadata.py (one widened early return and two widened branches in _create_output_model), its test file, and docs/servers/structured-output.md, for +123/-9 lines. It touches no security-sensitive surface such as auth, injection or data exposure. The second commit moved the bare list/tuple opt-out to the call site, and this run found no bugs. Approval was ruled out because the change deliberately alters observable output for already-deployed tools, which AGENTS.md treats as an explicit maintainer decision for the 2.x line. Two other automated reviewers also left inline comments whose content is not visible here, so I cannot confirm they were addressed.

Still open from earlier reviews (3):

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

@Kludex
Kludex merged commit a525a3d into main Oct 24, 2026
52 checks passed
@Kludex
Kludex deleted the structured-output-bare-containers branch October 24, 2026 20:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant