Give recursive tool return types an object-rooted output schema (#3376)

This commit is contained in:
Max 2026-08-24 19:06:22 +01:00 • committed by GitHub
commit d8b638304e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 72 additions and 1 deletions

View file

@ -74,6 +74,25 @@ class StrictJsonSchema(GenerateJsonSchema):
raise ValueError(f"JSON schema warning: {kind} - {detail}")
_LOCAL_DEFS_PREFIX = "#/$defs/"
def _inline_root_ref(schema: dict[str, Any]) -> dict[str, Any]:
"""Give a schema whose root is a bare `$ref` into `$defs` an inline root.
pydantic emits a self-referential model as `{"$defs": {...}, "$ref": "#/$defs/Model"}`, with no
`type` at the root; `Tool.outputSchema` needs an object root (required on the wire through
2025-11-25). The referenced definition is copied onto the root and `$defs` is kept, since nested
references still point into it. Root siblings of the `$ref` win over the definition's keys.
"""
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)])
siblings = {key: value for key, value in schema.items() if key != "$ref"}
return {**definition, **siblings}
class ArgModelBase(BaseModel):
"""A model representing the arguments to a function."""
@ -108,7 +127,8 @@ class FuncMetadata(BaseModel):
def model_post_init(self, context: Any, /) -> None:
if self.output_model is not None and self.output_schema is None:
# StrictJsonSchema raises instead of warning, so an unserializable return type fails construction.
self.output_schema = self._output_adapter(self.output_model).json_schema(schema_generator=StrictJsonSchema)
schema = self._output_adapter(self.output_model).json_schema(schema_generator=StrictJsonSchema)
self.output_schema = _inline_root_ref(schema)
def _output_adapter(self, output_model: type[Any]) -> TypeAdapter[Any]:
"""The validator/serializer for `output_model`, built once and rebuilt only if the field is reassigned."""

View file

@ -1202,6 +1202,34 @@ def test_structured_output_nested_models():
}
def test_structured_output_self_referential_model_gets_an_object_root():
"""pydantic publishes a recursive model as a bare root `$ref`; the definition is inlined onto the
root and `$defs` is kept for the nested reference."""
class Node(BaseModel):
name: str
children: list["Node"] = []
def tree() -> Node:
return Node(name="root", children=[Node(name="leaf")])
node_definition: dict[str, Any] = {
"properties": {
"name": {"title": "Name", "type": "string"},
"children": {"default": [], "items": {"$ref": "#/$defs/Node"}, "title": "Children", "type": "array"},
},
"required": ["name"],
"title": "Node",
"type": "object",
}
meta = func_metadata(tree)
assert meta.output_schema == {**node_definition, "$defs": {"Node": node_definition}}
result = meta.convert_result(tree())
assert isinstance(result, CallToolResult)
assert result.structured_content == {"name": "root", "children": [{"name": "leaf", "children": []}]}
def test_structured_output_unserializable_type_error():
"""Test error when structured_output=True is used with unserializable types"""

View file

@ -2167,6 +2167,29 @@ async def test_prompt_input_required_result_on_legacy_session_is_a_serialization
assert exc.value.error.message == "Handler returned an invalid result"
async def test_recursive_tool_return_type_lists_and_calls_on_legacy_session():
"""A 2025-11-25 session requires `type: object` at the outputSchema root; a self-referential
return type must not fail the whole listing, and its result validates client-side via `$defs`."""
class Node(BaseModel):
name: str
children: list["Node"] = []
mcp = MCPServer()
@mcp.tool()
def tree() -> Node:
return Node(name="root", children=[Node(name="leaf")])
async with Client(mcp, mode="legacy") as client:
[tool] = (await client.list_tools()).tools
assert tool.output_schema is not None
assert tool.output_schema["type"] == "object"
assert tool.output_schema["properties"]["children"]["items"] == {"$ref": "#/$defs/Node"}
result = await client.call_tool("tree", {})
assert result.structured_content == {"name": "root", "children": [{"name": "leaf", "children": []}]}
async def test_resource_template_input_required_result_on_legacy_session_is_a_serialization_error():
"""Pins the shared era gate for resources/read: a pre-2026 session has no
input_required vocabulary, so the runner rejects the frame with -32603."""