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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions