On this page
To implement a custom WordPress ability for MCP tools, register it with
wp_register_ability() on the wp_abilities_api_init hook. Give it a
permission_callback that checks a real capability, and set
meta.mcp.public to true. The MCP Adapter plugin then offers it to AI
clients as a tool.
Abilities are private by default, so nothing reaches an AI client until you opt it in. This guide walks through one ability from start to finish. It covers the permission checks that matter, and the things you should never put on the MCP surface.
How the WordPress MCP Adapter abilities ecosystem works
The MCP Adapter is the official WordPress plugin that connects the Abilities API to the Model Context Protocol (MCP). The Abilities API is in WordPress core since 6.9. It's a registry where core, plugins and themes describe what they can do: a name, an input schema, a permission check and a callback. The adapter reads that registry and shows the opted-in entries to AI clients as MCP tools, resources or prompts.
That's the "abilities ecosystem": you write one ability, and any MCP client can use it through the adapter. You don't write a separate MCP server.
| Piece | What it does |
|---|---|
| Abilities API (core, 6.9+) | wp_register_ability(), wp_register_ability_category(), permission and input checks |
MCP Adapter plugin (WordPress/mcp-adapter) |
Turns public abilities into MCP tools; HTTP endpoint and a WP-CLI stdio command |
| Default server | Created on plugin load at /wp-json/mcp/mcp-adapter-default-server, with three meta-tools to find and run abilities |
| Custom servers | Your own endpoint with an explicit list of abilities, each one its own tool |
@automattic/mcp-wordpress-remote |
Optional local helper (npm) for desktop clients that can't send an auth header |
The current release is 0.7.1 (16 October 2026). It needs WordPress 6.9 and
PHP 7.4, and WordPress.org lists 70,000+ active installs. The license is
GPL-2.0-or-later. It replaces Automattic's older wordpress-mcp plugin, which
is now archived and points users here.
Install the adapter
On a site, install it like any plugin. The project's docs give this WP-CLI line:
wp plugin install https://github.com/WordPress/mcp-adapter/releases/latest/download/mcp-adapter.zip --activate
If your own plugin depends on it, declare it in your plugin header with
Requires Plugins: mcp-adapter. Check for the class before you use it:
add_action( 'plugins_loaded', function () {
if ( ! class_exists( \WP\MCP\Core\McpAdapter::class ) ) {
return; // MCP Adapter isn't active; skip MCP setup.
}
// Register MCP servers here.
} );
The docs advise against bundling the adapter in your vendor/ folder with
Composer (composer require wordpress/mcp-adapter). Two plugins with their
own copies can clash, and the v0.7.0 migration guide explains how to move to
the canonical plugin.
How to implement a custom WordPress ability for MCP tools
An ability needs a category, and the category must exist before the
ability registers. If it doesn't, wp_register_ability() returns null and
the ability quietly never appears. Core ships two categories, site and
user. Register your own on an earlier hook:
add_action( 'wp_abilities_api_categories_init', function () {
wp_register_ability_category( 'my-plugin', array(
'label' => 'My Plugin',
'description' => 'Abilities provided by My Plugin.',
) );
} );
Then the ability itself. This one is read-only. It returns a short summary of one post, and only if the current user may read that post:
add_action( 'wp_abilities_api_init', function () {
wp_register_ability( 'my-plugin/get-post-summary', array(
'label' => 'Get post summary',
'description' => 'Returns the title, status and first 40 words of one post.',
'category' => 'my-plugin',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'post_id' => array(
'type' => 'integer',
'description' => 'ID of the post to summarise.',
'minimum' => 1,
),
),
'required' => array( 'post_id' ),
'additionalProperties' => false,
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'title' => array( 'type' => 'string' ),
'status' => array( 'type' => 'string' ),
'excerpt' => array( 'type' => 'string' ),
),
),
'execute_callback' => function ( $input ) {
$post = get_post( $input['post_id'] );
if ( ! $post ) {
return new WP_Error( 'not_found', 'No post with that ID.' );
}
return array(
'title' => get_the_title( $post ),
'status' => $post->post_status,
'excerpt' => wp_trim_words( $post->post_content, 40 ),
);
},
'permission_callback' => function ( $input ) {
// Object-level check: this user, this post.
return current_user_can( 'read_post', $input['post_id'] );
},
'meta' => array(
'annotations' => array(
'readonly' => true,
'destructive' => false,
'idempotent' => true,
),
'mcp' => array(
'public' => true, // Expose to MCP only.
'type' => 'tool', // Or 'resource' / 'prompt'.
),
),
) );
} );
What each part does:
- Name.
my-plugin/get-post-summaryis the registration name. MCP tool names can't contain/, so the adapter turns it intomy-plugin-get-post-summary. Themcp_adapter_tool_namefilter lets you change it. - Input schema. Core checks the input against this schema before it calls
your permission callback, so
$input['post_id']is there and is a whole number. KeepadditionalPropertiesoff so the model can't slip in extra fields. - Annotations.
readonly,destructiveandidempotentbecome MCP'sreadOnlyHint,destructiveHintandidempotentHint. Clients use them to decide when to ask before a call. They're hints, not a lock (see MCP tool annotations). - Exposure.
meta.mcp.publicopts the ability into MCP and nothing else.meta.publicopts it into every client channel. From WordPress 7.1, core also usesmeta.publicfor the REST API. On 6.9 and 7.0 the adapter honours it for MCP, but REST still needsmeta.show_in_rest.
Write permission callbacks that fail closed
A request passes two checks. The first is the server's transport
permission, a gate for the whole endpoint. By default it is
is_user_logged_in(). The second is your ability's permission_callback,
which runs on every call and gets the call's input. If either one says no,
the call doesn't run.
Core won't register an ability that has no permission callback. (WordPress
7.2, in beta now, adds a required_capabilities list you can use instead.)
Core also runs the ability only when the callback returns exactly true.
false, a WP_Error, or a truthy value like 1 all count as a no.
Rules that keep it tight:
- Check a capability, not a login. A Subscriber is logged in.
is_user_logged_in()is fine for the transport gate. It is not enough for an ability that changes anything. - Check the object, not only the type.
current_user_can( 'edit_post', $input['post_id'] )asks about this post.current_user_can( 'edit_posts' )only asks whether they can edit some post. - Never take the acting user from the input. The user is whoever signed
in. A
user_idfield in the input is just a target, so check that the current user may act on it. - Return
falseor aWP_Errorwhen in doubt. Don't catch an exception and then allow the call. Don't maketruethe fallback.
The cautionary tale: GHSA-7x77-5r4r-q4rf
The adapter's own advisory shows why "fail closed" matters. In versions before
0.5.0, HttpTransport::check_permission() didn't treat a WP_Error from a
custom transport permission callback as a denial. An exception was handled
the same way. In both cases the adapter logged the error and fell back to the
default check, is_user_logged_in().
So a callback written as "return WP_Error to deny" let any logged-in user,
including a Subscriber, start a session on an admin-only server. They could
then list its tools and call them. GitHub rates it high (CVSS 8.1).
The fix was merged in March 2026 and shipped in 0.5.0 on 15 April 2026. The
advisory was published on 23 October 2026, months after the fix. If you're
stuck on an older version, make your transport callback return false to
deny. Better, update to 0.7.1.
One thing to watch: the project's transport-permissions doc still says
exceptions "fall back to is_user_logged_in()". The current code returns
false and denies. Go by the code, and write your callbacks so they're safe
either way.
Default server or custom server
| Default server | Custom server | |
|---|---|---|
| Created | Automatically when the plugin loads | By you, on the mcp_adapter_init hook |
| What the client sees | Three meta-tools: mcp-adapter-discover-abilities, mcp-adapter-get-ability-info, mcp-adapter-execute-ability |
Each listed ability as its own tool, with its full schema |
| Which abilities | Every ability marked public, from any plugin | Only the ones you list |
| Access gate | is_user_logged_in() |
Your own transport permission callback |
For a focused integration, use a custom server. You know exactly which tools
it exposes, and you can put a stricter gate on the whole endpoint. The gate
is the last argument of create_server():
add_action( 'mcp_adapter_init', function ( $adapter ) {
$adapter->create_server(
'my-plugin-editor-server', // Server ID
'my-plugin', // REST namespace
'mcp', // REST route
'My Plugin MCP', // Name
'Editorial tools for My Plugin', // Description
'1.0.0', // Version
array( \WP\MCP\Transport\HttpTransport::class ),
\WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler::class,
null, // Observability handler
array( 'my-plugin/get-post-summary' ), // Tools
array(), // Resources
array(), // Prompts
function (): bool { // Transport permission
return current_user_can( 'edit_posts' );
}
);
} );
Set the transport gate to the broadest capability that any of the server's abilities needs. Then let each ability's own callback narrow it down. If you don't want public abilities from other plugins to show up through the meta-tools at all, turn the default server off:
add_filter( 'mcp_adapter_create_default_server', '__return_false' );
What to keep off the MCP surface
Abilities change your live site just like an action in wp-admin. A model can be steered by text it reads, such as a comment, a post or a form entry (see MCP prompt injection). So treat every exposed ability as something an outsider might trigger through the connected user.
Keep these out of MCP, or behind an admin-only custom server:
- Code and raw queries. Anything that runs PHP, SQL or shell commands
built from the input. That includes
$wpdb->query()on model-written text. - Site control. Installing or activating plugins and themes, editing
files, changing user roles, creating users, and writing options like
siteurloractive_plugins. - Irreversible bulk actions. Bulk delete, empty trash, mass email. Offer "move to trash" for one item instead.
- Publishing without review. Expose "create draft", and let a person press Publish.
- Secrets and personal data. API keys stored in options, Application Passwords, and customer or user emails you don't need for the task.
- Fetching any URL. An ability that calls
wp_remote_get()on a model-chosen URL can be pointed at internal addresses.
To keep a public ability out of MCP only, opt it out:
'meta' => array(
'public' => true,
'mcp' => array( 'public' => false ),
),
Connect a client to test it
Locally, with WP-CLI. No password needed. List the tools, then call yours:
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | wp mcp-adapter serve --user=editor --server=my-plugin-editor-server
Use --user with the lowest role that should be able to use the tools, not
admin. Without --user, it runs as a logged-out visitor.
Over HTTP. Clients sign in with a WordPress Application Password (Users > Profile). WordPress offers Application Passwords only on HTTPS sites, local development sites aside. Claude Code can send the header directly:
claude mcp add --transport http my-wordpress-site \
https://example.com/wp-json/mcp/mcp-adapter-default-server \
--header "Authorization: Basic PASTE_THE_BASE64_HERE"
The value is username:application-password in base64. Desktop clients that
can't send a header can use the @automattic/mcp-wordpress-remote helper, as
shown on the listing. The web apps
(claude.ai, ChatGPT) need an OAuth sign-in. Neither WordPress nor the adapter
provides one, so they can't connect on their own today. More on the
trade-offs is in MCP authentication.
An Application Password can do everything its user can do. So connect as a dedicated user with a narrow role, create one password per client, and revoke it from the profile screen when you're done.
Before you expose an ability: 8 checks
0 of 8 done
- The category is registered on
wp_abilities_api_categories_init, before the ability. - The
permission_callbackchecks a capability on the specific object, and returnstrueonly on success. - The input schema lists every field, marks the required ones and sets
additionalPropertiestofalse. - The ability is opted in with
meta.mcp.public(MCP only) unless you also want it on REST. - The annotations are honest:
readonlyonly if it changes nothing,destructiveif it deletes or overwrites. - It does none of the things in "What to keep off the MCP surface", or it lives on an admin-only custom server.
- The transport gate is a capability check, and the default server is off if you don't need it.
- You tested it with
wp mcp-adapter serveas a low-role user, and saw it refuse a post that user can't read.
How we grade the adapter
We grade the MCP Adapter listing A, as an assessment from public signals. It's maintained by the WordPress project with a dozen human contributors in the last 90 days. Its one advisory was fixed before it went public. There's no shell-out in the plugin code, and remote access needs HTTPS for Application Passwords. The deduction is authentication: an Application Password has no scopes and no OAuth, so it carries the user's full role.
The grade covers the adapter, not the abilities other plugins register. What an AI client can do on your site is the sum of every public ability, so look at those too. The MCP security best practices checklist applies to them as well.
Need custom abilities for your plugin or a client's site, with the permissions done right? Tell us what it should do.
Frequently asked questions
What is the WordPress MCP Adapter?
It's the official WordPress plugin that turns abilities registered with the
Abilities API into MCP tools, resources and prompts. AI clients reach them
over HTTP at /wp-json/mcp/... or locally through wp mcp-adapter serve.
How do I implement a custom WordPress ability for MCP tools?
Register a category on wp_abilities_api_categories_init. Then call
wp_register_ability() on wp_abilities_api_init with a label, description,
category, input schema, execute_callback and permission_callback. Set
meta.mcp.public to true so the adapter shows it to MCP clients.
What is the WordPress MCP Adapter abilities ecosystem?
It's the way abilities from core, plugins and themes all land in one registry, the Abilities API, and the adapter offers the public ones to MCP clients. You write an ability once and any MCP client can use it. The catch is that the sum of every public ability is what an AI client can do on your site.
Is the Abilities API part of WordPress core?
Yes, since WordPress 6.9. The MCP Adapter is still a separate plugin that you install on top of it.
Why doesn't my ability show up in the MCP client?
The usual causes: the category wasn't registered first, so
wp_register_ability() returned null. Or the ability isn't marked public.
Or it's on a custom server you didn't list it on. Or the connected user fails
the transport gate or the ability's permission check.
Is the WordPress MCP Adapter safe to use?
We grade it A from public signals, with one advisory fixed in 0.5.0. The real risk is in the abilities you expose and the role of the user you connect as. Use a low-role user, one Application Password per client, and keep site control abilities off MCP.
Can ChatGPT or claude.ai connect to a WordPress site through the adapter?
Not on their own today. The web apps need an OAuth sign-in, and neither WordPress nor the adapter provides one. Use Claude Code, Claude Desktop, Cursor or VS Code with an Application Password, or a plugin that adds OAuth.
What happened to Automattic's wordpress-mcp plugin?
It's archived on GitHub. Its description says it will be deprecated as
stable releases of the MCP Adapter arrive, and it points users to
WordPress/mcp-adapter.