Give recursive tool return types an object-rooted output schema

Merged
MA
2mo ago
+72-1

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_root pins the generated shape and round-trips a nested result through convert_result.
  • test_recursive_tool_return_type_lists_and_calls_on_legacy_session drives Client(mcp, mode="legacy") through tools/list and tools/call; both tests fail on main.
  • Drove it by hand on both mode="legacy" and 2026-07-28 with self-referential, mutually recursive, and TypedDict return types, and validated the resulting wire ListToolsResult against the spec's schema/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.

AI Disclaimer