Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion src/mcp/server/mcpserver/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,30 @@ def emit_warning(self, kind: JsonSchemaWarningKind, detail: str) -> None:
raise ValueError(f"JSON schema warning: {kind} - {detail}")


_DEFS_REF_PREFIX = "#/$defs/"


def _with_object_root(schema: dict[str, Any]) -> dict[str, Any]:
"""Give a generated schema whose root is a bare `$ref` an object root.

Pydantic emits `{"$defs": ..., "$ref": "#/$defs/Model"}` for self-referential
return types, but the published tool schema requires `type: "object"` at the
root. Wrapping the reference (instead of inlining it) terminates on recursive
models; refs to non-object defs are left alone.
"""
ref = schema.get("$ref")
if not isinstance(ref, str) or "type" in schema or not ref.startswith(_DEFS_REF_PREFIX):
return schema
defs: dict[str, Any] = schema["$defs"] if "$defs" in schema else {}
try:
is_object_root = defs[ref[len(_DEFS_REF_PREFIX) :]]["type"] == "object"
except (KeyError, TypeError):
return schema
if not is_object_root:
return schema
return {"type": "object", "allOf": [{"$ref": ref}], **{k: v for k, v in schema.items() if k != "$ref"}}


class ArgModelBase(BaseModel):
"""A model representing the arguments to a function."""

Expand Down Expand Up @@ -107,7 +131,9 @@ 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)
self.output_schema = _with_object_root(
self._output_adapter(self.output_model).json_schema(schema_generator=StrictJsonSchema)
)

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."""
Expand Down
35 changes: 35 additions & 0 deletions tests/server/mcpserver/test_func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,41 @@ def func_returning_person() -> PersonModel: # pragma: no cover
}


def test_structured_output_recursive_model():
"""A self-referential return type publishes its schema with `type: "object"` at
the root — pydantic emits a bare `$ref` root, which the published tool schema
contract rejects. The reference is wrapped (not inlined), which terminates on
recursive models."""

class Node(BaseModel):
name: str
children: list["Node"] = []

def func_returning_tree() -> Node: # pragma: no cover
raise NotImplementedError

meta = func_metadata(func_returning_tree)
node_def: 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",
}
assert meta.output_schema == {
"type": "object",
"allOf": [{"$ref": "#/$defs/Node"}],
"$defs": {"Node": node_def},
}


def test_structured_output_primitives():
"""Test structured output with primitive return types"""

Expand Down
27 changes: 27 additions & 0 deletions tests/server/mcpserver/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2423,3 +2423,30 @@ async def refuse_listen(ctx: ServerRequestContext[Any, Any], call_next: Any) ->
pass # pragma: no cover - the refusal precedes the stream
assert exc_info.value.error.code == INVALID_REQUEST
assert exc_info.value.error.message == "not permitted to watch the requested resources"


@pytest.mark.anyio
async def test_recursive_tool_output_schema_serves_on_legacy_sessions() -> None:
"""A self-referential tool return type publishes `type: "object"` at the schema
root, so `tools/list` succeeds on 2025-11-25 sessions whose OutputSchema model
rejects a bare `$ref` root instead of failing the entire listing (#3337)."""

class Node(BaseModel):
name: str
children: list["Node"] = []

mcp = MCPServer("rec")

@mcp.tool()
def tree() -> Node:
return Node(name="root")

async with Client(mcp) as client:
tools = (await client.list_tools()).tools
assert tools[0].output_schema is not None
assert tools[0].output_schema["type"] == "object"

async with Client(mcp, mode="legacy") as client:
tools = (await client.list_tools()).tools
assert tools[0].output_schema is not None
assert tools[0].output_schema["type"] == "object"
Loading