In early 2025, I built CMS MCP Hub a monorepo of MCP servers covering 12 CMS platforms and 589 tools. WordPress was the first platform I tackled, and it's the best example to learn from because the WordPress REST API is well-documented, authentication is straightforward, and the use cases are obvious.
This guide walks through building a production-quality MCP server for WordPress from scratch: project setup, tool definitions, REST API integration, pagination, error handling, and connecting to Claude Desktop.
What is MCP and Why Does it Matter?
Model Context Protocol (MCP) is an open standard from Anthropic that defines how AI models connect to external tools and data sources. Instead of writing bespoke function-calling code for every AI integration, MCP gives you a standard protocol: a server exposes tools, an AI client (like Claude) calls those tools, and the server executes them and returns results.
The architecture for a WordPress MCP server:
- Claude Desktop (or any MCP client) the AI that decides which tools to call
- Your MCP server a Node.js process that registers tools and handles tool calls
- WordPress REST API the actual system being controlled
When a user tells Claude "Draft a blog post about Node.js best practices and schedule it for tomorrow", Claude calls your MCP server's create_post tool. Your server calls the WordPress REST API. The result comes back to Claude, which reports success to the user. The user never leaves their conversation interface.
Project Setup
Start with a clean TypeScript project. The @modelcontextprotocol/sdk package handles the MCP protocol you focus on the tools.
bash
mkdir mcp-wordpress && cd mcp-wordpress
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsup @types/node
npx tsc --init
package.json
{
"name": "@cms-mcp-hub/wordpress",
"version": "1.0.0",
"type": "module",
"bin": {
"mcp-wordpress": "./dist/index.js"
},
"scripts": {
"build": "tsup src/index.ts --format esm --dts --clean",
"dev": "tsup src/index.ts --format esm --watch",
"start": "node dist/index.js"
}
}
tsup.config.ts
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
clean: true,
target: "node18",
banner: {
// Required for MCP servers they communicate via stdio
js: "#!/usr/bin/env node",
},
});
WordPress Authentication: Application Passwords
WordPress 5.6+ supports Application Passwords credentials scoped to a specific application that don't expire with user sessions. This is the right authentication mechanism for MCP servers.
To generate an application password: WordPress Admin → Users → Profile → Application Passwords → enter a name → Add New Application Password. Copy the password immediately (it's shown once).
src/wordpress-client.ts
export interface WordPressConfig {
siteUrl: string; // e.g. "https://mysite.com"
username: string; // WordPress username
appPassword: string; // Application password (spaces are fine)
}
export interface WPPost {
id: number;
title: { rendered: string; raw?: string };
content: { rendered: string; raw?: string };
status: "publish" | "draft" | "private" | "pending" | "trash";
slug: string;
date: string;
modified: string;
link: string;
categories: number[];
tags: number[];
author: number;
excerpt: { rendered: string; raw?: string };
meta: Record<string, unknown>;
}
export interface WPCategory {
id: number;
name: string;
slug: string;
description: string;
count: number;
parent: number;
}
export class WordPressClient {
private baseUrl: string;
private authHeader: string;
constructor(config: WordPressConfig) {
this.baseUrl = `${config.siteUrl.replace(/\/+$/, "")}/wp-json/wp/v2`;
// WordPress application passwords are "username:password" base64-encoded
const credentials = `${config.username}:${config.appPassword.replace(/\s/g, "")}`;
this.authHeader = `Basic ${Buffer.from(credentials).toString("base64")}`;
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: this.authHeader,
...options.headers,
},
});
if (!response.ok) {
let errorMessage = `WordPress API error: ${response.status} ${response.statusText}`;
try {
const errorBody = await response.json() as { message?: string; code?: string };
if (errorBody.message) {
errorMessage = `WordPress error: ${errorBody.message} (code: ${errorBody.code ?? "unknown"})`;
}
} catch {
// If we can't parse the error body, use the status text
}
throw new Error(errorMessage);
}
return response.json() as Promise<T>;
}
async listPosts(params: {
page?: number;
perPage?: number;
status?: string;
search?: string;
categories?: number[];
} = {}): Promise<{ posts: WPPost[]; total: number; totalPages: number }> {
const query = new URLSearchParams({
page: String(params.page ?? 1),
per_page: String(params.perPage ?? 10),
...(params.status && { status: params.status }),
...(params.search && { search: params.search }),
...(params.categories?.length && {
categories: params.categories.join(","),
}),
// Request raw content so we get unrendered text
context: "edit",
_fields: "id,title,content,status,slug,date,modified,link,categories,tags,author,excerpt",
});
const response = await fetch(
`${this.baseUrl}/posts?${query}`,
{
headers: {
Authorization: this.authHeader,
},
}
);
if (!response.ok) throw new Error(`Failed to list posts: ${response.statusText}`);
const total = parseInt(response.headers.get("X-WP-Total") ?? "0", 10);
const totalPages = parseInt(response.headers.get("X-WP-TotalPages") ?? "1", 10);
const posts = await response.json() as WPPost[];
return { posts, total, totalPages };
}
async getPost(id: number): Promise<WPPost> {
return this.request<WPPost>(`/posts/${id}?context=edit`);
}
async createPost(data: {
title: string;
content: string;
status?: WPPost["status"];
categories?: number[];
tags?: number[];
excerpt?: string;
slug?: string;
}): Promise<WPPost> {
return this.request<WPPost>("/posts", {
method: "POST",
body: JSON.stringify({
title: data.title,
content: data.content,
status: data.status ?? "draft",
categories: data.categories ?? [],
tags: data.tags ?? [],
excerpt: data.excerpt ?? "",
slug: data.slug,
}),
});
}
async updatePost(
id: number,
data: Partial<{
title: string;
content: string;
status: WPPost["status"];
categories: number[];
tags: number[];
excerpt: string;
}>
): Promise<WPPost> {
return this.request<WPPost>(`/posts/${id}`, {
method: "POST", // WordPress REST API uses POST for updates too
body: JSON.stringify(data),
});
}
async deletePost(id: number, force = false): Promise<{ deleted: boolean; previous: WPPost }> {
return this.request(`/posts/${id}?force=${force}`, { method: "DELETE" });
}
async listCategories(params: { perPage?: number; search?: string } = {}): Promise<WPCategory[]> {
const query = new URLSearchParams({
per_page: String(params.perPage ?? 100),
...(params.search && { search: params.search }),
});
return this.request<WPCategory[]>(`/categories?${query}`);
}
}
WarningWordPress uses POST (not PATCH) for updates to existing posts via the REST API. This is a quirk of the WordPress REST API that trips up developers coming from other REST APIs.
Defining MCP Tools with Zod Schemas
Each MCP tool has a name, a description, and an input schema. The description is critical it's what Claude reads to decide which tool to call for a given user request. Be specific about what the tool does and what parameters mean.
src/tools/posts.ts
import { z } from "zod";
// Zod schemas for tool input validation
export const ListPostsSchema = z.object({
page: z.number().int().positive().default(1)
.describe("Page number for pagination. Starts at 1."),
per_page: z.number().int().min(1).max(100).default(10)
.describe("Number of posts per page. Max 100."),
status: z.enum(["publish", "draft", "private", "pending", "trash", "any"])
.default("publish")
.describe("Filter by post status. Use 'any' to retrieve all statuses."),
search: z.string().optional()
.describe("Search keyword to filter posts by title or content."),
categories: z.array(z.number().int().positive()).optional()
.describe("Filter by category IDs. Use list_categories to find IDs."),
});
export const GetPostSchema = z.object({
id: z.number().int().positive().describe("The WordPress post ID."),
});
export const CreatePostSchema = z.object({
title: z.string().min(1).max(200)
.describe("Post title. Plain text, not HTML."),
content: z.string()
.describe("Post content. HTML is supported and preferred for formatting."),
status: z.enum(["publish", "draft", "private", "pending"])
.default("draft")
.describe("Publication status. Defaults to draft for safety."),
excerpt: z.string().optional()
.describe("Short summary for SEO and post listings. Plain text."),
categories: z.array(z.number().int().positive()).optional()
.describe("Array of category IDs to assign to the post."),
tags: z.array(z.number().int().positive()).optional()
.describe("Array of tag IDs to assign to the post."),
slug: z.string().regex(/^[a-z0-9-]+$/).optional()
.describe("URL slug. Auto-generated from title if omitted."),
});
export const UpdatePostSchema = z.object({
id: z.number().int().positive().describe("The ID of the post to update."),
title: z.string().min(1).max(200).optional()
.describe("New post title. Omit to keep existing."),
content: z.string().optional()
.describe("New post content. Omit to keep existing."),
status: z.enum(["publish", "draft", "private", "pending"]).optional()
.describe("New publication status. Omit to keep existing."),
excerpt: z.string().optional(),
categories: z.array(z.number().int().positive()).optional(),
tags: z.array(z.number().int().positive()).optional(),
});
export const DeletePostSchema = z.object({
id: z.number().int().positive().describe("The ID of the post to delete."),
force: z.boolean().default(false)
.describe("If true, permanently deletes. If false, moves to trash. Default false."),
});
export const ListCategoriesSchema = z.object({
per_page: z.number().int().min(1).max(100).default(100),
search: z.string().optional().describe("Search keyword to filter categories."),
});
Building the MCP Server
src/index.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { WordPressClient } from "./wordpress-client.js";
import {
ListPostsSchema,
GetPostSchema,
CreatePostSchema,
UpdatePostSchema,
DeletePostSchema,
ListCategoriesSchema,
} from "./tools/posts.js";
// Config from environment variables users set these in claude_desktop_config.json
const config = {
siteUrl: process.env.WORDPRESS_SITE_URL ?? "",
username: process.env.WORDPRESS_USERNAME ?? "",
appPassword: process.env.WORDPRESS_APP_PASSWORD ?? "",
};
if (!config.siteUrl || !config.username || !config.appPassword) {
console.error(
"Missing required environment variables: WORDPRESS_SITE_URL, WORDPRESS_USERNAME, WORDPRESS_APP_PASSWORD"
);
process.exit(1);
}
const wp = new WordPressClient(config);
const server = new Server(
{ name: "mcp-wordpress", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
// Register tool list handler
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "list_posts",
description:
"List WordPress blog posts with optional filtering by status, category, or search term. " +
"Returns post IDs, titles, statuses, dates, and excerpts. Use this before get_post to find a specific post ID.",
inputSchema: {
type: "object",
properties: {
page: { type: "number", description: "Page number for pagination. Starts at 1." },
per_page: { type: "number", description: "Number of posts per page. Max 100." },
status: {
type: "string",
enum: ["publish", "draft", "private", "pending", "trash", "any"],
description: "Filter by post status.",
},
search: { type: "string", description: "Search keyword." },
categories: {
type: "array",
items: { type: "number" },
description: "Filter by category IDs.",
},
},
},
},
{
name: "get_post",
description:
"Get the full content of a specific WordPress post by ID. " +
"Returns title, full HTML content, status, categories, tags, and metadata.",
inputSchema: {
type: "object",
properties: {
id: { type: "number", description: "The WordPress post ID." },
},
required: ["id"],
},
},
{
name: "create_post",
description:
"Create a new WordPress blog post. Defaults to draft status for safety " +
"set status to 'publish' only when explicitly asked to publish immediately.",
inputSchema: {
type: "object",
properties: {
title: { type: "string" },
content: { type: "string", description: "HTML content." },
status: { type: "string", enum: ["publish", "draft", "private", "pending"] },
excerpt: { type: "string" },
categories: { type: "array", items: { type: "number" } },
tags: { type: "array", items: { type: "number" } },
slug: { type: "string" },
},
required: ["title", "content"],
},
},
{
name: "update_post",
description: "Update an existing WordPress post. Only include fields you want to change.",
inputSchema: {
type: "object",
properties: {
id: { type: "number" },
title: { type: "string" },
content: { type: "string" },
status: { type: "string", enum: ["publish", "draft", "private", "pending"] },
excerpt: { type: "string" },
categories: { type: "array", items: { type: "number" } },
tags: { type: "array", items: { type: "number" } },
},
required: ["id"],
},
},
{
name: "delete_post",
description:
"Delete a WordPress post. By default moves to trash (recoverable). " +
"Set force=true only when explicitly asked to permanently delete.",
inputSchema: {
type: "object",
properties: {
id: { type: "number" },
force: { type: "boolean", description: "Permanent delete if true." },
},
required: ["id"],
},
},
{
name: "list_categories",
description:
"List all WordPress categories. Use this to find category IDs before creating/updating posts.",
inputSchema: {
type: "object",
properties: {
per_page: { type: "number" },
search: { type: "string" },
},
},
},
],
}));
// Register tool call handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case "list_posts": {
const params = ListPostsSchema.parse(args);
const result = await wp.listPosts(params);
return {
content: [
{
type: "text",
text: JSON.stringify({
posts: result.posts.map((p) => ({
id: p.id,
title: p.title.raw ?? p.title.rendered,
status: p.status,
date: p.date,
slug: p.slug,
excerpt: p.excerpt.raw ?? "",
categories: p.categories,
})),
pagination: {
page: params.page,
total: result.total,
totalPages: result.totalPages,
},
}, null, 2),
},
],
};
}
case "get_post": {
const { id } = GetPostSchema.parse(args);
const post = await wp.getPost(id);
return {
content: [
{
type: "text",
text: JSON.stringify({
id: post.id,
title: post.title.raw ?? post.title.rendered,
content: post.content.raw ?? post.content.rendered,
status: post.status,
slug: post.slug,
date: post.date,
modified: post.modified,
link: post.link,
categories: post.categories,
tags: post.tags,
excerpt: post.excerpt.raw ?? "",
}, null, 2),
},
],
};
}
case "create_post": {
const data = CreatePostSchema.parse(args);
const post = await wp.createPost(data);
return {
content: [
{
type: "text",
text: `Post created successfully.\nID: ${post.id}\nTitle: ${post.title.rendered}\nStatus: ${post.status}\nURL: ${post.link}`,
},
],
};
}
case "update_post": {
const { id, ...data } = UpdatePostSchema.parse(args);
const post = await wp.updatePost(id, data);
return {
content: [
{
type: "text",
text: `Post updated successfully.\nID: ${post.id}\nTitle: ${post.title.rendered}\nStatus: ${post.status}\nModified: ${post.modified}`,
},
],
};
}
case "delete_post": {
const { id, force } = DeletePostSchema.parse(args);
await wp.deletePost(id, force);
return {
content: [
{
type: "text",
text: force
? `Post ${id} permanently deleted.`
: `Post ${id} moved to trash. Use force=true to permanently delete.`,
},
],
};
}
case "list_categories": {
const params = ListCategoriesSchema.parse(args);
const categories = await wp.listCategories(params);
return {
content: [
{
type: "text",
text: JSON.stringify(
categories.map((c) => ({
id: c.id,
name: c.name,
slug: c.slug,
count: c.count,
})),
null, 2
),
},
],
};
}
default:
throw new Error(`Unknown tool: ${name}`);
}
} catch (error) {
// Return errors as text content the LLM can read this and inform the user
const message = error instanceof z.ZodError
? `Invalid parameters: ${error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ")}`
: error instanceof Error
? error.message
: "Unknown error occurred";
return {
content: [{ type: "text", text: `Error: ${message}` }],
isError: true,
};
}
});
// Start the MCP server over stdio
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
// Don't log to stdout it's reserved for MCP protocol messages
console.error("WordPress MCP server running");
}
main().catch((err) => {
console.error("Fatal:", err);
process.exit(1);
});
WarningMCP servers communicate over stdio. Never use console.log() in your server it will corrupt the protocol stream. Use console.error() for debugging output (stderr is safe).
Testing with Claude Desktop
Build the server first: npm run build. Then add it to Claude Desktop's config. On macOS, the config file is at ~/Library/Application Support/Claude/claude_desktop_config.json. On Windows it's at %APPDATA%\Claude\claude_desktop_config.json.
claude_desktop_config.json
{
"mcpServers": {
"wordpress": {
"command": "node",
"args": ["/absolute/path/to/mcp-wordpress/dist/index.js"],
"env": {
"WORDPRESS_SITE_URL": "https://yourblog.com",
"WORDPRESS_USERNAME": "your-username",
"WORDPRESS_APP_PASSWORD": "xxxx xxxx xxxx xxxx xxxx xxxx"
}
}
}
}
Restart Claude Desktop after editing the config. You should see a hammer icon in the conversation input area indicating tools are available. Test with: "List my last 5 draft posts" or "Create a draft post titled 'Hello MCP' with some placeholder content".
Handling Pagination
WordPress paginates all list endpoints. The total count and total page count are in response headers (X-WP-Total, X-WP-TotalPages). Teach Claude how to paginate by including pagination metadata in your response and keeping it human-readable:
typescript
// In list_posts handler response:
text: JSON.stringify({
posts: [ /* ... */ ],
pagination: {
currentPage: params.page,
totalPosts: result.total,
totalPages: result.totalPages,
hasMore: params.page < result.totalPages,
nextPage: params.page < result.totalPages ? params.page + 1 : null,
},
hint: result.totalPages > 1
? `Showing page ${params.page} of ${result.totalPages}. Call list_posts with page=${params.page + 1} to see more.`
: undefined,
})
The hint field is important. Claude reads all the text in a tool response and uses it to decide next steps. An explicit hint like "Call listposts with page=2 to see more"_ makes Claude much more reliably autonomous when browsing large post lists.
Key Lessons from Building 589 MCP Tools
Tool descriptions are your LLM routing config
Claude reads tool descriptions to decide which tool to call. Vague descriptions cause wrong tool selection. Be explicit: what does the tool do, when should it be used, what are the critical parameter semantics (e.g., "defaults to draft for safety")?
Zod validation is non-negotiable
Claude sometimes sends slightly wrong types a number as a string, an array with one item as a scalar. Zod's .coerce methods and .default() values handle the majority of these cases gracefully. Without Zod, you're debugging mysterious 400 errors from WordPress instead of the actual problem.
Error messages must be human-readable for the LLM
When an error occurs, return it as text content (not a thrown error). Claude reads the error text and uses it to recover or inform the user. "Post ID 9999 not found" is useful. "404" is not. "Cannot read property 'id' of undefined" is useless and scary.
Shape responses for consumption, not inspection
WordPress API responses are noisy lots of _links, rendered HTML, and fields you don't need. Strip the response down to what's useful for the LLM to read and act on. 50 fields of metadata is noise; 8 relevant fields is signal.
TipThe full CMS MCP Hub monorepo including WordPress, Shopify, Ghost, Strapi, Webflow, and 7 more CMS platforms is available as open source. Each package follows the same patterns described here. Check the GitHub repo for reference implementations.
Building MCP servers is straightforward once you understand the protocol. The value isn't in the MCP SDK (it's simple) it's in your REST API integration, your Zod schemas, and your tool descriptions. That's where the AI usability lives.