MCP in WordPress: what core ships, and the four tools I wrote myself

At a Glance
WordPress pages are built from blocks and fields. The Abilities API in core lets me describe actions against them, and the MCP Adapter plugin exposes those actions to an agent.

Core’s three built-in abilities only read. Here are the four content tools I added, why the read-only one matters most, and how I connect to them locally and over HTTP.

I installed the mcp-adapter plugin on a WordPress site and pointed Claude Code at it. The handshake worked first time.

Then I looked at what the agent could actually call. It had three tools: one that discovers the abilities registered on the site, one that describes a single ability, and one that runs any of them by name. Discovery came back with an empty list.

The reason was a single piece of metadata. The default server only discovers abilities exposed through meta.public or meta.mcp.public. The core abilities were not exposed through MCP, and I had not opted mine in, so discovery returned nothing.

I had a working protocol and nothing behind it.

Connecting an agent is the easy part. Telling it what your content is made of is the job.

The problem this actually solves

A modern WordPress page is not a document. It is a stack of blocks, and each block has its own fields: a lead block with a heading and an intro, a teaser grid with three items, a contact block that starts a dark section. Editors know this because they see it in wp-admin every day.

A language model does not know any of it. It can write you good copy, and then it has nowhere to put it. So it hands you a wall of HTML that ignores your design, or it asks you to paste the text in yourself, which is the part that costs the time.

MCP is the fix for that, and the idea is simple enough to say in a sentence. Instead of giving a model a page to read, you give it a list of tools it can call. Each tool has a name, a plain description, a form describing what it needs, and a function behind it. The model reads the list and picks one.

With the right tools in place, I can ask for a new services page. What comes back is a draft built from the correct blocks, with the fields filled in, sitting in wp-admin for me to read. Nobody pastes anything.

A model can write your copy. It cannot know that your page wants a lead block with two fields.

Two pieces, one in core

The first piece is the Abilities API, which has been in core since 6.9. An ability is a function with a name, a description, an input schema and its own permission check. Core registers three of them, and the AI client that shipped in 7.0 reads from the same registry.

The second piece is the mcp-adapter plugin, which comes out of the AI Building Blocks initiative. It takes the abilities in that registry and serves them as MCP tools, either over stdio or over HTTP.

That split gives me a clean off switch. My abilities live in the theme and the endpoint lives in the plugin, so deactivating the plugin in wp-admin removes the endpoint while leaving my code exactly where it was.

What core gives you, and what it does not

Core ships three abilities: core/get-site-info, core/get-user-info and core/get-environment-info. All three of them read, and none of them writes.

So how much content can you create through those three built-in abilities? None. WordPress has other ways to write posts, but these abilities do not create or update a single field.

The adapter’s default server starts with the three tools from my opening. I could expose my abilities for execute-ability, or add them as direct tools with the default server’s configuration filter. I chose a custom server so my four tools appear by name without a discovery call.

The default route works, but the agent would usually discover an ability, inspect its schema, then call a generic executor. My four direct tools need one call each. Since I use the custom server, I turned the default one off.

add_filter( 'mcp_adapter_create_default_server', '__return_false' );

A named tool list beats discover-then-execute. The model should be choosing, not exploring.

The four tools I wrote

Four abilities cover everything I want an agent to do with content. One describes the content model, one reads a post, one creates a draft, one updates a post that already exists. Nothing deletes or changes publication status, and nothing goes near users, settings or plugins.

That list is short on purpose. Every tool in the list is something the model has to read and choose between, so a short list of well-named tools gets chosen correctly more often than a long list of similar ones.

What an ability looks like

An ability is a function plus the paperwork that tells a model when to call it. This is the update tool, trimmed to the parts that carry weight.

wp_register_ability( 'site/update-content', [
    'label'       => __( 'Update content', 'site' ),
    'category'    => 'site',
    'description' => 'Updates an existing post or page. Publication status is never changed. Omit "blocks" to leave the content untouched. Passing it replaces all blocks, so read the post first.',
    'input_schema' => [
        'type'       => 'object',
        'properties' => [
            'id'     => [ 'type' => 'integer', 'description' => 'The post ID to update.' ],
            'title'  => [ 'type' => 'string',  'description' => 'A new title. Omit to keep the current one.' ],
            'fields' => [ 'type' => 'object',  'description' => 'Fields of the post itself, keyed by name.' ],
            'blocks' => self::blocksSchema(),
        ],
        'required' => [ 'id' ],
    ],
    'permission_callback' => static function ( $input = null ): bool {
        $postId = (int) ( $input['id'] ?? 0 );

        return $postId > 0 && current_user_can( 'edit_post', $postId );
    },
    'execute_callback' => static fn ( $input = null ) => ContentWriter::update( (array) $input ),
] );

Three of those keys do the real work, and they are worth understanding even if you never write one yourself.

The description tells the model when to use the tool, alongside its name and input schema. I write it for the agent, not just for a developer, and use it to spell out what the tool refuses to do. Giving those limits up front can save a failed call.

The input_schema is a form. The model fills it in, and WordPress validates the answer before my code ever sees it, which means a malformed request is rejected rather than half-applied.

The permission_callback receives that input, so it can check the actual post instead of a blanket capability. It runs on every single call, whatever the connection was allowed to do at the door.

A useful tool name gets the model’s attention. A clear description tells it when to stop.

The one that writes nothing

Of the four, the one that earns its place does no work at all.

describe-content-model returns the post types an agent is allowed to write and the sixteen blocks a page can be built from. For each block, it lists the fields, their types, whether each is required, the available choices, and the instruction text an editor reads in wp-admin. It builds all of that from my field definitions at the moment it is called, which means it cannot fall out of step with the editor.

