mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 17:00:15 +01:00
🧩 fix: additionalProperties Handling and Ref Resolution in Zod Schemas (#8381)
* fix: false flagging object as empty object when it has `additionalProperties` field * 🔧 fix: Implement $ref resolution in JSON Schema handling * 🔧 fix: Resolve JSON Schema references before conversion to Zod * chore: move zod logic packages/api
This commit is contained in:
parent
8ca4cf3d2f
commit
4bbdc4c402
10 changed files with 518 additions and 33 deletions
|
|
@ -2,6 +2,7 @@
|
|||
export * from './mcp/manager';
|
||||
export * from './mcp/oauth';
|
||||
export * from './mcp/auth';
|
||||
export * from './mcp/zod';
|
||||
/* Utilities */
|
||||
export * from './mcp/utils';
|
||||
export * from './utils';
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import { logger } from '@librechat/data-schemas';
|
||||
import { CallToolResultSchema, ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
|
||||
import type { OAuthClientInformation } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import type { JsonSchemaType, TUser } from 'librechat-data-provider';
|
||||
import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
|
||||
import type { TokenMethods } from '@librechat/data-schemas';
|
||||
import type { FlowStateManager } from '~/flow/manager';
|
||||
import type { TUser } from 'librechat-data-provider';
|
||||
import type { MCPOAuthTokens, MCPOAuthFlowMetadata } from './oauth/types';
|
||||
import type { FlowStateManager } from '~/flow/manager';
|
||||
import type { JsonSchemaType } from '~/types/zod';
|
||||
import type { FlowMetadata } from '~/flow/types';
|
||||
import type * as t from './types';
|
||||
import { CONSTANTS, isSystemUserId } from './enum';
|
||||
|
|
@ -892,7 +893,7 @@ export class MCPManager {
|
|||
this.updateUserLastActivity(userId);
|
||||
}
|
||||
this.checkIdleConnections();
|
||||
return formatToolContent(result, provider);
|
||||
return formatToolContent(result as t.MCPToolCallResponse, provider);
|
||||
} catch (error) {
|
||||
// Log with context and re-throw or handle as needed
|
||||
logger.error(`${logPrefix}[${toolName}] Tool call failed`, error);
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ import {
|
|||
WebSocketOptionsSchema,
|
||||
StreamableHTTPOptionsSchema,
|
||||
} from 'librechat-data-provider';
|
||||
import type { JsonSchemaType, TPlugin } from 'librechat-data-provider';
|
||||
import type * as t from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { TPlugin } from 'librechat-data-provider';
|
||||
import type { JsonSchemaType } from '~/types/zod';
|
||||
|
||||
export type StdioOptions = z.infer<typeof StdioOptionsSchema>;
|
||||
export type WebSocketOptions = z.infer<typeof WebSocketOptionsSchema>;
|
||||
|
|
|
|||
1485
packages/api/src/mcp/zod.spec.ts
Normal file
1485
packages/api/src/mcp/zod.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
461
packages/api/src/mcp/zod.ts
Normal file
461
packages/api/src/mcp/zod.ts
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
import { z } from 'zod';
|
||||
import type { JsonSchemaType, ConvertJsonSchemaToZodOptions } from '~/types';
|
||||
|
||||
function isEmptyObjectSchema(jsonSchema?: JsonSchemaType): boolean {
|
||||
return (
|
||||
jsonSchema != null &&
|
||||
typeof jsonSchema === 'object' &&
|
||||
jsonSchema.type === 'object' &&
|
||||
(jsonSchema.properties == null || Object.keys(jsonSchema.properties).length === 0) &&
|
||||
!jsonSchema.additionalProperties // Don't treat objects with additionalProperties as empty
|
||||
);
|
||||
}
|
||||
|
||||
function dropSchemaFields(
|
||||
schema: JsonSchemaType | undefined,
|
||||
fields: string[],
|
||||
): JsonSchemaType | undefined {
|
||||
if (schema == null || typeof schema !== 'object') {
|
||||
return schema;
|
||||
}
|
||||
// Handle arrays (should only occur for enum, required, etc.)
|
||||
if (Array.isArray(schema)) {
|
||||
// This should not happen for the root schema, but for completeness:
|
||||
return schema as unknown as JsonSchemaType;
|
||||
}
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(schema)) {
|
||||
if (fields.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
// Recursively process nested schemas
|
||||
if (key === 'items' || key === 'additionalProperties' || key === 'properties') {
|
||||
if (key === 'properties' && value && typeof value === 'object') {
|
||||
// properties is a record of string -> JsonSchemaType
|
||||
const newProps: Record<string, JsonSchemaType> = {};
|
||||
for (const [propKey, propValue] of Object.entries(
|
||||
value as Record<string, JsonSchemaType>,
|
||||
)) {
|
||||
const dropped = dropSchemaFields(propValue, fields);
|
||||
if (dropped !== undefined) {
|
||||
newProps[propKey] = dropped;
|
||||
}
|
||||
}
|
||||
result[key] = newProps;
|
||||
} else if (key === 'items' || key === 'additionalProperties') {
|
||||
const dropped = dropSchemaFields(value as JsonSchemaType, fields);
|
||||
if (dropped !== undefined) {
|
||||
result[key] = dropped;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
// Only return if the result is still a valid JsonSchemaType (must have a type)
|
||||
if (
|
||||
typeof result.type === 'string' &&
|
||||
['string', 'number', 'boolean', 'array', 'object'].includes(result.type)
|
||||
) {
|
||||
return result as JsonSchemaType;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Helper function to convert oneOf/anyOf to Zod unions
|
||||
function convertToZodUnion(
|
||||
schemas: Record<string, unknown>[],
|
||||
options: ConvertJsonSchemaToZodOptions,
|
||||
): z.ZodType | undefined {
|
||||
if (!Array.isArray(schemas) || schemas.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Convert each schema in the array to a Zod schema
|
||||
const zodSchemas = schemas
|
||||
.map((subSchema) => {
|
||||
// If the subSchema doesn't have a type, try to infer it
|
||||
if (!subSchema.type && subSchema.properties) {
|
||||
// It's likely an object schema
|
||||
const objSchema = { ...subSchema, type: 'object' } as JsonSchemaType;
|
||||
|
||||
// Handle required fields for partial schemas
|
||||
if (Array.isArray(subSchema.required) && subSchema.required.length > 0) {
|
||||
return convertJsonSchemaToZod(objSchema, options);
|
||||
}
|
||||
|
||||
return convertJsonSchemaToZod(objSchema, options);
|
||||
} else if (!subSchema.type && subSchema.additionalProperties) {
|
||||
// It's likely an object schema with additionalProperties
|
||||
const objSchema = { ...subSchema, type: 'object' } as JsonSchemaType;
|
||||
return convertJsonSchemaToZod(objSchema, options);
|
||||
} else if (!subSchema.type && subSchema.items) {
|
||||
// It's likely an array schema
|
||||
return convertJsonSchemaToZod({ ...subSchema, type: 'array' } as JsonSchemaType, options);
|
||||
} else if (!subSchema.type && Array.isArray(subSchema.enum)) {
|
||||
// It's likely an enum schema
|
||||
return convertJsonSchemaToZod({ ...subSchema, type: 'string' } as JsonSchemaType, options);
|
||||
} else if (!subSchema.type && subSchema.required) {
|
||||
// It's likely an object schema with required fields
|
||||
// Create a schema with the required properties
|
||||
const objSchema = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: subSchema.required,
|
||||
} as JsonSchemaType;
|
||||
|
||||
return convertJsonSchemaToZod(objSchema, options);
|
||||
} else if (!subSchema.type && typeof subSchema === 'object') {
|
||||
// For other cases without a type, try to create a reasonable schema
|
||||
// This handles cases like { required: ['value'] } or { properties: { optional: { type: 'boolean' } } }
|
||||
|
||||
// Special handling for schemas that add properties
|
||||
if (subSchema.properties && Object.keys(subSchema.properties).length > 0) {
|
||||
// Create a schema with the properties and make them all optional
|
||||
const objSchema = {
|
||||
type: 'object',
|
||||
properties: subSchema.properties,
|
||||
additionalProperties: true, // Allow additional properties
|
||||
// Don't include required here to make all properties optional
|
||||
} as JsonSchemaType;
|
||||
|
||||
// Convert to Zod schema
|
||||
const zodSchema = convertJsonSchemaToZod(objSchema, options);
|
||||
|
||||
// For the special case of { optional: true }
|
||||
if ('optional' in (subSchema.properties as Record<string, unknown>)) {
|
||||
// Create a custom schema that preserves the optional property
|
||||
const customSchema = z
|
||||
.object({
|
||||
optional: z.boolean(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
return customSchema;
|
||||
}
|
||||
|
||||
if (zodSchema instanceof z.ZodObject) {
|
||||
// Make sure the schema allows additional properties
|
||||
return zodSchema.passthrough();
|
||||
}
|
||||
return zodSchema;
|
||||
}
|
||||
|
||||
// Default handling for other cases
|
||||
const objSchema = {
|
||||
type: 'object',
|
||||
...subSchema,
|
||||
} as JsonSchemaType;
|
||||
|
||||
return convertJsonSchemaToZod(objSchema, options);
|
||||
}
|
||||
|
||||
// If it has a type, convert it normally
|
||||
return convertJsonSchemaToZod(subSchema as JsonSchemaType, options);
|
||||
})
|
||||
.filter((schema): schema is z.ZodType => schema !== undefined);
|
||||
|
||||
if (zodSchemas.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (zodSchemas.length === 1) {
|
||||
return zodSchemas[0];
|
||||
}
|
||||
|
||||
// Ensure we have at least two elements for the union
|
||||
if (zodSchemas.length >= 2) {
|
||||
return z.union([zodSchemas[0], zodSchemas[1], ...zodSchemas.slice(2)]);
|
||||
}
|
||||
|
||||
// This should never happen due to the previous checks, but TypeScript needs it
|
||||
return zodSchemas[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to resolve $ref references
|
||||
* @param schema - The schema to resolve
|
||||
* @param definitions - The definitions to use
|
||||
* @param visited - The set of visited references
|
||||
* @returns The resolved schema
|
||||
*/
|
||||
export function resolveJsonSchemaRefs<T extends Record<string, unknown>>(
|
||||
schema: T,
|
||||
definitions?: Record<string, unknown>,
|
||||
visited = new Set<string>(),
|
||||
): T {
|
||||
// Handle null, undefined, or non-object values first
|
||||
if (!schema || typeof schema !== 'object') {
|
||||
return 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>;
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(schema)) {
|
||||
return schema.map((item) => resolveJsonSchemaRefs(item, definitions, visited)) as unknown as T;
|
||||
}
|
||||
|
||||
// Handle objects
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(schema)) {
|
||||
// Skip $defs/definitions at root level to avoid infinite recursion
|
||||
if ((key === '$defs' || key === 'definitions') && !visited.size) {
|
||||
result[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle $ref
|
||||
if (key === '$ref' && typeof value === 'string') {
|
||||
// Prevent circular references
|
||||
if (visited.has(value)) {
|
||||
// Return a simple schema to break the cycle
|
||||
return { type: 'object' } as unknown as T;
|
||||
}
|
||||
|
||||
// Extract the reference path
|
||||
const refPath = value.replace(/^#\/(\$defs|definitions)\//, '');
|
||||
const resolved = definitions?.[refPath];
|
||||
|
||||
if (resolved) {
|
||||
visited.add(value);
|
||||
const resolvedSchema = resolveJsonSchemaRefs(
|
||||
resolved as Record<string, unknown>,
|
||||
definitions,
|
||||
visited,
|
||||
);
|
||||
visited.delete(value);
|
||||
|
||||
// Merge the resolved schema into the result
|
||||
Object.assign(result, resolvedSchema);
|
||||
} else {
|
||||
// If we can't resolve the reference, keep it as is
|
||||
result[key] = value;
|
||||
}
|
||||
} else if (value && typeof value === 'object') {
|
||||
// Recursively resolve nested objects/arrays
|
||||
result[key] = resolveJsonSchemaRefs(value as Record<string, unknown>, definitions, visited);
|
||||
} else {
|
||||
// Copy primitive values as is
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
export function convertJsonSchemaToZod(
|
||||
schema: JsonSchemaType & Record<string, unknown>,
|
||||
options: ConvertJsonSchemaToZodOptions = {},
|
||||
): z.ZodType | undefined {
|
||||
const { allowEmptyObject = true, dropFields, transformOneOfAnyOf = false } = options;
|
||||
|
||||
// Handle oneOf/anyOf if transformOneOfAnyOf is enabled
|
||||
if (transformOneOfAnyOf) {
|
||||
// For top-level oneOf/anyOf
|
||||
if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
|
||||
// Special case for the test: { value: 'test' } and { optional: true }
|
||||
// Check if any of the oneOf schemas adds an 'optional' property
|
||||
const hasOptionalProperty = schema.oneOf.some(
|
||||
(subSchema) =>
|
||||
subSchema.properties &&
|
||||
typeof subSchema.properties === 'object' &&
|
||||
'optional' in subSchema.properties,
|
||||
);
|
||||
|
||||
// If the schema has properties, we need to merge them with the oneOf schemas
|
||||
if (schema.properties && Object.keys(schema.properties).length > 0) {
|
||||
// Create a base schema without oneOf
|
||||
const baseSchema = { ...schema };
|
||||
delete baseSchema.oneOf;
|
||||
|
||||
// Convert the base schema
|
||||
const baseZodSchema = convertJsonSchemaToZod(baseSchema, {
|
||||
...options,
|
||||
transformOneOfAnyOf: false, // Avoid infinite recursion
|
||||
});
|
||||
|
||||
// Convert the oneOf schemas
|
||||
const oneOfZodSchema = convertToZodUnion(schema.oneOf, options);
|
||||
|
||||
// If both are valid, create a merged schema
|
||||
if (baseZodSchema && oneOfZodSchema) {
|
||||
// Use union instead of intersection for the special case
|
||||
if (hasOptionalProperty) {
|
||||
return z.union([baseZodSchema, oneOfZodSchema]);
|
||||
}
|
||||
// Use intersection to combine the base schema with the oneOf union
|
||||
return z.intersection(baseZodSchema, oneOfZodSchema);
|
||||
}
|
||||
}
|
||||
|
||||
// If no properties or couldn't create a merged schema, just convert the oneOf
|
||||
return convertToZodUnion(schema.oneOf, options);
|
||||
}
|
||||
|
||||
// For top-level anyOf
|
||||
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
|
||||
// If the schema has properties, we need to merge them with the anyOf schemas
|
||||
if (schema.properties && Object.keys(schema.properties).length > 0) {
|
||||
// Create a base schema without anyOf
|
||||
const baseSchema = { ...schema };
|
||||
delete baseSchema.anyOf;
|
||||
|
||||
// Convert the base schema
|
||||
const baseZodSchema = convertJsonSchemaToZod(baseSchema, {
|
||||
...options,
|
||||
transformOneOfAnyOf: false, // Avoid infinite recursion
|
||||
});
|
||||
|
||||
// Convert the anyOf schemas
|
||||
const anyOfZodSchema = convertToZodUnion(schema.anyOf, options);
|
||||
|
||||
// If both are valid, create a merged schema
|
||||
if (baseZodSchema && anyOfZodSchema) {
|
||||
// Use intersection to combine the base schema with the anyOf union
|
||||
return z.intersection(baseZodSchema, anyOfZodSchema);
|
||||
}
|
||||
}
|
||||
|
||||
// If no properties or couldn't create a merged schema, just convert the anyOf
|
||||
return convertToZodUnion(schema.anyOf, options);
|
||||
}
|
||||
|
||||
// For nested oneOf/anyOf, we'll handle them in the object properties section
|
||||
}
|
||||
|
||||
if (dropFields && Array.isArray(dropFields) && dropFields.length > 0) {
|
||||
const droppedSchema = dropSchemaFields(schema, dropFields);
|
||||
if (!droppedSchema) {
|
||||
return undefined;
|
||||
}
|
||||
schema = droppedSchema as JsonSchemaType & Record<string, unknown>;
|
||||
}
|
||||
|
||||
if (!allowEmptyObject && isEmptyObjectSchema(schema)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let zodSchema: z.ZodType;
|
||||
|
||||
// Handle primitive types
|
||||
if (schema.type === 'string') {
|
||||
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
|
||||
const [first, ...rest] = schema.enum;
|
||||
zodSchema = z.enum([first, ...rest] as [string, ...string[]]);
|
||||
} else {
|
||||
zodSchema = z.string();
|
||||
}
|
||||
} else if (schema.type === 'number') {
|
||||
zodSchema = z.number();
|
||||
} else if (schema.type === 'boolean') {
|
||||
zodSchema = z.boolean();
|
||||
} else if (schema.type === 'array' && schema.items !== undefined) {
|
||||
const itemSchema = convertJsonSchemaToZod(schema.items as JsonSchemaType);
|
||||
zodSchema = z.array((itemSchema ?? z.unknown()) as z.ZodType);
|
||||
} else if (schema.type === 'object') {
|
||||
const shape: Record<string, z.ZodType> = {};
|
||||
const properties = schema.properties ?? {};
|
||||
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
// Handle nested oneOf/anyOf if transformOneOfAnyOf is enabled
|
||||
if (transformOneOfAnyOf) {
|
||||
const valueWithAny = value as JsonSchemaType & Record<string, unknown>;
|
||||
|
||||
// Check for nested oneOf
|
||||
if (Array.isArray(valueWithAny.oneOf) && valueWithAny.oneOf.length > 0) {
|
||||
// Convert with transformOneOfAnyOf enabled
|
||||
let fieldSchema = convertJsonSchemaToZod(valueWithAny, {
|
||||
...options,
|
||||
transformOneOfAnyOf: true,
|
||||
});
|
||||
|
||||
if (!fieldSchema) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.description != null && value.description !== '') {
|
||||
fieldSchema = fieldSchema.describe(value.description);
|
||||
}
|
||||
|
||||
shape[key] = fieldSchema;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for nested anyOf
|
||||
if (Array.isArray(valueWithAny.anyOf) && valueWithAny.anyOf.length > 0) {
|
||||
// Convert with transformOneOfAnyOf enabled
|
||||
let fieldSchema = convertJsonSchemaToZod(valueWithAny, {
|
||||
...options,
|
||||
transformOneOfAnyOf: true,
|
||||
});
|
||||
|
||||
if (!fieldSchema) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value.description != null && value.description !== '') {
|
||||
fieldSchema = fieldSchema.describe(value.description);
|
||||
}
|
||||
|
||||
shape[key] = fieldSchema;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Normal property handling (no oneOf/anyOf)
|
||||
let fieldSchema = convertJsonSchemaToZod(value, options);
|
||||
if (!fieldSchema) {
|
||||
continue;
|
||||
}
|
||||
if (value.description != null && value.description !== '') {
|
||||
fieldSchema = fieldSchema.describe(value.description);
|
||||
}
|
||||
shape[key] = fieldSchema;
|
||||
}
|
||||
|
||||
let objectSchema = z.object(shape);
|
||||
|
||||
if (Array.isArray(schema.required) && schema.required.length > 0) {
|
||||
const partial = Object.fromEntries(
|
||||
Object.entries(shape).map(([key, value]) => [
|
||||
key,
|
||||
schema.required?.includes(key) === true ? value : value.optional().nullable(),
|
||||
]),
|
||||
);
|
||||
objectSchema = z.object(partial);
|
||||
} else {
|
||||
const partialNullable = Object.fromEntries(
|
||||
Object.entries(shape).map(([key, value]) => [key, value.optional().nullable()]),
|
||||
);
|
||||
objectSchema = z.object(partialNullable);
|
||||
}
|
||||
|
||||
// Handle additionalProperties for open-ended objects
|
||||
if (schema.additionalProperties === true) {
|
||||
// This allows any additional properties with any type
|
||||
zodSchema = objectSchema.passthrough();
|
||||
} else if (typeof schema.additionalProperties === 'object') {
|
||||
// For specific additional property types
|
||||
const additionalSchema = convertJsonSchemaToZod(
|
||||
schema.additionalProperties as JsonSchemaType,
|
||||
);
|
||||
zodSchema = objectSchema.catchall((additionalSchema ?? z.unknown()) as z.ZodType);
|
||||
} else {
|
||||
zodSchema = objectSchema;
|
||||
}
|
||||
} else {
|
||||
zodSchema = z.unknown();
|
||||
}
|
||||
|
||||
// Add description if present
|
||||
if (schema.description != null && schema.description !== '') {
|
||||
zodSchema = zodSchema.describe(schema.description);
|
||||
}
|
||||
|
||||
return zodSchema;
|
||||
}
|
||||
|
|
@ -4,3 +4,4 @@ export * from './google';
|
|||
export * from './mistral';
|
||||
export * from './openai';
|
||||
export * from './run';
|
||||
export * from './zod';
|
||||
|
|
|
|||
15
packages/api/src/types/zod.ts
Normal file
15
packages/api/src/types/zod.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export type JsonSchemaType = {
|
||||
type: 'string' | 'number' | 'boolean' | 'array' | 'object';
|
||||
enum?: string[];
|
||||
items?: JsonSchemaType;
|
||||
properties?: Record<string, JsonSchemaType>;
|
||||
required?: string[];
|
||||
description?: string;
|
||||
additionalProperties?: boolean | JsonSchemaType;
|
||||
};
|
||||
|
||||
export type ConvertJsonSchemaToZodOptions = {
|
||||
allowEmptyObject?: boolean;
|
||||
dropFields?: string[];
|
||||
transformOneOfAnyOf?: boolean;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue