From f6d2cbf2136acb41ad7c3e0695e6ee89e070a6c6 Mon Sep 17 00:00:00 2001 From: veera Date: Fri, 6 Mar 2026 13:50:50 -0500 Subject: [PATCH] fix: replace @dmitryrechkin/json-schema-to-zod with native zod v4 converter Remove the @dmitryrechkin/json-schema-to-zod dependency which requires zod v3 and replace it with a self-contained JSON Schema to Zod converter that works natively with zod v4. Changes: - Remove @dmitryrechkin/json-schema-to-zod from dependencies in package.json - Add src/json-schema-to-zod.ts: self-contained converter supporting strings (format, pattern, enum, length), numbers/integers (min/max/multipleOf), booleans, nulls, arrays (items, min/max), objects (required, additionalProperties), combinators (oneOf/anyOf/allOf), nullable types, and type arrays - Update src/agent.ts: replace dynamic import of the external package with a direct import of the local jsonSchemaToZod function; removes the `as unknown as ZodType` cast since it now returns a native zod v4 type - Add src/json-schema-to-zod.test.ts: 25 tests covering all conversion scenarios including MCP-style tool input schemas This eliminates the zod v3/v4 compatibility issue where ZodSchema was renamed to ZodType in zod v4, which was causing runtime errors without patching. --- packages/agent-kit/package.json | 1 - packages/agent-kit/pnpm-lock.yaml | 10 - packages/agent-kit/src/agent.ts | 10 +- .../agent-kit/src/json-schema-to-zod.test.ts | 265 +++++++++++++++ packages/agent-kit/src/json-schema-to-zod.ts | 306 ++++++++++++++++++ 5 files changed, 574 insertions(+), 18 deletions(-) create mode 100644 packages/agent-kit/src/json-schema-to-zod.test.ts create mode 100644 packages/agent-kit/src/json-schema-to-zod.ts diff --git a/packages/agent-kit/package.json b/packages/agent-kit/package.json index ab9052c..ab39a14 100644 --- a/packages/agent-kit/package.json +++ b/packages/agent-kit/package.json @@ -47,7 +47,6 @@ } }, "dependencies": { - "@dmitryrechkin/json-schema-to-zod": "^1.0.0", "@inngest/ai": "0.1.6", "@modelcontextprotocol/sdk": "^1.11.2", "eventsource": "^3.0.2", diff --git a/packages/agent-kit/pnpm-lock.yaml b/packages/agent-kit/pnpm-lock.yaml index d65da42..286d321 100644 --- a/packages/agent-kit/pnpm-lock.yaml +++ b/packages/agent-kit/pnpm-lock.yaml @@ -19,9 +19,6 @@ importers: .: dependencies: - '@dmitryrechkin/json-schema-to-zod': - specifier: ^1.0.0 - version: 1.0.1 '@inngest/ai': specifier: 0.1.6 version: 0.1.6 @@ -84,9 +81,6 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} - '@dmitryrechkin/json-schema-to-zod@1.0.1': - resolution: {integrity: sha512-cG9gC4NMu/7JZqmRZy6uIb+l+kxek2GFQ0/qrhw7xeFK2l5B9yF9FVuujoqFPLRGDHNFYqtBWht7hY4KB0ngrA==} - '@esbuild/aix-ppc64@0.25.10': resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==} engines: {node: '>=18'} @@ -2635,10 +2629,6 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 - '@dmitryrechkin/json-schema-to-zod@1.0.1': - dependencies: - zod: 3.25.76 - '@esbuild/aix-ppc64@0.25.10': optional: true diff --git a/packages/agent-kit/src/agent.ts b/packages/agent-kit/src/agent.ts index 32cc5bd..8ea3f44 100644 --- a/packages/agent-kit/src/agent.ts +++ b/packages/agent-kit/src/agent.ts @@ -1,4 +1,3 @@ -import type { JSONSchema } from "@dmitryrechkin/json-schema-to-zod"; import { type AiAdapter } from "@inngest/ai"; import { Client as MCPClient } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; @@ -14,6 +13,7 @@ import { errors } from "inngest/internals"; import { type InngestFunction } from "inngest"; import { type MinimalEventPayload } from "inngest/types"; import type { ZodType } from "zod"; +import { jsonSchemaToZod, type JSONSchema } from "./json-schema-to-zod"; import { createAgenticModelFromAiAdapter, type AgenticModel } from "./model"; import { createNetwork, NetworkRun } from "./network"; import { State, type StateData } from "./state"; @@ -830,9 +830,6 @@ export class Agent { * listMCPTools lists all available tools for a given MCP server */ private async listMCPTools(server: MCP.Server) { - const { JSONSchemaToZod } = await import( - "@dmitryrechkin/json-schema-to-zod" - ); const client = await this.mcpClient(server); this._mcpClients.push(client); try { @@ -845,10 +842,9 @@ export class Agent { let zschema: undefined | ZodType; try { - // The converter may return a Zod v3 schema type; coerce to v4 type or fallback - zschema = JSONSchemaToZod.convert( + zschema = jsonSchemaToZod( t.inputSchema as JSONSchema - ) as unknown as ZodType; + ); // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { // Do nothing here. diff --git a/packages/agent-kit/src/json-schema-to-zod.test.ts b/packages/agent-kit/src/json-schema-to-zod.test.ts new file mode 100644 index 0000000..138c8af --- /dev/null +++ b/packages/agent-kit/src/json-schema-to-zod.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, test } from "vitest"; +import { z } from "zod"; +import { jsonSchemaToZod, type JSONSchema } from "./json-schema-to-zod"; + +describe("jsonSchemaToZod", () => { + test("converts string type", () => { + const schema: JSONSchema = { type: "string" }; + const zod = jsonSchemaToZod(schema); + expect(zod.parse("hello")).toBe("hello"); + expect(() => zod.parse(123)).toThrow(); + }); + + test("converts string with minLength and maxLength", () => { + const schema: JSONSchema = { type: "string", minLength: 2, maxLength: 5 }; + const zod = jsonSchemaToZod(schema); + expect(zod.parse("hi")).toBe("hi"); + expect(() => zod.parse("a")).toThrow(); + expect(() => zod.parse("toolong")).toThrow(); + }); + + test("converts string with pattern", () => { + const schema: JSONSchema = { type: "string", pattern: "^[a-z]+$" }; + const zod = jsonSchemaToZod(schema); + expect(zod.parse("abc")).toBe("abc"); + expect(() => zod.parse("ABC")).toThrow(); + }); + + test("converts string with email format", () => { + const schema: JSONSchema = { type: "string", format: "email" }; + const zod = jsonSchemaToZod(schema); + expect(zod.parse("test@example.com")).toBe("test@example.com"); + expect(() => zod.parse("not-an-email")).toThrow(); + }); + + test("converts string enum", () => { + const schema: JSONSchema = { type: "string", enum: ["a", "b", "c"] }; + const zod = jsonSchemaToZod(schema); + expect(zod.parse("a")).toBe("a"); + expect(() => zod.parse("d")).toThrow(); + }); + + test("converts number type", () => { + const schema: JSONSchema = { type: "number" }; + const zod = jsonSchemaToZod(schema); + expect(zod.parse(42)).toBe(42); + expect(zod.parse(3.14)).toBe(3.14); + expect(() => zod.parse("not a number")).toThrow(); + }); + + test("converts integer type", () => { + const schema: JSONSchema = { type: "integer" }; + const zod = jsonSchemaToZod(schema); + expect(zod.parse(42)).toBe(42); + expect(() => zod.parse(3.14)).toThrow(); + }); + + test("converts number with min/max", () => { + const schema: JSONSchema = { type: "number", minimum: 0, maximum: 100 }; + const zod = jsonSchemaToZod(schema); + expect(zod.parse(50)).toBe(50); + expect(() => zod.parse(-1)).toThrow(); + expect(() => zod.parse(101)).toThrow(); + }); + + test("converts boolean type", () => { + const schema: JSONSchema = { type: "boolean" }; + const zod = jsonSchemaToZod(schema); + expect(zod.parse(true)).toBe(true); + expect(zod.parse(false)).toBe(false); + expect(() => zod.parse("true")).toThrow(); + }); + + test("converts null type", () => { + const schema: JSONSchema = { type: "null" }; + const zod = jsonSchemaToZod(schema); + expect(zod.parse(null)).toBe(null); + expect(() => zod.parse("null")).toThrow(); + }); + + test("converts simple object", () => { + const schema: JSONSchema = { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name"], + }; + const zod = jsonSchemaToZod(schema); + expect(zod.parse({ name: "John" })).toEqual({ name: "John" }); + expect(zod.parse({ name: "John", age: 30 })).toEqual({ + name: "John", + age: 30, + }); + expect(() => zod.parse({ age: 30 })).toThrow(); + }); + + test("converts object without explicit type (with properties)", () => { + const schema: JSONSchema = { + properties: { + format: { type: "string" }, + }, + required: ["format"], + }; + const zod = jsonSchemaToZod(schema); + expect(zod.parse({ format: "hello" })).toEqual({ format: "hello" }); + expect(() => zod.parse({})).toThrow(); + }); + + test("converts array type", () => { + const schema: JSONSchema = { + type: "array", + items: { type: "string" }, + }; + const zod = jsonSchemaToZod(schema); + expect(zod.parse(["a", "b"])).toEqual(["a", "b"]); + expect(() => zod.parse([1, 2])).toThrow(); + }); + + test("converts array with min/max items", () => { + const schema: JSONSchema = { + type: "array", + items: { type: "number" }, + minItems: 1, + maxItems: 3, + }; + const zod = jsonSchemaToZod(schema); + expect(zod.parse([1])).toEqual([1]); + expect(() => zod.parse([])).toThrow(); + expect(() => zod.parse([1, 2, 3, 4])).toThrow(); + }); + + test("converts nested objects", () => { + const schema: JSONSchema = { + type: "object", + properties: { + address: { + type: "object", + properties: { + street: { type: "string" }, + city: { type: "string" }, + }, + required: ["street", "city"], + }, + }, + required: ["address"], + }; + const zod = jsonSchemaToZod(schema); + expect( + zod.parse({ address: { street: "123 Main St", city: "Springfield" } }) + ).toEqual({ address: { street: "123 Main St", city: "Springfield" } }); + expect(() => zod.parse({ address: { street: "123 Main St" } })).toThrow(); + }); + + test("converts nullable type via type array", () => { + const schema: JSONSchema = { type: ["string", "null"] }; + const zod = jsonSchemaToZod(schema); + expect(zod.parse("hello")).toBe("hello"); + expect(zod.parse(null)).toBe(null); + expect(() => zod.parse(123)).toThrow(); + }); + + test("converts oneOf combinator", () => { + const schema: JSONSchema = { + oneOf: [{ type: "string" }, { type: "number" }], + }; + const zod = jsonSchemaToZod(schema); + expect(zod.parse("hello")).toBe("hello"); + expect(zod.parse(42)).toBe(42); + expect(() => zod.parse(true)).toThrow(); + }); + + test("converts anyOf combinator", () => { + const schema: JSONSchema = { + anyOf: [{ type: "string" }, { type: "number" }], + }; + const zod = jsonSchemaToZod(schema); + expect(zod.parse("hello")).toBe("hello"); + expect(zod.parse(42)).toBe(42); + }); + + test("converts allOf combinator for objects", () => { + const schema: JSONSchema = { + allOf: [ + { + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], + }, + { + type: "object", + properties: { age: { type: "number" } }, + required: ["age"], + }, + ], + }; + const zod = jsonSchemaToZod(schema); + expect(zod.parse({ name: "John", age: 30 })).toEqual({ + name: "John", + age: 30, + }); + }); + + test("converts enum without type", () => { + const schema: JSONSchema = { enum: ["red", "green", "blue"] }; + const zod = jsonSchemaToZod(schema); + expect(zod.parse("red")).toBe("red"); + expect(() => zod.parse("yellow")).toThrow(); + }); + + test("handles unknown type as z.any()", () => { + const schema: JSONSchema = {}; + const zod = jsonSchemaToZod(schema); + expect(zod.parse("anything")).toBe("anything"); + expect(zod.parse(42)).toBe(42); + expect(zod.parse(null)).toBe(null); + }); + + test("converts MCP-style tool input schema (format property as string)", () => { + // This is the exact schema from the MCP test + const schema: JSONSchema = { + type: "object", + properties: { + format: { type: "string" }, + }, + required: ["format"], + }; + const zod = jsonSchemaToZod(schema); + expect(zod.parse({ format: "%s" })).toEqual({ format: "%s" }); + expect(() => zod.parse({})).toThrow(); + expect(() => zod.parse({ format: 123 })).toThrow(); + }); + + test("converts object with additionalProperties: true", () => { + const schema: JSONSchema = { + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + additionalProperties: true, + }; + const zod = jsonSchemaToZod(schema); + expect(zod.parse({ name: "John", extra: "data" })).toEqual({ + name: "John", + extra: "data", + }); + }); + + test("converts multi-type array (union)", () => { + const schema: JSONSchema = { type: ["string", "number"] }; + const zod = jsonSchemaToZod(schema); + expect(zod.parse("hello")).toBe("hello"); + expect(zod.parse(42)).toBe(42); + expect(() => zod.parse(true)).toThrow(); + }); + + test("converts number enum", () => { + const schema: JSONSchema = { type: "number", enum: [1, 2, 3] }; + const zod = jsonSchemaToZod(schema); + expect(zod.parse(1)).toBe(1); + expect(zod.parse(2)).toBe(2); + expect(() => zod.parse(4)).toThrow(); + }); +}); diff --git a/packages/agent-kit/src/json-schema-to-zod.ts b/packages/agent-kit/src/json-schema-to-zod.ts new file mode 100644 index 0000000..4f25725 --- /dev/null +++ b/packages/agent-kit/src/json-schema-to-zod.ts @@ -0,0 +1,306 @@ +import { z, type ZodType } from "zod"; + +/** + * JSONSchema type representing a JSON Schema object. + * Supports standard JSON Schema properties used by MCP tool definitions. + */ +export interface JSONSchema { + type?: string | string[]; + properties?: Record; + items?: JSONSchema | JSONSchema[]; + required?: string[]; + enum?: (string | number | boolean | null)[]; + const?: string | number | boolean | null; + format?: string; + pattern?: string; + minLength?: number; + maxLength?: number; + minimum?: number; + maximum?: number; + exclusiveMinimum?: number; + exclusiveMaximum?: number; + multipleOf?: number; + minItems?: number; + maxItems?: number; + uniqueItems?: boolean; + oneOf?: JSONSchema[]; + allOf?: JSONSchema[]; + anyOf?: JSONSchema[]; + not?: JSONSchema; + additionalProperties?: boolean | JSONSchema; + description?: string; + default?: unknown; + nullable?: boolean; + if?: JSONSchema; + then?: JSONSchema; + else?: JSONSchema; + [key: string]: unknown; +} + +/** + * Converts a JSON Schema to a Zod schema. + * + * This is a self-contained replacement for `@dmitryrechkin/json-schema-to-zod` + * that works natively with zod v4. + */ +export function jsonSchemaToZod(schema: JSONSchema): ZodType { + return parseSchema(schema); +} + +function parseSchema(schema: JSONSchema): ZodType { + // Handle array of types (e.g., ['string', 'null'] for nullable types) + if (Array.isArray(schema.type)) { + return handleTypeArray(schema); + } + + // Handle combinators (oneOf, anyOf, allOf) + if (schema.oneOf || schema.anyOf || schema.allOf) { + return parseCombinator(schema); + } + + // Handle object schema without explicit type but with properties + if (schema.properties && (!schema.type || schema.type === "object")) { + return parseObject(schema); + } + + // Handle all other types + return handleSingleType(schema); +} + +function handleTypeArray(schema: JSONSchema): ZodType { + const types = schema.type as string[]; + const isNullable = types.includes("null"); + const nonNullTypes = types.filter((t) => t !== "null"); + + if (nonNullTypes.length === 0) { + return z.null(); + } + + if (nonNullTypes.length === 1) { + const inner = handleSingleType({ ...schema, type: nonNullTypes[0] }); + return isNullable ? inner.nullable() : inner; + } + + // Union of multiple types + const schemas = nonNullTypes.map((t) => + handleSingleType({ ...schema, type: t }) + ); + const union = z.union(schemas as [ZodType, ZodType, ...ZodType[]]); + return isNullable ? union.nullable() : union; +} + +function handleSingleType(schema: JSONSchema): ZodType { + if (schema.type === undefined) { + if (schema.oneOf || schema.anyOf || schema.allOf) { + return parseCombinator(schema); + } + if (schema.properties) { + return parseObject(schema); + } + if (schema.enum) { + return parseEnum(schema); + } + return z.any(); + } + + switch (schema.type) { + case "string": + return parseString(schema); + case "number": + case "integer": + return parseNumber(schema); + case "boolean": + return z.boolean(); + case "array": + return parseArray(schema); + case "object": + return parseObject(schema); + case "null": + return z.null(); + default: + return z.any(); + } +} + +function parseString(schema: JSONSchema): ZodType { + let s = z.string(); + + if (schema.minLength !== undefined) { + s = s.min(schema.minLength); + } + if (schema.maxLength !== undefined) { + s = s.max(schema.maxLength); + } + if (schema.pattern) { + s = s.regex(new RegExp(schema.pattern)); + } + + if (schema.format) { + switch (schema.format) { + case "email": + s = s.email(); + break; + case "uri": + case "url": + s = s.url(); + break; + case "uuid": + s = s.uuid(); + break; + case "date-time": + s = s.datetime(); + break; + case "ipv4": + s = s.regex(/^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$/); + break; + case "ipv6": + s = s.regex(/^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/); + break; + } + } + + if (schema.enum) { + return z.enum(schema.enum as [string, ...string[]]); + } + + return applyNullable(s, schema); +} + +function parseNumber(schema: JSONSchema): ZodType { + let n = schema.type === "integer" ? z.int() : z.number(); + + if (schema.minimum !== undefined) { + n = n.min(schema.minimum); + } + if (schema.maximum !== undefined) { + n = n.max(schema.maximum); + } + if (schema.exclusiveMinimum !== undefined) { + n = n.min(schema.exclusiveMinimum + (schema.type === "integer" ? 1 : Number.MIN_VALUE)); + } + if (schema.exclusiveMaximum !== undefined) { + n = n.max(schema.exclusiveMaximum - (schema.type === "integer" ? 1 : Number.MIN_VALUE)); + } + if (schema.multipleOf !== undefined) { + n = n.multipleOf(schema.multipleOf); + } + + if (schema.enum) { + const values = schema.enum as number[]; + if (values.length >= 2) { + return z.union( + values.map((v) => z.literal(v)) as unknown as [ZodType, ZodType, ...ZodType[]] + ); + } + if (values.length === 1) { + return z.literal(values[0]!); + } + } + + return applyNullable(n, schema); +} + +function parseArray(schema: JSONSchema): ZodType { + let itemSchema: ZodType = z.any(); + + if (schema.items) { + if (Array.isArray(schema.items)) { + // Tuple validation + const tupleSchemas = schema.items.map((item) => parseSchema(item)); + return z.tuple(tupleSchemas as [ZodType, ...ZodType[]]); + } else { + itemSchema = parseSchema(schema.items); + } + } + + let a = z.array(itemSchema); + + if (schema.minItems !== undefined) { + a = a.min(schema.minItems); + } + if (schema.maxItems !== undefined) { + a = a.max(schema.maxItems); + } + + return applyNullable(a, schema); +} + +function parseObject(schema: JSONSchema): ZodType { + const shape: Record = {}; + const required = new Set(schema.required || []); + + if (schema.properties) { + for (const [key, propSchema] of Object.entries(schema.properties)) { + let prop = parseSchema(propSchema); + if (!required.has(key)) { + prop = prop.optional(); + } + shape[key] = prop; + } + } + + let obj = z.object(shape); + + if (schema.additionalProperties === true) { + obj = obj.passthrough(); + } else if ( + schema.additionalProperties !== undefined && + schema.additionalProperties !== false && + typeof schema.additionalProperties === "object" + ) { + obj = obj.passthrough(); + } + + return applyNullable(obj, schema); +} + +function parseCombinator(schema: JSONSchema): ZodType { + if (schema.allOf && schema.allOf.length > 0) { + // allOf = intersection of all schemas + const schemas = schema.allOf.map((s) => parseSchema(s)); + return schemas.reduce((acc, s) => z.intersection(acc, s)); + } + + if (schema.oneOf && schema.oneOf.length > 0) { + const schemas = schema.oneOf.map((s) => parseSchema(s)); + if (schemas.length === 1) { + return schemas[0]!; + } + return z.union(schemas as [ZodType, ZodType, ...ZodType[]]); + } + + if (schema.anyOf && schema.anyOf.length > 0) { + const schemas = schema.anyOf.map((s) => parseSchema(s)); + if (schemas.length === 1) { + return schemas[0]!; + } + return z.union(schemas as [ZodType, ZodType, ...ZodType[]]); + } + + return z.any(); +} + +function parseEnum(schema: JSONSchema): ZodType { + if (!schema.enum || schema.enum.length === 0) { + return z.any(); + } + + // If all values are strings, use z.enum + if (schema.enum.every((v) => typeof v === "string")) { + return z.enum(schema.enum as [string, ...string[]]); + } + + // Otherwise, use union of literals + const literals = schema.enum.map((v) => z.literal(v as string | number | boolean)); + if (literals.length === 1) { + return literals[0]!; + } + return z.union(literals as unknown as [ZodType, ZodType, ...ZodType[]]); +} + +function applyNullable(schema: ZodType, jsonSchema: JSONSchema): ZodType { + if (jsonSchema.nullable) { + return schema.nullable(); + } + return schema; +}