fix: Strip remaining unsupported JSON Schema keywords for Gemini MCP tools (#13850)

*  fix: Strip remaining unsupported JSON Schema keywords for Gemini MCP tools

Gemini's FunctionDeclaration.parameters schema rejects more JSON Schema
keywords than sanitizeGeminiSchema previously stripped. MCP tools shipping
examples/readOnly/multipleOf/uniqueItems/prefixItems/etc. still 400 with
`Unknown name "<key>"`, the same class as #13623 (exclusiveMinimum).

Verified live against gemini-2.5-flash and gemini-3.5-flash: each added
keyword is rejected through `parameters`, and @langchain/google-genai only
removes additionalProperties/$schema, so they must be stripped here.

*  refactor: Make Gemini strip-list fully live-verified; preserve `default`

Probed every candidate keyword against both the live Gemini API
(gemini-2.5-flash, gemini-3.5-flash) and Vertex AI. Confirmed the inferred
siblings (dependencies/dependentSchemas/contentSchema) are rejected, so they
stay. Dropped `default`: it is part of Gemini's Schema and is accepted by both
the Gemini API and Vertex (no documented reason for its removal in #13623), so
it is now preserved instead of stripped.

*  fix: Preserve `default` data and synthesize array `items` (Codex P2s)

Addresses two Codex findings on the strip-list rework:

- `default` is now copied verbatim instead of recursed, so object/array default
  values (e.g. `{ id: 'abc', readOnly: true }`) keep ordinary data keys that the
  schema-recursion would otherwise strip.
- `prefixItems` is dropped but its first member is synthesized into `items`, since
  Gemini's API requires `items` on every array (live: itemless array => 400; the
  synthesized `{type:array, items:{...}}` => 200 on Gemini 2.5/3.5 and Vertex).

Third finding (patternProperties -> empty object) not actioned: live probing shows
`{type:'object'}` with no properties is accepted by both the Gemini API and Vertex.

*  fix: Treat boolean/tuple array `items` as missing (Codex P2)

The Draft 2020 tuple form `prefixItems: [...], items: false` slipped through: the
`'items' in collapsed` check treated boolean `false` as a real item schema, so no
fallback was synthesized and `items: false` was emitted — which Gemini rejects
(live: `items: false`/`true` => 400 "Invalid value").

Now `items` is only kept when it is a schema object; boolean and tuple-array
(`items: [...]`) forms are dropped, a `prefixItems` member is synthesized when
present, and any array still missing `items` falls back to `{}` (verified accepted
by the Gemini API and Vertex). Adds an `isObjectSchema` guard + tests.
This commit is contained in:
Danny Avila 2026-06-19 13:14:47 -04:00 committed by GitHub
parent 36ae268620
commit 8969034ad1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 279 additions and 5 deletions

View file

@ -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 } },
},
},
},
});
});
});

View file

@ -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 "<key>"`
* 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<string, unknown>): Record<string, un
return current;
}
/** True when the value is a usable JSON Schema object (not a boolean or array). */
function isObjectSchema(value: unknown): value is Record<string, unknown> {
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<T extends Record<string, unknown>>(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<string, unknown>);
}
}
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<string, unknown>);
}
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<T extends Record<string, unknown>>(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;
}