🔳 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:
Danny Avila 2025-07-24 00:11:20 -04:00 committed by GitHub
parent 365e3bca95
commit 0aafdc0a86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 344 additions and 1 deletions

View file

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

View file

@ -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