diff --git a/packages/registry/src/zod-to-json-schema.ts b/packages/registry/src/zod-to-json-schema.ts index 2b756d4c..6a6144c7 100644 --- a/packages/registry/src/zod-to-json-schema.ts +++ b/packages/registry/src/zod-to-json-schema.ts @@ -20,25 +20,71 @@ export function zodObjectToJsonSchema(schema: z.ZodObject): Record { - 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, + description: string | undefined +): Record { + 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; } diff --git a/packages/registry/tests/zod-to-json-schema.test.ts b/packages/registry/tests/zod-to-json-schema.test.ts new file mode 100644 index 00000000..c6fa558b --- /dev/null +++ b/packages/registry/tests/zod-to-json-schema.test.ts @@ -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"], + }, + }); + }); +});