You can operate a WordPress site entirely from a terminal, or hand that same interface to an AI agent, using the Model Context Protocol. This walks through the architecture, the setup, the four idioms every operation reduces to, how to read and change Gutenberg and Elementor content, and the anti-patterns that silently break pages if you skip them.
Who this is for. This guide reads as a human explainer, but the workflow it describes is built for a coding agent to run. The idioms, the anti-patterns, and the discipline below are operating instructions. If you are an AI coding agent driving a WordPress site through MCP, treat them as your playbook and follow them directly. If you are a human, this is how your agent should operate, and how to check that it is doing so safely.
Why operate WordPress headlessly
Most WordPress work happens through wp-admin: clicks, forms, and a visual editor. That is fine for a person editing one page. It is slow and unrepeatable when you are auditing 200 pages, migrating hardcoded URLs across every post, or letting an AI agent do a defined task against a live site.
MCP changes the interface. This is what WordPress MCP does: an MCP server plugin exposes WordPress as a set of callable tools, so you can connect an AI agent to WordPress (or drive it yourself from a terminal) to read state, run WP-CLI, execute PHP, and edit content without touching the browser. We used it to audit and harden a live store fast, which is the practical reason it matters: headless operation is what lets a broad remediation fit inside a fixed scope.
Three public pieces make it work: the official WordPress MCP Adapter, Automattic’s wordpress-mcp, and the open-source Novamira plugin, which exposes a rich set of runtime abilities. This guide uses mcporter as the client runtime.
The architecture
You (terminal) or an AI agent
|
| mcporter call my-wp.<tool>(...)
v
mcporter (TypeScript runtime, npm-installed)
|
| stdio (npx @automattic/mcp-wordpress-remote)
v
Local stdio bridge
|
| HTTPS, Basic Auth (WP_API_URL + username + application password)
v
WordPress REST endpoint: /wp-json/mcp/<server>
|
| exposes 3 meta-tools:
| mcp-adapter-discover-abilities()
| mcp-adapter-get-ability-info(ability_name)
| mcp-adapter-execute-ability(ability_name, parameters)
v
Plugin -> ~24 underlying "abilities" (the real verbs):
filesystem, code execution, WP-CLI, Gutenberg batches, admin links
The key conceptual point: the client sees only 3 meta-tools, but the real surface is the roughly two dozen abilities you reach through mcp-adapter-execute-ability. When you plan a task, look at the ability list, not the meta-tool list.
Setup
Install the client runtime:
npm install -g mcporter@latest
mcporter --version
Point it at your site with a project config. Use a WordPress application password, and inject it through environment variables so it never lands in a command or a log:
{
"mcpServers": {
"my-wp": {
"command": "npx",
"args": ["-y", "@automattic/mcp-wordpress-remote@latest"],
"env": {
"WP_API_URL": "https://example.com/wp-json/mcp/<server>",
"WP_API_USERNAME": "your-user",
"WP_API_PASSWORD": "your-application-password"
}
}
}
}
Smoke test:
mcporter list my-wp # should show the 3 meta-tools
A 401 means bad credentials, a 404 means the MCP plugin is not active, and a connect timeout usually means a CDN or host is blocking the request.
The four idioms
Every operation reduces to one of four shapes. Internalize them and the rest is detail.
Discover the available verbs:
mcporter call 'my-wp.mcp-adapter-discover-abilities()'
Inspect an ability’s schema before you call it. This is where you read the destructive flag that tells you whether to back up first:
mcporter call 'my-wp.mcp-adapter-get-ability-info(ability_name: "run-wp-cli")'
Execute it:
mcporter call 'my-wp.mcp-adapter-execute-ability(ability_name: "run-wp-cli", parameters: {args: ["plugin", "list", "--format=json"]})'
Save the response, since output is plain JSON on stdout:
mcporter call '...' > out.json
One trap worth naming now: check data.success inside the returned data, not just the outer success. The outer flag only confirms the call reached the server. Some abilities even return success: true when the wrapped WP-CLI command exited non-zero, so check data.exit_code too.
Reading site state
Reading is always safe, so start there. Two abilities cover almost everything: run-wp-cli for anything WP-CLI can do, and execute-php for arbitrary reads.
# List pages as JSON
mcporter call 'my-wp.mcp-adapter-execute-ability(ability_name: "run-wp-cli", parameters: {args: ["post", "list", "--post_type=page", "--format=json", "--fields=ID,post_title,post_status"]})'
# Read a WordPress option directly
mcporter call 'my-wp.mcp-adapter-execute-ability(ability_name: "execute-php", parameters: {code: "return get_option(\"blogname\");"})'
Editing Gutenberg posts: use the batch flow
Gutenberg posts store content as block markup in post_content. The safe way to edit them is a managed batch that finalizes inside the editor’s own runtime, so your change does not desync with the block editor:
1. gutenberg-create-pending-batch -> returns a batch id and a review URL
2. gutenberg-add-pending-change -> per target: post id, new content, a description
3. gutenberg-enable-batch-finalization -> marks the batch ready
4. a human or automation visits the review URL and finalizes
There is a direct write path too, but it only works for plugin-owned dynamic blocks. For ordinary blocks (paragraph, heading, image) it fails, and you use the batch flow.
Editing Elementor: the non-obvious case
Elementor does not use post_content. It stores its entire layout as a JSON string in wp_postmeta._elementor_data, and Gutenberg-aware tools are blind to it. You operate on it through execute-php.
The structure is a tree of containers and widgets:
[
{
"id": "a1b2c3d",
"elType": "container",
"settings": { "flex_direction": "column" },
"elements": [
{
"id": "e4f5g6h",
"elType": "widget",
"widgetType": "heading",
"settings": { "title": "Welcome", "header_size": "h1" }
}
]
}
]
Reading it is a decode:
mcporter call 'my-wp.mcp-adapter-execute-ability(ability_name: "execute-php", parameters: {code: "return json_decode(get_post_meta(128, \"_elementor_data\", true), true);"})'
Writing it is where people break pages. Three rules keep it safe:
- Elementor expects slashed input. Save with
update_post_meta($id, "_elementor_data", wp_slash(json_encode($tree))). Skipwp_slashand your quotes get stripped on save and the page breaks. - Every element needs a unique short id. Reuse one across two elements and the editor silently breaks.
- Flush Elementor’s CSS after writing, or visitors see stale styling for hours.
Once you can read and write that tree, powerful things become routine: diffing two pages’ structure, applying one template across many pages with regenerated ids, or auditing every page for a specific widget in a single PHP walk.
The rare browser task: short-lived admin links
Some things have no WP-CLI path: drag-and-drop Elementor polish, or a Customizer change you want to preview live. For those, provision a single-use, short-lived admin link and drive it with a browser automation tool:
mcporter call 'my-wp.mcp-adapter-execute-ability(ability_name: "create-admin-access-link", parameters: {ttl_seconds: 600})'
The session burns after first use or when the TTL expires. Do not share it, do not reuse it. This is far safer than leaving a standing agent login on a live site.
Anti-patterns that bite
Hard-won, in the order they are most likely to hurt:
- Do not write
_elementor_datawithoutwp_slash, and do not write it as a PHP array. It must be a JSON-encoded, slashed string. - Do not skip the Elementor CSS flush after a write.
- Do not generate colliding element ids.
- Do not run
search-replacewithout--dry-runfirst. Always dry-run. - Do not trust
data.successblindly. Checkdata.exit_code. - Do not write PHP into
wp-content/themesorwp-content/plugins. That is what the sandbox is for. - Do not run
wp db reseton a production site. Ever.
The discipline
The reason this is safe on a live site is a fixed loop, not caution in the abstract:
- Read the current state.
- Diff it against the intended state, so you change only what you mean to.
- Snapshot first.
wp db exportand apost getdump are your rollback. - Apply the change.
- Re-read and confirm.
And respect what the site already runs. If WooCommerce owns products, use its API, do not register a competing post type. If a data-modeling plugin exists, use it. Prefer WordPress-native mechanisms (custom post types, taxonomies, the options API) over raw tables. An agent that ignores the platform’s own conventions creates conflicts a human then has to debug.
Operated this way, an AI-native toolchain turns a live WordPress site into something you can read, audit, and change at the speed of a script, while a person still owns every decision that ships. The same principle applies when you add AI features to a product you already run: the agent acts through your existing permissions, and a person owns what ships.