fix(mcp): inline internal $refs in tool schemas at ingest - #622
Open
koreankop wants to merge 1 commit into
Open
Conversation
A tool schema that refers to its own $defs or properties by JSON pointer stops
resolving once the schema is re-rooted into a model's tool list: the pointer
still reads '#/properties/...', but that path no longer exists at the new root.
The provider then rejects the whole tool list — Anthropic answers 'Reference
not found' — so every turn fails while such a server is enabled, including
turns that never touch the offending tool. Registration does not catch it:
tools/list and the admin probe both succeed, because the re-rooting happens
later.
Inline internal refs when a server's tools are ingested. Keywords beside a
$ref still apply, so a constrained reference keeps its bounds and description
instead of widening to the bare target. Refs that are external, unresolvable,
or cyclic become a permissive {}, and a 'false' subschema stays restrictive.
Expansion is bounded by a node budget and a depth cap: inlining duplicates a
target at every use site, so an ordinary schema with a reused type — what zod
and pydantic emit by default — can otherwise expand by orders of magnitude and
stall the event loop, and deep plain nesting can exhaust the stack. Both now
degrade to a permissive subschema. The result is always an object schema, so a
root-level ref that cannot be resolved falls back to a permissive object rather
than the untyped {} that caused the original failure.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A tool whose input schema refers to its own
$defsorpropertiesby JSON pointer breaks every turn once its schema is re-rooted into a model provider's tool list: the pointer still reads#/properties/..., but that path no longer exists at the new root. The provider rejects the entire tool list — Anthropic answersReference not found— so turns fail even when they never touch the offending tool. Registration does not catch it:tools/listand the admin probe both succeed, because the re-rooting happens later.This change inlines internal refs when a server's tools are ingested:
$refstill apply, so{"$ref": "#/$defs/int", "minimum": 1, "maximum": 10}keeps its bounds and description instead of widening to the bare target.{}; afalsesubschema stays restrictive as{"not": {}}.$defs/definitionsare dropped at every level once inlined, not only at the root.~1/~0unescaping, per RFC 6901.Expansion is bounded. Inlining duplicates a target at every use site, so an ordinary schema with a reused type — what
zod'stoJSONSchema({reused: "ref"})and pydantic emit by default — can expand by orders of magnitude; a 2 KB schema of depth 8 with 8 fields per level expanded to hundreds of MB and stalled the event loop for tens of seconds, and deep plain nesting could exhaust the stack. A node budget and a depth cap now degrade those to a permissive subschema instead. Sincerefresh()runs on the main loop at construction, on registry change, and on a timer, that bound matters for availability.Finally, the result is always an object schema: a root-level
$refthat cannot be resolved falls back to a permissive object rather than an untyped{}, which would otherwise reach the provider as a tool with no object schema and reproduce the original failure by another route.Test plan
node --experimental-test-module-mocks --test test/mcp-connectors.test.ts— 16/16 pass,tsc --noEmitandeslintclean. New cases:$defsremoval, no$refanywhere in the result;$refsurvive;$ref,#, and a non-object schema all fall back; a typeless schema withpropertiesgainstype: "object";~0,~1,%20) and array-index pointers resolve;$defs/definitionsdropped, and a__proto__key neither reparents the output nor survives;falsesubschema stays restrictive.Observed in practice with a public MCP server whose schemas carry
#/properties/...refs: with it enabled every turn failed; with this change the same server's tools ingest and run.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.