diff --git a/scripts/notion-openapi.json b/scripts/notion-openapi.json index 18669f22..4718c7f4 100644 --- a/scripts/notion-openapi.json +++ b/scripts/notion-openapi.json @@ -1243,7 +1243,7 @@ "type": "array", "description": "The content to be rendered on the new page, represented as an array of [block objects](https://developers.notion.com/reference/block).", "items": { - "type": "string" + "$ref": "#/components/schemas/blockObjectRequest" } }, "icon": { diff --git a/src/openapi-mcp-server/mcp/__tests__/proxy.test.ts b/src/openapi-mcp-server/mcp/__tests__/proxy.test.ts index f80f232d..b904762e 100644 --- a/src/openapi-mcp-server/mcp/__tests__/proxy.test.ts +++ b/src/openapi-mcp-server/mcp/__tests__/proxy.test.ts @@ -793,5 +793,55 @@ describe('MCPProxy', () => { }, ) }) + + it('should deserialize an entire children array sent as a JSON string (issue #82)', async () => { + const mockResponse = { + data: { id: 'new-page-id' }, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + } + ;(HttpClient.prototype.executeOperation as ReturnType).mockResolvedValue(mockResponse) + + ;(proxy as any).openApiLookup = { + 'API-createPage': { + operationId: 'createPage', + responses: { '200': { description: 'Success' } }, + method: 'post', + path: '/pages', + }, + } + + const server = (proxy as any).server + const handlers = server.setRequestHandler.mock.calls.flatMap((x: unknown[]) => x).filter((x: unknown) => typeof x === 'function') + const callToolHandler = handlers[1] + + // Simulates the bug from issue #82: the entire children array is stringified + const childrenAsString = JSON.stringify([ + { type: 'paragraph', paragraph: { rich_text: [{ type: 'text', text: { content: 'Test' } }] } }, + ]) + const parentAsString = JSON.stringify({ database_id: 'example-database-id' }) + + await callToolHandler({ + params: { + name: 'API-createPage', + arguments: { + parent: parentAsString, + children: childrenAsString, + properties: { title: 'Test Page' }, + }, + }, + }) + + expect(HttpClient.prototype.executeOperation).toHaveBeenCalledWith( + expect.anything(), + { + parent: { database_id: 'example-database-id' }, + children: [ + { type: 'paragraph', paragraph: { rich_text: [{ type: 'text', text: { content: 'Test' } }] } }, + ], + properties: { title: 'Test Page' }, + }, + ) + }) }) }) diff --git a/src/openapi-mcp-server/openapi/__tests__/parser.test.ts b/src/openapi-mcp-server/openapi/__tests__/parser.test.ts index 0d07ba68..c622c983 100644 --- a/src/openapi-mcp-server/openapi/__tests__/parser.test.ts +++ b/src/openapi-mcp-server/openapi/__tests__/parser.test.ts @@ -1545,3 +1545,154 @@ describe('OpenAPIToMCPConverter - Additional Complex Tests', () => { expect(openApiLookup).toEqual(expected.openApiLookup) }) }) + +describe('Array items with $ref are preserved through schema conversion (issue #82)', () => { + const specWithBlockRef: OpenAPIV3.Document = { + openapi: '3.0.0', + info: { title: 'Test API', version: '1.0.0' }, + components: { + schemas: { + blockObjectRequest: { + anyOf: [ + { $ref: '#/components/schemas/paragraphBlockRequest' }, + { $ref: '#/components/schemas/headingBlockRequest' }, + ], + }, + paragraphBlockRequest: { + type: 'object', + properties: { + type: { type: 'string', enum: ['paragraph'] }, + paragraph: { + type: 'object', + properties: { + rich_text: { type: 'array', items: { type: 'object', additionalProperties: true } }, + }, + }, + }, + }, + headingBlockRequest: { + type: 'object', + properties: { + type: { type: 'string', enum: ['heading_1'] }, + heading_1: { + type: 'object', + properties: { + rich_text: { type: 'array', items: { type: 'object', additionalProperties: true } }, + }, + }, + }, + }, + }, + }, + paths: { + '/pages': { + post: { + operationId: 'createPage', + summary: 'Create a page', + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + required: ['parent', 'properties'], + properties: { + parent: { type: 'object', additionalProperties: true }, + properties: { type: 'object', additionalProperties: true }, + children: { + type: 'array', + description: 'Content blocks', + items: { $ref: '#/components/schemas/blockObjectRequest' }, + }, + }, + }, + }, + }, + }, + responses: { '200': { description: 'Created' } }, + }, + }, + '/blocks/{block_id}/children': { + patch: { + operationId: 'appendBlockChildren', + summary: 'Append block children', + parameters: [ + { name: 'block_id', in: 'path', required: true, schema: { type: 'string' } }, + ], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + required: ['children'], + properties: { + children: { + type: 'array', + items: { $ref: '#/components/schemas/blockObjectRequest' }, + description: 'Child content blocks', + }, + }, + }, + }, + }, + }, + responses: { '200': { description: 'Success' } }, + }, + }, + }, + } + + it('both createPage and appendBlockChildren should have matching children schema structure', () => { + const converter = new OpenAPIToMCPConverter(specWithBlockRef) + const { tools } = converter.convertToMCPTools() + + const createPage = tools.API.methods.find(m => m.name === 'createPage') + const appendBlockChildren = tools.API.methods.find(m => m.name === 'appendBlockChildren') + expect(createPage).toBeDefined() + expect(appendBlockChildren).toBeDefined() + + // Both children properties should have the same items schema structure + const createPageChildren = createPage!.inputSchema.properties!.children as IJsonSchema + const appendChildren = appendBlockChildren!.inputSchema.properties!.children as IJsonSchema + + // The items should reference blockObjectRequest via $defs, not degrade to {type: "string"} + expect(createPageChildren).toHaveProperty('items') + expect(appendChildren).toHaveProperty('items') + + // Extract the effective items schema (unwrapping the anyOf string fallback) + const getEffectiveItems = (schema: IJsonSchema): IJsonSchema => { + // withStringFallback wraps array items in anyOf: [original, {type: "string"}, {type: "object"}] + const items = schema.items as IJsonSchema + if (items && 'anyOf' in items && Array.isArray(items.anyOf)) { + return items.anyOf[0] as IJsonSchema + } + return items + } + + const createItems = getEffectiveItems(createPageChildren) + const appendItems = getEffectiveItems(appendChildren) + + // Both should reference blockObjectRequest (not be {type: "string"}) + expect(createItems).toHaveProperty('$ref') + expect(appendItems).toHaveProperty('$ref') + expect((createItems as any).$ref).toBe((appendItems as any).$ref) + }) + + it('children items should not be typed as plain string', () => { + const converter = new OpenAPIToMCPConverter(specWithBlockRef) + const { tools } = converter.convertToMCPTools() + + const createPage = tools.API.methods.find(m => m.name === 'createPage') + expect(createPage).toBeDefined() + + const children = createPage!.inputSchema.properties!.children as IJsonSchema + const items = children.items as IJsonSchema + + // Items should not be {type: "string"} -- that was the bug + if ('anyOf' in items && Array.isArray(items.anyOf)) { + // The primary schema (first element) should not be {type: "string"} + expect(items.anyOf[0]).not.toEqual({ type: 'string' }) + } else { + expect(items).not.toEqual({ type: 'string' }) + } + }) +})