Skip to content

Commit cc8f888

Browse files
feat(plugin-mcp): ignore virtual fields in create and update operations (#15680)
Virtual fields (fields with `virtual: true`) are not stored and should not be exposed as inputs or passed to Payload operations via MCP. - Strips virtual fields from the MCP tool input schema so they don't appear as accepted parameters - Filters virtual fields them from parsed data before calling `payload.create`, `payload.update`, and `payload.updateGlobal`.
1 parent e3a76e3 commit cc8f888

9 files changed

Lines changed: 262 additions & 7 deletions

File tree

packages/plugin-mcp/src/mcp/getMcpHandler.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ import type { MCPAccessSettings, PluginMCPServerConfig } from '../types.js'
88

99
import { toCamelCase } from '../utils/camelCase.js'
1010
import { getEnabledSlugs } from '../utils/getEnabledSlugs.js'
11+
import {
12+
getCollectionVirtualFieldNames,
13+
getGlobalVirtualFieldNames,
14+
} from '../utils/getVirtualFieldNames.js'
15+
import { removeVirtualFieldsFromSchema } from '../utils/schemaConversion/removeVirtualFieldsFromSchema.js'
1116
import { registerTool } from './registerTool.js'
1217

1318
// Tools
@@ -107,7 +112,16 @@ export const getMCPHandler = (
107112
// Collection Operation Tools
108113
enabledCollectionSlugs.forEach((enabledCollectionSlug) => {
109114
try {
110-
const schema = configSchema.definitions?.[enabledCollectionSlug] as JSONSchema4
115+
const rawSchema = configSchema.definitions?.[enabledCollectionSlug] as JSONSchema4
116+
117+
const virtualFieldNames = getCollectionVirtualFieldNames(
118+
payload.config,
119+
enabledCollectionSlug,
120+
)
121+
const schema = removeVirtualFieldsFromSchema(
122+
JSON.parse(JSON.stringify(rawSchema)) as JSONSchema4,
123+
virtualFieldNames,
124+
)
111125

112126
const toolCapabilities = mcpAccessSettings?.[
113127
`${toCamelCase(enabledCollectionSlug)}`
@@ -200,7 +214,13 @@ export const getMCPHandler = (
200214

201215
enabledGlobalSlugs.forEach((enabledGlobalSlug) => {
202216
try {
203-
const schema = configSchema.definitions?.[enabledGlobalSlug] as JSONSchema4
217+
const rawSchema = configSchema.definitions?.[enabledGlobalSlug] as JSONSchema4
218+
219+
const virtualFieldNames = getGlobalVirtualFieldNames(payload.config, enabledGlobalSlug)
220+
const schema = removeVirtualFieldsFromSchema(
221+
JSON.parse(JSON.stringify(rawSchema)) as JSONSchema4,
222+
virtualFieldNames,
223+
)
204224

205225
const toolCapabilities = mcpAccessSettings?.[
206226
`${toCamelCase(enabledGlobalSlug)}`

packages/plugin-mcp/src/mcp/tools/global/update.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import { z } from 'zod'
77
import type { PluginMCPServerConfig } from '../../../types.js'
88

99
import { toCamelCase } from '../../../utils/camelCase.js'
10+
import {
11+
getGlobalVirtualFieldNames,
12+
stripVirtualFields,
13+
} from '../../../utils/getVirtualFieldNames.js'
1014
import { convertCollectionSchemaToZod } from '../../../utils/schemaConversion/convertCollectionSchemaToZod.js'
1115
import { toolSchemas } from '../schemas.js'
1216

@@ -45,6 +49,10 @@ export const updateGlobalTool = (
4549
let parsedData: Record<string, unknown>
4650
try {
4751
parsedData = JSON.parse(data)
52+
53+
const virtualFieldNames = getGlobalVirtualFieldNames(payload.config, globalSlug)
54+
parsedData = stripVirtualFields(parsedData, virtualFieldNames)
55+
4856
if (verboseLogs) {
4957
payload.logger.info(
5058
`[payload-mcp] Parsed data for ${globalSlug}: ${JSON.stringify(parsedData)}`,

packages/plugin-mcp/src/mcp/tools/resource/create.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import { z } from 'zod'
77
import type { PluginMCPServerConfig } from '../../../types.js'
88

99
import { toCamelCase } from '../../../utils/camelCase.js'
10+
import {
11+
getCollectionVirtualFieldNames,
12+
stripVirtualFields,
13+
} from '../../../utils/getVirtualFieldNames.js'
1014
import { convertCollectionSchemaToZod } from '../../../utils/schemaConversion/convertCollectionSchemaToZod.js'
1115
import { transformPointDataToPayload } from '../../../utils/transformPointDataToPayload.js'
1216
import { toolSchemas } from '../schemas.js'
@@ -49,6 +53,9 @@ export const createResourceTool = (
4953
// Transform point fields from object format to tuple array
5054
parsedData = transformPointDataToPayload(parsedData)
5155

56+
const virtualFieldNames = getCollectionVirtualFieldNames(payload.config, collectionSlug)
57+
parsedData = stripVirtualFields(parsedData, virtualFieldNames)
58+
5259
if (verboseLogs) {
5360
payload.logger.info(
5461
`[payload-mcp] Parsed data for ${collectionSlug}: ${JSON.stringify(parsedData)}`,

packages/plugin-mcp/src/mcp/tools/resource/update.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import { z } from 'zod'
77
import type { PluginMCPServerConfig } from '../../../types.js'
88

99
import { toCamelCase } from '../../../utils/camelCase.js'
10+
import {
11+
getCollectionVirtualFieldNames,
12+
stripVirtualFields,
13+
} from '../../../utils/getVirtualFieldNames.js'
1014
import { convertCollectionSchemaToZod } from '../../../utils/schemaConversion/convertCollectionSchemaToZod.js'
1115
import { transformPointDataToPayload } from '../../../utils/transformPointDataToPayload.js'
1216
import { toolSchemas } from '../schemas.js'
@@ -54,6 +58,9 @@ export const updateResourceTool = (
5458
// Transform point fields from object format to tuple array
5559
parsedData = transformPointDataToPayload(parsedData)
5660

61+
const virtualFieldNames = getCollectionVirtualFieldNames(payload.config, collectionSlug)
62+
parsedData = stripVirtualFields(parsedData, virtualFieldNames)
63+
5764
if (verboseLogs) {
5865
payload.logger.info(
5966
`[payload-mcp] Parsed data for ${collectionSlug}: ${JSON.stringify(parsedData)}`,
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { SanitizedConfig } from 'payload'
2+
3+
import { fieldIsVirtual } from 'payload/shared'
4+
5+
/**
6+
* Returns the names of all top-level virtual fields for a given collection slug.
7+
*/
8+
export function getCollectionVirtualFieldNames(config: SanitizedConfig, slug: string): string[] {
9+
const collection = config.collections.find((c) => c.slug === slug)
10+
11+
if (!collection) {
12+
return []
13+
}
14+
15+
return collection.flattenedFields
16+
.filter((field) => 'name' in field && fieldIsVirtual(field))
17+
.map((field) => (field as { name: string }).name)
18+
}
19+
20+
/**
21+
* Returns the names of all top-level virtual fields for a given global slug.
22+
*/
23+
export function getGlobalVirtualFieldNames(config: SanitizedConfig, slug: string): string[] {
24+
const global = config.globals.find((g) => g.slug === slug)
25+
26+
if (!global) {
27+
return []
28+
}
29+
30+
return global.flattenedFields
31+
.filter((field) => 'name' in field && fieldIsVirtual(field))
32+
.map((field) => (field as { name: string }).name)
33+
}
34+
35+
/**
36+
* Strips virtual field values from a data object given a list of virtual field names.
37+
*/
38+
export function stripVirtualFields(
39+
data: Record<string, unknown>,
40+
virtualFieldNames: string[],
41+
): Record<string, unknown> {
42+
if (virtualFieldNames.length === 0) {
43+
return data
44+
}
45+
46+
const stripped = { ...data }
47+
48+
for (const name of virtualFieldNames) {
49+
delete stripped[name]
50+
}
51+
52+
return stripped
53+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { JSONSchema4 } from 'json-schema'
2+
3+
/**
4+
* Removes virtual fields from a JSON Schema by name so they don't appear
5+
* in the generated MCP tool input schema.
6+
*/
7+
export function removeVirtualFieldsFromSchema(
8+
schema: JSONSchema4,
9+
virtualFieldNames: string[],
10+
): JSONSchema4 {
11+
if (virtualFieldNames.length === 0) {
12+
return schema
13+
}
14+
15+
for (const name of virtualFieldNames) {
16+
delete schema?.properties?.[name]
17+
}
18+
19+
if (Array.isArray(schema.required)) {
20+
schema.required = schema.required.filter((field) => !virtualFieldNames.includes(field))
21+
if (schema.required.length === 0) {
22+
delete schema.required
23+
}
24+
}
25+
26+
return schema
27+
}

packages/ui/src/elements/DocumentControls/index.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -285,11 +285,11 @@ export const DocumentControls: React.FC<{
285285
{(unsavedDraftWithValidations ||
286286
!autosaveEnabled ||
287287
(autosaveEnabled && showSaveDraftButton)) && (
288-
<RenderCustomComponent
289-
CustomComponent={CustomSaveDraftButton}
290-
Fallback={<SaveDraftButton />}
291-
/>
292-
)}
288+
<RenderCustomComponent
289+
CustomComponent={CustomSaveDraftButton}
290+
Fallback={<SaveDraftButton />}
291+
/>
292+
)}
293293
<RenderCustomComponent
294294
CustomComponent={CustomPublishButton}
295295
Fallback={<PublishButton />}

test/plugin-mcp/collections/Posts.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ export const Posts: CollectionConfig = {
3636
description: 'Geographic location coordinates',
3737
},
3838
},
39+
{
40+
name: 'computedTitle',
41+
type: 'text',
42+
virtual: true,
43+
admin: {
44+
description: 'A virtual field that is computed and not stored in the database',
45+
},
46+
},
3947
],
4048
hooks: {
4149
beforeRead: [

test/plugin-mcp/int.spec.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1470,6 +1470,131 @@ describe('@payloadcms/plugin-mcp', () => {
14701470
})
14711471
})
14721472

1473+
describe('Virtual Fields', () => {
1474+
it('should not include virtual fields in createPosts tool schema', async () => {
1475+
const apiKey = await getApiKey(true)
1476+
const response = await restClient.POST('/mcp', {
1477+
body: JSON.stringify({
1478+
id: 1,
1479+
jsonrpc: '2.0',
1480+
method: 'tools/list',
1481+
params: {},
1482+
}),
1483+
headers: {
1484+
Accept: 'application/json, text/event-stream',
1485+
Authorization: `Bearer ${apiKey}`,
1486+
'Content-Type': 'application/json',
1487+
},
1488+
})
1489+
1490+
const json = await parseStreamResponse(response)
1491+
1492+
const createTool = json.result.tools.find((t: any) => t.name === 'createPosts')
1493+
expect(createTool).toBeDefined()
1494+
expect(createTool.inputSchema.properties.computedTitle).toBeUndefined()
1495+
})
1496+
1497+
it('should not include virtual fields in updatePosts tool schema', async () => {
1498+
const apiKey = await getApiKey(true)
1499+
const response = await restClient.POST('/mcp', {
1500+
body: JSON.stringify({
1501+
id: 1,
1502+
jsonrpc: '2.0',
1503+
method: 'tools/list',
1504+
params: {},
1505+
}),
1506+
headers: {
1507+
Accept: 'application/json, text/event-stream',
1508+
Authorization: `Bearer ${apiKey}`,
1509+
'Content-Type': 'application/json',
1510+
},
1511+
})
1512+
1513+
const json = await parseStreamResponse(response)
1514+
1515+
const updateTool = json.result.tools.find((t: any) => t.name === 'updatePosts')
1516+
expect(updateTool).toBeDefined()
1517+
expect(updateTool.inputSchema.properties.computedTitle).toBeUndefined()
1518+
})
1519+
1520+
it('should ignore virtual fields when creating a post via MCP', async () => {
1521+
const apiKey = await getApiKey()
1522+
const response = await restClient.POST('/mcp', {
1523+
body: JSON.stringify({
1524+
id: 1,
1525+
jsonrpc: '2.0',
1526+
method: 'tools/call',
1527+
params: {
1528+
name: 'createPosts',
1529+
arguments: {
1530+
title: 'Virtual Field Create Test',
1531+
content: 'Testing virtual field exclusion on create',
1532+
},
1533+
},
1534+
}),
1535+
headers: {
1536+
Accept: 'application/json, text/event-stream',
1537+
Authorization: `Bearer ${apiKey}`,
1538+
'Content-Type': 'application/json',
1539+
},
1540+
})
1541+
1542+
const json = await parseStreamResponse(response)
1543+
1544+
expect(json.result).toBeDefined()
1545+
expect(json.result.content[0].text).toContain(
1546+
'Resource created successfully in collection "posts"!',
1547+
)
1548+
expect(json.result.content[0].text).toContain('"title": "Virtual Field Create Test"')
1549+
expect(json.result.content[0].text).not.toContain('"computedTitle"')
1550+
1551+
// Clean up
1552+
const createdId = JSON.parse(
1553+
json.result.content[0].text.match(/```json\n([\s\S]*?)\n```/)?.[1] || '{}',
1554+
).id
1555+
if (createdId) {
1556+
await payload.delete({ id: createdId, collection: 'posts' })
1557+
}
1558+
})
1559+
1560+
it('should ignore virtual fields when updating a post via MCP', async () => {
1561+
const post = await payload.create({
1562+
collection: 'posts',
1563+
data: { title: 'Virtual Field Update Test' },
1564+
})
1565+
1566+
const apiKey = await getApiKey(true, false)
1567+
const response = await restClient.POST('/mcp', {
1568+
body: JSON.stringify({
1569+
id: 1,
1570+
jsonrpc: '2.0',
1571+
method: 'tools/call',
1572+
params: {
1573+
name: 'updatePosts',
1574+
arguments: {
1575+
id: post.id,
1576+
title: 'Virtual Field Updated Title',
1577+
},
1578+
},
1579+
}),
1580+
headers: {
1581+
Accept: 'application/json, text/event-stream',
1582+
Authorization: `Bearer ${apiKey}`,
1583+
'Content-Type': 'application/json',
1584+
},
1585+
})
1586+
1587+
const json = await parseStreamResponse(response)
1588+
1589+
expect(json.result).toBeDefined()
1590+
expect(json.result.content[0].text).toContain('Document updated successfully')
1591+
expect(json.result.content[0].text).toContain('"title": "Virtual Field Updated Title"')
1592+
expect(json.result.content[0].text).not.toContain('"computedTitle"')
1593+
1594+
await payload.delete({ id: post.id, collection: 'posts' })
1595+
})
1596+
})
1597+
14731598
describe('payloadAPI context', () => {
14741599
it('should call operations with the payloadAPI context as MCP', async () => {
14751600
await payload.create({

0 commit comments

Comments
 (0)