Skip to content

[v1.x] Give recursive tool return types an object-rooted output schema - #3377

Merged
maxisbey merged 1 commit into
v1.xfrom
fix/v1x-recursive-output-schema-object-root
Aug 24, 2026
Merged

[v1.x] Give recursive tool return types an object-rooted output schema#3377
maxisbey merged 1 commit into
v1.xfrom
fix/v1x-recursive-output-schema-object-root

Conversation

@maxisbey

Copy link
Copy Markdown
Contributor

Backport of #3376 to the v1.x line. Refs #3337.

When a FastMCP 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 every protocol version v1.x speaks, and strict clients — TypeScript SDK 1.x, C# SDK 1.x, and python-sdk 2.x on a 2025-11-25 session — reject the entire tools/list result when one tool publishes that shape. This inlines the referenced definition onto the root and keeps $defs for the nested references.

Motivation and Context

On v1.x nothing errors on the Python side (Tool.outputSchema is dict[str, Any] and isn't shape-checked), so the failure shows up in the peer: a host embedding the TypeScript SDK 1.x sees the server as having no tools at all. That's the same report as PrefectHQ/fastmcp#2455. Given the blast radius against the most common client family and the size of the change, this seemed worth carrying on the maintenance line.

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.
  • test_tool_structured_output_self_referential_model drives an in-memory client session through tools/list and tools/call; both fail before the change.
  • Fed the resulting wire ListToolsResult to the TypeScript SDK 1.x ListToolsResultSchema: accepted after the change, tools.0.outputSchema.type Invalid input: expected "object" before. Also valid against the spec's schema/2025-11-25/schema.json.

Breaking Changes

None. The only observable change is the JSON content of outputSchema 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

Same out-of-scope note as #3376: a RootModel whose root isn't an object still publishes a non-object root.

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, and
strict clients (TypeScript SDK 1.x, C# SDK 1.x, python-sdk 2.x on a 2025-11-25
session) reject the entire tools/list result when one tool publishes that shape.

Inline the referenced definition onto the root when the generated schema is a
bare local $ref, keeping $defs for the nested references. Backport of the fix on
main.

Github-Issue: #3337

@cubic-dev-ai 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.

No issues found across 3 files

Re-trigger cubic

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.

🟡 Minor/edge: _inline_root_ref does unguarded schema["$defs"][name] lookups, so a root-level $ref without a matching local definition raises KeyError instead of being passed through

Extended reasoning...

A user customizes a return model's schema (e.g. model_config = ConfigDict(json_schema_extra={"$ref": "#/$defs/X"}) or a custom schema generator) so the generated schema has a root $ref starting with #/$defs/ but no $defs key or no X entry. Before this change the schema was published as-is; after it, func_metadata raises an uncaught KeyError inside _try_create_model_and_schema (the surrounding try only wraps model_json_schema), so mcp.tool() registration crashes at import/startup instead of registering the tool.

Verification: nit — Line 62 of src/mcp/server/fastmcp/utilities/func_metadata.py performs unguarded lookups: definition = cast(dict[str, Any], schema["$defs"][ref.removeprefix(_LOCAL_DEFS_PREFIX)]). The guard at lines 59-61 only checks that $ref is a string starting with "#/$defs/"; it never checks that $defs exists or contains the referenced name. The enclosing try/except in `_try_create_model_and_sc

@maxisbey
maxisbey merged commit b222713 into v1.x Aug 24, 2026
29 checks passed
@maxisbey
maxisbey deleted the fix/v1x-recursive-output-schema-object-root branch August 24, 2026 18:12
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.

1 participant