We build Payload sites where a page is not a form. It is a tree. A page holds a blocks field, the blocks hold other blocks, one of those holds a rich text field, and that rich text field holds blocks of its own. Editors like it, because it means a new page shape is a matter of arranging what already exists rather than asking us for a template.
Then we wanted agents doing content work on those pages, and the shape that had made the pages pleasant to edit made them awkward to describe. The content model was never the hard part. Explaining it to something that has to hold the whole explanation at once was.
So we built payloadcms-mcpx, a Payload plugin that mounts an MCP server whose tool surface stays the same size no matter how large the content model gets.
💥 Collection of common packages for Payload CMS
The page is bigger than the edit
Two things have to travel between a client and a CMS, and on a deeply nested content model both of them get expensive for the same reason.
The field shapes have to get out. Somewhere the client has to learn that sections accepts a sectionWrapper, that a sectionWrapper holds a rich text field, and that the rich text field accepts a callout block whose tone is one of two values. Handing that over as one document means handing over the entire content model before the first question has been asked, and it means the surface grows every time somebody adds a block.
The change has to get back. If a write is expressed as the document, then changing one select field means reading a few hundred nodes, holding them, and writing all of them back. Every field the client only partly understood is now part of the payload, and any of them can come back subtly different.

Both problems have the same shape. The unit of work is a node, and both halves of the surface were built around the document.
Schema on demand
describeSchema is a walker rather than a dump. Called with no paths it returns the root fields of a collection or a global. Traversal stops at every blocks field and every rich text field, and each response carries a next array of ready-to-use paths for what lies past that boundary. You pass an entry of next straight back as paths to descend one level further.
{ "path": "/layout/sections", "type": "blocks", "blocks": ["sectionWrapper", "richText"], "next": ["/layout/sections/sectionWrapper", "/layout/sections/richText"]}A block is described as it exists at that position, not as a generic definition, so a block reused in three places under three different constraints reads correctly in all three. Constraints travel with the field they belong to: minRows and maxRows on arrays and blocks fields, maxLength on text, min and max on numbers. So does admin.description, which means the intent somebody wrote for the admin panel reaches the client instead of stopping at the UI.
Rich text works the same way. The field lists the Lexical node types it accepts, and next carries a path for every node type that holds fields of its own: /content/link for a link node, /content/block/callout for a block node. That matters because a link extended through LinkFeature({ fields }) has a real field list that is worth reading rather than guessing. The response also reports nodeOptions, the node properties the editor narrows, so an editor configured with HeadingFeature({ enabledHeadingSizes: ["h4"] }) answers { "heading": { "tag": ["h4"] } } and a write carrying any other tag comes back refused. Lexical itself stores whatever tag it is handed, so this is the only place that restriction is ever checked.
The point of all of it is that the cost of knowing the content model is paid per node actually visited. Adding one more block type to a page does not change the tool list, and does not cost anything at all until somebody walks into it.
Patches, not documents
Writes are RFC 6902 operations addressed by JSON Pointer, applied server-side against the real document.
[ { "op": "replace", "path": "/layout/sections/1/content/root/children/7/fields/tone", "value": "warning" }]Two things make this fit. Models write pointers accurately, more accurately than they reproduce a large nested object. And a pointer names exactly one place, so the nodes nobody mentioned are never sent, which is a stronger guarantee than sending them and hoping they come back unchanged.

The one real subtlety is that a schema path and a document pointer are not the same string. They differ only in what stands in an element position: a schema path writes * for an array element and names a block by its slug, where a pointer carries a 0-based index. /items/*/title is written at /items/0/title, and /layout/sections/hero at /layout/sections/0.
Inside a rich text field that substitution does not apply, because an editor state is a tree rather than a list per type. A pointer enters the state at root and walks children by an index counted over every child at that level, not over the blocks among them, with a node’s own fields under fields. So the schema path /content/block/callout/tone is written at the pointer /content/root/children/7/fields/tone, and only the stored state says which index that is. getDocument in outline mode answers exactly that question: one line per node with its pointer, its version and an excerpt, which is enough to pick a position without holding the whole state.
Batches are atomic, so nothing applies unless every operation validates first. Pass the updatedAt you read back as expectedUpdatedAt and a concurrent edit is refused rather than overwritten. Anything Payload left unchanged anyway, a field-level access denial for instance, comes back listed under notApplied instead of being reported as a success.
Every write lands in the draft. That is enforced by a hook on each exposed collection and global rather than by the tool being polite about it, and each write returns the publish blockers still standing between that draft and a publish. Getting to live is a separate tool, publishDocument, and it only exists where the config asked for it.
Status
payloadcms-mcpx is Apache-2.0 and published on npm as @abinnovision/payloadcms-mcpx. It ships as ESM only, matching Payload itself.
The scope is deliberately narrow. There is no file handling, no delete tool, and publish blockers are checked for the locale that was written rather than across all of them. So far we only run it against our own content models, which are one shape out of many, so feedback is welcome. Issues and questions go on the repository.
