From 0ecafcd38eba3f66c92c66d0661be58d63348298 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 5 Sep 2025 11:12:44 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A2=20feat:=20Add=20Support=20for=20In?= =?UTF-8?q?teger=20and=20Float=20JSON=20Schema=20Types=20(#9469)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔧 fix: Extend JsonSchemaType to include 'integer' and 'float' types * ci: tests for new integer/float types --- packages/api/src/mcp/__tests__/zod.spec.ts | 160 +++++++++++++++++++++ packages/api/src/mcp/zod.ts | 2 +- packages/api/src/types/zod.ts | 2 +- 3 files changed, 162 insertions(+), 2 deletions(-) diff --git a/packages/api/src/mcp/__tests__/zod.spec.ts b/packages/api/src/mcp/__tests__/zod.spec.ts index fbc72b7e6..bc579f016 100644 --- a/packages/api/src/mcp/__tests__/zod.spec.ts +++ b/packages/api/src/mcp/__tests__/zod.spec.ts @@ -5,6 +5,166 @@ import type { JsonSchemaType } from '~/types'; import { resolveJsonSchemaRefs, convertJsonSchemaToZod, convertWithResolvedRefs } from '../zod'; describe('convertJsonSchemaToZod', () => { + describe('integer type handling', () => { + // Before the fix, integer types were falling through to the default case + // and being converted to something like: + // "anyOf": [{"anyOf": [{"not": {}}, {}]}, {"type": "null"}] + // This test ensures that integer is now properly handled + it('should convert integer type to z.number() and NOT to anyOf', () => { + const schema = { + type: 'integer' as const, + }; + const result = convertJsonSchemaToZod(schema); + expect(result).toBeDefined(); + + // The schema should be a ZodNumber, not a ZodUnion + expect(result).toBeInstanceOf(z.ZodNumber); + + // It should parse numbers correctly + expect(result?.parse(42)).toBe(42); + expect(result?.parse(3.14)).toBe(3.14); // z.number() accepts floats too + }); + + it('should NOT convert optional integer fields to anyOf structures', () => { + // User reported that before the fix, this schema: + // "max_results": { "default": 10, "title": "Max Results", "type": "integer" } + // Was being converted to: + // "max_results": {"anyOf":[{"anyOf":[{"not":{}},{}]},{"type":"null"}]} + const searchSchema = { + type: 'object' as const, + properties: { + query: { + title: 'Query', + type: 'string' as const, + }, + max_results: { + default: 10, + title: 'Max Results', + type: 'integer' as const, + }, + }, + required: ['query'], + title: 'searchArguments', + }; + + const result = convertJsonSchemaToZod(searchSchema); + expect(result).toBeDefined(); + + // Check the shape to ensure max_results is not a union type + if (result instanceof z.ZodObject) { + const shape = result.shape; + expect(shape.query).toBeInstanceOf(z.ZodString); + + // max_results should be ZodOptional(ZodNullable(ZodNumber)), not a ZodUnion + const maxResultsSchema = shape.max_results; + expect(maxResultsSchema).toBeDefined(); + + // It should NOT be a ZodUnion (which would indicate the anyOf structure) + expect(maxResultsSchema).not.toBeInstanceOf(z.ZodUnion); + + // Extract the inner type (it's wrapped in ZodOptional and ZodNullable) + let innerType = maxResultsSchema; + while (innerType instanceof z.ZodOptional || innerType instanceof z.ZodNullable) { + if (innerType instanceof z.ZodOptional) { + innerType = innerType._def.innerType; + } else if (innerType instanceof z.ZodNullable) { + innerType = innerType._def.innerType; + } + } + + // The core type should be ZodNumber + expect(innerType).toBeInstanceOf(z.ZodNumber); + } + + // Test with valid data + const validData = { query: 'test search' }; + const parsedValid = result?.parse(validData); + expect(parsedValid).toBeDefined(); + expect(parsedValid.query).toBe('test search'); + // max_results is optional and may not be in the result when not provided + + // Test with max_results included + const dataWithMaxResults = { query: 'test search', max_results: 5 }; + expect(result?.parse(dataWithMaxResults)).toEqual(dataWithMaxResults); + + // Test that integer values work + const dataWithIntegerMaxResults = { query: 'test', max_results: 20 }; + expect(result?.parse(dataWithIntegerMaxResults)).toEqual(dataWithIntegerMaxResults); + }); + + it('should handle float type correctly', () => { + const schema = { + type: 'float' as const, + }; + const result = convertJsonSchemaToZod(schema); + expect(result).toBeDefined(); + expect(result?.parse(3.14159)).toBe(3.14159); + expect(result?.parse(42)).toBe(42); // integers are valid floats + }); + + it('should handle mixed number, integer, and float in object properties', () => { + const schema = { + type: 'object' as const, + properties: { + numberField: { type: 'number' as const }, + integerField: { type: 'integer' as const }, + floatField: { type: 'float' as const }, + }, + required: ['numberField'], + }; + + const result = convertJsonSchemaToZod(schema); + expect(result).toBeDefined(); + + const testData = { + numberField: 1.5, + integerField: 42, + floatField: 3.14, + }; + + expect(result?.parse(testData)).toEqual(testData); + + // Test with optional fields omitted + const minimalData = { numberField: 2.5 }; + const parsedMinimal = result?.parse(minimalData); + expect(parsedMinimal).toBeDefined(); + expect(parsedMinimal.numberField).toBe(2.5); + // Optional fields may be undefined or null when not provided + expect(parsedMinimal.integerField ?? null).toBe(null); + expect(parsedMinimal.floatField ?? null).toBe(null); + }); + }); + + describe('existing functionality preservation', () => { + it('should still handle string types correctly', () => { + const schema = { + type: 'string' as const, + }; + const result = convertJsonSchemaToZod(schema); + expect(result).toBeDefined(); + expect(result?.parse('hello')).toBe('hello'); + }); + + it('should still handle number types correctly', () => { + const schema = { + type: 'number' as const, + }; + const result = convertJsonSchemaToZod(schema); + expect(result).toBeDefined(); + expect(result?.parse(123.45)).toBe(123.45); + }); + + it('should still handle boolean types correctly', () => { + const schema = { + type: 'boolean' as const, + }; + const result = convertJsonSchemaToZod(schema); + expect(result).toBeDefined(); + expect(result?.parse(true)).toBe(true); + expect(result?.parse(false)).toBe(false); + }); + }); + describe('primitive types', () => { it('should convert string schema', () => { const schema: JsonSchemaType = { diff --git a/packages/api/src/mcp/zod.ts b/packages/api/src/mcp/zod.ts index cff63cd0a..305765dfa 100644 --- a/packages/api/src/mcp/zod.ts +++ b/packages/api/src/mcp/zod.ts @@ -350,7 +350,7 @@ export function convertJsonSchemaToZod( } else { zodSchema = z.string(); } - } else if (schema.type === 'number') { + } else if (schema.type === 'number' || schema.type === 'integer' || schema.type === 'float') { zodSchema = z.number(); } else if (schema.type === 'boolean') { zodSchema = z.boolean(); diff --git a/packages/api/src/types/zod.ts b/packages/api/src/types/zod.ts index e8b3c9327..75d1a0b4e 100644 --- a/packages/api/src/types/zod.ts +++ b/packages/api/src/types/zod.ts @@ -1,5 +1,5 @@ export type JsonSchemaType = { - type: 'string' | 'number' | 'boolean' | 'array' | 'object'; + type: 'string' | 'number' | 'integer' | 'float' | 'boolean' | 'array' | 'object'; enum?: string[]; items?: JsonSchemaType; properties?: Record;