Skip to content

Commit a66e844

Browse files
fix(plugin-mcp): use inline block schemas in JSON output (#15675)
Blocks were previously emitted as `$ref` references in the JSON Schema. This caused MCP tools to reject block field values during schema validation. Fixes #15399
1 parent 2347cd9 commit a66e844

File tree

6 files changed

+372
-14
lines changed

6 files changed

+372
-14
lines changed

packages/payload/src/utilities/configToJSONSchema.spec.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,18 @@ describe('configToJSONSchema', () => {
388388

389389
const schema = configToJSONSchema(sanitizedConfig, 'text')
390390

391+
const expectedBlockSchema = {
392+
type: 'object',
393+
additionalProperties: false,
394+
properties: {
395+
id: { type: ['string', 'null'] },
396+
blockName: { type: ['string', 'null'] },
397+
blockType: { const: 'sharedBlock' },
398+
richText: { type: ['array', 'null'], items: { type: 'object' } },
399+
},
400+
required: ['blockType'],
401+
}
402+
391403
expect(schema?.definitions?.test).toStrictEqual({
392404
type: 'object',
393405
additionalProperties: false,
@@ -399,18 +411,15 @@ describe('configToJSONSchema', () => {
399411
someBlockField: {
400412
type: ['array', 'null'],
401413
items: {
402-
oneOf: [
403-
{
404-
$ref: '#/definitions/SharedBlock',
405-
},
406-
],
414+
oneOf: [expectedBlockSchema],
407415
},
408416
},
409417
},
410418
required: ['id'],
411419
})
412420

413-
expect(schema?.definitions?.SharedBlock).toBeDefined()
421+
// The definition should still be registered for TypeScript type generation
422+
expect(schema?.definitions?.SharedBlock).toStrictEqual(expectedBlockSchema)
414423
})
415424

416425
it('should allow overriding required to false', async () => {

packages/payload/src/utilities/configToJSONSchema.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -324,18 +324,47 @@ export function fieldsToJSONSchema(
324324
oneOf: (field.blockReferences ?? field.blocks).map((block) => {
325325
if (typeof block === 'string') {
326326
const resolvedBlock = config?.blocks?.find((b) => b.slug === block)
327-
return {
328-
$ref: `#/definitions/${resolvedBlock!.interfaceName ?? resolvedBlock!.slug}`,
327+
if (!resolvedBlock) {
328+
return {}
329329
}
330+
331+
const resolvedBlockFieldSchemas = fieldsToJSONSchema(
332+
collectionIDFieldTypes,
333+
resolvedBlock.flattenedFields,
334+
interfaceNameDefinitions,
335+
config,
336+
i18n,
337+
)
338+
339+
const resolvedBlockSchema: JSONSchema4 = {
340+
type: 'object',
341+
additionalProperties: false,
342+
properties: {
343+
...resolvedBlockFieldSchemas.properties,
344+
blockType: {
345+
const: resolvedBlock.slug,
346+
},
347+
},
348+
required: ['blockType', ...resolvedBlockFieldSchemas.required],
349+
}
350+
351+
if (resolvedBlock.interfaceName) {
352+
interfaceNameDefinitions.set(
353+
resolvedBlock.interfaceName,
354+
resolvedBlockSchema,
355+
)
356+
}
357+
358+
return resolvedBlockSchema
330359
}
360+
331361
const blockFieldSchemas = fieldsToJSONSchema(
332362
collectionIDFieldTypes,
333363
block.flattenedFields,
334364
interfaceNameDefinitions,
335365
config,
336366
i18n,
337367
)
338-
339368
const blockSchema: JSONSchema4 = {
340369
type: 'object',
341370
additionalProperties: false,
@@ -350,10 +379,7 @@ export function fieldsToJSONSchema(
350379

351380
if (block.interfaceName) {
352381
interfaceNameDefinitions.set(block.interfaceName, blockSchema)
353-
354-
return {
355-
$ref: `#/definitions/${block.interfaceName}`,
356-
}
382+
return blockSchema
357383
}
358384

359385
return blockSchema
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { CollectionConfig } from 'payload'
2+
3+
export const heroBlockSlug = 'hero'
4+
export const textBlockSlug = 'textContent'
5+
6+
export const Pages: CollectionConfig = {
7+
slug: 'pages',
8+
fields: [
9+
{
10+
name: 'title',
11+
type: 'text',
12+
required: true,
13+
},
14+
{
15+
name: 'layout',
16+
type: 'blocks',
17+
blocks: [
18+
{
19+
slug: heroBlockSlug,
20+
interfaceName: 'HeroBlock',
21+
fields: [
22+
{
23+
name: 'heading',
24+
type: 'text',
25+
required: true,
26+
},
27+
{
28+
name: 'subheading',
29+
type: 'text',
30+
},
31+
],
32+
},
33+
{
34+
slug: textBlockSlug,
35+
fields: [
36+
{
37+
name: 'body',
38+
type: 'textarea',
39+
},
40+
],
41+
},
42+
],
43+
},
44+
],
45+
}

test/plugin-mcp/config.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { z } from 'zod'
77
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
88
import { Media } from './collections/Media.js'
99
import { ModifiedPrompts } from './collections/ModifiedPrompts.js'
10+
import { Pages } from './collections/Pages.js'
1011
import { Posts } from './collections/Posts.js'
1112
import { Products } from './collections/Products.js'
1213
import { ReturnedResources } from './collections/ReturnedResources.js'
@@ -26,7 +27,7 @@ export default buildConfigWithDefaults({
2627
baseDir: path.resolve(dirname),
2728
},
2829
},
29-
collections: [Users, Media, Posts, Products, Rolls, ModifiedPrompts, ReturnedResources],
30+
collections: [Users, Media, Posts, Products, Rolls, ModifiedPrompts, ReturnedResources, Pages],
3031
localization: {
3132
defaultLocale: 'en',
3233
fallback: true,
@@ -95,6 +96,15 @@ export default buildConfigWithDefaults({
9596
[Products.slug]: {
9697
enabled: true,
9798
},
99+
pages: {
100+
enabled: {
101+
find: true,
102+
create: true,
103+
update: true,
104+
delete: true,
105+
},
106+
description: 'Pages with block-based layouts.',
107+
},
98108
posts: {
99109
enabled: {
100110
find: true,

test/plugin-mcp/int.spec.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1297,6 +1297,179 @@ describe('@payloadcms/plugin-mcp', () => {
12971297
})
12981298
})
12991299

1300+
describe('Blocks fields', () => {
1301+
const createdPageIds: (number | string)[] = []
1302+
1303+
const getPagesApiKey = async (enableUpdate = false) => {
1304+
const doc = await payload.create({
1305+
collection: 'payload-mcp-api-keys',
1306+
data: {
1307+
enableAPIKey: true,
1308+
label: 'Pages API Key',
1309+
pages: { create: true, find: true, update: enableUpdate, delete: true },
1310+
posts: { create: false, find: false },
1311+
products: { find: false },
1312+
apiKey: randomUUID(),
1313+
user: userId,
1314+
},
1315+
})
1316+
return doc.apiKey as string
1317+
}
1318+
1319+
it('should create a page with a block', async () => {
1320+
const apiKey = await getPagesApiKey()
1321+
1322+
const response = await restClient.POST('/mcp', {
1323+
body: JSON.stringify({
1324+
id: 1,
1325+
jsonrpc: '2.0',
1326+
method: 'tools/call',
1327+
params: {
1328+
name: 'createPages',
1329+
arguments: {
1330+
title: 'Hero Page',
1331+
layout: [
1332+
{
1333+
blockType: 'hero',
1334+
heading: 'Welcome to our site',
1335+
subheading: 'Discover amazing things',
1336+
},
1337+
],
1338+
},
1339+
},
1340+
}),
1341+
headers: {
1342+
Accept: 'application/json, text/event-stream',
1343+
Authorization: `Bearer ${apiKey}`,
1344+
'Content-Type': 'application/json',
1345+
},
1346+
})
1347+
1348+
const json = await parseStreamResponse(response)
1349+
1350+
expect(json.result).toBeDefined()
1351+
expect(json.result.isError).toBeFalsy()
1352+
expect(json.result.content[0].type).toBe('text')
1353+
expect(json.result.content[0].text).toContain('"title": "Hero Page"')
1354+
expect(json.result.content[0].text).toContain('"blockType": "hero"')
1355+
expect(json.result.content[0].text).toContain('"heading": "Welcome to our site"')
1356+
1357+
const jsonMatch = json.result.content[0].text.match(/```json\n([\s\S]*?)\n```/)
1358+
if (jsonMatch) {
1359+
createdPageIds.push(JSON.parse(jsonMatch[1]).id)
1360+
}
1361+
})
1362+
1363+
it('should create a page with multiple block types', async () => {
1364+
const apiKey = await getPagesApiKey()
1365+
1366+
const response = await restClient.POST('/mcp', {
1367+
body: JSON.stringify({
1368+
id: 1,
1369+
jsonrpc: '2.0',
1370+
method: 'tools/call',
1371+
params: {
1372+
name: 'createPages',
1373+
arguments: {
1374+
title: 'Multi-block Page',
1375+
layout: [
1376+
{
1377+
blockType: 'hero',
1378+
heading: 'Page Hero',
1379+
subheading: 'Hero subtitle',
1380+
},
1381+
{
1382+
blockType: 'textContent',
1383+
body: 'This is the body text.',
1384+
},
1385+
],
1386+
},
1387+
},
1388+
}),
1389+
headers: {
1390+
Accept: 'application/json, text/event-stream',
1391+
Authorization: `Bearer ${apiKey}`,
1392+
'Content-Type': 'application/json',
1393+
},
1394+
})
1395+
1396+
const json = await parseStreamResponse(response)
1397+
1398+
expect(json.result).toBeDefined()
1399+
expect(json.result.isError).toBeFalsy()
1400+
expect(json.result.content[0].text).toContain('"blockType": "hero"')
1401+
expect(json.result.content[0].text).toContain('"blockType": "textContent"')
1402+
expect(json.result.content[0].text).toContain('"heading": "Page Hero"')
1403+
expect(json.result.content[0].text).toContain('"body": "This is the body text."')
1404+
1405+
const jsonMatch = json.result.content[0].text.match(/```json\n([\s\S]*?)\n```/)
1406+
if (jsonMatch) {
1407+
createdPageIds.push(JSON.parse(jsonMatch[1]).id)
1408+
}
1409+
})
1410+
1411+
it('should update a page layout that contains blocks', async () => {
1412+
const page = await payload.create({
1413+
collection: 'pages',
1414+
data: {
1415+
title: 'Page to Update',
1416+
layout: [],
1417+
},
1418+
})
1419+
1420+
createdPageIds.push(page.id)
1421+
1422+
const apiKey = await getPagesApiKey(true)
1423+
1424+
const response = await restClient.POST('/mcp', {
1425+
body: JSON.stringify({
1426+
id: 1,
1427+
jsonrpc: '2.0',
1428+
method: 'tools/call',
1429+
params: {
1430+
name: 'updatePages',
1431+
arguments: {
1432+
id: page.id,
1433+
layout: [
1434+
{
1435+
blockType: 'hero',
1436+
heading: 'Updated Hero Heading',
1437+
},
1438+
{
1439+
blockType: 'textContent',
1440+
body: 'Updated body text.',
1441+
},
1442+
],
1443+
},
1444+
},
1445+
}),
1446+
headers: {
1447+
Accept: 'application/json, text/event-stream',
1448+
Authorization: `Bearer ${apiKey}`,
1449+
'Content-Type': 'application/json',
1450+
},
1451+
})
1452+
1453+
const json = await parseStreamResponse(response)
1454+
1455+
expect(json.result).toBeDefined()
1456+
expect(json.result.isError).toBeFalsy()
1457+
expect(json.result.content[0].text).toContain('"blockType": "hero"')
1458+
expect(json.result.content[0].text).toContain('"heading": "Updated Hero Heading"')
1459+
expect(json.result.content[0].text).toContain('"blockType": "textContent"')
1460+
expect(json.result.content[0].text).toContain('"body": "Updated body text."')
1461+
1462+
const updatedPage = await payload.findByID({
1463+
collection: 'pages',
1464+
id: page.id,
1465+
})
1466+
1467+
expect((updatedPage as any).layout).toHaveLength(2)
1468+
expect((updatedPage as any).layout[0].blockType).toBe('hero')
1469+
expect((updatedPage as any).layout[0].heading).toBe('Updated Hero Heading')
1470+
})
1471+
})
1472+
13001473
describe('payloadAPI context', () => {
13011474
it('should call operations with the payloadAPI context as MCP', async () => {
13021475
await payload.create({

0 commit comments

Comments
 (0)