| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
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
Sorry, something went wrong.
| Back | FazBrowse Home | New Git URL |
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?
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
Checklist
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