Repository navigation
Give bare dict, set and frozenset return annotations structured output - #3758
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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".
📚 Documentation preview
|
There was a problem hiding this comment.
All reported issues were addressed across 3 files
Reply with feedback, questions, or to request a fix.
Turn on auto-fix | Re-trigger cubic
There was a problem hiding this comment.
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/tupleopt-out now sits at the call site (func_metadata.py:449), so it only fires for the whole annotation.list | Nonekeeps its wrapped schema and has a test;Annotated[list, ...]andlist | InputRequiredResultreduce to a barelistand are opted out consistently. - Cases 3 and 4 in
_create_output_model: baredictmaps todict[str, Any]unwrapped, and bareset/frozenset(pluslist/tupleunderstructured_output=True) are wrapped.dictsubclasses still take the class-with-type-hints path as before. - The docs now say that a forced wrapper fails on
Image/Audioitems and that only a whole-annotation barelist/tupleis 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):
- 🔴
src/mcp/server/mcpserver/utilities/func_metadata.py:549—Existing tools annotated -> dict, -> set or -> frozenset can start returning errors after upgrade, where the base branc… - Also unresolved: 2 minor or pre-existing.
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.
Fixes #3725
A bare
-> dictnow gets the same structured output as-> dict[str, Any], and a bare-> set/-> frozensetis wrapped in{"result": ...}like their parameterized forms. A bare-> list/-> tupledeliberately stays unstructured, andstructured_output=Truenow wraps it instead of raising.Motivation and Context
Bare builtins fell through to the "other class" branch of
_create_output_model, whereget_type_hints(dict)is{}, so the tool silently got nooutputSchemaand nostructuredContent. Withstructured_output=Trueit raisedreturn type <class 'dict'> is not serializable, which reads as if a dict weren't serializable.-> dict{"type": "object", "additionalProperties": true}, unwrapped-> set,-> frozenset{"type": "array", "uniqueItems": true, "items": {}}-> list,-> tuplestructured_output=TrueWhy bare
list/tuplestay unstructured_convert_to_contentrenders content blocks found in a list or tuple as blocks, and_returns_contentalready optslist[ImageContent]and friends out of structured output so the image isn't also echoed intostructuredContent. A barelistsays nothing about its items, so it may hold blocks too: a-> listtool returning[Image(...), {...}]would send the image twice, once as a block and once as base64 instructuredContent. Some clients then render the JSON instead of the picture, others put the base64 in the model's context.set/frozensetcan't hold content blocks (they aren't hashable), so wrapping them is safe.FastMCP made the same call (
function_parsing.py: barelist/tupleproduce no output schema for this reason, while baredictandsetget one).list[X]andstructured_output=Trueremain the explicit opt-ins.How Has This Been Tested?
list/tupleadded totest_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_anyandtest_a_bare_set_is_wrappedsnapshot bothoutput_schemaand the fullCallToolResult.strict-no-cover, ruff and pyright clean.Breaking Changes
Tools annotated with a bare
dict,setorfrozensetstart publishing anoutputSchemaand validating their result against it, exactly asdict[str, Any]/set[Any]do today. Values that used to be dumped as text withto_json(..., fallback=str)and now fail as a tool error (is_error=True):dicttool returning non-strkeys, e.g.{1: "a"};Nonereturn from a-> dict/-> settool;Bare
list/tupletools are unchanged unless they passstructured_output=True, which used to raise. Only a barelist/tupleas the whole annotation is opted out; nested ones (list | None,dict | list,list[list]) keep the wrapped schema they publish today.Types of changes
Checklist
help wanted, or I'm a maintainer)Additional context
This differs from the branch proposed in #3725, which also wraps bare
list/tuple; the reason is the double-send above. Unparameterizedtyping.List/typing.Tupleare unchanged (still wrapped likelist[Any]).AI Disclaimer
This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.