Skip to content

[MCP Plugin] Rejects valid blocks payloads due to strict JSONSchema→Zod conversion #15399

@rdgomt

Description

@rdgomt

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions