diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index a4b7f4873e..9c3cb0bcdb 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -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.""" @@ -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.""" diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index eff3479279..e4cf47d686 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -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""" diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index f9055a4ed4..053aefb844 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -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"