Migrating an existing WordPress API to MCP: a 4-week playbook
A clean greenfield MCP build is straightforward. Migrating from an existing, working WordPress REST API while it keeps serving production traffic is the harder shape. This playbook is what I use to move from “we have /wp-json/ and one partner consumer” to “we have /wp-json/, that partner, and an LLM-friendly MCP server in front” without breaking anything.
This article anchors to the MCP server development service pillar.
TL;DR
- Four weeks: audit, scaffold, parallel-run, cutover.
- REST stays live for existing consumers; MCP is an addition, not a replacement.
- Zod schemas come from the REST audit, not from a wishlist.
- Parallel-run with one internal agent before any external traffic touches MCP.
- Logpush captures every tool call; mismatches between MCP and REST responses surface as schema failures.
How to audit WordPress REST API endpoints
The audit is a spreadsheet. One row per endpoint, the columns are:
| Column | What goes in it |
|---|---|
| Endpoint | /wp-json/wp/v2/posts, /wp-json/wc/v3/products, etc. |
| HTTP verbs | GET, POST, PUT, DELETE supported |
| Current consumers | Storefront, partner ERP, webhook receivers |
| Traffic volume | Requests per day from access logs |
| Data sensitivity | Public, customer-only, admin-only |
| Mutates state? | Yes/no |
| Maps to MCP tool? | Proposed tool name and intent |
| Notes | Plugins involved, custom field shapes, gotchas |
For a typical WooCommerce store the spreadsheet has 20 to 60 rows. Most of them map cleanly to MCP tools; a handful (admin internals, plugin-specific endpoints, webhook receivers) stay REST-only.
The output of week 1 is two artefacts:
- The proposed tool inventory:
catalogue.list,product.detail,order.intent,order.status,inventory.check, plus whatever is specific to your build. - A Zod schema first draft for each tool’s input and output, derived from the actual REST responses you captured during the audit.
I capture REST responses with curl plus jq for shape inspection, or a Postman collection for the team to share. The point is empirical truth, not what the README says the response should look like. WordPress plugins are notorious for adding fields that documentation never gets around to mentioning; the audit catches them.
How to build an MCP server on Cloudflare Workers
The week-2 deliverable is a working MCP server, deployed to a non-production Cloudflare Workers environment, with the tool inventory from week 1 implemented as thin adapters over the existing REST endpoints.
The server skeleton:
import { createMcpHandler, hostHeaderValidationResponse, McpServer } from "@modelcontextprotocol/server";
import { catalogueListInput, catalogueListOutput, handleCatalogueList } from "./tools/catalogue-list.js";
import { productDetailInput, productDetailOutput, handleProductDetail } from "./tools/product-detail.js";
// ... one import per tool
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// reject requests whose Host is not mine (DNS rebinding guard)
const rejected = hostHeaderValidationResponse(request, [env.MCP_HOSTNAME]);
if (rejected) return rejected;
const handler = createMcpHandler(() => {
const server = new McpServer({ name: "wppoland-mcp", version: "0.1.0" });
server.registerTool(
"catalogue.list",
{
description: "List WooCommerce products with filters",
inputSchema: catalogueListInput,
outputSchema: catalogueListOutput,
annotations: { readOnlyHint: true },
},
(input) => handleCatalogueList(input, env),
);
server.registerTool(
"product.detail",
{
description: "Return one product by SKU",
inputSchema: productDetailInput,
outputSchema: productDetailOutput,
annotations: { readOnlyHint: true },
},
(input) => handleProductDetail(input, env),
);
// ... one registerTool() call per tool
return server;
});
return handler.fetch(request);
},
};The skeleton follows version 2 of the official TypeScript SDK (@modelcontextprotocol/server), which implements the 2026-07-28 revision of the specification. createMcpHandler returns an object with a fetch method, the shape a Cloudflare Workers module expects as its default export, and the factory builds a fresh McpServer for every request (SDK guide to web-standard runtimes). The same guide warns that the handler performs no Host or Origin validation on its own, which is why the guard sits in front of it. Projects still on @modelcontextprotocol/sdk 1.x keep getting bug fixes and security updates for at least six months after the v2 release on 2026-07-27 (SDK README), so a migration that starts now should target v2.
Each handler is a thin adapter. It takes the validated input, builds a query string, fetches from /wp-json/, maps the response into the schema.org-aligned output shape, runs the output parse, returns. Business logic stays in WordPress; the handler is mechanical translation.
Week 2 also includes the auth scaffolding from the MCP authentication patterns guide. For the parallel-run in week 3 I use a single test token with all scopes; production scopes get tightened in week 4.
The wrangler.toml for the preview environment points at a staging WordPress install or a copy of production. The MCP server is reachable on a *.mcp-staging.wppoland.workers.dev URL accessible only to the team.
How to test an MCP server against the REST API
Week 3 is where bugs surface. The pattern:
Build a comparison harness. A small TypeScript script that takes a tool name and an input, calls the MCP server, then calls the equivalent REST endpoint directly, and diffs the two responses. The diff is logged with the tool name, input, and a JSON-pointer-style path to each mismatch.
async function compareToolToRest(toolName: string, input: unknown) {
const mcpResponse = await callMcp(toolName, input);
const restResponse = await callEquivalentRest(toolName, input);
const diff = jsonDiff(restResponse, mcpResponse);
if (diff.length > 0) {
await logMismatch({ toolName, input, diff });
}
}Run every tool against a representative input set. For catalogue.list: empty query, a query that returns hundreds of products, a query with category filter, a query with price range, a query that returns nothing. For product.detail: known SKU, unknown SKU, SKU with variations, SKU with custom fields. Cover the edge cases the audit surfaced.
Point one internal agent at the MCP server. Claude Desktop with the MCP server configured as a remote tool source is the configuration I use. A team member spends 30 minutes per day for the week interacting with their own store via the agent, capturing surprises in a shared doc.
Run the same golden prompts on three hosts. Luca Rossi’s October 2026 essay How to design a good MCP server, written with the Manufact team, points out that the same server can produce very different outcomes in ChatGPT, Claude and the long tail of other clients, so a short set of golden prompts on a few real hosts belongs in normal QA. Its checklist adds one owner for MCP health who re-checks the server after every major MCP, Claude or ChatGPT release. In this playbook that means the week 3 agent sessions run on Claude Desktop plus two other clients your consumers actually use, and the comparison harness runs again after every protocol revision as well as after WordPress upgrades.
Tighten schemas based on what comes back. Every Zod validation failure in the log is a schema bug or a mapping bug. Fix it; redeploy the preview Worker; rerun the harness.
The output of week 3 is a passing harness with zero diffs and a tool inventory that has survived 30 minutes of real agent interaction per day for five days. If either of those is missing, week 4 does not start.
How to deploy an MCP server to production and monitor it
The cutover is anticlimactic if weeks 1 to 3 went well.
Deploy production Workers. wrangler deploy --env production. The production MCP server points at the production WordPress origin, with production-scoped tokens issued through the WordPress admin.
Issue tokens to the first real agent runtime. This is usually one of: an internal agent that staff use, a partner integration that has asked for an MCP surface, or a public-facing assistant on the storefront. Start with the smallest, lowest-risk consumer.
Wire Cloudflare Logpush to a long-term store. The log fields covered in the building an MCP server for WooCommerce article are the right defaults: tool name, input hash, latency, validation outcome, token scope. The store is whatever your team already uses (BigQuery, ClickHouse, S3 + Athena).
Set up the watch dashboard. Three queries on day one:
- Tool calls per minute, broken down by tool name. Detects burst load.
- Validation failure rate per tool, breakdown by code (
input_invalid,output_invalid). Detects regression. - Latency p50/p95/p99 per tool. Detects upstream WordPress slowness leaking through.
Keep REST live, untouched. The existing storefront, the existing partner integrations, the existing webhook receivers all keep using /wp-json/ exactly as before. MCP is an addition, not a replacement. This is the single most important rule of the migration.
Common failures when migrating REST API to MCP
Six things I have watched go wrong on real migrations:
REST response field that the audit missed. A plugin adds meta_data: [...] to product responses. The MCP output schema does not include it. The Zod parse fails on real data. Fix: rerun the audit on production traffic, expand the schema or explicitly drop the field with a .transform().
Permalink mismatch between staging and production. The MCP product.detail tool returns the WooCommerce permalink field. Staging permalinks are https://staging.example.com/...; production permalinks are https://example.com/.... Test data passes; production fails the URL validator. Fix: configure the staging Worker with a permalink-rewrite step that mirrors production behaviour.
WooCommerce variation handling. The audit captured the simple-product response shape. Variation responses are different (the variant SKUs live under /wp-json/wc/v3/products/<id>/variations). Fix: handle variations as a separate fetch in product.detail, mirror to schema.org hasVariant.
Auth token leaks into logs. A handler logs the full Authorization header for debugging. The token ends up in the log store. Fix: redact the header in the log layer; rotate every token issued before the redaction was deployed.
Plugin update breaks a REST response. WooCommerce 9.x renames a field, the MCP handler still expects the old name, the Zod parse fails. Fix: pin WooCommerce versions in staging, run the comparison harness on every WordPress upgrade, treat the harness as part of the upgrade gate.
Agent loops on a malformed tool call. A buggy agent retries order.intent 100 times in a minute when the input fails validation. Without rate limiting the WordPress origin sees 100 fan-out calls. Fix: rate-limit per principal as documented in the MCP authentication patterns guide, and return a retry_after_seconds hint in the error envelope.
How long a WordPress MCP migration takes
Four weeks is the default. Two adjustments:
Smaller surface, two weeks. Three tools, one consumer, no auth complexity. Compress audit and scaffold into week 1, parallel-run and cutover into week 2. The pattern is the same; the calendar is shorter.
Larger surface, six weeks. A dozen tools, multiple auth modes, sensitive mutating actions. Add a week between scaffold and parallel-run for security review. Add a week between parallel-run and cutover for a soft launch with a single OAuth-authorized user before the wider rollout.
The four phases stay in the same order regardless of the time budget.
Does WordPress change after adding MCP
This is worth saying explicitly. After the migration:
- The WordPress admin UI is unchanged.
- The Block Editor is unchanged.
- The user authentication system is unchanged.
- The existing REST endpoints are unchanged and continue to serve their existing consumers.
- The webhook firing logic is unchanged.
- The data layer (
wp_posts,wp_postmeta, WooCommerce tables) is unchanged.
What is added: a Cloudflare Workers MCP server, a token-issuance UI on the WordPress admin side (a small plugin or theme function), a Cloudflare Logpush configuration. Everything else is unchanged.
This is what makes the migration low-risk. If the MCP server has a bad day, you turn off the Worker. The agent surface goes down. The storefront keeps working, the partner integrations keep working, orders keep coming in.
How to map REST verbs to MCP tool annotations
The audit spreadsheet already has a “modifies state” column. In MCP that column turns into tool annotations. The schema of the 2026-07-28 specification defines four boolean hints in ToolAnnotations: readOnlyHint (default false), destructiveHint (default true), idempotentHint (default false) and openWorldHint (default true). The destructive and idempotent hints only mean something when readOnlyHint is false (MCP schema).
A mapping that follows straight from the audit columns:
| REST verb in the audit | Annotations on the MCP tool |
|---|---|
GET on /wp/v2/posts or /wc/v3/products | readOnlyHint: true |
| POST that creates a record (order intent, draft post) | readOnlyHint: false, destructiveHint: false |
| PUT or PATCH on a known ID | readOnlyHint: false, idempotentHint: true |
| DELETE | readOnlyHint: false, destructiveHint: true |
The defaults are the reason to set them. A tool registered without annotations counts as possibly destructive and not idempotent, so a read-only catalogue tool without readOnlyHint looks riskier to the client than it is. The SDK guide puts the practical effect plainly: a host can auto-approve a read-only tool and ask for confirmation before a destructive one (SDK tools guide). The specification also says clients must treat annotations as untrusted unless they come from a trusted server, so annotations never replace the capability check on the WordPress side.
Naming comes from the same specification page: tool names should be 1 to 128 characters, case-sensitive, and use only ASCII letters, digits, underscore, hyphen and dot (MCP tools). catalogue.list and product.detail fit that rule; a name copied from a REST route with slashes in it does not.
How to return WordPress REST errors to an agent
A WordPress REST error arrives as JSON with a code, a message and a data.status. The MCP specification separates two kinds of failure. Protocol errors (unknown tool, malformed request, server error) go back as standard JSON-RPC errors. Tool execution errors (API failures, input validation errors, business logic errors) go back inside the tool result with isError: true, and clients should pass them to the model so it can correct the call and retry (MCP tools, error handling).
For a REST-backed handler, a 404 for an unknown product ID or a 400 for an invalid parameter is a tool execution error. Return isError: true with a text block that tells the model what to change, for example that no product has that SKU and that catalogue.list returns valid ones. Only a failure of the MCP layer itself becomes a JSON-RPC error. Turning every upstream 4xx into a JSON-RPC error hides the reason from the model, and the agent retries blind, which is exactly the retry loop from the failures list above.
The same page sets two rules for successful results. When a tool declares an outputSchema, the server must return structured results that conform to it. A tool that returns structuredContent should also return the serialized JSON in a text block for backwards compatibility. Version 2 of the TypeScript SDK validates structuredContent against outputSchema before the result leaves the server (SDK tools guide), the same check the week 3 harness depends on.
Which credential the MCP server sends to WordPress
Two credentials meet in this architecture, and the specification keeps them apart. Authorization is optional in MCP. When a server on an HTTP transport supports it, the server acts as an OAuth 2.1 resource server: it must check that each access token was issued for it as the intended audience (RFC 8707), it must answer invalid or expired tokens with HTTP 401, and it must not accept or pass on any other token (MCP authorization). Servers on the stdio transport should skip that flow and read credentials from the environment.
For a Worker in front of /wp-json/ this rules out forwarding the agent’s token to WordPress. The agent’s token stops at the Worker. The Worker calls WordPress with a credential of its own, usually an Application Password for a dedicated WordPress user that holds only the capabilities the tool inventory needs. Application Passwords have shipped with WordPress since 5.6 and are sent with HTTP Basic authentication over HTTPS (REST API handbook, authentication). Keep that password as a Worker secret, set with wrangler secret put, not as a plain variable in wrangler.toml (Cloudflare Workers secrets).
The split has a practical upside for week 4. Rotating the WordPress credential does not touch agent tokens, and revoking an agent token does not touch the WordPress side. The audit’s “data sensitivity” column decides which capabilities the dedicated user gets: a catalogue-only inventory needs none of the order capabilities.
When the WordPress MCP Adapter is the better route
This playbook puts MCP at the edge, in a Worker. WordPress also has an official route that puts MCP inside WordPress. The MCP Adapter, part of the AI Building Blocks for WordPress initiative, exposes abilities registered through the WordPress Abilities API as MCP tools, resources and prompts. On wordpress.org the plugin is at version 0.7.1, requires WordPress 6.9 and PHP 7.4, and was last updated on 16 October 2026 (plugin page).
What the adapter’s documentation says that shapes the choice:
- Abilities are private by default. An ability reaches MCP only when
meta.public(ormeta.mcp.public) istrue. On WordPress 6.9 and 7.0 that flag governs MCP only, and REST access still needsmeta.show_in_rest; core appliesmeta.publicto REST from 7.1. - The default server exposes three meta-tools (
mcp-adapter/discover-abilities,mcp-adapter/get-ability-info,mcp-adapter/execute-ability) and answers HTTP at/wp-json/mcp/mcp-adapter-default-server. wp mcp-adapter serve --user=<id>serves a server over stdio through WP-CLI.- Without a custom transport permission callback, a server admits logged-in users (
is_user_logged_in()). - Annotations written in the Abilities API format (
readonly,destructive,idempotent) are converted toreadOnlyHint,destructiveHintandidempotentHint.
The adapter fits when the tools are WordPress logic that already lives in PHP and the agent clients can reach the site directly. The Worker route fits when the MCP surface needs its own token issuance, edge rate limits and Logpush, or has to keep answering while the WordPress origin is slow. The week 1 audit serves both: each row that maps to a tool becomes either a Worker handler or a registered ability.
Update, 24 October 2026: in Andy Peatling’s Building with WordPress benchmark for October, Opus 5.5 in Claude Code and GPT-6.1-Sol in Codex finished 0 of 42 site-owner jobs when the adapter was their only way in, and 33 of 42 with an application password for the REST API. WordPress’s own three abilities only read site, user and server information, and the only writes came from WooCommerce product abilities. Peatling’s issue #402 plans experimental core abilities in four milestones to close that gap. On 21 October the WordPress AI team recorded approval to make MCP part of the default WordPress experience, with 7.3 discussed as a possible target and no release commitment (weekly summary). Until the write abilities exist, the adapter route means writing them yourself, while the REST endpoints from the week 1 audit already work.
Update, 24 October 2026: every plugin that brings MCP into WordPress adds attack surface of its own. Wordfence published three such entries on 15 and 16 October 2026: AI Engine up to 3.8.3 missed a capability check, so a logged-in contributor could perform an unauthorised action (CVE-2026-110888); the wsp_get_posts MCP tool in WSP MCP up to 2.9.5 let any logged-in subscriber read titles, authors and excerpts of draft, pending and scheduled posts (CVE-2026-109382); and xSpeed Cache up to 1.4.2, a caching plugin with MCP features, allowed stored cross-site scripting by contributors (CVE-2026-109985). None of the three needs an administrator account. Registering abilities inside WordPress puts the same question to your own code: give every ability a permission callback that checks a specific capability, not only whether the user is logged in.
Which MCP specification version to target
The current revision of the specification is 2026-07-28, and two of its rules touch this playbook. First, MCP has no protocol-level session, so a server cannot rely on per-connection state to relate one tool call to the next. For state such as a basket, the specification’s guidance is to return an explicit handle from a creation tool, accept that handle on later calls, check the caller’s authorization against the handle on every call, and state the handle’s lifetime in the tool description (MCP tools, stateful tools). For order.intent the intent ID is that handle.
Second, every request carries its protocol version and client capabilities in _meta fields in the message body, and the Streamable HTTP transport mirrors them into HTTP headers so intermediaries can route requests without parsing the body (MCP transports). On the adapter side, wp mcp-adapter list --protocol=2026-07-28 counts the components available under that revision; the adapter accepts 2025-11-25 and 2026-07-28 there.
Related MCP server guides
This article covers the migration shape. For the implementation walkthrough see building an MCP server for WooCommerce. For the auth strategy see MCP authentication patterns. For typed tool design see writing typed catalogue tools with Zod for MCP. For the protocol-level decision see MCP vs REST. The pillar is MCP server development.
Pricing is individual because the migration scope depends on the number of endpoints in the audit, the auth complexity, and the consumer count.
For the delivery side of this topic I keep the work under Next.js or Astro migration.







