Skip to content

Commit 5ebdfed

Browse files
authored
[v1.x] Resolve tool output-schema references within the schema document only (#3396)
1 parent b222713 commit 5ebdfed

2 files changed

Lines changed: 41 additions & 1 deletion

File tree

src/mcp/client/session.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,17 +428,24 @@ async def _validate_tool_result(self, name: str, result: types.CallToolResult) -
428428

429429
if output_schema is not None:
430430
from jsonschema import SchemaError, ValidationError, validate
431+
from referencing import Registry
432+
from referencing.exceptions import Unresolvable
431433

432434
if result.structuredContent is None:
433435
raise RuntimeError(
434436
f"Tool {name} has an output schema but did not return structured content"
435437
) # pragma: no cover
438+
# An explicit empty registry: `$ref`s resolve within the schema document and the bundled metaschemas.
439+
registry: Registry[Any] = Registry()
436440
try:
437-
validate(result.structuredContent, output_schema)
441+
validate(result.structuredContent, output_schema, registry=registry)
438442
except ValidationError as e:
439443
raise RuntimeError(f"Invalid structured content returned by tool {name}: {e}") # pragma: no cover
440444
except SchemaError as e: # pragma: no cover
441445
raise RuntimeError(f"Invalid schema for tool {name}: {e}") # pragma: no cover
446+
except Unresolvable as e:
447+
# A `$ref` did not resolve within the schema document.
448+
raise RuntimeError(f"Invalid schema for tool {name}: {e}") from e
442449

443450
@overload
444451
@deprecated("Use list_prompts(params=PaginatedRequestParams(...)) instead")

tests/client/test_output_schema_validation.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import logging
22
from contextlib import contextmanager
3+
from pathlib import Path
34
from typing import Any
45
from unittest.mock import patch
56

67
import pytest
8+
from referencing.exceptions import Unresolvable
79

810
from mcp.server.lowlevel import Server
911
from mcp.shared.memory import (
@@ -215,3 +217,34 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
215217

216218
# Check that warning was logged
217219
assert "Tool mystery_tool not listed" in caplog.text
220+
221+
222+
# jsonschema's fallback retriever emits this DeprecationWarning; keep it a plain warning so the
223+
# assertions below decide the outcome rather than the suite's warnings-as-errors filter.
224+
@pytest.mark.filterwarnings("default:Automatically retrieving remote references:DeprecationWarning")
225+
@pytest.mark.anyio
226+
async def test_output_schema_ref_outside_the_document_is_rejected(tmp_path: Path):
227+
"""A `$ref` to a URI outside the output schema is not resolved, and a result whose validation
228+
reaches one fails as an invalid schema (spec `$ref` resolution; applying it to `file:` URIs too
229+
is SDK-defined)."""
230+
target = tmp_path / "schema.json"
231+
target.write_text("{}", encoding="utf-8")
232+
server = Server("test-server")
233+
234+
@server.list_tools()
235+
async def list_tools():
236+
return [
237+
Tool(name="probe", description="", inputSchema={"type": "object"}, outputSchema={"$ref": target.as_uri()})
238+
]
239+
240+
@server.call_tool()
241+
async def call_tool(name: str, arguments: dict[str, Any]):
242+
return {"v": 1}
243+
244+
with bypass_server_output_validation():
245+
async with client_session(server) as client:
246+
with pytest.raises(RuntimeError) as exc_info:
247+
await client.call_tool("probe", {})
248+
# SDK-authored prefix only; the tail is `referencing`'s text.
249+
assert str(exc_info.value).startswith("Invalid schema for tool probe: ")
250+
assert isinstance(exc_info.value.__cause__, Unresolvable)

0 commit comments

Comments
 (0)