MW
Tutorial 16 min read

Building a WordPress MCP Server

Step-by-step scaffold of an MCP server that wraps a WordPress site. Architecture, prerequisites, project layout, and your first typed tool — in both Node.js and PHP.

WordPress MCP Node.js PHP Tutorial
Auto-cycle
1. The agent calls a typed tool with structured arguments.

Click a node to step through

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.

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
Typed Authenticated Auditable

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.

npm ts

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.

composer phpstan

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.

REST auth

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.

.env bash
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.

terminal bash
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).

src/server.ts typescript
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

Verify the skeleton runs by piping a stdio handshake at it:

terminal bash
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.

terminal bash
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.

src/server.php php
<?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.

src/server.ts typescript
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:

Request
request.json json
{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "posts.create",
    "arguments": {
      "title":  "Hello from MCP",
      "content":"Drafted by an agent.",
      "status": "draft"
    }
  }
}
Response
response.json json
{
  "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.

You shipped a real MCP server

One typed tool. One agent. One WordPress install.

Everything from here on is composition.

  • Typed
  • Scoped
  • Agent-native