Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/notion-openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
50 changes: 50 additions & 0 deletions src/openapi-mcp-server/mcp/__tests__/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>).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' },
},
)
})
})
})
151 changes: 151 additions & 0 deletions src/openapi-mcp-server/openapi/__tests__/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
}
})
})