diff --git a/packages/api/src/mcp/zod.spec.ts b/packages/api/src/mcp/zod.spec.ts index c1ab8c902f..7eb9c162a5 100644 --- a/packages/api/src/mcp/zod.spec.ts +++ b/packages/api/src/mcp/zod.spec.ts @@ -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({}); + }); + }); }); diff --git a/packages/api/src/mcp/zod.ts b/packages/api/src/mcp/zod.ts index bd0a6e1f1b..cff63cd0ae 100644 --- a/packages/api/src/mcp/zod.ts +++ b/packages/api/src/mcp/zod.ts @@ -361,6 +361,18 @@ export function convertJsonSchemaToZod( const shape: Record = {}; 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