fix: Sanitize MCP Tool Schemas for Gemini/Vertex Compatibility (#13623)

* 🧰 fix: Flatten union schemas for Gemini/Vertex MCP tool compatibility

`@langchain/google-common`'s `zod_to_gemini_parameters` throws "Gemini cannot
handle union types" on any genuine `anyOf`/`oneOf` (e.g. discriminated unions),
so MCP tools shipping union-typed schemas crash on the Google endpoint while
working fine on OpenAI/Claude.

Add `flattenJsonSchemaUnions` (packages/api) to collapse unions to their first
non-null member and multi-entry `type` arrays to a single nullable type, and
apply it in `createToolInstance`'s existing `isGoogle` branch so only the
Google/Vertex path is affected. Lossy by design, mirroring the existing
empty-object fallback.

Closes #13612

* 🩹 fix: Address Codex review — preserve fields, strip null enums, cover definitions path

- Preserve parent-level `properties`/`required` when collapsing a union: merge the
  chosen branch into the parent instead of overwriting, so args declared outside the
  union (e.g. always-required fields) still reach Gemini.
- Drop the `null` member from `enum` when a union/type-array makes a field nullable,
  keeping Gemini's required homogeneous-enum invariant.
- Propagate the Google-flattened schema to the definitions/deferred-tool path:
  thread `provider` into `loadToolDefinitions` and flatten there, and store the
  flattened schema on `mcpJsonSchema` so `extractMCPToolDefinition` no longer emits
  raw unions on Google/Vertex.

* 🎨 style: Sort imports in tools/definitions per import-order check

*  feat: Broaden union flatten into a full Gemini schema sanitizer

The union flatten alone wasn't enough — real GitHub MCP tools on Gemini also 400
with `Invalid value ... (TYPE_STRING), true`, because Gemini's function-calling
Schema (https://ai.google.dev/api/caching#Schema) accepts only a restricted JSON
Schema subset, and `enum` is `Type.STRING`-only.

Rename `flattenJsonSchemaUnions` → `sanitizeGeminiSchema` and broaden it (one pass,
Gemini-gated) to cover the documented subset:

- Keep only string `enum` values; drop the keyword for non-string types (fixes the
  reported boolean-enum 400, incl. boolean `const` normalized to `enum: [true]`).
- `const` → single-value string enum, or drop if non-string.
- Merge `allOf` intersections; fold `exclusiveMinimum`/`exclusiveMaximum` into
  `minimum`/`maximum`.
- Strip unsupported keywords: `additionalProperties`, `default`, `$schema`, `$id`.
- (Existing) collapse `anyOf`/`oneOf`, multi-entry `type` arrays, nullable.

Grounded in Google's Schema docs rather than reverse-engineered from 400s. Verified
end-to-end against the real `@langchain/google-common` converter. Complements
danny-avila/agents#232 (langchain bump), which defers schema flattening to LibreChat.

* 🩹 fix: Gate enum retention on the effective (collapsed) type

Codex review: a mixed-type enum like `type: ['integer','string'], enum: [1,'auto']`
collapsed the type to `integer` but still kept the string value `'auto'`, yielding
`{type:'integer', enum:['auto']}` — a non-string type with an enum, which Gemini
rejects. Keep `enum` only when the effective collapsed type is string (or unset),
and stamp `type: 'string'` on a surviving typeless enum (e.g. a string `const`
discriminator) so it satisfies Gemini's Type.STRING enum requirement.
This commit is contained in:
Danny Avila 2026-06-09 14:16:25 -04:00 committed by GitHub
parent 9db68eeae8
commit 6c36d8038c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 710 additions and 12 deletions

View file

@ -9,6 +9,7 @@ const {
normalizeJsonSchema,
GenerationJobManager,
resolveJsonSchemaRefs,
sanitizeGeminiSchema,
buildMCPAuthStepId,
buildMCPAuthToolCall,
buildMCPAuthRunStepEvent,
@ -648,6 +649,12 @@ function createToolInstance({
let schema = parameters ? normalizeJsonSchema(resolveJsonSchemaRefs(parameters)) : null;
if (schema && isGoogle) {
// Gemini/Vertex AI accept only a subset of JSON Schema; sanitize so MCP tools with
// unions, non-string enums, etc. don't 400 (they work as-is on OpenAI/Claude).
schema = sanitizeGeminiSchema(schema);
}
if (!schema || (isGoogle && isEmptyObjectSchema(schema))) {
schema = {
type: 'object',
@ -779,7 +786,9 @@ function createToolInstance({
});
toolInstance.mcp = true;
toolInstance.mcpRawServerName = serverName;
toolInstance.mcpJsonSchema = parameters;
// On Google/Vertex, propagate the union-flattened schema so definitions extracted
// from this instance don't reach the Gemini converter with unsupported unions.
toolInstance.mcpJsonSchema = isGoogle ? schema : parameters;
return toolInstance;
}

View file

@ -840,6 +840,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
deferredToolsEnabled,
programmaticToolsEnabled,
codeExecutionEnabled,
provider: agent.provider,
},
{
isBuiltInTool,
@ -919,6 +920,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to
deferredToolsEnabled,
programmaticToolsEnabled,
codeExecutionEnabled,
provider: agent.provider,
},
{
isBuiltInTool,

View file

@ -7,6 +7,7 @@ import {
convertJsonSchemaToZod,
resolveJsonSchemaRefs,
normalizeJsonSchema,
sanitizeGeminiSchema,
} from '../zod';
describe('convertJsonSchemaToZod', () => {
@ -2361,3 +2362,352 @@ describe('normalizeJsonSchema', () => {
expect(result.properties.origin).toEqual({ type: 'string', description: 'Starting address' });
});
});
describe('sanitizeGeminiSchema', () => {
it('collapses a multi-member anyOf to its first member', () => {
const schema = {
anyOf: [
{ type: 'string', description: 'a string' },
{ type: 'number', description: 'a number' },
],
} as any;
const result = sanitizeGeminiSchema(schema);
expect(result).toEqual({ type: 'string', description: 'a string' });
expect(result).not.toHaveProperty('anyOf');
});
it('collapses oneOf the same way as anyOf', () => {
const schema = {
oneOf: [{ type: 'integer' }, { type: 'boolean' }],
} as any;
expect(sanitizeGeminiSchema(schema)).toEqual({ type: 'integer' });
});
it('preserves sibling keys and lets the chosen member override them', () => {
const schema = {
title: 'field',
description: 'parent description',
anyOf: [
{
type: 'object',
description: 'member description',
properties: { id: { type: 'string' } },
},
{ type: 'string' },
],
} as any;
expect(sanitizeGeminiSchema(schema)).toEqual({
title: 'field',
description: 'member description',
type: 'object',
properties: { id: { type: 'string' } },
});
});
it('marks the field nullable when a null member is dropped from a union', () => {
const schema = {
anyOf: [{ type: 'string' }, { type: 'null' }],
} as any;
expect(sanitizeGeminiSchema(schema)).toEqual({ type: 'string', nullable: true });
});
it('handles a 3-member union that includes null (google-common would throw on this)', () => {
const schema = {
anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'null' }],
} as any;
expect(sanitizeGeminiSchema(schema)).toEqual({ type: 'string', nullable: true });
});
it('collapses a nested union introduced by the chosen member', () => {
const schema = {
anyOf: [{ anyOf: [{ type: 'string' }, { type: 'number' }] }, { type: 'boolean' }],
} as any;
expect(sanitizeGeminiSchema(schema)).toEqual({ type: 'string' });
});
it('collapses multi-entry type arrays', () => {
expect(sanitizeGeminiSchema({ type: ['string', 'number'] } as any)).toEqual({
type: 'string',
});
});
it('collapses a nullable type array to a single type plus nullable', () => {
expect(sanitizeGeminiSchema({ type: ['string', 'null'] } as any)).toEqual({
type: 'string',
nullable: true,
});
});
it('flattens unions nested inside object properties and array items', () => {
const schema = {
type: 'object',
properties: {
owner: { anyOf: [{ type: 'string' }, { type: 'object', properties: {} }] },
labels: {
type: 'array',
items: { oneOf: [{ type: 'string' }, { type: 'object' }] },
},
plain: { type: 'string', description: 'untouched' },
},
required: ['owner'],
} as any;
const result = sanitizeGeminiSchema(schema);
expect(result.properties.owner).toEqual({ type: 'string' });
expect(result.properties.labels.items).toEqual({ type: 'string' });
expect(result.properties.plain).toEqual({ type: 'string', description: 'untouched' });
expect(result.required).toEqual(['owner']);
});
it('flattens a discriminated-union MCP tool schema (GitHub issue_write pattern)', () => {
const schema = {
type: 'object',
properties: {
method: {
anyOf: [
{
type: 'object',
properties: { action: { const: 'create' }, title: { type: 'string' } },
required: ['action', 'title'],
},
{
type: 'object',
properties: { action: { const: 'update' }, issue_number: { type: 'number' } },
required: ['action', 'issue_number'],
},
],
},
},
} as any;
const result = sanitizeGeminiSchema(schema);
expect(result.properties.method).toEqual({
type: 'object',
properties: { action: { type: 'string', enum: ['create'] }, title: { type: 'string' } },
required: ['action', 'title'],
});
expect(result.properties.method).not.toHaveProperty('anyOf');
});
it('is a no-op for schemas without unions', () => {
const schema = {
type: 'object',
properties: {
name: { type: 'string', description: 'Name' },
age: { type: 'number' },
tags: { type: 'array', items: { type: 'string' } },
},
required: ['name'],
} as any;
expect(sanitizeGeminiSchema(schema)).toEqual(schema);
});
it('handles null, undefined, and primitive inputs safely', () => {
expect(sanitizeGeminiSchema(null as any)).toBeNull();
expect(sanitizeGeminiSchema(undefined as any)).toBeUndefined();
expect(sanitizeGeminiSchema('string' as any)).toBe('string');
expect(sanitizeGeminiSchema(42 as any)).toBe(42);
});
it('leaves no anyOf/oneOf/multi-type keys for google-common to reject', () => {
const schema = {
type: 'object',
properties: {
a: { anyOf: [{ type: 'string' }, { type: 'number' }] },
b: { type: ['boolean', 'null'] },
c: { oneOf: [{ type: 'object', properties: { x: { type: 'string' } } }, { type: 'null' }] },
},
} as any;
const json = JSON.stringify(sanitizeGeminiSchema(schema));
expect(json).not.toContain('"anyOf"');
expect(json).not.toContain('"oneOf"');
expect(sanitizeGeminiSchema(schema).properties.b).toEqual({
type: 'boolean',
nullable: true,
});
});
it('preserves parent properties and required outside the union when collapsing', () => {
const schema = {
type: 'object',
properties: { repo: { type: 'string' } },
required: ['repo'],
anyOf: [
{ properties: { title: { type: 'string' } }, required: ['title'] },
{ properties: { body: { type: 'string' } }, required: ['body'] },
],
} as any;
const result = sanitizeGeminiSchema(schema);
expect(result.type).toBe('object');
expect(result.properties).toEqual({
repo: { type: 'string' },
title: { type: 'string' },
});
expect([...result.required].sort()).toEqual(['repo', 'title']);
expect(result).not.toHaveProperty('anyOf');
});
it('merges nested object properties from parent and chosen branch', () => {
const schema = {
type: 'object',
properties: { owner: { type: 'string' } },
oneOf: [
{ type: 'object', properties: { sha: { type: 'string' } } },
{ type: 'object', properties: { ref: { type: 'string' } } },
],
} as any;
expect(sanitizeGeminiSchema(schema).properties).toEqual({
owner: { type: 'string' },
sha: { type: 'string' },
});
});
it('strips the dropped null from enum when a type array is nullable', () => {
const schema = { type: ['string', 'null'], enum: ['open', 'closed', null] } as any;
expect(sanitizeGeminiSchema(schema)).toEqual({
type: 'string',
nullable: true,
enum: ['open', 'closed'],
});
});
it('strips null from enum when nullability comes from a union member', () => {
const schema = {
anyOf: [{ type: 'string', enum: ['a', 'b', null] }, { type: 'null' }],
} as any;
const result = sanitizeGeminiSchema(schema);
expect(result.type).toBe('string');
expect(result.nullable).toBe(true);
expect(result.enum).toEqual(['a', 'b']);
});
it('keeps enum untouched when the field is not nullable', () => {
const schema = { type: ['string', 'number'], enum: ['a', 'b'] } as any;
expect(sanitizeGeminiSchema(schema)).toEqual({ type: 'string', enum: ['a', 'b'] });
});
it('drops a boolean enum that Gemini rejects (the reported 400), keeping the type', () => {
const schema = { type: 'boolean', enum: [true, false] } as any;
expect(sanitizeGeminiSchema(schema)).toEqual({ type: 'boolean' });
});
it('drops a boolean enum produced by normalizeJsonSchema const→enum', () => {
// normalizeJsonSchema turns `const: true` into `enum: [true]`; Gemini rejects it.
const schema = normalizeJsonSchema({ type: 'boolean', const: true } as any);
expect(sanitizeGeminiSchema(schema as any)).toEqual({ type: 'boolean' });
});
it('resolves the reported failing shape: boolean enum inside array→items→object', () => {
// tools[0].function_declarations[N].parameters.properties[k].items.properties[0].enum[0] = true
const schema = {
type: 'object',
properties: {
rows: {
type: 'array',
items: {
type: 'object',
properties: {
active: { type: 'boolean', enum: [true, false] },
name: { type: 'string' },
},
},
},
},
} as any;
const result = sanitizeGeminiSchema(schema);
expect(result.properties.rows.items.properties.active).toEqual({ type: 'boolean' });
expect(JSON.stringify(result)).not.toContain('"enum"');
});
it('filters non-string members out of a mixed string enum', () => {
const schema = { type: 'string', enum: ['a', 1, true, 'b', null] } as any;
expect(sanitizeGeminiSchema(schema)).toEqual({ type: 'string', enum: ['a', 'b'] });
});
it('strips unsupported keywords (additionalProperties, default, $schema)', () => {
const schema = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
additionalProperties: { type: 'string' },
properties: {
name: { type: 'string', default: 'anon' },
},
} as any;
const result = sanitizeGeminiSchema(schema);
expect(result).not.toHaveProperty('$schema');
expect(result).not.toHaveProperty('additionalProperties');
expect(result.properties.name).toEqual({ type: 'string' });
});
it('folds exclusive bounds into inclusive minimum/maximum', () => {
const schema = { type: 'number', exclusiveMinimum: 0, exclusiveMaximum: 100 } as any;
expect(sanitizeGeminiSchema(schema)).toEqual({ type: 'number', minimum: 0, maximum: 100 });
});
it('does not overwrite an existing minimum with an exclusive bound', () => {
const schema = { type: 'number', minimum: 5, exclusiveMinimum: 0 } as any;
expect(sanitizeGeminiSchema(schema)).toEqual({ type: 'number', minimum: 5 });
});
it('merges allOf intersections into a single object', () => {
const schema = {
allOf: [
{ type: 'object', properties: { a: { type: 'string' } }, required: ['a'] },
{ type: 'object', properties: { b: { type: 'number' } }, required: ['b'] },
],
} as any;
const result = sanitizeGeminiSchema(schema);
expect(result.type).toBe('object');
expect(result.properties).toEqual({ a: { type: 'string' }, b: { type: 'number' } });
expect([...result.required].sort()).toEqual(['a', 'b']);
expect(result).not.toHaveProperty('allOf');
});
it('leaves a clean Gemini-ready schema unchanged', () => {
const schema = {
type: 'object',
properties: {
owner: { type: 'string', description: 'Repo owner' },
side: { type: 'string', enum: ['LEFT', 'RIGHT'] },
line: { type: 'number' },
},
required: ['owner'],
} as any;
expect(sanitizeGeminiSchema(schema)).toEqual(schema);
});
it('drops the enum when a mixed type array collapses to a non-string type', () => {
const schema = { type: ['integer', 'string'], enum: [1, 'auto'] } as any;
expect(sanitizeGeminiSchema(schema)).toEqual({ type: 'integer' });
});
it('keeps the enum when a mixed type array collapses to string', () => {
const schema = { type: ['string', 'integer'], enum: ['auto', 1] } as any;
expect(sanitizeGeminiSchema(schema)).toEqual({ type: 'string', enum: ['auto'] });
});
it('makes a typeless surviving enum a string type (Gemini enum requires Type.STRING)', () => {
const schema = { enum: ['a', 'b'] } as any;
expect(sanitizeGeminiSchema(schema)).toEqual({ type: 'string', enum: ['a', 'b'] });
});
it('drops a number enum on an integer field', () => {
const schema = { type: 'integer', enum: [1, 2, 3] } as any;
expect(sanitizeGeminiSchema(schema)).toEqual({ type: 'integer' });
});
});

View file

@ -322,6 +322,268 @@ export function normalizeJsonSchema<T extends Record<string, unknown>>(schema: T
return result as T;
}
function isNullSchema(member: unknown): boolean {
return (
member != null &&
typeof member === 'object' &&
(member as Record<string, unknown>).type === 'null'
);
}
function mergeProperties(a: unknown, b: unknown): Record<string, unknown> | undefined {
const objA =
a && typeof a === 'object' && !Array.isArray(a) ? (a as Record<string, unknown>) : undefined;
const objB =
b && typeof b === 'object' && !Array.isArray(b) ? (b as Record<string, unknown>) : undefined;
if (!objA && !objB) {
return undefined;
}
return { ...(objA ?? {}), ...(objB ?? {}) };
}
function mergeRequired(a: unknown, b: unknown): string[] | undefined {
const arrA = Array.isArray(a) ? (a as string[]) : [];
const arrB = Array.isArray(b) ? (b as string[]) : [];
if (arrA.length === 0 && arrB.length === 0) {
return undefined;
}
return Array.from(new Set([...arrA, ...arrB]));
}
/**
* JSON Schema keywords absent from Gemini's function-calling Schema subset
* (https://ai.google.dev/api/caching#Schema); they trigger 400s and are stripped.
*/
const GEMINI_UNSUPPORTED_KEYS = new Set(['additionalProperties', 'default', '$schema', '$id']);
/**
* Merges the members of an `allOf` (schema intersection) into the parent: combines
* `properties`/`required` and fills any scalar keyword the parent doesn't already set.
*/
function mergeAllOf(schema: Record<string, unknown>): Record<string, unknown> {
const members = (schema.allOf as unknown[]).filter(
(member): member is Record<string, unknown> => member != null && typeof member === 'object',
);
const result = { ...schema };
delete result.allOf;
let properties = mergeProperties(result.properties, undefined);
let required = mergeRequired(result.required, undefined);
for (const member of members) {
properties = mergeProperties(properties, member.properties);
required = mergeRequired(required, member.required);
for (const [key, value] of Object.entries(member)) {
if (key !== 'properties' && key !== 'required' && !(key in result)) {
result[key] = value;
}
}
}
if (properties) {
result.properties = properties;
}
if (required) {
result.required = required;
}
return result;
}
/**
* Collapses a single `anyOf`/`oneOf` level into its parent by keeping the first
* non-null member, marking the field nullable when a `null` member was present.
* Parent and branch `properties`/`required` are merged so fields declared outside
* the union (e.g. always-required args) survive the collapse. Loops to fully strip
* union keys that the chosen member re-introduces.
*/
function collapseSchemaUnion(schema: Record<string, unknown>): Record<string, unknown> {
let current = schema;
let guard = 0;
while (guard < 200) {
guard += 1;
if (Array.isArray(current.allOf)) {
current = mergeAllOf(current);
continue;
}
let unionKey: 'anyOf' | 'oneOf' | null = null;
if (Array.isArray(current.anyOf)) {
unionKey = 'anyOf';
} else if (Array.isArray(current.oneOf)) {
unionKey = 'oneOf';
}
if (!unionKey) {
break;
}
const members = (current[unionKey] as unknown[]).filter(
(member): member is Record<string, unknown> => member != null && typeof member === 'object',
);
const nonNull = members.filter((member) => !isNullSchema(member));
const hadNull = nonNull.length !== members.length;
const chosen = nonNull[0] ?? {};
const rest = { ...current };
delete rest[unionKey];
const mergedProperties = mergeProperties(rest.properties, chosen.properties);
const mergedRequired = mergeRequired(rest.required, chosen.required);
current = { ...rest, ...chosen };
if (mergedProperties) {
current.properties = mergedProperties;
}
if (mergedRequired) {
current.required = mergedRequired;
}
if (hadNull) {
current.nullable = true;
}
}
return current;
}
/**
* Collapses a multi-entry `type` array (e.g. `['string', 'null']`) into a single
* type, reporting whether a `null` entry made the field nullable.
*/
function collapseTypeArray(types: unknown[]): { type?: string; nullable: boolean } {
const nonNull = types.filter((type) => type !== 'null');
const first = nonNull[0];
return {
type: typeof first === 'string' ? first : undefined,
nullable: nonNull.length !== types.length,
};
}
/**
* Sanitizes a JSON schema to Gemini/Vertex AI's function-calling Schema subset
* (https://ai.google.dev/api/caching#Schema), recursively. Gemini accepts only a
* restricted slice of JSON Schema, and `@langchain/google-common`'s
* `zod_to_gemini_parameters` additionally throws on any union so MCP tools that
* ship richer schemas crash on the Google endpoint while working on OpenAI/Claude.
*
* Transforms (all lossy and Gemini-specific gate on the Google/Vertex provider,
* run after `normalizeJsonSchema`):
* - Collapses `anyOf`/`oneOf` to the first non-null member (merging parent + branch
* `properties`/`required`), and merges `allOf` intersections.
* - Collapses multi-entry `type` arrays to a single type, tracking `nullable`.
* - Keeps only string `enum` values Gemini's `enum` is `Type.STRING`-only and
* 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`).
*
* @param schema - The JSON schema to sanitize
* @returns The Gemini-compatible schema
*/
export function sanitizeGeminiSchema<T extends Record<string, unknown>>(schema: T): T {
if (!schema || typeof schema !== 'object') {
return schema;
}
if (Array.isArray(schema)) {
return schema.map((item) =>
item && typeof item === 'object' ? sanitizeGeminiSchema(item) : item,
) as unknown as T;
}
const collapsed = collapseSchemaUnion(schema);
const typeHasNull =
Array.isArray(collapsed.type) && (collapsed.type as unknown[]).includes('null');
const nullable = collapsed.nullable === true || typeHasNull;
let effectiveType: string | undefined;
if (Array.isArray(collapsed.type)) {
effectiveType = collapseTypeArray(collapsed.type as unknown[]).type;
} else if (typeof collapsed.type === 'string') {
effectiveType = collapsed.type;
}
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(collapsed)) {
if (GEMINI_UNSUPPORTED_KEYS.has(key)) {
continue;
}
if (key === 'type' && Array.isArray(value)) {
if (effectiveType !== undefined) {
result['type'] = effectiveType;
}
continue;
}
// Re-emitted once below so type-array and union sources don't double up.
if (key === 'nullable') {
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') {
if (typeof value === 'string' && !('enum' in collapsed)) {
result['enum'] = [value];
}
continue;
}
// Gemini has no exclusive bounds; fold them into the inclusive ones it accepts.
if (key === 'exclusiveMinimum') {
if (typeof value === 'number' && !('minimum' in collapsed)) {
result['minimum'] = value;
}
continue;
}
if (key === 'exclusiveMaximum') {
if (typeof value === 'number' && !('maximum' in collapsed)) {
result['maximum'] = value;
}
continue;
}
// Gemini `enum` is Type.STRING-only: keep string values only when the effective
// (collapsed) type is string or unset; drop the keyword entirely for non-string
// types (e.g. boolean/number), which also covers null-stripping for string enums.
if (key === 'enum' && Array.isArray(value)) {
const enumAllowed = effectiveType === undefined || effectiveType === 'string';
const stringValues = enumAllowed ? value.filter((entry) => typeof entry === 'string') : [];
if (stringValues.length > 0) {
result['enum'] = stringValues;
}
continue;
}
if (key === 'properties' && value && typeof value === 'object' && !Array.isArray(value)) {
const newProps: Record<string, unknown> = {};
for (const [propKey, propValue] of Object.entries(value as Record<string, unknown>)) {
newProps[propKey] =
propValue && typeof propValue === 'object'
? sanitizeGeminiSchema(propValue as Record<string, unknown>)
: propValue;
}
result[key] = newProps;
continue;
}
if (value && typeof value === 'object') {
result[key] = sanitizeGeminiSchema(value as Record<string, unknown>);
continue;
}
result[key] = value;
}
// A surviving enum implies a string field; Gemini's enum requires Type.STRING.
if ('enum' in result && !('type' in result)) {
result['type'] = 'string';
}
if (nullable) {
result['nullable'] = true;
}
return result as T;
}
/**
* Converts a JSON Schema to a Zod schema.
*

View file

@ -1,11 +1,12 @@
import { loadToolDefinitions } from './definitions';
import { toolkitExpansion, toolkitParent } from './toolkits/mapping';
import { getToolDefinition } from './registry/definitions';
import { Providers } from '@librechat/agents';
import type {
LoadToolDefinitionsParams,
LoadToolDefinitionsDeps,
ActionToolDefinition,
} from './definitions';
import { toolkitExpansion, toolkitParent } from './toolkits/mapping';
import { getToolDefinition } from './registry/definitions';
import { loadToolDefinitions } from './definitions';
describe('definitions.ts', () => {
const mockGetOrFetchMCPServerTools = jest.fn().mockResolvedValue(null);
@ -428,6 +429,69 @@ describe('definitions.ts', () => {
expect(getItemDef?.description).toBe('Get a specific item');
});
it('union-flattens MCP tool schemas for Google, but preserves unions otherwise', async () => {
const mockServerTools = {
issue_write_mcp_github: {
function: {
name: 'issue_write_mcp_github',
description: 'Write an issue',
parameters: {
type: 'object',
properties: {
repo: { type: 'string' },
payload: {
anyOf: [
{
type: 'object',
properties: { action: { const: 'create' }, title: { type: 'string' } },
},
{
type: 'object',
properties: { action: { const: 'update' }, number: { type: 'number' } },
},
],
},
},
required: ['repo'],
},
},
},
};
mockGetOrFetchMCPServerTools.mockResolvedValue(mockServerTools);
const deps: LoadToolDefinitionsDeps = {
getOrFetchMCPServerTools: mockGetOrFetchMCPServerTools,
isBuiltInTool: mockIsBuiltInTool,
};
const googleResult = await loadToolDefinitions(
{
userId: 'user-123',
agentId: 'agent-123',
tools: ['issue_write_mcp_github'],
provider: Providers.GOOGLE,
},
deps,
);
const googleDef = googleResult.toolDefinitions.find(
(d) => d.name === 'issue_write_mcp_github',
);
expect(JSON.stringify(googleDef?.parameters)).not.toContain('anyOf');
expect(
(googleDef?.parameters as { properties: Record<string, { properties: object }> })
.properties.payload.properties,
).toEqual({ action: { type: 'string', enum: ['create'] }, title: { type: 'string' } });
const defaultResult = await loadToolDefinitions(
{ userId: 'user-123', agentId: 'agent-123', tools: ['issue_write_mcp_github'] },
deps,
);
const defaultDef = defaultResult.toolDefinitions.find(
(d) => d.name === 'issue_write_mcp_github',
);
expect(JSON.stringify(defaultDef?.parameters)).toContain('anyOf');
});
it('should load MCP tools with hyphenated server names (server-one)', async () => {
const mockServerTools = {
'list_items_mcp_server-one': {

View file

@ -5,11 +5,12 @@
* @module packages/api/src/tools/definitions
*/
import { Providers } from '@librechat/agents';
import { Constants, isActionTool } from 'librechat-data-provider';
import type { AgentToolOptions } from 'librechat-data-provider';
import type { LCToolRegistry, JsonSchemaType, LCTool, GenericTool } from '@librechat/agents';
import type { AgentToolOptions } from 'librechat-data-provider';
import type { ToolDefinition } from './classification';
import { resolveJsonSchemaRefs, normalizeJsonSchema } from '~/mcp/zod';
import { resolveJsonSchemaRefs, normalizeJsonSchema, sanitizeGeminiSchema } from '~/mcp/zod';
import { buildToolClassification } from './classification';
import { getToolDefinition } from './registry/definitions';
import { toolkitExpansion } from './toolkits/mapping';
@ -39,6 +40,8 @@ export interface LoadToolDefinitionsParams {
programmaticToolsEnabled?: boolean;
/** Whether code execution is enabled and requested by this agent */
codeExecutionEnabled?: boolean;
/** Agent provider — Gemini/Vertex tool schemas get union-flattened for compatibility */
provider?: Providers;
}
export interface ActionToolDefinition {
@ -83,9 +86,21 @@ export async function loadToolDefinitions(
deferredToolsEnabled = false,
programmaticToolsEnabled = false,
codeExecutionEnabled = false,
provider,
} = params;
const { getOrFetchMCPServerTools, isBuiltInTool, getActionToolDefinitions } = deps;
const isGoogle = provider === Providers.GOOGLE || provider === Providers.VERTEXAI;
/** Normalizes MCP tool params, additionally union-flattening for the Gemini/Vertex path. */
const buildMcpParameters = (mcpParams?: JsonSchemaType): JsonSchemaType | undefined => {
if (!mcpParams) {
return undefined;
}
const normalized = normalizeJsonSchema(resolveJsonSchemaRefs(mcpParams));
return isGoogle ? sanitizeGeminiSchema(normalized) : normalized;
};
const emptyResult: LoadToolDefinitionsResult = {
toolDefinitions: [],
toolRegistry: new Map(),
@ -159,9 +174,7 @@ export async function loadToolDefinitions(
mcpToolDefs.push({
name: actualToolName,
description: toolDef.function.description || undefined,
parameters: toolDef.function.parameters
? normalizeJsonSchema(resolveJsonSchemaRefs(toolDef.function.parameters))
: undefined,
parameters: buildMcpParameters(toolDef.function.parameters),
serverName,
});
}
@ -174,9 +187,7 @@ export async function loadToolDefinitions(
mcpToolDefs.push({
name: toolName,
description: toolDef.function.description || undefined,
parameters: toolDef.function.parameters
? normalizeJsonSchema(resolveJsonSchemaRefs(toolDef.function.parameters))
: undefined,
parameters: buildMcpParameters(toolDef.function.parameters),
serverName,
});
}