From 62987f8f2997e7e81782ada512537bf12131ea5b Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 15 Dec 2025 13:58:07 -0500 Subject: [PATCH 01/10] fix: add additionalProperties: false to nested MCP tool schemas OpenAI's API requires additionalProperties: false on all object schemas, including nested ones. MCP tools like 'create_entities' have array items that are objects, which were missing this property. Created a recursive utility function that transforms JSON schemas to ensure all nested object schemas have additionalProperties: false. --- .../prompts/tools/native-tools/mcp_server.ts | 7 +- src/utils/__tests__/json-schema.spec.ts | 291 ++++++++++++++++++ src/utils/json-schema.ts | 72 +++++ 3 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 src/utils/__tests__/json-schema.spec.ts create mode 100644 src/utils/json-schema.ts diff --git a/src/core/prompts/tools/native-tools/mcp_server.ts b/src/core/prompts/tools/native-tools/mcp_server.ts index 3b47f84adf4..f46859fa07f 100644 --- a/src/core/prompts/tools/native-tools/mcp_server.ts +++ b/src/core/prompts/tools/native-tools/mcp_server.ts @@ -1,6 +1,7 @@ import type OpenAI from "openai" import { McpHub } from "../../../../services/mcp/McpHub" import { buildMcpToolName } from "../../../../utils/mcp-name" +import { addAdditionalPropertiesFalse } from "../../../../utils/json-schema" /** * Dynamically generates native tool definitions for all enabled tools across connected MCP servers. @@ -41,9 +42,13 @@ export function getMcpServerTools(mcpHub?: McpHub): OpenAI.Chat.ChatCompletionTo seenToolNames.add(toolName) const originalSchema = tool.inputSchema as Record | undefined - const toolInputProps = originalSchema?.properties ?? {} const toolInputRequired = (originalSchema?.required ?? []) as string[] + // Transform the schema to ensure all nested object schemas have additionalProperties: false + // This is required by some API providers (e.g., OpenAI) for strict function calling + const transformedSchema = originalSchema ? addAdditionalPropertiesFalse(originalSchema) : {} + const toolInputProps = (transformedSchema as Record)?.properties ?? {} + // Build parameters directly from the tool's input schema. // The server_name and tool_name are encoded in the function name itself // (e.g., mcp_serverName_toolName), so they don't need to be in the arguments. diff --git a/src/utils/__tests__/json-schema.spec.ts b/src/utils/__tests__/json-schema.spec.ts new file mode 100644 index 00000000000..abdd43c343a --- /dev/null +++ b/src/utils/__tests__/json-schema.spec.ts @@ -0,0 +1,291 @@ +import { addAdditionalPropertiesFalse } from "../json-schema" + +describe("addAdditionalPropertiesFalse", () => { + it("should add additionalProperties: false to a simple object schema", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + }, + } + + const result = addAdditionalPropertiesFalse(schema) + + expect(result).toEqual({ + type: "object", + properties: { + name: { type: "string" }, + }, + additionalProperties: false, + }) + }) + + it("should add additionalProperties: false to nested object schemas", () => { + const schema = { + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + address: { + type: "object", + properties: { + street: { type: "string" }, + }, + }, + }, + }, + }, + } + + const result = addAdditionalPropertiesFalse(schema) + + expect(result).toEqual({ + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + address: { + type: "object", + properties: { + street: { type: "string" }, + }, + additionalProperties: false, + }, + }, + additionalProperties: false, + }, + }, + additionalProperties: false, + }) + }) + + it("should add additionalProperties: false to array items that are objects", () => { + const schema = { + type: "object", + properties: { + entities: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + entityType: { type: "string" }, + }, + }, + }, + }, + } + + const result = addAdditionalPropertiesFalse(schema) + + expect(result).toEqual({ + type: "object", + properties: { + entities: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + entityType: { type: "string" }, + }, + additionalProperties: false, + }, + }, + }, + additionalProperties: false, + }) + }) + + it("should handle tuple-style array items", () => { + const schema = { + type: "object", + properties: { + tuple: { + type: "array", + items: [ + { type: "object", properties: { a: { type: "string" } } }, + { type: "object", properties: { b: { type: "number" } } }, + ], + }, + }, + } + + const result = addAdditionalPropertiesFalse(schema) + + expect(result).toEqual({ + type: "object", + properties: { + tuple: { + type: "array", + items: [ + { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, + { type: "object", properties: { b: { type: "number" } }, additionalProperties: false }, + ], + }, + }, + additionalProperties: false, + }) + }) + + it("should preserve existing additionalProperties: false", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + }, + additionalProperties: false, + } + + const result = addAdditionalPropertiesFalse(schema) + + expect(result).toEqual({ + type: "object", + properties: { + name: { type: "string" }, + }, + additionalProperties: false, + }) + }) + + it("should handle anyOf schemas", () => { + const schema = { + anyOf: [{ type: "object", properties: { a: { type: "string" } } }, { type: "string" }], + } + + const result = addAdditionalPropertiesFalse(schema) + + expect(result).toEqual({ + anyOf: [ + { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, + { type: "string" }, + ], + }) + }) + + it("should handle oneOf schemas", () => { + const schema = { + oneOf: [{ type: "object", properties: { a: { type: "string" } } }, { type: "number" }], + } + + const result = addAdditionalPropertiesFalse(schema) + + expect(result).toEqual({ + oneOf: [ + { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, + { type: "number" }, + ], + }) + }) + + it("should handle allOf schemas", () => { + const schema = { + allOf: [ + { type: "object", properties: { a: { type: "string" } } }, + { type: "object", properties: { b: { type: "number" } } }, + ], + } + + const result = addAdditionalPropertiesFalse(schema) + + expect(result).toEqual({ + allOf: [ + { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, + { type: "object", properties: { b: { type: "number" } }, additionalProperties: false }, + ], + }) + }) + + it("should not mutate the original schema", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + }, + } + + const original = JSON.parse(JSON.stringify(schema)) + addAdditionalPropertiesFalse(schema) + + expect(schema).toEqual(original) + }) + + it("should return non-object values as-is", () => { + expect(addAdditionalPropertiesFalse(null as any)).toBeNull() + expect(addAdditionalPropertiesFalse("string" as any)).toBe("string") + }) + + it("should handle deeply nested complex schemas", () => { + const schema = { + type: "object", + properties: { + level1: { + type: "object", + properties: { + level2: { + type: "array", + items: { + type: "object", + properties: { + level3: { + type: "object", + properties: { + value: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + const result = addAdditionalPropertiesFalse(schema) + + expect(result.additionalProperties).toBe(false) + expect((result.properties as any).level1.additionalProperties).toBe(false) + expect((result.properties as any).level1.properties.level2.items.additionalProperties).toBe(false) + expect((result.properties as any).level1.properties.level2.items.properties.level3.additionalProperties).toBe( + false, + ) + }) + + it("should handle the real-world MCP memory create_entities schema", () => { + // This is based on the actual schema that caused the error + const schema = { + type: "object", + properties: { + entities: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string", description: "The name of the entity" }, + entityType: { type: "string", description: "The type of the entity" }, + observations: { + type: "array", + items: { type: "string" }, + description: "An array of observation contents", + }, + }, + required: ["name", "entityType", "observations"], + }, + description: "An array of entities to create", + }, + }, + required: ["entities"], + } + + const result = addAdditionalPropertiesFalse(schema) + + // Top-level object should have additionalProperties: false + expect(result.additionalProperties).toBe(false) + // Items in the entities array should have additionalProperties: false + expect((result.properties as any).entities.items.additionalProperties).toBe(false) + }) +}) diff --git a/src/utils/json-schema.ts b/src/utils/json-schema.ts new file mode 100644 index 00000000000..2aba62c2581 --- /dev/null +++ b/src/utils/json-schema.ts @@ -0,0 +1,72 @@ +/** + * Recursively adds `additionalProperties: false` to all object schemas in a JSON Schema. + * This is required by some API providers (e.g., OpenAI) for strict function calling. + * + * @param schema - The JSON Schema object to transform + * @returns A new schema object with `additionalProperties: false` added to all object schemas + */ +export function addAdditionalPropertiesFalse(schema: Record): Record { + if (typeof schema !== "object" || schema === null) { + return schema + } + + // Create a shallow copy to avoid mutating the original + const result: Record = { ...schema } + + // If this is an object schema, add additionalProperties: false + if (result.type === "object") { + result.additionalProperties = false + } + + // Recursively process properties + if (result.properties && typeof result.properties === "object") { + const properties = result.properties as Record + const newProperties: Record = {} + for (const key of Object.keys(properties)) { + const value = properties[key] + if (typeof value === "object" && value !== null) { + newProperties[key] = addAdditionalPropertiesFalse(value as Record) + } else { + newProperties[key] = value + } + } + result.properties = newProperties + } + + // Recursively process items (for arrays) + if (result.items && typeof result.items === "object") { + if (Array.isArray(result.items)) { + result.items = result.items.map((item) => + typeof item === "object" && item !== null + ? addAdditionalPropertiesFalse(item as Record) + : item, + ) + } else { + result.items = addAdditionalPropertiesFalse(result.items as Record) + } + } + + // Recursively process anyOf, oneOf, allOf + for (const keyword of ["anyOf", "oneOf", "allOf"]) { + if (Array.isArray(result[keyword])) { + result[keyword] = (result[keyword] as unknown[]).map((subSchema) => + typeof subSchema === "object" && subSchema !== null + ? addAdditionalPropertiesFalse(subSchema as Record) + : subSchema, + ) + } + } + + // Recursively process additionalProperties if it's a schema (not just true/false) + if ( + result.additionalProperties && + typeof result.additionalProperties === "object" && + result.additionalProperties !== null + ) { + result.additionalProperties = addAdditionalPropertiesFalse( + result.additionalProperties as Record, + ) + } + + return result +} From a5d93807a88a056c9b0c4bf467129b22ca0a8619 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 15 Dec 2025 17:07:36 -0500 Subject: [PATCH 02/10] refactor: add Zod-based JSON Schema validation to utility - Add JsonSchema TypeScript interface for type safety - Add JsonSchemaSchema Zod validator for validating JSON Schema structures - Add JsonSchemaUtils class with validation methods: - validate(): safe validation with result object - validateOrThrow(): throws on invalid schema - isValid(): type guard for checking validity - stripUnknownFields(): sanitize schemas - validateAndAddAdditionalPropertiesFalse(): combined validation + transform - Keep standalone addAdditionalPropertiesFalse() for backwards compatibility - Expand test coverage from 12 to 29 tests --- src/utils/__tests__/json-schema.spec.ts | 619 ++++++++++++++++-------- src/utils/json-schema.ts | 284 +++++++++-- 2 files changed, 638 insertions(+), 265 deletions(-) diff --git a/src/utils/__tests__/json-schema.spec.ts b/src/utils/__tests__/json-schema.spec.ts index abdd43c343a..839e96ff31d 100644 --- a/src/utils/__tests__/json-schema.spec.ts +++ b/src/utils/__tests__/json-schema.spec.ts @@ -1,240 +1,354 @@ -import { addAdditionalPropertiesFalse } from "../json-schema" - -describe("addAdditionalPropertiesFalse", () => { - it("should add additionalProperties: false to a simple object schema", () => { - const schema = { - type: "object", - properties: { - name: { type: "string" }, - }, - } +import { addAdditionalPropertiesFalse, JsonSchemaUtils, JsonSchemaSchema, JsonSchema } from "../json-schema" + +describe("JsonSchemaUtils", () => { + describe("validate", () => { + it("should validate a simple object schema", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + }, + } - const result = addAdditionalPropertiesFalse(schema) + const result = JsonSchemaUtils.validate(schema) - expect(result).toEqual({ - type: "object", - properties: { - name: { type: "string" }, - }, - additionalProperties: false, + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.type).toBe("object") + } }) - }) - it("should add additionalProperties: false to nested object schemas", () => { - const schema = { - type: "object", - properties: { - user: { - type: "object", - properties: { - name: { type: "string" }, - address: { - type: "object", - properties: { - street: { type: "string" }, - }, + it("should validate a nested schema", () => { + const schema = { + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "integer" }, }, }, }, - }, - } + } - const result = addAdditionalPropertiesFalse(schema) + const result = JsonSchemaUtils.validate(schema) - expect(result).toEqual({ - type: "object", - properties: { - user: { + expect(result.success).toBe(true) + }) + + it("should validate array schemas", () => { + const schema = { + type: "array", + items: { type: "object", properties: { - name: { type: "string" }, - address: { - type: "object", - properties: { - street: { type: "string" }, - }, - additionalProperties: false, - }, + id: { type: "number" }, }, - additionalProperties: false, }, - }, - additionalProperties: false, + } + + const result = JsonSchemaUtils.validate(schema) + + expect(result.success).toBe(true) + }) + + it("should validate schemas with anyOf/oneOf/allOf", () => { + const schema = { + anyOf: [{ type: "string" }, { type: "number" }], + } + + const result = JsonSchemaUtils.validate(schema) + + expect(result.success).toBe(true) + }) + + it("should pass through unknown properties", () => { + const schema = { + type: "object", + customProperty: "custom value", + properties: { + name: { type: "string" }, + }, + } + + const result = JsonSchemaUtils.validate(schema) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.customProperty).toBe("custom value") + } }) }) - it("should add additionalProperties: false to array items that are objects", () => { - const schema = { - type: "object", - properties: { - entities: { - type: "array", - items: { + describe("validateOrThrow", () => { + it("should return validated schema for valid input", () => { + const schema = { type: "object" } + + const result = JsonSchemaUtils.validateOrThrow(schema) + + expect(result.type).toBe("object") + }) + + it("should throw for invalid input", () => { + const invalidSchema = { type: "invalid-type" } + + expect(() => JsonSchemaUtils.validateOrThrow(invalidSchema)).toThrow() + }) + }) + + describe("isValid", () => { + it("should return true for valid schemas", () => { + expect(JsonSchemaUtils.isValid({ type: "string" })).toBe(true) + expect(JsonSchemaUtils.isValid({ type: "object", properties: {} })).toBe(true) + expect(JsonSchemaUtils.isValid({ anyOf: [{ type: "string" }] })).toBe(true) + }) + + it("should return false for invalid type values", () => { + expect(JsonSchemaUtils.isValid({ type: "invalid" })).toBe(false) + }) + + it("should return true for empty object (valid JSON Schema)", () => { + expect(JsonSchemaUtils.isValid({})).toBe(true) + }) + }) + + describe("addAdditionalPropertiesFalse", () => { + it("should add additionalProperties: false to a simple object schema", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + }, + } + + const result = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) + + expect(result).toEqual({ + type: "object", + properties: { + name: { type: "string" }, + }, + additionalProperties: false, + }) + }) + + it("should add additionalProperties: false to nested object schemas", () => { + const schema = { + type: "object", + properties: { + user: { type: "object", properties: { name: { type: "string" }, - entityType: { type: "string" }, + address: { + type: "object", + properties: { + street: { type: "string" }, + }, + }, }, }, }, - }, - } + } - const result = addAdditionalPropertiesFalse(schema) + const result = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) - expect(result).toEqual({ - type: "object", - properties: { - entities: { - type: "array", - items: { + expect(result).toEqual({ + type: "object", + properties: { + user: { type: "object", properties: { name: { type: "string" }, - entityType: { type: "string" }, + address: { + type: "object", + properties: { + street: { type: "string" }, + }, + additionalProperties: false, + }, }, additionalProperties: false, }, }, - }, - additionalProperties: false, + additionalProperties: false, + }) }) - }) - it("should handle tuple-style array items", () => { - const schema = { - type: "object", - properties: { - tuple: { - type: "array", - items: [ - { type: "object", properties: { a: { type: "string" } } }, - { type: "object", properties: { b: { type: "number" } } }, - ], + it("should add additionalProperties: false to array items that are objects", () => { + const schema = { + type: "object", + properties: { + entities: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + entityType: { type: "string" }, + }, + }, + }, }, - }, - } + } - const result = addAdditionalPropertiesFalse(schema) + const result = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) - expect(result).toEqual({ - type: "object", - properties: { - tuple: { - type: "array", - items: [ - { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, - { type: "object", properties: { b: { type: "number" } }, additionalProperties: false }, - ], + expect(result).toEqual({ + type: "object", + properties: { + entities: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + entityType: { type: "string" }, + }, + additionalProperties: false, + }, + }, }, - }, - additionalProperties: false, + additionalProperties: false, + }) }) - }) - it("should preserve existing additionalProperties: false", () => { - const schema = { - type: "object", - properties: { - name: { type: "string" }, - }, - additionalProperties: false, - } - - const result = addAdditionalPropertiesFalse(schema) - - expect(result).toEqual({ - type: "object", - properties: { - name: { type: "string" }, - }, - additionalProperties: false, + it("should handle tuple-style array items", () => { + const schema = { + type: "object", + properties: { + tuple: { + type: "array", + items: [ + { type: "object", properties: { a: { type: "string" } } }, + { type: "object", properties: { b: { type: "number" } } }, + ], + }, + }, + } + + const result = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) + + expect(result).toEqual({ + type: "object", + properties: { + tuple: { + type: "array", + items: [ + { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, + { type: "object", properties: { b: { type: "number" } }, additionalProperties: false }, + ], + }, + }, + additionalProperties: false, + }) }) - }) - it("should handle anyOf schemas", () => { - const schema = { - anyOf: [{ type: "object", properties: { a: { type: "string" } } }, { type: "string" }], - } + it("should preserve existing additionalProperties: false", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + }, + additionalProperties: false, + } - const result = addAdditionalPropertiesFalse(schema) + const result = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) - expect(result).toEqual({ - anyOf: [ - { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, - { type: "string" }, - ], + expect(result).toEqual({ + type: "object", + properties: { + name: { type: "string" }, + }, + additionalProperties: false, + }) }) - }) - it("should handle oneOf schemas", () => { - const schema = { - oneOf: [{ type: "object", properties: { a: { type: "string" } } }, { type: "number" }], - } + it("should handle anyOf schemas", () => { + const schema = { + anyOf: [{ type: "object", properties: { a: { type: "string" } } }, { type: "string" }], + } - const result = addAdditionalPropertiesFalse(schema) + const result = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) - expect(result).toEqual({ - oneOf: [ - { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, - { type: "number" }, - ], + expect(result).toEqual({ + anyOf: [ + { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, + { type: "string" }, + ], + }) }) - }) - it("should handle allOf schemas", () => { - const schema = { - allOf: [ - { type: "object", properties: { a: { type: "string" } } }, - { type: "object", properties: { b: { type: "number" } } }, - ], - } + it("should handle oneOf schemas", () => { + const schema = { + oneOf: [{ type: "object", properties: { a: { type: "string" } } }, { type: "number" }], + } - const result = addAdditionalPropertiesFalse(schema) + const result = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) - expect(result).toEqual({ - allOf: [ - { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, - { type: "object", properties: { b: { type: "number" } }, additionalProperties: false }, - ], + expect(result).toEqual({ + oneOf: [ + { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, + { type: "number" }, + ], + }) }) - }) - it("should not mutate the original schema", () => { - const schema = { - type: "object", - properties: { - name: { type: "string" }, - }, - } + it("should handle allOf schemas", () => { + const schema = { + allOf: [ + { type: "object", properties: { a: { type: "string" } } }, + { type: "object", properties: { b: { type: "number" } } }, + ], + } + + const result = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) + + expect(result).toEqual({ + allOf: [ + { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, + { type: "object", properties: { b: { type: "number" } }, additionalProperties: false }, + ], + }) + }) - const original = JSON.parse(JSON.stringify(schema)) - addAdditionalPropertiesFalse(schema) + it("should not mutate the original schema", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + }, + } - expect(schema).toEqual(original) - }) + const original = JSON.parse(JSON.stringify(schema)) + JsonSchemaUtils.addAdditionalPropertiesFalse(schema) - it("should return non-object values as-is", () => { - expect(addAdditionalPropertiesFalse(null as any)).toBeNull() - expect(addAdditionalPropertiesFalse("string" as any)).toBe("string") - }) + expect(schema).toEqual(original) + }) - it("should handle deeply nested complex schemas", () => { - const schema = { - type: "object", - properties: { - level1: { - type: "object", - properties: { - level2: { - type: "array", - items: { - type: "object", - properties: { - level3: { - type: "object", - properties: { - value: { type: "string" }, + it("should return non-object values as-is", () => { + expect(JsonSchemaUtils.addAdditionalPropertiesFalse(null as any)).toBeNull() + expect(JsonSchemaUtils.addAdditionalPropertiesFalse("string" as any)).toBe("string") + }) + + it("should handle deeply nested complex schemas", () => { + const schema = { + type: "object", + properties: { + level1: { + type: "object", + properties: { + level2: { + type: "array", + items: { + type: "object", + properties: { + level3: { + type: "object", + properties: { + value: { type: "string" }, + }, }, }, }, @@ -242,50 +356,127 @@ describe("addAdditionalPropertiesFalse", () => { }, }, }, - }, - } + } - const result = addAdditionalPropertiesFalse(schema) + const result = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) - expect(result.additionalProperties).toBe(false) - expect((result.properties as any).level1.additionalProperties).toBe(false) - expect((result.properties as any).level1.properties.level2.items.additionalProperties).toBe(false) - expect((result.properties as any).level1.properties.level2.items.properties.level3.additionalProperties).toBe( - false, - ) - }) + expect(result.additionalProperties).toBe(false) + expect((result.properties as any).level1.additionalProperties).toBe(false) + expect((result.properties as any).level1.properties.level2.items.additionalProperties).toBe(false) + expect( + (result.properties as any).level1.properties.level2.items.properties.level3.additionalProperties, + ).toBe(false) + }) - it("should handle the real-world MCP memory create_entities schema", () => { - // This is based on the actual schema that caused the error - const schema = { - type: "object", - properties: { - entities: { - type: "array", - items: { - type: "object", - properties: { - name: { type: "string", description: "The name of the entity" }, - entityType: { type: "string", description: "The type of the entity" }, - observations: { - type: "array", - items: { type: "string" }, - description: "An array of observation contents", + it("should handle the real-world MCP memory create_entities schema", () => { + // This is based on the actual schema that caused the error + const schema = { + type: "object", + properties: { + entities: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string", description: "The name of the entity" }, + entityType: { type: "string", description: "The type of the entity" }, + observations: { + type: "array", + items: { type: "string" }, + description: "An array of observation contents", + }, }, + required: ["name", "entityType", "observations"], }, - required: ["name", "entityType", "observations"], + description: "An array of entities to create", }, - description: "An array of entities to create", }, + required: ["entities"], + } + + const result = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) + + // Top-level object should have additionalProperties: false + expect(result.additionalProperties).toBe(false) + // Items in the entities array should have additionalProperties: false + expect((result.properties as any).entities.items.additionalProperties).toBe(false) + }) + }) + + describe("validateAndAddAdditionalPropertiesFalse", () => { + it("should validate and transform valid schema", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + }, + } + + const result = JsonSchemaUtils.validateAndAddAdditionalPropertiesFalse(schema) + + expect(result.additionalProperties).toBe(false) + }) + + it("should throw for invalid schema", () => { + const invalidSchema = { type: "invalid-type" } + + expect(() => JsonSchemaUtils.validateAndAddAdditionalPropertiesFalse(invalidSchema)).toThrow() + }) + }) + + describe("stripUnknownFields", () => { + it("should return schema for valid input", () => { + const schema = { + type: "object", + properties: { name: { type: "string" } }, + } + + const result = JsonSchemaUtils.stripUnknownFields(schema) + + expect(result).not.toBeNull() + expect(result?.type).toBe("object") + }) + + it("should return null for invalid input", () => { + const invalidSchema = { type: "invalid" } + + const result = JsonSchemaUtils.stripUnknownFields(invalidSchema) + + expect(result).toBeNull() + }) + }) +}) + +describe("addAdditionalPropertiesFalse (standalone function)", () => { + it("should work the same as the class method", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, }, - required: ["entities"], } - const result = addAdditionalPropertiesFalse(schema) + const standaloneResult = addAdditionalPropertiesFalse(schema) + const classResult = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) + + expect(standaloneResult).toEqual(classResult) + }) +}) + +describe("JsonSchemaSchema (Zod schema)", () => { + it("should be exported and usable directly", () => { + const schema = { type: "object" } + + const result = JsonSchemaSchema.safeParse(schema) + + expect(result.success).toBe(true) + }) + + it("should reject invalid types", () => { + const schema = { type: "invalid-type" } + + const result = JsonSchemaSchema.safeParse(schema) - // Top-level object should have additionalProperties: false - expect(result.additionalProperties).toBe(false) - // Items in the entities array should have additionalProperties: false - expect((result.properties as any).entities.items.additionalProperties).toBe(false) + expect(result.success).toBe(false) }) }) diff --git a/src/utils/json-schema.ts b/src/utils/json-schema.ts index 2aba62c2581..65930832098 100644 --- a/src/utils/json-schema.ts +++ b/src/utils/json-schema.ts @@ -1,72 +1,254 @@ +import { z } from "zod" + +/** + * Type representing a JSON Schema structure + * Defined first so we can reference it in the Zod schema + */ +export interface JsonSchema { + type?: "string" | "number" | "integer" | "boolean" | "null" | "object" | "array" + properties?: Record + items?: JsonSchema | JsonSchema[] + required?: string[] + additionalProperties?: boolean | JsonSchema + description?: string + default?: unknown + enum?: unknown[] + const?: unknown + anyOf?: JsonSchema[] + oneOf?: JsonSchema[] + allOf?: JsonSchema[] + $ref?: string + minimum?: number + maximum?: number + minLength?: number + maxLength?: number + pattern?: string + minItems?: number + maxItems?: number + uniqueItems?: boolean + [key: string]: unknown // Allow additional properties +} + +/** + * Zod schema for JSON Schema primitive types + */ +const JsonSchemaPrimitiveTypeSchema = z.enum(["string", "number", "integer", "boolean", "null"]) + +/** + * Zod schema for validating JSON Schema structures + * Uses z.lazy for recursive definition with explicit type casting + */ +export const JsonSchemaSchema: z.ZodType = z.lazy(() => + z + .object({ + type: z.union([JsonSchemaPrimitiveTypeSchema, z.literal("object"), z.literal("array")]).optional(), + properties: z.record(z.string(), JsonSchemaSchema).optional(), + items: z.union([JsonSchemaSchema, z.array(JsonSchemaSchema)]).optional(), + required: z.array(z.string()).optional(), + additionalProperties: z.union([z.boolean(), JsonSchemaSchema]).optional(), + description: z.string().optional(), + default: z.unknown().optional(), + enum: z.array(z.unknown()).optional(), + const: z.unknown().optional(), + anyOf: z.array(JsonSchemaSchema).optional(), + oneOf: z.array(JsonSchemaSchema).optional(), + allOf: z.array(JsonSchemaSchema).optional(), + $ref: z.string().optional(), + minimum: z.number().optional(), + maximum: z.number().optional(), + minLength: z.number().optional(), + maxLength: z.number().optional(), + pattern: z.string().optional(), + minItems: z.number().optional(), + maxItems: z.number().optional(), + uniqueItems: z.boolean().optional(), + }) + .passthrough(), +) + /** - * Recursively adds `additionalProperties: false` to all object schemas in a JSON Schema. - * This is required by some API providers (e.g., OpenAI) for strict function calling. + * Result of schema validation + */ +export type ValidationResult = { success: true; data: T } | { success: false; error: z.ZodError } + +/** + * JSON Schema utility class for validation and transformation * - * @param schema - The JSON Schema object to transform - * @returns A new schema object with `additionalProperties: false` added to all object schemas + * @example + * ```typescript + * const schema = { type: "object", properties: { name: { type: "string" } } } + * + * // Validate schema + * const result = JsonSchemaUtils.validate(schema) + * if (result.success) { + * // Use validated schema + * const transformed = JsonSchemaUtils.addAdditionalPropertiesFalse(result.data) + * } + * + * // Or transform directly (throws on invalid schema) + * const transformed = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) + * ``` */ -export function addAdditionalPropertiesFalse(schema: Record): Record { - if (typeof schema !== "object" || schema === null) { - return schema +export class JsonSchemaUtils { + /** + * Validates that an object conforms to JSON Schema structure + * + * @param schema - The object to validate + * @returns Validation result with typed data or error + */ + static validate(schema: unknown): ValidationResult { + const result = JsonSchemaSchema.safeParse(schema) + if (result.success) { + return { success: true, data: result.data } + } + return { success: false, error: result.error } } - // Create a shallow copy to avoid mutating the original - const result: Record = { ...schema } - - // If this is an object schema, add additionalProperties: false - if (result.type === "object") { - result.additionalProperties = false + /** + * Validates and throws if invalid + * + * @param schema - The object to validate + * @returns Validated JSON Schema + * @throws ZodError if validation fails + */ + static validateOrThrow(schema: unknown): JsonSchema { + return JsonSchemaSchema.parse(schema) } - // Recursively process properties - if (result.properties && typeof result.properties === "object") { - const properties = result.properties as Record - const newProperties: Record = {} - for (const key of Object.keys(properties)) { - const value = properties[key] - if (typeof value === "object" && value !== null) { - newProperties[key] = addAdditionalPropertiesFalse(value as Record) + /** + * Recursively adds `additionalProperties: false` to all object schemas. + * This is required by some API providers (e.g., OpenAI) for strict function calling. + * + * @param schema - The JSON Schema to transform (can be unvalidated, will be coerced) + * @returns A new schema with `additionalProperties: false` on all object schemas + * + * @example + * ```typescript + * const schema = { + * type: "object", + * properties: { + * users: { + * type: "array", + * items: { type: "object", properties: { name: { type: "string" } } } + * } + * } + * } + * + * const result = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) + * // Result: all object schemas now have additionalProperties: false + * ``` + */ + static addAdditionalPropertiesFalse(schema: Record): Record { + if (typeof schema !== "object" || schema === null) { + return schema + } + + // Create a shallow copy to avoid mutating the original + const result: Record = { ...schema } + + // If this is an object schema, add additionalProperties: false + if (result.type === "object") { + result.additionalProperties = false + } + + // Recursively process properties + if (result.properties && typeof result.properties === "object") { + const properties = result.properties as Record + const newProperties: Record = {} + for (const key of Object.keys(properties)) { + const value = properties[key] + if (typeof value === "object" && value !== null) { + newProperties[key] = this.addAdditionalPropertiesFalse(value as Record) + } else { + newProperties[key] = value + } + } + result.properties = newProperties + } + + // Recursively process items (for arrays) + if (result.items && typeof result.items === "object") { + if (Array.isArray(result.items)) { + result.items = result.items.map((item) => + typeof item === "object" && item !== null + ? this.addAdditionalPropertiesFalse(item as Record) + : item, + ) } else { - newProperties[key] = value + result.items = this.addAdditionalPropertiesFalse(result.items as Record) + } + } + + // Recursively process anyOf, oneOf, allOf + for (const keyword of ["anyOf", "oneOf", "allOf"] as const) { + if (Array.isArray(result[keyword])) { + result[keyword] = (result[keyword] as unknown[]).map((subSchema) => + typeof subSchema === "object" && subSchema !== null + ? this.addAdditionalPropertiesFalse(subSchema as Record) + : subSchema, + ) } } - result.properties = newProperties - } - // Recursively process items (for arrays) - if (result.items && typeof result.items === "object") { - if (Array.isArray(result.items)) { - result.items = result.items.map((item) => - typeof item === "object" && item !== null - ? addAdditionalPropertiesFalse(item as Record) - : item, + // Recursively process additionalProperties if it's a schema (not just true/false) + if ( + result.additionalProperties && + typeof result.additionalProperties === "object" && + result.additionalProperties !== null + ) { + result.additionalProperties = this.addAdditionalPropertiesFalse( + result.additionalProperties as Record, ) - } else { - result.items = addAdditionalPropertiesFalse(result.items as Record) } + + return result } - // Recursively process anyOf, oneOf, allOf - for (const keyword of ["anyOf", "oneOf", "allOf"]) { - if (Array.isArray(result[keyword])) { - result[keyword] = (result[keyword] as unknown[]).map((subSchema) => - typeof subSchema === "object" && subSchema !== null - ? addAdditionalPropertiesFalse(subSchema as Record) - : subSchema, - ) + /** + * Validates schema and then adds `additionalProperties: false` to all object schemas. + * Throws if schema is invalid. + * + * @param schema - The JSON Schema to validate and transform + * @returns A validated and transformed schema + * @throws ZodError if validation fails + */ + static validateAndAddAdditionalPropertiesFalse(schema: unknown): Record { + this.validateOrThrow(schema) + return this.addAdditionalPropertiesFalse(schema as Record) + } + + /** + * Strips unknown/unsupported properties from a schema, keeping only known JSON Schema fields + * + * @param schema - The JSON Schema to clean + * @returns A new schema with only known JSON Schema fields + */ + static stripUnknownFields(schema: unknown): JsonSchema | null { + const result = JsonSchemaSchema.safeParse(schema) + if (!result.success) { + return null } + return result.data } - // Recursively process additionalProperties if it's a schema (not just true/false) - if ( - result.additionalProperties && - typeof result.additionalProperties === "object" && - result.additionalProperties !== null - ) { - result.additionalProperties = addAdditionalPropertiesFalse( - result.additionalProperties as Record, - ) + /** + * Checks if a schema is valid JSON Schema + * + * @param schema - The object to check + * @returns true if valid JSON Schema, false otherwise + */ + static isValid(schema: unknown): schema is JsonSchema { + return JsonSchemaSchema.safeParse(schema).success } +} - return result +/** + * Standalone function for adding additionalProperties: false + * (for backwards compatibility and simpler imports) + * + * @param schema - The JSON Schema object to transform + * @returns A new schema object with `additionalProperties: false` added to all object schemas + */ +export function addAdditionalPropertiesFalse(schema: Record): Record { + return JsonSchemaUtils.addAdditionalPropertiesFalse(schema) } From f6fb950d7dc1a6dd5c0ed60055314a4ace3541ec Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 15 Dec 2025 17:22:54 -0500 Subject: [PATCH 03/10] refactor: use json-schema-traverse library for schema transformation - Replace custom recursive traversal with json-schema-traverse library - Library is small (~22KB), has TypeScript types, and is well-tested - Same author as ajv (most popular JSON Schema validator) - Simplifies the addAdditionalPropertiesFalse implementation - All 29 tests still pass --- pnpm-lock.yaml | 9 +++++ src/package.json | 1 + src/utils/json-schema.ts | 75 +++++++++------------------------------- 3 files changed, 26 insertions(+), 59 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d94adb27944..522a890f8c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -726,6 +726,9 @@ importers: isbinaryfile: specifier: ^5.0.2 version: 5.0.4 + json-schema-traverse: + specifier: ^1.0.0 + version: 1.0.0 jwt-decode: specifier: ^4.0.0 version: 4.0.0 @@ -7031,6 +7034,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -7842,6 +7848,7 @@ packages: next@15.2.6: resolution: {integrity: sha512-DIKFctUpZoCq5ok2ztVU+PqhWsbiqM9xNP7rHL2cAp29NQcmDp7Y6JnBBhHRbFt4bCsCZigj6uh+/Gwh2158Wg==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details. hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -17163,6 +17170,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-safe@5.0.1: {} diff --git a/src/package.json b/src/package.json index f9173fb1d55..034430d4b02 100644 --- a/src/package.json +++ b/src/package.json @@ -465,6 +465,7 @@ "i18next": "^25.0.0", "ignore": "^7.0.3", "isbinaryfile": "^5.0.2", + "json-schema-traverse": "^1.0.0", "jwt-decode": "^4.0.0", "lodash.debounce": "^4.0.8", "mammoth": "^1.9.1", diff --git a/src/utils/json-schema.ts b/src/utils/json-schema.ts index 65930832098..9a44c19c253 100644 --- a/src/utils/json-schema.ts +++ b/src/utils/json-schema.ts @@ -1,8 +1,8 @@ import { z } from "zod" +import traverse from "json-schema-traverse" /** * Type representing a JSON Schema structure - * Defined first so we can reference it in the Zod schema */ export interface JsonSchema { type?: "string" | "number" | "integer" | "boolean" | "null" | "object" | "array" @@ -85,7 +85,7 @@ export type ValidationResult = { success: true; data: T } | { success: false; * const transformed = JsonSchemaUtils.addAdditionalPropertiesFalse(result.data) * } * - * // Or transform directly (throws on invalid schema) + * // Or transform directly * const transformed = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) * ``` */ @@ -119,7 +119,9 @@ export class JsonSchemaUtils { * Recursively adds `additionalProperties: false` to all object schemas. * This is required by some API providers (e.g., OpenAI) for strict function calling. * - * @param schema - The JSON Schema to transform (can be unvalidated, will be coerced) + * Uses `json-schema-traverse` library for robust schema traversal. + * + * @param schema - The JSON Schema to transform * @returns A new schema with `additionalProperties: false` on all object schemas * * @example @@ -143,65 +145,20 @@ export class JsonSchemaUtils { return schema } - // Create a shallow copy to avoid mutating the original - const result: Record = { ...schema } - - // If this is an object schema, add additionalProperties: false - if (result.type === "object") { - result.additionalProperties = false - } + // Deep clone to avoid mutating the original + const cloned = JSON.parse(JSON.stringify(schema)) as Record - // Recursively process properties - if (result.properties && typeof result.properties === "object") { - const properties = result.properties as Record - const newProperties: Record = {} - for (const key of Object.keys(properties)) { - const value = properties[key] - if (typeof value === "object" && value !== null) { - newProperties[key] = this.addAdditionalPropertiesFalse(value as Record) - } else { - newProperties[key] = value + // Use json-schema-traverse to visit all schemas and add additionalProperties: false to objects + traverse(cloned, { + allKeys: true, + cb: (subSchema: Record) => { + if (subSchema.type === "object") { + subSchema.additionalProperties = false } - } - result.properties = newProperties - } - - // Recursively process items (for arrays) - if (result.items && typeof result.items === "object") { - if (Array.isArray(result.items)) { - result.items = result.items.map((item) => - typeof item === "object" && item !== null - ? this.addAdditionalPropertiesFalse(item as Record) - : item, - ) - } else { - result.items = this.addAdditionalPropertiesFalse(result.items as Record) - } - } - - // Recursively process anyOf, oneOf, allOf - for (const keyword of ["anyOf", "oneOf", "allOf"] as const) { - if (Array.isArray(result[keyword])) { - result[keyword] = (result[keyword] as unknown[]).map((subSchema) => - typeof subSchema === "object" && subSchema !== null - ? this.addAdditionalPropertiesFalse(subSchema as Record) - : subSchema, - ) - } - } - - // Recursively process additionalProperties if it's a schema (not just true/false) - if ( - result.additionalProperties && - typeof result.additionalProperties === "object" && - result.additionalProperties !== null - ) { - result.additionalProperties = this.addAdditionalPropertiesFalse( - result.additionalProperties as Record, - ) - } + }, + }) - return result + return cloned } /** From e902e1c78ba82743a984b821fba6f77b66e6dfb4 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 15 Dec 2025 17:57:50 -0500 Subject: [PATCH 04/10] refactor: simplify json-schema utils with generic transform API - Keep Zod validation for JSON Schema structure - Add generic transformJsonSchema() function for extensibility - Remove unused JsonSchemaUtils class - Reduce code while maintaining all functionality --- src/utils/__tests__/json-schema.spec.ts | 713 ++++++++++++------------ src/utils/json-schema.ts | 225 ++++---- 2 files changed, 474 insertions(+), 464 deletions(-) diff --git a/src/utils/__tests__/json-schema.spec.ts b/src/utils/__tests__/json-schema.spec.ts index 839e96ff31d..fa4b98abecf 100644 --- a/src/utils/__tests__/json-schema.spec.ts +++ b/src/utils/__tests__/json-schema.spec.ts @@ -1,354 +1,397 @@ -import { addAdditionalPropertiesFalse, JsonSchemaUtils, JsonSchemaSchema, JsonSchema } from "../json-schema" +import { + addAdditionalPropertiesFalse, + validateJsonSchema, + isJsonSchema, + transformJsonSchema, + validateAndAddAdditionalPropertiesFalse, + JsonSchemaSchema, +} from "../json-schema" + +describe("validateJsonSchema", () => { + it("should return validated schema for valid input", () => { + const schema = { type: "object", properties: { name: { type: "string" } } } + + const result = validateJsonSchema(schema) + + expect(result).not.toBeNull() + expect(result?.type).toBe("object") + }) -describe("JsonSchemaUtils", () => { - describe("validate", () => { - it("should validate a simple object schema", () => { - const schema = { - type: "object", - properties: { - name: { type: "string" }, - }, - } + it("should return null for invalid type values", () => { + const schema = { type: "invalid-type" } - const result = JsonSchemaUtils.validate(schema) + const result = validateJsonSchema(schema) - expect(result.success).toBe(true) - if (result.success) { - expect(result.data.type).toBe("object") - } - }) + expect(result).toBeNull() + }) - it("should validate a nested schema", () => { - const schema = { - type: "object", - properties: { - user: { - type: "object", - properties: { - name: { type: "string" }, - age: { type: "integer" }, - }, + it("should validate nested schemas", () => { + const schema = { + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "integer" }, }, }, - } + }, + } - const result = JsonSchemaUtils.validate(schema) + const result = validateJsonSchema(schema) - expect(result.success).toBe(true) - }) + expect(result).not.toBeNull() + }) - it("should validate array schemas", () => { - const schema = { - type: "array", - items: { - type: "object", - properties: { - id: { type: "number" }, - }, + it("should validate array schemas", () => { + const schema = { + type: "array", + items: { + type: "object", + properties: { + id: { type: "number" }, }, - } + }, + } - const result = JsonSchemaUtils.validate(schema) + const result = validateJsonSchema(schema) - expect(result.success).toBe(true) - }) + expect(result).not.toBeNull() + }) - it("should validate schemas with anyOf/oneOf/allOf", () => { - const schema = { - anyOf: [{ type: "string" }, { type: "number" }], - } + it("should validate schemas with anyOf/oneOf/allOf", () => { + const schema = { + anyOf: [{ type: "string" }, { type: "number" }], + } - const result = JsonSchemaUtils.validate(schema) + const result = validateJsonSchema(schema) - expect(result.success).toBe(true) - }) + expect(result).not.toBeNull() + }) - it("should pass through unknown properties", () => { - const schema = { - type: "object", - customProperty: "custom value", - properties: { - name: { type: "string" }, - }, - } + it("should pass through unknown properties", () => { + const schema = { + type: "object", + customProperty: "custom value", + properties: { + name: { type: "string" }, + }, + } - const result = JsonSchemaUtils.validate(schema) + const result = validateJsonSchema(schema) - expect(result.success).toBe(true) - if (result.success) { - expect(result.data.customProperty).toBe("custom value") - } - }) + expect(result).not.toBeNull() + expect(result?.customProperty).toBe("custom value") }) +}) - describe("validateOrThrow", () => { - it("should return validated schema for valid input", () => { - const schema = { type: "object" } +describe("isJsonSchema", () => { + it("should return true for valid schemas", () => { + expect(isJsonSchema({ type: "string" })).toBe(true) + expect(isJsonSchema({ type: "object", properties: {} })).toBe(true) + expect(isJsonSchema({ anyOf: [{ type: "string" }] })).toBe(true) + }) - const result = JsonSchemaUtils.validateOrThrow(schema) + it("should return false for invalid type values", () => { + expect(isJsonSchema({ type: "invalid" })).toBe(false) + }) - expect(result.type).toBe("object") - }) + it("should return true for empty object (valid JSON Schema)", () => { + expect(isJsonSchema({})).toBe(true) + }) +}) - it("should throw for invalid input", () => { - const invalidSchema = { type: "invalid-type" } +describe("transformJsonSchema", () => { + it("should apply callback to all sub-schemas", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + }, + } - expect(() => JsonSchemaUtils.validateOrThrow(invalidSchema)).toThrow() + const visited: string[] = [] + transformJsonSchema(schema, (subSchema) => { + if (subSchema.type) { + visited.push(subSchema.type as string) + } }) + + expect(visited).toContain("object") + expect(visited).toContain("string") }) - describe("isValid", () => { - it("should return true for valid schemas", () => { - expect(JsonSchemaUtils.isValid({ type: "string" })).toBe(true) - expect(JsonSchemaUtils.isValid({ type: "object", properties: {} })).toBe(true) - expect(JsonSchemaUtils.isValid({ anyOf: [{ type: "string" }] })).toBe(true) - }) + it("should not mutate the original schema", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + }, + } - it("should return false for invalid type values", () => { - expect(JsonSchemaUtils.isValid({ type: "invalid" })).toBe(false) + const original = JSON.parse(JSON.stringify(schema)) + transformJsonSchema(schema, (subSchema) => { + subSchema.modified = true }) - it("should return true for empty object (valid JSON Schema)", () => { - expect(JsonSchemaUtils.isValid({})).toBe(true) - }) + expect(schema).toEqual(original) }) - describe("addAdditionalPropertiesFalse", () => { - it("should add additionalProperties: false to a simple object schema", () => { - const schema = { - type: "object", - properties: { - name: { type: "string" }, - }, - } + it("should return non-object values as-is", () => { + expect(transformJsonSchema(null as any, () => {})).toBeNull() + expect(transformJsonSchema("string" as any, () => {})).toBe("string") + }) - const result = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) + it("should allow custom transformations", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + }, + } - expect(result).toEqual({ - type: "object", - properties: { - name: { type: "string" }, - }, - additionalProperties: false, - }) + // Add description to all string types + const result = transformJsonSchema(schema, (subSchema) => { + if (subSchema.type === "string") { + subSchema.description = "A string field" + } }) - it("should add additionalProperties: false to nested object schemas", () => { - const schema = { - type: "object", - properties: { - user: { - type: "object", - properties: { - name: { type: "string" }, - address: { - type: "object", - properties: { - street: { type: "string" }, - }, - }, - }, - }, - }, - } + expect((result.properties as any).name.description).toBe("A string field") + }) +}) + +describe("addAdditionalPropertiesFalse", () => { + it("should add additionalProperties: false to a simple object schema", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + }, + } - const result = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) + const result = addAdditionalPropertiesFalse(schema) - expect(result).toEqual({ - type: "object", - properties: { - user: { - type: "object", - properties: { - name: { type: "string" }, - address: { - type: "object", - properties: { - street: { type: "string" }, - }, - additionalProperties: false, - }, - }, - additionalProperties: false, - }, - }, - additionalProperties: false, - }) + expect(result).toEqual({ + type: "object", + properties: { + name: { type: "string" }, + }, + additionalProperties: false, }) + }) - it("should add additionalProperties: false to array items that are objects", () => { - const schema = { - type: "object", - properties: { - entities: { - type: "array", - items: { + it("should add additionalProperties: false to nested object schemas", () => { + const schema = { + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + address: { type: "object", properties: { - name: { type: "string" }, - entityType: { type: "string" }, + street: { type: "string" }, }, }, }, }, - } + }, + } - const result = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) + const result = addAdditionalPropertiesFalse(schema) - expect(result).toEqual({ - type: "object", - properties: { - entities: { - type: "array", - items: { + expect(result).toEqual({ + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + address: { type: "object", properties: { - name: { type: "string" }, - entityType: { type: "string" }, + street: { type: "string" }, }, additionalProperties: false, }, }, + additionalProperties: false, }, - additionalProperties: false, - }) + }, + additionalProperties: false, }) + }) - it("should handle tuple-style array items", () => { - const schema = { - type: "object", - properties: { - tuple: { - type: "array", - items: [ - { type: "object", properties: { a: { type: "string" } } }, - { type: "object", properties: { b: { type: "number" } } }, - ], + it("should add additionalProperties: false to array items that are objects", () => { + const schema = { + type: "object", + properties: { + entities: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + entityType: { type: "string" }, + }, }, }, - } + }, + } - const result = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) + const result = addAdditionalPropertiesFalse(schema) - expect(result).toEqual({ - type: "object", - properties: { - tuple: { - type: "array", - items: [ - { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, - { type: "object", properties: { b: { type: "number" } }, additionalProperties: false }, - ], + expect(result).toEqual({ + type: "object", + properties: { + entities: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + entityType: { type: "string" }, + }, + additionalProperties: false, }, }, - additionalProperties: false, - }) + }, + additionalProperties: false, }) + }) - it("should preserve existing additionalProperties: false", () => { - const schema = { - type: "object", - properties: { - name: { type: "string" }, + it("should handle tuple-style array items", () => { + const schema = { + type: "object", + properties: { + tuple: { + type: "array", + items: [ + { type: "object", properties: { a: { type: "string" } } }, + { type: "object", properties: { b: { type: "number" } } }, + ], }, - additionalProperties: false, - } + }, + } - const result = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) + const result = addAdditionalPropertiesFalse(schema) - expect(result).toEqual({ - type: "object", - properties: { - name: { type: "string" }, + expect(result).toEqual({ + type: "object", + properties: { + tuple: { + type: "array", + items: [ + { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, + { type: "object", properties: { b: { type: "number" } }, additionalProperties: false }, + ], }, - additionalProperties: false, - }) + }, + additionalProperties: false, }) + }) - it("should handle anyOf schemas", () => { - const schema = { - anyOf: [{ type: "object", properties: { a: { type: "string" } } }, { type: "string" }], - } + it("should preserve existing additionalProperties: false", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + }, + additionalProperties: false, + } - const result = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) + const result = addAdditionalPropertiesFalse(schema) - expect(result).toEqual({ - anyOf: [ - { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, - { type: "string" }, - ], - }) + expect(result).toEqual({ + type: "object", + properties: { + name: { type: "string" }, + }, + additionalProperties: false, }) + }) - it("should handle oneOf schemas", () => { - const schema = { - oneOf: [{ type: "object", properties: { a: { type: "string" } } }, { type: "number" }], - } + it("should handle anyOf schemas", () => { + const schema = { + anyOf: [{ type: "object", properties: { a: { type: "string" } } }, { type: "string" }], + } - const result = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) + const result = addAdditionalPropertiesFalse(schema) - expect(result).toEqual({ - oneOf: [ - { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, - { type: "number" }, - ], - }) + expect(result).toEqual({ + anyOf: [ + { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, + { type: "string" }, + ], }) + }) - it("should handle allOf schemas", () => { - const schema = { - allOf: [ - { type: "object", properties: { a: { type: "string" } } }, - { type: "object", properties: { b: { type: "number" } } }, - ], - } + it("should handle oneOf schemas", () => { + const schema = { + oneOf: [{ type: "object", properties: { a: { type: "string" } } }, { type: "number" }], + } - const result = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) + const result = addAdditionalPropertiesFalse(schema) - expect(result).toEqual({ - allOf: [ - { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, - { type: "object", properties: { b: { type: "number" } }, additionalProperties: false }, - ], - }) + expect(result).toEqual({ + oneOf: [ + { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, + { type: "number" }, + ], }) + }) - it("should not mutate the original schema", () => { - const schema = { - type: "object", - properties: { - name: { type: "string" }, - }, - } + it("should handle allOf schemas", () => { + const schema = { + allOf: [ + { type: "object", properties: { a: { type: "string" } } }, + { type: "object", properties: { b: { type: "number" } } }, + ], + } - const original = JSON.parse(JSON.stringify(schema)) - JsonSchemaUtils.addAdditionalPropertiesFalse(schema) + const result = addAdditionalPropertiesFalse(schema) - expect(schema).toEqual(original) + expect(result).toEqual({ + allOf: [ + { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, + { type: "object", properties: { b: { type: "number" } }, additionalProperties: false }, + ], }) + }) - it("should return non-object values as-is", () => { - expect(JsonSchemaUtils.addAdditionalPropertiesFalse(null as any)).toBeNull() - expect(JsonSchemaUtils.addAdditionalPropertiesFalse("string" as any)).toBe("string") - }) + it("should not mutate the original schema", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + }, + } - it("should handle deeply nested complex schemas", () => { - const schema = { - type: "object", - properties: { - level1: { - type: "object", - properties: { - level2: { - type: "array", - items: { - type: "object", - properties: { - level3: { - type: "object", - properties: { - value: { type: "string" }, - }, + const original = JSON.parse(JSON.stringify(schema)) + addAdditionalPropertiesFalse(schema) + + expect(schema).toEqual(original) + }) + + it("should handle deeply nested complex schemas", () => { + const schema = { + type: "object", + properties: { + level1: { + type: "object", + properties: { + level2: { + type: "array", + items: { + type: "object", + properties: { + level3: { + type: "object", + properties: { + value: { type: "string" }, }, }, }, @@ -356,99 +399,78 @@ describe("JsonSchemaUtils", () => { }, }, }, - } + }, + } - const result = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) + const result = addAdditionalPropertiesFalse(schema) - expect(result.additionalProperties).toBe(false) - expect((result.properties as any).level1.additionalProperties).toBe(false) - expect((result.properties as any).level1.properties.level2.items.additionalProperties).toBe(false) - expect( - (result.properties as any).level1.properties.level2.items.properties.level3.additionalProperties, - ).toBe(false) - }) + expect(result.additionalProperties).toBe(false) + expect((result.properties as any).level1.additionalProperties).toBe(false) + expect((result.properties as any).level1.properties.level2.items.additionalProperties).toBe(false) + expect((result.properties as any).level1.properties.level2.items.properties.level3.additionalProperties).toBe( + false, + ) + }) - it("should handle the real-world MCP memory create_entities schema", () => { - // This is based on the actual schema that caused the error - const schema = { - type: "object", - properties: { - entities: { - type: "array", - items: { - type: "object", - properties: { - name: { type: "string", description: "The name of the entity" }, - entityType: { type: "string", description: "The type of the entity" }, - observations: { - type: "array", - items: { type: "string" }, - description: "An array of observation contents", - }, + it("should handle the real-world MCP memory create_entities schema", () => { + // This is based on the actual schema that caused the error + const schema = { + type: "object", + properties: { + entities: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string", description: "The name of the entity" }, + entityType: { type: "string", description: "The type of the entity" }, + observations: { + type: "array", + items: { type: "string" }, + description: "An array of observation contents", }, - required: ["name", "entityType", "observations"], }, - description: "An array of entities to create", + required: ["name", "entityType", "observations"], }, + description: "An array of entities to create", }, - required: ["entities"], - } + }, + required: ["entities"], + } - const result = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) + const result = addAdditionalPropertiesFalse(schema) - // Top-level object should have additionalProperties: false - expect(result.additionalProperties).toBe(false) - // Items in the entities array should have additionalProperties: false - expect((result.properties as any).entities.items.additionalProperties).toBe(false) - }) + // Top-level object should have additionalProperties: false + expect(result.additionalProperties).toBe(false) + // Items in the entities array should have additionalProperties: false + expect((result.properties as any).entities.items.additionalProperties).toBe(false) }) - describe("validateAndAddAdditionalPropertiesFalse", () => { - it("should validate and transform valid schema", () => { - const schema = { - type: "object", - properties: { - name: { type: "string" }, - }, - } - - const result = JsonSchemaUtils.validateAndAddAdditionalPropertiesFalse(schema) - - expect(result.additionalProperties).toBe(false) - }) - - it("should throw for invalid schema", () => { - const invalidSchema = { type: "invalid-type" } - - expect(() => JsonSchemaUtils.validateAndAddAdditionalPropertiesFalse(invalidSchema)).toThrow() - }) - }) - - describe("stripUnknownFields", () => { - it("should return schema for valid input", () => { - const schema = { - type: "object", - properties: { name: { type: "string" } }, - } - - const result = JsonSchemaUtils.stripUnknownFields(schema) - - expect(result).not.toBeNull() - expect(result?.type).toBe("object") - }) - - it("should return null for invalid input", () => { - const invalidSchema = { type: "invalid" } + it("should not add additionalProperties to non-object types", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + count: { type: "number" }, + active: { type: "boolean" }, + tags: { type: "array", items: { type: "string" } }, + }, + } - const result = JsonSchemaUtils.stripUnknownFields(invalidSchema) + const result = addAdditionalPropertiesFalse(schema) - expect(result).toBeNull() - }) + // Only the root object should have additionalProperties + expect(result.additionalProperties).toBe(false) + expect((result.properties as any).name.additionalProperties).toBeUndefined() + expect((result.properties as any).count.additionalProperties).toBeUndefined() + expect((result.properties as any).active.additionalProperties).toBeUndefined() + expect((result.properties as any).tags.additionalProperties).toBeUndefined() + expect((result.properties as any).tags.items.additionalProperties).toBeUndefined() }) }) -describe("addAdditionalPropertiesFalse (standalone function)", () => { - it("should work the same as the class method", () => { +describe("validateAndAddAdditionalPropertiesFalse", () => { + it("should validate and transform valid schema", () => { const schema = { type: "object", properties: { @@ -456,10 +478,15 @@ describe("addAdditionalPropertiesFalse (standalone function)", () => { }, } - const standaloneResult = addAdditionalPropertiesFalse(schema) - const classResult = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) + const result = validateAndAddAdditionalPropertiesFalse(schema) + + expect(result.additionalProperties).toBe(false) + }) + + it("should throw for invalid schema", () => { + const invalidSchema = { type: "invalid-type" } - expect(standaloneResult).toEqual(classResult) + expect(() => validateAndAddAdditionalPropertiesFalse(invalidSchema)).toThrow() }) }) diff --git a/src/utils/json-schema.ts b/src/utils/json-schema.ts index 9a44c19c253..356ceda4a04 100644 --- a/src/utils/json-schema.ts +++ b/src/utils/json-schema.ts @@ -35,8 +35,8 @@ export interface JsonSchema { const JsonSchemaPrimitiveTypeSchema = z.enum(["string", "number", "integer", "boolean", "null"]) /** - * Zod schema for validating JSON Schema structures - * Uses z.lazy for recursive definition with explicit type casting + * Zod schema for validating JSON Schema structures. + * Uses z.lazy for recursive definition with explicit type annotation. */ export const JsonSchemaSchema: z.ZodType = z.lazy(() => z @@ -67,145 +67,128 @@ export const JsonSchemaSchema: z.ZodType = z.lazy(() => ) /** - * Result of schema validation + * Callback function for schema transformation */ -export type ValidationResult = { success: true; data: T } | { success: false; error: z.ZodError } +export type SchemaTransformCallback = (subSchema: Record) => void /** - * JSON Schema utility class for validation and transformation + * Validates that an object is a valid JSON Schema. + * + * @param schema - The object to validate + * @returns The validated schema if valid, null otherwise * * @example * ```typescript * const schema = { type: "object", properties: { name: { type: "string" } } } - * - * // Validate schema - * const result = JsonSchemaUtils.validate(schema) - * if (result.success) { - * // Use validated schema - * const transformed = JsonSchemaUtils.addAdditionalPropertiesFalse(result.data) + * const validated = validateJsonSchema(schema) + * if (validated) { + * // schema is valid, use it * } - * - * // Or transform directly - * const transformed = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) * ``` */ -export class JsonSchemaUtils { - /** - * Validates that an object conforms to JSON Schema structure - * - * @param schema - The object to validate - * @returns Validation result with typed data or error - */ - static validate(schema: unknown): ValidationResult { - const result = JsonSchemaSchema.safeParse(schema) - if (result.success) { - return { success: true, data: result.data } - } - return { success: false, error: result.error } - } - - /** - * Validates and throws if invalid - * - * @param schema - The object to validate - * @returns Validated JSON Schema - * @throws ZodError if validation fails - */ - static validateOrThrow(schema: unknown): JsonSchema { - return JsonSchemaSchema.parse(schema) - } - - /** - * Recursively adds `additionalProperties: false` to all object schemas. - * This is required by some API providers (e.g., OpenAI) for strict function calling. - * - * Uses `json-schema-traverse` library for robust schema traversal. - * - * @param schema - The JSON Schema to transform - * @returns A new schema with `additionalProperties: false` on all object schemas - * - * @example - * ```typescript - * const schema = { - * type: "object", - * properties: { - * users: { - * type: "array", - * items: { type: "object", properties: { name: { type: "string" } } } - * } - * } - * } - * - * const result = JsonSchemaUtils.addAdditionalPropertiesFalse(schema) - * // Result: all object schemas now have additionalProperties: false - * ``` - */ - static addAdditionalPropertiesFalse(schema: Record): Record { - if (typeof schema !== "object" || schema === null) { - return schema - } - - // Deep clone to avoid mutating the original - const cloned = JSON.parse(JSON.stringify(schema)) as Record +export function validateJsonSchema(schema: unknown): JsonSchema | null { + const result = JsonSchemaSchema.safeParse(schema) + return result.success ? result.data : null +} - // Use json-schema-traverse to visit all schemas and add additionalProperties: false to objects - traverse(cloned, { - allKeys: true, - cb: (subSchema: Record) => { - if (subSchema.type === "object") { - subSchema.additionalProperties = false - } - }, - }) +/** + * Type guard to check if a value is a valid JSON Schema. + * + * @param schema - The value to check + * @returns true if the value is a valid JSON Schema + */ +export function isJsonSchema(schema: unknown): schema is JsonSchema { + return JsonSchemaSchema.safeParse(schema).success +} - return cloned +/** + * Transforms a JSON Schema by visiting all sub-schemas and applying a callback. + * Uses `json-schema-traverse` for robust traversal of all JSON Schema constructs. + * + * @param schema - The JSON Schema to transform + * @param callback - Function to call on each sub-schema (can mutate the sub-schema) + * @returns A new transformed schema (original is not mutated) + * + * @example + * ```typescript + * // Add a custom property to all object schemas + * const result = transformJsonSchema(schema, (subSchema) => { + * if (subSchema.type === "object") { + * subSchema.myCustomProp = true + * } + * }) + * ``` + */ +export function transformJsonSchema( + schema: Record, + callback: SchemaTransformCallback, +): Record { + if (typeof schema !== "object" || schema === null) { + return schema } - /** - * Validates schema and then adds `additionalProperties: false` to all object schemas. - * Throws if schema is invalid. - * - * @param schema - The JSON Schema to validate and transform - * @returns A validated and transformed schema - * @throws ZodError if validation fails - */ - static validateAndAddAdditionalPropertiesFalse(schema: unknown): Record { - this.validateOrThrow(schema) - return this.addAdditionalPropertiesFalse(schema as Record) - } + // Deep clone to avoid mutating the original + const cloned = JSON.parse(JSON.stringify(schema)) as Record - /** - * Strips unknown/unsupported properties from a schema, keeping only known JSON Schema fields - * - * @param schema - The JSON Schema to clean - * @returns A new schema with only known JSON Schema fields - */ - static stripUnknownFields(schema: unknown): JsonSchema | null { - const result = JsonSchemaSchema.safeParse(schema) - if (!result.success) { - return null - } - return result.data - } + // Use json-schema-traverse to visit all sub-schemas + traverse(cloned, { + allKeys: true, + cb: callback, + }) - /** - * Checks if a schema is valid JSON Schema - * - * @param schema - The object to check - * @returns true if valid JSON Schema, false otherwise - */ - static isValid(schema: unknown): schema is JsonSchema { - return JsonSchemaSchema.safeParse(schema).success - } + return cloned } /** - * Standalone function for adding additionalProperties: false - * (for backwards compatibility and simpler imports) + * Recursively adds `additionalProperties: false` to all object schemas. + * This is required by some API providers (e.g., OpenAI) for strict function calling. + * + * @param schema - The JSON Schema to transform + * @returns A new schema with `additionalProperties: false` on all object schemas * - * @param schema - The JSON Schema object to transform - * @returns A new schema object with `additionalProperties: false` added to all object schemas + * @example + * ```typescript + * const schema = { + * type: "object", + * properties: { + * users: { + * type: "array", + * items: { type: "object", properties: { name: { type: "string" } } } + * } + * } + * } + * + * const result = addAdditionalPropertiesFalse(schema) + * // All nested object schemas now have additionalProperties: false + * ``` */ export function addAdditionalPropertiesFalse(schema: Record): Record { - return JsonSchemaUtils.addAdditionalPropertiesFalse(schema) + return transformJsonSchema(schema, (subSchema) => { + if (subSchema.type === "object") { + subSchema.additionalProperties = false + } + }) +} + +/** + * Validates a schema and then transforms it to add `additionalProperties: false`. + * Throws if the schema is invalid. + * + * @param schema - The schema to validate and transform + * @returns The validated and transformed schema + * @throws ZodError if the schema is invalid + * + * @example + * ```typescript + * try { + * const result = validateAndAddAdditionalPropertiesFalse(schema) + * // Use the validated and transformed schema + * } catch (error) { + * // Handle invalid schema + * } + * ``` + */ +export function validateAndAddAdditionalPropertiesFalse(schema: unknown): Record { + JsonSchemaSchema.parse(schema) // Throws if invalid + return addAdditionalPropertiesFalse(schema as Record) } From 77208b786ea9183e81592338d97acb1f08e00c47 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 15 Dec 2025 19:33:34 -0500 Subject: [PATCH 05/10] refactor: use Zod schema with .default(false) for additionalProperties - Single schema definition (StrictJsonSchemaSchema) that validates AND applies defaults - Removed json-schema-traverse dependency - mcp_server.ts uses parsed schema directly, no manual property additions - Updated tests to verify Zod default behavior --- pnpm-lock.yaml | 10 +- .../prompts/tools/native-tools/mcp_server.ts | 32 +- src/package.json | 1 - src/utils/__tests__/json-schema.spec.ts | 378 +++++------------- src/utils/json-schema.ts | 162 ++------ 5 files changed, 147 insertions(+), 436 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 522a890f8c7..0db1f9acefc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -726,9 +726,6 @@ importers: isbinaryfile: specifier: ^5.0.2 version: 5.0.4 - json-schema-traverse: - specifier: ^1.0.0 - version: 1.0.0 jwt-decode: specifier: ^4.0.0 version: 4.0.0 @@ -7034,9 +7031,6 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -14138,7 +14132,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: @@ -17170,8 +17164,6 @@ snapshots: json-schema-traverse@0.4.1: {} - json-schema-traverse@1.0.0: {} - json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-safe@5.0.1: {} diff --git a/src/core/prompts/tools/native-tools/mcp_server.ts b/src/core/prompts/tools/native-tools/mcp_server.ts index f46859fa07f..35b5048c8e1 100644 --- a/src/core/prompts/tools/native-tools/mcp_server.ts +++ b/src/core/prompts/tools/native-tools/mcp_server.ts @@ -1,7 +1,7 @@ import type OpenAI from "openai" import { McpHub } from "../../../../services/mcp/McpHub" import { buildMcpToolName } from "../../../../utils/mcp-name" -import { addAdditionalPropertiesFalse } from "../../../../utils/json-schema" +import { StrictJsonSchemaSchema, type JsonSchema } from "../../../../utils/json-schema" /** * Dynamically generates native tool definitions for all enabled tools across connected MCP servers. @@ -41,26 +41,16 @@ export function getMcpServerTools(mcpHub?: McpHub): OpenAI.Chat.ChatCompletionTo } seenToolNames.add(toolName) - const originalSchema = tool.inputSchema as Record | undefined - const toolInputRequired = (originalSchema?.required ?? []) as string[] + const originalSchema = tool.inputSchema as Record | undefined - // Transform the schema to ensure all nested object schemas have additionalProperties: false - // This is required by some API providers (e.g., OpenAI) for strict function calling - const transformedSchema = originalSchema ? addAdditionalPropertiesFalse(originalSchema) : {} - const toolInputProps = (transformedSchema as Record)?.properties ?? {} - - // Build parameters directly from the tool's input schema. - // The server_name and tool_name are encoded in the function name itself - // (e.g., mcp_serverName_toolName), so they don't need to be in the arguments. - const parameters: OpenAI.FunctionParameters = { - type: "object", - properties: toolInputProps, - additionalProperties: false, - } - - // Only add required if there are required fields - if (toolInputRequired.length > 0) { - parameters.required = toolInputRequired + // Parse with StrictJsonSchemaSchema + let parameters: JsonSchema + if (originalSchema) { + const result = StrictJsonSchemaSchema.safeParse(originalSchema) + parameters = result.success ? result.data : (originalSchema as JsonSchema) + } else { + // No schema provided - create a minimal valid schema + parameters = StrictJsonSchemaSchema.parse({ type: "object" }) } const toolDefinition: OpenAI.Chat.ChatCompletionTool = { @@ -68,7 +58,7 @@ export function getMcpServerTools(mcpHub?: McpHub): OpenAI.Chat.ChatCompletionTo function: { name: toolName, description: tool.description, - parameters: parameters, + parameters: parameters as OpenAI.FunctionParameters, }, } diff --git a/src/package.json b/src/package.json index 034430d4b02..f9173fb1d55 100644 --- a/src/package.json +++ b/src/package.json @@ -465,7 +465,6 @@ "i18next": "^25.0.0", "ignore": "^7.0.3", "isbinaryfile": "^5.0.2", - "json-schema-traverse": "^1.0.0", "jwt-decode": "^4.0.0", "lodash.debounce": "^4.0.8", "mammoth": "^1.9.1", diff --git a/src/utils/__tests__/json-schema.spec.ts b/src/utils/__tests__/json-schema.spec.ts index fa4b98abecf..b891bf3b171 100644 --- a/src/utils/__tests__/json-schema.spec.ts +++ b/src/utils/__tests__/json-schema.spec.ts @@ -1,28 +1,23 @@ -import { - addAdditionalPropertiesFalse, - validateJsonSchema, - isJsonSchema, - transformJsonSchema, - validateAndAddAdditionalPropertiesFalse, - JsonSchemaSchema, -} from "../json-schema" - -describe("validateJsonSchema", () => { - it("should return validated schema for valid input", () => { +import { JsonSchemaSchema, StrictJsonSchemaSchema } from "../json-schema" + +describe("JsonSchemaSchema", () => { + it("should validate a simple schema", () => { const schema = { type: "object", properties: { name: { type: "string" } } } - const result = validateJsonSchema(schema) + const result = JsonSchemaSchema.safeParse(schema) - expect(result).not.toBeNull() - expect(result?.type).toBe("object") + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.type).toBe("object") + } }) - it("should return null for invalid type values", () => { + it("should reject invalid type values", () => { const schema = { type: "invalid-type" } - const result = validateJsonSchema(schema) + const result = JsonSchemaSchema.safeParse(schema) - expect(result).toBeNull() + expect(result.success).toBe(false) }) it("should validate nested schemas", () => { @@ -39,9 +34,9 @@ describe("validateJsonSchema", () => { }, } - const result = validateJsonSchema(schema) + const result = JsonSchemaSchema.safeParse(schema) - expect(result).not.toBeNull() + expect(result.success).toBe(true) }) it("should validate array schemas", () => { @@ -55,9 +50,9 @@ describe("validateJsonSchema", () => { }, } - const result = validateJsonSchema(schema) + const result = JsonSchemaSchema.safeParse(schema) - expect(result).not.toBeNull() + expect(result.success).toBe(true) }) it("should validate schemas with anyOf/oneOf/allOf", () => { @@ -65,9 +60,9 @@ describe("validateJsonSchema", () => { anyOf: [{ type: "string" }, { type: "number" }], } - const result = validateJsonSchema(schema) + const result = JsonSchemaSchema.safeParse(schema) - expect(result).not.toBeNull() + expect(result.success).toBe(true) }) it("should pass through unknown properties", () => { @@ -79,91 +74,34 @@ describe("validateJsonSchema", () => { }, } - const result = validateJsonSchema(schema) - - expect(result).not.toBeNull() - expect(result?.customProperty).toBe("custom value") - }) -}) - -describe("isJsonSchema", () => { - it("should return true for valid schemas", () => { - expect(isJsonSchema({ type: "string" })).toBe(true) - expect(isJsonSchema({ type: "object", properties: {} })).toBe(true) - expect(isJsonSchema({ anyOf: [{ type: "string" }] })).toBe(true) - }) - - it("should return false for invalid type values", () => { - expect(isJsonSchema({ type: "invalid" })).toBe(false) - }) - - it("should return true for empty object (valid JSON Schema)", () => { - expect(isJsonSchema({})).toBe(true) - }) -}) + const result = JsonSchemaSchema.safeParse(schema) -describe("transformJsonSchema", () => { - it("should apply callback to all sub-schemas", () => { - const schema = { - type: "object", - properties: { - name: { type: "string" }, - }, + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.customProperty).toBe("custom value") } - - const visited: string[] = [] - transformJsonSchema(schema, (subSchema) => { - if (subSchema.type) { - visited.push(subSchema.type as string) - } - }) - - expect(visited).toContain("object") - expect(visited).toContain("string") }) - it("should not mutate the original schema", () => { - const schema = { - type: "object", - properties: { - name: { type: "string" }, - }, - } - - const original = JSON.parse(JSON.stringify(schema)) - transformJsonSchema(schema, (subSchema) => { - subSchema.modified = true - }) - - expect(schema).toEqual(original) - }) + it("should accept empty object (valid JSON Schema)", () => { + const result = JsonSchemaSchema.safeParse({}) - it("should return non-object values as-is", () => { - expect(transformJsonSchema(null as any, () => {})).toBeNull() - expect(transformJsonSchema("string" as any, () => {})).toBe("string") + expect(result.success).toBe(true) }) - it("should allow custom transformations", () => { + it("should NOT add additionalProperties (validation only)", () => { const schema = { type: "object", - properties: { - name: { type: "string" }, - }, + properties: { name: { type: "string" } }, } - // Add description to all string types - const result = transformJsonSchema(schema, (subSchema) => { - if (subSchema.type === "string") { - subSchema.description = "A string field" - } - }) + const result = JsonSchemaSchema.parse(schema) - expect((result.properties as any).name.description).toBe("A string field") + expect(result.additionalProperties).toBeUndefined() }) }) -describe("addAdditionalPropertiesFalse", () => { - it("should add additionalProperties: false to a simple object schema", () => { +describe("StrictJsonSchemaSchema", () => { + it("should validate and default additionalProperties to false", () => { const schema = { type: "object", properties: { @@ -171,18 +109,13 @@ describe("addAdditionalPropertiesFalse", () => { }, } - const result = addAdditionalPropertiesFalse(schema) + const result = StrictJsonSchemaSchema.parse(schema) - expect(result).toEqual({ - type: "object", - properties: { - name: { type: "string" }, - }, - additionalProperties: false, - }) + expect(result.type).toBe("object") + expect(result.additionalProperties).toBe(false) }) - it("should add additionalProperties: false to nested object schemas", () => { + it("should recursively apply defaults to nested schemas", () => { const schema = { type: "object", properties: { @@ -190,161 +123,76 @@ describe("addAdditionalPropertiesFalse", () => { type: "object", properties: { name: { type: "string" }, - address: { - type: "object", - properties: { - street: { type: "string" }, - }, - }, }, }, }, } - const result = addAdditionalPropertiesFalse(schema) + const result = StrictJsonSchemaSchema.parse(schema) - expect(result).toEqual({ - type: "object", - properties: { - user: { - type: "object", - properties: { - name: { type: "string" }, - address: { - type: "object", - properties: { - street: { type: "string" }, - }, - additionalProperties: false, - }, - }, - additionalProperties: false, - }, - }, - additionalProperties: false, - }) + expect(result.additionalProperties).toBe(false) + expect((result.properties as any).user.additionalProperties).toBe(false) }) - it("should add additionalProperties: false to array items that are objects", () => { + it("should apply defaults to object schemas in array items", () => { const schema = { type: "object", properties: { - entities: { + items: { type: "array", items: { type: "object", properties: { - name: { type: "string" }, - entityType: { type: "string" }, + id: { type: "number" }, }, }, }, }, } - const result = addAdditionalPropertiesFalse(schema) + const result = StrictJsonSchemaSchema.parse(schema) - expect(result).toEqual({ - type: "object", - properties: { - entities: { - type: "array", - items: { - type: "object", - properties: { - name: { type: "string" }, - entityType: { type: "string" }, - }, - additionalProperties: false, - }, - }, - }, - additionalProperties: false, - }) + expect(result.additionalProperties).toBe(false) + expect((result.properties as any).items.items.additionalProperties).toBe(false) }) - it("should handle tuple-style array items", () => { - const schema = { - type: "object", - properties: { - tuple: { - type: "array", - items: [ - { type: "object", properties: { a: { type: "string" } } }, - { type: "object", properties: { b: { type: "number" } } }, - ], - }, - }, - } - - const result = addAdditionalPropertiesFalse(schema) + it("should throw on invalid schema", () => { + const invalidSchema = { type: "invalid-type" } - expect(result).toEqual({ - type: "object", - properties: { - tuple: { - type: "array", - items: [ - { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, - { type: "object", properties: { b: { type: "number" } }, additionalProperties: false }, - ], - }, - }, - additionalProperties: false, - }) + expect(() => StrictJsonSchemaSchema.parse(invalidSchema)).toThrow() }) - it("should preserve existing additionalProperties: false", () => { - const schema = { - type: "object", - properties: { - name: { type: "string" }, - }, - additionalProperties: false, - } + it("should use safeParse for error handling", () => { + const invalidSchema = { type: "invalid-type" } - const result = addAdditionalPropertiesFalse(schema) + const result = StrictJsonSchemaSchema.safeParse(invalidSchema) - expect(result).toEqual({ - type: "object", - properties: { - name: { type: "string" }, - }, - additionalProperties: false, - }) + expect(result.success).toBe(false) }) - it("should handle anyOf schemas", () => { + it("should apply defaults in anyOf schemas", () => { const schema = { anyOf: [{ type: "object", properties: { a: { type: "string" } } }, { type: "string" }], } - const result = addAdditionalPropertiesFalse(schema) + const result = StrictJsonSchemaSchema.parse(schema) - expect(result).toEqual({ - anyOf: [ - { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, - { type: "string" }, - ], - }) + expect((result.anyOf as any)[0].additionalProperties).toBe(false) + expect((result.anyOf as any)[1].additionalProperties).toBe(false) }) - it("should handle oneOf schemas", () => { + it("should apply defaults in oneOf schemas", () => { const schema = { oneOf: [{ type: "object", properties: { a: { type: "string" } } }, { type: "number" }], } - const result = addAdditionalPropertiesFalse(schema) + const result = StrictJsonSchemaSchema.parse(schema) - expect(result).toEqual({ - oneOf: [ - { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, - { type: "number" }, - ], - }) + expect((result.oneOf as any)[0].additionalProperties).toBe(false) + expect((result.oneOf as any)[1].additionalProperties).toBe(false) }) - it("should handle allOf schemas", () => { + it("should apply defaults in allOf schemas", () => { const schema = { allOf: [ { type: "object", properties: { a: { type: "string" } } }, @@ -352,28 +200,45 @@ describe("addAdditionalPropertiesFalse", () => { ], } - const result = addAdditionalPropertiesFalse(schema) + const result = StrictJsonSchemaSchema.parse(schema) - expect(result).toEqual({ - allOf: [ - { type: "object", properties: { a: { type: "string" } }, additionalProperties: false }, - { type: "object", properties: { b: { type: "number" } }, additionalProperties: false }, - ], - }) + expect((result.allOf as any)[0].additionalProperties).toBe(false) + expect((result.allOf as any)[1].additionalProperties).toBe(false) }) - it("should not mutate the original schema", () => { + it("should apply defaults to tuple-style array items", () => { + const schema = { + type: "object", + properties: { + tuple: { + type: "array", + items: [ + { type: "object", properties: { a: { type: "string" } } }, + { type: "object", properties: { b: { type: "number" } } }, + ], + }, + }, + } + + const result = StrictJsonSchemaSchema.parse(schema) + + const tupleItems = (result.properties as any).tuple.items + expect(tupleItems[0].additionalProperties).toBe(false) + expect(tupleItems[1].additionalProperties).toBe(false) + }) + + it("should preserve explicit additionalProperties: false", () => { const schema = { type: "object", properties: { name: { type: "string" }, }, + additionalProperties: false, } - const original = JSON.parse(JSON.stringify(schema)) - addAdditionalPropertiesFalse(schema) + const result = StrictJsonSchemaSchema.parse(schema) - expect(schema).toEqual(original) + expect(result.additionalProperties).toBe(false) }) it("should handle deeply nested complex schemas", () => { @@ -402,7 +267,7 @@ describe("addAdditionalPropertiesFalse", () => { }, } - const result = addAdditionalPropertiesFalse(schema) + const result = StrictJsonSchemaSchema.parse(schema) expect(result.additionalProperties).toBe(false) expect((result.properties as any).level1.additionalProperties).toBe(false) @@ -413,7 +278,7 @@ describe("addAdditionalPropertiesFalse", () => { }) it("should handle the real-world MCP memory create_entities schema", () => { - // This is based on the actual schema that caused the error + // This is based on the actual schema that caused the OpenAI error const schema = { type: "object", properties: { @@ -438,72 +303,11 @@ describe("addAdditionalPropertiesFalse", () => { required: ["entities"], } - const result = addAdditionalPropertiesFalse(schema) + const result = StrictJsonSchemaSchema.parse(schema) // Top-level object should have additionalProperties: false expect(result.additionalProperties).toBe(false) // Items in the entities array should have additionalProperties: false expect((result.properties as any).entities.items.additionalProperties).toBe(false) }) - - it("should not add additionalProperties to non-object types", () => { - const schema = { - type: "object", - properties: { - name: { type: "string" }, - count: { type: "number" }, - active: { type: "boolean" }, - tags: { type: "array", items: { type: "string" } }, - }, - } - - const result = addAdditionalPropertiesFalse(schema) - - // Only the root object should have additionalProperties - expect(result.additionalProperties).toBe(false) - expect((result.properties as any).name.additionalProperties).toBeUndefined() - expect((result.properties as any).count.additionalProperties).toBeUndefined() - expect((result.properties as any).active.additionalProperties).toBeUndefined() - expect((result.properties as any).tags.additionalProperties).toBeUndefined() - expect((result.properties as any).tags.items.additionalProperties).toBeUndefined() - }) -}) - -describe("validateAndAddAdditionalPropertiesFalse", () => { - it("should validate and transform valid schema", () => { - const schema = { - type: "object", - properties: { - name: { type: "string" }, - }, - } - - const result = validateAndAddAdditionalPropertiesFalse(schema) - - expect(result.additionalProperties).toBe(false) - }) - - it("should throw for invalid schema", () => { - const invalidSchema = { type: "invalid-type" } - - expect(() => validateAndAddAdditionalPropertiesFalse(invalidSchema)).toThrow() - }) -}) - -describe("JsonSchemaSchema (Zod schema)", () => { - it("should be exported and usable directly", () => { - const schema = { type: "object" } - - const result = JsonSchemaSchema.safeParse(schema) - - expect(result.success).toBe(true) - }) - - it("should reject invalid types", () => { - const schema = { type: "invalid-type" } - - const result = JsonSchemaSchema.safeParse(schema) - - expect(result.success).toBe(false) - }) }) diff --git a/src/utils/json-schema.ts b/src/utils/json-schema.ts index 356ceda4a04..77576f8df32 100644 --- a/src/utils/json-schema.ts +++ b/src/utils/json-schema.ts @@ -1,5 +1,4 @@ import { z } from "zod" -import traverse from "json-schema-traverse" /** * Type representing a JSON Schema structure @@ -35,8 +34,16 @@ export interface JsonSchema { const JsonSchemaPrimitiveTypeSchema = z.enum(["string", "number", "integer", "boolean", "null"]) /** - * Zod schema for validating JSON Schema structures. + * Zod schema for validating JSON Schema structures (without transformation). * Uses z.lazy for recursive definition with explicit type annotation. + * + * @example + * ```typescript + * const result = JsonSchemaSchema.safeParse(schema) + * if (result.success) { + * // schema is valid + * } + * ``` */ export const JsonSchemaSchema: z.ZodType = z.lazy(() => z @@ -67,128 +74,47 @@ export const JsonSchemaSchema: z.ZodType = z.lazy(() => ) /** - * Callback function for schema transformation - */ -export type SchemaTransformCallback = (subSchema: Record) => void - -/** - * Validates that an object is a valid JSON Schema. - * - * @param schema - The object to validate - * @returns The validated schema if valid, null otherwise + * Zod schema that validates JSON Schema and sets `additionalProperties: false` by default. + * Uses recursive parsing so the default applies to all nested schemas automatically. * - * @example - * ```typescript - * const schema = { type: "object", properties: { name: { type: "string" } } } - * const validated = validateJsonSchema(schema) - * if (validated) { - * // schema is valid, use it - * } - * ``` - */ -export function validateJsonSchema(schema: unknown): JsonSchema | null { - const result = JsonSchemaSchema.safeParse(schema) - return result.success ? result.data : null -} - -/** - * Type guard to check if a value is a valid JSON Schema. - * - * @param schema - The value to check - * @returns true if the value is a valid JSON Schema - */ -export function isJsonSchema(schema: unknown): schema is JsonSchema { - return JsonSchemaSchema.safeParse(schema).success -} - -/** - * Transforms a JSON Schema by visiting all sub-schemas and applying a callback. - * Uses `json-schema-traverse` for robust traversal of all JSON Schema constructs. - * - * @param schema - The JSON Schema to transform - * @param callback - Function to call on each sub-schema (can mutate the sub-schema) - * @returns A new transformed schema (original is not mutated) - * - * @example - * ```typescript - * // Add a custom property to all object schemas - * const result = transformJsonSchema(schema, (subSchema) => { - * if (subSchema.type === "object") { - * subSchema.myCustomProp = true - * } - * }) - * ``` - */ -export function transformJsonSchema( - schema: Record, - callback: SchemaTransformCallback, -): Record { - if (typeof schema !== "object" || schema === null) { - return schema - } - - // Deep clone to avoid mutating the original - const cloned = JSON.parse(JSON.stringify(schema)) as Record - - // Use json-schema-traverse to visit all sub-schemas - traverse(cloned, { - allKeys: true, - cb: callback, - }) - - return cloned -} - -/** - * Recursively adds `additionalProperties: false` to all object schemas. * This is required by some API providers (e.g., OpenAI) for strict function calling. * - * @param schema - The JSON Schema to transform - * @returns A new schema with `additionalProperties: false` on all object schemas - * * @example * ```typescript - * const schema = { - * type: "object", - * properties: { - * users: { - * type: "array", - * items: { type: "object", properties: { name: { type: "string" } } } - * } - * } - * } - * - * const result = addAdditionalPropertiesFalse(schema) - * // All nested object schemas now have additionalProperties: false - * ``` - */ -export function addAdditionalPropertiesFalse(schema: Record): Record { - return transformJsonSchema(schema, (subSchema) => { - if (subSchema.type === "object") { - subSchema.additionalProperties = false - } - }) -} - -/** - * Validates a schema and then transforms it to add `additionalProperties: false`. - * Throws if the schema is invalid. + * // Validates and applies defaults in one pass - throws on invalid + * const strictSchema = StrictJsonSchemaSchema.parse(schema) * - * @param schema - The schema to validate and transform - * @returns The validated and transformed schema - * @throws ZodError if the schema is invalid - * - * @example - * ```typescript - * try { - * const result = validateAndAddAdditionalPropertiesFalse(schema) - * // Use the validated and transformed schema - * } catch (error) { - * // Handle invalid schema + * // Or use safeParse for error handling + * const result = StrictJsonSchemaSchema.safeParse(schema) + * if (result.success) { + * // result.data has additionalProperties: false by default * } * ``` */ -export function validateAndAddAdditionalPropertiesFalse(schema: unknown): Record { - JsonSchemaSchema.parse(schema) // Throws if invalid - return addAdditionalPropertiesFalse(schema as Record) -} +export const StrictJsonSchemaSchema: z.ZodType = z.lazy(() => + z + .object({ + type: z.union([JsonSchemaPrimitiveTypeSchema, z.literal("object"), z.literal("array")]).optional(), + properties: z.record(z.string(), StrictJsonSchemaSchema).optional(), + items: z.union([StrictJsonSchemaSchema, z.array(StrictJsonSchemaSchema)]).optional(), + required: z.array(z.string()).optional(), + additionalProperties: z.union([z.boolean(), StrictJsonSchemaSchema]).default(false), + description: z.string().optional(), + default: z.unknown().optional(), + enum: z.array(z.unknown()).optional(), + const: z.unknown().optional(), + anyOf: z.array(StrictJsonSchemaSchema).optional(), + oneOf: z.array(StrictJsonSchemaSchema).optional(), + allOf: z.array(StrictJsonSchemaSchema).optional(), + $ref: z.string().optional(), + minimum: z.number().optional(), + maximum: z.number().optional(), + minLength: z.number().optional(), + maxLength: z.number().optional(), + pattern: z.string().optional(), + minItems: z.number().optional(), + maxItems: z.number().optional(), + uniqueItems: z.boolean().optional(), + }) + .passthrough(), +) From 048181ece6ef6e9becaa78fe89f08fcd3bebe2bb Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 15 Dec 2025 19:52:07 -0500 Subject: [PATCH 06/10] revert: restore original pnpm-lock.yaml --- pnpm-lock.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0db1f9acefc..d94adb27944 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7842,7 +7842,6 @@ packages: next@15.2.6: resolution: {integrity: sha512-DIKFctUpZoCq5ok2ztVU+PqhWsbiqM9xNP7rHL2cAp29NQcmDp7Y6JnBBhHRbFt4bCsCZigj6uh+/Gwh2158Wg==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} - deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details. hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -14132,7 +14131,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: From 5cfe5385d0469c5d692905192b7df79716fa375a Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 16 Dec 2025 09:59:32 -0500 Subject: [PATCH 07/10] refactor: use Zod v4 JSONSchema type for schema validation --- pnpm-lock.yaml | 37 +++++------ src/utils/json-schema.ts | 132 ++++++++++++++++----------------------- 2 files changed, 72 insertions(+), 97 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d94adb27944..bf27fba3ea9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -644,7 +644,7 @@ importers: version: 1.2.0 '@mistralai/mistralai': specifier: ^1.9.18 - version: 1.9.18(zod@3.25.61) + version: 1.9.18(zod@3.25.76) '@modelcontextprotocol/sdk': specifier: 1.12.0 version: 1.12.0 @@ -749,7 +749,7 @@ importers: version: 0.5.17 openai: specifier: ^5.12.2 - version: 5.12.2(ws@8.18.3)(zod@3.25.61) + version: 5.12.2(ws@8.18.3)(zod@3.25.76) os-name: specifier: ^6.0.0 version: 6.1.0 @@ -854,7 +854,7 @@ importers: version: 2.8.0 zod: specifier: ^3.25.61 - version: 3.25.61 + version: 3.25.76 devDependencies: '@roo-code/build': specifier: workspace:^ @@ -966,7 +966,7 @@ importers: version: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) zod-to-ts: specifier: ^1.2.0 - version: 1.2.0(typescript@5.8.3)(zod@3.25.61) + version: 1.2.0(typescript@5.8.3)(zod@3.25.76) webview-ui: dependencies: @@ -7842,6 +7842,7 @@ packages: next@15.2.6: resolution: {integrity: sha512-DIKFctUpZoCq5ok2ztVU+PqhWsbiqM9xNP7rHL2cAp29NQcmDp7Y6JnBBhHRbFt4bCsCZigj6uh+/Gwh2158Wg==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details. hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 @@ -11874,10 +11875,10 @@ snapshots: dependencies: exenv-es6: 1.1.1 - '@mistralai/mistralai@1.9.18(zod@3.25.61)': + '@mistralai/mistralai@1.9.18(zod@3.25.76)': dependencies: - zod: 3.25.61 - zod-to-json-schema: 3.24.5(zod@3.25.61) + zod: 3.25.76 + zod-to-json-schema: 3.24.5(zod@3.25.76) '@mixmark-io/domino@2.2.0': {} @@ -14131,7 +14132,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: @@ -17263,8 +17264,8 @@ snapshots: smol-toml: 1.3.4 strip-json-comments: 5.0.2 typescript: 5.8.3 - zod: 3.25.61 - zod-validation-error: 3.4.1(zod@3.25.61) + zod: 3.25.76 + zod-validation-error: 3.4.1(zod@3.25.76) knuth-shuffle-seeded@1.0.6: dependencies: @@ -18413,10 +18414,10 @@ snapshots: is-inside-container: 1.0.0 is-wsl: 3.1.0 - openai@5.12.2(ws@8.18.3)(zod@3.25.61): + openai@5.12.2(ws@8.18.3)(zod@3.25.76): optionalDependencies: ws: 8.18.3 - zod: 3.25.61 + zod: 3.25.76 option@0.2.4: {} @@ -21202,22 +21203,18 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.7.0 - zod-to-json-schema@3.24.5(zod@3.25.61): - dependencies: - zod: 3.25.61 - zod-to-json-schema@3.24.5(zod@3.25.76): dependencies: zod: 3.25.76 - zod-to-ts@1.2.0(typescript@5.8.3)(zod@3.25.61): + zod-to-ts@1.2.0(typescript@5.8.3)(zod@3.25.76): dependencies: typescript: 5.8.3 - zod: 3.25.61 + zod: 3.25.76 - zod-validation-error@3.4.1(zod@3.25.61): + zod-validation-error@3.4.1(zod@3.25.76): dependencies: - zod: 3.25.61 + zod: 3.25.76 zod@3.23.8: {} diff --git a/src/utils/json-schema.ts b/src/utils/json-schema.ts index 77576f8df32..2d4c5b7c76d 100644 --- a/src/utils/json-schema.ts +++ b/src/utils/json-schema.ts @@ -1,41 +1,23 @@ -import { z } from "zod" +import { z } from "zod/v4" /** - * Type representing a JSON Schema structure + * Re-export Zod v4's JSONSchema type for convenience */ -export interface JsonSchema { - type?: "string" | "number" | "integer" | "boolean" | "null" | "object" | "array" - properties?: Record - items?: JsonSchema | JsonSchema[] - required?: string[] - additionalProperties?: boolean | JsonSchema - description?: string - default?: unknown - enum?: unknown[] - const?: unknown - anyOf?: JsonSchema[] - oneOf?: JsonSchema[] - allOf?: JsonSchema[] - $ref?: string - minimum?: number - maximum?: number - minLength?: number - maxLength?: number - pattern?: string - minItems?: number - maxItems?: number - uniqueItems?: boolean - [key: string]: unknown // Allow additional properties -} +export type JsonSchema = z.core.JSONSchema.JSONSchema /** * Zod schema for JSON Schema primitive types */ const JsonSchemaPrimitiveTypeSchema = z.enum(["string", "number", "integer", "boolean", "null"]) +/** + * Zod schema for JSON Schema enum values + */ +const JsonSchemaEnumValueSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]) + /** * Zod schema for validating JSON Schema structures (without transformation). - * Uses z.lazy for recursive definition with explicit type annotation. + * Uses z.lazy for recursive definition. * * @example * ```typescript @@ -46,31 +28,29 @@ const JsonSchemaPrimitiveTypeSchema = z.enum(["string", "number", "integer", "bo * ``` */ export const JsonSchemaSchema: z.ZodType = z.lazy(() => - z - .object({ - type: z.union([JsonSchemaPrimitiveTypeSchema, z.literal("object"), z.literal("array")]).optional(), - properties: z.record(z.string(), JsonSchemaSchema).optional(), - items: z.union([JsonSchemaSchema, z.array(JsonSchemaSchema)]).optional(), - required: z.array(z.string()).optional(), - additionalProperties: z.union([z.boolean(), JsonSchemaSchema]).optional(), - description: z.string().optional(), - default: z.unknown().optional(), - enum: z.array(z.unknown()).optional(), - const: z.unknown().optional(), - anyOf: z.array(JsonSchemaSchema).optional(), - oneOf: z.array(JsonSchemaSchema).optional(), - allOf: z.array(JsonSchemaSchema).optional(), - $ref: z.string().optional(), - minimum: z.number().optional(), - maximum: z.number().optional(), - minLength: z.number().optional(), - maxLength: z.number().optional(), - pattern: z.string().optional(), - minItems: z.number().optional(), - maxItems: z.number().optional(), - uniqueItems: z.boolean().optional(), - }) - .passthrough(), + z.looseObject({ + type: z.union([JsonSchemaPrimitiveTypeSchema, z.literal("object"), z.literal("array")]).optional(), + properties: z.record(z.string(), JsonSchemaSchema).optional(), + items: z.union([JsonSchemaSchema, z.array(JsonSchemaSchema)]).optional(), + required: z.array(z.string()).optional(), + additionalProperties: z.union([z.boolean(), JsonSchemaSchema]).optional(), + description: z.string().optional(), + default: z.unknown().optional(), + enum: z.array(JsonSchemaEnumValueSchema).optional(), + const: JsonSchemaEnumValueSchema.optional(), + anyOf: z.array(JsonSchemaSchema).optional(), + oneOf: z.array(JsonSchemaSchema).optional(), + allOf: z.array(JsonSchemaSchema).optional(), + $ref: z.string().optional(), + minimum: z.number().optional(), + maximum: z.number().optional(), + minLength: z.number().optional(), + maxLength: z.number().optional(), + pattern: z.string().optional(), + minItems: z.number().optional(), + maxItems: z.number().optional(), + uniqueItems: z.boolean().optional(), + }), ) /** @@ -92,29 +72,27 @@ export const JsonSchemaSchema: z.ZodType = z.lazy(() => * ``` */ export const StrictJsonSchemaSchema: z.ZodType = z.lazy(() => - z - .object({ - type: z.union([JsonSchemaPrimitiveTypeSchema, z.literal("object"), z.literal("array")]).optional(), - properties: z.record(z.string(), StrictJsonSchemaSchema).optional(), - items: z.union([StrictJsonSchemaSchema, z.array(StrictJsonSchemaSchema)]).optional(), - required: z.array(z.string()).optional(), - additionalProperties: z.union([z.boolean(), StrictJsonSchemaSchema]).default(false), - description: z.string().optional(), - default: z.unknown().optional(), - enum: z.array(z.unknown()).optional(), - const: z.unknown().optional(), - anyOf: z.array(StrictJsonSchemaSchema).optional(), - oneOf: z.array(StrictJsonSchemaSchema).optional(), - allOf: z.array(StrictJsonSchemaSchema).optional(), - $ref: z.string().optional(), - minimum: z.number().optional(), - maximum: z.number().optional(), - minLength: z.number().optional(), - maxLength: z.number().optional(), - pattern: z.string().optional(), - minItems: z.number().optional(), - maxItems: z.number().optional(), - uniqueItems: z.boolean().optional(), - }) - .passthrough(), + z.looseObject({ + type: z.union([JsonSchemaPrimitiveTypeSchema, z.literal("object"), z.literal("array")]).optional(), + properties: z.record(z.string(), StrictJsonSchemaSchema).optional(), + items: z.union([StrictJsonSchemaSchema, z.array(StrictJsonSchemaSchema)]).optional(), + required: z.array(z.string()).optional(), + additionalProperties: z.union([z.boolean(), StrictJsonSchemaSchema]).default(false), + description: z.string().optional(), + default: z.unknown().optional(), + enum: z.array(JsonSchemaEnumValueSchema).optional(), + const: JsonSchemaEnumValueSchema.optional(), + anyOf: z.array(StrictJsonSchemaSchema).optional(), + oneOf: z.array(StrictJsonSchemaSchema).optional(), + allOf: z.array(StrictJsonSchemaSchema).optional(), + $ref: z.string().optional(), + minimum: z.number().optional(), + maximum: z.number().optional(), + minLength: z.number().optional(), + maxLength: z.number().optional(), + pattern: z.string().optional(), + minItems: z.number().optional(), + maxItems: z.number().optional(), + uniqueItems: z.boolean().optional(), + }), ) From 76e81ea6bf38bf6842f69c422d43c91066cba3c6 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 16 Dec 2025 10:37:44 -0500 Subject: [PATCH 08/10] fix: update mcp_server tests to expect additionalProperties on nested schemas --- .../prompts/tools/native-tools/__tests__/mcp_server.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/prompts/tools/native-tools/__tests__/mcp_server.spec.ts b/src/core/prompts/tools/native-tools/__tests__/mcp_server.spec.ts index 9e33b0552c9..f436e5f294a 100644 --- a/src/core/prompts/tools/native-tools/__tests__/mcp_server.spec.ts +++ b/src/core/prompts/tools/native-tools/__tests__/mcp_server.spec.ts @@ -158,8 +158,8 @@ describe("getMcpServerTools", () => { expect(getFunction(result[0]).parameters).toEqual({ type: "object", properties: { - requiredField: { type: "string" }, - optionalField: { type: "number" }, + requiredField: { type: "string", additionalProperties: false }, + optionalField: { type: "number", additionalProperties: false }, }, additionalProperties: false, required: ["requiredField"], @@ -186,7 +186,7 @@ describe("getMcpServerTools", () => { expect(getFunction(result[0]).parameters).toEqual({ type: "object", properties: { - optionalField: { type: "string" }, + optionalField: { type: "string", additionalProperties: false }, }, additionalProperties: false, }) From 000de24a6bec09f1a654e54529f3fc1e06070914 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 16 Dec 2025 10:58:26 -0500 Subject: [PATCH 09/10] refactor: use Zod schema with .default(false) for additionalProperties - Rename StrictJsonSchemaSchema to ToolInputSchema - Remove unused JsonSchemaSchema - Uses type-only import from zod/v4 to avoid type instantiation issues --- .../prompts/tools/native-tools/mcp_server.ts | 8 +- src/utils/__tests__/json-schema.spec.ts | 128 ++---------------- src/utils/json-schema.ts | 99 +++++--------- 3 files changed, 50 insertions(+), 185 deletions(-) diff --git a/src/core/prompts/tools/native-tools/mcp_server.ts b/src/core/prompts/tools/native-tools/mcp_server.ts index 35b5048c8e1..aff8f068ed4 100644 --- a/src/core/prompts/tools/native-tools/mcp_server.ts +++ b/src/core/prompts/tools/native-tools/mcp_server.ts @@ -1,7 +1,7 @@ import type OpenAI from "openai" import { McpHub } from "../../../../services/mcp/McpHub" import { buildMcpToolName } from "../../../../utils/mcp-name" -import { StrictJsonSchemaSchema, type JsonSchema } from "../../../../utils/json-schema" +import { ToolInputSchema, type JsonSchema } from "../../../../utils/json-schema" /** * Dynamically generates native tool definitions for all enabled tools across connected MCP servers. @@ -43,14 +43,14 @@ export function getMcpServerTools(mcpHub?: McpHub): OpenAI.Chat.ChatCompletionTo const originalSchema = tool.inputSchema as Record | undefined - // Parse with StrictJsonSchemaSchema + // Parse with ToolInputSchema to ensure additionalProperties: false is set recursively let parameters: JsonSchema if (originalSchema) { - const result = StrictJsonSchemaSchema.safeParse(originalSchema) + const result = ToolInputSchema.safeParse(originalSchema) parameters = result.success ? result.data : (originalSchema as JsonSchema) } else { // No schema provided - create a minimal valid schema - parameters = StrictJsonSchemaSchema.parse({ type: "object" }) + parameters = ToolInputSchema.parse({ type: "object" }) } const toolDefinition: OpenAI.Chat.ChatCompletionTool = { diff --git a/src/utils/__tests__/json-schema.spec.ts b/src/utils/__tests__/json-schema.spec.ts index b891bf3b171..b2607dc2556 100644 --- a/src/utils/__tests__/json-schema.spec.ts +++ b/src/utils/__tests__/json-schema.spec.ts @@ -1,106 +1,6 @@ -import { JsonSchemaSchema, StrictJsonSchemaSchema } from "../json-schema" +import { ToolInputSchema } from "../json-schema" -describe("JsonSchemaSchema", () => { - it("should validate a simple schema", () => { - const schema = { type: "object", properties: { name: { type: "string" } } } - - const result = JsonSchemaSchema.safeParse(schema) - - expect(result.success).toBe(true) - if (result.success) { - expect(result.data.type).toBe("object") - } - }) - - it("should reject invalid type values", () => { - const schema = { type: "invalid-type" } - - const result = JsonSchemaSchema.safeParse(schema) - - expect(result.success).toBe(false) - }) - - it("should validate nested schemas", () => { - const schema = { - type: "object", - properties: { - user: { - type: "object", - properties: { - name: { type: "string" }, - age: { type: "integer" }, - }, - }, - }, - } - - const result = JsonSchemaSchema.safeParse(schema) - - expect(result.success).toBe(true) - }) - - it("should validate array schemas", () => { - const schema = { - type: "array", - items: { - type: "object", - properties: { - id: { type: "number" }, - }, - }, - } - - const result = JsonSchemaSchema.safeParse(schema) - - expect(result.success).toBe(true) - }) - - it("should validate schemas with anyOf/oneOf/allOf", () => { - const schema = { - anyOf: [{ type: "string" }, { type: "number" }], - } - - const result = JsonSchemaSchema.safeParse(schema) - - expect(result.success).toBe(true) - }) - - it("should pass through unknown properties", () => { - const schema = { - type: "object", - customProperty: "custom value", - properties: { - name: { type: "string" }, - }, - } - - const result = JsonSchemaSchema.safeParse(schema) - - expect(result.success).toBe(true) - if (result.success) { - expect(result.data.customProperty).toBe("custom value") - } - }) - - it("should accept empty object (valid JSON Schema)", () => { - const result = JsonSchemaSchema.safeParse({}) - - expect(result.success).toBe(true) - }) - - it("should NOT add additionalProperties (validation only)", () => { - const schema = { - type: "object", - properties: { name: { type: "string" } }, - } - - const result = JsonSchemaSchema.parse(schema) - - expect(result.additionalProperties).toBeUndefined() - }) -}) - -describe("StrictJsonSchemaSchema", () => { +describe("ToolInputSchema", () => { it("should validate and default additionalProperties to false", () => { const schema = { type: "object", @@ -109,7 +9,7 @@ describe("StrictJsonSchemaSchema", () => { }, } - const result = StrictJsonSchemaSchema.parse(schema) + const result = ToolInputSchema.parse(schema) expect(result.type).toBe("object") expect(result.additionalProperties).toBe(false) @@ -128,7 +28,7 @@ describe("StrictJsonSchemaSchema", () => { }, } - const result = StrictJsonSchemaSchema.parse(schema) + const result = ToolInputSchema.parse(schema) expect(result.additionalProperties).toBe(false) expect((result.properties as any).user.additionalProperties).toBe(false) @@ -150,7 +50,7 @@ describe("StrictJsonSchemaSchema", () => { }, } - const result = StrictJsonSchemaSchema.parse(schema) + const result = ToolInputSchema.parse(schema) expect(result.additionalProperties).toBe(false) expect((result.properties as any).items.items.additionalProperties).toBe(false) @@ -159,13 +59,13 @@ describe("StrictJsonSchemaSchema", () => { it("should throw on invalid schema", () => { const invalidSchema = { type: "invalid-type" } - expect(() => StrictJsonSchemaSchema.parse(invalidSchema)).toThrow() + expect(() => ToolInputSchema.parse(invalidSchema)).toThrow() }) it("should use safeParse for error handling", () => { const invalidSchema = { type: "invalid-type" } - const result = StrictJsonSchemaSchema.safeParse(invalidSchema) + const result = ToolInputSchema.safeParse(invalidSchema) expect(result.success).toBe(false) }) @@ -175,7 +75,7 @@ describe("StrictJsonSchemaSchema", () => { anyOf: [{ type: "object", properties: { a: { type: "string" } } }, { type: "string" }], } - const result = StrictJsonSchemaSchema.parse(schema) + const result = ToolInputSchema.parse(schema) expect((result.anyOf as any)[0].additionalProperties).toBe(false) expect((result.anyOf as any)[1].additionalProperties).toBe(false) @@ -186,7 +86,7 @@ describe("StrictJsonSchemaSchema", () => { oneOf: [{ type: "object", properties: { a: { type: "string" } } }, { type: "number" }], } - const result = StrictJsonSchemaSchema.parse(schema) + const result = ToolInputSchema.parse(schema) expect((result.oneOf as any)[0].additionalProperties).toBe(false) expect((result.oneOf as any)[1].additionalProperties).toBe(false) @@ -200,7 +100,7 @@ describe("StrictJsonSchemaSchema", () => { ], } - const result = StrictJsonSchemaSchema.parse(schema) + const result = ToolInputSchema.parse(schema) expect((result.allOf as any)[0].additionalProperties).toBe(false) expect((result.allOf as any)[1].additionalProperties).toBe(false) @@ -220,7 +120,7 @@ describe("StrictJsonSchemaSchema", () => { }, } - const result = StrictJsonSchemaSchema.parse(schema) + const result = ToolInputSchema.parse(schema) const tupleItems = (result.properties as any).tuple.items expect(tupleItems[0].additionalProperties).toBe(false) @@ -236,7 +136,7 @@ describe("StrictJsonSchemaSchema", () => { additionalProperties: false, } - const result = StrictJsonSchemaSchema.parse(schema) + const result = ToolInputSchema.parse(schema) expect(result.additionalProperties).toBe(false) }) @@ -267,7 +167,7 @@ describe("StrictJsonSchemaSchema", () => { }, } - const result = StrictJsonSchemaSchema.parse(schema) + const result = ToolInputSchema.parse(schema) expect(result.additionalProperties).toBe(false) expect((result.properties as any).level1.additionalProperties).toBe(false) @@ -303,7 +203,7 @@ describe("StrictJsonSchemaSchema", () => { required: ["entities"], } - const result = StrictJsonSchemaSchema.parse(schema) + const result = ToolInputSchema.parse(schema) // Top-level object should have additionalProperties: false expect(result.additionalProperties).toBe(false) diff --git a/src/utils/json-schema.ts b/src/utils/json-schema.ts index 2d4c5b7c76d..a857cdb6415 100644 --- a/src/utils/json-schema.ts +++ b/src/utils/json-schema.ts @@ -1,9 +1,10 @@ -import { z } from "zod/v4" +import type { z as z4 } from "zod/v4" +import { z } from "zod" /** * Re-export Zod v4's JSONSchema type for convenience */ -export type JsonSchema = z.core.JSONSchema.JSONSchema +export type JsonSchema = z4.core.JSONSchema.JSONSchema /** * Zod schema for JSON Schema primitive types @@ -16,45 +17,7 @@ const JsonSchemaPrimitiveTypeSchema = z.enum(["string", "number", "integer", "bo const JsonSchemaEnumValueSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]) /** - * Zod schema for validating JSON Schema structures (without transformation). - * Uses z.lazy for recursive definition. - * - * @example - * ```typescript - * const result = JsonSchemaSchema.safeParse(schema) - * if (result.success) { - * // schema is valid - * } - * ``` - */ -export const JsonSchemaSchema: z.ZodType = z.lazy(() => - z.looseObject({ - type: z.union([JsonSchemaPrimitiveTypeSchema, z.literal("object"), z.literal("array")]).optional(), - properties: z.record(z.string(), JsonSchemaSchema).optional(), - items: z.union([JsonSchemaSchema, z.array(JsonSchemaSchema)]).optional(), - required: z.array(z.string()).optional(), - additionalProperties: z.union([z.boolean(), JsonSchemaSchema]).optional(), - description: z.string().optional(), - default: z.unknown().optional(), - enum: z.array(JsonSchemaEnumValueSchema).optional(), - const: JsonSchemaEnumValueSchema.optional(), - anyOf: z.array(JsonSchemaSchema).optional(), - oneOf: z.array(JsonSchemaSchema).optional(), - allOf: z.array(JsonSchemaSchema).optional(), - $ref: z.string().optional(), - minimum: z.number().optional(), - maximum: z.number().optional(), - minLength: z.number().optional(), - maxLength: z.number().optional(), - pattern: z.string().optional(), - minItems: z.number().optional(), - maxItems: z.number().optional(), - uniqueItems: z.boolean().optional(), - }), -) - -/** - * Zod schema that validates JSON Schema and sets `additionalProperties: false` by default. + * Zod schema that validates tool input JSON Schema and sets `additionalProperties: false` by default. * Uses recursive parsing so the default applies to all nested schemas automatically. * * This is required by some API providers (e.g., OpenAI) for strict function calling. @@ -62,37 +25,39 @@ export const JsonSchemaSchema: z.ZodType = z.lazy(() => * @example * ```typescript * // Validates and applies defaults in one pass - throws on invalid - * const strictSchema = StrictJsonSchemaSchema.parse(schema) + * const validatedSchema = ToolInputSchema.parse(schema) * * // Or use safeParse for error handling - * const result = StrictJsonSchemaSchema.safeParse(schema) + * const result = ToolInputSchema.safeParse(schema) * if (result.success) { * // result.data has additionalProperties: false by default * } * ``` */ -export const StrictJsonSchemaSchema: z.ZodType = z.lazy(() => - z.looseObject({ - type: z.union([JsonSchemaPrimitiveTypeSchema, z.literal("object"), z.literal("array")]).optional(), - properties: z.record(z.string(), StrictJsonSchemaSchema).optional(), - items: z.union([StrictJsonSchemaSchema, z.array(StrictJsonSchemaSchema)]).optional(), - required: z.array(z.string()).optional(), - additionalProperties: z.union([z.boolean(), StrictJsonSchemaSchema]).default(false), - description: z.string().optional(), - default: z.unknown().optional(), - enum: z.array(JsonSchemaEnumValueSchema).optional(), - const: JsonSchemaEnumValueSchema.optional(), - anyOf: z.array(StrictJsonSchemaSchema).optional(), - oneOf: z.array(StrictJsonSchemaSchema).optional(), - allOf: z.array(StrictJsonSchemaSchema).optional(), - $ref: z.string().optional(), - minimum: z.number().optional(), - maximum: z.number().optional(), - minLength: z.number().optional(), - maxLength: z.number().optional(), - pattern: z.string().optional(), - minItems: z.number().optional(), - maxItems: z.number().optional(), - uniqueItems: z.boolean().optional(), - }), +export const ToolInputSchema: z.ZodType = z.lazy(() => + z + .object({ + type: z.union([JsonSchemaPrimitiveTypeSchema, z.literal("object"), z.literal("array")]).optional(), + properties: z.record(z.string(), ToolInputSchema).optional(), + items: z.union([ToolInputSchema, z.array(ToolInputSchema)]).optional(), + required: z.array(z.string()).optional(), + additionalProperties: z.union([z.boolean(), ToolInputSchema]).default(false), + description: z.string().optional(), + default: z.unknown().optional(), + enum: z.array(JsonSchemaEnumValueSchema).optional(), + const: JsonSchemaEnumValueSchema.optional(), + anyOf: z.array(ToolInputSchema).optional(), + oneOf: z.array(ToolInputSchema).optional(), + allOf: z.array(ToolInputSchema).optional(), + $ref: z.string().optional(), + minimum: z.number().optional(), + maximum: z.number().optional(), + minLength: z.number().optional(), + maxLength: z.number().optional(), + pattern: z.string().optional(), + minItems: z.number().optional(), + maxItems: z.number().optional(), + uniqueItems: z.boolean().optional(), + }) + .passthrough(), ) From a93c1a7d31e0cb5832abeb4c67ade09f628d500a Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Tue, 16 Dec 2025 11:09:54 -0500 Subject: [PATCH 10/10] fix: pin zod to 3.25.61 to avoid TS2589 regression in 3.25.76 --- pnpm-lock.yaml | 28 ++++++++++++++++------------ src/package.json | 2 +- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf27fba3ea9..4890004c8a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -644,7 +644,7 @@ importers: version: 1.2.0 '@mistralai/mistralai': specifier: ^1.9.18 - version: 1.9.18(zod@3.25.76) + version: 1.9.18(zod@3.25.61) '@modelcontextprotocol/sdk': specifier: 1.12.0 version: 1.12.0 @@ -749,7 +749,7 @@ importers: version: 0.5.17 openai: specifier: ^5.12.2 - version: 5.12.2(ws@8.18.3)(zod@3.25.76) + version: 5.12.2(ws@8.18.3)(zod@3.25.61) os-name: specifier: ^6.0.0 version: 6.1.0 @@ -853,8 +853,8 @@ importers: specifier: ^2.8.0 version: 2.8.0 zod: - specifier: ^3.25.61 - version: 3.25.76 + specifier: 3.25.61 + version: 3.25.61 devDependencies: '@roo-code/build': specifier: workspace:^ @@ -966,7 +966,7 @@ importers: version: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) zod-to-ts: specifier: ^1.2.0 - version: 1.2.0(typescript@5.8.3)(zod@3.25.76) + version: 1.2.0(typescript@5.8.3)(zod@3.25.61) webview-ui: dependencies: @@ -11875,10 +11875,10 @@ snapshots: dependencies: exenv-es6: 1.1.1 - '@mistralai/mistralai@1.9.18(zod@3.25.76)': + '@mistralai/mistralai@1.9.18(zod@3.25.61)': dependencies: - zod: 3.25.76 - zod-to-json-schema: 3.24.5(zod@3.25.76) + zod: 3.25.61 + zod-to-json-schema: 3.24.5(zod@3.25.61) '@mixmark-io/domino@2.2.0': {} @@ -18414,10 +18414,10 @@ snapshots: is-inside-container: 1.0.0 is-wsl: 3.1.0 - openai@5.12.2(ws@8.18.3)(zod@3.25.76): + openai@5.12.2(ws@8.18.3)(zod@3.25.61): optionalDependencies: ws: 8.18.3 - zod: 3.25.76 + zod: 3.25.61 option@0.2.4: {} @@ -21203,14 +21203,18 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.7.0 + zod-to-json-schema@3.24.5(zod@3.25.61): + dependencies: + zod: 3.25.61 + zod-to-json-schema@3.24.5(zod@3.25.76): dependencies: zod: 3.25.76 - zod-to-ts@1.2.0(typescript@5.8.3)(zod@3.25.76): + zod-to-ts@1.2.0(typescript@5.8.3)(zod@3.25.61): dependencies: typescript: 5.8.3 - zod: 3.25.76 + zod: 3.25.61 zod-validation-error@3.4.1(zod@3.25.76): dependencies: diff --git a/src/package.json b/src/package.json index f9173fb1d55..544544c5b88 100644 --- a/src/package.json +++ b/src/package.json @@ -507,7 +507,7 @@ "web-tree-sitter": "^0.25.6", "workerpool": "^9.2.0", "yaml": "^2.8.0", - "zod": "^3.25.61" + "zod": "3.25.61" }, "devDependencies": { "@roo-code/build": "workspace:^",