Make bare object schemas and sibling-less anyOf representable in z.toJSONSchema - #64
Merged
Merged
Conversation
…JSONSchema
Two JSON Schema constructs converted to Zod schemas containing z.custom()
parts, which z.toJSONSchema() refuses to serialize unless called with
unrepresentable: "any". This broke consumers that don't control the
serialization call site, notably @modelcontextprotocol/sdk's tools/list.
- The default object schema (used when neither properties nor
additionalProperties is present, e.g. a bare { "type": "object" }) is now
z.looseObject({}) instead of a z.custom() validator. It has identical
accept/reject behavior (rejects arrays, accepts any other non-null
object) and emits { type: "object" }.
- A schema whose only constraint is anyOf now converts to a plain
z.union(...) instead of z.intersection() with an unconstrained base that
contained the z.custom() object part; the base added nothing.
Unlike the transparent z.custom(), z.looseObject strips own "__proto__"
keys from its parse output, which broke own-property semantics for
dependentSchemas on untyped schemas. dependentSchemas and dependentRequired
now run their checks on the raw input (piped in front of the base schema)
whenever their configuration mentions "__proto__"; other configurations
keep the plain output refinement so their base structure stays visible to
z.toJSONSchema.
Fixes #63
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
schani
commented
Jul 16, 2026
The string match against the whole stringified configuration is deliberate: in dependentRequired every string (entry key or dependent name) is a property name that gets an own-property check, and in dependentSchemas "__proto__" can matter in positions a precise walk cannot enumerate. A false positive only switches the check to the raw input, which is always at least as correct. Rename to mayDependOnProtoKey and spell this out in the doc comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #63.
Problem
Two JSON Schema constructs converted to Zod schemas containing
z.custom()parts, which Zod 4'sz.toJSONSchema()refuses to serialize (Custom types cannot be represented in JSON Schema) unless called withunrepresentable: "any":propertiesnoradditionalProperties:{ "type": "object" }(and any schema where the permissive object base ends up in a type union, e.g.{ "type": ["object", "string"] })anyOfThis broke consumers that don't control the serialization call site — notably
@modelcontextprotocol/sdk, which serializes every registered tool's input schema fortools/listviaz.toJSONSchema(schema, { target, io })withoutunrepresentable: "any", so one affected tool schema failed the entiretools/listresponse.Fix
src/core/converter.ts):z.looseObject({})instead of az.custom()validator. Verified identical accept/reject behavior against the old custom check (rejects arrays and primitives, accepts any other non-null object includingObject.create(null),Date,Map); emits{ "type": "object", ... }.anyOf(src/handlers/refinement/anyOf.ts): whenanyOfis the schema's only constraint (only metadata-like keys beside it, and no$ref/$dynamicRef, which are enforced through the base), return the plainz.union(...)instead ofz.intersection()with a base that adds nothing. Emits a plainanyOf. Schemas with sibling constraints keep the intersection.__proto__own-property semanticsUnlike the transparent
z.custom()(which passed the raw value through),z.looseObjectstrips own__proto__keys from its parse output, which broke the existingdependentSchemasown-property-semantics fixture on untyped schemas.dependentSchemasanddependentRequirednow run their checks on the raw input (a check piped in front of the base schema, the same patternPropertiesHandleruses for presence checks) whenever their configuration mentions__proto__. All other configurations keep the plain output refinement, so their base structure stays visible toz.toJSONSchema.Accept/reject behavior is unchanged for everything the test suite covers (44,129 tests), but the permissive object base now validates by parsing instead of passing the raw value through, which is observable in three ways (verified by running 0.5.5 and this branch side by side):
Parse output is a copy, not the input reference. For schemas using the permissive object base (e.g.
{ "type": "object" }):The copy also has own
__proto__keys stripped (Zod's anti-prototype-pollution behavior), matching what this library'sz.object-based schemas already did.Non-plain objects are accepted but flattened. Validation is unchanged (
Date,Map, class instances still pass), but the parse output is a plain-object copy of enumerable own properties:JSON Schema describes JSON values, where such objects don't exist, but this matters to anyone round-tripping rich objects through a bare object schema.
__proto__own-key semantics narrow for unpatched refinements. Refinements other thandependentSchemas/dependentRequiredrun on the parse output, where own__proto__keys are now stripped when the permissive object base is in play:None of these cases had test coverage, and the README's Known Limitations already document
__proto__handling as best-effort because validation runs on Zod's parse output; this widens that documented limitation to the untyped-schema case for keywords other than the two patched ones. If desired, the same raw-input pipe treatment could be extended topropertyNames/notin a follow-up.Tests
src/structural-output.test.tsthat serialize exactly like the MCP SDK ({ target: "draft-7", io: "input" }, nounrepresentableescape hatch) for both constructs, plus behavioral checks (array rejection, unknown-key preservation, sibling constraints and$refstill enforced alongsideanyOf).anyOffromallOf: [base, anyOf]to a plainanyOf, and re-pinned the intersection shape with an actual sibling constraint.tests/dependent-requiredfixtures covering__proto__/toStringown-property semantics (mirroring the existingdependentSchemasfixtures).Issue repro output after the fix:
Full suite: 44,129 passed, 100% line and branch coverage.
🤖 Generated with Claude Code