If you describe your tool parameters with Zod's .describe() and convert the schemas to JSON Schema with a converter you wrote yourself, there's a fair chance most of those descriptions never reach the model. In a 450-tool MCP server I built, 100 of 123 .describe() calls (81 percent) were silently discarded. All 12 on banking.tx_history were among them, including the one on limit that read "default 100, max 1000". The code wrote z.string().optional().describe(...), which puts the description on the ZodOptional wrapper. The converter unwrapped that wrapper and returned the inner schema without carrying the description across.
Below I show where the description sits in the Zod tree and why the converter lost it. Then come the other node types the same converter dropped, what each loss does to a model calling the tool, the three-line fix, and a checklist for your own server. The appendix is a separate experiment on the response side. It tests where in a tool result a note has to sit before six models will pass it on to the user.
The server is a personal one, 54 modules of tools, and I had an adversarial review run over its tool layer, read-only, with no source modified. The reviewer counted the .describe() calls in src/tools/ and the top-level source files and checked which position each one was written in. Then it built a schema with each form and ran it through the compiled converter in dist/ to see what came out. It counted 83 of the 123 calls in the form the converter drops. That count only caught chains written on one line. Seventeen more put .optional() on one line and .describe() on the next, mostly in the sleep-diary and purchasing tools, and the converter drops those the same way. Counted together, 100 of the 123 never reached the model.
source: lifemain docs/review-2026-09-01-tools-bot.md section 2, lines 62-63 (123 calls, 83 dropped on one line); chain-aware recount of src/tools/*.ts and src/*.ts at commit 8cbe41f (17 split across lines); banking.ts lines 126-137 · describeCount.data.ts
Where the Description Goes
A Zod schema is a tree of instances. z.string() is one instance. Calling .optional() on it returns a new ZodOptional instance that wraps the string. The Zod docs say so directly: it "returns a ZodOptional instance that wraps the original schema," with .unwrap() to get the inner one back. Calling .describe("ISO date") on that result also returns a new instance, and the description belongs to whichever instance it was called on. So the order of the two calls decides which node holds the text.
The server pins Zod 3 (^3.23.8 in package.json, 3.25.76 installed), where the description lives on the schema's _def. Zod 4 moved it into a global registry. Its metadata docs say .describe() "is a shorthand for registering a schema in z.globalRegistry with just a description field" and that metadata "is associated with a specific schema instance." Either way the rule is the same. z.string().optional().describe("ISO date") puts the description on the ZodOptional. z.string().describe("ISO date").optional() puts it on the string inside. The codebase overwhelmingly wrote the first form.
source: lifemain src/zod-to-json.ts (ZodOptional branch, withDesc); fix from docs/review-2026-09-01-tools-bot.md lines 67-69; Zod 3 API: optional, unwrap, describe · describeTrees.data.ts
The converter reads the description in one place, a helper called withDesc that copies _def.description onto the JSON it's about to return. Every leaf type (string, number, boolean, enum) goes through it, and so do arrays. The wrappers didn't. Here are lines 40 to 57 of src/zod-to-json.ts as the review found them:
if (t === "ZodOptional" || t === "ZodNullable") { const inner = (def as unknown as { innerType: ZodTypeAny }).innerType; return convert(inner);}if (t === "ZodDefault") { const inner = (def as unknown as { innerType: ZodTypeAny }).innerType; return convert(inner);}// ZodEffects wraps a schema in .refine()/.transform()/.superRefine(); the// real shape lives at _def.schema. Unwrap it so refine-guarded tools (e.g.// pantry.adjust's xor on delta/set_qty) still advertise their object schema// rather than a degenerate {} that the MCP client rejects.if (t === "ZodEffects") { const inner = (def as unknown as { schema: ZodTypeAny }).schema; return convert(inner);}return {};Each wrapper branch hands its inner schema to convert and returns whatever comes back. The inner string has no description, because the description is on the wrapper, and the wrapper's _def is never read. The comment on the ZodEffects branch shows the converter had already been fixed once for a related problem. A refined object used to come out as {}, and the fix unwrapped it, which carried the shape across and left the description on the wrapper.
I ran a small schema through the server's compiled converter to show both orders and three of the other losses. This is the input:
z.object({ a: z.string().optional().describe("YYYY-MM-DD"), b: z.string().describe("YYYY-MM-DD").optional(), limit: z.number().int().min(1).max(50).default(10), confirm: z.literal(true), details: z.record(z.string()),});And this is what the server serves today, unformatted:
{"type":"object","properties":{"a":{"type":"string"},"b":{"type":"string","description":"YYYY-MM-DD"},"limit":{"type":"integer"},"confirm":{"type":"boolean","const":true},"details":{}},"required":["confirm","details"]}Field a lost its description and b kept it. limit lost its minimum, its maximum and its default. details is an empty schema. (confirm comes out correctly now. At the time of the review it came out as {}, which the next section covers.)
The review's fix captures the description before unwrapping and puts it on whatever the inner call returns, unless the inner node already has its own:
// for ZodOptional, ZodNullable, ZodDefault and ZodEffects: capture before unwrappingconst d = def.description;const out = convert(inner);if (d && !out.description) out.description = d;return out;I applied those lines to the three wrapper branches in a copy of the compiled converter and ran the same schema. Field a came out as {"type":"string","description":"YYYY-MM-DD"}, the same as b, and nothing else in the output changed.
The Model Learns Your Bounds by Triggering Errors
The same converter dropped three node types to {} and threw away every constraint. A second review, of the core infrastructure, counted the node types it had no branch for across all 450 tools: ZodRecord 7 times, ZodUnion 4 times and ZodLiteral twice. Twelve advertised parameters came out as an untyped {}.
The literal mattered most. z.literal(true) on a confirm field was the interlock on the two destructive purge tools, policy.retention_apply and policy.archive_purge. The point of the field is that the caller has to send true on purpose. Converted to {}, the schema published it as a required property with no type and no const. A strict MCP client might reject the tool outright. A lenient one lets the call through, and the model finds out what confirm wants when the runtime check sends back an error.
Minimum and maximum went next. life.timeline declares limit: z.number().int().min(1).max(1000).default(200) and published a bare {"type":"integer"}. A model that passes limit: 5000 gets a raw Zod validation string back, and nothing it could have read beforehand would have told it 1,000 was the ceiling.
Defaults went with the ZodDefault unwrap, which never emitted a default key. purchasing.allowlist takes an action that defaults to "show". A model that calls purchasing.allowlist({merchant: "nike"}) meaning to block the merchant gets a listing back instead, and there's no error to tell it anything went wrong.
Records and unions became {}, so a field that accepts one of a few shapes reads to the model as accepting anything. Those are the details fields on life.record, life.update and finance.position. They are also the from_id and to_id on life.link, the source_id on commitments.add and goals.attribute, the conditions and action on automation.rule_set, and metadata on identity.upsert. Some MCP clients reject or mishandle a property with an empty schema, so the cost there may be the whole tool rather than one field.
Since the review, the literal and nullable branches have been fixed. ZodLiteral now emits its type and const, and ZodNullable emits an anyOf with null and keeps its own description. That's why confirm is correct in the output above. The ZodOptional, ZodDefault and ZodEffects branches still return convert(inner) with nothing carried across, records and unions still come out as {}, and bounds and defaults are still not emitted.
source: lifemain docs/review-2026-09-01-core.md section 11, lines 353-377; tool source at commit 8cbe41f; current src/zod-to-json.ts lines 31-34 and 64 · emptySchemas.data.ts
The Checklist
These are the checks I'd run on any MCP server that builds its schemas this way.
- Serve your schemas, then diff them against your source. Count
.describe()in the code and countdescriptionin the JSON the server actually serves. If the second number is smaller, find out where the rest went. - Copy descriptions across every wrapper: optional, nullable, default and effects (refine, transform, superRefine). The fix above does it.
- Never convert a node type to
{}silently. Throw on an unknown type, or at least log it, so the next type you add without a branch shows up at startup. - Carry min, max, default, enum and literal into the JSON Schema. They're the only bounds the model can read before it calls.
- Put any interlock in the schema and in the runtime check, and confirm both are present after conversion. A
confirm: truethat only the runtime enforces is one the model learns about from an error. - Or use a maintained converter, and diff its output against your hand-written one once, field by field.
- Add a converter test that asserts
.optional().describe()round-trips. The server had 35 test files and none for the converter.
Limits
The MCP tool definition carries a description and an inputSchema in JSON Schema (the 2025-06-18 specification). Parameter descriptions live inside that schema's properties, which is exactly where this converter was dropping them.
The 81 percent is one server's number, from one converter and one review. A server that writes .describe() before .optional() by habit would lose far fewer, and a server that uses a maintained converter may lose none. I haven't measured whether the missing descriptions changed how often models called these tools wrong. The appendix is the closest evidence I have, and it's about a different channel.
The describe fix has not landed. As of the current source, the wrapper branches are as shown above minus ZodNullable. The server has grown, and the current source has 167 .describe() calls, 139 of them (83 percent) in a position the converter drops.
The descriptions were written, reviewed and committed alongside the tools they describe. The model never saw a word of the 100, and no test, log or client error reported it.
Appendix: Where a Note Has to Sit to Be Read at All
This is a separate experiment from Stratum, an MCP server and REST API I built over every SEC EDGAR filing. I wanted the server to get a short note to the user through the assistant, so I tested where in a tool response the note has to sit. The model plays an assistant and the user asks for Apple's most recent 10-K. The tool response carries the answer plus the note in one of eight placements, one of which is no note at all. Six models, three runs per cell, 144 replies. A reply counts as relayed when it contains the note's URL.
As a second item in the MCP content[] array, the note was relayed in 3 of 3 runs by Claude Sonnet 4.5, Claude Haiku 4.5, GPT-4o, GPT-4o-mini and Llama 3.3 70B. Gemini 2.5 Flash relayed it in 1 of 3. The four other placements with neutral wording were a metadata field, a _notice inside the data, a sibling of the data, and a text block after the JSON. Across the six models, relay fell to between 6 and 9 of 18 runs there (33 to 50 percent). Haiku relayed nothing in any metadata placement and everything in the content channel. An earlier finding that Haiku won't relay was an artifact of the note never reaching it. The conversational phrasing in the metadata field was relayed 2 times in 18, both by GPT-4o, and the deprecation-warning phrasing 0 times in 18. N is 3 per cell, and the eval doc calls the result directional, not significant.
| Placement | Haiku 4.5 | Sonnet 4.5 | GPT-4o-mini | GPT-4o | Gemini 2.5 Flash | Llama 3.3 70B |
|---|---|---|---|---|---|---|
Second content[] item | 3/3 | 3/3 | 3/3 | 3/3 | 1/3 | 3/3 |
data._notice inside the data | 0/3 | 3/3 | 0/3 | 3/3 | 0/3 | 3/3 |
| Metadata field, neutral | 0/3 | 3/3 | 0/3 | 1/3 | 0/3 | 3/3 |
Top-level sibling of data | 0/3 | 3/3 | 0/3 | 1/3 | 0/3 | 2/3 |
| Text block after the JSON | 0/3 | 1/3 | 0/3 | 2/3 | 0/3 | 3/3 |
| Metadata field, conversational | 0/3 | 0/3 | 0/3 | 2/3 | 0/3 | 0/3 |
| Metadata field, deprecation warning | 0/3 | 0/3 | 0/3 | 0/3 | 0/3 | 0/3 |
| No note (baseline) | 0/3 | 0/3 | 0/3 | 0/3 | 0/3 | 0/3 |
source: stratum test-results/assistant-note-eval.json, aggregate.byVariantModel urlCount (run 2026-05-26); setup and limits, docs/assistant-note-eval.md · relayGrid.data.ts