diff --git a/packages/api/src/mcp/__tests__/zod.spec.ts b/packages/api/src/mcp/__tests__/zod.spec.ts index 5c948c388d..03ff634097 100644 --- a/packages/api/src/mcp/__tests__/zod.spec.ts +++ b/packages/api/src/mcp/__tests__/zod.spec.ts @@ -2636,7 +2636,7 @@ describe('sanitizeGeminiSchema', () => { expect(sanitizeGeminiSchema(schema)).toEqual({ type: 'string', enum: ['a', 'b'] }); }); - it('strips unsupported keywords (additionalProperties, default, $schema)', () => { + it('strips unsupported keywords (additionalProperties, $schema) but keeps Gemini-supported default', () => { const schema = { $schema: 'http://json-schema.org/draft-07/schema#', type: 'object', @@ -2649,7 +2649,7 @@ describe('sanitizeGeminiSchema', () => { const result = sanitizeGeminiSchema(schema); expect(result).not.toHaveProperty('$schema'); expect(result).not.toHaveProperty('additionalProperties'); - expect(result.properties.name).toEqual({ type: 'string' }); + expect(result.properties.name).toEqual({ type: 'string', default: 'anon' }); }); it('folds exclusive bounds into inclusive minimum/maximum', () => { @@ -2710,4 +2710,201 @@ describe('sanitizeGeminiSchema', () => { const schema = { type: 'integer', enum: [1, 2, 3] } as any; expect(sanitizeGeminiSchema(schema)).toEqual({ type: 'integer' }); }); + + it('strips annotation keywords Gemini rejects (examples, readOnly, writeOnly, deprecated, $comment)', () => { + const schema = { + type: 'object', + properties: { + token: { + type: 'string', + examples: ['abc'], + readOnly: true, + writeOnly: false, + deprecated: true, + $comment: 'internal', + }, + }, + } as any; + expect(sanitizeGeminiSchema(schema)).toEqual({ + type: 'object', + properties: { token: { type: 'string' } }, + }); + }); + + it('strips numeric/array validators Gemini rejects (multipleOf, uniqueItems)', () => { + const schema = { + type: 'object', + properties: { + count: { type: 'integer', multipleOf: 2 }, + tags: { type: 'array', items: { type: 'string' }, uniqueItems: true }, + }, + } as any; + expect(sanitizeGeminiSchema(schema)).toEqual({ + type: 'object', + properties: { + count: { type: 'integer' }, + tags: { type: 'array', items: { type: 'string' } }, + }, + }); + }); + + it('strips object-composition keywords Gemini rejects (patternProperties, propertyNames, dependentRequired)', () => { + const schema = { + type: 'object', + properties: { a: { type: 'string' } }, + patternProperties: { '^x': { type: 'string' } }, + propertyNames: { pattern: '^[a-z]+$' }, + dependentRequired: { a: ['b'] }, + } as any; + expect(sanitizeGeminiSchema(schema)).toEqual({ + type: 'object', + properties: { a: { type: 'string' } }, + }); + }); + + it('strips tuple/array-extension keywords Gemini rejects (prefixItems, additionalItems)', () => { + const schema = { + type: 'array', + items: { type: 'string' }, + prefixItems: [{ type: 'string' }, { type: 'number' }], + additionalItems: false, + } as any; + expect(sanitizeGeminiSchema(schema)).toEqual({ + type: 'array', + items: { type: 'string' }, + }); + }); + + it('synthesizes `items` from `prefixItems` when a tuple array has none (Gemini requires items)', () => { + const schema = { + type: 'array', + prefixItems: [{ type: 'string', readOnly: true }, { type: 'number' }], + } as any; + expect(sanitizeGeminiSchema(schema)).toEqual({ + type: 'array', + items: { type: 'string' }, + }); + }); + + it('preserves an existing `items` over a `prefixItems`-derived one', () => { + const schema = { + type: 'array', + items: { type: 'boolean' }, + prefixItems: [{ type: 'string' }], + } as any; + expect(sanitizeGeminiSchema(schema)).toEqual({ + type: 'array', + items: { type: 'boolean' }, + }); + }); + + it('synthesizes `items` from `prefixItems` when `items` is boolean false (Draft 2020 tuple)', () => { + const schema = { + type: 'array', + prefixItems: [{ type: 'number' }], + items: false, + } as any; + expect(sanitizeGeminiSchema(schema)).toEqual({ + type: 'array', + items: { type: 'number' }, + }); + }); + + it('falls back to an empty `items` schema for a boolean `items` with no prefixItems', () => { + expect(sanitizeGeminiSchema({ type: 'array', items: false } as any)).toEqual({ + type: 'array', + items: {}, + }); + expect(sanitizeGeminiSchema({ type: 'array', items: true } as any)).toEqual({ + type: 'array', + items: {}, + }); + }); + + it('falls back to an empty `items` schema for an array missing items entirely', () => { + expect(sanitizeGeminiSchema({ type: 'array' } as any)).toEqual({ + type: 'array', + items: {}, + }); + }); + + it('drops a tuple-array `items: [...]` form and falls back rather than emit an array items', () => { + const schema = { type: 'array', items: [{ type: 'string' }, { type: 'number' }] } as any; + expect(sanitizeGeminiSchema(schema)).toEqual({ type: 'array', items: {} }); + }); + + it('preserves an object `default` verbatim without sanitizing its data keys', () => { + const schema = { + type: 'object', + properties: { + cfg: { + type: 'object', + default: { id: 'abc', readOnly: true, deprecated: false, nested: { id: 'x' } }, + properties: { id: { type: 'string' } }, + }, + }, + } as any; + const result = sanitizeGeminiSchema(schema); + expect(result.properties.cfg.default).toEqual({ + id: 'abc', + readOnly: true, + deprecated: false, + nested: { id: 'x' }, + }); + expect(result.properties.cfg.properties).toEqual({ id: { type: 'string' } }); + }); + + it('preserves an array `default` verbatim', () => { + const schema = { + type: 'array', + items: { type: 'string' }, + default: ['id', 'readOnly'], + } as any; + expect(sanitizeGeminiSchema(schema)).toEqual({ + type: 'array', + items: { type: 'string' }, + default: ['id', 'readOnly'], + }); + }); + + it('strips content keywords and the bare `id` alias Gemini rejects', () => { + const schema = { + id: 'urn:example', + type: 'object', + properties: { + blob: { type: 'string', contentEncoding: 'base64', contentMediaType: 'image/png' }, + }, + } as any; + expect(sanitizeGeminiSchema(schema)).toEqual({ + type: 'object', + properties: { blob: { type: 'string' } }, + }); + }); + + it('strips deeply nested unsupported keywords through arrays, items, and properties', () => { + const schema = { + type: 'object', + properties: { + ranges: { + type: 'array', + items: { + type: 'object', + properties: { score: { type: 'number', exclusiveMinimum: 0, multipleOf: 0.5 } }, + }, + }, + }, + } as any; + expect(sanitizeGeminiSchema(schema)).toEqual({ + type: 'object', + properties: { + ranges: { + type: 'array', + items: { + type: 'object', + properties: { score: { type: 'number', minimum: 0 } }, + }, + }, + }, + }); + }); }); diff --git a/packages/api/src/mcp/zod.ts b/packages/api/src/mcp/zod.ts index c6df45b6a0..767c1919a5 100644 --- a/packages/api/src/mcp/zod.ts +++ b/packages/api/src/mcp/zod.ts @@ -352,9 +352,40 @@ function mergeRequired(a: unknown, b: unknown): string[] | undefined { /** * JSON Schema keywords absent from Gemini's function-calling Schema subset - * (https://ai.google.dev/api/caching#Schema); they trigger 400s and are stripped. + * (https://ai.google.dev/api/caching#Schema); they trigger `Unknown name ""` + * 400s and are stripped. Every entry below was verified to be rejected through + * `FunctionDeclaration.parameters` against the live Gemini API (`gemini-2.5-flash`, + * `gemini-3.5-flash`) and/or Vertex AI — e.g. `additionalProperties` is rejected + * only by the Gemini API but accepted by Vertex, so the union is stripped for both. + * `@langchain/google-genai` only removes `additionalProperties`/`$schema`, so the + * rest must be stripped here. + * + * Not listed (handled elsewhere in `sanitizeGeminiSchema`): `default` is preserved + * (part of Gemini's Schema, accepted live by both endpoints); `prefixItems` is + * dropped but synthesized into `items` so the array keeps a required element schema. */ -const GEMINI_UNSUPPORTED_KEYS = new Set(['additionalProperties', 'default', '$schema', '$id']); +const GEMINI_UNSUPPORTED_KEYS = new Set([ + 'additionalProperties', + '$schema', + '$id', + 'id', + '$comment', + 'examples', + 'readOnly', + 'writeOnly', + 'deprecated', + 'multipleOf', + 'uniqueItems', + 'additionalItems', + 'propertyNames', + 'patternProperties', + 'dependencies', + 'dependentRequired', + 'dependentSchemas', + 'contentEncoding', + 'contentMediaType', + 'contentSchema', +]); /** * Merges the members of an `allOf` (schema intersection) into the parent: combines @@ -441,6 +472,11 @@ function collapseSchemaUnion(schema: Record): Record { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + /** * Collapses a multi-entry `type` array (e.g. `['string', 'null']`) into a single * type, reporting whether a `null` entry made the field nullable. @@ -470,7 +506,9 @@ function collapseTypeArray(types: unknown[]): { type?: string; nullable: boolean * drops the keyword entirely for non-string types (e.g. a boolean `const` * normalized to `enum: [true]`). * - Folds `exclusiveMinimum`/`exclusiveMaximum` into `minimum`/`maximum`. - * - Strips unsupported keywords (`additionalProperties`, `default`, `const`, `$schema`, `$id`). + * - Strips `const` (after enum conversion) and every keyword in `GEMINI_UNSUPPORTED_KEYS` + * (`additionalProperties`, `examples`, `readOnly`, `multipleOf`, `uniqueItems`, + * `patternProperties`, `prefixItems`, etc.) that the Gemini schema validator rejects. * * @param schema - The JSON schema to sanitize * @returns The Gemini-compatible schema @@ -517,6 +555,38 @@ export function sanitizeGeminiSchema>(schema: continue; } + // `default` holds a literal data value (Gemini-supported), not a subschema — + // copy it verbatim so object/array defaults aren't recursively sanitized + // (which would strip ordinary data keys like `id`/`readOnly`). + if (key === 'default') { + result['default'] = value; + continue; + } + + // Gemini has no tuple validation, so drop `prefixItems`; but Gemini requires + // `items` to be a schema object on every array, so synthesize one from the + // first tuple member unless a real object `items` is already present (a + // boolean `items: false` does not count). + if (key === 'prefixItems') { + if (!isObjectSchema(collapsed.items) && Array.isArray(value)) { + const first = value.find((member) => member && typeof member === 'object'); + if (first) { + result['items'] = sanitizeGeminiSchema(first as Record); + } + } + continue; + } + + // Gemini requires `items` to be a schema object; drop the boolean + // (`items: false`) and tuple-array (`items: [...]`) forms — a + // `prefixItems`-derived or empty fallback is emitted instead. + if (key === 'items') { + if (isObjectSchema(value)) { + result['items'] = sanitizeGeminiSchema(value as Record); + } + continue; + } + // Gemini has no `const`; a string const becomes a single-value (string) enum, // a non-string const is dropped (Gemini enum is string-only). if (key === 'const') { @@ -577,6 +647,13 @@ export function sanitizeGeminiSchema>(schema: result['type'] = 'string'; } + // Gemini rejects an array whose `items` is missing or not a schema object; fall + // back to a permissive empty schema (verified accepted by the API) so tuple/ + // itemless arrays don't 400 after their unsupported item forms are dropped. + if (result['type'] === 'array' && !isObjectSchema(result['items'])) { + result['items'] = {}; + } + if (nullable) { result['nullable'] = true; }