Skip to content

fix(mcp): inline internal $refs in tool schemas at ingest - #622

Open
koreankop wants to merge 1 commit into
yc-software:mainfrom
koreankop:mcp-inline-internal-schema-refs
Open

fix(mcp): inline internal $refs in tool schemas at ingest#622
koreankop wants to merge 1 commit into
yc-software:mainfrom
koreankop:mcp-inline-internal-schema-refs

Conversation

@koreankop

@koreankop koreankop commented Aug 20, 2026

Copy link
Copy Markdown

Summary

A tool whose input schema refers to its own $defs or properties by 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 answers Reference not found — so turns fail even when they never touch the offending tool. Registration does not catch it: tools/list and the admin probe both succeed, because the re-rooting happens later.

This change inlines internal refs when a server's tools are ingested:

  • Keywords beside a $ref still apply, so {"$ref": "#/$defs/int", "minimum": 1, "maximum": 10} keeps its bounds and description instead of widening to the bare target.
  • External, unresolvable, and cyclic refs become a permissive {}; a false subschema stays restrictive as {"not": {}}.
  • $defs/definitions are dropped at every level once inlined, not only at the root.
  • Pointers are percent-decoded before ~1/~0 unescaping, per RFC 6901.

Expansion is bounded. Inlining duplicates a target at every use site, so an ordinary schema with a reused type — what zod's toJSONSchema({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. Since refresh() 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 $ref that 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 --noEmit and eslint clean. New cases:

  • ingest through the tool service: multi-hop chain, unresolvable ref, external ref, root $defs removal, no $ref anywhere in the result;
  • keywords beside a $ref survive;
  • always an object schema: root $ref, #, and a non-object schema all fall back; a typeless schema with properties gains type: "object";
  • bounded expansion: depth 8 × width 8 stays under 2 MB and 2 s;
  • 5000-deep plain nesting does not exhaust the stack;
  • escaped (~0, ~1, %20) and array-index pointers resolve;
  • direct and mutual cycles terminate;
  • nested $defs/definitions dropped, and a __proto__ key neither reparents the output nor survives;
  • a false subschema 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.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant