Two routes: the managed plugin, and building your own for self-hosted sites
For years, the honest answer to “can I get an AI assistant to actually work on my WordPress site?” was a shrug and a copy-paste workflow. You’d draft somewhere else, paste into the block editor, fix the formatting, upload the image, set the SEO fields by hand, and repeat. The assistant never saw the site. It couldn’t tell you which posts were missing a meta description, couldn’t check what you’d already published on a topic, and certainly couldn’t put a draft where your editor would find it.
That changed in two steps. WordPress 6.9 introduced the Abilities API, a standard way for core, plugins and themes to register units of functionality with typed inputs, typed outputs and a permission check. Separately, the Model Context Protocol (MCP) became the common language AI clients use to discover and call tools on remote systems. Put those together — an adapter that exposes WordPress abilities as MCP tools — and Claude can talk to your site directly.
There are two practical ways to get there. The first is the managed route: install a plugin, flip some toggles, connect. The second is the build-it-yourself route, which is what you’ll want if your site isn’t on WordPress.com, or if the tools you need don’t exist yet. This post covers both, with the second one in more detail, because that’s where the interesting decisions live.
It’s worth being precise about the vocabulary, because three things get conflated constantly.
An ability is a WordPress-side concept. It’s a named, namespaced unit of functionality — my-plugin/create-draft, say — registered with a label, a human-readable description, a JSON Schema describing its input, a JSON Schema describing its output, a callback that does the work, and a permission_callback that decides whether the current user is allowed to run it. Abilities live in the WordPress registry. They know nothing about AI.
MCP is the transport-and-discovery protocol. An MCP server advertises a list of tools; an MCP client (Claude, or Claude Code, or an editor) reads that list, decides which tool to call, sends arguments, and gets structured results back.
The MCP Adapter is the bridge. It takes abilities you’ve registered and exposes them as MCP tools or resources over HTTP or STDIO. If your code already registers abilities, you are genuinely one step from having an AI agent able to use them.
The reason this layering matters: the interesting work is almost entirely in ability design. The MCP part is close to boilerplate. If you spend your time thinking about transports, you’re thinking about the wrong problem.
If your site is on WordPress.com, or it’s self-hosted but connected through Jetpack, most of the work has been done for you.
WordPress.com ships an official Claude connector, and it’s listed in Claude’s connector directory, so there’s no config file to hand-edit. The flow is:
MCP access requires a paid WordPress.com plan (Personal and above). If you’d rather use a client that isn’t in the directory — Cursor, VS Code, or your own agent — the endpoint is https://public-api.wordpress.com/wpcom/v2/mcp/v1, with the same OAuth flow.
This is the part people miss: you don’t have to be on WordPress.com to use the managed route. Jetpack exposes the same MCP surface for self-hosted sites, from Jetpack 15.8 onward, on a Jetpack AI or Jetpack Complete plan.
Go to Jetpack → AI in wp-admin and toggle Enable MCP access under the external AI agent access section. It’s off by default. From there you get granular read permissions (Site, Posts, Pages, Design, Domains) and separately granular write permissions — the two are independent, so granting read never implies write. Jetpack then generates the MCP server configuration for you to paste into whichever client you’re using, and keeps an activity log of every tool call the agent makes, with timestamps.
One design choice worth calling out: every write action an agent performs requires your explicit confirmation before it executes. That’s a sensible default and it removes most of the “what if it goes rogue” anxiety.
Take it if you can. Genuinely. It’s OAuth-based, someone else maintains it, the permission UI is comprehensible to a non-developer, and it covers the common cases: analysing traffic, summarising comments, finding stale posts, spotting broken links, mapping internal linking opportunities, drafting and editing content.
You outgrow it when one of these becomes true:
That last one turns out to matter more than expected, and it’s the main argument for building your own.
Here’s the architecture end to end:
Your PHP → Abilities API → MCP Adapter → HTTP / STDIO → Claude
(register) (WP registry) (exposes as (transport) (client)
MCP tools)
You write PHP that registers abilities. The adapter turns them into an MCP server. Claude connects to it. Nothing else is required.
The quickest path is as a plugin:
wp plugin install \ https://github.com/WordPress/mcp-adapter/releases/latest/download/mcp-adapter.zip \ --activate
If you’re building a plugin that ships MCP support, take it as a library instead, and use the Jetpack Autoloader so that two plugins bundling different versions of the adapter don’t fight:
composer require wordpress/mcp-adapter
Installing the adapter as a plugin also registers a default server at /wp-json/mcp/mcp-adapter-default-server, which exposes abilities flagged as public. That’s useful for a smoke test. For production you’ll almost always want your own server with an explicit allow-list, which we’ll come to.
Categories have to exist before the abilities that reference them, and both registrations happen on dedicated hooks. Calling wp_register_ability() outside wp_abilities_api_init silently returns null, which is a fun ten minutes of debugging if you don’t know it.
add_action( 'wp_abilities_api_categories_init', function () {
wp_register_ability_category(
'editorial',
array(
'label' => __( 'Editorial', 'my-plugin' ),
'description' => __( 'Drafting and revision tools.', 'my-plugin' ),
)
);
} );
add_action( 'wp_abilities_api_init', function () {
wp_register_ability( 'my-plugin/create-draft', array(
'label' => __( 'Create Post Draft', 'my-plugin' ),
'description' => __(
'Creates a new unpublished draft. Content may be Gutenberg '
. 'block markup or plain/markdown text, which is converted '
. 'to blocks automatically. Cannot publish — status is '
. 'always draft or pending review. Returns the draft ID '
. 'and its wp-admin edit URL.',
'my-plugin'
),
'category' => 'editorial',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'title' => array(
'type' => 'string',
'description' => 'Post title, plain text.',
),
'content' => array(
'type' => 'string',
'description' => 'Block markup or markdown.',
),
'status' => array(
'type' => 'string',
'enum' => array( 'draft', 'pending' ),
'default' => 'draft',
'description' => 'Only draft or pending.',
),
),
'required' => array( 'title', 'content' ),
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'id' => array( 'type' => 'integer' ),
'edit_url' => array( 'type' => 'string' ),
'status' => array( 'type' => 'string' ),
),
),
'execute_callback' => 'my_plugin_create_draft',
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
'meta' => array(
'mcp' => array( 'public' => true ),
),
) );
} );
Two details in there do a lot of work.
meta.mcp.public is what makes the ability visible to MCP at all. Abilities are not exposed by default, which is the correct default. Core abilities that you want to expose can be flagged retroactively through the wp_register_ability_args filter.
The enum on status is not decoration. It’s a hard constraint enforced by schema validation before your callback ever runs. If the model asks to publish, the request is rejected at the boundary. This is the single most useful pattern in the whole exercise, and we’ll come back to it.
The default server is fine for experimenting. In production you want a named server with an explicit list of abilities, so that adding a new ability somewhere else in your codebase doesn’t silently widen what the agent can reach.
add_action( 'mcp_adapter_init', function ( $adapter ) {
$adapter->create_server(
'editorial-server', // server id
'my-plugin', // namespace
'mcp', // route base
'Editorial MCP Server', // name
'Drafting, media and SEO tools.', // description
'v1.0.0',
array( \WP\MCP\Transport\HttpTransport::class ),
\WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler::class,
\WP\MCP\Infrastructure\Observability\NullMcpObservabilityHandler::class,
array(
'my-plugin/create-draft',
'my-plugin/update-draft',
'my-plugin/get-draft',
'my-plugin/list-drafts',
)
);
} );
That gives you an endpoint at /wp-json/my-plugin/mcp/editorial-server. Swap the null observability handler for a real one when you go live — see the operations section below.
Two transports are supported. Which you use depends on where Claude is running.
STDIO, for local development. The adapter ships a WP-CLI command, so the client spawns a process against your local install:
{
"mcpServers": {
"wordpress-local": {
"command": "wp",
"args": [
"--path=/path/to/wordpress",
"mcp-adapter",
"serve",
"--server=editorial-server",
"--user=admin"
]
}
}
}
HTTP, for a real site. The adapter’s HTTP transport implements the MCP HTTP spec, and authenticates the client as a logged-in WordPress user. The most reliable way to wire this up today is Automattic’s proxy package, which translates STDIO on the client side into authenticated REST calls to your site:
{
"mcpServers": {
"wordpress": {
"command": "npx",
"args": ["-y", "@automattic/mcp-wordpress-remote@latest"],
"env": {
"WP_API_URL": "https://example.com/wp-json/my-plugin/mcp/editorial-server",
"WP_API_USERNAME": "claude-agent",
"WP_API_PASSWORD": "xxxx xxxx xxxx xxxx xxxx xxxx"
}
}
}
}
The password there is a WordPress Application Password, generated per user under Users → Profile → Application Passwords. Not the account password. Generate a distinct one per client so you can revoke a single machine without breaking everything else.
If you’d rather not run a local proxy, you can point an MCP client at the HTTP endpoint directly, and layer your own auth via a transport permission callback. Custom transports are supported too, by implementing the adapter’s transport interface. Most teams won’t need to.
Getting a connection up takes an afternoon. Getting a connection that’s genuinely useful — and that you’d let run against a production site — takes longer, and it’s almost all ability design. Here’s what we learned.
The tempting move is to mirror the REST API: get_post, update_post, create_media. Resist it. Generic CRUD gives the model maximum freedom and minimum guidance, which produces exactly the behaviour you’d expect.
Better to name the job. Instead of update_post, ship update-draft that refuses to touch anything already published, scheduled or private. Instead of a general list_posts, ship list-drafts that returns work in progress, newest-modified first — because “find the thing I was working on” is a real question and “list all posts with these seventeen filter parameters” is not. Instead of a generic settings reader, ship list-posts-missing-seo that answers “which posts need attention?” in one call.
Each of those is a narrower tool that does more. The model doesn’t have to compose four calls and infer a filter; it makes one call whose name matches the intent.
Anything you’d otherwise have to say in a prompt — “don’t publish”, “only these post types”, “max 8MB” — should be expressed in the input schema instead. Schema validation runs before your callback and before the model can talk itself into anything. An enum of [“draft”, “pending”] is worth more than three paragraphs of instruction, because it can’t be forgotten between sessions, can’t be overridden by something the model read in a post it was summarising, and applies identically to every client that ever connects.
The same logic applies to the callback body. Our draft-editing abilities check post status and hard-refuse published, scheduled and private posts, returning a clear error that says to use wp-admin for those. The model doesn’t need to be trusted on this, so it isn’t.
The description field is not a code comment. It’s the entire documentation the model gets, and it’s read fresh at the start of every session by something with no memory of your last conversation.
Write it as if for a competent contractor on their first day. State what the tool does, what it explicitly won’t do, what the input formats are, and what comes back. Compare:
Creates a draft.
against:
Creates a new unpublished draft. Content may be Gutenberg block markup or plain/markdown text, which is converted to blocks automatically. Cannot publish — status is always draft or pending review. Returns the draft ID and its wp-admin edit URL.
The second one prevents half a dozen wrong turns before they happen. It tells the model it can write markdown and doesn’t need to hand-build block comments. It kills the “and now publish it” follow-up. And it tells the model something useful comes back, so it can hand you a link instead of saying “done.”
Half the value in a mature WordPress site lives in third-party plugins, and none of those plugins have MCP support. That’s fine — you can wrap them.
We built abilities over an SEO plugin’s data: read a post’s SEO snapshot, update specific fields, list posts missing a focus keyphrase, rank posts by content score, pull search-console-backed reports for content decay and near-miss keywords, manage redirects and read the 404 log. None of that required the plugin vendor to ship anything. It’s a read of their data model behind a schema you control.
Wrapping also lets you be selective in a way the underlying plugin can’t. Our SEO update ability accepts a specific list of fields and rejects everything else, with an error explaining that the remaining fields must be edited in the admin UI. The plugin would happily let you write them all. We decided which subset was safe for an agent, and encoded that decision once.
Every write ability should return the ID and the wp-admin URL of the thing it touched. It costs one line and it changes the experience completely: the session ends with a link a human clicks, rather than an assertion that something happened somewhere.
The same applies to lookups. A list-media ability that returns attachment IDs alongside URLs lets the model construct correct block markup on the next call. Without the IDs it will guess, and guessing produces broken images.
Some things are much better done in PHP than by asking the model to produce them. We ship an ability that composites a branded cover image server-side: it takes a background image already in the media library, crops it, applies a scrim, draws the brand furniture, sets the post title across it, saves the result and optionally attaches it as the featured image. One call, deterministic output, on-brand every time.
That’s a good template for the general pattern. Where a step has a single correct answer, encode the correct answer in an ability and give the model one parameter, rather than asking it to reproduce your design system from a description.
A small one that pays off: an ability that lists users who can publish a given post type, returning display name, ID, slug and roles — and deliberately not email addresses or login details. Now a draft can be credited to the right person by name, without the agent ever seeing PII it has no use for.
Everything you expose over MCP is application surface area, and should be treated as such.
Use a dedicated user. Create a claude-agent user with the narrowest role that works — often Contributor or a custom role — rather than pointing the connection at an administrator account. The adapter authenticates MCP clients as WordPress users, so the role is your outer boundary, and permission_callback is your inner one. Both should be tight.
Belt and braces on permissions. Every ability needs a permission_callback that checks a real capability, not __return_true. is_user_logged_in() is a starting point for local development and not a production answer. And where an ability is destructive, consider whether it should exist at all in the agent-facing server. We chose not to ship a delete ability. If deletion is needed, that’s what wp-admin is for.
Prefer read-only on anything public-facing. If an endpoint is reachable without a proxy, keep it to reads.
Rotate application passwords and scope them per client. One per machine, revoked individually.
Log everything. The adapter takes an observability handler; the default is a null handler that discards. Replace it. You want a record of which tool was called, by which user, with what arguments, and whether it succeeded — both to debug and to answer “what did it do last Tuesday?” The managed Jetpack route gives you this activity log for free, which is a fair argument in its favour.
Think about prompt injection. This is the one people forget. If an ability returns post content, comments, or 404 logs, that content is attacker-influenced text arriving in the model’s context. The mitigation isn’t clever prompting; it’s the same as above — constrain what the tools can do, so that even a fully persuaded model can’t publish, delete, or escalate. A model that has been convinced to publish something and finds that publishing isn’t in the schema is a non-event.
| Managed plugin | Custom abilities | |
|---|---|---|
| Setup | Minutes | An afternoon, then ongoing |
| Requirements | WordPress.com plan, or Jetpack AI/Complete | WP 6.9+, PHP 7.4+, ability to ship PHP |
| Auth | OAuth 2.1 | Application passwords, or custom |
| Tool surface | Fixed groups, toggleable | Exactly what you write |
| Third-party plugin data | Only if supported | Wrap anything |
| Constraints | Read/write toggles | Arbitrary, schema-enforced |
| Audit log | Built in | You build it |
| Maintenance | Someone else’s | Yours |
The honest recommendation: start with the managed route, even if you expect to outgrow it. It costs an hour, and a week of using it will teach you which five tools you actually reach for and which twenty you never touch. That list is the specification for your custom server, and it will look nothing like what you’d have guessed up front.
Then build custom abilities for the gap — the plugin-specific data, the house rules, the multi-step jobs. The two aren’t mutually exclusive; nothing stops you running the managed connector for general site questions and your own server for editorial workflow.
The gap between “an AI assistant that has opinions about WordPress” and “an AI assistant that works on your WordPress site” turns out to be about two hundred lines of PHP and a set of decisions about what it’s allowed to do. The PHP is the easy half.