From 2ea72a0f8742609fcd4a6fb56b5ff2cb0e160b1a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 15 Feb 2026 21:31:16 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=9B=EF=B8=8F=20fix:=20Google=20JSON=20?= =?UTF-8?q?Schema=20Normalization/Resolution=20Logic=20(#11804)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated `resolveJsonSchemaRefs` to prevent `` and `definitions` from appearing in the resolved output, ensuring compatibility with LLM APIs. - Improved `normalizeJsonSchema` to strip vendor extension fields (e.g., `x-*` prefixed keys) and leftover ``/`definitions` blocks, enhancing schema normalization for Google/Gemini API. - Added comprehensive tests to validate the stripping of ``, vendor extensions, and proper normalization across various schema structures. --- packages/api/src/mcp/__tests__/zod.spec.ts | 168 +++++++++++++++++++++ packages/api/src/mcp/zod.ts | 29 +++- 2 files changed, 190 insertions(+), 7 deletions(-) diff --git a/packages/api/src/mcp/__tests__/zod.spec.ts b/packages/api/src/mcp/__tests__/zod.spec.ts index 9566ba0def..684b6de975 100644 --- a/packages/api/src/mcp/__tests__/zod.spec.ts +++ b/packages/api/src/mcp/__tests__/zod.spec.ts @@ -1604,6 +1604,34 @@ describe('convertJsonSchemaToZod', () => { expect(() => zodSchema?.parse(testData)).not.toThrow(); }); + it('should strip $defs from the resolved output', () => { + const schemaWithDefs = { + type: 'object' as const, + properties: { + item: { $ref: '#/$defs/Item' }, + }, + $defs: { + Item: { + type: 'object' as const, + properties: { + name: { type: 'string' as const }, + }, + }, + }, + }; + + const resolved = resolveJsonSchemaRefs(schemaWithDefs); + // $defs should NOT be in the output — it was only used for resolution + expect(resolved).not.toHaveProperty('$defs'); + // The $ref should be resolved inline + expect(resolved.properties?.item).toEqual({ + type: 'object', + properties: { + name: { type: 'string' }, + }, + }); + }); + it('should handle various edge cases safely', () => { // Test with null/undefined expect(resolveJsonSchemaRefs(null as any)).toBeNull(); @@ -2192,4 +2220,144 @@ describe('normalizeJsonSchema', () => { { type: 'number', enum: [1] }, ]); }); + + it('should strip vendor extension fields (x-* prefixed keys)', () => { + const schema = { + type: 'object', + properties: { + travelMode: { + type: 'string', + enum: ['DRIVE', 'BICYCLE', 'TRANSIT', 'WALK'], + 'x-google-enum-descriptions': ['By car', 'By bicycle', 'By public transit', 'By walking'], + description: 'Mode of travel', + }, + }, + } as any; + + const result = normalizeJsonSchema(schema); + expect(result.properties.travelMode).toEqual({ + type: 'string', + enum: ['DRIVE', 'BICYCLE', 'TRANSIT', 'WALK'], + description: 'Mode of travel', + }); + expect(result.properties.travelMode).not.toHaveProperty('x-google-enum-descriptions'); + }); + + it('should strip x-* fields at all nesting levels', () => { + const schema = { + type: 'object', + 'x-custom-root': true, + properties: { + outer: { + type: 'object', + 'x-custom-outer': 'value', + properties: { + inner: { + type: 'string', + 'x-custom-inner': 42, + }, + }, + }, + arr: { + type: 'array', + items: { + type: 'string', + 'x-item-meta': 'something', + }, + }, + }, + } as any; + + const result = normalizeJsonSchema(schema); + expect(result).not.toHaveProperty('x-custom-root'); + expect(result.properties.outer).not.toHaveProperty('x-custom-outer'); + expect(result.properties.outer.properties.inner).not.toHaveProperty('x-custom-inner'); + expect(result.properties.arr.items).not.toHaveProperty('x-item-meta'); + // Standard fields should be preserved + expect(result.type).toBe('object'); + expect(result.properties.outer.type).toBe('object'); + expect(result.properties.outer.properties.inner.type).toBe('string'); + expect(result.properties.arr.items.type).toBe('string'); + }); + + it('should strip $defs and definitions as a safety net', () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + $defs: { + SomeType: { type: 'string' }, + }, + } as any; + + const result = normalizeJsonSchema(schema); + expect(result).not.toHaveProperty('$defs'); + expect(result.type).toBe('object'); + expect(result.properties.name).toEqual({ type: 'string' }); + }); + + it('should strip x-* fields inside oneOf/anyOf/allOf', () => { + const schema = { + type: 'object', + oneOf: [ + { type: 'string', 'x-meta': 'a' }, + { type: 'number', 'x-meta': 'b' }, + ], + } as any; + + const result = normalizeJsonSchema(schema); + expect(result.oneOf[0]).toEqual({ type: 'string' }); + expect(result.oneOf[1]).toEqual({ type: 'number' }); + }); + + it('should handle a Google Maps MCP-like schema with $defs and x-google-enum-descriptions', () => { + const schema = { + type: 'object', + properties: { + origin: { type: 'string', description: 'Starting address' }, + destination: { type: 'string', description: 'Ending address' }, + travelMode: { + type: 'string', + enum: ['DRIVE', 'BICYCLE', 'TRANSIT', 'WALK'], + 'x-google-enum-descriptions': ['By car', 'By bicycle', 'By public transit', 'By walking'], + }, + waypoints: { + type: 'array', + items: { $ref: '#/$defs/Waypoint' }, + }, + }, + required: ['origin', 'destination'], + $defs: { + Waypoint: { + type: 'object', + properties: { + location: { type: 'string' }, + stopover: { type: 'boolean' }, + }, + }, + }, + } as any; + + // First resolve refs, then normalize + const resolved = resolveJsonSchemaRefs(schema); + const result = normalizeJsonSchema(resolved); + + // $defs should be stripped (by both resolveJsonSchemaRefs and normalizeJsonSchema) + expect(result).not.toHaveProperty('$defs'); + // x-google-enum-descriptions should be stripped + expect(result.properties.travelMode).not.toHaveProperty('x-google-enum-descriptions'); + // $ref should be resolved inline + expect(result.properties.waypoints.items).not.toHaveProperty('$ref'); + expect(result.properties.waypoints.items).toEqual({ + type: 'object', + properties: { + location: { type: 'string' }, + stopover: { type: 'boolean' }, + }, + }); + // Standard fields preserved + expect(result.properties.travelMode.enum).toEqual(['DRIVE', 'BICYCLE', 'TRANSIT', 'WALK']); + expect(result.properties.origin).toEqual({ type: 'string', description: 'Starting address' }); + }); }); diff --git a/packages/api/src/mcp/zod.ts b/packages/api/src/mcp/zod.ts index 4f6e955ce8..53cb6e71a8 100644 --- a/packages/api/src/mcp/zod.ts +++ b/packages/api/src/mcp/zod.ts @@ -203,9 +203,9 @@ export function resolveJsonSchemaRefs>( const result: Record = {}; for (const [key, value] of Object.entries(schema)) { - // Skip $defs/definitions at root level to avoid infinite recursion - if ((key === '$defs' || key === 'definitions') && !visited.size) { - result[key] = value; + // Skip $defs/definitions — they are only used for resolving $ref and + // should not appear in the resolved output (e.g. Google/Gemini API rejects them). + if (key === '$defs' || key === 'definitions') { continue; } @@ -249,12 +249,15 @@ export function resolveJsonSchemaRefs>( } /** - * Recursively normalizes a JSON schema by converting `const` values to `enum` arrays. - * Gemini/Vertex AI does not support the `const` keyword in function declarations, - * but `const: X` is semantically equivalent to `enum: [X]` per the JSON Schema spec. + * Recursively normalizes a JSON schema for LLM API compatibility. + * + * Transformations applied: + * - Converts `const` values to `enum` arrays (Gemini/Vertex AI rejects `const`) + * - Strips vendor extension fields (`x-*` prefixed keys, e.g. `x-google-enum-descriptions`) + * - Strips leftover `$defs`/`definitions` blocks that may survive ref resolution * * @param schema - The JSON schema to normalize - * @returns The normalized schema with `const` converted to `enum` + * @returns The normalized schema */ export function normalizeJsonSchema>(schema: T): T { if (!schema || typeof schema !== 'object') { @@ -270,6 +273,18 @@ export function normalizeJsonSchema>(schema: T const result: Record = {}; for (const [key, value] of Object.entries(schema)) { + // Strip vendor extension fields (e.g. x-google-enum-descriptions) — + // these are valid in JSON Schema but rejected by Google/Gemini API. + if (key.startsWith('x-')) { + continue; + } + + // Strip leftover $defs/definitions (should already be resolved by resolveJsonSchemaRefs, + // but strip as a safety net for schemas that bypass ref resolution). + if (key === '$defs' || key === 'definitions') { + continue; + } + if (key === 'const' && !('enum' in schema)) { result['enum'] = [value]; continue;