permission_callback is the single most important field in wp_register_ability().
Get the schema wrong and you get a confusing error. Get the permission check wrong and
you get an AI agent, or anyone who can reach it, doing something on your site it should
never have been allowed to do. This lesson treats it with the weight it deserves.
The my-plugin/create-draft-post ability from Lessons 2 and 3. Basic familiarity with
WordPress roles and capabilities (edit_posts, edit_post, manage_options, and so
on).
Step 1: What permission_callback actually receives and returns
permission_callback receives the same $input your execute_callback will receive,
before execution happens, and must return true, false, or a WP_Error for a more
descriptive rejection. Returning anything falsy blocks execution entirely,
execute_callback never runs.
// File: my-mcp-abilities.php (basic capability check)
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
This is the correct baseline for the create-draft-post ability: edit_posts is the
capability WordPress core itself uses to gate creating new posts, reusing it means your
ability follows the same access rules as the rest of wp-admin, rather than inventing a
parallel permission system.
Step 2: Matching the capability to the actual action
A common mistake is checking a capability that’s technically true for the caller but
wrong for what the ability actually does. edit_posts is right for creating a new
draft. It is not right for an ability that publishes a post, deletes one, or edits an
arbitrary existing post by ID, each of those needs its own, more specific check:
// File: my-mcp-abilities.php (publish-post ability, matching capability)
'permission_callback' => function () {
return current_user_can( 'publish_posts' );
},
// File: my-mcp-abilities.php (delete-post ability, matching capability)
'permission_callback' => function ( $input ) {
return current_user_can( 'delete_post', $input['post_id'] ?? 0 );
},
Notice the second example passes $input['post_id'] into current_user_can(). That
brings us to the check most people skip.
Step 3: Object-level ownership, not just role-level capability
current_user_can( 'edit_posts' ) answers “can this user edit posts in general?” It
does not answer “can this user edit this specific post?” WordPress’s meta-capability
system exists exactly to answer the second question, by passing the object ID as a
second argument:
// File: my-mcp-abilities.php (update-post ability, object-level check)
'permission_callback' => function ( $input ) {
$post_id = $input['post_id'] ?? 0;
if ( ! $post_id || ! get_post( $post_id ) ) {
return new WP_Error(
'invalid_post',
__( 'No post exists with that ID.', 'my-plugin' )
);
}
return current_user_can( 'edit_post', $post_id );
},
current_user_can( 'edit_post', $post_id ) resolves WordPress’s built-in ownership and
status rules for you, an author can edit their own draft but not someone else’s,
depending on your site’s role setup. Skipping the second argument and only checking the
general edit_posts capability would let any user who can edit some posts edit any
post, including ones they don’t own, an easy and dangerous mistake to miss in review
since the ability “works” in every manual test done as an administrator.
Step 4: Returning WP_Error for better client-side handling
A plain false tells the calling client “no”, with no reason. A WP_Error lets you say
why, which an AI client can actually surface to the human it’s working for instead of
failing opaquely:
// File: my-mcp-abilities.php (descriptive permission failure)
'permission_callback' => function () {
if ( ! is_user_logged_in() ) {
return new WP_Error(
'not_logged_in',
__( 'You must be logged in to create a draft.', 'my-plugin' )
);
}
if ( ! current_user_can( 'edit_posts' ) ) {
return new WP_Error(
'insufficient_permissions',
__( 'Your account does not have permission to create posts.', 'my-plugin' )
);
}
return true;
},
Test it
Confirm the permission check actually blocks a user it should block. Create (or use) a Subscriber-role test user, then call the ability as them:
# File: terminal, assumes a user "test-subscriber" with the Subscriber role exists
wp eval '
wp_set_current_user( get_user_by( "login", "test-subscriber" )->ID );
$ability = wp_get_ability( "my-plugin/create-draft-post" );
var_dump( $ability->execute( array( "title" => "Should fail", "body" => "..." ) ) );
'
You should get a WP_Error, not a created post. Then repeat as a user with the
edit_posts capability and confirm it succeeds. Testing the failure case is not
optional, it’s the more important of the two.
The single most common security mistake with abilities is developing and testing exclusively as an administrator, where every capability check silently passes. A permission check with a bug in it looks identical to a correct one until you test with a lower-privileged account. Always test every ability with at least one deliberately under-privileged user before considering it done, and always test the specific-object case (someone else’s post, not your own) for any ability that takes an object ID.
Recap
permission_callback is a required, security-critical gate that runs before
execute_callback and can return true, false, or a WP_Error. Match the capability
you check to the actual action, not a loosely-related one, and use the meta-capability
form (edit_post, delete_post, with the object ID) whenever an ability acts on a
specific existing item, not just the general form. This is the ability-level half of
permission checking, Lesson 6 adds the server-wide transport-level check that sits in
front of it.
Resources & further reading
- wp_register_ability() function reference, developer.wordpress.org
- Abilities API reference, developer.wordpress.org
- MCP Adapter repository, GitHub