A role check answers “can this kind of account do this kind of thing.” It does not
answer “should this specific account do this to this specific object, right now.” Most
real-world abilities need the second question answered too. This lesson writes
permission_callback functions that check ownership, status, and context, on top of the
role and capability checks from the previous lesson, not instead of them.
Lesson 4 (building a dedicated role). Comfortable reading input_schema and permission_callback from Course 1’s ability lessons.
Step 1: Why role checks alone are frequently not enough
current_user_can( 'edit_posts' ) answers a yes/no question about the account’s general
standing, it doesn’t know or care which post is about to be edited. WordPress core
handles this for its own meta-capabilities (edit_post, delete_post) by resolving
ownership internally, but plenty of ability designs need finer-grained logic than any
built-in meta-capability provides, especially once an ability’s argument is something
custom, like an order ID, a support ticket ID, or a specific comment.
Step 2: Checking ownership explicitly
permission_callback receives the same request arguments execute_callback does. Use
them to look up the actual object and compare it against the current user before
granting access:
// File: my-plugin.php
add_action( 'wp_abilities_api_init', function () {
wp_register_ability(
'my-plugin/update-own-draft',
array(
'label' => __( 'Update own draft', 'my-plugin' ),
'input_schema' => array(
'type' => 'object',
'properties' => array(
'post_id' => array( 'type' => 'integer' ),
'content' => array( 'type' => 'string' ),
),
'required' => array( 'post_id', 'content' ),
),
'permission_callback' => function ( $input ) {
$post = get_post( $input['post_id'] );
if ( ! $post ) {
return false;
}
// Role-level check first, object-level check second.
return current_user_can( 'edit_post', $post->ID )
&& (int) $post->post_author === get_current_user_id();
},
'execute_callback' => function ( $input ) {
wp_update_post( array(
'ID' => $input['post_id'],
'post_content' => $input['content'],
) );
return array( 'updated' => true );
},
)
);
} );
current_user_can( 'edit_post', $post->ID ) already covers most ownership logic through
WordPress’s meta-capability mapping, but the explicit post_author comparison makes the
intent unambiguous and gives you a place to add further conditions without relying on
core’s internal resolution alone.
Never trust an ability’s input arguments as already-validated. permission_callback
should independently load the real object (get_post(), get_comment(), a custom
lookup) and check it directly, rather than assuming the ID passed in refers to something
the caller is actually allowed to touch.
Step 3: Gating on object state, not just identity
Some actions should only be available when an object is in a specific status, regardless
of who owns it. A “publish” ability, for instance, might only make sense against posts
still in draft or pending:
// File: my-plugin.php
'permission_callback' => function ( $input ) {
$post = get_post( $input['post_id'] );
if ( ! $post || ! in_array( $post->post_status, array( 'draft', 'pending' ), true ) ) {
return false;
}
return current_user_can( 'publish_posts' );
},
This prevents an agent from re-triggering a publish action on already-published content, or acting on a trashed or scheduled post where that action doesn’t make sense, even if the role-level capability check alone would have allowed it.
Step 4: Return WP_Error for clearer failures, not just false
permission_callback can return a WP_Error instead of a bare false, which surfaces a
real, specific message back through the MCP Adapter rather than a generic denial:
'permission_callback' => function ( $input ) {
$post = get_post( $input['post_id'] );
if ( ! $post ) {
return new WP_Error( 'not_found', 'No post with that ID exists.', array( 'status' => 404 ) );
}
if ( (int) $post->post_author !== get_current_user_id() ) {
return new WP_Error( 'forbidden', 'This agent account does not own that post.', array( 'status' => 403 ) );
}
return true;
},
This is a genuine usability improvement for agent workflows: a model that receives a specific, structured denial reason can explain the failure to the person it’s working for, rather than just reporting an opaque “action failed.”
Test it: confirm cross-account access is actually denied
Using two agent accounts (or one agent account and a manually created draft owned by a different user), confirm the second scenario correctly fails:
wp-env run cli wp eval '
$other_post = wp_insert_post( array( "post_title" => "Not yours", "post_status" => "draft", "post_author" => 1 ) );
echo $other_post . "\n";
'
Then attempt to call update-own-draft from your agent account against that post ID
through your actual MCP client. It should be denied, since the account is not the
post’s author, even though it holds the edit_posts capability generally.
It’s easy to verify an ability works when everything lines up, right user, right status, right ownership, and never test the denial paths at all. Write at least one deliberate negative test per permission-gated ability: wrong owner, wrong status, missing object. That’s where the actual security value of this lesson lives.
Recap
A role or capability check tells you what kind of thing an account is generally allowed
to do. Object ownership and status checks, added directly inside permission_callback,
tell you whether this specific action on this specific object, right now, should be
allowed. Combine both, re-fetch objects independently rather than trusting input
arguments, and return structured WP_Error responses so denials are informative rather
than opaque.
Resources & further reading
- wp_register_ability() function reference, developer.wordpress.org
- WP_Error class reference, developer.wordpress.org
- MCP Adapter repository, GitHub