mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🔳 fix: Bare Object MCP Tool Schemas as Passthrough (#8637)
* 🔳 fix: Bare Object MCP Tool Schemas as Passthrough
* ci: Add cases for handling complex object schemas in convertJsonSchemaToZod
This commit is contained in:
parent
365e3bca95
commit
0aafdc0a86
2 changed files with 344 additions and 1 deletions
|
|
@ -1485,4 +1485,334 @@ describe('convertJsonSchemaToZod', () => {
|
|||
expect(resolvedKeywords.additionalProperties).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bare object schema handling for dynamic properties', () => {
|
||||
it('should handle object type without explicit properties but expecting dynamic field definitions', () => {
|
||||
// This simulates the Kintone add_fields tool schema
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
app_id: {
|
||||
type: 'number',
|
||||
description: 'アプリID',
|
||||
},
|
||||
properties: {
|
||||
type: 'object',
|
||||
description: 'フィールドの設定(各フィールドには code, type, label の指定が必須)',
|
||||
},
|
||||
},
|
||||
required: ['app_id', 'properties'],
|
||||
};
|
||||
|
||||
const zodSchema = convertWithResolvedRefs(schema);
|
||||
|
||||
// Test case 1: Basic field definition
|
||||
const testData1 = {
|
||||
app_id: 810,
|
||||
properties: {
|
||||
minutes_id: {
|
||||
code: 'minutes_id',
|
||||
type: 'SINGLE_LINE_TEXT',
|
||||
label: 'minutes_id',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// WITH THE FIX: Bare object schemas now act as passthrough
|
||||
const result = zodSchema?.parse(testData1);
|
||||
expect(result).toEqual(testData1); // Properties pass through!
|
||||
});
|
||||
|
||||
it('should work when properties field has additionalProperties true', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
app_id: {
|
||||
type: 'number',
|
||||
description: 'アプリID',
|
||||
},
|
||||
properties: {
|
||||
type: 'object',
|
||||
description: 'フィールドの設定(各フィールドには code, type, label の指定が必須)',
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
required: ['app_id', 'properties'],
|
||||
};
|
||||
|
||||
const zodSchema = convertWithResolvedRefs(schema);
|
||||
|
||||
const testData = {
|
||||
app_id: 810,
|
||||
properties: {
|
||||
minutes_id: {
|
||||
code: 'minutes_id',
|
||||
type: 'SINGLE_LINE_TEXT',
|
||||
label: 'minutes_id',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = zodSchema?.parse(testData);
|
||||
expect(result).toEqual(testData);
|
||||
expect(result?.properties?.minutes_id).toBeDefined();
|
||||
});
|
||||
|
||||
it('should work with proper field type definitions in additionalProperties', () => {
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
app_id: {
|
||||
type: 'number',
|
||||
description: 'アプリID',
|
||||
},
|
||||
properties: {
|
||||
type: 'object',
|
||||
description: 'フィールドの設定(各フィールドには code, type, label の指定が必須)',
|
||||
additionalProperties: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
code: { type: 'string' },
|
||||
label: { type: 'string' },
|
||||
required: { type: 'boolean' },
|
||||
options: {
|
||||
type: 'object',
|
||||
additionalProperties: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
label: { type: 'string' },
|
||||
index: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['type', 'code', 'label'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['app_id', 'properties'],
|
||||
};
|
||||
|
||||
const zodSchema = convertWithResolvedRefs(schema);
|
||||
|
||||
// Test case 1: Simple text field
|
||||
const testData1 = {
|
||||
app_id: 810,
|
||||
properties: {
|
||||
minutes_id: {
|
||||
code: 'minutes_id',
|
||||
type: 'SINGLE_LINE_TEXT',
|
||||
label: 'minutes_id',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result1 = zodSchema?.parse(testData1);
|
||||
expect(result1).toEqual(testData1);
|
||||
|
||||
// Test case 2: Dropdown field with options
|
||||
const testData2 = {
|
||||
app_id: 820,
|
||||
properties: {
|
||||
status: {
|
||||
type: 'DROP_DOWN',
|
||||
code: 'status',
|
||||
label: 'Status',
|
||||
options: {
|
||||
'Not Started': {
|
||||
label: 'Not Started',
|
||||
index: '0',
|
||||
},
|
||||
'In Progress': {
|
||||
label: 'In Progress',
|
||||
index: '1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result2 = zodSchema?.parse(testData2);
|
||||
expect(result2).toEqual(testData2);
|
||||
|
||||
// Test case 3: Multiple fields
|
||||
const testData3 = {
|
||||
app_id: 123,
|
||||
properties: {
|
||||
number_field: {
|
||||
type: 'NUMBER',
|
||||
code: 'number_field',
|
||||
label: '数値フィールド',
|
||||
},
|
||||
text_field: {
|
||||
type: 'SINGLE_LINE_TEXT',
|
||||
code: 'text_field',
|
||||
label: 'テキストフィールド',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result3 = zodSchema?.parse(testData3);
|
||||
expect(result3).toEqual(testData3);
|
||||
});
|
||||
|
||||
it('should handle the actual reported failing case', () => {
|
||||
// This is the exact schema that's failing for the user
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
app_id: {
|
||||
type: 'number',
|
||||
description: 'アプリID',
|
||||
},
|
||||
properties: {
|
||||
type: 'object',
|
||||
description: 'フィールドの設定(各フィールドには code, type, label の指定が必須)',
|
||||
},
|
||||
},
|
||||
required: ['app_id', 'properties'],
|
||||
};
|
||||
|
||||
const zodSchema = convertWithResolvedRefs(schema);
|
||||
|
||||
// The exact data the user is trying to send
|
||||
const userData = {
|
||||
app_id: 810,
|
||||
properties: {
|
||||
minutes_id: {
|
||||
code: 'minutes_id',
|
||||
type: 'SINGLE_LINE_TEXT',
|
||||
label: 'minutes_id',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// WITH THE FIX: The properties now pass through correctly!
|
||||
const result = zodSchema?.parse(userData);
|
||||
expect(result).toEqual(userData);
|
||||
|
||||
// This fixes the error "properties requires at least one field definition"
|
||||
// The MCP server now receives the full properties object
|
||||
});
|
||||
|
||||
it('should demonstrate fix by treating bare object type as passthrough', () => {
|
||||
// Test what happens if we modify the conversion to treat bare object types
|
||||
// without properties as passthrough schemas
|
||||
const schema: JsonSchemaType = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
app_id: {
|
||||
type: 'number',
|
||||
description: 'アプリID',
|
||||
},
|
||||
properties: {
|
||||
type: 'object',
|
||||
description: 'フィールドの設定(各フィールドには code, type, label の指定が必須)',
|
||||
},
|
||||
},
|
||||
required: ['app_id', 'properties'],
|
||||
};
|
||||
|
||||
// For now, we'll simulate the fix by adding additionalProperties
|
||||
const fixedSchema: JsonSchemaType = {
|
||||
...schema,
|
||||
properties: {
|
||||
...schema.properties,
|
||||
properties: {
|
||||
...(schema.properties!.properties as JsonSchemaType),
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const zodSchema = convertWithResolvedRefs(fixedSchema);
|
||||
|
||||
const userData = {
|
||||
app_id: 810,
|
||||
properties: {
|
||||
minutes_id: {
|
||||
code: 'minutes_id',
|
||||
type: 'SINGLE_LINE_TEXT',
|
||||
label: 'minutes_id',
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = zodSchema?.parse(userData);
|
||||
expect(result).toEqual(userData);
|
||||
});
|
||||
|
||||
it('should NOT treat object schemas with $ref or complex properties as bare objects', () => {
|
||||
// This test ensures our fix doesn't affect schemas with $ref or other complex structures
|
||||
const schemaWithRef = {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
data: {
|
||||
type: 'object' as const,
|
||||
// This has anyOf with $ref - should NOT be treated as a bare object
|
||||
anyOf: [{ $ref: '#/$defs/dataSchema' }, { type: 'null' as const }],
|
||||
},
|
||||
},
|
||||
$defs: {
|
||||
dataSchema: {
|
||||
type: 'object' as const,
|
||||
additionalProperties: {
|
||||
type: 'string' as const,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Convert without resolving refs
|
||||
const zodSchema = convertJsonSchemaToZod(schemaWithRef as any, {
|
||||
transformOneOfAnyOf: true,
|
||||
});
|
||||
|
||||
const testData = {
|
||||
data: {
|
||||
field1: 'value1',
|
||||
field2: 'value2',
|
||||
},
|
||||
};
|
||||
|
||||
// Without ref resolution, the data field should be stripped/empty
|
||||
const result = zodSchema?.parse(testData);
|
||||
expect(result?.data).toEqual({});
|
||||
});
|
||||
|
||||
it('should NOT treat object schemas with oneOf/anyOf as bare objects', () => {
|
||||
// Ensure schemas with oneOf/anyOf are not treated as bare objects
|
||||
const schemaWithOneOf = {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
config: {
|
||||
type: 'object' as const,
|
||||
// Empty properties but has oneOf - should NOT be passthrough
|
||||
oneOf: [
|
||||
{ properties: { type: { const: 'A' } } },
|
||||
{ properties: { type: { const: 'B' } } },
|
||||
],
|
||||
} as any,
|
||||
},
|
||||
};
|
||||
|
||||
const zodSchema = convertWithResolvedRefs(schemaWithOneOf as any, {
|
||||
transformOneOfAnyOf: true,
|
||||
});
|
||||
|
||||
const testData = {
|
||||
config: {
|
||||
randomField: 'should not pass through',
|
||||
},
|
||||
};
|
||||
|
||||
// The random field should be stripped because this isn't a bare object
|
||||
const result = zodSchema?.parse(testData);
|
||||
expect(result?.config).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -361,6 +361,18 @@ export function convertJsonSchemaToZod(
|
|||
const shape: Record<string, z.ZodType> = {};
|
||||
const properties = schema.properties ?? {};
|
||||
|
||||
/** Check if this is a bare object schema with no properties defined
|
||||
and no explicit additionalProperties setting */
|
||||
const isBareObjectSchema =
|
||||
Object.keys(properties).length === 0 &&
|
||||
schema.additionalProperties === undefined &&
|
||||
!schema.patternProperties &&
|
||||
!schema.propertyNames &&
|
||||
!schema.$ref &&
|
||||
!schema.allOf &&
|
||||
!schema.anyOf &&
|
||||
!schema.oneOf;
|
||||
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
// Handle nested oneOf/anyOf if transformOneOfAnyOf is enabled
|
||||
if (transformOneOfAnyOf) {
|
||||
|
|
@ -436,8 +448,9 @@ export function convertJsonSchemaToZod(
|
|||
}
|
||||
|
||||
// Handle additionalProperties for open-ended objects
|
||||
if (schema.additionalProperties === true) {
|
||||
if (schema.additionalProperties === true || isBareObjectSchema) {
|
||||
// This allows any additional properties with any type
|
||||
// Bare object schemas are treated as passthrough to allow dynamic properties
|
||||
zodSchema = objectSchema.passthrough();
|
||||
} else if (typeof schema.additionalProperties === 'object') {
|
||||
// For specific additional property types
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue