diff --git a/src/openapi-mcp-server/openapi/__tests__/parser-multipart.test.ts b/src/openapi-mcp-server/openapi/__tests__/parser-multipart.test.ts index 63b29414..da071f27 100644 --- a/src/openapi-mcp-server/openapi/__tests__/parser-multipart.test.ts +++ b/src/openapi-mcp-server/openapi/__tests__/parser-multipart.test.ts @@ -154,26 +154,36 @@ describe('OpenAPI Multipart Form Parser', () => { type: 'integer', }, documents: { - type: 'array', - items: { - anyOf: [ - { type: 'string', format: 'uri-reference', description: 'absolute paths to local files' }, - { type: 'string' }, - { type: 'object', additionalProperties: true }, - ], - }, - description: expect.stringContaining('max 5 files'), + anyOf: [ + { + type: 'array', + items: { + anyOf: [ + { type: 'string', format: 'uri-reference', description: 'absolute paths to local files' }, + { type: 'string' }, + { type: 'object', additionalProperties: true }, + ], + }, + description: expect.stringContaining('max 5 files'), + }, + { type: 'string' }, + ], }, tags: { - type: 'array', - items: { - anyOf: [ - { type: 'string' }, - { type: 'string' }, - { type: 'object', additionalProperties: true }, - ], - }, - description: expect.stringContaining('Optional tags'), + anyOf: [ + { + type: 'array', + items: { + anyOf: [ + { type: 'string' }, + { type: 'string' }, + { type: 'object', additionalProperties: true }, + ], + }, + description: expect.stringContaining('Optional tags'), + }, + { type: 'string' }, + ], }, }) @@ -274,15 +284,20 @@ describe('OpenAPI Multipart Form Parser', () => { description: expect.stringContaining('Profile picture (absolute paths to local files)'), }, gallery: { - type: 'array', - items: { - anyOf: [ - { type: 'string', format: 'uri-reference', description: 'absolute paths to local files' }, - { type: 'string' }, - { type: 'object', additionalProperties: true }, - ], - }, - description: expect.stringContaining('Additional pet photos'), + anyOf: [ + { + type: 'array', + items: { + anyOf: [ + { type: 'string', format: 'uri-reference', description: 'absolute paths to local files' }, + { type: 'string' }, + { type: 'object', additionalProperties: true }, + ], + }, + description: expect.stringContaining('Additional pet photos'), + }, + { type: 'string' }, + ], }, details: { anyOf: [ @@ -299,21 +314,26 @@ describe('OpenAPI Multipart Form Parser', () => { ], }, preferences: { - type: 'array', - items: { - anyOf: [ - { - type: 'object', - properties: { - category: { type: 'string' }, - value: { type: 'string' }, - }, - additionalProperties: true, + anyOf: [ + { + type: 'array', + items: { + anyOf: [ + { + type: 'object', + properties: { + category: { type: 'string' }, + value: { type: 'string' }, + }, + additionalProperties: true, + }, + { type: 'string' }, + { type: 'object', additionalProperties: true }, + ], }, - { type: 'string' }, - { type: 'object', additionalProperties: true }, - ], - }, + }, + { type: 'string' }, + ], }, }) @@ -420,15 +440,20 @@ describe('OpenAPI Multipart Form Parser', () => { description: expect.stringContaining('Optional pet certificate (absolute paths to local files)'), }, vaccinations: { - type: 'array', - items: { - anyOf: [ - { type: 'string', format: 'uri-reference', description: 'absolute paths to local files' }, - { type: 'string' }, - { type: 'object', additionalProperties: true }, - ], - }, - description: expect.stringContaining('Optional vaccination records'), + anyOf: [ + { + type: 'array', + items: { + anyOf: [ + { type: 'string', format: 'uri-reference', description: 'absolute paths to local files' }, + { type: 'string' }, + { type: 'object', additionalProperties: true }, + ], + }, + description: expect.stringContaining('Optional vaccination records'), + }, + { type: 'string' }, + ], }, }) }) diff --git a/src/openapi-mcp-server/openapi/__tests__/parser.test.ts b/src/openapi-mcp-server/openapi/__tests__/parser.test.ts index 0d07ba68..426464d9 100644 --- a/src/openapi-mcp-server/openapi/__tests__/parser.test.ts +++ b/src/openapi-mcp-server/openapi/__tests__/parser.test.ts @@ -942,6 +942,79 @@ describe('OpenAPIToMCPConverter', () => { const workspaceOption = parentRequest.oneOf[2] expect(workspaceOption.properties.type.const).toBe('workspace') }) + + it('wraps top-level array request body parameters in anyOf to also accept JSON-encoded strings', () => { + // Reproduces the bug where MCP clients like Claude Code double-serialize array parameters, + // sending them as JSON strings (e.g. "[{...}]") instead of actual arrays. + // The schema must accept both an array and a string so validation passes. + // At runtime, deserializeParams() in proxy.ts converts the string back to an array. + // See: https://github.com/makenotion/notion-mcp-server/issues/176 + const spec: OpenAPIV3.Document = { + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + paths: { + '/pages': { + post: { + operationId: 'createPages', + summary: 'Create multiple pages', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['pages'], + properties: { + pages: { + type: 'array', + description: 'List of pages to create', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Pages created', + content: { + 'application/json': { + schema: { type: 'object' }, + }, + }, + }, + }, + }, + }, + }, + } + + const converter = new OpenAPIToMCPConverter(spec) + const { tools } = converter.convertToMCPTools() + + const createPagesMethod = tools.API.methods.find((m) => m.name === 'createPages') + expect(createPagesMethod).toBeDefined() + + const pagesSchema = (createPagesMethod!.inputSchema.properties as any).pages + expect(pagesSchema).toBeDefined() + + // The pages property must accept both an array and a JSON-encoded string + expect(pagesSchema).toHaveProperty('anyOf') + const types = pagesSchema.anyOf.map((s: any) => s.type) + expect(types).toContain('array') + expect(types).toContain('string') + + // The array variant must also accept string items (for double-serialized elements) + const arrayVariant = pagesSchema.anyOf.find((s: any) => s.type === 'array') + expect(arrayVariant).toBeDefined() + expect(arrayVariant.items).toHaveProperty('anyOf') + }) }) // Additional complex test scenarios as a table test diff --git a/src/openapi-mcp-server/openapi/parser.ts b/src/openapi-mcp-server/openapi/parser.ts index 7f603484..a2944d33 100644 --- a/src/openapi-mcp-server/openapi/parser.ts +++ b/src/openapi-mcp-server/openapi/parser.ts @@ -500,15 +500,24 @@ export class OpenAPIToMCPConverter { } if (schema.type === 'array' && schema.items) { + // Also accept the entire array as a JSON-encoded string, consistent with + // the object handling above. Some MCP clients (e.g. Claude Code) double-serialize + // top-level array parameters, sending them as JSON strings instead of arrays. + // deserializeParams() in proxy.ts handles the string→array conversion at runtime. return { - ...schema, - items: { - anyOf: [ - schema.items as IJsonSchema, - { type: 'string' }, - { type: 'object', additionalProperties: true }, - ], - }, + anyOf: [ + { + ...schema, + items: { + anyOf: [ + schema.items as IJsonSchema, + { type: 'string' }, + { type: 'object', additionalProperties: true }, + ], + }, + }, + { type: 'string' }, + ], } }