WP Manifestindependent plugin directory
manifest / ai / wp-guarded-mcp

Guarded MCP

A Model Context Protocol server for WordPress, built on the assumption that the agent will occasionally get it wrong.

by Jorge Barnaby · github.com/yorch/wp-guarded-mcp · website

0stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/yorch/wp-guarded-mcp/archive/refs/heads/main.zip

A Model Context Protocol server for WordPress, so an AI agent such as Claude Code or Claude Desktop can administer your site through conversation.

It is built on one assumption: the agent will occasionally get it wrong. An agent administering your site also reads your comments, your post bodies and your plugin descriptions, all written by anonymous people, and it has no reliable way to tell an instruction from content. So this hands an agent everything an administrator can do, and puts a guard on each of the operations you would not want done on a misread instruction.

There is a project page if you would rather read the short version.

This is a fork of the MCP layer of AI Engine 3.7.7 by Jordy Meow, stripped of everything that is not the MCP server. GPLv2 or later, same as the original. See CREDITS.md for what was kept and what changed.

What it is

One REST endpoint, /wp-json/mcp/v1/http, speaking the MCP Streamable HTTP transport, with WordPress tools behind it. No chatbots, no AI provider keys, no front-end assets. The plugin never calls an AI model itself: your agent does that, and this is what it reaches into.

It also speaks the parts of MCP most servers skip: prompts, so your client offers a menu of upkeep jobs, and resources, so a person can attach a post or the comment queue to a conversation directly.

Requirements

  • WordPress 6.0 or newer
  • PHP 8.1 or newer

Install

Build the zip with .dev/build.sh, then upload it through Plugins, Add New, Upload Plugin. Or copy the directory into wp-content/plugins/guarded-mcp and activate it.

wp plugin install /path/to/guarded-mcp.zip --activate

The directory must be named guarded-mcp. Not wp-guarded-mcp, not wp-guarded-mcp-main, not ai-engine. The plugin derives its own identity from the folder through plugin_basename(), and the guard that stops an agent deactivating or deleting the plugin mid-call compares against that. Rename the folder and the self-protection silently stops matching. A GitHub "Download ZIP" gives you wp-guarded-mcp-main, so rename it if you go that route. The repository is named wp-guarded-mcp and the plugin guarded-mcp; only the second name matters to WordPress.

Then open MCP Server in the admin menu.

Connecting an agent

The settings screen shows the endpoint URL. There are two ways in.

OAuth, for clients that support it (Claude Desktop, the Claude web connector). Paste the endpoint URL into the client. It discovers the authorization server, sends you to a WordPress login, and shows a consent screen. Nothing to configure, and no shared secret. Only administrators can approve a connection, and the resulting token keeps working only while that account is still an administrator.

A named key, for clients that cannot do OAuth, such as a CLI agent. Create one on the Access page and give it to the client. A key is shown once and stored only as a hash, so keep it wherever the client keeps its configuration:

{
  "mcpServers": {
    "wordpress": {
      "type": "http",
      "url": "https://example.com/wp-json/mcp/v1/http",
      "headers": { "Authorization": "Bearer YOUR_TOKEN" }
    }
  }
}

If your host strips the Authorization header before PHP sees it, the plugin also registers /wp-json/mcp/v1/<token> as an alternative path. Prefer the header when it works.

Access levels

An access level belongs to a named key. OAuth callers always act as the administrator who approved the connection, at full access.

Level Content only + administration + WooCommerce What it can do
admin 55 90 102 Everything, including deletes, users and options
readwrite 38 49 58 Create and update, no destructive tools
readonly 18 28 32 Reads only

These nine numbers are checked by smoke-admin.sh against a running site, because all nine had drifted behind the code before anything checked them. Elementor, Kirki, Yoast SEO and ACF are not counted: their tools are optional groups that come and go with a plugin, so folding them in would make the table depend on what happens to be installed.

A named key narrows this further. It carries its own level, an optional expiry date, and an optional list of the only tools it may call, so a key handed to a deploy script can be limited to reading posts and nothing else. Keys are stored hashed and shown once. There is no shared token. There was one, and it was retired rather than hardened: it sat in the options table in the clear because the screen showed it back, it carried no identity so the log could not say who acted, it could not expire, and it could not be limited to anything. A key answers all four, and an existing shared token is carried over into one on upgrade so nothing stops working.

Tools

Content and site data, on by default: posts and pages, block content, taxonomies and terms, comments, media (including upload by URL or by a one-time upload link), users, post meta, site options, post types, block patterns.

Four of those exist because a value can be too large to survive a tool argument. wp_copy_post_meta and wp_duplicate_post copy inside PHP, so a page design of 100KB never leaves the server. wp_write_post_meta_chunk is the general answer, staging a value across several calls and writing the meta row only on the last one, so a half-written value is never on the post for something else to read as finished. wp_read_post_meta_chunk is its mirror, so the round trip closes: it walks a value by byte offset, says how large the whole thing is and whether more remains, and returns each piece as base64. A single piece is capped at 256KB, and that number is a policy rather than a limit: nothing failed in testing until the memory limit was lowered well below a stock host's, and on a normal one the tool will hand back any meta value the database can deliver. A duplicate is a draft unless you ask otherwise, because a copy that inherits publish goes live on a misread instruction.

