1. Overview
An MCP server for WordPress is a thin daemon that speaks the Model Context Protocol (MCP) on one side and the WordPress REST API on the other. The AI agent never touches WordPress directly. Every read or mutation flows through your server, where it gets validated by a typed schema, authenticated with a scoped service user, optionally rate-limited, and logged. By the end of this chapter you will have that daemon running locally, registered with Claude Desktop (or any MCP client), and capable of drafting a real post when an agent calls a tool.
Why a separate daemon instead of "just letting the agent hit the
REST API"? Three reasons that won't be obvious until you've shipped
one without them. First, the WP REST API exposes hundreds of routes
and the agent will happily call the wrong one — a typed MCP tool
surface lets you offer a small, hand-picked, intent-shaped subset
(posts.create instead of POST /wp/v2/posts
with seventeen optional fields). Second, application passwords are
blunt; the MCP server is where you add per-tool authorisation,
per-call audit logs, and dry-run safety on destructive verbs.
Third, every AI client wants the integration in a different shape
— Custom GPT actions, Claude config files, Cursor's
.cursor/mcp.json, the WindSurf manifest. MCP is the
one place this stops being your problem.
posts.create
— talking to your local WordPress install over the REST API. Stdio
transport. Application-password auth via a dedicated service user.
Runnable in Node.js or PHP. Total install: about twenty minutes if
your stack is warm.
The shape we ship here will compose. Once posts.create
exists, adding posts.update, media.upload,
plugins.list, and the rest of the canonical verb set
(covered in chapter 8) is mostly Zod schema authoring + a
fetch wrapper. The interesting work is the
architectural setup we do here.
2. Architecture
Four nodes, three hops. The AI client (Claude Desktop, ChatGPT, Cursor) sends a JSON-RPC request to the MCP server over its chosen transport. The MCP server validates arguments against the tool's schema, authenticates the outbound call as a scoped service user, and translates it into the appropriate REST API request. WordPress executes the request, returns a JSON payload, and the MCP server reshapes it into a typed response the client can show the model.
AI Client
(Claude / ChatGPT / Cursor)
MCP Server
(Node.js or PHP)
REST
/wp-json/wp/v2/...
WordPress
- Posts
- Users
- Plugins
- Settings
The thing worth internalising: each arrow in this diagram is a separate trust boundary. The arrow from the AI client to your MCP server is where you decide which tools to even expose. The arrow from your MCP server to WordPress is where you decide which user the call runs as — and therefore which capabilities it has. The arrow from WordPress back to the MCP server is where you decide what data leaks back to the model (stripping fields like author email, internal post meta, draft revisions of unpublished work). These are policy decisions, not protocol decisions, and the MCP server is the only place to enforce them centrally.
3. Prerequisites
Pick one runtime — Node.js or PHP — and have a WordPress site ready to be the backend. You don't need a public URL yet; local Docker on port 8080 is enough for everything in this chapter. Chapter 4 covers exposing it for remote clients.
Node.js 20+
The TypeScript MCP SDK uses native fetch and top-level await. Node 20.10+ is the floor — 22 LTS is the recommended target.
PHP 8.2+
Composer-based MCP SDK with strict types, readonly classes, and enums. Anything older than 8.2 won't load. PHP 8.3 is the comfortable target.
WordPress 6.4+
REST API enabled (the default since 4.7). Permalinks set to anything other than 'Plain' — REST 404s otherwise. Application Passwords enabled in profile.
Before you touch any code, create the service user in WordPress
admin: Users → Add New, role = Editor
(not Administrator), email = a real address you control
(WordPress sends notifications and one of them is the application-
password creation alert — you want that audit trail). Then sign
in as that user, open Users → Profile, scroll to
Application Passwords, name the password
"mcp-server-local", and copy the generated string —
it's the only time WordPress will ever show it to you in clear.
Format the credential as base64 of username:password
(the application password, not your login password) — that's the
Basic auth value you'll set on outbound requests.
Stash it in a .env file the MCP server can read, and
add .env to .gitignore before your first
commit. Yes, that warning is here because people have shipped MCP
servers to GitHub with WP_AUTH= in the README.
WP_URL=http://localhost:8080
# base64 of "mcp-bot:xxxx xxxx xxxx xxxx xxxx xxxx" — the application password
# generated above, with spaces preserved.
WP_AUTH=bWNwLWJvdDp4eHh4IHh4eHggeHh4eCB4eHh4IHh4eHggeHh4eA== 4. Setup (Node.js)
Spin up a fresh project. We're going to use the official
TypeScript SDK — @modelcontextprotocol/sdk — and
zod for input validation. dotenv reads
the .env file we just created. tsx lets
us run TypeScript without a build step during dev, which matters
because Claude Desktop spawns the server fresh on every restart
and a build step there is slow tax on iteration.
mkdir wp-mcp-server && cd wp-mcp-server
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/sdk zod dotenv
npm install -D typescript tsx @types/node
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext --strict --outDir dist
mkdir src && touch src/server.ts
Two non-obvious flags worth flagging: type=module
(top-level await needs ESM) and module=nodenext (the
MCP SDK ships as ESM with subpath exports — older module
resolutions refuse to find /server/stdio). Skip
either and the import on the first line of server.ts
fails with a cryptic "Cannot find module".
Now author the server skeleton in src/server.ts. The
important parts: name + version (clients display these in their
UI so users know what's connected), the capabilities object (this
is how MCP advertises which feature sets the server supports —
we only need tools), and the transport (stdio for
local development, HTTP+SSE for remote).
import 'dotenv/config'
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
const WP_URL = process.env.WP_URL ?? 'http://localhost:8080'
const WP_AUTH = process.env.WP_AUTH ?? ''
if (!WP_AUTH) {
console.error('[wp-mcp] WP_AUTH is empty — refusing to start')
process.exit(1)
}
export const server = new Server(
{ name: 'wp-mcp', version: '0.1.0' },
{ capabilities: { tools: {} } },
)
// tool registration goes here — added in section 6.
await server.connect(new StdioServerTransport())
console.error('[wp-mcp] connected via stdio') // stderr — stdout is the JSON-RPC channel console.log() will land inside the byte stream
Claude is parsing, and the client will silently disconnect with no
useful error. Always use console.error() for diagnostics
— Claude Desktop pipes that to the host log where you can read it.
Verify the skeleton runs by piping a stdio handshake at it:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' | npx tsx src/server.ts
You should see a single JSON line back announcing the server
name, version, and an empty tools capabilities map.
That's the handshake completing. Now we can add a tool.
5. Setup (PHP)
If your WordPress shop is PHP-first, the SDK lets you keep the same language end-to-end. The runtime model is identical to Node: a long-lived process with stdio attached to the parent client. The only real differences are syntax and the way Composer namespaces the SDK.
mkdir wp-mcp-server-php && cd wp-mcp-server-php
composer init --no-interaction --name=mw/wp-mcp --type=project --require="php:^8.2"
composer require modelcontextprotocol/sdk:^0.4 vlucas/phpdotenv:^5.6
mkdir src && touch src/server.php
The PHP MCP SDK uses readonly classes + named arguments, so PHP
8.2 is the absolute floor. On 8.1, the parser refuses the
readonly modifier on a class and you'll get
"syntax error, unexpected token 'readonly'". That error
message lies — it really means your PHP version is too old.
<?php
declare(strict_types=1);
use Dotenv\Dotenv;
use ModelContextProtocol\Server\Server;
use ModelContextProtocol\Server\Transport\StdioServerTransport;
require __DIR__ . '/../vendor/autoload.php';
Dotenv::createImmutable(__DIR__ . '/..')->safeLoad();
$wpUrl = $_ENV['WP_URL'] ?? 'http://localhost:8080';
$wpAuth = $_ENV['WP_AUTH'] ?? '';
if ($wpAuth === '') {
fwrite(STDERR, "[wp-mcp] WP_AUTH is empty — refusing to start\n");
exit(1);
}
$server = new Server(
name: 'wp-mcp',
version: '0.1.0',
capabilities: ['tools' => new \stdClass()],
);
// tool registration goes here — added in section 6.
$server->connect(new StdioServerTransport());
fwrite(STDERR, "[wp-mcp] connected via stdio\n");
Two PHP-specific gotchas worth knowing now rather than later.
First: opcache must be disabled for CLI processes, or hot
restarts (which Claude Desktop does on every reconnect) will
serve a cached stale class definition the second time around.
Either set opcache.enable_cli=0 in your CLI
php.ini or pass -d opcache.enable_cli=0
in the spawn command. Second: PHP buffers stdout by default;
call ob_implicit_flush(true) at the top of the file
if you ever return long streaming responses, or the client will
think your server has hung.
6. Your first tool (Create Post)
A tool definition has three required parts and one critical
property of the response shape. The name is what the agent calls
it — make it read like a verb (posts.create, not
postsCreate or createPost, both of which
are common in REST APIs and confuse the model). The description is
the only thing the model sees when deciding whether to use this
tool — write it for that audience, not for humans. The input
schema (a Zod object here) is enforced before the handler runs;
the handler can assume valid input. And the response — a
structured array of content blocks — is what the
agent reads back to the user.
import { z } from 'zod'
const PostsCreateInput = z.object({
title: z.string().min(1).max(160),
content: z.string().min(1),
status: z.enum(['draft', 'publish', 'private']).default('draft'),
excerpt: z.string().max(280).optional(),
})
server.tool(
'posts.create',
'Create a new WordPress post as the configured service user. ' +
'Defaults to draft. Returns the new post id and permalink.',
PostsCreateInput.shape,
async ({ title, content, status, excerpt }) => {
try {
const res = await fetch(`${WP_URL}/wp-json/wp/v2/posts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Basic ${WP_AUTH}`,
'User-Agent': 'wp-mcp/0.1.0',
},
body: JSON.stringify({ title, content, status, excerpt }),
})
if (!res.ok) {
const detail = await res.text()
return {
isError: true,
content: [{ type: 'text', text: `WP REST ${res.status}: ${detail.slice(0, 400)}` }],
}
}
const post = await res.json() as { id: number; link: string; status: string }
return {
content: [{
type: 'text',
text: `Created post #${post.id} (${post.status}): ${post.link}`,
}],
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'unknown error'
return { isError: true, content: [{ type: 'text', text: `Network error: ${msg}` }] }
}
},
)
Four things worth pointing out in that handler that aren't
obvious from reading. First, the User-Agent header
makes your traffic identifiable in WP access logs — a small
courtesy to whoever debugs the server later (probably you).
Second, !res.ok covers everything from 400 (bad
request) to 502 (WordPress down) — return a structured error
with isError: true so the agent gets a typed
failure it can describe to the user, instead of an exception
that crashes the request.
Third, the response body is truncated to 400 characters. WP REST
errors can ship multi-kilobyte HTML pages when a plugin crashes,
and the agent's context window will object. Fourth, the
try/catch around fetch handles network-
layer failures (DNS, ECONNREFUSED) that fetch throws
instead of returning a response — those would otherwise crash the
whole MCP server and disconnect the client.
Test the wired tool end-to-end with another stdio exchange:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "posts.create",
"arguments": {
"title": "Hello from MCP",
"content":"Drafted by an agent.",
"status": "draft"
}
}
} {
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [{
"type": "text",
"text": "Created post #42 (draft): http://localhost:8080/?p=42"
}]
}
}
Open http://localhost:8080/wp-admin/edit.php?post_status=draft
— the draft is there. Authored by your service user. Editable
like any human-drafted post. That's the entire feedback loop
working: agent → JSON-RPC → MCP server → WP REST → MariaDB →
agent.
posts.create with validation, permissions, error
mapping, and tests. The pattern in this chapter is the pattern
the rest of the series elaborates.