Skip to content

Tool inputSchema generation emits $ref for reused Zod instances · breaks strict MCP clients (kimi) #2100

@daveCode-dot

Description

@daveCode-dot

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions