Give recursive tool return types an object-rooted output schema - #3376
Conversation
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.
| 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)]) |
There was a problem hiding this comment.
🟡 _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
Fixes #3337
When a tool's return type is self-referential, pydantic emits the output schema as
{"$defs": {...}, "$ref": "#/$defs/Node"}with notypeat the root.Tool.outputSchemarequirestype: "object"at the root on 2025-11-25 and earlier, so one such tool failed the entiretools/listresult for every legacy-negotiated client (Handler returned an invalid result). This inlines the referenced definition onto the root and keeps$defsfor the nested references.Motivation and Context
The trigger is narrow (recursive
BaseModel, recursiveTypedDictsince #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-rootedoutputSchema. 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$refwhen the definition is referenced exactly once). The resulting shape is what pydantic already produces for the non-recursive version of the same model, anddocs/servers/structured-output.mdalready 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.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_schemafor recursive return types: the root gains the definition's keys,$defsis unchanged,structuredContentis unchanged.Types of changes
Checklist
Additional context
Deliberately out of scope: a
RootModelwhose root is not an object (RootModel[list[int]],RootModel[int]) still publishes a non-object root and non-dictstructuredContent, which fails bothtools/listand that tool'stools/callon 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