← writing

WordPress meets MCP: editing your site by talking to Claude

I built a small MCP server that wraps WP-CLI and the WP REST API so I can edit my WordPress site by talking to Claude. Eight tools, one Go binary, and suddenly the admin panel is optional.

I built a small MCP server that wraps WP-CLI and the WP REST API. Now I can say publish a draft titled X with this image as featured and the article lands. No tabbing into wp-admin, no hunting for the right draft in a list of forty, no clicking through the media library to set a featured image. One sentence, one confirmation, done.

This post is the walkthrough. The architecture, the eight tools the server exposes, how each one maps to either a wp-cli call or an authenticated REST request, the security model that keeps me from accidentally shelling out the universe, and a concrete session where Claude publishes a real draft I had been sitting on.

The shape of the thing

The server is a single Go binary that runs on the same box as the WordPress install. That co-location matters, because half of the tools shell out to wp-cli, which needs filesystem access to the WordPress root and database credentials that already live in wp-config.php. Running on the same host means I do not invent a new auth model. I reuse the one WordPress already trusts.

The binary speaks the Model Context Protocol over stdio. Claude Desktop or any other MCP client launches it as a subprocess, lists the tools it exposes, and from that point on the LLM can call them by name. From the server's point of view, every call is a typed JSON request and a typed JSON response. From Claude's point of view, every call is a tool it just learned how to use.

There are eight tools. I picked them by going through a week of my own admin work and writing down every action I actually performed. Anything that came up twice became a tool. Anything that only came up once stayed manual. The list ended up being smaller than I expected.

The eight tools

Here they are, in the order they tend to get called in a real session.

list_posts returns a paginated list of posts with id, title, status, slug, and modified date. It accepts a status filter (draft, publish, pending, any), a search string, and a per_page cap. Under the hood it is a REST call to /wp/v2/posts with the right query params. Claude leans on this one constantly, because it is the cheapest way to find the post the user meant.

get_post fetches a single post by id, including the full content, excerpt, featured image id, categories, and tags. REST again, /wp/v2/posts/{id}. When Claude needs to summarise or edit a specific post, this is the call that primes the context.

create_post takes title, content (HTML or block markup), status, slug, excerpt, categories, and tags, and creates the post. REST, POSTed with an application password. The response is the new post id, which Claude usually feeds straight into set_featured_image or update_post.

update_post is the workhorse. It accepts a post id and any subset of editable fields: title, content, status, slug, excerpt, categories, tags. Critically, status can be flipped from draft to publish through this same call, which is how publishing actually happens in practice. REST PATCH to /wp/v2/posts/{id}.

upload_media reads a file from disk (path passed in by Claude, validated against a configured uploads allowlist) and POSTs it to /wp/v2/media. The response includes the media id and the public URL. This is what I use when I say upload that screenshot I just saved to my desktop.

set_featured_image attaches an existing media id to a post as its featured image. Strictly speaking the REST API can do this in the create or update call, but having a dedicated tool makes the agent's life easier. It can chain upload_media into set_featured_image without having to reason about which field on the post object to populate.

search_replace is the one tool that exists purely because I got tired of doing the same wp-cli command by hand after every domain change or content rename. It wraps wp search-replace with strict argument validation: from-string, to-string, optional table filter, and a dry-run flag that defaults to true. I have to explicitly pass dry_run: false to actually mutate, which has saved me from myself more than once.

plugin_list returns active and inactive plugins with their versions, by calling wp plugin list --format=json. I added this because I kept asking Claude things like what plugins do I have running that might be doing redirects? and the only sane answer started with a list of installed plugins. There is no plugin_activate or plugin_deactivate. I want a human in the loop for that one.

How the tools split between wp-cli and REST

The split is not arbitrary. Anything that is content-shaped, posts, pages, media, taxonomies, goes through the REST API because the REST API has good JSON in and JSON out, predictable error responses, and a well-known auth story via application passwords. Anything that is system-shaped, plugin state, search-replace across the database, cache flushes, goes through wp-cli because the REST API either does not expose it or exposes it awkwardly.

The Go server has two small clients sitting behind a shared interface. The REST client holds the base URL and the application password, sets the right Authorization header, and parses responses into typed structs. The CLI client is a thin wrapper around os/exec that runs wp with arguments and parses the resulting JSON. Both clients refuse to do anything they were not configured to do.

Security: typed args, no arbitrary shell

This is the part I want to be loud about, because it is the part that scared me most when I started building.

The server never accepts a raw shell string. Every tool's arguments are typed, validated, and converted into a fixed argv before wp-cli is invoked. The search_replace tool, for example, does not take a command. It takes a from-string and a to-string, both strings, and constructs wp search-replace <from> <to> with those values as separate argv entries. There is no string interpolation, no shell expansion, no way for a clever input to escape into a second command.

The REST side is similarly tight. The server reads the application password from a config file at startup, never accepts credentials over the wire, and rejects any tool call that tries to set the post author to a user the configured account is not allowed to impersonate.

Uploads are scoped to an allowlist of directories I configure in the same file. Claude cannot ask the server to upload /etc/passwd as a featured image, no matter how creatively it tries.

A real session

Here is one that happened last week, roughly transcribed.

I said: publish today's draft about the Vietnam trip.

Claude called list_posts with status=draft and a per_page of twenty. The response had four drafts. Claude looked at the titles, found one called "Two weeks in Vietnam, the honest version", and asked me: is this the one? I said yes. Claude called get_post on that id to confirm the content was complete, did a short summary back to me of what the post contained, and asked if I wanted to publish as-is or tweak the excerpt first.

I said publish as-is. Claude called update_post with that id and status: publish. The response came back with the new published URL. Claude pasted it back to me. The whole thing took about fifteen seconds, most of which was me reading.

What is interesting is what did not happen. I did not open wp-admin. I did not search for the post myself. I did not click into the editor to verify the content. I did not toggle the status dropdown and hit update. The agent did all of that, in the right order, and asked me exactly once before doing anything destructive.

WordPress as an AI backend

Here is the realisation I want to leave you with. WordPress, which is a twenty-something-year-old PHP CMS, turns out to be a great backend for AI-driven content workflows. The REST API was the bridge. It made the data reachable from outside the admin UI. MCP is the wider, smoother bridge. It makes the data reachable from inside a conversation.

I do not think this is a temporary novelty. I think every CMS, including the ones I am building from scratch, is going to grow an MCP surface, and the ones that have a clean API today are going to get there fastest. WordPress got there first by accident, because the REST API already existed. The rest of us have some catching up to do. 🚀

Want more like this?

Occasional, opinionated, no listicles.
all writing →