On this page
WordPress MCP Adapter: Build Custom Abilities for MCP Tools (Nov 2026)

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-summary is the registration name. MCP tool names can't contain /, so the adapter turns it into my-plugin-get-post-summary. The mcp_adapter_tool_name filter 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. Keep additionalProperties off so the model can't slip in extra fields.
  • Annotations. readonly, destructive and idempotent become MCP's readOnlyHint, destructiveHint and idempotentHint. Clients use them to decide when to ask before a call. They're hints, not a lock (see MCP tool annotations).
  • Exposure. meta.mcp.public opts the ability into MCP and nothing else. meta.public opts it into every client channel. From WordPress 7.1, core also uses meta.public for the REST API. On 6.9 and 7.0 the adapter honours it for MCP, but REST still needs meta.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_id field in the input is just a target, so check that the current user may act on it.
  • Return false or a WP_Error when in doubt. Don't catch an exception and then allow the call. Don't make true the 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 siteurl or active_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

  • The category is registered on wp_abilities_api_categories_init, before the ability.
  • The permission_callback checks a capability on the specific object, and returns true only on success.
  • The input schema lists every field, marks the required ones and sets additionalProperties to false.
  • The ability is opted in with meta.mcp.public (MCP only) unless you also want it on REST.
  • The annotations are honest: readonly only if it changes nothing, destructive if 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 serve as 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.