[v1.x] Resolve tool output-schema references within the schema document only - #3396
Conversation
…nt 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`.
There was a problem hiding this comment.
Beyond the inline findings, I also checked whether the empty Registry could break legitimate in-document references — it doesn't: the schema document remains its own resolution root, so #/$defs/... refs still resolve, and the existing self-referential-model test in tests/server/fastmcp/test_server.py exercises exactly that path through client-side call_tool validation.
Extended reasoning...
The main correctness risk of passing an explicit empty referencing.Registry to jsonschema.validate in ClientSession._validate_tool_result (src/mcp/client/session.py) is regressing schemas that use legitimate in-document $refs (#/$defs/...), which FastMCP itself generates for recursive models. I ruled this out: with the registry= parameter, the schema under validation is still registered as its own root resource, so document-internal pointers and anchors resolve without any external retrieval, and the existing test_tool_structured_output_self_referential_model test validates a #/$defs/Node result end-to-end through the client session, so CI covers that path. The two inline findings (the transitively-supplied referencing import not being a declared dependency, and the inaccurate DeprecationWarning comment in the new test) are minor and do not affect the fix's behavior; the security direction of the change — refusing automatic retrieval of http:/file: refs from server-supplied output schemas — is sound.
|
|
||
| if output_schema is not None: | ||
| from jsonschema import SchemaError, ValidationError, validate | ||
| from referencing import Registry |
There was a problem hiding this comment.
🟡 nit: referencing is now imported at runtime (here and in tests/client/test_output_schema_validation.py) but is not declared in pyproject.toml dependencies — it is only available transitively through jsonschema>=4.20.0, so a future jsonschema release that drops or vendors it would break call_tool output validation with an ImportError. Declare referencing as a direct dependency (e.g. uv add referencing).
Extended reasoning...
src/mcp/client/session.py:431-432 does from referencing import Registry / from referencing.exceptions import Unresolvable inside _validate_tool_result, executed on every call_tool for a tool with an outputSchema. pyproject.toml lists only jsonschema>=4.20.0 (line 37); referencing appears nowhere as a direct dependency. Today jsonschema 4.18+ depends on referencing so the import resolves, which is why tests pass — but the package now has a direct code dependency it does not declare, violating packaging hygiene: if jsonschema pins change (vendoring, extras split) within the allowed >=4.20.0 range, or a downstream resolver installs jsonschema without referencing, _validate_tool_result raises ImportError at call time instead of validating. On the base branch neither src nor tests imported referencing, so this undeclared dependency is introduced by this PR. Fix: add referencing to [project.dependencies] (uv add referencing).
Verification: nit. The factual claims all check out. The diff adds runtime imports of an undeclared package at src/mcp/client/session.py:431-432 (from referencing import Registry / from referencing.exceptions import Unresolvable), executed inside _validate_tool_result on every call_tool for a tool with an output schema, and tests/client/test_output_schema_validation.py:8 also imports it at module level.
| # 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") |
There was a problem hiding this comment.
🟡 nit: This comment and filterwarnings mark claim jsonschema's fallback retriever emits the "Automatically retrieving remote references" DeprecationWarning during this test, but that deprecated auto-retrieval only exists on the default registry jsonschema uses when no registry= is passed; with the explicit empty Registry() the validator combines it with the retrieve-less bundled SPECIFICATIONS, so lookup raises Unresolvable without ever invoking a retriever and the warning can never fire — the mark is dead and the comment falsely implies retrieval is still attempted under the fix. Drop both, or reword them as purely defensive.
Extended reasoning...
Path: the client calls jsonschema.validate(..., registry=Registry()) (src/mcp/client/session.py:439-441). In jsonschema 4.25.1, Validator.attrs_post_init checks if registry is not <default>: registry = SPECIFICATIONS.combine(registry); the warning-emitting retrieve function is attached only to the default registry used when no registry kwarg is given (that is how the base branch triggered the warning), and SPECIFICATIONS carries no retrieve function — it cannot, since referencing's Registry.combine raises "conflicting retrieval functions" when both sides define one, which would break the documented pattern of passing a registry with a custom retrieve. So for the file: $ref here, Resolver.lookup -> crawl -> get_or_retrieve hits referencing's _fail_to_retrieve, raising NoSuchResource, wrapped into Unresolvable and then jsonschema's _WrappedReferencingError; no retriever runs and no DeprecationWarning is emitted at any point (check_schema validates the schema as an instance and follows no instance-position refs either). pytest's default:... filterwarnings mark does not require
Verification: nit. The filterwarnings mark and its comment at tests/client/test_output_schema_validation.py:221-223 ("jsonschema's fallback retriever emits this DeprecationWarning; keep it a plain warning...") describe a warning that cannot fire on this test's code path. The client validates via src/mcp/client/session.py: registry: Registry[Any] = Registry() then `validate(result.structuredContent, output_s
Backport of #3394 to
v1.x.Passes an explicit empty
referencing.Registryto the client's output-schema validation (ClientSession._validate_tool_result), so$refs in a tool'soutputSchemaresolve within that schema and the bundled metaschemas, per the spec's$refresolution section. A reference that doesn't resolve there surfaces fromcall_toolasRuntimeError("Invalid schema for tool …"), the same shape the method already uses for an invalid schema.Motivation and Context
Same change as on
main; see #3394. jsonschema merges its bundled metaschemas into any registry it is given, so#/$defs/…,#/definitions/…,$anchor, embedded$idresources and$refs to the standard metaschema URIs resolve as before.How Has This Been Tested?
New test in
tests/client/test_output_schema_validation.pythrough the in-memory client session; the existing recursive-$defsserver test covers in-document references. Full suite, coverage, pyright and ruff clean locally.Breaking Changes
No API changes. A result whose validation reaches a
$refoutside the schema document now fails withRuntimeError: Invalid schema for tool <name>: …, and a dangling in-document$refsurfaces as that sameRuntimeErrorrather than areferencingexception.Types of changes
Checklist
Additional context
referencingis jsonschema's own dependency and the type of itsregistry=parameter; it is imported directly, as onmain. The new test uses the file's existingbypass_server_output_validation()helper like its neighbours, and is a plain top-level function per the current test guidelines.AI Disclaimer