-
Notifications
You must be signed in to change notification settings - Fork 3.5k
Description
Describe the Bug
Hi Payload team — we’re using @payloadcms/plugin-mcp (v3.73.0) and hit an issue where MCP update tools reject valid blocks data (e.g., pages.layout) before Payload validation runs.
Problem
The MCP plugin builds tool schemas via configToJSONSchema and converts them to Zod with json-schema-to-zod. For blocks fields, the schema becomes a union of strict object shapes keyed by literal blockType. In practice, only a subset of union branches survive, so valid block types are rejected during MCP validation (e.g., cards-carousel, upcoming-events) with errors like:
- “Invalid literal value, expected "richContent"”
- “Unrecognized key(s) in object: 'cards'”
This is a tool-side validation issue; Payload itself accepts the block data.
Impact
MCP update<collection> tools (e.g., updatePages) cannot update pages that contain custom blocks, making MCP unusable for pages layouts in real projects.
Proposed fix
Relax blocks validation in the MCP layer so that block arrays accept any object and let Payload perform schema validation.
We implemented a small patch that detects blocks unions (items with blockType literal/enum) and replaces the array item schema with { type: 'object', additionalProperties: true } before converting to Zod. This keeps MCP flexible while preserving server-side validation in Payload.
diff --git a/node_modules/@payloadcms/plugin-mcp/dist/utils/convertCollectionSchemaToZod.js b/node_modules/@payloadcms/plugin-mcp/dist/utils/convertCollectionSchemaToZod.js
index 9b26c4d..002dbee 100644
--- a/node_modules/@payloadcms/plugin-mcp/dist/utils/convertCollectionSchemaToZod.js
+++ b/node_modules/@payloadcms/plugin-mcp/dist/utils/convertCollectionSchemaToZod.js
@@ -40,6 +40,55 @@ import { z } from 'zod';
}
return processed;
}
+function hasBlockTypeProperty(schema) {
+ if (!schema || typeof schema !== 'object') {
+ return false;
+ }
+ const properties = schema.properties;
+ if (!properties || typeof properties !== 'object') {
+ return false;
+ }
+ const blockType = properties.blockType;
+ if (!blockType || typeof blockType !== 'object') {
+ return false;
+ }
+ return 'const' in blockType || Array.isArray(blockType.enum);
+}
+function relaxBlocksFields(schema) {
+ if (!schema || typeof schema !== 'object') {
+ return schema;
+ }
+ const processed = {
+ ...schema
+ };
+ if (processed.type === 'array' && processed.items && typeof processed.items === 'object' && !Array.isArray(processed.items)) {
+ const items = processed.items;
+ const union = Array.isArray(items.oneOf) ? items.oneOf : Array.isArray(items.anyOf) ? items.anyOf : undefined;
+ if (union && union.some((option)=>hasBlockTypeProperty(option))) {
+ processed.items = {
+ type: 'object',
+ additionalProperties: true
+ };
+ return processed;
+ }
+ }
+ if (processed.properties && typeof processed.properties === 'object') {
+ processed.properties = Object.fromEntries(Object.entries(processed.properties).map(([key, value])=>[
+ key,
+ relaxBlocksFields(value)
+ ]));
+ }
+ if (processed.items && typeof processed.items === 'object' && !Array.isArray(processed.items)) {
+ processed.items = relaxBlocksFields(processed.items);
+ }
+ if (Array.isArray(processed.oneOf)) {
+ processed.oneOf = processed.oneOf.map((option)=>relaxBlocksFields(option));
+ }
+ if (Array.isArray(processed.anyOf)) {
+ processed.anyOf = processed.anyOf.map((option)=>relaxBlocksFields(option));
+ }
+ return processed;
+}
export const convertCollectionSchemaToZod = (schema)=>{
// Clone to avoid mutating the original schema (used elsewhere for tool listing)
const schemaClone = JSON.parse(JSON.stringify(schema));
@@ -53,7 +102,7 @@ export const convertCollectionSchemaToZod = (schema)=>{
delete schemaClone.required;
}
}
- const simplifiedSchema = simplifyRelationshipFields(schemaClone);
+ const simplifiedSchema = relaxBlocksFields(simplifyRelationshipFields(schemaClone));
const zodSchemaAsString = jsonSchemaToZod(simplifiedSchema);
// Transpile TypeScript to JavaScript
const transpileResult = ts.transpileModule(zodSchemaAsString, {
diff --git a/node_modules/@payloadcms/plugin-mcp/src/utils/convertCollectionSchemaToZod.ts b/node_modules/@payloadcms/plugin-mcp/src/utils/convertCollectionSchemaToZod.ts
index 2cf1749..840fd3a 100644
--- a/node_modules/@payloadcms/plugin-mcp/src/utils/convertCollectionSchemaToZod.ts
+++ b/node_modules/@payloadcms/plugin-mcp/src/utils/convertCollectionSchemaToZod.ts
@@ -50,6 +50,62 @@ function simplifyRelationshipFields(schema: JSONSchema4): JSONSchema4 {
return processed
}
+function hasBlockTypeProperty(schema: JSONSchema4): boolean {
+ if (!schema || typeof schema !== 'object') {
+ return false
+ }
+
+ const properties = schema.properties
+ if (!properties || typeof properties !== 'object') {
+ return false
+ }
+
+ const blockType = properties.blockType
+ if (!blockType || typeof blockType !== 'object') {
+ return false
+ }
+
+ return 'const' in blockType || Array.isArray((blockType as JSONSchema4).enum)
+}
+
+function relaxBlocksFields(schema: JSONSchema4): JSONSchema4 {
+ if (!schema || typeof schema !== 'object') {
+ return schema
+ }
+
+ const processed = { ...schema }
+
+ if (processed.type === 'array' && processed.items && typeof processed.items === 'object' && !Array.isArray(processed.items)) {
+ const items = processed.items as JSONSchema4
+ const union = Array.isArray(items.oneOf) ? items.oneOf : Array.isArray(items.anyOf) ? items.anyOf : undefined
+
+ if (union && union.some((option) => hasBlockTypeProperty(option as JSONSchema4))) {
+ processed.items = { type: 'object', additionalProperties: true }
+ return processed
+ }
+ }
+
+ if (processed.properties && typeof processed.properties === 'object') {
+ processed.properties = Object.fromEntries(
+ Object.entries(processed.properties).map(([key, value]) => [key, relaxBlocksFields(value as JSONSchema4)]),
+ )
+ }
+
+ if (processed.items && typeof processed.items === 'object' && !Array.isArray(processed.items)) {
+ processed.items = relaxBlocksFields(processed.items as JSONSchema4)
+ }
+
+ if (Array.isArray(processed.oneOf)) {
+ processed.oneOf = processed.oneOf.map((option) => relaxBlocksFields(option as JSONSchema4))
+ }
+
+ if (Array.isArray(processed.anyOf)) {
+ processed.anyOf = processed.anyOf.map((option) => relaxBlocksFields(option as JSONSchema4))
+ }
+
+ return processed
+}
+
export const convertCollectionSchemaToZod = (schema: JSONSchema4) => {
// Clone to avoid mutating the original schema (used elsewhere for tool listing)
const schemaClone = JSON.parse(JSON.stringify(schema)) as JSONSchema4
@@ -65,7 +121,7 @@ export const convertCollectionSchemaToZod = (schema: JSONSchema4) => {
}
}
- const simplifiedSchema = simplifyRelationshipFields(schemaClone)
+ const simplifiedSchema = relaxBlocksFields(simplifyRelationshipFields(schemaClone))
const zodSchemaAsString = jsonSchemaToZod(simplifiedSchema)
Link to the code that reproduces this issue
n/a
Reproduction Steps
I just asked Codex to use Payload MCP to update a collection item (Page) appending a custom block data into the page.layout field.
Which area(s) are affected?
plugin: mcp
Environment Info
Binaries:
Node: 22.21.1
npm: 10.9.4
Yarn: 1.22.22
pnpm: 10.19.0
Relevant Packages:
payload: 3.73.0
next: 15.5.7
@payloadcms/db-postgres: 3.73.0
@payloadcms/drizzle: 3.73.0
@payloadcms/email-nodemailer: 3.73.0
@payloadcms/graphql: 3.73.0
@payloadcms/next/utilities: 3.73.0
@payloadcms/payload-cloud: 3.73.0
@payloadcms/plugin-cloud-storage: 3.73.0
@payloadcms/plugin-mcp: 3.73.0
@payloadcms/plugin-seo: 3.73.0
@payloadcms/richtext-lexical: 3.73.0
@payloadcms/storage-s3: 3.73.0
@payloadcms/translations: 3.73.0
@payloadcms/ui/shared: 3.73.0
react: 19.2.4
react-dom: 19.2.4
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 25.2.0: Tue Nov 18 21:09:55 PST 2025; root:xnu-12377.61.12~1/RELEASE_ARM64_T8103
Available memory (MB): 8192
Available CPU cores: 8