Skip to content

Commit 6705402

Browse files
authored
Resolve tool output-schema references within the schema document only (#3394)
1 parent e7284ed commit 6705402

2 files changed

Lines changed: 39 additions & 8 deletions

File tree

src/mcp/client/session.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,7 +1126,8 @@ async def validate_tool_result(self, name: str, result: types.CallToolResult) ->
11261126
"""Revalidate a `CallToolResult` against the tool's declared output schema.
11271127
11281128
Raises:
1129-
RuntimeError: Structured content is missing or does not conform to the schema.
1129+
RuntimeError: Structured content is missing or does not conform to the schema, or the
1130+
schema is invalid or has a `$ref` that does not resolve within the schema document.
11301131
"""
11311132
if name not in self._tool_output_schemas:
11321133
# refresh output schema cache
@@ -1140,17 +1141,22 @@ async def validate_tool_result(self, name: str, result: types.CallToolResult) ->
11401141

11411142
if output_schema is not None:
11421143
from jsonschema import exceptions as jsonschema_exceptions
1144+
from referencing.exceptions import Unresolvable
11431145

11441146
if result.structured_content is None:
11451147
raise RuntimeError(f"Tool {name} has an output schema but did not return structured content")
11461148
validator = self._output_schema_validator(name, output_schema)
11471149
# `best_match` picks the same error the previous `jsonschema.validate()` call raised,
11481150
# so the message a caller sees is unchanged. It is untyped upstream.
11491151
errors = validator.iter_errors(result.structured_content)
1150-
error = cast(
1151-
"Exception | None",
1152-
jsonschema_exceptions.best_match(errors), # pyright: ignore[reportUnknownMemberType]
1153-
)
1152+
try:
1153+
error = cast(
1154+
"Exception | None",
1155+
jsonschema_exceptions.best_match(errors), # pyright: ignore[reportUnknownMemberType]
1156+
)
1157+
except Unresolvable as e:
1158+
# A `$ref` did not resolve within the schema document.
1159+
raise RuntimeError(f"Invalid schema for tool {name}: {e}") from e
11541160
if error is not None:
11551161
raise RuntimeError(f"Invalid structured content returned by tool {name}: {error}") from error
11561162

@@ -1168,6 +1174,7 @@ def _output_schema_validator(self, name: str, output_schema: dict[str, Any]) ->
11681174
"""
11691175
from jsonschema import SchemaError
11701176
from jsonschema.validators import validator_for
1177+
from referencing import Registry
11711178

11721179
if (validator := self._tool_output_validators.get(name)) is not None:
11731180
return validator
@@ -1177,9 +1184,8 @@ def _output_schema_validator(self, name: str, output_schema: dict[str, Any]) ->
11771184
validator_cls.check_schema(output_schema)
11781185
except SchemaError as e:
11791186
raise RuntimeError(f"Invalid schema for tool {name}: {e}")
1180-
# jsonschema ships no `py.typed`, so pyright reads typeshed's stub, which declares
1181-
# `registry` as required (concrete validators default it); cast to a schema-only ctor.
1182-
validator = cast("Callable[[dict[str, Any]], Validator]", validator_cls)(output_schema)
1187+
# An explicit empty registry: `$ref`s resolve within the schema document and the bundled metaschemas.
1188+
validator = validator_cls(output_schema, registry=Registry())
11831189
self._tool_output_validators[name] = validator
11841190
return validator
11851191

tests/client/test_output_schema_validation.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from pathlib import Path
23
from typing import Any
34

45
import pytest
@@ -10,6 +11,7 @@
1011
TextContent,
1112
Tool,
1213
)
14+
from referencing.exceptions import Unresolvable
1315

1416
from mcp import Client
1517
from mcp.server import Server, ServerRequestContext
@@ -163,3 +165,26 @@ async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams)
163165
assert result.is_error is False
164166

165167
assert "Tool mystery_tool not listed" in caplog.text
168+
169+
170+
# jsonschema's fallback retriever emits this DeprecationWarning; keep it a plain warning so the
171+
# assertions below decide the outcome rather than the suite's warnings-as-errors filter.
172+
@pytest.mark.filterwarnings("default:Automatically retrieving remote references:DeprecationWarning")
173+
@pytest.mark.anyio
174+
async def test_output_schema_ref_outside_the_document_is_rejected(tmp_path: Path):
175+
"""A `$ref` to a URI outside the output schema is not resolved, and a result whose validation
176+
reaches one fails as an invalid schema (spec `$ref` resolution; applying it to `file:` URIs too
177+
is SDK-defined)."""
178+
target = tmp_path / "schema.json"
179+
target.write_text("{}", encoding="utf-8")
180+
server = _make_server(
181+
tools=[Tool(name="probe", input_schema={"type": "object"}, output_schema={"$ref": target.as_uri()})],
182+
structured_content={"v": 1},
183+
)
184+
185+
async with Client(server) as client:
186+
with pytest.raises(RuntimeError) as exc_info:
187+
await client.call_tool("probe", {})
188+
# SDK-authored prefix only; the tail is `referencing`'s text.
189+
assert str(exc_info.value).startswith("Invalid schema for tool probe: ")
190+
assert isinstance(exc_info.value.__cause__, Unresolvable)

0 commit comments

Comments
 (0)