All tests

PRISM JOURNAL

CMS MCP Refactoring: CRUD Design Principles

This article breaks down the problems that appear when posts and pages are handled by a single tool in a CMS MCP server. Drawing on official MCP examples, it explains why content CRUD tools should be split by format.

CMS MCP Refactoring: CRUD Design Principles

This post summarizes the trial and error I went through while designing an MCP server that lets AI agents write blog posts and build landing pages, along with patterns I learned from official MCP servers such as Sanity and Notion.


The problem: "One tool is doing too much"

Our CMS has two kinds of content.

  • Posts: blog articles written in a block editor based on Editor.js
  • Pages: landing pages and company pages built with the GrapesJS visual builder

At first, I handled both with a single MCP tool.

{
  "name": "create_post",
  "description": "Creates a post or a page"
}

Inside one tool, the server branched into a post if it found a markdown field, and a page if it found a grapes field. Partial edits worked the same way: I packed eight operation types into a single patch_content tool.

{
  "name": "patch_content",
  "operations": [
    { "op": "replace_text", "search": "...", "replace": "..." },
    { "op": "insert_block", "blockIndex": 2, "block": { "type": "paragraph", "data": { "text": "..." } } },
    { "op": "replace_section", "selector": "#hero", "html": "..." },
    { "op": "update_css", "css": "..." }
  ]
}

Because operations for the block editor and the visual builder were mixed together, AI agents had no clear idea which operation to use. If they sent the wrong kind, all they got back was an error saying it was "for posts only."


Research: How Sanity, Notion, and Contentful approached it

Before refactoring, I reviewed the official MCP servers of major CMS products.

Sanity: "Predictability beats AI magic"

Sanity MCP removed its AI-powered mutation tools in v2.6.0 and replaced them with direct field-level patches. The reason stood out to me:

"more predictable, easier to debug"

It also makes the input format explicit in the tool name:

ToolPurpose
create_documents_from_jsonCreate documents from JSON
create_documents_from_markdownCreate documents from Markdown
patch_document_from_jsonPartially update documents with JSON

If the format changes, the tool changes too. One tool is not expected to auto-detect multiple formats.

Notion: "Markdown as an abstraction layer"

Notion MCP internally works with more than 28 block types, but its MCP tools use Notion-flavored Markdown as the input and output format. Block JSON costs 10 to 20 times more tokens.

For partial edits, it reduced everything to two commands:

  • update_content — search and replace text
  • replace_content — replace the entire body

Instead of exposing eight operation types, it exposes only two clear choices.

Contentful: "Publishing is a separate tool"

Contentful separates publish_entry from create_entry and update_entry. State transitions are not mixed into field updates.

Shared patterns

PrincipleMeaning
Split tools by formatIf the input format changes, split the tool
Prioritize predictabilityExplicit choice beats auto-detection
Separate publish/deleteState transitions should not live inside CRUD
Expose schema lookupLet agents inspect structure first
Aim for 5 to 15 toolsProtect the context budget

Refactoring: From a mixed model to format-specific tools

Before (v2.x): 3 mixed tools

create_post     → handles both posts and pages
update_post     → accepts markdown as well as html/css
patch_content   → 8 operation types in one tool

To use these tools, the AI first had to figure out whether the content was a post or a page, then select the right fields to send. If it guessed wrong, the request was silently ignored or failed only at runtime.

After (v3.0): 7 format-specific tools

Create:

ToolTargetInput
create_postPostMarkdown
create_pagePageHTML + CSS

Replace content:

ToolTargetInput
update_post_contentPostMarkdown
update_page_contentPageHTML + CSS

Partial edits:

ToolTargetPurpose
patch_textSharedSearch and replace text
patch_post_blocksPostAdd, update, delete, and move blocks
patch_page_htmlPageReplace sections and update CSS

The tool count went from 3 to 7, but each tool's input schema became much simpler. An AI agent can tell from the name alone what format a tool accepts.


Unifying storage in rawContent

Posts and pages share the same database schema. They both use the raw_content field in the page_translations table, but the stored format is different.

Post (Editor.js):

{
  "blocks": [
    { "type": "header", "data": { "text": "Title", "level": 2 } },
    { "type": "paragraph", "data": { "text": "Body text goes here." } }
  ],
  "version": "2.28.0"
}

Page (GrapesJS):

{
  "type": "grapes",
  "builderState": { "pages": [{ "name": "Page", "component": "..." }] },
  "html": "
...
...
", "css": "#hero h1 { font-size: 3rem; } ..." }

The type: "grapes" field acts as a discriminator. When loading content, the system checks this field to decide which editor to open.

The content field stores renderable HTML. For pages, I combined <style>CSS</style>HTML into self-contained HTML so the page can render immediately without loading a separate CSS asset.


Why partial editing is hard

A request like "change the title text" sounds simple, but internally it is a completely different operation depending on the content type.

Post (block-based):

{
  "op": "update",
  "blockIndex": 0,
  "block": { "data": { "text": "New title" } }
}

You find the block by index and merge the data field.

Page (HTML-based):

{
  "op": "replace_section",
  "selector": "#hero",
  "html": "

New title

" }

You find the DOM node by CSS selector and replace it as a whole. Because this had to run server-side without a DOM parser, I used regular-expression matching based on ID selectors.

That difference is why a single patch tool inevitably becomes complex. Following Sanity's principle and splitting the tools was the right call.

The only thing that can truly stay shared is text search and replace (patch_text). That operation works the same regardless of format.


Checklist for designing MCP tools for CMS workflows

These are the design principles I ended up with.

  1. If the input format changes, split the tool. "This tool only accepts Markdown" is clearer for AI than "This tool accepts Markdown or HTML/CSS."
  2. Prefer explicit choice over auto-detection. Do not infer the content type at runtime if the decision can be made when choosing the tool.
  3. Keep state transitions in separate tools. Do not mix publish or archive into update.
  4. Match partial edits to the content structure. Use block-level edits for block editors and selector-level edits for visual builders. The only broadly shared operation is text replacement.
  5. Shape read responses for agents. Do not return raw JSON as-is. Return something agents can use immediately.
  6. Tool names are documentation. Names like create_post, create_page, and patch_post_blocks should reveal the target and the format without extra explanation.

Result

The number of tools increased from 3 to 7, but the input schema for each tool became much clearer. Errors like "sending Grapes fields to a post" or "sending block operations to a page" became structurally impossible.

This refactoring made me feel firsthand why Sanity removed AI-powered mutations and went back to explicit patches. AI tools are not better when they are smarter. They are better when they are more predictable.

#CMS MCP#MCP server#content CRUD#tool design#refactoring#Sanity MCP#Notion MCP
kojaen