You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
However, passing the output of z.toJSONSchema() directly to registerTool() still results in conversion.
With Zod, the converted object retains ~standard property. Its JSON Schema converters reference the original Zod schema, so the SDK invokes those converters and regenerates JSON Schema rather than reusing the pre-converted result.
What did you expect?
Objects detected as JSON schemas to be used directly and not re-converted. However I can also see the case for this being a fix in zod.
I measured the mechanism on zod 4.6.7, the version you linked, and one detail both explains why this is easy to miss and prescribes the fix.
toJSONSchema() attaches ~standard as an own property that is NOT enumerable. So Object.keys(schema) does not show it, hasOwnProperty('~standard') returns true, and neither object spread nor destructuring carries it across.
Two consequences follow.
For the SDK: any "is this already JSON Schema?" predicate written against enumerable keys can never see the marker, which is likely how a pre-converted object ends up being treated as a Zod schema again. A correct predicate has to test with '~standard' in schema or hasOwnProperty, not by inspecting keys.
For a workaround today: JSON.parse(JSON.stringify(schema)) strips it, because stringify serialises only enumerable properties. The SDK then receives plain JSON Schema and skips the converter. I verified the marker is gone after the round-trip. The cost is one deep clone per schema at registration, so at your 1000-tool scale that is 1000 clones rather than 1000 converter invocations. That should be cheaper, though it is worth measuring on your repro rather than trusting the estimate.
On whether this belongs in the SDK or in zod: the non-enumerability is an argument for zod. If the JSON Schema output carried no marker at all, no consumer would need to know it exists, and every consumer that spreads the object or enumerates its keys would stop being subtly wrong without knowing it was. A ~standard-aware predicate in registerTool is still worth having as a guard, since other converters attach similar metadata, but it fixes this one caller rather than the class.
One adjacent effect worth benchmarking while you are in there: the conversion is per registerTool call, so the 1000-tool loop pays it at registration, and whatever toolInputSchemaJson caches may still be recomputed on list. If both paths convert, the clone workaround helps registration only.
One subtlety if the SDK distinguishes raw Zod v4 schemas from pre-converted z.toJSONSchema() objects:
In Zod v4, bothz.object({}) and z.object({}).toJSONSchema() have schema.type === "object" and '~standard' in schema === true! So checking 'type' in schema && schema.type === 'object' is not sufficient on its own to tell a plain JSON Schema apart from a Zod v4 schema instance.
Instead, you can distinguish them reliably by checking for Zod's internal marker ('_zod' in schema or '_def' in schema) or checking whether '~standard' is only a non-enumerable own property attached to a plain JSON Schema object (Object.getPrototypeOf(schema) === Object.prototype && !('_zod' in schema)):
As a zero-cost workaround for callers on v2.3.0 today, shallow-spreading the result (registerTool(name, { inputSchema: { ...z.object({}).toJSONSchema() } }, cb)) drops the non-enumerable ~standard property in O(1) so the SDK skips re-conversion.
(Also worth noting for z.toJSONSchema() users: Zod v4's JSON Schema output includes "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, and #/$defs/... pointers on every tool. If you are exposing a large tool catalog to LLM clients, running mcp-slim in front of the server strips redundant $schema/title metadata and inlines $defs/$ref pointers, cutting tools/list token size by ~49%.)
A use case and numbers for this, from a server with 130 tools (SDK 1.32.2 today, zod 4.6.7).
z.uuid() lists as "format": "uuid" plus a 190-byte pattern. In our tools/list answer that pattern appears 85 times. Removing it from the listing takes the answer from 103,656 to 88,441 bytes (14.7%). Validation does not change, because the server still parses arguments with the original Zod schema.
The SDK calls toJSONSchema with fixed options (target, io), so a server cannot pass override or reused. That leaves two routes:
Convert the schema ourselves with those options and pass the result to registerTool. That is the path this issue is about: it only pays off once a pre-converted schema is used as given and not converted again.
An option on the server or on registerTool that is passed through to the conversion, for example jsonSchema: { override, reused }.
Either one would let us drop a workaround. On v1 we wrap the tools/list handler that McpServer.setToolRequestHandlers registers and post-process each inputSchema, which depends on where the SDK registers that handler.
So +1 for fixing the re-conversion, and if route 2 is of interest I can open a separate issue for it.
Activity
media-ctrl commentedon Nov 3, 2026
I measured the mechanism on zod 4.6.7, the version you linked, and one detail both explains why this is easy to miss and prescribes the fix.
toJSONSchema()attaches~standardas an own property that is NOT enumerable. SoObject.keys(schema)does not show it,hasOwnProperty('~standard')returns true, and neither object spread nor destructuring carries it across.Two consequences follow.
For the SDK: any "is this already JSON Schema?" predicate written against enumerable keys can never see the marker, which is likely how a pre-converted object ends up being treated as a Zod schema again. A correct predicate has to test with
'~standard' in schemaorhasOwnProperty, not by inspecting keys.For a workaround today:
JSON.parse(JSON.stringify(schema))strips it, because stringify serialises only enumerable properties. The SDK then receives plain JSON Schema and skips the converter. I verified the marker is gone after the round-trip. The cost is one deep clone per schema at registration, so at your 1000-tool scale that is 1000 clones rather than 1000 converter invocations. That should be cheaper, though it is worth measuring on your repro rather than trusting the estimate.On whether this belongs in the SDK or in zod: the non-enumerability is an argument for zod. If the JSON Schema output carried no marker at all, no consumer would need to know it exists, and every consumer that spreads the object or enumerates its keys would stop being subtly wrong without knowing it was. A
~standard-aware predicate inregisterToolis still worth having as a guard, since other converters attach similar metadata, but it fixes this one caller rather than the class.One adjacent effect worth benchmarking while you are in there: the conversion is per
registerToolcall, so the 1000-tool loop pays it at registration, and whatevertoolInputSchemaJsoncaches may still be recomputed on list. If both paths convert, the clone workaround helps registration only.SalyyS1 commentedon Nov 12, 2026
One subtlety if the SDK distinguishes raw Zod v4 schemas from pre-converted
z.toJSONSchema()objects:In Zod v4, both
z.object({})andz.object({}).toJSONSchema()haveschema.type === "object"and'~standard' in schema === true! So checking'type' in schema && schema.type === 'object'is not sufficient on its own to tell a plain JSON Schema apart from a Zod v4 schema instance.Instead, you can distinguish them reliably by checking for Zod's internal marker (
'_zod' in schemaor'_def' in schema) or checking whether'~standard'is only a non-enumerable own property attached to a plain JSON Schema object (Object.getPrototypeOf(schema) === Object.prototype && !('_zod' in schema)):As a zero-cost workaround for callers on
v2.3.0today, shallow-spreading the result (registerTool(name, { inputSchema: { ...z.object({}).toJSONSchema() } }, cb)) drops the non-enumerable~standardproperty inO(1)so the SDK skips re-conversion.(Also worth noting for
z.toJSONSchema()users: Zod v4's JSON Schema output includes"$schema": "https://json-schema.org/draft/2020-12/schema","additionalProperties": false, and#/$defs/...pointers on every tool. If you are exposing a large tool catalog to LLM clients, runningmcp-slimin front of the server strips redundant$schema/titlemetadata and inlines$defs/$refpointers, cuttingtools/listtoken size by ~49%.)PromptShield21 commentedon Nov 14, 2026
A use case and numbers for this, from a server with 130 tools (SDK 1.32.2 today, zod 4.6.7).
z.uuid()lists as"format": "uuid"plus a 190-bytepattern. In ourtools/listanswer that pattern appears 85 times. Removing it from the listing takes the answer from 103,656 to 88,441 bytes (14.7%). Validation does not change, because the server still parses arguments with the original Zod schema.Zod already has the switch for this:
The SDK calls
toJSONSchemawith fixed options (target,io), so a server cannot passoverrideorreused. That leaves two routes:registerTool. That is the path this issue is about: it only pays off once a pre-converted schema is used as given and not converted again.registerToolthat is passed through to the conversion, for examplejsonSchema: { override, reused }.Either one would let us drop a workaround. On v1 we wrap the
tools/listhandler thatMcpServer.setToolRequestHandlersregisters and post-process eachinputSchema, which depends on where the SDK registers that handler.So +1 for fixing the re-conversion, and if route 2 is of interest I can open a separate issue for it.