Course 1 covered the shape of wp_register_ability() in the abstract. This lesson
builds one for real: an ability that lets an AI client create a draft post from a title
and a body. It’s deliberately simple, the schema and permission work you’ll add on top
of it across the next three lessons is where the real engineering happens.
The MCP Adapter installed and active (previous lesson). A basic custom plugin file or
must-use plugin to add code to. Familiarity with wp_insert_post() is helpful but not
required.
Step 1: Register a category first
Every ability must declare a category, and that category has to already be registered
when wp_register_ability() runs. If you skip this or reference a category that isn’t
registered yet, wp_register_ability() returns null and the ability never exists,
with no WP_Error and no visible failure unless WP_DEBUG is on. WordPress core ships
two categories you can reuse right away, site and user, but a real plugin should
register its own so its abilities are grouped sensibly.
Categories register on wp_abilities_api_categories_init, a separate, earlier hook
than the one abilities themselves use:
// File: my-mcp-abilities.php
add_action( 'wp_abilities_api_categories_init', function () {
wp_register_ability_category(
'my-plugin',
array(
'label' => __( 'My Plugin', 'my-plugin' ),
'description' => __( 'Abilities provided by My Plugin.', 'my-plugin' ),
)
);
} );
Step 2: Register the ability
Now the ability itself, on wp_abilities_api_init. This one accepts a post title and
body and creates a draft:
// File: my-mcp-abilities.php
add_action( 'wp_abilities_api_init', function () {
wp_register_ability(
'my-plugin/create-draft-post',
array(
'label' => __( 'Create draft post', 'my-plugin' ),
'description' => __(
'Creates a new WordPress post in draft status from a title and body. ' .
'Use this when the user asks to draft, start, or save a new post idea.',
'my-plugin'
),
'category' => 'my-plugin',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'title' => array(
'type' => 'string',
'description' => 'The post title.',
),
'body' => array(
'type' => 'string',
'description' => 'The post content, as HTML or plain text.',
),
),
'required' => array( 'title', 'body' ),
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'post_id' => array( 'type' => 'integer' ),
'edit_url' => array( 'type' => 'string' ),
),
),
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
'execute_callback' => function ( $input ) {
$post_id = wp_insert_post(
array(
'post_title' => $input['title'],
'post_content' => $input['body'],
'post_status' => 'draft',
'post_type' => 'post',
),
true
);
if ( is_wp_error( $post_id ) ) {
return $post_id;
}
return array(
'post_id' => $post_id,
'edit_url' => get_edit_post_link( $post_id, 'raw' ),
);
},
'meta' => array(
'annotations' => array( 'destructive' => false, 'idempotent' => false ),
'mcp' => array( 'public' => false ),
),
)
);
} );
A few details worth noticing: execute_callback receives the validated $input array
as its only argument, matching the shape defined in input_schema. It can return any
value on success, or a WP_Error on failure, wp_insert_post()’s second argument
(true) makes it return a WP_Error instead of 0 on failure, which we pass straight
through. permission_callback can likewise return true/false for a simple check, or
a WP_Error for a more descriptive failure, Lesson 4 goes deeper on that. And
meta.mcp.public is deliberately false here, this ability isn’t reachable by an
external AI client yet, Lesson 5 covers turning that on safely.
Step 3: Why the description matters more than usual
Unlike a typical PHP docblock, description isn’t for other developers, it’s the text
an AI model reads to decide whether this ability is the right one to call for a given
request. Write it the way you’d brief a new team member on when to use this action, not
just what it technically does. “Creates a draft post” is accurate but weak. “Use this
when the user asks to draft, start, or save a new post idea” gives a model something
concrete to match against.
Test it
Confirm the ability registered and call it directly from PHP, no MCP client needed yet:
# File: terminal
wp eval '
$result = wp_get_ability( "my-plugin/create-draft-post" )->execute( array(
"title" => "Hello from an ability",
"body" => "This draft was created directly, without any AI client involved.",
) );
var_dump( $result );
'
You should see an array containing a real post_id and edit_url. Confirm it in
wp-admin too, a new draft post with that title should exist.
If you see wp_get_ability() return null for an ability you just wrote, the most
common cause is a missing or unregistered category. Check WP_DEBUG is on and look
for a _doing_it_wrong() notice, WordPress core does not throw a hard error here, it
fails silently on production. The fix is almost always registering the category on
wp_abilities_api_categories_init before the ability that references it, not on
wp_abilities_api_init alongside it.
Recap
You registered a real ability category and a real ability that creates a draft post,
end to end, and called it directly with wp_get_ability( ... )->execute() before any AI
client was involved. The input_schema, output_schema, permission_callback, and
execute_callback you wrote here are the exact shape every ability in this course
builds on. Next, the schema work gets a full lesson of its own: required fields, types,
and enums done properly.
Resources & further reading
- wp_register_ability() function reference, developer.wordpress.org
- Abilities API reference, developer.wordpress.org
- MCP Adapter repository, GitHub