Give recursive tool output schemas an object root - #3368
Conversation
Pydantic emits a bare $ref root for self-referential return types,
which the published tool schema contract rejects: on 2025-11-25
sessions the whole tools/list result failed validation, so legacy
clients lost every tool, not just the recursive one.
Generated schemas whose root is a $ref to an object def are now
published as {"type": "object", "allOf": [$ref]} with $defs kept,
which terminates where inlining the root would not. Hand-built
schemas are untouched.
Fixes modelcontextprotocol#3337
|
Thanks for the contribution. This repository only keeps pull requests open when they're linked to an issue that a maintainer has assigned to the author — CONTRIBUTING.md explains why and how we work. This PR has been closed for now because you aren't currently assigned to #3337. If a maintainer would like this change as a PR from you, they'll assign you to #3337 and this PR will reopen automatically — there's nothing more you need to do. (If you opened the issue, this PR already shows up on its timeline.) There's no need to open a new PR — this one will be reopened. While it's closed, please push any updates as new commits rather than force-pushing, since GitHub can't reopen a PR whose branch has been rewritten. Maintainers: reopening this PR, removing the |
Give recursive tool output schemas an object root
Fixes #3337
What
A tool whose return type is self-referential publishes
{"$defs": {...}, "$ref": "#/$defs/Node"}with no root"type". The 2025-11-25OutputSchemamodel requirestype: Literal["object"]at the root, so servingtools/listto a legacy-negotiated session failed validation for the entire listing — legacy clients lost every tool, not just the recursive one.Reproduced on
main: aMCPServerwith one recursive-BaselineModel tool serves modern sessions fine, butClient(mcp, mode="legacy")getsMCPError: Handler returned an invalid result.How
Normalization happens where derivation already lives —
FuncMetadata.model_post_init, next to the existingStrictJsonSchemacall. When pydantic emits a root that is a bare$refto an object def, the schema is published as:{"type": "object", "allOf": [{"$ref": "#/$defs/Node"}], "$defs": {"Node": {...}}}Wrapping rather than inlining is what terminates on recursive models. Sibling
$ref+typewould also satisfy the model; wrapping keeps it draft-agnostic.Scope guards:
output_schemas are untouched — "a schema published without a model is advertised but not validated", and that stays true.$refroots for self-referential types).The reporter's separate question — whether one unrepresentable tool should degrade instead of failing the listing — is a design decision deliberately left out of this PR.
Tests
test_func_metadata.py): exact-schema assertion for a recursive BaseModel return — roottype,allOf-wrapped ref,$defspreserved.test_server.py):tools/listsucceeds on bothClient(mcp)andClient(mcp, mode="legacy")with a recursive tool registered.Both fail on
mainexactly as #3337 describes. Full gate green locally:./scripts/test, ruff format/check, pyright.(AI-assisted implementation, prepared with a coding agent and reviewed by me.)