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.

flowchart LR subgraph WP["WordPress 7.1 at lumenpress.test"] P["lumen-catalog: wp_register_ability() on wp_abilities_api_init"] --> R["Abilities registry (WP_Ability objects)"] R --> X["PHP: wp_get_ability()->execute()"] R --> REST["REST: /wp-json/wp-abilities/v1"] R --> AD["MCP Adapter 0.7.1: default server and the lumen-catalog server"] end REST --> JS["@wordpress/core-abilities in wp-admin"] REST --> HC["Scripts and apps with an application password"] AD -->|"STDIO: wp mcp-adapter serve --user=..."| C["MCP client: Inspector, IDE or agent"] AD -->|"Streamable HTTP: /wp-json/lumen/mcp"| C

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

wp_register_ability_category( $slug, $args ) (6.9)

Only on wp_abilities_api_categories_init. Slug: lowercase letters, digits and single dashes. $args: label and description (required strings), optional meta. Filter wp_register_ability_category_args.

wp_register_ability( $name, $args ) (6.9)

Only on wp_abilities_api_init. Name: namespace/ability, exactly two segments of lowercase letters, digits and dashes. Returns the WP_Ability, or null. Filter wp_register_ability_args runs before validation.

wp_unregister_ability(), wp_has_ability(), wp_get_ability() (6.9)

Remove, test or fetch one ability by name; the same trio exists for categories, plus wp_get_ability_categories().

wp_get_abilities( $args ) (6.9, $args 7.1)

All abilities, or a filtered subset (see Running abilities in PHP).

The arguments of wp_register_ability():

Key Meaning

label, description

Required non-empty strings. The description is the documentation an agent reads: say what comes back and when to use another ability.

category

Required; must already be registered.

input_schema

JSON Schema of the input (WordPress’s REST subset: type, properties, required, additionalProperties, minimum, pattern, enum, oneOf, anyOf and so on). Without it the callback receives no argument and any non-null input is rejected with ability_missing_input_schema. A top-level default is used when the caller sends no input.

output_schema

JSON Schema the result is validated against after the callback; a mismatch returns ability_invalid_output instead of the data.

execute_callback

Receives the validated input (only when there is an input schema) and returns data or a WP_Error. A thrown exception becomes the error ability_callback_exception.

permission_callback

Required. Receives the input too, so it can check object-level capabilities. Returns true, false or a WP_Error.

meta

annotations (readonly, destructive, idempotent, each true, false or null for unknown), show_in_rest (6.9), public (7.1), plus free keys read by consumers such as mcp.

ability_class

A subclass of WP_Ability to instantiate instead, for abilities that override execute() or the callbacks.

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.

  • stock is the current stock, the lp_stock post meta written every six hours by lp_catalog_sync_stock; the {$wpdb->prefix}lp_stock movement history is not exposed.

  • Coarse permission callback, precise callback. read keeps anonymous callers out; inside, the read_post meta 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, uuid and hex-color; uri is only sanitized and date is ignored, hence the pattern on published_on.

  • No ["integer","null"] unions: valid JSON Schema, but flagged as unportable by the Inspector’s --strict check.

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:

  1. Action wp_ability_invoked (7.1), with the raw input, for every call whatever its outcome.

  2. Filter wp_pre_execute_ability (7.1): returning anything but the sentinel it receives ends the call with that value.

  3. Input normalization: a null input takes the schema’s top-level default; filter wp_ability_normalize_input (7.1).

  4. Input validation against input_schema (ability_invalid_input); filter wp_ability_validate_input (7.1).

  5. permission_callback, then filter wp_ability_permission_result (7.1). Any refusal returns ability_invalid_permissions; a WP_Error from the callback is not passed on, so its message cannot leak.

  6. Action wp_before_execute_ability, the execute_callback, filter wp_ability_execute_result (7.1), output validation with filter wp_ability_validate_output (7.1), action wp_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, /categories/<slug>

GET

Categories; requires the read capability.

/abilities

GET

Abilities with show_in_rest only; category, namespace (7.1), meta (7.1), page, per_page (default 50, at most 100); X-WP-Total headers. Requires read.

/abilities/<name>

GET

One ability with its schemas, meta and a wp:action-run link; 404 rest_ability_not_found when hidden.

/abilities/<name>/run

GET, POST or DELETE

Runs the ability. GET when readonly, DELETE when destructive and idempotent, otherwise POST; a wrong verb gives 405 rest_ability_invalid_method. GET and DELETE take input as a query parameter, POST as {"input": ...} in a JSON body.

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

wp plugin install mcp-adapter --version=0.7.1 --activate

Ordinary sites; the dashboard updates it.

Composer, site level

composer require wp-plugin/mcp-adapter:0.7.1 with WP Packages

Composer-managed sites (below).

Plugin dependency

Requires Plugins: mcp-adapter in a plugin header

A plugin that cannot work without MCP; WordPress refuses to activate it until the adapter is active.

Bundled library

composer require wordpress/mcp-adapter inside a plugin

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 } }
}

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

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 tools/list

3, however many abilities exist

One per listed ability, named by replacing / with - (lumen-get-book); filter mcp_adapter_tool_name

Agent effort

Discover, inspect, execute

One direct call

Which abilities

Every exposed ability on the site, including other plugins'

Exactly the list given

Controls

mcp_adapter_create_default_server (return false to disable), mcp_adapter_default_server_config, mcp_adapter_discover_abilities_capability, mcp_adapter_get_ability_info_capability, mcp_adapter_execute_ability_capability (each read by default)

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' );
			}
		);
	}
}

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

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_true permission 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 from wp_ability_invoked. General MCP threats are covered in MCP Security.

Pitfalls and older material

Symptom or older advice Current practice

Ability missing, no error

wp_register_ability() returned null (unknown category, bad name, wrong hook); enable WP_DEBUG or assert the return value in tests.

Ability visible over REST on 7.1 but not on 6.9 or 7.0

Those versions ignore public for REST; also set show_in_rest.

Callback receives nothing

An ability without input_schema gets no argument; add a schema, with a top-level default if input is optional.

composer require wordpress/mcp-adapter inside a plugin

Deprecated since 0.7.0; install the mcp-adapter plugin and declare Requires Plugins: mcp-adapter if needed.

A lone tools/list piped into wp mcp-adapter serve fails

Send initialize and notifications/initialized first.

HTTP calls fail with 400 after a successful initialize

Send Mcp-Session-Id and MCP-Protocol-Version on every request.

A tool called lumen/get-book not found

MCP names replace / with -: lumen-get-book. Through the default server the slash name is passed to mcp-adapter-execute-ability.

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.