Development
How to Build and Secure a WordPress MCP Ability for an AI Agent

The safest first WordPress MCP ability for an AI agent is narrow, read-only, authenticated, and easy to verify. Do not begin with arbitrary SQL, option updates, plugin installation, or a generic “run WordPress function” tool.
WordPress 6.9 introduced the Abilities API as a standard registry for named functions with input schemas, output schemas, execution callbacks, and permission callbacks. The official MCP Adapter can expose selected abilities through the Model Context Protocol.
This tutorial registers one ability that lists a bounded number of recently published posts for authenticated users who have the edit_posts capability. It is a reference implementation based on the WordPress 7.1 and MCP Adapter 0.5.0 documentation, not a report of completed end-to-end testing. This site's build verifies article rendering only; it does not verify PHP registration, REST behavior, or MCP transport behavior. Pin those versions when running the verification matrix below, then review their release notes before upgrading either dependency.
Understand the security boundaries
Several controls are necessary, and they solve different problems:
- Authentication establishes which WordPress user is making the request.
- The permission callback decides whether that user may execute this ability.
- The input schema constrains the request's structure and values.
- The execute callback still uses safe WordPress APIs and limits work.
- The output schema constrains what leaves the site.
- MCP transport authentication controls who can reach the MCP server.
- Logs provide accountability without recording secrets or private content.
Marking an ability read-only is metadata, not a security sandbox. The callback must genuinely avoid writes and external side effects.
Register a dedicated category
Abilities must belong to a category registered on the category initialization hook:
<?php
declare(strict_types=1);
/**
* Registers the category used by editorial read abilities.
*/
function ml_register_editorial_ability_category(): void
{
wp_register_ability_category(
'ml-editorial',
[
'label' => __('Editorial', 'ml-abilities'),
'description' => __(
'Read-only abilities for reviewing published content.',
'ml-abilities'
),
]
);
}
add_action(
'wp_abilities_api_categories_init',
'ml_register_editorial_ability_category'
);
The Abilities API overview documents the lifecycle and notes that the API is available in WordPress 6.9 and newer. Guard plugin activation or registration when supporting older WordPress versions.
Register one read-only ability
Give inputs tight bounds and disallow undeclared properties. Describe the output precisely enough for validation and client discovery.
<?php
declare(strict_types=1);
/**
* Registers the recent-published-posts ability.
*/
function ml_register_recent_posts_ability(): void
{
wp_register_ability(
'ml-content/get-recent-published-posts',
[
'label' => __('Get Recent Published Posts', 'ml-abilities'),
'description' => __(
'Returns a limited list of recently published posts for editorial review.',
'ml-abilities'
),
'category' => 'ml-editorial',
'input_schema' => [
'type' => 'object',
'properties' => [
'limit' => [
'type' => 'integer',
'minimum' => 1,
'maximum' => 20,
'default' => 5,
],
],
'additionalProperties' => false,
],
'output_schema' => [
'type' => 'object',
'required' => ['posts'],
'properties' => [
'posts' => [
'type' => 'array',
'items' => [
'type' => 'object',
'required' => ['id', 'title', 'url', 'published_at'],
'properties' => [
'id' => ['type' => 'integer'],
'title' => ['type' => 'string'],
'url' => ['type' => 'string', 'format' => 'uri'],
'published_at' => [
'type' => 'string',
'format' => 'date-time',
],
],
'additionalProperties' => false,
],
],
],
'additionalProperties' => false,
],
'execute_callback' => 'ml_execute_recent_posts_ability',
'permission_callback' => 'ml_can_execute_recent_posts_ability',
'meta' => [
'show_in_rest' => true,
'mcp' => [
'public' => true,
],
'annotations' => [
'readonly' => true,
'destructive' => false,
'idempotent' => true,
],
],
]
);
}
add_action('wp_abilities_api_init', 'ml_register_recent_posts_ability');
Abilities are private by default. WordPress core reads meta.show_in_rest; setting it to true exposes the ability through the authenticated Abilities REST endpoints. MCP Adapter 0.5.0 reads meta.mcp.public for channel-specific MCP exposure and also supports its broader meta.public compatibility flag. Those keys belong to different consumers, so enabling one does not prove that the other transport is available. This example opts into both surfaces explicitly.
The official wp_register_ability() reference documents names, schemas, required callbacks, annotations, and exposure flags.
Enforce permissions independently
The permission callback receives the same input as the executor. This ability requires a logged-in user who can edit posts:
<?php
declare(strict_types=1);
/**
* Checks whether the current user may list editorial post details.
*/
function ml_can_execute_recent_posts_ability(mixed $input = null): bool
{
return current_user_can('edit_posts');
}
Do not use is_user_logged_in() when the action needs a capability. Subscriber accounts are authenticated but normally should not receive editorial tooling.
For object-specific abilities, check access to the requested object too, for example current_user_can('edit_post', $post_id). A broad capability is not always enough.
Keep execution bounded and read-only
The schema validates client input before execution, but the callback still applies a defensive default and cap. It returns only fields promised in the schema.
<?php
declare(strict_types=1);
/**
* Returns a bounded list of recently published posts.
*
* @return array{posts:list<array{id:int,title:string,url:string,published_at:string}>}
*/
function ml_execute_recent_posts_ability(mixed $input = null): array
{
$arguments = is_array($input) ? $input : [];
$limit = isset($arguments['limit']) ? absint($arguments['limit']) : 5;
$limit = max(1, min($limit, 20));
$query = new WP_Query(
[
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => $limit,
'orderby' => [
'date' => 'DESC',
'ID' => 'DESC',
],
'no_found_rows' => true,
'ignore_sticky_posts' => true,
'fields' => 'ids',
]
);
$posts = [];
foreach ($query->posts as $post_id) {
$published = get_post_datetime($post_id, 'date', 'gmt');
if (!$published instanceof DateTimeImmutable) {
continue;
}
$posts[] = [
'id' => (int) $post_id,
'title' => get_the_title($post_id),
'url' => get_permalink($post_id),
'published_at' => $published->format(DateTimeInterface::RFC3339),
];
}
return ['posts' => $posts];
}
The callback does not return post content, author email, custom fields, drafts, or arbitrary options. If a client later needs one of those, create a separate reviewed ability with its own permission and output contract.
Test through PHP first
Retrieve the registered ability and execute it as representative users:
<?php
$ability = wp_get_ability('ml-content/get-recent-published-posts');
if (!$ability instanceof WP_Ability) {
throw new RuntimeException('Ability was not registered.');
}
$result = $ability->execute(['limit' => 3]);
Automated tests should verify:
- Registration happens on the required hook.
- The category exists first.
- Anonymous users and subscribers are denied.
- Contributors, authors, and editors are allowed under WordPress's default role capabilities because each has
edit_posts. - Zero, negative, oversized, string, and undeclared inputs are rejected.
- Only published posts are returned.
- The limit is honored.
- Output matches the schema.
- Repeated calls do not write options, posts, metadata, or external requests.
Use the real WordPress ability instance so schema and permission validation are part of the test.
Verify REST exposure separately
The Abilities REST endpoints require an authenticated WordPress user. The ability's permission callback applies in addition to transport authentication.
For a local test user with an application password:
curl --user 'editor:APPLICATION_PASSWORD' \
'https://example.test/wp-json/wp-abilities/v1/ml-content/get-recent-published-posts/run?input=%7B%22limit%22%3A3%7D'
Do not paste a real application password into shell history, documentation, or CI logs. Use a secret manager or an interactive local variable and revoke temporary credentials after testing.
Read-only abilities execute with GET according to the current Abilities REST endpoint documentation. Test unauthenticated, unauthorized, invalid-input, and successful requests, including their status codes and public error bodies.
Expose and test through the MCP Adapter
Install and configure the official adapter according to the pinned 0.5.0 release documentation. The WordPress MCP Adapter repository supports HTTP and WP-CLI STDIO transports and keeps abilities private unless they are explicitly public.
Its default server exposes meta-tools that discover abilities, retrieve ability information, and execute an ability. Connect a development MCP client using a dedicated low-privilege WordPress account. Verify that:
- The ability appears in discovery for an authorized session.
- Its description and schemas are accurate.
- Invalid input is rejected before the callback runs.
- Permission denial reaches the client as an error.
- A valid call returns only the declared fields.
- The same behavior is observed through direct PHP, REST, and MCP.
Transport authentication does not replace the ability permission callback. A passing PHP execution does not prove REST exposure, and a passing REST request does not prove MCP discovery. Record the result of each path independently for the same anonymous, subscriber, contributor, author, and editor cases.
Those checks are the acceptance plan for this reference implementation, not completed test results attached to this article. Treat the example as unverified until the direct PHP, REST discovery and execution, and MCP discovery and execution cases have been run in the pinned environment.
Add privacy-safe observability
Log a generated request ID, ability name, authenticated user ID, success or error category, duration, and result count. Do not log application passwords, transport tokens, full post content, or arbitrary inputs.
Apply rate and concurrency limits at the HTTP or MCP gateway. The callback's limit controls rows per invocation, but an agent can still invoke it repeatedly.
Expand through small abilities
Once the read path is reliable, add narrowly scoped abilities for distinct jobs. Prefer get-post-summary, find-broken-links, or create-editorial-draft over a generic command runner.
Any write ability needs stronger review: nonce or transport context where relevant, object-level capabilities, idempotency, audit logs, dry-run support, and explicit destructive annotations. Human approval may be appropriate before consequential actions.
An MCP adapter makes WordPress functionality discoverable to agents. The security comes from the ability you design: limited input, precise output, least privilege, bounded work, and behavior that stays identical across every transport.