Abilities API and MCP Adapter
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
An ability is one self-described unit of work a WordPress site offers: a name, a human description, a JSON Schema
for its input and output, a permission check and a PHP callback. One core registry lets the same definition run from
PHP, over the REST API, in admin JavaScript and, through the MCP Adapter plugin, as Model Context Protocol tools
for AI agents. This page, for plugin developers and for site owners connecting agents, adds two read-only abilities,
lumen/search-books and lumen/get-book, to lumen-catalog, the catalogue plugin of the fictitious publisher
Lumen Press, and exposes them through the adapter. MCP itself is covered in the
Model Context Protocol (MCP) section.
The examples were tested on WordPress 7.1.3 (released 2026-10-06); WordPress 7.1.4, a security-only release of
2026-10-28 with no API changes, is the current release and the one to run in production. WordPress 7.2 is planned for
2026-12-08 and changes nothing here. The page also uses PHP 7.4 minimum (8.3+ recommended), WP-CLI 2.12.0, the MCP
Adapter 0.7.1 (released 2026-10-16 on WordPress.org, GitHub and Packagist) and the MCP Inspector 2.12.0, all checked on
2026-10-28. The PHP API arrived in 6.9, the JavaScript packages in 7.0, the public flag, wp_get_abilities()
filtering and the lifecycle hooks in 7.1. Every output was recorded with WordPress in
Docker (wordpress:7.1.3-php8.4-apache, MariaDB 11.8, WP_ENVIRONMENT_TYPE local, so application passwords work
over plain HTTP), WP-CLI from wordpress:cli-2.12.0-php8.4, and is shown with the canonical https://lumenpress.test
address. The standard stack is described in
Local Development Environments.
What an ability is
Before 6.9 each integration described a site’s features its own way (REST routes, admin-ajax.php actions, WP-CLI
commands, each AI plugin’s list of "tools"); an ability is written once and read by all. Compared with a REST route
(see REST API: Extending It) it has no URL design, verbs or envelope, but
must carry a label, a description written for a reader that has never seen the code (an agent decides from it whether
to call), a category, schemas and behaviour hints called annotations.
The registry is built lazily: the first Abilities call after init fires wp_abilities_api_categories_init, then
wp_abilities_api_init, so a request that never asks for an ability never builds it. Before init it returns nothing.
Core registers the categories site and user and three public abilities: core/get-site-info,
core/get-environment-info (both need manage_options) and core/get-user-info (the caller’s own profile).
Registering categories and abilities
| Function (since) | Behaviour |
|---|---|
|
Only on |
|
Only on |
|
Remove, test or fetch one ability by name; the same trio exists for categories, plus |
|
All abilities, or a filtered subset (see Running abilities in PHP). |
The arguments of wp_register_ability():
| Key | Meaning |
|---|---|
|
Required non-empty strings. The description is the documentation an agent reads: say what comes back and when to use another ability. |
|
Required; must already be registered. |
|
JSON Schema of the input (WordPress’s REST subset: |
|
JSON Schema the result is validated against after the callback; a mismatch returns |
|
Receives the validated input (only when there is an input schema) and returns data or a |
|
Required. Receives the input too, so it can check object-level capabilities. Returns |
|
|
|
A subclass of |
A bad name, an unknown category, a missing label or callback, a duplicate or a call outside the hook makes
wp_register_ability() return null with a _doing_it_wrong() notice visible only with WP_DEBUG; in production
the ability silently does not exist, so assert the return value in tests (see Testing).
The lumen-catalog abilities
Plugin::boot() calls Abilities::boot(). Requires at least: 6.9 makes a function_exists() guard unnecessary;
meta sets both public (7.1) and show_in_rest (read by 6.9 and 7.0).
<?php
namespace Lumen\Catalog;
use WP_Error;
use WP_Post;
use WP_Query;
defined( 'ABSPATH' ) || exit;
final class Abilities {
const CATEGORY = 'lumen-catalog';
public static function boot(): void {
add_action( 'wp_abilities_api_categories_init', array( self::class, 'register_category' ) );
add_action( 'wp_abilities_api_init', array( self::class, 'register_abilities' ) );
}
public static function register_category(): void {
wp_register_ability_category( self::CATEGORY, array(
'label' => __( 'Lumen catalogue', 'lumen-catalog' ),
'description' => __( 'Read the Lumen Press book catalogue.', 'lumen-catalog' ),
) );
}
public static function register_abilities(): void {
$meta = array(
'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true ),
'public' => true, // 7.1: seeds show_in_rest and opts in to MCP.
'show_in_rest' => true, // 6.9 and 7.0 ignore 'public' for REST.
);
$summary = array(
'id' => array( 'type' => 'integer' ),
'title' => array( 'type' => 'string' ),
'isbn' => array( 'type' => 'string' ),
'link' => array( 'type' => 'string', 'format' => 'uri' ),
);
wp_register_ability( 'lumen/get-book', array(
'label' => __( 'Get book', 'lumen-catalog' ),
'description' => __( 'Returns one book of the Lumen Press catalogue by its post ID: title, ISBN, page count, publication date, genres, current stock and URL.', 'lumen-catalog' ),
'category' => self::CATEGORY,
'input_schema' => array(
'type' => 'object',
'properties' => array(
'id' => array( 'type' => 'integer', 'minimum' => 1, 'description' => __( 'Post ID of the book, as returned by lumen/search-books.', 'lumen-catalog' ) ),
),
'required' => array( 'id' ),
'additionalProperties' => false,
),
'output_schema' => array(
'type' => 'object',
'properties' => $summary + array(
'pages' => array( 'type' => 'integer', 'minimum' => 1 ),
'published_on' => array( 'type' => 'string', 'pattern' => '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' ),
'genres' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ),
'stock' => array( 'type' => 'integer', 'minimum' => 0 ),
),
'required' => array( 'id', 'title', 'isbn', 'link' ),
),
'execute_callback' => array( self::class, 'get_book' ),
'permission_callback' => array( self::class, 'can_read_catalogue' ),
'meta' => $meta,
) );
wp_register_ability( 'lumen/search-books', array(
'label' => __( 'Search books', 'lumen-catalog' ),
'description' => __( 'Searches published books by words in the title or text, optionally within one genre slug. Returns up to per_page matches with ID, title, ISBN and URL; call lumen/get-book for details.', 'lumen-catalog' ),
'category' => self::CATEGORY,
'input_schema' => array(
'type' => 'object',
'properties' => array(
'search' => array( 'type' => 'string', 'minLength' => 2, 'maxLength' => 100 ),
'genre' => array( 'type' => 'string', 'pattern' => '^[a-z0-9-]+$' ),
'per_page' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 20, 'default' => 10 ),
),
'additionalProperties' => false,
'default' => array(), // Lets callers omit the input entirely.
),
'output_schema' => array(
'type' => 'array',
'items' => array( 'type' => 'object', 'properties' => $summary, 'required' => array( 'id', 'title', 'isbn', 'link' ) ),
),
'execute_callback' => array( self::class, 'search_books' ),
'permission_callback' => array( self::class, 'can_read_catalogue' ),
'meta' => $meta,
) );
}
public static function can_read_catalogue(): bool {
return current_user_can( 'read' );
}
/** @return array<string, mixed>|WP_Error */
public static function get_book( array $input ) {
$post = get_post( $input['id'] );
if ( ! $post instanceof WP_Post || 'lp_book' !== $post->post_type || ! current_user_can( 'read_post', $post->ID ) ) {
return new WP_Error( 'lumen_book_not_found', __( 'No book with this ID.', 'lumen-catalog' ), array( 'status' => 404 ) );
}
$genres = get_the_terms( $post, 'lp_genre' );
$book = array(
'id' => $post->ID,
'title' => get_the_title( $post ),
'isbn' => (string) get_post_meta( $post->ID, 'lp_isbn', true ),
'genres' => is_array( $genres ) ? wp_list_pluck( $genres, 'slug' ) : array(),
'stock' => (int) get_post_meta( $post->ID, 'lp_stock', true ),
'link' => get_permalink( $post ),
);
// Optional fields are left out rather than null: some MCP clients reject type unions.
$pages = (int) get_post_meta( $post->ID, 'lp_pages', true );
$date = (string) get_post_meta( $post->ID, 'lp_published_on', true );
if ( $pages > 0 ) {
$book['pages'] = $pages;
}
if ( '' !== $date ) {
$book['published_on'] = $date;
}
return $book;
}
/** @return list<array<string, mixed>> */
public static function search_books( array $input ): array {
$args = array(
'post_type' => 'lp_book',
'post_status' => 'publish',
'posts_per_page' => $input['per_page'] ?? 10,
'no_found_rows' => true,
);
if ( isset( $input['search'] ) ) {
$args['s'] = $input['search'];
}
if ( isset( $input['genre'] ) ) {
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- one indexed term.
$args['tax_query'] = array( array( 'taxonomy' => 'lp_genre', 'field' => 'slug', 'terms' => $input['genre'] ) );
}
$books = array();
foreach ( ( new WP_Query( $args ) )->posts as $post ) {
$books[] = array(
'id' => $post->ID,
'title' => get_the_title( $post ),
'isbn' => (string) get_post_meta( $post->ID, 'lp_isbn', true ),
'link' => get_permalink( $post ),
);
}
return $books;
}
}
-
Two narrow abilities, not one broad one: agents choose tools by description and call precise schemas more reliably.
-
stockis the current stock, thelp_stockpost meta written every six hours bylp_catalog_sync_stock; the{$wpdb->prefix}lp_stockmovement history is not exposed. -
Coarse permission callback, precise callback.
readkeeps anonymous callers out; inside, theread_postmeta capability (see Roles and Capabilities API) hides drafts from Authors, which get "not found", like a missing ID, so draft IDs do not leak. -
Validation formats are limited. WordPress validates
date-time,email,ip,uuidandhex-color;uriis only sanitized anddateis ignored, hence thepatternonpublished_on. -
No
["integer","null"]unions: valid JSON Schema, but flagged as unportable by the Inspector’s--strictcheck.
meta: annotations, show_in_rest and public
Annotations are hints, not enforcement: readonly (nothing changes), destructive (data may be removed or
overwritten; false means additive only), idempotent (a repeat has no further effect). REST derives the HTTP method
from them, the MCP Adapter maps them to readOnlyHint, destructiveHint and idempotentHint, and clients use them to
decide whether to ask the user first, so a deleting ability must never claim readonly.
Exposure is resolved at registration as show_in_rest ?? public ?? false: public => true alone exposes the
ability to REST on 7.1 (not on 6.9 or 7.0), an explicit show_in_rest wins in either direction, and the MCP Adapter
reads meta.mcp.public as its own override. Exposure is not authorization: the permission_callback still runs.
Running abilities in PHP
$ability->execute( $input ) runs the whole lifecycle, for core, WP-CLI, REST and the adapter alike:
-
Action
wp_ability_invoked(7.1), with the raw input, for every call whatever its outcome. -
Filter
wp_pre_execute_ability(7.1): returning anything but the sentinel it receives ends the call with that value. -
Input normalization: a
nullinput takes the schema’s top-leveldefault; filterwp_ability_normalize_input(7.1). -
Input validation against
input_schema(ability_invalid_input); filterwp_ability_validate_input(7.1). -
permission_callback, then filterwp_ability_permission_result(7.1). Any refusal returnsability_invalid_permissions; aWP_Errorfrom the callback is not passed on, so its message cannot leak. -
Action
wp_before_execute_ability, theexecute_callback, filterwp_ability_execute_result(7.1), output validation with filterwp_ability_validate_output(7.1), actionwp_after_execute_ability.
REST-style validate_callback and sanitize_callback keywords are ignored; use the validation filters. Since 7.1
wp_get_abilities() accepts category, namespace (prefix), meta (nested pairs, all must match),
item_include_callback and result_callback, then applies wp_get_abilities_item_include and
wp_get_abilities_result. Filtering is discovery, not access control.
wp eval '
foreach ( wp_get_abilities( array( "namespace" => "lumen", "meta" => array( "annotations" => array( "readonly" => true ) ) ) ) as $name => $a ) {
echo $name, "\n";
}
wp_set_current_user( 3 );
echo wp_json_encode( wp_get_ability( "lumen/get-book" )->execute( array( "id" => 4 ) ) ), "\n";
$e = wp_get_ability( "lumen/get-book" )->execute( array( "id" => "4a" ) );
echo $e->get_error_code(), ": ", $e->get_error_message(), "\n";
wp_set_current_user( 0 );
echo wp_get_ability( "lumen/get-book" )->execute( array( "id" => 4 ) )->get_error_code(), "\n";
'
lumen/get-book
lumen/search-books
{"id":4,"title":"The Lighthouse Ledger","isbn":"978-1-23456-789-7","genres":["mystery"],"stock":14,"link":"https:\/\/lumenpress.test\/books\/the-lighthouse-ledger\/","pages":312,"published_on":"2025-03-14"}
ability_invalid_input: Ability "lumen/get-book" has invalid input. Reason: input[id] is not of type integer.
ability_invalid_permissions
Lifecycle filters: rate limiting
The 7.1 hooks let one plugin add policies to every ability without touching its code: logging, caching, quotas,
maintenance mode, extra validation or a second authorization factor; wp_ability_invoked suits audit logs because it
sees every call. lumen-catalog rate-limits its own abilities; on 6.9 and 7.0 the filter never fires.
<?php
namespace Lumen\Catalog;
use WP_Ability;
use WP_Error;
defined( 'ABSPATH' ) || exit;
final class AbilityGuards {
const LIMIT_PER_MINUTE = 60;
public static function boot(): void {
add_filter( 'wp_pre_execute_ability', array( self::class, 'rate_limit' ), 10, 4 );
}
/** Returning anything but $pre ends the call with that value. */
public static function rate_limit( $pre, string $name, $input, WP_Ability $ability ) {
if ( 0 !== strpos( $name, 'lumen/' ) ) {
return $pre;
}
$key = 'lp_ability_calls_' . get_current_user_id();
$calls = (int) get_transient( $key );
if ( $calls >= self::LIMIT_PER_MINUTE ) {
return new WP_Error( 'lumen_rate_limited', __( 'Too many catalogue requests; try again in a minute.', 'lumen-catalog' ), array( 'status' => 429 ) );
}
set_transient( $key, $calls + 1, MINUTE_IN_SECONDS );
return $pre;
}
}
With the counter of tom.author at 60, the next REST call answered HTTP 429 with lumen_rate_limited. The counter is
approximate; a strict quota needs an atomic increment in a persistent object cache (see
Options, Transients and Object Cache). A short-circuit
skips validation and permissions, so return only errors or data that is safe for anyone.
The REST endpoints
Route under /wp-json/wp-abilities/v1 |
Method | Behaviour |
|---|---|---|
|
|
Categories; requires the |
|
|
Abilities with |
|
|
One ability with its schemas, meta and a |
|
|
Runs the ability. |
The run route checks the verb, validates the input (400) and the permission (401 or 403
rest_ability_cannot_execute) before running; callback errors keep their own status. Since 7.1 query-string input
is coerced to the schema’s types (input[per_page]=5 arrives as an integer). The run route requires no login of its
own, so an ability whose permission callback allows everyone runs for anonymous visitors. Application passwords are
covered in REST API: Using It.
B=https://lumenpress.test/wp-json/wp-abilities/v1
AUTH="tom.author:$LP_APP_PASSWORD" # wp user application-password create tom.author "Catalogue agent" --porcelain
curl -s -u "$AUTH" "$B/abilities?namespace=lumen&_fields=name"
curl -s -u "$AUTH" -G "$B/abilities/lumen/search-books/run" \
--data-urlencode "input[search]=lighthouse" --data-urlencode "input[per_page]=5"
curl -s -u "$AUTH" -G "$B/abilities/lumen/get-book/run" --data-urlencode "input[id]=4"
curl -s -u "$AUTH" -X POST -H 'Content-Type: application/json' -d '{"input":{"id":4}}' "$B/abilities/lumen/get-book/run"
curl -s -u "$AUTH" -G "$B/abilities/lumen/search-books/run" --data-urlencode "input[per_page]=50"
curl -s "$B/abilities/lumen/get-book/run?input%5Bid%5D=4"
[{"name":"lumen\/get-book"},{"name":"lumen\/search-books"}]
[{"id":6,"title":"The Quiet Lighthouse","isbn":"978-1-23456-791-0","link":"https:\/\/lumenpress.test\/books\/the-quiet-lighthouse\/"},{"id":4,"title":"The Lighthouse Ledger","isbn":"978-1-23456-789-7","link":"https:\/\/lumenpress.test\/books\/the-lighthouse-ledger\/"}]
{"id":4,"title":"The Lighthouse Ledger","isbn":"978-1-23456-789-7","genres":["mystery"],"stock":14,"link":"https:\/\/lumenpress.test\/books\/the-lighthouse-ledger\/","pages":312,"published_on":"2025-03-14"}
{"code":"rest_ability_invalid_method","message":"Read-only abilities require GET method.","data":{"status":405}}
{"code":"ability_invalid_input","message":"Ability \"lumen\/search-books\" has invalid input. Reason: input[per_page] must be between 1 (inclusive) and 20 (inclusive)","data":{"status":400}}
{"code":"rest_ability_cannot_execute","message":"Sorry, you are not allowed to execute this ability.","data":{"status":401}}
Client-side abilities (7.0)
WordPress 7.0 added two script modules, neither enqueued by default. @wordpress/abilities is a store with
registerAbility(), registerAbilityCategory(), getAbilities(), getAbility(), executeAbility() and unregister
functions; @wordpress/core-abilities loads the server’s categories and abilities from /wp-abilities/v1 with
callbacks that call the run endpoint. A browser ability has a callback (and optional permissionCallback), is
validated in the browser, and may have two to four name segments where PHP allows two. Such abilities serve in-page
agents and browser extensions (WebMCP); a model running elsewhere cannot reach them.
lumen-catalog loads its module on the lp_book admin screens from admin_enqueue_scripts with
wp_enqueue_script_module( 'lumen-catalog/catalogue-abilities', plugins_url( 'assets/catalogue-abilities.js', LUMEN_CATALOG_FILE ), array( '@wordpress/abilities', '@wordpress/core-abilities' ), '0.1.0' ).
assets/catalogue-abilities.js waits for the server abilities, adds one browser-only ability and runs a server one:
import { ready } from '@wordpress/core-abilities';
import { executeAbility, getAbilities, registerAbility } from '@wordpress/abilities';
await ready; // Server categories and abilities are now in the store.
// A client-side ability: it runs in the browser and never reaches PHP.
registerAbility( {
name: 'lumen/open-book-editor',
label: 'Open book in the editor',
description: 'Opens the block editor for one Lumen Press book by its post ID.',
category: 'lumen-catalog',
input_schema: {
type: 'object',
properties: { id: { type: 'integer', minimum: 1 } },
required: [ 'id' ],
additionalProperties: false,
},
callback: async ( { id } ) => {
window.location.assign( `post.php?post=${ id }&action=edit` );
return { opened: id };
},
} );
const names = getAbilities( { category: 'lumen-catalog' } ).map( ( a ) => a.name );
// Server abilities run through /wp-abilities/v1 with the user's cookie and nonce.
const books = await executeAbility( 'lumen/search-books', { search: 'lighthouse', per_page: 5 } );
window.lumenAbilityCheck = { names, books };
On Books in the admin, names listed both server abilities and lumen/open-book-editor, and books held the same two
results as the REST call. The category must exist before registerAbility(), hence the wait for ready.
The MCP Adapter
The MCP Adapter, maintained by the WordPress AI team outside core, turns abilities into MCP tools (or, with
meta.mcp.type resource or prompt, resources and prompts), serves them over STDIO through WP-CLI and over
Streamable HTTP through a REST route, and checks permissions for the transport and for each ability. Version 0.7.0
made it a canonical plugin (slug mcp-adapter, on WordPress.org since October 2026, WordPress 6.9 and PHP 7.4),
deprecated bundling it as a library, and added protocol revision 2026-07-28 next to 2025-11-25.
| Install method | Command | Use when |
|---|---|---|
Plugin directory |
|
Ordinary sites; the dashboard updates it. |
Composer, site level |
|
Composer-managed sites (below). |
Plugin dependency |
|
A plugin that cannot work without MCP; WordPress refuses to activate it until the adapter is active. |
Bundled library |
|
Deprecated since 0.7.0: two copies conflict, a notice is logged, and support will be removed. |
lumen-catalog does not depend on the adapter; its server registration below runs only when the adapter is active.
On the Composer-managed site (see Composer-Based WordPress) the
adapter is one more requirement; the WP Packages ZIP already contains its own vendor/ with Jetpack Autoloader:
{
"repositories": [ { "type": "composer", "url": "https://repo.wp-packages.org", "only": ["wp-plugin/*", "wp-theme/*"] } ],
"require": { "composer/installers": "^2.3", "wp-plugin/mcp-adapter": "0.7.1" },
"extra": { "installer-paths": { "wp-content/plugins/{$name}/": ["type:wordpress-plugin"] } },
"config": { "allow-plugins": { "composer/installers": true } }
}
Source: MCP Adapter: Installation Guide
composer install installed composer/installers 2.3.0 and wp-plugin/mcp-adapter 0.7.1; then:
$ wp plugin activate mcp-adapter && wp mcp-adapter list
ID Name Version Tools Resources Prompts
lumen-catalog Lumen Press catalogue 0.1.0 2 0 0
mcp-adapter-default-server MCP Adapter Default Server v1.0.0 3 0 0
Source: MCP Adapter: CLI Usage Guide
Default server and custom servers
Unless disabled, the adapter creates mcp-adapter-default-server at /wp-json/mcp/mcp-adapter-default-server, with
three meta-tools instead of one tool per ability: mcp-adapter-discover-abilities, mcp-adapter-get-ability-info and
mcp-adapter-execute-ability list every exposed ability, read its schema and run it. An ability is exposed when meta.mcp.public is true, or when it is absent and
meta.public is true; meta.mcp.public => false keeps a public ability out of MCP. A custom server created on
mcp_adapter_init lists chosen abilities as ordinary tools with their full schemas.
| Aspect | Default server | Custom server |
|---|---|---|
Tools in |
3, however many abilities exist |
One per listed ability, named by replacing |
Agent effort |
Discover, inspect, execute |
One direct call |
Which abilities |
Every exposed ability on the site, including other plugins' |
Exactly the list given |
Controls |
|
The transport permission callback |
Lumen Press keeps the default server for general agents and adds a focused one for the catalogue:
<?php
namespace Lumen\Catalog;
use WP\MCP\Core\McpAdapter;
use WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler;
use WP\MCP\Transport\HttpTransport;
defined( 'ABSPATH' ) || exit;
final class McpServer {
public static function boot(): void {
// Fires only when the MCP Adapter plugin is active.
add_action( 'mcp_adapter_init', array( self::class, 'register' ) );
}
public static function register( McpAdapter $adapter ): void {
$adapter->create_server(
'lumen-catalog', // Server ID, for wp mcp-adapter serve --server.
'lumen', // REST namespace ...
'mcp', // ... and route: /wp-json/lumen/mcp.
'Lumen Press catalogue',
'Search and read the Lumen Press book catalogue.',
'0.1.0',
array( HttpTransport::class ),
ErrorLogMcpErrorHandler::class,
null, // Default observability handler.
array( 'lumen/search-books', 'lumen/get-book' ),
array(), // No resources.
array(), // No prompts.
static function (): bool {
return current_user_can( 'read' );
}
);
}
}
Source: MCP Adapter: Default MCP Server
The use statements only name classes, so the file loads without the adapter. The last argument is the transport
permission, checked on every HTTP request; without it the adapter requires a logged-in user (the default server
also read, filter mcp_adapter_default_transport_permission_user_capability). In tools/list each tool has its
schemas, a title from the label and the annotations as hints; an array output schema is wrapped as
{"result": [...]} because MCP output schemas must be objects, and an empty PHP default array becomes [].
STDIO through WP-CLI
wp mcp-adapter serve [--server=<id>] [--user=<id|login|email>] reads JSON-RPC lines on standard input, answers on
standard output and logs to standard error, as the given user (anonymous without --user, so every Lumen call is
refused). In 0.7.1 a session must start with initialize and notifications/initialized; a lone tools/list, as
some older examples pipe in, gets "The 2025 lifecycle requires initialization before this request".
cat > session.jsonl <<'EOF'
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"shell","version":"1.0.0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"lumen-get-book","arguments":{"id":4}}}
EOF
wp mcp-adapter serve --server=lumen-catalog --user=tom.author < session.jsonl 2>/dev/null
Source: MCP Adapter: CLI Usage Guide
The answers name the server and protocol 2025-11-25, list both tools, and return the book as JSON text in content
and as an object in structuredContent. On the default server the same user discovered the three core and two Lumen
abilities, ran lumen/get-book through mcp-adapter-execute-ability ({"success":true,"data":{...}}), and was
refused core/get-site-info (isError: true), since an Author lacks manage_options: discovery is not permission.
STDIO has no transport permission (whoever runs WP-CLI picks any --user), so it belongs on a developer machine or
an SSH session; add_filter( 'mcp_adapter_enable_stdio_transport', '__return_false' ) disables it.
Streamable HTTP and authentication
Remote clients authenticate as a WordPress user, normally with an application password. initialize returns an
Mcp-Session-Id header; every later request must send it and MCP-Protocol-Version: 2025-11-25 (0.7.1 answers 400
otherwise). DELETE ends the session; server-sent events over GET are not implemented (405).
URL=https://lumenpress.test/wp-json/lumen/mcp
AUTH="tom.author:$LP_APP_PASSWORD"
SID=$(curl -s -D - -o /dev/null -u "$AUTH" -H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"8"}}}' \
"$URL" | awk 'tolower($1)=="mcp-session-id:" {print $2}' | tr -d '\r')
H=(-u "$AUTH" -H 'Content-Type: application/json' -H "Mcp-Session-Id: $SID" -H 'MCP-Protocol-Version: 2025-11-25')
curl -s -o /dev/null -w '%{http_code}\n' "${H[@]}" -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' "$URL"
curl -s "${H[@]}" -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"lumen-get-book","arguments":{"id":5}}}' "$URL"
curl -s -o /dev/null -w '%{http_code}\n' -u "$AUTH" -X DELETE -H "Mcp-Session-Id: $SID" "$URL"
The notification answered 202, the call returned The Glass Orchard (stock 6) and DELETE 200; without credentials
initialize gets rest_forbidden (401). Sessions live in user meta: at most 32 per user
(mcp_adapter_session_max_per_user), expiring after an idle day (mcp_adapter_session_inactivity_timeout).
Connecting an MCP client
The MCP Inspector (see Testing and Debugging) is the quickest check. Its CLI
reaches the HTTP server with a Basic Authorization header, and launches the STDIO server as a child process; put the
server command after --, otherwise the Inspector treats --server as its own option:
npm install --save-dev @modelcontextprotocol/inspector@2.12.0
TOKEN=$(printf '%s' "tom.author:$LP_APP_PASSWORD" | base64)
npx mcp-inspector --cli https://lumenpress.test/wp-json/lumen/mcp --transport http \
--header "Authorization: Basic $TOKEN" --method tools/call --tool-name lumen-search-books --tool-arg search=lighthouse
npx mcp-inspector --cli --method tools/call --tool-name lumen-get-book --tool-arg id=6 \
-- wp --path=/srv/lumenpress/public/wp mcp-adapter serve --server=lumen-catalog --user=tom.author
Source: MCP Inspector
Both succeeded (the STDIO run went through docker run -i with the WP-CLI image, which works the same way), and
--method tools/list --strict reported no schema portability warnings. Desktop agents and IDEs take the same two shapes in their MCP
configuration: a command (wp with --path, mcp-adapter, serve, --server and --user) for a local site, or a
Streamable HTTP URL with an Authorization header for a remote one. Clients that only speak STDIO can reach a remote
site through Automattic’s @automattic/mcp-wordpress-remote proxy, which also supports OAuth.
Security
An agent is an automated client that follows instructions it reads, including instructions hidden in content it fetches, so design for the case where it does the wrong thing:
-
Least privilege. Each agent gets its own user with the smallest role that works and its own revocable application password (see Users, Roles and Profiles); Lumen Press connects catalogue agents as an Author-level user, never as
admin. -
Exposure is not authorization. Every ability checks permissions itself; a
__return_truepermission on a REST-exposed ability lets anyone run it without logging in. -
Honest annotations, narrow writes. Mark destructive abilities so clients ask a human first, validate tightly, and prefer drafts or reversible changes for anything an agent may trigger.
-
Returned content is data. Text returned to a model can carry prompt injection; return only the fields a task needs and keep personal data out of public abilities (see Privacy and Personal Data).
-
Transport hygiene. HTTPS outside
local, a transport permission on custom servers, the default server disabled where unused, rate limits, and an audit log fed fromwp_ability_invoked. General MCP threats are covered in MCP Security.
Pitfalls and older material
| Symptom or older advice | Current practice |
|---|---|
Ability missing, no error |
|
Ability visible over REST on 7.1 but not on 6.9 or 7.0 |
Those versions ignore |
Callback receives nothing |
An ability without |
|
Deprecated since 0.7.0; install the |
A lone |
Send |
HTTP calls fail with 400 after a successful |
Send |
A tool called |
MCP names replace |
The three books predate the Abilities API and the MCP Adapter and cover neither abilities nor agents nor MCP; treat any older tutorial that hand-rolls an "AI tools" endpoint as superseded. For new integrations the order is: an ability with schemas and a permission callback first, then REST exposure, then MCP exposure. Generating text with a model from inside WordPress (the AI Client) is covered in AI Client and Connectors.
References
-
WordPress Developer Blog: Introducing the WordPress Abilities API
-
Make WordPress Core: Client-side Abilities API in WordPress 7.0
-
Make WordPress Core: New execution lifecycle filters for the Abilities API in WordPress 7.1
-
Make WordPress Core: Abilities API improvements in WordPress 7.1
-
Make WordPress Core: A unified public exposure flag for abilities in WordPress 7.1
-
Make WordPress Core: Filtering registered abilities with wp_get_abilities() in WordPress 7.1
-
WordPress Developer Blog: From Abilities to AI Agents: Introducing the WordPress MCP Adapter