FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Give recursive tool return types an object-rooted output schema by maxisbey · Pull Request #3376 · modelcontextprotocol/python-sdk · GitHub

Give recursive tool return types an object-rooted output schema - #3376

Merged
maxisbey merged 1 commit into
mainfrom
fix/recursive-output-schema-object-root
Aug 24, 2026
Merged

Give recursive tool return types an object-rooted output schema#3376
maxisbey merged 1 commit into
mainfrom
fix/recursive-output-schema-object-root

Conversation

Copy link
Copy Markdown
Contributor

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

pydantic emits a self-referential model as {"$defs": {...}, "$ref": "#/$defs/Model"}
with no type at the root. Tool.outputSchema requires type: object at the root on
2025-11-25 and earlier, so a single tool with a recursive return type failed the
entire tools/list result for every legacy-negotiated client.

Inline the referenced definition onto the root when the generated schema is a
bare local $ref, keeping $defs for the nested references. The shape is the same
on every protocol version.

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.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

No issues found across 3 files

Re-trigger cubic

maxisbey merged commit d8b6383 into main Aug 24, 2026
35 checks passed
maxisbey deleted the fix/recursive-output-schema-object-root branch August 24, 2026 18:06
ref = schema.get("$ref")
if not isinstance(ref, str) or not ref.startswith(_LOCAL_DEFS_PREFIX):
return schema
definition = cast(dict[str, Any], schema["$defs"][ref.removeprefix(_LOCAL_DEFS_PREFIX)])

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.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

🟡 _inline_root_ref assumes a root $ref starting with #/$defs/ always has a matching local definition; when it doesn't, the unguarded schema["$defs"][ref.removeprefix(...)] raises KeyError inside FuncMetadata.model_post_init, and KeyError is not in func_metadata's except tuple (lines 451-459; pydantic wraps only ValueError/AssertionError from model_post_init into ValidationError), so tool registration crashes with a bare KeyError instead of falling back to unstructured output or raising InvalidSignature. Before this diff such schemas registered and published as-is, so this is a registration-time regression for schema-override edge cases. Fix: guard with e.g. definition = schema.get("$defs", {}).get(name) and return the schema unchanged when the definition is absent (covers both a…

Extended reasoning...

A tool returns a BaseModel whose schema is customized to carry a root $ref without local defs — e.g. class Payload(BaseModel): model_config = ConfigDict(json_schema_extra={"$ref": "#/$defs/Payload"}) (mirroring an externally-managed schema), or a model whose __get_pydantic_json_schema__ returns {"$ref": "#/$defs/External"}. TypeAdapter(...).json_schema() then produces a root containing that $ref but no matching $defs entry. On @ mcp.tool() decoration, FuncMetadata construction calls _inline_root_ref, schema["$defs"] raises KeyError, which propagates raw out of model_post_init past the except tuple at func_metadata lines 451-459, crashing server startup with an unexplained KeyError: '$defs'. On the pre-diff code the same tool registered successfully and published its schema unchanged.

Verification: nit — src/mcp/server/mcpserver/utilities/func_metadata.py:91 definition = cast(dict[str, Any], schema["$defs"][ref.removeprefix(_LOCAL_DEFS_PREFIX)]) is guarded only by the prefix check on lines 89-90 (if not isinstance(ref, str) or not ref.startswith(_LOCAL_DEFS_PREFIX)); nothing verifies "$defs" exists or contains the referenced key, so a root "$ref": "#/$defs/X" without a matchi

This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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.

Recursive tool return type publishes an outputSchema with no root type, failing tools/list on 2025-11-25 sessions

1 participant


Back | FazBrowse Home | New Git URL