mcp-wp-cli-terminus
# mcp-wp-cli-terminus
**An [MCP](https://modelcontextprotocol.io) (Model Context Protocol) server that lets Claude and other AI agents run [WP-CLI](https://wp-cli.org) against WordPress — over local Docker, [Pantheon Terminus](https://docs.pantheon.io/terminus), or SSH — and byte-faithfully copy posts and post meta between environments with checksum verification.**
Built for developers using **Claude Code / Claude Desktop** (or any MCP client) to operate **WordPress** sites — including **Pantheon** multidevs reached through **Terminus** — without hand-assembling fragile `wp eval` commands.
<sub>`wp-cli` · `wordpress` · `terminus` · `pantheon` · `mcp` · `model-context-protocol` · `claude` · `claude-code` · `anthropic` · `wordpress-migration` · `devops`</sub>
---
## Why
Moving a WordPress post's body or custom fields between environments (e.g. pushing a block-based front page from local to a Pantheon multidev) is deceptively hard to do correctly:
- `wp post update --post_content` **truncates at newlines** ([wp-cli#2712](https://github.com/wp-cli/wp-cli/issues/2712)).
- Piping content over STDIN **hangs on `terminus remote:wp`** ([terminus#1615](https://github.com/pantheon-systems/terminus/issues/1615)).
- Hand-pasting base64 into an agent prompt is **lossy** — a single flipped byte silently corrupts production.
- Large payloads passed as one shell argument hit the Linux **`MAX_ARG_STRLEN` (131072 bytes)** limit and fail with `E2BIG`.
- Post **meta** with serialized arrays / multiple values per key is easy to corrupt by re-serializing.
This server solves all of that: content is read, base64-encoded **in code**, delivered over a transport-safe path, and then **re-read and checksum-compared** to the source. A mismatch is reported, never silently trusted.
## Tools
| Tool | What it does |
|------|--------------|
| `wp_init_config` | Guided setup: detects your Docker container, WordPress path, and Terminus site, then writes `.serena/wp-cli.conf` (asking you for anything it can't detect). |
| `wp_cli` | Run any WP-CLI command against a configured site — `target: local` (Docker) or `target: production` (Terminus or SSH, chosen by config). Destructive commands are guarded on production. |
| `wp_sync_post` | **Sync** a post's `post_content` onto the **same post ID** in another environment (updates an existing post; e.g. a multidev cloned from the same DB), with an **md5 round-trip verification**. |
| `wp_sync_post_meta` | **Sync** a post's **complete** meta (serialized arrays, multiple values per key, ACF repeaters) onto the **same post ID** in another environment, with a canonical **checksum verification**. All keys (full mirror) or an allow-list. |
| `wp_sync_option` | **Sync** one or more `wp_options` rows (site/plugin settings, and **ACF options-page** data incl. repeaters/nested groups) between environments — or between two **subsites** of one multisite — **md5-verified**. Select by exact `option_names` or a `like` prefix; multisite `--url` is resolved automatically per side from a bare subdomain. The options-table counterpart to `wp_sync_post_meta`. |
| `wp_clone_post` | **Clone** a post to another environment as a **NEW** post — the destination assigns its own ID (returned as `new_id`). Use when the post doesn't exist on the destination yet. Copies fields + all meta, verifies, and reports meta keys that hold ID references for manual remapping. |
| `wp_block` | **Surgically** edit **one** Gutenberg/ACF block of a post — `list` / `get` / `insert` / `replace` / `update-attrs` / `remove` / `move` — leaving every other block byte-identical. Uses WP core `parse_blocks()`/`serialize_blocks()` (never string surgery), with a re-parse md5 verification and the same production guard. |
| `wp_create_post` | **Create** a NEW post from scratch, with the body carried **content-safe** (base64 in code) — no hand-quoted `wp post create`, no throwaway PHP file. Give `content` (raw markup) or `blocks` (specs serialized server-side); returns `new_id` to build up with `wp_block`. |
### Sync vs. clone vs. block
- **Block** (`wp_block`) is the **single-block primitive**: change, read, add, or reorder **one** block of a post without touching the rest. Reach for it instead of a whole-body `wp_sync_post` (which overwrites every block) or hand-written `str_replace`/`eval` surgery on `post_content` (which corrupts self-closing ACF blocks and inner blocks). Selectors: `name:<blockName>` (first of type), `name:<blockName>#<N>` (Nth, 0-based), `anchor:<anchor>`, `index:<N>`.
- **Sync** (`wp_sync_post`, `wp_sync_post_meta`) updates an **existing** post that shares the **same ID** on both sides — the right tool when the environments were cloned from the same database (a Pantheon multidev, a staging copy). It fails if the destination ID doesn't exist.
- **Options** (`wp_sync_option`) copies `wp_options` rows — data the post tools can't reach. Reach for it for **settings / ACF options-page** content (theme options, plugin config, an options-page repeater). For an ACF repeater, match **both** `options_<field>%` and the underscore-prefixed `_options_<field>%` field-reference rows; a `like` sync **mirrors** the pattern (surplus destination rows are removed) so a repeater shrinks correctly.
- **Clone** (`wp_clone_post`) **creates a new** post on the destination from a source post; the destination assigns a fresh ID. Use it when the content is new to the destination (e.g. pushing a locally-authored post/alert up to a multidev). Meta values that look like ID references (`_thumbnail_id`, ACF relationship/image fields) are copied verbatim and **reported** — never silently remapped across databases.
### Correctness guarantees
- **Never routes content through the model's text.** Payloads are read into the server and base64-encoded in code.
- **Checksum-verified.** Every sync/clone re-reads the destination and compares it to the transferred source; `verified: false` + an error on any mismatch.
- **Transport-agnostic.** The same logic runs over local Docker, Pantheon Terminus, and WP-CLI `--ssh`.
- **Large payloads.** Docker/SSH deliver PHP over STDIN (`wp eval-file -`, exempt from the argv size limit); Terminus uses a size-guarded argv path and fails loud rather than emitting a raw `E2BIG`.
- **Meta fidelity.** Values are round-tripped so WordPress's own `maybe_serialize()` reproduces the exact stored `meta_value` — arrays stay arrays, and strings that merely look serialized stay strings.
- **Production guard.** Writes to a production destination require `confirm: true` when `PROD_GUARD` is enabled.
- **MCP-client tolerant.** Some MCP clients (e.g. Claude Code) serialize object/array/number/boolean tool arguments as JSON strings before sending them ([claude-code#5504](https://github.com/anthropics/claude-code/issues/5504), [#24599](https://github.com/anthropics/claude-code/issues/24599)). The tools coerce such stringified arguments back to their native types, so a `block`/`blocks`/`data`/`fields` object delivered as a string still works — while a genuine raw-markup string is never mis-parsed.
## Install & run
The server is **pure Python (stdlib only, zero dependencies)**.
### With `uvx` (recommended — no install)
```jsonc
// Claude Desktop / Claude Code MCP config
{
"mcpServers": {
"wp-cli": {
"command": "uvx",
"args": ["mcp-wp-cli-terminus"]
}
}
}
```
### With `pip`
```bash
pip install mcp-wp-cli-terminus
```
```jsonc
{
"mcpServers": {
"wp-cli": { "command": "mcp-wp-cli-terminus" }
}
}
```
### From source
```bash
git clone https://github.com/EarthmanWeb/mcp-wp-cli-terminus
cd mcp-wp-cli-terminus
python -m wp_cli_mcp # PYTHONPATH=src, or `pip install -e .`
```
## Configure
### Guided setup with `wp_init_config` (recommended)
The easiest way to create the config is to ask your MCP client to run the **`wp_init_config`** tool. It works in two phases:
1. **Detect** — called with no arguments, it probes the environment (running Docker containers, the WordPress path inside them, and any authenticated Pantheon Terminus site) and reports what it found plus a list of anything it couldn't determine.
2. **Write** — the agent asks you for whatever was missing, then calls it again with `write=true` to save `<project-root>/.serena/wp-cli.conf`.
Just tell your agent: *"set up the wp-cli config for this project"* — it will call `wp_init_config`, fill in what it can, ask you for the rest, and write the file (it won't overwrite an existing config unless you say so).
### Manual setup
The server reads `<project-root>/.serena/wp-cli.conf` at runtime (set `CLAUDE_PROJECT_DIR` to point at your project). Copy [`wp-cli.conf.example`](wp-cli.conf.example) and edit:
```ini
DEFAULT_SITE=example-site
PROD_GUARD=true
[site:example-site]
LOCAL_CONTAINER=my-container # docker container running WP-CLI
LOCAL_PATH=/var/www/html # WordPress path inside the container
TERMINUS_SITE=example # Pantheon site — production routes over Terminus
TERMINUS_ENV=dev # default env (override per call)
# — or, for a non-Pantheon remote, omit TERMINUS_* and set:
# REMOTE_SSH=deploy@example.com:22/var/www/html
```
- **Production transport is chosen by config:** `TERMINUS_SITE` → `terminus remote:wp`; otherwise `REMOTE_SSH` → WP-CLI `--ssh`.
- **Multi-site:** add more `[site:NAME]` sections and pass `site` per call.
**Never commit `.serena/wp-cli.conf`** — it may contain hostnames/SSH strings. The shipped [`.gitignore`](.gitignore) excludes it.
## Usage examples
```jsonc
// Run a WP-CLI command locally
{ "tool": "wp_cli", "args": "plugin list --status=active --format=json" }
// Run against production (Terminus or SSH per config)
{ "tool": "wp_cli", "args": "option get siteurl", "target": "production" }
// SYNC a front page's block markup onto the SAME post ID on production, verified
{ "tool": "wp_sync_post", "post_id": 42, "from": "local", "to": "production", "confirm": true }
// SYNC ALL meta for a post (full mirror) onto the same ID, verified
{ "tool": "wp_sync_post_meta", "post_id": 42, "from": "local", "to": "production", "confirm": true }
// SYNC only specific meta keys
{ "tool": "wp_sync_post_meta", "post_id": 42, "from": "local", "to": "production",
"keys": ["_thumbnail_id", "my_field"], "confirm": true }
// CLONE a locally-authored post to production as a NEW post (returns new_id)
{ "tool": "wp_clone_post", "post_id": 268529, "from": "local", "to": "production", "confirm": true }
// Clone but force the new post to draft
{ "tool": "wp_clone_post", "post_id": 268529, "from": "local", "to": "production",
"overrides": { "post_status": "draft" }, "confirm": true }
// LIST a post's top-level blocks (index, blockName, anchor, ACF data keys)
{ "tool": "wp_block", "op": "list", "post_id": 268483 }
// GET one block's parsed attrs + exact markup
{ "tool": "wp_block", "op": "get", "post_id": 268483, "selector": "name:acf/sps-hero-slideshow-block" }
// INSERT a feature-cards block just before the celebrations block (ACF data form)
{ "tool": "wp_block", "op": "insert", "post_id": 268483,
"position": "before:name:acf/sps-celebrations-block",
"block": { "name": "acf/sps-feature-cards-block", "data": { "cards": [268486, 268388, 268364] } } }
// UPDATE-ATTRS: switch the celebrations block to tag mode (merges into attrs.data)
{ "tool": "wp_block", "op": "update-attrs", "post_id": 268483,
"selector": "name:acf/sps-celebrations-block",
"data": { "source": "tag", "tag": 436, "posts_per_page": 10 } }
// REPLACE the 2nd paragraph with raw markup; MOVE / REMOVE by selector
{ "tool": "wp_block", "op": "replace", "post_id": 42, "selector": "name:core/paragraph#1",
"block": "<!-- wp:paragraph --><p>New copy</p><!-- /wp:paragraph -->" }
{ "tool": "wp_block", "op": "move", "post_id": 42, "selector": "anchor:cta", "position": "first" }
{ "tool": "wp_block", "op": "remove", "post_id": 42, "selector": "index:3" }
// PREVIEW a change without writing (returns the intended new_content_b64 + new_md5)
{ "tool": "wp_block", "op": "remove", "post_id": 42, "selector": "index:3", "preview": true }
// SYNC a post's body to production but PRESERVE the destination's hand-built slideshow
{ "tool": "wp_sync_post", "post_id": 268483, "from": "local", "to": "production",
"except_blocks": ["acf/sps-hero-slideshow-block"], "confirm": true }
// CREATE a new page from block specs in ONE call (no file, no arg-quoting) -> new_id
{ "tool": "wp_create_post", "title": "Landing", "post_type": "page", "status": "draft",
"blocks": [
{ "name": "acf/sps-feature-cards-block", "data": { "cards": [268486, 268388] } },
"<!-- wp:paragraph --><p>Intro copy.</p><!-- /wp:paragraph -->"
] }
// CREATE from raw markup, then keep building with wp_block on the returned new_id
{ "tool": "wp_create_post", "title": "Draft", "content": "<!-- wp:heading --><h2>Hi</h2><!-- /wp:heading -->" }
{ "tool": "wp_block", "op": "insert", "post_id": /* new_id */ 0, "position": "last",
"block": { "name": "acf/sps-celebrations-block", "data": { "source": "tag", "tag": 436 } } }
// SYNC named options (site settings) local -> production
{ "tool": "wp_sync_option", "option_names": ["blogname", "blogdescription"],
"from": "local", "to": "production", "confirm": true }
// SYNC a whole ACF options-page repeater — BOTH prefixes (values + field refs)
{ "tool": "wp_sync_option", "like": "options_page_callouts%", "from": "local", "to": "production", "confirm": true }
{ "tool": "wp_sync_option", "like": "_options_page_callouts%", "from": "local", "to": "production", "confirm": true }
// COPY options between two SUBSITES of one multisite (same env; bare subdomains)
{ "tool": "wp_sync_option", "like": "options_hero%",
"from": "local", "to": "local", "from_subdomain": "site-a", "to_subdomain": "site-b" }
```
`wp_sync_*` return `verified: true/false` with `src_md5` / `dst_md5`, the delivery mode, and per-side transport. `wp_sync_option` adds `options_written`, the `selector` used, and (for a subsite copy) the resolved `subsite`. A bare `wp_cli` call that a specialized tool would do better (e.g. `option get/update`, `post get --field=post_content`, raw `eval`) also returns a `suggestion` pointing you at it — the command still runs. `wp_clone_post` returns `new_id`, `verified`, `content_verified`, `meta_verified`, and `id_reference_keys` (meta keys to review). `wp_block` read ops return the block list / one block's attrs+markup; write ops return `wrote`, `verified` (re-parse md5 round-trip), `before_count`/`after_count`, and `target_index` — and, when blocked by the production guard or `preview: true`, the intended `new_content_b64` + `new_md5` instead of writing. A block-filtered `wp_sync_post` (`only_blocks`/`except_blocks`) returns `blocks_carried`, `intended_md5`/`reread_md5`, and the `filter` applied.
## Debug logging
Failures (non-zero WP-CLI exits) are appended to a log in your system temp dir — **failures only**, successes are never logged:
- Location: `${TMPDIR}/wp-cli-mcp/failures.log` (override with `WP_CLI_MCP_LOG_DIR`).
- Disable entirely with `WP_CLI_MCP_LOG=0`.
- SSH connection strings are redacted in the log.
## Requirements
- Python 3.8+
- [WP-CLI](https://wp-cli.org) reachable via one of: a local Docker container (`docker exec`), Pantheon [Terminus](https://docs.pantheon.io/terminus) on the host, or a host WP-CLI with `--ssh`.
## Tests
```bash
python -m unittest discover -s tests -v
```
187 stdlib-only unit tests (split by area under `tests/`) cover config parsing, transport selection (local/Terminus/SSH), the argv size guard + `--url` extra-token passthrough, newline handling, PHP-key safety, id-reference detection, option/ACF-repeater copy with pattern mirroring, multisite subdomain→URL resolution, the `wp_cli` discovery hints, and the full sync/clone/verify orchestration via an injectable command-runner seam (no real WP-CLI invoked).
## Releasing
Releases publish to PyPI **automatically** via GitHub Actions ([`.github/workflows/publish.yml`](.github/workflows/publish.yml)) using [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/) — **no API token is stored in the repo**. The workflow builds, runs the tests, and uploads on every published GitHub Release.
**One-time setup** (per project, on PyPI):
1. On PyPI, open the project → **Publishing** → **Add a new publisher** → GitHub, with:
- Owner: `EarthmanWeb` · Repository: `mcp-wp-cli-terminus`
- Workflow name: `publish.yml` · Environment: `pypi`
2. (Optional) In GitHub repo **Settings → Environments**, create an environment named `pypi` to gate/approve publishes.
**Cut a release** (this triggers the publish):
```bash
# 1. Bump the version in pyproject.toml (e.g. 0.1.0 -> 0.1.1), commit, push.
# 2. Tag + create the GitHub Release — the workflow does the rest:
gh release create v0.1.1 --title "v0.1.1" --notes "What changed"
```
The action then builds, tests, and publishes `mcp-wp-cli-terminus` to PyPI. Within ~a minute `uvx mcp-wp-cli-terminus` (and the SWE plugin launcher) pick up the new version. You can also run it manually from the **Actions** tab (`workflow_dispatch`).
> First release was published manually with `uv build && uv publish`; subsequent releases use the workflow above.
## License
[MIT](LICENSE)
TDQS
Scored across 8 tools
Each tool targets a distinct operation: config bootstrap, generic WP-CLI execution, post creation from scratch, environment-to-environment sync of content/meta/options, block-level editing, and full post cloning. The descriptions explicitly cross-reference similar tools (e.g. wp_clone_post vs wp_sync_post) so an agent can reliably distinguish them.
All tools share a consistent wp_ prefix and snake_case style, and most follow a clear verb_noun pattern like wp_create_post, wp_sync_post, and wp_sync_option. wp_cli and wp_block are noun-style names rather than explicit verb_noun, which is a minor deviation but still predictable.
Eight tools is a well-scoped size for a WordPress/Terminus workflow server; each tool covers a distinct high-value operation without redundancy. The count sits comfortably in the ideal range for a focused server.
The set covers the full workflow of authoring and moving content across environments: config initialization, creation, cloning, content/meta/options sync, and surgical block edits. The included generic wp_cli runner also fills any remaining low-level WP-CLI gaps, so agents are not left with dead ends.