Two Abilities API changes landed in WordPress 7.1 within a day of each other, and they are worth reading together rather than separately. One gives you a single place to say an ability is meant for outside consumption. The other gives every caller one shared way to ask which abilities exist.
Between them they answer a question that has been getting more awkward as the client list grows: when a REST consumer, an MCP adapter or an AI agent asks your site what it can do, who decides what appears in that list?
There is also a trap in here that will bite anyone filtering on custom metadata over REST, and one distinction worth being pedantic about, because getting it wrong produces code that looks secure and is not.
Neither change is large on its own. The exposure flag is one metadata key and a single line of resolution logic. The filtering work is an optional arguments array on a function that already existed. Read as a pair, though, they are core taking a position on something that had been left to each integration to invent: a site should be able to say what it offers to the outside world once, in one vocabulary, rather than once per client that happens to ask.
What both changes are fixing
Before 7.1 there were two ways into the registry:
// Everything.
$abilities = wp_get_abilities();
// One, by name.
$ability = wp_get_ability( 'my-plugin/export-users' );Wanting a subset meant fetching the whole registry and filtering it yourself:
$abilities = array_filter(
wp_get_abilities(),
function ( WP_Ability $ability ): bool {
return 'data-export' === $ability->get_category();
}
);Every consumer wrote a version of that. The REST abilities controller had its own category filtering bolted on after retrieval. The result was duplicated logic, semantics that quietly differed between PHP and REST, no extension point for anyone wanting to influence selection, and an extra pass over the registry each time.
The exposure side had the same shape of problem. An ability meant for external clients had to declare that intent once per channel:
'meta' => array(
'show_in_rest' => true,
'mcp' => array(
'public' => true,
),
// And one more for every future client.
),That does not scale with the number of integrations, and it leaves a new channel with no way to work out whether an existing ability was ever meant for outside use.
One flag for intent: meta.public
The new public metadata flag records the author’s general exposure intent in one stable place:
function my_plugin_register_abilities(): void {
wp_register_ability(
'my-plugin/export-users',
array(
'label' => __( 'Export users', 'my-plugin' ),
'description' => __( 'Exports user data as CSV.', 'my-plugin' ),
'category' => 'data-export',
'execute_callback' => 'my_plugin_export_users',
'permission_callback' => function (): bool {
return current_user_can( 'export' );
},
'meta' => array(
'public' => true,
),
)
);
}
add_action( 'wp_abilities_api_init', 'my_plugin_register_abilities' );For REST, setting public to true makes show_in_rest default to true. The whole resolution is one line, and it is worth memorising:
$show_in_rest = $meta['show_in_rest'] ?? $meta['public'] ?? false;Channel-specific beats general, and the default when neither is present is false. Null coalescing rather than a truthiness check means an explicit false is preserved rather than falling through, while null counts as unset and moves to the next value in the chain. That distinction between false and null is the part people get wrong when they write their own version of this.
Registration metadata | Effective public | Effective show_in_rest |
|---|---|---|
No exposure metadata | false | false |
| true | true |
| false | false |
| false | true |
| true | false |
| false | true |
Rows five and six are the useful ones. An ability can be generally available to clients while staying out of REST specifically, or stay private in general while opting into one channel deliberately.
// Public in general, but not over REST.
'meta' => array(
'public' => true,
'show_in_rest' => false,
),REST is the first built-in consumer of the flag. Per the dev note, the WordPress MCP Adapter will respect it from its next release, which is the real point of the change: a new channel can read one value instead of asking core to add another property.
One pipeline for asking: wp_get_abilities( $args )
The function now takes an optional arguments array with three declarative filters plus two callbacks. Called with no arguments it behaves as before and returns the full registry.
category
$abilities = wp_get_abilities(
array(
'category' => 'data-export',
)
);Exact comparison, single string only. Arrays of category slugs are not supported, so wanting two categories means two calls or a callback.
namespace
$abilities = wp_get_abilities(
array(
'namespace' => 'my-plugin',
)
);Trailing slashes are normalised, so my-plugin and my-plugin/ mean the same thing. The detail worth noticing is that matching includes the namespace delimiter, so my-plugin does not accidentally match abilities registered under my-plugin-extra. Anyone who has written a prefix match with strpos() and later discovered a neighbouring plugin sharing the first eleven characters will appreciate why that is specified.
meta
$abilities = wp_get_abilities(
array(
'meta' => array(
'public' => true,
'show_in_rest' => true,
),
)
);Every supplied condition has to match, nested structures are supported, and an ability is free to carry additional metadata you did not ask about. Comparisons are strict: true does not match 1, and false does not match 0. Hold that thought, because it becomes the REST trap later.
// Nested metadata works.
'meta' => array(
'my_client' => array(
'public' => true,
),
),Combining them
All three combine with AND. An ability has to satisfy every argument supplied.
$abilities = wp_get_abilities(
array(
'category' => 'data-export',
'namespace' => 'my-plugin',
'meta' => array(
'public' => true,
),
)
);When declarative is not enough
Two callbacks handle what the arguments cannot express, and both are scoped to the single call rather than to the site.
item_include_callback
$abilities = wp_get_abilities(
array(
'namespace' => 'my-plugin',
'item_include_callback' => function ( WP_Ability $ability ): bool {
return my_plugin_should_include_ability( $ability );
},
)
);It runs once per ability that survived the declarative filters, receives the WP_Ability instance and returns a boolean. Use it for context-dependent visibility, relationships between metadata keys, or any condition involving more than one property.
result_callback
$abilities = wp_get_abilities(
array(
'namespace' => 'my-plugin',
'result_callback' => function ( array $abilities ): array {
uasort(
$abilities,
function ( WP_Ability $first, WP_Ability $second ): int {
return strcasecmp( $first->get_label(), $second->get_label() );
}
);
return array_slice( $abilities, 0, 10, true );
},
)
);This one runs after all per-item matching, which makes it the place for sorting, slicing and pagination. Note uasort() and the true on array_slice(). Abilities come back keyed by name, and downstream code often depends on those keys, so preserving them is not optional decoration.
Two global filters, for when it is site policy
The callbacks above belong to one call site. When the rule has to apply everywhere, 7.1 adds two filters.
add_filter(
'wp_get_abilities_item_include',
function ( bool $include, WP_Ability $ability, array $args ): bool {
if ( 'my-plugin/private-operation' === $ability->get_name() ) {
return false;
}
return $include;
},
10,
3
);It receives the current inclusion decision, the ability, and the full arguments array that was passed to wp_get_abilities(). Having $args matters, because it lets a filter behave differently depending on who is asking and for what.
One limit is worth stating clearly: declarative mismatches are removed before this filter runs, so it cannot add back an ability that failed category, namespace or meta matching. It can exclude things globally, and it can influence inclusion among the candidates that reached it. It is not an escape hatch for re-adding whatever you like.
add_filter(
'wp_get_abilities_result',
function ( array $abilities, array $args ): array {
// Site-wide ordering or final processing.
return $abilities;
},
10,
2
);Both are global. Every caller on the site pays for whatever you do in them, including callers in other plugins that had no idea you were there. If the logic belongs to one operation, use the callbacks instead.
The order everything runs in
- Match
category. - Match
namespace. - Match
meta. - Run
item_include_callback. - Apply
wp_get_abilities_item_include. - Add surviving abilities to the result.
- Run
result_callbackon the complete result. - Apply
wp_get_abilities_result.
Steps one to five happen in a single pass over the registry rather than as separate array_filter() sweeps, which is the performance argument for moving this into core instead of leaving it to each consumer.
Discovery over REST
The collection endpoint now delegates to wp_get_abilities() and exposes the declarative filters as query parameters, with the same AND semantics:
GET /wp-json/wp-abilities/v1/abilities?namespace=my-plugin
GET /wp-json/wp-abilities/v1/abilities?category=data-export
GET /wp-json/wp-abilities/v1/abilities?category=data-export&namespace=my-plugin
GET /wp-json/wp-abilities/v1/abilities?meta[annotations][readonly]=trueThree guards apply on top. Every collection request forces meta.show_in_rest = true internally, so no crafted metadata query can surface an ability hidden from REST. The endpoint requires an authenticated user. And listing an ability is not permission to run it, because execution still goes through the ability’s own permission callback.
The trap: your custom metadata will never match
Here is the one that will cost someone an afternoon.
Metadata comparison is strict, and query strings are strings. A request carrying enabled=true arrives as the string 'true', which will never equal boolean true. The filter is working exactly as documented and your ability silently fails to appear.
Custom metadata needs a REST parameter schema so the value is coerced before it reaches the matching logic:
add_filter(
'rest_abilities_collection_params',
static function ( array $params ): array {
$params['meta']['properties']['my_plugin'] = array(
'type' => 'object',
'properties' => array(
'enabled' => array(
'type' => 'boolean',
),
),
);
return $params;
}
);With that declared, REST casts "true" to boolean true before comparison and the query behaves as expected:
GET /wp-json/wp-abilities/v1/abilities?meta[my_plugin][enabled]=trueThe built-in annotations are already handled. Core declares the schema for readonly, destructive and idempotent, each accepting a boolean or null, so those coerce correctly without any plugin code. Only your own metadata keys need the filter.
If you are filtering on custom metadata over REST and getting an empty collection back, check this before checking anything else.
A worked example: three abilities, one agent
Abstract API notes are easier to follow with a concrete case, so take a plugin that registers three abilities and think about what each one should be allowed to advertise.
The first summarises a post. It is read-only, it touches nothing sensitive, and an assistant offering it to a writer is the entire point of the feature. That one is public without much debate.
The second exports every user as CSV. It is genuinely useful to an administrator holding an export capability, and genuinely alarming as a menu item an agent can discover and suggest. The capability check makes it safe to call. Whether it belongs in a list handed to a language model is a separate judgement, and it is exactly the judgement the new flag exists to record.
The third rebuilds an internal cache. It exists so your own scheduled task can call it by name. Nothing external should ever see it, and nothing external needs to.
Written out, that becomes a fairly readable set of intents:
// 1. Summarise a post: safe to advertise anywhere.
'meta' => array(
'public' => true,
'annotations' => array( 'readonly' => true ),
),
// 2. Export users: advertise nowhere, still callable by name,
// still gated by the capability check.
'meta' => array(
'public' => false,
),
// 3. Rebuild cache: internal plumbing, never listed.
'meta' => array(
'public' => false,
),Now the discovery side. An agent integration asking what it can safely offer wants the public, read-only set, and can ask for exactly that in one call:
$safe = wp_get_abilities(
array(
'meta' => array(
'public' => true,
'annotations' => array( 'readonly' => true ),
),
)
);Before 7.1 that was a full registry fetch plus a hand-written nested metadata comparison, written slightly differently in every integration that needed it. The behaviour is the same. What changed is that there is now one implementation of it, with documented semantics, that a plugin and the REST controller both share.
Notice what the second ability demonstrates. It is not listed, and it is not protected by not being listed. The capability check is what stops the wrong caller, and it would still stop them if a future integration decided to list everything.
Filtering is discovery. It is not authorisation.
This is the part to be pedantic about, and the dev note is explicit about it for good reason.
An ability missing from a list is not an ability that cannot be run.
Everything described above decides what appears in a result set. None of it decides who may execute anything. Those are different questions with different answers, and the temptation to conflate them is strong precisely because hiding something feels protective.
Setting public => false, or excluding an ability in wp_get_abilities_item_include, keeps it out of discovery. A caller that already knows the name can still reach it, and should still be stopped by the only thing that actually stops anyone:
'permission_callback' => function (): bool {
return current_user_can( 'export' );
},Write every permission callback as though the ability were listed publicly, then use the exposure flag and the filters to control what gets advertised. Discovery is a usability and noise problem. Authorisation is the security boundary. If your reason for hiding an ability is that it would be dangerous when called, the exposure flag is the wrong tool and the permission callback is the right one.
The reason this deserves the emphasis is that ability names are not secret and were never designed to be. They appear in plugin source that ships to every install, in documentation, in support threads, in the code of anyone who integrated with you. Treating a name as unguessable is the same mistake as treating an unlinked URL as private.
There is also a version of this that catches careful people. An ability might be genuinely safe today because the only client is your own admin interface, where the surrounding UI never offers it to the wrong user. Then a new integration arrives, reads the registry directly, and every assumption the interface was quietly enforcing is gone. The permission callback is the only part of that picture which travels with the ability rather than with whatever happens to be calling it.
This is the same boundary we walked through when 7.1 added the execution lifecycle filters, where a filter can override the permission result on the way through. Discovery filters shape the menu. The execution pipeline decides whether the order gets cooked.
Compatibility and cost
Both changes are additive, which is the reason this article contains no upgrade warnings. Calling wp_get_abilities() with no arguments returns the complete registry exactly as it did before, so existing code keeps working untouched. An ability that never declares public or show_in_rest resolves both to false, which was already the effective behaviour for anything that had not opted into REST.
The cost side is worth a note for anyone with a large registry. The old pattern was retrieve everything, then run array_filter() over it, sometimes more than once as different conditions were applied in sequence. The new pipeline does the declarative matching, the item callback and the item filter in a single pass, then hands the matched set to the result stage.
That matters more than it sounds on a site where several plugins each register a handful of abilities and several integrations each ask for a subset on the same request. The saving is not in any one call. It is in not repeating the same sweep in four places that do not know about each other.
The global filters cut the other way, and it is worth being honest about it. A filter registered on wp_get_abilities_item_include runs for every candidate ability of every caller on every request that touches the registry. Doing something expensive in there, an option lookup or a remote call, turns a cheap pass into a slow one for code that never asked for your behaviour. Keep them to comparisons on data you already have.
What to change in your plugin
- Replace channel flags with intent. If your abilities set
show_in_restonly because they should be available to clients generally, move that topublicand let each channel default from it. Keepshow_in_restwhere you specifically mean REST and nothing else. - Delete your own filtering helpers. Any wrapper you wrote around
wp_get_abilities()to filter by category or namespace is now duplicated logic with slightly different semantics from core. Delete it and pass arguments instead. - Audit for the false and null distinction. Anywhere you resolve exposure yourself, make sure an explicit
falseis preserved rather than treated as absent. A truthiness check gets this wrong. - Declare schemas for custom metadata you expect anyone to filter on over REST, or accept that those queries will silently return nothing.
- Reread your permission callbacks on the assumption that every ability is discoverable, because the next client integration may well make it so.
That last one is the item worth doing this week regardless of whether you adopt anything else here.
The direction this is heading
Read together, these two changes are core deciding that ability exposure is a first-class concept rather than a per-integration detail. One flag records intent. One pipeline answers queries. New clients inherit both without core growing a property per client.
It fits the pattern of the rest of the 7.1 Abilities work, including turning internal schemas into portable output a client can actually consume. Each piece makes the site more legible to something that is not a browser.
Which raises the question worth sitting with before you set public => true on anything. You are not only deciding what appears in a REST collection today. You are recording an intent that channels which do not exist yet will read and act on. The MCP adapter is the first to inherit it and it will not be the last.
So set it deliberately, and make the permission callback carry the weight.




No comments yet