Why does that matter more than the tools that actually write? The model cannot rely on knowing what site/teaser-grid means or which fields it accepts. Without this map it may invent plausible names, and each wrong name means a rejected call and another round trip.

Hand over the map of your blocks and fields, or watch the agent guess.

Reading a page back

read-content takes an ID or a path such as /about-us/, because an agent usually knows the URL and not the number. It returns the post with its own fields resolved and its blocks in document order, each one numbered.

That numbering is deliberate. My tools do not have a stable block ID they can rely on in post_content, so they address blocks by position. An update that passes blocks replaces all of them. The agent has to read the post before it changes a word of it.

Block IDs look like handles. They are not.

The two that write

create-draft makes a new post of a type from a fixed list, with a title, its own fields and its blocks. update-content changes a post that already exists, and leaving blocks out of the call means the content is not touched at all, so an agent can fix a title without risking the body.

Both follow two rules I am not willing to give up. New content is always created as a draft. An update never sets post_status, so a published page stays published and a draft stays a draft. Changes to a published page are live as soon as that update succeeds.

Then both read the status back after writing and return an error if it has moved anyway. That check has never fired. I would rather find out from an error message than from the front page.

New pages stay in drafts until I publish them. Published pages need care before an update.

Running it on my laptop

Abilities on their own do nothing until a client can reach them, and there are two ways in. Locally it is a pipe between two programs. Remotely it is an ordinary web request.

My local site runs in Docker, with WordPress and PHP in one container and the database in another beside it. WP-CLI is installed in the first container, and that is what makes the pipe the easy path here, because the adapter ships a WP-CLI command that speaks MCP directly over stdin and stdout.

There is no HTTP involved and no credentials to manage. Claude Code starts a process inside the container and talks to it down a pipe.

{
  "mcpServers": {
    "site-content": {
      "command": "docker",
      "args": [
        "exec", "-i", "-u", "www-data", "-w", "/var/www",
        "site-php-1",
        "wp", "mcp-adapter", "serve", "--server=site", "--user=editor"
      ]
    }
  }
}

The file is committed, so anyone who clones the repository gets the server with it. One approval prompt and they are connected.

The --user flag is the entire authentication story here, and that is the right amount of security for a local container. Anyone who can exec into it already has my database.

Use docker exec -i here and nothing else. Most Docker projects wrap their commands in a script or a Makefile target so that nobody has to type the full exec line, and mine does too. But that wrapper breaks an MCP server in two ways. It does not forward stdin, so the client’s requests never reach the server, and it lets PHP notices through to stdout, which is the channel the client is reading JSON-RPC on.

A stdio server speaks JSON-RPC over stdout. One stray PHP notice on that pipe and the conversation is over.

Running it from anywhere else

Production has no container for me to exec into, so the same four abilities go out over HTTP instead. The adapter registers a REST route, and that route is the entire transport.

Most people expect OAuth at this point, because that is what the MCP specification describes for remote servers. The adapter implements none of it. It ships no authorisation server.

What you get instead is whatever the WordPress REST API already accepts, which in practice means an application password. Core has supported those since 5.6 and needs no plugin.

So I created a user called agent, gave it the Editor role, and generated an application password at the bottom of its profile page. WordPress displays that password once, as six groups of four characters. Basic auth needs it joined to the username with a colon and then base64-encoded, which the command below does inline.

claude mcp add --transport http site https://example.com/wp-json/site/mcp \
  --header "Authorization: Basic $(printf '%s' 'agent:abcd efgh ijkl mnop qrst uvwx' | base64)"

That is the entire difference between my two setups. Locally I pass --user and WP-CLI believes me, because I am already inside the container. Remotely I pass a header, and WordPress authenticates it exactly as it authenticates any other REST request.

stdio trusts the shell you are already in. HTTP trusts a password, the same way every other REST call does.

The endpoint also has a permission gate of its own, which defaults to any logged-in user and which mine raises to edit_posts. It guards the endpoint rather than the abilities behind it, because every ability checks the user again when it runs. That matters if you ever want token authentication here: capabilities in WordPress belong to users, so any scheme you add has to end by resolving to one.

With MCP Adapter 0.7.1 over HTTP, the client must complete the session handshake before it calls a tool. WordPress authenticates each request with the application password.

The trap worth knowing about

My agent runs as an Editor. On a single-site installation, that role normally has unfiltered_html, so WordPress may save HTML from the agent without applying its usual post-content filter. Multisite handles that capability differently.

I call wp_kses_post() on the way in. When the agent’s WordPress user has unfiltered_html, I want an allowlist at the tool boundary rather than relying on the save operation to remove unwanted markup.

WordPress trusts the user’s credentials. I gave the agent a way to use them.

Where this goes

An agent drafts a whole post from a brief and leaves it in the right blocks. A translation lands beside the original with the same structure, because the agent reads the blocks of the source and writes the same blocks back in another language. A pass over every page reports what has gone wrong in the text: the price that moved, the name that changed, the claim that stopped being true.

The one I underrated is the layout. The agent fills fields inside blocks that the theme already renders, so the page keeps the site’s structure. I still check drafts in wp-admin, and I review changes to published pages before the agent sends the update.

The blocks protect the structure. I still have to check what the agent put in them.


Conclusion
WordPress gives me the Abilities API, and the MCP Adapter plugin exposes my four content tools to an agent. The read-only tool matters most: it gives the agent the block and field map before it writes anything.

The hard part was deciding what the agent may touch. That decision belongs to the site owner, regardless of which adapter version comes next.

Tagged as