From 810ce59113b2056678c1b9eb816af7ef6dda2507 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:24:38 +0000 Subject: [PATCH] [v1.x] Resolve tool output-schema references within the schema document only Backport of #3394. Pass an explicit empty `referencing.Registry` to the client's output-schema validation so `$ref`s resolve within the tool's schema and the bundled metaschemas, and surface a reference that does not resolve there from `call_tool` as the documented `RuntimeError`. --- src/mcp/client/session.py | 9 ++++- tests/client/test_output_schema_validation.py | 33 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 86f2676dcb..d40d767dde 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -428,17 +428,24 @@ async def _validate_tool_result(self, name: str, result: types.CallToolResult) - if output_schema is not None: from jsonschema import SchemaError, ValidationError, validate + from referencing import Registry + from referencing.exceptions import Unresolvable if result.structuredContent is None: raise RuntimeError( f"Tool {name} has an output schema but did not return structured content" ) # pragma: no cover + # An explicit empty registry: `$ref`s resolve within the schema document and the bundled metaschemas. + registry: Registry[Any] = Registry() try: - validate(result.structuredContent, output_schema) + validate(result.structuredContent, output_schema, registry=registry) except ValidationError as e: raise RuntimeError(f"Invalid structured content returned by tool {name}: {e}") # pragma: no cover except SchemaError as e: # pragma: no cover raise RuntimeError(f"Invalid schema for tool {name}: {e}") # pragma: no cover + except Unresolvable as e: + # A `$ref` did not resolve within the schema document. + raise RuntimeError(f"Invalid schema for tool {name}: {e}") from e @overload @deprecated("Use list_prompts(params=PaginatedRequestParams(...)) instead") diff --git a/tests/client/test_output_schema_validation.py b/tests/client/test_output_schema_validation.py index e4a06b7f82..fb158cef98 100644 --- a/tests/client/test_output_schema_validation.py +++ b/tests/client/test_output_schema_validation.py @@ -1,9 +1,11 @@ import logging from contextlib import contextmanager +from pathlib import Path from typing import Any from unittest.mock import patch import pytest +from referencing.exceptions import Unresolvable from mcp.server.lowlevel import Server from mcp.shared.memory import ( @@ -215,3 +217,34 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]: # Check that warning was logged assert "Tool mystery_tool not listed" in caplog.text + + +# jsonschema's fallback retriever emits this DeprecationWarning; keep it a plain warning so the +# assertions below decide the outcome rather than the suite's warnings-as-errors filter. +@pytest.mark.filterwarnings("default:Automatically retrieving remote references:DeprecationWarning") +@pytest.mark.anyio +async def test_output_schema_ref_outside_the_document_is_rejected(tmp_path: Path): + """A `$ref` to a URI outside the output schema is not resolved, and a result whose validation + reaches one fails as an invalid schema (spec `$ref` resolution; applying it to `file:` URIs too + is SDK-defined).""" + target = tmp_path / "schema.json" + target.write_text("{}", encoding="utf-8") + server = Server("test-server") + + @server.list_tools() + async def list_tools(): + return [ + Tool(name="probe", description="", inputSchema={"type": "object"}, outputSchema={"$ref": target.as_uri()}) + ] + + @server.call_tool() + async def call_tool(name: str, arguments: dict[str, Any]): + return {"v": 1} + + with bypass_server_output_validation(): + async with client_session(server) as client: + with pytest.raises(RuntimeError) as exc_info: + await client.call_tool("probe", {}) + # SDK-authored prefix only; the tail is `referencing`'s text. + assert str(exc_info.value).startswith("Invalid schema for tool probe: ") + assert isinstance(exc_info.value.__cause__, Unresolvable)