GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — Give recursive tool return types an object-rooted output schema by maxisbey · Pull Request #3376 · modelcontextprotocol/python-sdk

Conversation

@maxisbey

@maxisbey maxisbey commented Aug 24, 2026

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

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@maxisbey
Give recursive tool return types an object-rooted output schema
d56f6c1
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]
cubic-dev-ai Bot reviewed Aug 24, 2026
View reviewed changes

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 3 files

Re-trigger cubic

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@maxisbey maxisbey mentioned this pull request Aug 24, 2026
[v1.x] Give recursive tool return types an object-rooted output schema #3377
Merged
6 of 9 tasks
@maxisbey
maxisbey merged commit d8b6383 into main Aug 24, 2026
35 checks passed

Uh oh!

There was an error while loading. Please reload this page.

@maxisbey
maxisbey deleted the fix/recursive-output-schema-object-root branch August 24, 2026 18:06
claude[bot]
claude Bot reviewed Aug 24, 2026
View reviewed changes
Comment thread src/mcp/server/mcpserver/utilities/func_metadata.py
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)])

@claude claude Bot Aug 24, 2026

Copy link
Copy Markdown
Contributor

There was a problem hiding this comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 _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

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

claude[bot] reacted with thumbs up emoji claude[bot] reacted with thumbs down emoji
All reactions
  • 👍 1 reaction
  • 👎 1 reaction
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

@cubic-dev-ai cubic-dev-ai[bot] cubic-dev-ai[bot] left review comments
@claude claude[bot] claude[bot] left review comments

Uh oh!

There was an error while loading. Please reload this page.

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

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

Uh oh!

There was an error while loading. Please reload this page.

1 participant

@maxisbey
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.