mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-03 22:32:42 +00:00
♊ 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:
parent
9db68eeae8
commit
6c36d8038c
6 changed files with 710 additions and 12 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue