Skip to content

Make bare object schemas and sibling-less anyOf representable in z.toJSONSchema - #64

Merged
schani merged 2 commits into
mainfrom
fix/issue-63-unrepresentable-schemas
Jul 16, 2026
Merged

Make bare object schemas and sibling-less anyOf representable in z.toJSONSchema#64
schani merged 2 commits into
mainfrom
fix/issue-63-unrepresentable-schemas

Conversation

@schani

@schani schani commented Jul 16, 2026

Copy link
Copy Markdown
Member

Fixes #63.

Problem

Two JSON Schema constructs converted to Zod schemas containing z.custom() parts, which Zod 4's z.toJSONSchema() refuses to serialize (Custom types cannot be represented in JSON Schema) unless called with unrepresentable: "any":

  1. A bare object schema with neither properties nor additionalProperties: { "type": "object" } (and any schema where the permissive object base ends up in a type union, e.g. { "type": ["object", "string"] })
  2. A schema whose only content is anyOf

This broke consumers that don't control the serialization call site — notably @modelcontextprotocol/sdk, which serializes every registered tool's input schema for tools/list via z.toJSONSchema(schema, { target, io }) without unrepresentable: "any", so one affected tool schema failed the entire tools/list response.

Fix

  • Default object schema (src/core/converter.ts): z.looseObject({}) instead of a z.custom() validator. Verified identical accept/reject behavior against the old custom check (rejects arrays and primitives, accepts any other non-null object including Object.create(null), Date, Map); emits { "type": "object", ... }.
  • Sibling-less anyOf (src/handlers/refinement/anyOf.ts): when anyOf is the schema's only constraint (only metadata-like keys beside it, and no $ref/$dynamicRef, which are enforced through the base), return the plain z.union(...) instead of z.intersection() with a base that adds nothing. Emits a plain anyOf. Schemas with sibling constraints keep the intersection.

__proto__ own-property semantics

Unlike the transparent z.custom() (which passed the raw value through), z.looseObject strips own __proto__ keys from its parse output, which broke the existing dependentSchemas own-property-semantics fixture on untyped schemas. dependentSchemas and dependentRequired now run their checks on the raw input (a check piped in front of the base schema, the same pattern PropertiesHandler uses for presence checks) whenever their configuration mentions __proto__. All other configurations keep the plain output refinement, so their base structure stays visible to z.toJSONSchema.

⚠️ Behavioral changes

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

  1. Parse output is a copy, not the input reference. For schemas using the permissive object base (e.g. { "type": "object" }):

    const schema = convertJsonSchemaToZod({ type: "object" });
    const input = { a: 1 };
    schema.parse(input) === input; // 0.5.5: true → now: false

    The copy also has own __proto__ keys stripped (Zod's anti-prototype-pollution behavior), matching what this library's z.object-based schemas already did.

  2. 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:

    convertJsonSchemaToZod({ type: "object" }).parse(new Date());
    // 0.5.5: the Date instance, unchanged → now: {}

    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.

  3. __proto__ own-key semantics narrow for unpatched refinements. Refinements other than dependentSchemas/dependentRequired run on the parse output, where own __proto__ keys are now stripped when the permissive object base is in play:

    convertJsonSchemaToZod({ propertyNames: { maxLength: 3 } })
      .safeParse(JSON.parse('{"__proto__": 1}')).success;
    // 0.5.5: false ("__proto__" is 9 chars) → now: true (key invisible to the check)
    
    convertJsonSchemaToZod({ not: { required: ["__proto__"] } })
      .safeParse(JSON.parse('{"__proto__": 1}')).success;
    // 0.5.5: false → now: true

    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 to propertyNames/not in a follow-up.

Tests

  • New strict-emission tests in src/structural-output.test.ts that serialize exactly like the MCP SDK ({ target: "draft-7", io: "input" }, no unrepresentable escape hatch) for both constructs, plus behavioral checks (array rejection, unknown-key preservation, sibling constraints and $ref still enforced alongside anyOf).
  • Updated the pinned emission for sibling-less anyOf from allOf: [base, anyOf] to a plain anyOf, and re-pinned the intersection shape with an actual sibling constraint.
  • New tests/dependent-required fixtures covering __proto__/toString own-property semantics (mirroring the existing dependentSchemas fixtures).

Issue repro output after the fix:

OK    bare object (no properties/additionalProperties) -> {"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{},"additionalProperties":{}}
OK    sibling-less anyOf -> {"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]}

Full suite: 44,129 passed, 100% line and branch coverage.

🤖 Generated with Claude Code

…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>
Comment thread src/core/utils.ts
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>
@schani
schani merged commit 64354ce into main Jul 16, 2026
4 checks passed
@schani
schani deleted the fix/issue-63-unrepresentable-schemas branch July 16, 2026 16:11
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.

Bare object schemas and sibling-less anyOf convert to Zod schemas that z.toJSONSchema() cannot serialize (breaks MCP SDK tools/list)

1 participant