Schemas & Permissions

Writing Permission Callbacks: Securing Who Can Call What

⏱ 17 min

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.

What you'll learn in this lesson
The permission_callback contract
What it receives, and what returning true/false versus WP_Error actually does.
Capability checks done properly
current_user_can() with the right capability for the action, not just "is logged in".
Object-level ownership checks
Why "can edit posts" and "can edit this specific post" are different questions.
The two layers of permission in a full MCP setup
How this ability-level check relates to the server-wide transport check covered in Lesson 6.
Prerequisites

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.

Capability checks to reach for by action type
Creating new content
edit_posts, or the relevant post type equivalent (edit_pages, etc.).
Publishing
publish_posts, distinct from just being able to create a draft.
Editing an existing, specific item
edit_post / edit_page with the object ID as the second argument, not just the general capability.
Deleting
delete_post with the object ID, same reasoning as editing.
Site-wide settings or user management
manage_options, or capability_type-specific equivalents, never left to a general logged-in check.

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.

Only ever testing as an administrator

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

← Defining Input and Output JSON Schemas for Abilities Controlling MCP Visibility with meta.mcp.public →