fix: Resolve Local JSON Pointer Refs & Sanitize Tool Search Schema for Gemini (#14161)

*  fix: Resolve Local JSON Pointer Refs & Sanitize Tool Search Schema for Gemini

Gemini/Vertex agents fail when MCP tools are attached, in two ways:

1. MCP servers with OpenAPI-derived schemas can emit local JSON pointer
   refs (e.g. `#/properties/body/properties/start`). `resolveJsonSchemaRefs`
   only resolves `#/$defs` and `#/definitions` refs, so these survive into
   the request payload and Vertex rejects it with 400
   'Invalid JSON payload received. Unknown name "$ref"'. Resolve any local
   `#/...` pointer against the schema root (RFC 6901 unescaping, cycle
   protection), and strip still-unresolvable `$ref` keys in
   `sanitizeGeminiSchema` as a safety net.

2. When deferred tools are enabled, the injected `tool_search` schema
   declares `mcp_server` as `oneOf: [string, string[]]`, which
   `zod_to_gemini_parameters` rejects ('Gemini cannot handle union types').
   `buildToolClassification` now accepts the agent provider and collapses
   the union via `sanitizeGeminiSchema` for Google/Vertex only — the tool's
   runtime (`normalizeServerFilter`) already accepts both forms.

Complements #13623, which sanitizes MCP tool schemas but not the injected
tool_search definition, and did not cover non-$defs local pointers.
Underlying converter limitation: langchain-ai/langchainjs#9691.

* style: format tool search schema assignment

* style: sort tool classification imports

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Joseph Licata 2026-07-08 15:30:37 -04:00 committed by GitHub
parent 280da51004
commit fc67416d08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 254 additions and 6 deletions

View file

@ -1197,6 +1197,7 @@ async function loadAgentTools({
loadedTools,
userId: req.user.id,
agentId: agent.id,
provider: agent.provider,
agentToolOptions: agent.tool_options,
deferredToolsEnabled,
programmaticToolsEnabled,

View file

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

View file

@ -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<string, unknown>,
pointer: string,
): Record<string, unknown> | 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<string, unknown>)[segment];
}
if (node != null && typeof node === 'object' && !Array.isArray(node)) {
return node as Record<string, unknown>;
}
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<T extends Record<string, unknown>>(
schema: T,
definitions?: Record<string, unknown>,
visited: Set<string> = new Set<string>(),
root?: Record<string, unknown>,
): 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<string, unknown>;
@ -196,7 +231,9 @@ export function resolveJsonSchemaRefs<T extends Record<string, unknown>>(
// 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<T extends Record<string, unknown>>(
// 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<T extends Record<string, unknown>>(
resolved as Record<string, unknown>,
definitions,
visited,
rootSchema,
);
visited.delete(value);
@ -238,7 +281,12 @@ export function resolveJsonSchemaRefs<T extends Record<string, unknown>>(
}
} else if (value && typeof value === 'object') {
// Recursively resolve nested objects/arrays
result[key] = resolveJsonSchemaRefs(value as Record<string, unknown>, definitions, visited);
result[key] = resolveJsonSchemaRefs(
value as Record<string, unknown>,
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',

View file

@ -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<string, { oneOf?: unknown[]; type?: string }>;
};
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')];

View file

@ -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<string, string>> | Record<string, string>;
}
@ -253,6 +257,7 @@ export async function buildToolClassification(
): Promise<BuildToolClassificationResult> {
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<string, unknown>)
: 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,

View file

@ -212,6 +212,7 @@ export async function loadToolDefinitions(
const classificationResult = await buildToolClassification({
userId,
agentId,
provider,
loadedTools,
deferredToolsEnabled,
programmaticToolsEnabled,