MW
Home Blog WordPress MCP server setup: a practical walkthrough

WordPress MCP server setup: a practical walkthrough

From empty folder to Claude Desktop running typed tools against your WordPress site. Step-by-step setup of an MCP server that wraps the REST API.

Mahesh Waghmare
Mahesh Waghmare
11 min read
Share:
This is a comprehensive guide based on real-world experience and best practices from production projects.

WORDPRESS MCP SERVER SETUP

Advertisement

This is the setup spoke of the WordPress + MCP cluster. If you haven’t read the hub yet, the short version is: MCP is a protocol that lets AI clients (Claude Desktop, Cursor, agent loops) discover and call typed tools on a server. We’re going to wrap WordPress in one.

By the end of this walkthrough you’ll have a TypeScript MCP server running locally, talking to a real WordPress install via the REST API, exposing four typed tools (posts.list, posts.get, posts.create, posts.update), and connected to Claude Desktop. Total time: about thirty minutes if you have Node 20+ and a WordPress site with the REST API enabled.

Choosing the transport

MCP supports two transports: stdio (the server runs as a child process of the client) and HTTP+SSE (the server runs as a daemon the client connects to). For a local-first setup — your laptop, your WordPress, your Claude Desktop — stdio is simpler, has no networking surface, and is what every client supports first. We’ll use stdio here.

If you’re hosting a multi-user MCP server later (an agency offering “AI for your WordPress” to clients), you’ll want HTTP+SSE so multiple clients can connect. The tool definitions are identical; only the transport layer changes.

Project scaffold

mkdir wp-mcp && cd wp-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
npx tsc --init

In tsconfig.json set "module": "node16", "target": "es2022", "moduleResolution": "node16", "strict": true. Add "type": "module" to package.json so Node treats your .ts output as ESM.

Create src/index.ts — this is the entire scaffold:

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';

const WP_BASE = process.env.WP_BASE!;           // e.g. https://example.com/wp-json/wp/v2
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');

const server = new Server(
  { name: 'wp-mcp', version: '0.1.0' },
  { capabilities: { tools: {} } },
);

That’s the skeleton: a server identifier, declared tools capability, and credentials for WordPress. The WP_BASE URL points at the REST API root for your site.

Generating credentials in WordPress

Don’t use your normal admin password. WordPress ships application passwords for exactly this kind of integration — a per-application credential that can be revoked independently and never enters a human’s keyboard.

In your WordPress admin: Users → Profile → scroll to “Application Passwords” → enter wp-mcp-local as the name → “Add New Application Password”. WordPress shows the password once. Copy it, paste it into a .env file in your project root:

WP_BASE=https://example.com/wp-json/wp/v2
WP_USER=your-wp-username
WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx

Application passwords inherit the user’s capabilities, which means whatever role you generate this on, the MCP server gets the same powers. For a setup walkthrough, use your admin account; for anything else, read the auth and security spoke before you ship.

Registering tools

MCP tools are declared by name + JSON schema for the input. Here’s posts.list:

server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: 'posts_list',
      description: 'List posts. Newest first. Filter by status and search query.',
      inputSchema: {
        type: 'object',
        properties: {
          per_page: { type: 'number', minimum: 1, maximum: 100, default: 10 },
          status:   { type: 'string', enum: ['publish', 'draft', 'pending', 'any'], default: 'publish' },
          search:   { type: 'string' },
        },
      },
    },
    // ...other tool declarations
  ],
}));

Two things to notice. Tool names use snake_case and a stable prefix (posts_, users_, comments_) so the client can group them sensibly. The description is for the LLM — write it like a docstring, not a marketing blurb. “List posts” beats “Get an awesome posts experience” every time.

Then the dispatcher — one handler routes all tool calls by name:

const ListInput = z.object({
  per_page: z.number().int().min(1).max(100).default(10),
  status:   z.enum(['publish', 'draft', 'pending', 'any']).default('publish'),
  search:   z.string().optional(),
});

server.setRequestHandler(CallToolRequestSchema, async (req) => {
  switch (req.params.name) {
    case 'posts_list': {
      const args = ListInput.parse(req.params.arguments ?? {});
      const qs = new URLSearchParams();
      qs.set('per_page', String(args.per_page));
      qs.set('status', args.status);
      if (args.search) qs.set('search', args.search);
      const res = await fetch(`${WP_BASE}/posts?${qs}`, { headers: { Authorization: authHeader } });
      if (!res.ok) throw new Error(`WP error ${res.status}: ${await res.text()}`);
      const posts = await res.json();
      const summary = posts.map((p: any) => ({
        id: p.id,
        slug: p.slug,
        title: p.title.rendered,
        status: p.status,
        date: p.date,
      }));
      return { content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }] };
    }
    // posts_get, posts_create, posts_update follow the same pattern
    default:
      throw new Error(`Unknown tool: ${req.params.name}`);
  }
});

Three patterns to copy from this snippet. Validate input with Zod — never trust what the LLM passes. Shape the response down — the REST API returns 30+ fields per post; the model only needs 5, and trimming the payload saves tokens. Throw on error — MCP wraps thrown errors as tool_error responses the client can surface cleanly.

Finally, wire it to stdio and start:

const transport = new StdioServerTransport();
await server.connect(transport);

Run it once to sanity-check:

npx tsx src/index.ts

You should see nothing — stdio servers are silent until a client connects. Press Ctrl-C and move on.

Connecting to Claude Desktop

On macOS, Claude Desktop reads MCP server config from ~/Library/Application Support/Claude/claude_desktop_config.json. Add an entry:

{
  "mcpServers": {
    "wp": {
      "command": "npx",
      "args": ["tsx", "/absolute/path/to/wp-mcp/src/index.ts"],
      "env": {
        "WP_BASE": "https://example.com/wp-json/wp/v2",
        "WP_USER": "your-wp-username",
        "WP_APP_PASSWORD": "xxxx xxxx xxxx xxxx xxxx xxxx"
      }
    }
  }
}

Restart Claude Desktop. The new “wp” server appears in the bottom-right of any chat. Click it, you’ll see your four tools listed.

Try: “List my five most recent draft posts.” Claude calls posts_list with { per_page: 5, status: "draft" }, shows you the JSON it got back, then summarises it. Every call shows up in the audit panel — you approve each one the first time.

What’s next

You now have the smallest useful WordPress MCP server. Before you give this to anyone else — or wire it to anything that can publish — read the auth and security spoke. It covers scoping application passwords, capability gating, rate limits, and the exact threat model you should hold in your head.

After that, the tools-and-resources spoke goes deep on tool granularity — when to split posts.update into ten smaller intent-shaped tools, when to keep one big one, and which resources to expose for retrieval.

Get weekly notes in your inbox

Practical tips, tutorials and resources. No spam.