🧰 fix: Convert const to enum in MCP Schemas for Gemini Compatibility (#11784)

* fix: Convert `const` to `enum` in MCP tool schemas for Gemini/Vertex AI compatibility

  Gemini/Vertex AI rejects the JSON Schema `const` keyword in function declarations
  with a 400 error. Previously, the Zod conversion layer accidentally stripped `const`,
  but after migrating to pass raw JSON schemas directly to providers, the unsupported
  keyword now reaches Gemini verbatim.

  Add `normalizeJsonSchema` to recursively convert `const: X` → `enum: [X]`, which is
  semantically equivalent per the JSON Schema spec and supported by all providers.

* fix: Update secure cookie handling in AuthService to use dynamic secure flag

Replaced the static `secure: isProduction` with a call to `shouldUseSecureCookie()` in the `setOpenIDAuthTokens` function. This change ensures that the secure cookie setting is evaluated at runtime, improving cookie handling in development environments while maintaining security in production.

* refactor: Simplify MCP tool key formatting and remove unused mocks in tests

- Updated MCP test suite to replace static tool key formatting with a dynamic delimiter from Constants, enhancing consistency and maintainability.
- Removed unused mock implementations for `@langchain/core/tools` and `@librechat/agents`, streamlining the test setup.
- Adjusted related test cases to reflect the new tool key format, ensuring all tests remain functional.

* chore: import order
This commit is contained in:
Danny Avila 2026-02-13 13:33:25 -05:00 committed by GitHub
parent 276ac8d011
commit ccbf9dc093
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 287 additions and 79 deletions

View file

@ -2,7 +2,12 @@
// zod.spec.ts
import { z } from 'zod';
import type { JsonSchemaType } from '@librechat/data-schemas';
import { resolveJsonSchemaRefs, convertJsonSchemaToZod, convertWithResolvedRefs } from '../zod';
import {
convertWithResolvedRefs,
convertJsonSchemaToZod,
resolveJsonSchemaRefs,
normalizeJsonSchema,
} from '../zod';
describe('convertJsonSchemaToZod', () => {
describe('integer type handling', () => {
@ -206,7 +211,7 @@ describe('convertJsonSchemaToZod', () => {
type: 'number' as const,
enum: [1, 2, 3, 5, 8, 13],
};
const zodSchema = convertWithResolvedRefs(schema as JsonSchemaType);
const zodSchema = convertWithResolvedRefs(schema as unknown as JsonSchemaType);
expect(zodSchema?.parse(1)).toBe(1);
expect(zodSchema?.parse(13)).toBe(13);
@ -2002,3 +2007,189 @@ describe('convertJsonSchemaToZod', () => {
});
});
});
describe('normalizeJsonSchema', () => {
it('should convert const to enum', () => {
const schema = { type: 'string', const: 'hello' } as any;
const result = normalizeJsonSchema(schema);
expect(result).toEqual({ type: 'string', enum: ['hello'] });
expect(result).not.toHaveProperty('const');
});
it('should preserve existing enum when const is also present', () => {
const schema = { type: 'string', const: 'hello', enum: ['hello', 'world'] } as any;
const result = normalizeJsonSchema(schema);
expect(result).toEqual({ type: 'string', enum: ['hello', 'world'] });
expect(result).not.toHaveProperty('const');
});
it('should handle non-string const values (number, boolean, null)', () => {
expect(normalizeJsonSchema({ type: 'number', const: 42 } as any)).toEqual({
type: 'number',
enum: [42],
});
expect(normalizeJsonSchema({ type: 'boolean', const: true } as any)).toEqual({
type: 'boolean',
enum: [true],
});
expect(normalizeJsonSchema({ type: 'string', const: null } as any)).toEqual({
type: 'string',
enum: [null],
});
});
it('should recursively normalize nested object properties', () => {
const schema = {
type: 'object',
properties: {
mode: { type: 'string', const: 'advanced' },
count: { type: 'number', const: 5 },
name: { type: 'string', description: 'A name' },
},
} as any;
const result = normalizeJsonSchema(schema);
expect(result.properties.mode).toEqual({ type: 'string', enum: ['advanced'] });
expect(result.properties.count).toEqual({ type: 'number', enum: [5] });
expect(result.properties.name).toEqual({ type: 'string', description: 'A name' });
});
it('should normalize inside oneOf/anyOf/allOf arrays', () => {
const schema = {
type: 'object',
oneOf: [
{ type: 'object', properties: { kind: { type: 'string', const: 'A' } } },
{ type: 'object', properties: { kind: { type: 'string', const: 'B' } } },
],
anyOf: [{ type: 'string', const: 'x' }],
allOf: [{ type: 'number', const: 1 }],
} as any;
const result = normalizeJsonSchema(schema);
expect(result.oneOf[0].properties.kind).toEqual({ type: 'string', enum: ['A'] });
expect(result.oneOf[1].properties.kind).toEqual({ type: 'string', enum: ['B'] });
expect(result.anyOf[0]).toEqual({ type: 'string', enum: ['x'] });
expect(result.allOf[0]).toEqual({ type: 'number', enum: [1] });
});
it('should normalize array items with const', () => {
const schema = {
type: 'array',
items: { type: 'string', const: 'fixed' },
} as any;
const result = normalizeJsonSchema(schema);
expect(result.items).toEqual({ type: 'string', enum: ['fixed'] });
});
it('should normalize additionalProperties with const', () => {
const schema = {
type: 'object',
additionalProperties: { type: 'string', const: 'val' },
} as any;
const result = normalizeJsonSchema(schema);
expect(result.additionalProperties).toEqual({ type: 'string', enum: ['val'] });
});
it('should handle null, undefined, and primitive inputs safely', () => {
expect(normalizeJsonSchema(null as any)).toBeNull();
expect(normalizeJsonSchema(undefined as any)).toBeUndefined();
expect(normalizeJsonSchema('string' as any)).toBe('string');
expect(normalizeJsonSchema(42 as any)).toBe(42);
expect(normalizeJsonSchema(true as any)).toBe(true);
});
it('should be a no-op when no const is present', () => {
const schema = {
type: 'object',
properties: {
name: { type: 'string', description: 'Name' },
age: { type: 'number' },
tags: { type: 'array', items: { type: 'string' } },
},
required: ['name'],
} as any;
const result = normalizeJsonSchema(schema);
expect(result).toEqual(schema);
});
it('should handle a Tavily-like schema pattern with const', () => {
const schema = {
type: 'object',
properties: {
query: {
type: 'string',
description: 'The search query',
},
search_depth: {
type: 'string',
const: 'advanced',
description: 'The depth of the search',
},
topic: {
type: 'string',
enum: ['general', 'news'],
description: 'The search topic',
},
include_answer: {
type: 'boolean',
const: true,
},
max_results: {
type: 'number',
const: 5,
},
},
required: ['query'],
} as any;
const result = normalizeJsonSchema(schema);
// const fields should be converted to enum
expect(result.properties.search_depth).toEqual({
type: 'string',
enum: ['advanced'],
description: 'The depth of the search',
});
expect(result.properties.include_answer).toEqual({
type: 'boolean',
enum: [true],
});
expect(result.properties.max_results).toEqual({
type: 'number',
enum: [5],
});
// Existing enum should be preserved
expect(result.properties.topic).toEqual({
type: 'string',
enum: ['general', 'news'],
description: 'The search topic',
});
// Non-const fields should be unchanged
expect(result.properties.query).toEqual({
type: 'string',
description: 'The search query',
});
// Top-level fields preserved
expect(result.required).toEqual(['query']);
expect(result.type).toBe('object');
});
it('should handle arrays at the top level', () => {
const schemas = [
{ type: 'string', const: 'a' },
{ type: 'number', const: 1 },
] as any;
const result = normalizeJsonSchema(schemas);
expect(result).toEqual([
{ type: 'string', enum: ['a'] },
{ type: 'number', enum: [1] },
]);
});
});

View file

@ -248,6 +248,65 @@ export function resolveJsonSchemaRefs<T extends Record<string, unknown>>(
return result as T;
}
/**
* Recursively normalizes a JSON schema by converting `const` values to `enum` arrays.
* Gemini/Vertex AI does not support the `const` keyword in function declarations,
* but `const: X` is semantically equivalent to `enum: [X]` per the JSON Schema spec.
*
* @param schema - The JSON schema to normalize
* @returns The normalized schema with `const` converted to `enum`
*/
export function normalizeJsonSchema<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' ? normalizeJsonSchema(item) : item,
) as unknown as T;
}
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(schema)) {
if (key === 'const' && !('enum' in schema)) {
result['enum'] = [value];
continue;
}
if (key === 'const' && 'enum' in schema) {
// Skip `const` when `enum` already exists
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'
? normalizeJsonSchema(propValue as Record<string, unknown>)
: propValue;
}
result[key] = newProps;
} else if (
(key === 'items' || key === 'additionalProperties') &&
value &&
typeof value === 'object'
) {
result[key] = normalizeJsonSchema(value as Record<string, unknown>);
} else if ((key === 'oneOf' || key === 'anyOf' || key === 'allOf') && Array.isArray(value)) {
result[key] = value.map((item) =>
item && typeof item === 'object' ? normalizeJsonSchema(item) : item,
);
} else {
result[key] = value;
}
}
return result as T;
}
/**
* Converts a JSON Schema to a Zod schema.
*

View file

@ -8,9 +8,10 @@
import { Constants, actionDelimiter } from 'librechat-data-provider';
import type { AgentToolOptions } from 'librechat-data-provider';
import type { LCToolRegistry, JsonSchemaType, LCTool, GenericTool } from '@librechat/agents';
import { buildToolClassification, type ToolDefinition } from './classification';
import type { ToolDefinition } from './classification';
import { resolveJsonSchemaRefs, normalizeJsonSchema } from '~/mcp/zod';
import { buildToolClassification } from './classification';
import { getToolDefinition } from './registry/definitions';
import { resolveJsonSchemaRefs } from '~/mcp/zod';
export interface MCPServerTool {
function?: {
@ -138,7 +139,7 @@ export async function loadToolDefinitions(
name: actualToolName,
description: toolDef.function.description,
parameters: toolDef.function.parameters
? resolveJsonSchemaRefs(toolDef.function.parameters)
? normalizeJsonSchema(resolveJsonSchemaRefs(toolDef.function.parameters))
: undefined,
serverName,
});
@ -153,7 +154,7 @@ export async function loadToolDefinitions(
name: toolName,
description: toolDef.function.description,
parameters: toolDef.function.parameters
? resolveJsonSchemaRefs(toolDef.function.parameters)
? normalizeJsonSchema(resolveJsonSchemaRefs(toolDef.function.parameters))
: undefined,
serverName,
});