Portfolio / Case study
Model Context Protocol · WordPress plugin239 tools, inside WordPress · for any AI client
An MCP server that runs inside a WordPress installation rather than beside it — so an AI client can read and write files, run WP-CLI, query MySQL, manage plugins and orders, and reach every ability other plugins register. And, when it is pointed at a client's live site, change nothing at all until a human approves the diff.
Connecting an AI assistant to a WordPress site sounds like a solved problem until you try it across more
than one client. They disagree about which transport they speak, which config format they want,
and whether they can send an HTTP header at all. Then the host fights back: a security plugin
disables the REST API, FastCGI strips the Authorization header, permalinks are plain.
And underneath all of that sits the real risk. An MCP server on a WordPress site is a root shell for that site. Pointing one at a client's production install to diagnose something is genuinely useful — and one careless write away from an outage.
Streamable HTTP, legacy HTTP+SSE, plain JSON-RPC and a dependency-free stdio bridge — all backed by the same code, auth and audit trail.
Per-client tokens with independent scopes, tool allow-lists, IP restrictions and expiry. Tools above a scope are hidden from tools/list, not just refused.
On activation and twice daily it makes real loopback requests over every endpoint and credential channel, then generates client configs against the combination that actually authenticates.
Protection mode moves the decision from “did I remember which window I'm in” to something the site enforces.
Every site sits in one of three modes, defaulting from WordPress' own wp_get_environment_type() — so a site that declares itself production is protected before anyone configures anything.
| Mode | What happens to writes |
|---|---|
| open | Applied immediately. The default off production. |
| protected | Captured into a changeset for review. The live site is untouched until it is applied. Default on production. |
| locked | Refused outright. Read and diagnose only. |
> edit_file wp-content/plugins/shop/cart.php … { "staged": true, "changeset": "cs_20260806_143022_a91c", "diff": "- $total = $sub; + $total = $sub + $tax;", "note": "1 change staged. The live site is untouched." } > diff_changeset # review everything staged so far > apply_changeset # write it — the changeset IS the backup > rollback_changeset # put it all back
What makes this trustworthy rather than a wrapper:
Building this surfaced a genuine security bug in the existing code: the batch tool invoked handlers directly, bypassing the permission check its own policy comment claimed it performed. A read-only credential could reach arbitrary PHP execution through it. Now closed, and covered by a regression test.
Because hosts differ, and half of them block outbound HTTP.
Site-to-site runs the tool on the far end over its own MCP endpoint. That detail matters: the target's protection mode still applies, so pushing to a protected production site stages a changeset there rather than overwriting anything. Somebody still has to approve it.
> compare_sites live --path=wp-content/plugins/shop local_only: includes/class-tax.php modified: includes/class-cart.php remote_only: assets/hotfix.css > deploy_files live --path=wp-content/plugins/shop "2 file(s) were STAGED on 'live', not applied — that site is protected." > pull_files live --files=["assets/hotfix.css"] # bring the hotfix home
Bundles are the offline equivalent: a zip with a manifest, the intended after-images and a unified patch, which the target reads into a changeset. Exporting a reviewed changeset rather than raw paths means what ships is exactly what was approved.
WordPress 6.9 added the Abilities API — a core registry where plugins publish their own capabilities with a JSON Schema and a permission callback. A single commercial plugin suite can register around 900 of them, covering Elementor, Bricks, Divi, ACF, Yoast, WooCommerce and more.
Flattening those into individual MCP tools would put hundreds of entries into tools/list and drown the client's context. Instead the bridge follows a discover / inspect / execute shape, so the entire registry is reachable through four tools — and anything installed later shows up automatically, with no code change here.
| Scope | Can do | Cannot do |
|---|---|---|
| Read | Inspect files, posts, options, logs; SELECT queries | Any write |
| Write | The above, plus posts, media, terms, comments, file edits | Plugins, users, options, execution |
| Admin | The above, plus plugins, themes, users, options, caches, backups | PHP execution, shell, raw SQL, Git |
| Full | Everything | — |
Staging could have re-implemented what each edit tool does. Instead the real tool runs against a copied path, so staged and unstaged behaviour cannot diverge — and the shadow persists per changeset, so a second edit builds on the first.
Protection hooks into a single line of the dispatch path and returns null when it does not apply, leaving the normal path untouched. The one tool that bypasses dispatch re-applies both gates itself.
Each change carries both its before and after image, so apply and rollback are the same walk in opposite directions. No separate backup step to forget.
Surfacing another vendor's ~900 abilities by reading the core registry — rather than copying them — means zero maintenance when they ship an update.
Modern string functions are polyfilled and the stdio bridges are dependency-free, so it installs on the shared hosts real clients actually use.
The source is a private repository — happy to walk through the architecture, or talk about something similar for your stack.