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:
| Tool | Purpose |
|---|---|
create_documents_from_json | Create documents from JSON |
create_documents_from_markdown | Create documents from Markdown |
patch_document_from_json | Partially 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 textreplace_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
| Principle | Meaning |
|---|---|
| Split tools by format | If the input format changes, split the tool |
| Prioritize predictability | Explicit choice beats auto-detection |
| Separate publish/delete | State transitions should not live inside CRUD |
| Expose schema lookup | Let agents inspect structure first |
| Aim for 5 to 15 tools | Protect 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:
| Tool | Target | Input |
|---|---|---|
create_post | Post | Markdown |
create_page | Page | HTML + CSS |
Replace content:
| Tool | Target | Input |
|---|---|---|
update_post_content | Post | Markdown |
update_page_content | Page | HTML + CSS |
Partial edits:
| Tool | Target | Purpose |
|---|---|---|
patch_text | Shared | Search and replace text |
patch_post_blocks | Post | Add, update, delete, and move blocks |
patch_page_html | Page | Replace 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.
- 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."
- Prefer explicit choice over auto-detection. Do not infer the content type at runtime if the decision can be made when choosing the tool.
- Keep state transitions in separate tools. Do not mix
publishorarchiveintoupdate. - 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.
- Shape read responses for agents. Do not return raw JSON as-is. Return something agents can use immediately.
- Tool names are documentation. Names like
create_post,create_page, andpatch_post_blocksshould 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.