Skip to content

Commit 9e21cd0

Browse files
committed
Resolve tool output-schema references within the schema document only
The client validated `structuredContent` with a jsonschema validator built on the library's default registry, which retrieves any `$ref` it cannot resolve locally over the network or filesystem, synchronously, on every call. The 2026-07-28 spec requires that non-local `$ref`s are not dereferenced by default and that a schema failing on an unresolved external reference is rejected rather than treated as permissive. Build the validator with an empty `referencing.Registry` so references resolve inside the tool's schema (and the bundled metaschemas) and nowhere else, and surface an unresolvable reference from `call_tool` as the documented `RuntimeError` instead of a `referencing` exception.
1 parent e7284ed commit 9e21cd0

3 files changed

Lines changed: 43 additions & 8 deletions

File tree

docs/advanced/low-level-server.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ The server never compares the two fields. This SDK's `Client` does: return `stru
121121

122122
* The root of `input_schema` must be `"type": "object"`. Beside it, `oneOf`, `additionalProperties`, `anyOf`, `if`/`then`/`else`, `prefixItems`, `$defs` with local `$ref`s and the rest of the 2020-12 keywords reach the client exactly as written.
123123
* No `$schema` key is needed. Add one only to opt into an older draft: this SDK's `Client`, which validates `structured_content` against a tool's `output_schema`, picks its validator from `$schema` and uses 2020-12 when there is none.
124+
* Keep `$ref`s inside the schema (`#/$defs/...`, `$anchor`, an embedded `$id`). The `Client` never fetches a reference to another document, URL or file: a result whose validation reaches one fails with a `RuntimeError` that starts with `Invalid schema for tool <name>`.
124125

125126
## `_meta`: for the application, not the model
126127

src/mcp/client/session.py

Lines changed: 15 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; nothing outside it is retrieved.
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,9 @@ 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 empty registry resolves `$ref`s within the schema document only; jsonschema's
1188+
# default one retrieves any other URI over the network or filesystem.
1189+
validator = validator_cls(output_schema, registry=Registry())
11831190
self._tool_output_validators[name] = validator
11841191
return validator
11851192

tests/client/test_output_schema_validation.py

Lines changed: 27 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,28 @@ 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 default registry warns only after it has fetched a reference; keep that a plain
171+
# warning here so a client that did fetch the (permissive) file would go on to accept the result.
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_without_being_retrieved(tmp_path: Path):
175+
"""A `$ref` to a URI outside the output schema is never fetched, and a result whose validation
176+
reaches one fails as an invalid schema (spec `$ref` resolution: non-local dereferencing is off by
177+
default and an unresolved external `$ref` SHOULD be rejected; treating `file:` like a network
178+
URI is SDK-defined)."""
179+
# Permissive target: had the client read it, `{"v": 1}` would have validated.
180+
permissive = tmp_path / "permissive.json"
181+
permissive.write_text("{}", encoding="utf-8")
182+
server = _make_server(
183+
tools=[Tool(name="probe", input_schema={"type": "object"}, output_schema={"$ref": permissive.as_uri()})],
184+
structured_content={"v": 1},
185+
)
186+
187+
async with Client(server) as client:
188+
with pytest.raises(RuntimeError) as exc_info:
189+
await client.call_tool("probe", {})
190+
# SDK-authored prefix only; the tail is `referencing`'s text plus a tmp path.
191+
assert str(exc_info.value).startswith("Invalid schema for tool probe: ")
192+
assert isinstance(exc_info.value.__cause__, Unresolvable)

0 commit comments

Comments
 (0)