Give recursive tool return types an object-rooted output schema
Fixes #3337
When a tool's return type is self-referential, pydantic emits the output schema as {"$defs": {...}, "$ref": "#/$defs/Node"} with no type at the root. Tool.outputSchema requires type: "object" at the root on 2025-11-25 and earlier, so one such tool failed the entire tools/list result for every legacy-negotiated client (Handler returned an invalid result). This inlines the referenced definition onto the root and keeps $defs for the nested references.
Motivation and Context
The trigger is narrow (recursive BaseModel, recursive TypedDict since #3331, mutually recursive models, RootModel[Model]), but the blast radius when hit is the whole tool listing, and it isn't only this SDK's serializer that objects: TypeScript SDK 1.x, TypeScript SDK 2.x on pre-2026 sessions, and C# SDK 1.x clients all reject a listing containing a $ref-rooted outputSchema. FastMCP hit the same thing (PrefectHQ/fastmcp#2455) and it's cited in SEP-2106's rationale.
The fix is unconditional rather than per-protocol-version: the schema is an object, pydantic just spells a recursive root as $ref (it only unpacks a root $ref when the definition is referenced exactly once). The resulting shape is what pydantic already produces for the non-recursive version of the same model, and docs/servers/structured-output.md already promises that model return types publish an object root. Other SDKs that can emit recursive roots (zod 4, schemars, System.Text.Json) all inline the root too.
How Has This Been Tested?
test_structured_output_self_referential_model_gets_an_object_rootpins the generated shape and round-trips a nested result throughconvert_result.test_recursive_tool_return_type_lists_and_calls_on_legacy_sessiondrivesClient(mcp, mode="legacy")throughtools/listandtools/call; both tests fail onmain.- Drove it by hand on both
mode="legacy"and 2026-07-28 with self-referential, mutually recursive, andTypedDictreturn types, and validated the resulting wireListToolsResultagainst the spec'sschema/2025-11-25/schema.json(valid; the old shape fails with'type' is a required property).
Breaking Changes
None. The only observable change is the JSON content of output_schema for recursive return types: the root gains the definition's keys, $defs is unchanged, structuredContent is unchanged.
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 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
Deliberately out of scope: a RootModel whose root is not an object (RootModel[list[int]], RootModel[int]) still publishes a non-object root and non-dict structuredContent, which fails both tools/list and that tool's tools/call on legacy sessions. That one genuinely isn't an object, so it needs either reclassification as a wrapped output or per-version projection of schema and value; I'll open a separate issue.
A v1.x backport follows — v1 ships the same shape silently and strict clients reject it there too.