Metadata-Version: 2.4
Name: mcp-wp-cli-terminus
Version: 0.9.0
Summary: MCP server for WP-CLI over local Docker, Pantheon Terminus, or SSH — run WP-CLI, byte-faithfully copy posts/meta/options (incl. ACF options-page repeaters) between WordPress environments and multisite subsites, and perform surgical single-block Gutenberg/ACF edits, all with checksum verification.
Project-URL: Homepage, https://github.com/EarthmanWeb/mcp-wp-cli-terminus
Project-URL: Repository, https://github.com/EarthmanWeb/mcp-wp-cli-terminus
Project-URL: Issues, https://github.com/EarthmanWeb/mcp-wp-cli-terminus/issues
Author: EarthmanWeb
License: MIT
License-File: LICENSE
Keywords: ai,anthropic,claude,claude-code,cli,devops,llm,mcp,migration,model-context-protocol,pantheon,terminus,wordpress,wp,wp-cli
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
Classifier: Topic :: Software Development :: Build Tools
Classifier: Topic :: Utilities
Requires-Python: >=3.8
Provides-Extra: http
Requires-Dist: cryptography>=41; extra == 'http'
Description-Content-Type: text/markdown

# 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)
