mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 18:00: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);
|
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 shape: Record<string, z.ZodType> = {};
|
||||||
const properties = schema.properties ?? {};
|
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)) {
|
for (const [key, value] of Object.entries(properties)) {
|
||||||
// Handle nested oneOf/anyOf if transformOneOfAnyOf is enabled
|
// Handle nested oneOf/anyOf if transformOneOfAnyOf is enabled
|
||||||
if (transformOneOfAnyOf) {
|
if (transformOneOfAnyOf) {
|
||||||
|
|
@ -436,8 +448,9 @@ export function convertJsonSchemaToZod(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle additionalProperties for open-ended objects
|
// Handle additionalProperties for open-ended objects
|
||||||
if (schema.additionalProperties === true) {
|
if (schema.additionalProperties === true || isBareObjectSchema) {
|
||||||
// This allows any additional properties with any type
|
// This allows any additional properties with any type
|
||||||
|
// Bare object schemas are treated as passthrough to allow dynamic properties
|
||||||
zodSchema = objectSchema.passthrough();
|
zodSchema = objectSchema.passthrough();
|
||||||
} else if (typeof schema.additionalProperties === 'object') {
|
} else if (typeof schema.additionalProperties === 'object') {
|
||||||
// For specific additional property types
|
// For specific additional property types
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue