Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 56 additions & 10 deletions packages/registry/src/zod-to-json-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,25 +20,71 @@ export function zodObjectToJsonSchema(schema: z.ZodObject<any>): Record<string,
}

function zodTypeToJsonSchema(type: z.ZodTypeAny): Record<string, unknown> {
if (type instanceof z.ZodString) return { type: "string" };
if (type instanceof z.ZodNumber) return { type: "number" };
if (type instanceof z.ZodBoolean) return { type: "boolean" };
if (type instanceof z.ZodOptional) return zodTypeToJsonSchema(type.unwrap());
// `.describe()` may sit on any wrapper. Read it before unwrapping so the outer
// description (the one the tool author last wrote) wins over any inner one.
const description = type.description;

// ZodDefault wraps a base type with a callable default. Unwrap, carry the
// default value into the emitted schema so downstream consumers (MCP clients,
// JSON-schema validators) see what value will be used if the caller omits it.
if (type instanceof z.ZodDefault) {
const base = zodTypeToJsonSchema(type._def.innerType);
const defaultValue = type._def.defaultValue();
return withDescription({ ...base, default: defaultValue }, description);
}

// ZodOptional + ZodNullable are both modelled as "absence of value" in JSON
// Schema terms; the caller side just marks them non-required. We pass through
// the inner type's emitted schema so `z.string().optional()` still reads as
// `{ type: "string" }` rather than `{}`.
if (type instanceof z.ZodOptional) {
return withDescription(zodTypeToJsonSchema(type.unwrap()), description);
}
if (type instanceof z.ZodNullable) {
return withDescription(zodTypeToJsonSchema(type.unwrap()), description);
}

if (type instanceof z.ZodString) return withDescription({ type: "string" }, description);
if (type instanceof z.ZodNumber) return withDescription({ type: "number" }, description);
if (type instanceof z.ZodBoolean) return withDescription({ type: "boolean" }, description);
if (type instanceof z.ZodLiteral) {
return withDescription({ const: type._def.value }, description);
}
if (type instanceof z.ZodUnion) {
const anyOf = (type._def.options as z.ZodTypeAny[]).map(zodTypeToJsonSchema);
return withDescription({ anyOf }, description);
}
if (type instanceof z.ZodArray) {
return { type: "array", items: zodTypeToJsonSchema(type.element) };
return withDescription(
{ type: "array", items: zodTypeToJsonSchema(type.element) },
description
);
}
if (type instanceof z.ZodObject) {
return zodObjectToJsonSchema(type);
return withDescription(zodObjectToJsonSchema(type), description);
}
if (type instanceof z.ZodRecord) {
return { type: "object" };
return withDescription({ type: "object" }, description);
}
if (type instanceof z.ZodEnum) {
return { type: "string", enum: type.options };
return withDescription({ type: "string", enum: type.options }, description);
}
return withDescription({}, description);
}

function withDescription(
schema: Record<string, unknown>,
description: string | undefined
): Record<string, unknown> {
if (description && !("description" in schema)) {
return { ...schema, description };
}
return {};
return schema;
}

function isOptional(type: z.ZodTypeAny): boolean {
return type instanceof z.ZodOptional;
// A defaulted field does not need to be supplied by the caller, so it must
// not land in JSON Schema's `required[]` — otherwise every tool with `.default(...)`
// looks mandatory to a strict validator (and, in practice, to every LLM).
return type instanceof z.ZodOptional || type instanceof z.ZodDefault;
}
238 changes: 238 additions & 0 deletions packages/registry/tests/zod-to-json-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { zodObjectToJsonSchema } from "../src/zod-to-json-schema";

describe("zodObjectToJsonSchema — description extraction", () => {
it("emits description from .describe() on primitives", () => {
const schema = zodObjectToJsonSchema(
z.object({
name: z.string().describe("The user's name"),
})
);
expect(schema).toEqual({
type: "object",
properties: {
name: { type: "string", description: "The user's name" },
},
required: ["name"],
});
});

it("emits description from .describe() when chained after .default()", () => {
const schema = zodObjectToJsonSchema(
z.object({
port: z.number().default(8081).describe("Metro server port"),
})
);
expect(schema.properties).toEqual({
port: { type: "number", default: 8081, description: "Metro server port" },
});
});

it("emits description from .describe() after .optional()", () => {
const schema = zodObjectToJsonSchema(
z.object({
nickname: z.string().optional().describe("Optional nickname"),
})
);
expect(schema.properties).toEqual({
nickname: { type: "string", description: "Optional nickname" },
});
});
});

describe("zodObjectToJsonSchema — ZodDefault support", () => {
it("emits default value and unwraps base type", () => {
const schema = zodObjectToJsonSchema(
z.object({
count: z.number().default(10),
})
);
expect(schema.properties).toEqual({
count: { type: "number", default: 10 },
});
});

it("handles z.coerce.number().default(X) — the common Metro-port pattern", () => {
const schema = zodObjectToJsonSchema(
z.object({
port: z.coerce.number().default(8081),
})
);
expect(schema.properties).toEqual({
port: { type: "number", default: 8081 },
});
});

it("handles boolean defaults", () => {
const schema = zodObjectToJsonSchema(
z.object({
verbose: z.boolean().default(false),
})
);
expect(schema.properties).toEqual({
verbose: { type: "boolean", default: false },
});
});

it("handles string defaults (including z.coerce.string)", () => {
const schema = zodObjectToJsonSchema(
z.object({
rn_version: z.coerce.string().default("unknown"),
})
);
expect(schema.properties).toEqual({
rn_version: { type: "string", default: "unknown" },
});
});

it("handles enum defaults", () => {
const schema = zodObjectToJsonSchema(
z.object({
platform: z.enum(["ios", "android"]).default("ios"),
})
);
expect(schema.properties).toEqual({
platform: { type: "string", enum: ["ios", "android"], default: "ios" },
});
});

it("does NOT mark defaulted fields as required", () => {
const schema = zodObjectToJsonSchema(
z.object({
port: z.number().default(8081),
host: z.string(),
})
);
expect(schema.required).toEqual(["host"]);
});

it("handles ZodDefault wrapping ZodOptional", () => {
const schema = zodObjectToJsonSchema(
z.object({
clear: z.boolean().optional().default(false),
})
);
expect(schema.properties).toEqual({
clear: { type: "boolean", default: false },
});
// Not required (both default and optional make it so).
expect(schema.required).toBeUndefined();
});
});

describe("zodObjectToJsonSchema — ZodLiteral and ZodUnion", () => {
it("emits const for string literal", () => {
const schema = zodObjectToJsonSchema(
z.object({
mode: z.literal("latest"),
})
);
expect(schema.properties).toEqual({
mode: { const: "latest" },
});
});

it("emits anyOf for z.union", () => {
const schema = zodObjectToJsonSchema(
z.object({
after: z.union([z.number().int().nonnegative(), z.literal("latest")]),
})
);
expect(schema.properties).toEqual({
after: {
anyOf: [{ type: "number" }, { const: "latest" }],
},
});
});

it("emits default for a union wrapped in ZodDefault", () => {
const schema = zodObjectToJsonSchema(
z.object({
after: z.union([z.number(), z.literal("latest")]).default("latest"),
})
);
expect(schema.properties).toEqual({
after: {
anyOf: [{ type: "number" }, { const: "latest" }],
default: "latest",
},
});
});
});

describe("zodObjectToJsonSchema — ZodNullable", () => {
it("passes through base-type schema for nullable fields", () => {
const schema = zodObjectToJsonSchema(
z.object({
maybeName: z.string().nullable(),
})
);
expect(schema.properties).toEqual({
maybeName: { type: "string" },
});
});
});

describe("zodObjectToJsonSchema — combined realistic shape", () => {
it("emits a full, useful schema for a typical tool", () => {
const toolSchema = z.object({
port: z.coerce.number().default(8081).describe("Metro server port"),
device_id: z.string().describe("Device logicalDeviceId"),
x: z.coerce.number().describe("X coordinate"),
y: z.coerce.number().describe("Y coordinate"),
maxItems: z.coerce.number().default(35).describe("Max items"),
includeSkipped: z.boolean().default(false).describe("Include skipped"),
});
const schema = zodObjectToJsonSchema(toolSchema);

expect(schema.properties).toEqual({
port: { type: "number", default: 8081, description: "Metro server port" },
device_id: { type: "string", description: "Device logicalDeviceId" },
x: { type: "number", description: "X coordinate" },
y: { type: "number", description: "Y coordinate" },
maxItems: { type: "number", default: 35, description: "Max items" },
includeSkipped: { type: "boolean", default: false, description: "Include skipped" },
});
// Only non-defaulted fields are required.
expect(schema.required).toEqual(["device_id", "x", "y"]);
});
});

describe("zodObjectToJsonSchema — backward-compatible basics", () => {
it("still emits required[] for plain required fields", () => {
const schema = zodObjectToJsonSchema(
z.object({
a: z.string(),
b: z.number(),
})
);
expect(schema.required).toEqual(["a", "b"]);
});

it("still handles ZodArray of primitives", () => {
const schema = zodObjectToJsonSchema(
z.object({
tags: z.array(z.string()),
})
);
expect(schema.properties).toEqual({
tags: { type: "array", items: { type: "string" } },
});
});

it("still handles nested ZodObject", () => {
const schema = zodObjectToJsonSchema(
z.object({
point: z.object({ x: z.number(), y: z.number() }),
})
);
expect(schema.properties).toEqual({
point: {
type: "object",
properties: { x: { type: "number" }, y: { type: "number" } },
required: ["x", "y"],
},
});
});
});
Loading