You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Recursive tool return type publishes an outputSchema with no root type, failing tools/list on 2025-11-25 sessions #3337
When an MCPServer tool's return type is self-referential, pydantic emits the output schema as {"$defs": {...}, "$ref": "#/$defs/Node"} with no root "type": "object". That's fine on 2026-07-28 sessions, but the 2025-11-25 Tool.outputSchema shape requires type: "object" at the root, so serializing the tools/list result for a legacy-negotiated client fails validation and the client gets an error for the entire listing, not just that tool.
On main this affects recursive BaseModel return types. #3331 hands TypedDict returns to pydantic natively, so recursive TypedDicts join them (previously the hand-built mirror model happened to inline the root).
Steps to reproduce
importanyiofrompydanticimportBaseModelfrommcpimportClientfrommcp.server.mcpserverimportMCPServerclassNode(BaseModel):
name: strchildren: list["Node"] = []
mcp=MCPServer("rec")
@mcp.tool()deftree() ->Node:
returnNode(name="root")
@mcp.tool()defother() ->int:
return1asyncdefmain():
asyncwithClient(mcp) asc: # 2026-07-28print(sorted((awaitc.list_tools()).tools[1].output_schema)) # ['$defs', '$ref']asyncwithClient(mcp, mode="legacy") asc: # 2025-11-25awaitc.list_tools() # raises; server logs "handler for 'tools/list' returned an invalid result"anyio.run(main)
Server log on the legacy session:
ValidationError: 1 validation error for ListToolsResult
tools.0.outputSchema.type
Field required [type=missing, input_value={'$defs': {'Node': {...}}, '$ref': '#/$defs/Node'}, input_type=dict]
Expected behaviour
tools/list succeeds on both protocol versions; the recursive tool's schema has an object root (e.g. inline the root $ref, or wrap it as {"type": "object", "allOf": [{"$ref": ...}], "$defs": ...}).
Separately, it seems worth deciding whether one tool's unrepresentable schema should fail the whole listing on a legacy session or just drop/degrade that tool.
I reproduced this on current main at 0d921927 with CPython 3.12.13, using the in-memory Client(MCPServer) path.
Observed behavior:
Client(server) lists and calls the recursive tool successfully.
Client(server, mode="legacy") fails tools/list during server-side result serialization with tools.0.outputSchema.type: Field required, and the client receives MCPError: Handler returned an invalid result.
The failure occurs at the protocol-version serialization boundary: Pydantic's root $ref schema is valid for 2026-07-28, while the handshake-era OutputSchema requires a root type: "object".
A narrow fix appears possible in MCPServer's request-aware tools/list path:
Keep the modern schema unchanged.
For handshake-era sessions only, when a root local $ref resolves to a $defs entry whose type is explicitly object, return a copied schema with a sibling type: "object".
Do not relabel recursive non-object schemas such as RootModel[list[...]].
Keep the low-level server's per-version validation strict.
I verified this shape with both list_tools() and call_tool(): modern and legacy clients both succeed and receive the same recursive structuredContent. The regression coverage I would add is the public in-memory round trip, plus boundaries for an unchanged modern schema and a recursive non-object schema.
If this scope matches the intended compatibility behavior, I can prepare the focused implementation and tests. Please assign the issue to @1678092075 if you would like an external PR.
AI assistance disclosure: Codex assisted with the local investigation and drafting this comment.
I'd like to take this one. I'll look at giving the recursive schema a root object type for 2025-11-25 sessions, with a regression test covering the legacy-negotiated tools/list path so a single recursive return type can't fail the whole listing again.
Confirmed against current main (57394b0): the modern session publishes {$defs, $ref} with no root type, and on a legacy session tools/list fails entirely with MCPError: Handler returned an invalid result.
I have a working fix on a branch. Scope note first: I left your second question (drop/degrade one unrepresentable tool instead of failing the listing) alone — that's a design decision for maintainers and separable from this.
The fix normalizes at schema-generation time (FuncMetadata.model_post_init, where StrictJsonSchema already owns derivation): when pydantic emits a root that is a bare $ref to an object def, publish it as
Wrapping rather than inlining is what terminates on recursive models; sibling `$ref` + `type` would also satisfy the 2025-11-25 `OutputSchema` model but wrapping keeps it draft-agnostic. Non-object ref targets and hand-built schemas are untouched, so this only changes the case that was already broken — modern sessions see the same schemas as before for every non-recursive tool.
Regression tests: exact-schema unit test for recursive BaseModel returns, plus an end-to-end test asserting tools/list succeeds on both `Client(mcp)` and `Client(mcp, mode="legacy")`. Full suite, 100% coverage, ruff, pyright green locally.
Happy to open a PR if the issue gets assigned to me.
(AI-assisted analysis and drafting, prepared with a coding agent and reviewed by me.)
@Srivatsa03 — saw your Aug 21 comment here only after posting mine; that's on me for not reading the full thread first. Your claimed scope is the same as what's working on my branch, so rather than race: happy to hand this off entirely, or if it helps, the branch is up at Parker-Fawcett/fix/3337-recursive-tool-output-schema-object-root to compare approaches against. Whatever whoever triaging prefers.
@Parker-Fawcett no need to hand anything off, you did the work and have it tested, so it should be yours. I'll stay off this one. Thanks for circling back rather than just racing it.
Activity
1678092075 commentedon Aug 20, 2026
I reproduced this on current
mainat0d921927with CPython 3.12.13, using the in-memoryClient(MCPServer)path.Observed behavior:
Client(server)lists and calls the recursive tool successfully.Client(server, mode="legacy")failstools/listduring server-side result serialization withtools.0.outputSchema.type: Field required, and the client receivesMCPError: Handler returned an invalid result.The failure occurs at the protocol-version serialization boundary: Pydantic's root
$refschema is valid for 2026-07-28, while the handshake-eraOutputSchemarequires a roottype: "object".A narrow fix appears possible in MCPServer's request-aware
tools/listpath:$refresolves to a$defsentry whose type is explicitlyobject, return a copied schema with a siblingtype: "object".RootModel[list[...]].I verified this shape with both
list_tools()andcall_tool(): modern and legacy clients both succeed and receive the same recursivestructuredContent. The regression coverage I would add is the public in-memory round trip, plus boundaries for an unchanged modern schema and a recursive non-object schema.If this scope matches the intended compatibility behavior, I can prepare the focused implementation and tests. Please assign the issue to
@1678092075if you would like an external PR.AI assistance disclosure: Codex assisted with the local investigation and drafting this comment.
Srivatsa03 commentedon Aug 21, 2026
I'd like to take this one. I'll look at giving the recursive schema a root object type for 2025-11-25 sessions, with a regression test covering the legacy-negotiated tools/list path so a single recursive return type can't fail the whole listing again.
Parker-Fawcett commentedon Aug 23, 2026
Confirmed against current
main(57394b0): the modern session publishes{$defs, $ref}with no root type, and on a legacy sessiontools/listfails entirely withMCPError: Handler returned an invalid result.I have a working fix on a branch. Scope note first: I left your second question (drop/degrade one unrepresentable tool instead of failing the listing) alone — that's a design decision for maintainers and separable from this.
The fix normalizes at schema-generation time (
FuncMetadata.model_post_init, whereStrictJsonSchemaalready owns derivation): when pydantic emits a root that is a bare $ref to an object def, publish it as```json
{"type": "object", "allOf": [{"$ref": "#/$defs/Node"}], "$defs": {…}}
```
Wrapping rather than inlining is what terminates on recursive models; sibling `$ref` + `type` would also satisfy the 2025-11-25 `OutputSchema` model but wrapping keeps it draft-agnostic. Non-object ref targets and hand-built schemas are untouched, so this only changes the case that was already broken — modern sessions see the same schemas as before for every non-recursive tool.
Regression tests: exact-schema unit test for recursive BaseModel returns, plus an end-to-end test asserting
tools/listsucceeds on both `Client(mcp)` and `Client(mcp, mode="legacy")`. Full suite, 100% coverage, ruff, pyright green locally.Happy to open a PR if the issue gets assigned to me.
(AI-assisted analysis and drafting, prepared with a coding agent and reviewed by me.)
Parker-Fawcett commentedon Aug 23, 2026
@Srivatsa03 — saw your Aug 21 comment here only after posting mine; that's on me for not reading the full thread first. Your claimed scope is the same as what's working on my branch, so rather than race: happy to hand this off entirely, or if it helps, the branch is up at
Parker-Fawcett/fix/3337-recursive-tool-output-schema-object-rootto compare approaches against. Whatever whoever triaging prefers.Srivatsa03 commentedon Aug 24, 2026
@Parker-Fawcett no need to hand anything off, you did the work and have it tested, so it should be yours. I'll stay off this one. Thanks for circling back rather than just racing it.
Parker-Fawcett commentedon Aug 24, 2026
Appreciate that — thank you. If anything in the branch turns out useful for other schema-shape work you pick up, take whatever's there.