Summary
When a server exposes many tools, repeated Zod schema instances (a common pattern in generated clients) end up producing $ref back-pointers in the tool inputs returned by tools/list. Strict MCP clients — kimi being the immediate trigger — reject those refs and surface a references must start with #/$defs/ error, even though the refs themselves are valid JSON Pointers.
Repro context
Caught downstream in Softeria/ms-365-mcp-server#458. Concrete numbers from the maintainer (@eirikb) after dumping tools/list against a real server:
- 64 of 311 tools had
$ref entries
- 1190 back-refs total
- All of the form
#/properties/... — JSON Pointers back into each tool's own schema, no $defs
- Root cause: the generated client reuses a small set of Zod instances (e.g.
microsoft_graph_dateTimeTimeZone) across many tool inputs, and Zod v4's toJSONSchema decides to emit $ref for each reused instance.
Where it lives in this SDK
packages/core/src/util/standardSchema.ts:200 (current main, commit at clone time):
result = z.toJSONSchema(schema as unknown as z.ZodType, { target: 'draft-2020-12', io }) as Record<string, unknown>;
The call is missing reused (Zod v4 controls deduplication behavior via that option). Without it, Zod's default is to emit $ref for every duplicate instance, which is the behavior @eirikb observed.
Proposed fix
Pass reused: 'inline' so tool input schemas stay self-contained:
result = z.toJSONSchema(schema as unknown as z.ZodType, {
target: 'draft-2020-12',
io,
reused: 'inline',
}) as Record<string, unknown>;
That mirrors what the downstream --discovery mode already does in practice (smaller per-tool schemas, no refs — @eirikb confirmed zero $ref in discovery output).
Why inline by default (vs. exposing as a registerTool option)
- Tool inputs are user-facing surface. A picky client failing on a JSON-Schema-valid construct is a worse default than slightly larger response payloads.
- Tool input schemas are typically shallow; the size penalty from inlining is bounded.
- Servers that genuinely want refs (deep nesting, true
$defs reuse) are the minority — and they can be supported via a server-side override on top of inline default, rather than the current "refs by default + no opt-out" state.
If exposing as an option is preferred over flipping the default, the option name + plumbing should land in McpServer.registerTool's tool-definition path so individual tools can opt out per-tool.
What this unblocks
Trade-offs to be aware of
- Marginal increase in
tools/list payload size for ref-heavy servers
- Behavior change for any client that was relying on the current
$ref shape — mitigated because the inlined schemas are semantically identical, just larger.
Happy to put up a PR with the change + a regression test that asserts no $ref in the output of a tool with reused inner schemas. Wanted to file the issue first to confirm direction.
Refs: downstream investigation thread (full data + @eirikb's tools/list dump).
Summary
When a server exposes many tools, repeated Zod schema instances (a common pattern in generated clients) end up producing
$refback-pointers in the tool inputs returned bytools/list. Strict MCP clients — kimi being the immediate trigger — reject those refs and surface areferences must start with #/$defs/error, even though the refs themselves are valid JSON Pointers.Repro context
Caught downstream in Softeria/ms-365-mcp-server#458. Concrete numbers from the maintainer (@eirikb) after dumping
tools/listagainst a real server:$refentries#/properties/...— JSON Pointers back into each tool's own schema, no$defsmicrosoft_graph_dateTimeTimeZone) across many tool inputs, and Zod v4'stoJSONSchemadecides to emit$reffor each reused instance.Where it lives in this SDK
packages/core/src/util/standardSchema.ts:200(current main, commit at clone time):The call is missing
reused(Zod v4 controls deduplication behavior via that option). Without it, Zod's default is to emit$reffor every duplicate instance, which is the behavior @eirikb observed.Proposed fix
Pass
reused: 'inline'so tool input schemas stay self-contained:That mirrors what the downstream
--discoverymode already does in practice (smaller per-tool schemas, no refs — @eirikb confirmed zero$refin discovery output).Why inline by default (vs. exposing as a registerTool option)
$defsreuse) are the minority — and they can be supported via a server-side override on top ofinlinedefault, rather than the current "refs by default + no opt-out" state.If exposing as an option is preferred over flipping the default, the option name + plumbing should land in
McpServer.registerTool's tool-definition path so individual tools can opt out per-tool.What this unblocks
$refresolver is stricter than the specTrade-offs to be aware of
tools/listpayload size for ref-heavy servers$refshape — mitigated because the inlined schemas are semantically identical, just larger.Happy to put up a PR with the change + a regression test that asserts no
$refin the output of a tool with reused inner schemas. Wanted to file the issue first to confirm direction.Refs: downstream investigation thread (full data + @eirikb's
tools/listdump).