Skip to content

fix: include defaults and descriptions in MCP schema#159

Draft
latekvo wants to merge 1 commit intomainfrom
fix/zod-schema-emit-defaults-and-descriptions
Draft

fix: include defaults and descriptions in MCP schema#159
latekvo wants to merge 1 commit intomainfrom
fix/zod-schema-emit-defaults-and-descriptions

Conversation

@latekvo
Copy link
Copy Markdown
Member

@latekvo latekvo commented Apr 24, 2026

We currently have a lot of descriptions and defaults in our tool zod definitions, but all are scrubbed before being passed to the agent.

This PR fixes this.

Do we want to expose ALL our descriptions of every single tool parameter on our MCP server? I'm yet to test this, but we probably want to trim these descriptions before doing that.

Claude:

The custom zod → JSON-Schema converter at packages/registry/src/zod-to-json-schema.ts silently dropped three things that every MCP-facing tool relies on: .default(X) values, .describe(...) strings, and z.literal / z.union types. In practice every tool with defaulted params (all of debugger-*, react-profiler-*, profiler-*, network-*, plus others) was handing the LLM a schema that was simultaneously:

  • Missing information — fields emitted as empty {} with no type, no default, no description.
  • Actively wrong — those same fields listed in required[] because the optional-check recognised only ZodOptional, not ZodDefault.

Net effect: an LLM reading the tool list was told "you must pass port, sample_interval_us, contextLines, maxItems, …" with no hint of type or default, instead of "these are optional numbers with sensible defaults."

What changed

  • ZodDefault → unwrap to base type, emit default: X, exclude from required[].
  • .describe() → read type.description on the outermost wrapper and attach it to the emitted schema.
  • ZodLiteral → emit { const: value }.
  • ZodUnion → emit { anyOf: [...] }.
  • ZodNullable → unwrap to base type (was also emitting {}).

isOptional also now treats ZodDefault as optional so defaulted fields leave required[].

Before / after (live HTTP /tools)

debugger-inspect-elementproperties.port

Before After
{} { "type": "number", "default": 8081, "description": "Metro server port" }

debugger-inspect-elementrequired[]

Before After
[port, device_id, x, y, contextLines, resolveSourceMaps, maxItems, includeSkipped] [device_id, x, y]

react-profiler-analyzeproperties.platform

Before After
{} (in required[]) { "type": "string", "enum": ["ios","android"], "default": "ios", "description": "Target platform" }

view-network-logsproperties.pageIndex

Before After
{} (in required[]) { "anyOf": [{"type":"number"}, {"const":"latest"}], "default": "latest", "description": "Page index (0-based) or \"latest\" for the most recent page. Each page contains up to 50 entries." }

Required-field counts (sample)

Tool Before After
debugger-inspect-element 8 3
react-profiler-start 3 1
react-profiler-analyze 5 2
boot-device 0 (all fields were .optional()) 0 (unchanged)
view-network-logs 3 1

Tests

18 new unit tests in packages/registry/tests/zod-to-json-schema.test.ts, one per behavior:

  • Description extraction (standalone, chained after .default(), chained after .optional()).
  • Defaults for number, z.coerce.number, boolean, z.coerce.string, enum.
  • Defaulted fields correctly excluded from required[].
  • ZodDefault wrapping ZodOptional and vice versa.
  • ZodLiteralconst, ZodUnionanyOf, defaulted union.
  • ZodNullable unwraps to base type.
  • Combined realistic tool-schema shape.
  • Three backward-compat guardrails (plain required fields, array-of-primitives, nested object).

All 18 pass; full registry (75) and tool-server (386) suites still pass.

Why a separate PR

This is not Android-specific — every platform, every tool is affected — so splitting it out from feat/android-emulator-support keeps the Android diff focused and makes this fix independently revertable / bisectable if any strict downstream consumer reacts to the new schema fields.

Test plan

  • npx vitest run --root packages/registry — new converter tests + existing registry tests.
  • npx vitest run --root packages/tool-server — ensure no downstream test relies on the old empty-schema shape.
  • Start the tool-server, curl /tools, spot-check a handful of schemas (debugger-inspect-element, react-profiler-analyze, view-network-logs).
  • If any MCP client caches schemas, confirm it picks up the expanded shape cleanly.

The custom zod→JSON-Schema converter dropped three things that matter
for MCP-facing tool schemas:

- ZodDefault was unhandled, so every `.default(X)` field emitted `{}`
  (no type, no default) AND landed in `required[]` because the
  optional-check only recognised ZodOptional. Callers and LLMs were
  told these fields were mandatory with no type hint.
- `.describe(...)` strings were never read, so no tool parameter
  description ever reached the JSON schema.
- ZodLiteral and ZodUnion produced `{}` as well, hiding the single
  union use case in view-network-logs.

Now the converter unwraps ZodDefault / ZodOptional / ZodNullable,
carries defaults and descriptions through, and emits `const` / `anyOf`
for literals and unions. Defaulted fields are correctly omitted from
`required[]`. Covered by 18 new unit tests.
@latekvo
Copy link
Copy Markdown
Member Author

latekvo commented Apr 24, 2026

Opus 4.7 flagged this as a serious rookie mistake. I have to research what our MCP server actually expects from the zod schemas before handing this off to review, because this PR has a large impact to what the harness sees.

@latekvo latekvo changed the title fix(registry): emit default, description, literal, union in JSON schema fix: include defaults and descriptions in MCP schema Apr 28, 2026
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