diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index e8265fc60842..dbd2c16f39c7 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -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; } diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 96da4edb30fa..06b963e783a3 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -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, diff --git a/packages/api/src/mcp/__tests__/zod.spec.ts b/packages/api/src/mcp/__tests__/zod.spec.ts index 684b6de975bd..5c948c388dd0 100644 --- a/packages/api/src/mcp/__tests__/zod.spec.ts +++ b/packages/api/src/mcp/__tests__/zod.spec.ts @@ -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' }); + }); +}); diff --git a/packages/api/src/mcp/zod.ts b/packages/api/src/mcp/zod.ts index cb2ffac40fd8..c6df45b6a084 100644 --- a/packages/api/src/mcp/zod.ts +++ b/packages/api/src/mcp/zod.ts @@ -322,6 +322,268 @@ export function normalizeJsonSchema>(schema: T return result as T; } +function isNullSchema(member: unknown): boolean { + return ( + member != null && + typeof member === 'object' && + (member as Record).type === 'null' + ); +} + +function mergeProperties(a: unknown, b: unknown): Record | undefined { + const objA = + a && typeof a === 'object' && !Array.isArray(a) ? (a as Record) : undefined; + const objB = + b && typeof b === 'object' && !Array.isArray(b) ? (b as Record) : 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): Record { + const members = (schema.allOf as unknown[]).filter( + (member): member is Record => 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): Record { + 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 => 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>(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 = {}; + + 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 = {}; + for (const [propKey, propValue] of Object.entries(value as Record)) { + newProps[propKey] = + propValue && typeof propValue === 'object' + ? sanitizeGeminiSchema(propValue as Record) + : propValue; + } + result[key] = newProps; + continue; + } + + if (value && typeof value === 'object') { + result[key] = sanitizeGeminiSchema(value as Record); + 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. * diff --git a/packages/api/src/tools/definitions.spec.ts b/packages/api/src/tools/definitions.spec.ts index 139fdfac3a6c..316bfcf44de5 100644 --- a/packages/api/src/tools/definitions.spec.ts +++ b/packages/api/src/tools/definitions.spec.ts @@ -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 }) + .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': { diff --git a/packages/api/src/tools/definitions.ts b/packages/api/src/tools/definitions.ts index bdb501e9d355..4babe3ed81fa 100644 --- a/packages/api/src/tools/definitions.ts +++ b/packages/api/src/tools/definitions.ts @@ -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, }); }