Technical

How to Build an MCP Server for WordPress from Scratch

February 20, 202615 min readby Rahul Patel

A step-by-step guide to building a Model Context Protocol server that lets Claude manage WordPress content. Zod schemas, tool definitions, and REST API integration.

MCPWordPressAITypeScriptCMS MCP Hub
Share:

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}`);
  }
}
Warning

WordPress 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);
});
Warning

MCP 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.

Tip

The 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.

more from the notebook

March 1, 2026Technical

AsyncLocalStorage in Node.js: The Complete Guide with Real Examples

Everything you need to know about AsyncLocalStorage from basic context tracking to building a full request-scoped loggi…

January 15, 2026Building

589 MCP Tools in One Monorepo: How I Built CMS MCP Hub

Architecture decisions behind CMS MCP Hub a Turborepo monorepo of MCP servers for 12 CMS platforms. Zod validation, uni…