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

Open Beginner friendly
#2,100 3 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
72/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Quiet
Tech stack
typescript
Domain
api

Research direction

Start in packages/core/src/util/standardSchema.ts around line 200 and inspect the z.toJSONSchema call and its current options. Update the tool input schema generation to inline reused schemas, then add a regression test using reused inner schemas and verify that the generated tool input contains no $ref entries.

Written by the indexing model from the issue text.

Description

bug

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

  • kimi k2.6 (immediate)
  • any future MCP client whose $ref resolver is stricter than the spec
  • token-budget reduction on tool listings for ref-heavy servers (already a separately tracked concern — see Softeria/ms-365-mcp-server#438)

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

Dominant language
TypeScript
Stars
13.4k
Forks
2.2k
Avg merge
3d 12h
Merged PRs (30d)
3

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from modelcontextprotocol/typescript-sdk

All issues in modelcontextprotocol/typescript-sdk

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.