diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 082217e783..8a51d0ac7f 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -1197,6 +1197,7 @@ async function loadAgentTools({ loadedTools, userId: req.user.id, agentId: agent.id, + provider: agent.provider, agentToolOptions: agent.tool_options, deferredToolsEnabled, programmaticToolsEnabled, diff --git a/packages/api/src/mcp/__tests__/zod.spec.ts b/packages/api/src/mcp/__tests__/zod.spec.ts index 03ff634097..85a183acec 100644 --- a/packages/api/src/mcp/__tests__/zod.spec.ts +++ b/packages/api/src/mcp/__tests__/zod.spec.ts @@ -2907,4 +2907,112 @@ describe('sanitizeGeminiSchema', () => { }, }); }); + + it('strips unresolvable `$ref` keys that survive ref resolution (Gemini rejects them)', () => { + const schema = { + type: 'object', + properties: { + recurrence: { $ref: 'https://example.com/external.json', description: 'Recurrence rule' }, + }, + } as any; + expect(sanitizeGeminiSchema(schema)).toEqual({ + type: 'object', + properties: { + recurrence: { description: 'Recurrence rule' }, + }, + }); + }); +}); + +describe('resolveJsonSchemaRefs local pointer refs', () => { + it('resolves a `#/properties/...` pointer against the root schema', () => { + const schema = { + type: 'object' as const, + properties: { + body: { + type: 'object' as const, + properties: { + start: { + type: 'object' as const, + properties: { + dateTime: { type: 'string' as const }, + timeZone: { type: 'string' as const }, + }, + }, + end: { $ref: '#/properties/body/properties/start' }, + }, + }, + }, + }; + + const resolved = resolveJsonSchemaRefs(schema); + expect(resolved.properties?.body?.properties?.end).toEqual({ + type: 'object', + properties: { + dateTime: { type: 'string' }, + timeZone: { type: 'string' }, + }, + }); + expect(JSON.stringify(resolved)).not.toContain('$ref'); + }); + + it('resolves pointers that traverse array indices (e.g. anyOf members)', () => { + const schema = { + type: 'object' as const, + properties: { + daysOfWeek: { + type: 'array' as const, + items: { + anyOf: [{ type: 'string' as const, enum: ['monday', 'tuesday'] }], + }, + }, + firstDayOfWeek: { $ref: '#/properties/daysOfWeek/items/anyOf/0' }, + }, + }; + + const resolved = resolveJsonSchemaRefs(schema); + expect(resolved.properties?.firstDayOfWeek).toEqual({ + type: 'string', + enum: ['monday', 'tuesday'], + }); + }); + + it('breaks mutually circular local pointers without hanging', () => { + const schema = { + type: 'object' as const, + properties: { + a: { $ref: '#/properties/b' }, + b: { $ref: '#/properties/a' }, + }, + }; + + const resolved = resolveJsonSchemaRefs(schema); + expect(resolved.properties?.a).toEqual({ type: 'object' }); + expect(resolved.properties?.b).toEqual({ type: 'object' }); + }); + + it('keeps dangling local pointers as-is', () => { + const schema = { + type: 'object' as const, + properties: { + broken: { $ref: '#/properties/missing/nested' }, + }, + }; + + const resolved = resolveJsonSchemaRefs(schema); + expect(resolved.properties?.broken).toEqual({ $ref: '#/properties/missing/nested' }); + }); + + it('unescapes RFC 6901 tokens (~0 → ~, ~1 → /) in pointer segments', () => { + const schema = { + type: 'object' as const, + properties: { + 'a/b': { type: 'string' as const }, + alias: { $ref: '#/properties/a~1b' }, + }, + }; + + const resolved = resolveJsonSchemaRefs(schema); + expect(resolved.properties?.alias).toEqual({ type: 'string' }); + }); }); diff --git a/packages/api/src/mcp/zod.ts b/packages/api/src/mcp/zod.ts index 767c1919a5..ac87eabd87 100644 --- a/packages/api/src/mcp/zod.ts +++ b/packages/api/src/mcp/zod.ts @@ -173,22 +173,57 @@ function convertToZodUnion( } /** - * Helper function to resolve $ref references + * Resolves a local JSON pointer (e.g. `#/properties/body/properties/start`) + * against the root schema, per RFC 6901 (`~1` → `/`, `~0` → `~`). + * @returns The referenced subschema, or undefined when the pointer is dangling + */ +function resolveLocalPointer( + root: Record, + pointer: string, +): Record | undefined { + const segments = pointer + .slice(2) + .split('/') + .map((segment) => segment.replace(/~1/g, '/').replace(/~0/g, '~')); + + let node: unknown = root; + for (const segment of segments) { + if (node == null || typeof node !== 'object') { + return undefined; + } + node = (node as Record)[segment]; + } + + if (node != null && typeof node === 'object' && !Array.isArray(node)) { + return node as Record; + } + return undefined; +} + +/** + * Helper function to resolve $ref references. Resolves `#/$defs/...` and + * `#/definitions/...` refs via the definitions map, and any other local + * `#/...` pointer (e.g. `#/properties/foo`) against the root schema — + * MCP servers with OpenAPI-derived schemas commonly emit both forms. * @param schema - The schema to resolve * @param definitions - The definitions to use * @param visited - The set of visited references + * @param root - The root schema local pointers resolve against (defaults to `schema`) * @returns The resolved schema */ export function resolveJsonSchemaRefs>( schema: T, definitions?: Record, visited: Set = new Set(), + root?: Record, ): T { // Handle null, undefined, or non-object values first if (!schema || typeof schema !== 'object') { return schema; } + const rootSchema = root ?? schema; + // If no definitions provided, try to extract from schema.$defs or schema.definitions if (!definitions) { definitions = (schema.$defs || schema.definitions) as Record; @@ -196,7 +231,9 @@ export function resolveJsonSchemaRefs>( // Handle arrays if (Array.isArray(schema)) { - return schema.map((item) => resolveJsonSchemaRefs(item, definitions, visited)) as unknown as T; + return schema.map((item) => + resolveJsonSchemaRefs(item, definitions, visited, rootSchema), + ) as unknown as T; } // Handle objects @@ -219,7 +256,12 @@ export function resolveJsonSchemaRefs>( // Extract the reference path const refPath = value.replace(/^#\/(\$defs|definitions)\//, ''); - const resolved = definitions?.[refPath]; + let resolved = definitions?.[refPath]; + + // Fall back to resolving any other local pointer against the root schema + if (!resolved && value.startsWith('#/')) { + resolved = resolveLocalPointer(rootSchema, value); + } if (resolved) { visited.add(value); @@ -227,6 +269,7 @@ export function resolveJsonSchemaRefs>( resolved as Record, definitions, visited, + rootSchema, ); visited.delete(value); @@ -238,7 +281,12 @@ export function resolveJsonSchemaRefs>( } } else if (value && typeof value === 'object') { // Recursively resolve nested objects/arrays - result[key] = resolveJsonSchemaRefs(value as Record, definitions, visited); + result[key] = resolveJsonSchemaRefs( + value as Record, + definitions, + visited, + rootSchema, + ); } else { // Copy primitive values as is result[key] = value; @@ -367,6 +415,7 @@ function mergeRequired(a: unknown, b: unknown): string[] | undefined { const GEMINI_UNSUPPORTED_KEYS = new Set([ 'additionalProperties', '$schema', + '$ref', '$id', 'id', '$comment', diff --git a/packages/api/src/tools/classification.spec.ts b/packages/api/src/tools/classification.spec.ts index 66cdfd4611..b543d2bad1 100644 --- a/packages/api/src/tools/classification.spec.ts +++ b/packages/api/src/tools/classification.spec.ts @@ -1,3 +1,4 @@ +import { Providers } from '@librechat/agents'; import type { AgentToolOptions } from 'librechat-data-provider'; import type { GenericTool } from '@librechat/agents'; import type { LCToolRegistry } from './classification'; @@ -338,6 +339,77 @@ describe('classification.ts', () => { expect(result.toolRegistry?.has('tool_search')).toBe(true); }); + type ToolSearchParams = { + properties?: Record; + }; + + it('should sanitize the tool_search schema for Google providers (no union types)', async () => { + const loadedTools: GenericTool[] = [createMCPTool('tool1')]; + + const agentToolOptions: AgentToolOptions = { + tool1: { defer_loading: true }, + }; + + const result = await buildToolClassification({ + loadedTools, + userId: 'user1', + agentId: 'agent1', + agentToolOptions, + deferredToolsEnabled: true, + definitionsOnly: true, + provider: Providers.GOOGLE, + }); + + const toolSearchDef = result.toolDefinitions.find((d) => d.name === 'tool_search'); + const params = toolSearchDef?.parameters as ToolSearchParams | undefined; + expect(params?.properties?.mcp_server?.oneOf).toBeUndefined(); + expect(params?.properties?.mcp_server?.type).toBe('string'); + }); + + it('should sanitize the tool_search instance schema for Vertex AI provider', async () => { + const loadedTools: GenericTool[] = [createMCPTool('tool1')]; + + const agentToolOptions: AgentToolOptions = { + tool1: { defer_loading: true }, + }; + + const result = await buildToolClassification({ + loadedTools, + userId: 'user1', + agentId: 'agent1', + agentToolOptions, + deferredToolsEnabled: true, + provider: Providers.VERTEXAI, + }); + + const toolSearchTool = result.additionalTools.find((t) => t.name === 'tool_search'); + const schema = (toolSearchTool as unknown as { schema: ToolSearchParams }).schema; + expect(schema.properties?.mcp_server?.oneOf).toBeUndefined(); + expect(schema.properties?.mcp_server?.type).toBe('string'); + }); + + it('should keep the original tool_search schema for non-Google providers', async () => { + const loadedTools: GenericTool[] = [createMCPTool('tool1')]; + + const agentToolOptions: AgentToolOptions = { + tool1: { defer_loading: true }, + }; + + const result = await buildToolClassification({ + loadedTools, + userId: 'user1', + agentId: 'agent1', + agentToolOptions, + deferredToolsEnabled: true, + definitionsOnly: true, + provider: Providers.OPENAI, + }); + + const toolSearchDef = result.toolDefinitions.find((d) => d.name === 'tool_search'); + const params = toolSearchDef?.parameters as ToolSearchParams | undefined; + expect(Array.isArray(params?.properties?.mcp_server?.oneOf)).toBe(true); + }); + it('should add PTC definition when definitionsOnly=true and capabilities allow programmatic tools', async () => { const loadedTools: GenericTool[] = [createMCPTool('tool1')]; diff --git a/packages/api/src/tools/classification.ts b/packages/api/src/tools/classification.ts index eaf4ff0953..b461c85990 100644 --- a/packages/api/src/tools/classification.ts +++ b/packages/api/src/tools/classification.ts @@ -8,12 +8,12 @@ import { logger } from '@librechat/data-schemas'; import { Constants } from 'librechat-data-provider'; import { + Providers, createToolSearch, ToolSearchToolDefinition, BashProgrammaticToolCallingDefinition, createBashProgrammaticToolCallingTool, } from '@librechat/agents'; -import type { AgentToolOptions } from 'librechat-data-provider'; import type { LCToolRegistry, JsonSchemaType, @@ -21,6 +21,8 @@ import type { GenericTool, LCTool, } from '@librechat/agents'; +import type { AgentToolOptions } from 'librechat-data-provider'; +import { sanitizeGeminiSchema } from '~/mcp/zod'; export type { LCTool, LCToolRegistry, AllowedCaller, JsonSchemaType }; @@ -191,6 +193,8 @@ export interface BuildToolClassificationParams { codeExecutionEnabled?: boolean; /** When true, skip creating tool instances (for event-driven mode) */ definitionsOnly?: boolean; + /** Agent provider — Gemini/Vertex rejects union types, so injected tool schemas get sanitized */ + provider?: Providers | string; /** Optional host-supplied Code API auth headers for remote programmatic execution. */ authHeaders?: () => Promise> | Record; } @@ -253,6 +257,7 @@ export async function buildToolClassification( ): Promise { const { agentId, + provider, loadedTools, agentToolOptions, definitionsOnly = false, @@ -261,6 +266,7 @@ export async function buildToolClassification( codeExecutionEnabled = false, authHeaders, } = params; + const isGoogle = provider === Providers.GOOGLE || provider === Providers.VERTEXAI; const additionalTools: GenericTool[] = []; const mcpTools = loadedTools.filter(isMCPTool); @@ -311,11 +317,22 @@ export async function buildToolClassification( /** Tool search uses local mode (no API key needed) */ if (hasDeferredTools) { + /** + * The ToolSearch schema declares `mcp_server` as a string/array union, which + * `zod_to_gemini_parameters` rejects — collapse it for Gemini/Vertex agents. + */ + const toolSearchParameters = (isGoogle + ? sanitizeGeminiSchema(ToolSearchToolDefinition.schema as Record) + : ToolSearchToolDefinition.schema) as unknown as LCTool['parameters']; + if (!definitionsOnly) { const toolSearchTool = createToolSearch({ mode: 'local', toolRegistry, }); + if (isGoogle) { + toolSearchTool.schema = toolSearchParameters as typeof toolSearchTool.schema; + } additionalTools.push(toolSearchTool); } @@ -323,7 +340,7 @@ export async function buildToolClassification( toolDefinitions.push({ name: ToolSearchToolDefinition.name, description: ToolSearchToolDefinition.description, - parameters: ToolSearchToolDefinition.schema as unknown as LCTool['parameters'], + parameters: toolSearchParameters, }); toolRegistry.set(ToolSearchToolDefinition.name, { name: ToolSearchToolDefinition.name, diff --git a/packages/api/src/tools/definitions.ts b/packages/api/src/tools/definitions.ts index 4babe3ed81..16d689807b 100644 --- a/packages/api/src/tools/definitions.ts +++ b/packages/api/src/tools/definitions.ts @@ -212,6 +212,7 @@ export async function loadToolDefinitions( const classificationResult = await buildToolClassification({ userId, agentId, + provider, loadedTools, deferredToolsEnabled, programmaticToolsEnabled,