MCP Server for Your CMS: Let AI Agents Read and Write Content
I spent the last three months building MCP servers for client projects, and I'm now convinced this is how every CMS will be accessed within two years. Not through dashboards. Not through REST APIs that humans manually call. Through AI agents that understand your content model and can read, create, and update content on your behalf.
The Model Context Protocol (MCP) is an open standard that Anthropic released in late 2024. It gives AI models a structured way to interact with external tools and data sources. Think of it as a universal adapter between an LLM and... anything. Your database. Your file system. Your CMS.
This article walks through building MCP servers for three popular CMS backends: Payload CMS, Supabase (used as a headless CMS), and WordPress. We'll cover the architecture, the actual code, and the gotchas I ran into along the way.
Table of Contents
- What Is MCP and Why Should You Care
- MCP Architecture for CMS Integration
- Building an MCP Server for Payload CMS
- Building an MCP Server for Supabase
- Building an MCP Server for WordPress
- Comparing the Three Approaches
- Security Considerations
- Real-World Use Cases
- FAQ
What Is MCP and Why Should You Care
The Model Context Protocol is essentially a JSON-RPC 2.0 based protocol that defines how AI models communicate with external systems. Before MCP, if you wanted Claude or GPT to interact with your CMS, you'd build a custom function-calling integration for each model. Different schemas, different auth flows, different everything.
MCP standardizes this. You build one server, and any MCP-compatible client (Claude Desktop, Cursor, Cline, custom agents) can connect to it.
Here's what makes this interesting for CMS work specifically:
- Content teams can ask an AI to "find all blog posts published last month that mention React" and get real results from their actual CMS
- Developers can have their coding assistant create and update content entries without leaving their editor
- Editorial workflows can be automated -- an AI agent that drafts content, checks it against your style guide, and publishes it through your existing workflow
The protocol defines three primitives:
- Tools -- Functions the AI can call (like
create_post,update_entry,search_content) - Resources -- Data the AI can read (like your content schema, recent posts, media library)
- Prompts -- Reusable prompt templates for common operations
For CMS integration, you'll mostly work with Tools and Resources.
MCP Architecture for CMS Integration
Before we write code, let's understand the architecture. An MCP server sits between the AI client and your CMS:
[AI Client] <--MCP Protocol--> [MCP Server] <--REST/GraphQL/SDK--> [Your CMS]
(Claude) (stdio/SSE) (Node.js) (HTTP/DB) (Payload/WP/Supabase)
The MCP server exposes tools and resources that map to your CMS operations. The transport layer can be:
- stdio -- For local development, the AI client spawns the MCP server as a subprocess
- SSE (Server-Sent Events) -- For remote servers, uses HTTP with SSE for streaming
- Streamable HTTP -- The newer transport option in the 2025 spec revision
For CMS servers, I recommend starting with stdio for development and moving to SSE or Streamable HTTP for production deployments where the MCP server runs on your infrastructure alongside the CMS.
The Basic Server Skeleton
Every MCP server follows the same pattern. Here's the skeleton in TypeScript using the official @modelcontextprotocol/sdk:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "my-cms-mcp",
version: "1.0.0",
});
// Define a tool
server.tool(
"search_content",
"Search for content entries by query",
{
query: z.string().describe("Search query"),
collection: z.string().describe("Collection/post type to search"),
limit: z.number().optional().default(10),
},
async ({ query, collection, limit }) => {
// Your CMS-specific logic here
const results = await searchCMS(query, collection, limit);
return {
content: [
{
type: "text",
text: JSON.stringify(results, null, 2),
},
],
};
}
);
// Define a resource
server.resource(
"schema",
"cms://schema",
async (uri) => ({
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(await getCMSSchema()),
},
],
})
);
const transport = new StdioServerTransport();
await server.connect(transport);
That's the foundation. Now let's build real implementations.
Building an MCP Server for Payload CMS
Payload is my favorite CMS for headless projects (and we use it heavily in our headless CMS development work). It's TypeScript-native, has a great API, and its collection-based architecture maps beautifully to MCP tools.
Setting Up the Payload MCP Server
First, install the dependencies:
mkdir payload-mcp-server && cd payload-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
Here's the full implementation. Payload's REST API is clean enough that this is surprisingly straightforward:
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const PAYLOAD_URL = process.env.PAYLOAD_URL || "http://localhost:3000";
const PAYLOAD_API_KEY = process.env.PAYLOAD_API_KEY || "";
const headers = {
"Content-Type": "application/json",
Authorization: `users API-Key ${PAYLOAD_API_KEY}`,
};
async function payloadFetch(path: string, options: RequestInit = {}) {
const res = await fetch(`${PAYLOAD_URL}/api${path}`, {
...options,
headers: { ...headers, ...options.headers },
});
if (!res.ok) {
throw new Error(`Payload API error: ${res.status} ${await res.text()}`);
}
return res.json();
}
const server = new McpServer({
name: "payload-cms-mcp",
version: "1.0.0",
});
// List all collections
server.tool(
"list_collections",
"List all available Payload CMS collections",
{},
async () => {
// Payload doesn't have a meta endpoint for this by default,
// so we hit the config or hardcode known collections
const collections = ["posts", "pages", "media", "categories"];
return {
content: [{ type: "text", text: JSON.stringify(collections) }],
};
}
);
// Search/list documents in a collection
server.tool(
"find_documents",
"Find documents in a Payload collection with optional filtering",
{
collection: z.string().describe("Collection slug (e.g., 'posts', 'pages')"),
where: z.string().optional().describe("Payload where query as JSON string"),
limit: z.number().optional().default(10),
page: z.number().optional().default(1),
sort: z.string().optional().describe("Field to sort by, prefix with - for desc"),
},
async ({ collection, where, limit, page, sort }) => {
const params = new URLSearchParams();
params.set("limit", String(limit));
params.set("page", String(page));
if (sort) params.set("sort", sort);
if (where) {
try {
const whereObj = JSON.parse(where);
// Convert to Payload query string format
for (const [key, val] of Object.entries(whereObj)) {
if (typeof val === "object" && val !== null) {
for (const [op, v] of Object.entries(val as Record<string, unknown>)) {
params.set(`where[${key}][${op}]`, String(v));
}
} else {
params.set(`where[${key}][equals]`, String(val));
}
}
} catch {
return { content: [{ type: "text", text: "Invalid where JSON" }] };
}
}
const data = await payloadFetch(`/${collection}?${params}`);
return {
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
};
}
);
// Get a single document
server.tool(
"get_document",
"Get a single document by ID from a Payload collection",
{
collection: z.string(),
id: z.string().describe("Document ID"),
},
async ({ collection, id }) => {
const data = await payloadFetch(`/${collection}/${id}`);
return {
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
};
}
);
// Create a document
server.tool(
"create_document",
"Create a new document in a Payload collection",
{
collection: z.string(),
data: z.string().describe("Document data as JSON string"),
},
async ({ collection, data }) => {
const result = await payloadFetch(`/${collection}`, {
method: "POST",
body: data,
});
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
};
}
);
// Update a document
server.tool(
"update_document",
"Update an existing document in a Payload collection",
{
collection: z.string(),
id: z.string(),
data: z.string().describe("Partial document data as JSON string"),
},
async ({ collection, id, data }) => {
const result = await payloadFetch(`/${collection}/${id}`, {
method: "PATCH",
body: data,
});
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
};
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
Connecting to Claude Desktop
Add this to your Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json on Mac):
{
"mcpServers": {
"payload-cms": {
"command": "npx",
"args": ["tsx", "/path/to/payload-mcp-server/src/index.ts"],
"env": {
"PAYLOAD_URL": "http://localhost:3000",
"PAYLOAD_API_KEY": "your-api-key-here"
}
}
}
}
Restart Claude Desktop, and you can now ask it things like "Find all draft posts in my CMS" or "Create a new blog post about MCP servers."
Building an MCP Server for Supabase
Supabase is interesting because it's not a CMS out of the box -- it's a Postgres database with a REST API. But a lot of teams use it as one, especially with custom admin panels built in Next.js (something we do regularly in our Next.js development practice).
The Supabase MCP server is actually the most powerful of the three because you get direct SQL access.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createClient } from "@supabase/supabase-js";
import { z } from "zod";
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // Use service role for server-side
);
const server = new McpServer({
name: "supabase-cms-mcp",
version: "1.0.0",
});
// List all tables (content types)
server.tool(
"list_tables",
"List all tables in the public schema",
{},
async () => {
const { data, error } = await supabase.rpc("get_tables");
// You'll need a Postgres function for this, or use:
const { data: tables } = await supabase
.from("information_schema.tables" as any)
.select("table_name")
.eq("table_schema", "public");
return {
content: [{ type: "text", text: JSON.stringify(tables, null, 2) }],
};
}
);
// Query content with filters
server.tool(
"query_content",
"Query rows from a table with optional filters",
{
table: z.string().describe("Table name"),
select: z.string().optional().default("*").describe("Columns to select"),
filters: z.array(z.object({
column: z.string(),
operator: z.enum(["eq", "neq", "gt", "lt", "gte", "lte", "like", "ilike", "is"]),
value: z.string(),
})).optional().describe("Array of filter conditions"),
limit: z.number().optional().default(20),
orderBy: z.string().optional(),
ascending: z.boolean().optional().default(false),
},
async ({ table, select, filters, limit, orderBy, ascending }) => {
let query = supabase.from(table).select(select).limit(limit);
if (filters) {
for (const f of filters) {
query = query.filter(f.column, f.operator, f.value);
}
}
if (orderBy) {
query = query.order(orderBy, { ascending });
}
const { data, error } = await query;
if (error) {
return { content: [{ type: "text", text: `Error: ${error.message}` }] };
}
return {
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
};
}
);
// Insert content
server.tool(
"insert_row",
"Insert a new row into a table",
{
table: z.string(),
data: z.string().describe("Row data as JSON string"),
},
async ({ table, data: rowData }) => {
const parsed = JSON.parse(rowData);
const { data, error } = await supabase.from(table).insert(parsed).select();
if (error) {
return { content: [{ type: "text", text: `Error: ${error.message}` }] };
}
return {
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
};
}
);
// Full-text search (Supabase's killer feature for CMS use)
server.tool(
"full_text_search",
"Perform full-text search on a table column",
{
table: z.string(),
column: z.string().describe("Column with tsvector index"),
query: z.string().describe("Search query"),
limit: z.number().optional().default(10),
},
async ({ table, column, query, limit }) => {
const { data, error } = await supabase
.from(table)
.select()
.textSearch(column, query)
.limit(limit);
if (error) {
return { content: [{ type: "text", text: `Error: ${error.message}` }] };
}
return {
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
};
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
One thing I love about the Supabase approach: because Supabase exposes your Postgres schema, you can create a resource that gives the AI your full schema definition. The AI then knows exactly what fields exist, what types they are, and how tables relate to each other. This makes its queries way more accurate.
Building an MCP Server for WordPress
WordPress. Still powering 43% of the web in 2025. Love it or not, you're going to encounter it.
The WordPress REST API is actually solid for MCP integration. The tricky part is authentication -- WordPress has like five different auth methods and none of them are great.
For MCP servers, I recommend Application Passwords (built into WordPress since 5.6):
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const WP_URL = process.env.WP_URL!;
const WP_USER = process.env.WP_USER!;
const WP_APP_PASSWORD = process.env.WP_APP_PASSWORD!;
const authHeader = `Basic ${Buffer.from(`${WP_USER}:${WP_APP_PASSWORD}`).toString("base64")}`;
async function wpFetch(endpoint: string, options: RequestInit = {}) {
const res = await fetch(`${WP_URL}/wp-json/wp/v2${endpoint}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: authHeader,
...options.headers,
},
});
if (!res.ok) {
throw new Error(`WP API error: ${res.status} ${await res.text()}`);
}
return res.json();
}
const server = new McpServer({
name: "wordpress-mcp",
version: "1.0.0",
});
server.tool(
"search_posts",
"Search WordPress posts",
{
search: z.string().optional().describe("Search query"),
status: z.enum(["publish", "draft", "pending", "private", "any"]).optional().default("any"),
per_page: z.number().optional().default(10),
categories: z.array(z.number()).optional(),
tags: z.array(z.number()).optional(),
after: z.string().optional().describe("ISO 8601 date -- only posts after this date"),
before: z.string().optional().describe("ISO 8601 date -- only posts before this date"),
},
async (params) => {
const searchParams = new URLSearchParams();
if (params.search) searchParams.set("search", params.search);
if (params.status) searchParams.set("status", params.status);
searchParams.set("per_page", String(params.per_page));
if (params.categories) searchParams.set("categories", params.categories.join(","));
if (params.tags) searchParams.set("tags", params.tags.join(","));
if (params.after) searchParams.set("after", params.after);
if (params.before) searchParams.set("before", params.before);
const posts = await wpFetch(`/posts?${searchParams}`);
// Strip HTML from content for cleaner AI consumption
const cleaned = posts.map((p: any) => ({
id: p.id,
title: p.title.rendered,
slug: p.slug,
status: p.status,
date: p.date,
excerpt: p.excerpt.rendered.replace(/<[^>]*>/g, ""),
content: p.content.rendered.replace(/<[^>]*>/g, ""),
categories: p.categories,
tags: p.tags,
}));
return {
content: [{ type: "text", text: JSON.stringify(cleaned, null, 2) }],
};
}
);
server.tool(
"create_post",
"Create a new WordPress post",
{
title: z.string(),
content: z.string().describe("Post content in HTML"),
status: z.enum(["publish", "draft", "pending"]).optional().default("draft"),
categories: z.array(z.number()).optional(),
tags: z.array(z.number()).optional(),
excerpt: z.string().optional(),
},
async (params) => {
const result = await wpFetch("/posts", {
method: "POST",
body: JSON.stringify(params),
});
return {
content: [{
type: "text",
text: `Created post "${result.title.rendered}" (ID: ${result.id}, Status: ${result.status})`,
}],
};
}
);
server.tool(
"update_post",
"Update an existing WordPress post",
{
id: z.number().describe("Post ID"),
title: z.string().optional(),
content: z.string().optional(),
status: z.enum(["publish", "draft", "pending", "trash"]).optional(),
excerpt: z.string().optional(),
},
async ({ id, ...updates }) => {
const result = await wpFetch(`/posts/${id}`, {
method: "POST",
body: JSON.stringify(updates),
});
return {
content: [{
type: "text",
text: `Updated post "${result.title.rendered}" (ID: ${result.id})`,
}],
};
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
The biggest gotcha with WordPress MCP: content comes back as rendered HTML. You'll want to strip tags for the AI's context window or you'll burn tokens on <p> tags. I learned this the hard way after watching Claude burn through 50k tokens reading five blog posts.
Comparing the Three Approaches
| Feature | Payload CMS | Supabase | WordPress |
|---|---|---|---|
| Auth Complexity | Simple (API keys) | Simple (service role key) | Medium (Application Passwords) |
| Schema Awareness | Good (typed collections) | Excellent (full Postgres schema) | Poor (no schema endpoint) |
| Content Format | Clean JSON | Clean JSON | HTML-heavy, needs sanitizing |
| Write Support | Excellent | Excellent | Good (some quirks with blocks) |
| Media Handling | Upload via API | Storage API | REST media endpoint |
| Query Flexibility | Good (Payload queries) | Excellent (SQL-level filtering) | Limited (WP_Query params) |
| Self-hosted Option | Yes | Yes (or cloud) | Yes |
| Typical Setup Time | 1-2 hours | 1-2 hours | 2-3 hours |
| Best For | Modern headless projects | Custom content structures | Existing WP sites |
Security Considerations
This is the section I almost forgot to write, and it's arguably the most important one.
When you give an AI agent write access to your CMS, you're trusting it not to delete everything. Here's how to not get burned:
Principle of Least Privilege
Create a dedicated API user/key for the MCP server with minimal permissions. In Payload, create a role that can only access specific collections. In Supabase, use Row Level Security policies. In WordPress, create a user with the Author role, not Administrator.
Add Confirmation for Destructive Operations
I always add a dryRun parameter to create/update/delete tools. When set to true, the tool returns what would happen without actually doing it. The AI can then confirm with the user before proceeding.
server.tool(
"delete_document",
"Delete a document (use dryRun first!)",
{
collection: z.string(),
id: z.string(),
dryRun: z.boolean().default(true).describe("If true, only preview the deletion"),
},
async ({ collection, id, dryRun }) => {
const doc = await payloadFetch(`/${collection}/${id}`);
if (dryRun) {
return {
content: [{
type: "text",
text: `DRY RUN: Would delete "${doc.title}" (${id}) from ${collection}`,
}],
};
}
await payloadFetch(`/${collection}/${id}`, { method: "DELETE" });
return {
content: [{ type: "text", text: `Deleted "${doc.title}" (${id})` }],
};
}
);
Rate Limiting and Audit Logging
Log every tool invocation. Seriously. You'll want to know what your AI agent did at 2 AM when your content editor asks why there are 47 new draft posts about penguins.
Never Expose Database Credentials Directly
The MCP server is the boundary. The AI never sees your database password or API key -- it only sees the tools you expose.
Real-World Use Cases
Here are patterns I've seen work well in production:
Content Migration: An AI agent that reads content from a legacy WordPress site, transforms it to match your new Payload schema, and creates entries in the new CMS. We did this for a client migrating ~3,000 posts. What would've taken weeks of manual work took a day and a half with human review.
SEO Auditing: An AI that reads all published content, checks for missing meta descriptions, thin content, and broken internal links. Writes its findings back as a structured report in the CMS.
Bulk Content Updates: "Change all references from 'our old brand name' to 'our new brand name' across all published pages." The agent reads, finds, proposes changes, and updates with human approval.
Content Calendar Management: An agent that knows your publishing schedule, checks what's in draft, and pings the team about upcoming deadlines -- all by reading CMS data.
If you're building something like this and want help, we've got deep experience with all three platforms in our headless CMS practice. You can also check our pricing page to get a sense of what a project like this costs.
FAQ
What is the Model Context Protocol (MCP)?
MCP is an open protocol created by Anthropic that standardizes how AI models interact with external data sources and tools. It uses JSON-RPC 2.0 and defines a client-server architecture where AI applications (clients) connect to servers that expose tools, resources, and prompts. Think of it as a USB-C port for AI -- one standard interface that connects to many different systems.
Can I use MCP with ChatGPT or just Claude?
MCP was created by Anthropic, so Claude has the best native support via Claude Desktop and the API. However, the protocol is open, and MCP clients exist for other platforms. OpenAI announced MCP support in March 2025. Cursor, Cline, Windsurf, and several other developer tools also support MCP. Your server works with any compatible client.
Is it safe to give AI agents write access to my CMS?
It can be, with proper guardrails. Use dedicated API credentials with minimal permissions, implement dry-run modes for destructive operations, add audit logging, and consider requiring human approval for publish actions. Never give an MCP server admin-level access. Treat it like you'd treat any third-party integration -- with appropriate trust boundaries.
Do I need to host the MCP server separately from my CMS?
For stdio transport (local development), the MCP server runs as a subprocess on your local machine. For production with remote access, you'll need to host it somewhere -- this could be on the same server as your CMS, in a Docker container alongside it, or as a separate service. Latency between the MCP server and your CMS matters, so co-locating them is ideal.
How does MCP compare to just using the CMS REST API directly with function calling?
MCP adds a standard interface layer. With raw function calling, you define custom schemas per AI provider, handle auth differently for each, and rebuild when providers change their API. MCP gives you one server that works with any compatible client. It also handles transport, error formatting, and resource discovery in a standardized way. For a single integration, the overhead might not be worth it. For anything you'll reuse, it absolutely is.
Which CMS works best with MCP?
Supabase gives you the most flexibility because you have direct database-level access and full schema introspection. Payload CMS is excellent because its TypeScript-first approach and clean REST API map naturally to MCP tools. WordPress works fine but requires more sanitization of HTML content and has a less flexible query API. The "best" choice depends on your existing stack.
Can I use MCP to manage media uploads in my CMS?
Yes, though it's a bit more involved than text content. For Payload and WordPress, you can create tools that accept base64-encoded file data or URLs and use the respective upload APIs. For Supabase, you'd use the Storage API. I'd recommend creating separate upload_media and attach_media tools rather than trying to handle file uploads inside content creation tools.
How much does it cost to run an MCP server?
The MCP server itself is lightweight -- a simple Node.js process that uses minimal resources. The real cost is in the AI tokens consumed when your agent reads and writes content. A typical content operation might use 2,000-10,000 tokens depending on how much content is being processed. At current 2025 pricing (Claude Sonnet at $3/$15 per million input/output tokens), that's fractions of a cent per operation. The infrastructure cost to host the MCP server is negligible -- a small VPS or container instance is more than enough.