Escaping used to be a second reason to reach for those, and is no longer. update_metadata() unslashes whatever it is handed, so a value carrying backslashes was stored stripped: a regex stopped matching, a Windows path lost its separators, and a JSON payload stopped parsing, while the tool still answered that the meta was updated. The chunk writer had always compensated and wp_update_post_meta had not, so the same bytes were stored two different ways depending on which tool you asked. Both now go through one function that slashes the value and decodes a JSON string for an array, which also means a small array no longer needs the chunk API. Size is the only remaining reason to prefer it.

The base64 on the read side is not fussiness either. A chunk boundary falls wherever the byte count lands, which is routinely inside a multi-byte character, and a half-character is fine only if nothing tries to repair it. Sent as text it does not survive: wp_json_encode hands invalid UTF-8 to WordPress's own sanity check, which substitutes a placeholder and reports no error, so a slice ending on the first byte of an emoji comes back the same length with that byte turned into a question mark. Every cheap check passes and the reassembled document differs from the stored one. Base64 carries those bytes through untouched, and each chunk also carries a hash of the whole value, so a caller can tell that the value was rewritten under it mid-walk and that what it reassembled is what was stored.

Site administration, off by default: installing, activating, updating and deleting plugins and themes; navigation menus and their items; widgets and widget areas; the General, Reading and Discussion settings; the permalink structure; the site's scheduled events; and a Site Health report. These install code and change how the site renders, so they are opt-in and carry their own guards:

  • Installs come from the wordpress.org repository by slug. An arbitrary ZIP URL is refused unless the site opts in through the gmcp_allow_remote_install filter, and the download host is checked so a plugin cannot rewrite the repository's answer. If you do open that filter, note that the URL you approve is the one before redirects: download_url() follows up to five, and WordPress only blocks non-HTTP schemes, odd ports and IPv4 private ranges along the way. Allowlist hosts you control, and be aware an open redirect on one of them defeats the check.
  • Deleting a plugin, a theme or a menu takes two calls, as does changing the administration email. The first changes nothing and returns a token bound to that exact target; only the second proceeds, so a single instruction cannot complete one, which matters because this agent reads comments and post content that other people wrote. Content deletions are not on that list: they usually go to the trash and can be restored, and the irreversible form, force: true, is one call. Use preview on it to see what would go, including the comments and attachments that go with it. Usually, because two ordinary situations have no trash to go to: WordPress has none for attachments, and a site with EMPTY_TRASH_DAYS set to 0 has none for anything. A call without force then destroys the thing, so the reply says which of the two happened rather than reporting both as "deleted", and the audit log records the same sentence.
  • The plugin refuses to deactivate or delete itself, to delete the active theme or its parent, and to activate a theme this server cannot run.
  • Options can be deleted, not only set. A stale cache is sometimes clearable only by removing the row, and some code treats an empty array as computed and so never rebuilds; Elementor's theme-builder conditions are exactly that. Nine options are refused, each carrying the sentence for what breaks if it goes, and so is anything the shared write policy already refuses to change, since deleting a row is the harsher edit of the two and a key too dangerous to set cannot be safe to drop. rewrite_rules is deliberately not among them, because WordPress regenerates it and deleting it is an ordinary repair. A deletion is in the audit log but cannot be undone: WordPress passes only the name to deleted_option, so nothing keeps the value. The reply therefore carries the value that was removed, since it is the only copy anyone gets, withheld when it looks credential-shaped by the same test that keeps such values out of the journal.
  • wp_flush_cache purges the object cache, expired transients, or one post. It follows the same rule as everything else here about caches: purge what can be named, and say what could not. The reply lists the page-cache plugins it recognised and then names, in as many words, the CDN or reverse proxy that no PHP can reach and that the caller still has to purge. A cache tool that implied the front end was now fresh would be worse than none, which is why that half of the answer is as prominent as the first.
  • Scheduled events can be listed, run and removed, which is what a cron event Site Health keeps flagging needs. Only events the site itself already scheduled can be run: a tool that fires any hook you name is a tool for running arbitrary code on an instruction, and hook names arrive in the same text as everything else. This plugin's own housekeeping is refused outright. The listing says whether each hook still has a callback, because an event orphaned by a deactivated plugin can never succeed and is the usual reason one keeps failing, and it says whether cron runs on this site at all, since when it does not every event is overdue by design. Removing an event takes two calls, and the undo journal cannot put it back: WordPress passes only the name to deleted_option, so nothing records what the schedule held.
  • Post and widget content is always filtered, regardless of the caller's capabilities. WordPress normally lets an administrator store raw HTML, but the caller being an administrator says nothing about who wrote the markup, and these tools sit at the write access level, so a deliberately limited token could otherwise plant a script on a public page. Blocks, shortcodes, inline styles and data- attributes all survive; `

This README is longer than the copy stored here. Read the rest on GitHub →