Skip to content
89 changes: 66 additions & 23 deletions src/wp-includes/abilities-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,21 @@
* 'show_in_rest' => false,
* ),
*
* Mark an ability as deprecated while keeping exact-name retrieval and execution
* available for backward compatibility:
*
* 'meta' => array(
* 'deprecated' => array(
* 'since' => '2.0.0',
* 'replacement' => 'my-plugin/new-ability',
* 'message' => __( 'The replacement supports the new data format.', 'my-plugin' ),
* ),
* ),
*
* @since 6.9.0
* @since 7.1.0 Added the `public` meta argument.
* @since 7.2.0 The `category` argument is now optional and defaults to `uncategorized`.
* @since 7.2.0 Added the `deprecated` meta property.
* @since 7.2.0 Added the `required_capabilities` argument.
*
* @see WP_Abilities_Registry::register()
Expand Down Expand Up @@ -288,6 +300,15 @@
* clients such as the REST API, MCP, or AI agents. Seeds
* the default for per-channel flags like `$show_in_rest`.
* Defaults to false.
* @type null|array<string, string> $deprecated {
* Optional. Deprecation details. If set, mark the ability as deprecated. Deprecated abilities are hidden
* from discovery by default, but can be retrieved by exact name or explicitly included in discovery
* with the `include_deprecated` argument. Default null.
*
* @type string $since Version of the ability provider that deprecated the ability.
* @type string $replacement Optional. Namespaced ability to use instead.
* @type string $message Optional. Additional migration guidance.
* }
* @type bool $show_in_rest Optional. Whether to expose this ability in the REST API.
* When true, the ability can be invoked via HTTP requests.
* Default is the value of `$public` when set, false otherwise.
Expand Down Expand Up @@ -424,13 +445,13 @@ function wp_get_ability( string $name ): ?WP_Ability {
/**
* Retrieves registered abilities, optionally filtered by the given arguments.
*
* When called without arguments, returns all registered abilities. When called
* with an $args array, returns only abilities that match every specified condition.
* When called without arguments, returns all registered abilities that are not deprecated. When called with an $args
* array, returns only abilities that match every specified condition.
*
* Filtering pipeline (executed in order):
*
* 1. Declarative filters (`category`, `namespace`, `meta`) — per-item, AND logic between
* arg types.
* 1. Declarative filters ( `category`, `include_deprecated`, `namespace`, `meta`) - per-item, AND logic between arg

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: there is an extra space after the opening parenthesis, and this line uses a hyphen while the other steps keep the em dash.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great eyes!!

* types.
* 2. `item_include_callback` — per-item, caller-scoped. Return true to include, false to exclude.
* 3. `wp_get_abilities_item_include` filter — per-item, ecosystem-scoped. Plugins can enforce
* universal inclusion rules regardless of what the caller passed.
Expand All @@ -441,9 +462,19 @@ function wp_get_ability( string $name ): ?WP_Ability {
*
* Examples:
*
* // All abilities (unchanged behaviour).
* // All (non-deprecated) abilities.
* $abilities = wp_get_abilities();
*
* // All abilities, including deprecated ones.
* $abilities = wp_get_abilities( array(
* 'include_deprecated' => true,
* ) );
*
* // Only deprecated abilities. An empty array matches any deprecation details.
* $abilities = wp_get_abilities( array(
* 'meta' => array( 'deprecated' => array() ),
* ) );
*
* // Filter by category.
* $abilities = wp_get_abilities( array( 'category' => 'content' ) );
*
Expand Down Expand Up @@ -483,21 +514,27 @@ function wp_get_ability( string $name ): ?WP_Ability {
*
* @since 6.9.0
* @since 7.1.0 Added the `$args` parameter for filtering support.
* @since 7.2.0 Deprecated abilities are excluded by default. Added the `include_deprecated` argument.
* @since 7.2.0 Added the `has_required_capabilities` argument.
*
* @see WP_Abilities_Registry::get_all_registered()
*
* @param array $args {
* Optional. Arguments to filter the returned abilities. Default empty array (returns all).
*
* @type string $category Filter by category slug. Only abilities whose category
* exactly matches the given slug are included.
* @type string $namespace Filter by ability namespace prefix. Pass the namespace
* without a trailing slash, e.g. `'woocommerce'` matches
* `'woocommerce/create-order'`.
* @type array $meta Filter by meta key/value pairs. All conditions must
* match (AND logic). Supports nested arrays for structured
* meta, e.g. `array( 'mcp' => array( 'public' => true ) )`.
* Optional. Arguments to filter the returned abilities. Default empty array (returns all abilities
* that are not deprecated).
*
* @type string $category Filter by category slug. Only abilities whose category
* exactly matches the given slug are included.
* @type bool $include_deprecated Whether to include deprecated abilities. Deprecated abilities
* retrieved this way do not trigger deprecation notices until
* they are executed. Default false.
* @type string $namespace Filter by ability namespace prefix. Pass the namespace
* without a trailing slash, e.g. `'woocommerce'` matches
* `'woocommerce/create-order'`.
* @type array $meta Filter by meta key/value pairs. All conditions must
* match (AND logic). Supports nested arrays for structured
* meta, e.g. `array( 'mcp' => array( 'public' => true ) )`.
* A `deprecated` condition implies `include_deprecated`.
* @type bool $has_required_capabilities Whether to leave out abilities whose required capabilities
* the current user doesn't have. Abilities without required
* capabilities are always included. Permission callbacks
Expand All @@ -523,27 +560,33 @@ function wp_get_abilities( array $args = array() ): array {

$abilities = $registry->get_all_registered();

$category = isset( $args['category'] ) && is_string( $args['category'] ) ? $args['category'] : '';
$namespace = isset( $args['namespace'] ) && is_string( $args['namespace'] ) ? rtrim( $args['namespace'], '/' ) . '/' : '';
$meta = isset( $args['meta'] ) && is_array( $args['meta'] ) ? $args['meta'] : array();
$category = isset( $args['category'] ) && is_string( $args['category'] ) ? $args['category'] : '';
$namespace = isset( $args['namespace'] ) && is_string( $args['namespace'] ) ? rtrim( $args['namespace'], '/' ) . '/' : '';
$meta = isset( $args['meta'] ) && is_array( $args['meta'] ) ? $args['meta'] : array();
$include_deprecated = ! empty( $args['include_deprecated'] ) || array_key_exists( 'deprecated', $meta );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we support true as a shorthand for any deprecation details here? From the REST API there is currently no way to get only the deprecated abilities without knowing the exact since value, e.g: meta[deprecated]=true returns an empty list. I guess we can do something like:

if ( true === $meta['deprecated'] ) {
	$meta['deprecated'] = array();
}

$has_required_capabilities = ! empty( $args['has_required_capabilities'] );
$item_include_callback = isset( $args['item_include_callback'] ) && is_callable( $args['item_include_callback'] ) ? $args['item_include_callback'] : null;
$result_callback = isset( $args['result_callback'] ) && is_callable( $args['result_callback'] ) ? $args['result_callback'] : null;
$item_include_callback = isset( $args['item_include_callback'] ) && is_callable( $args['item_include_callback'] ) ? $args['item_include_callback'] : null;
$result_callback = isset( $args['result_callback'] ) && is_callable( $args['result_callback'] ) ? $args['result_callback'] : null;

$matched = array();

foreach ( $abilities as $name => $ability ) {
// Step 1a: Filter by category.
// Step 1a: Exclude deprecated abilities unless requested via `include_deprecated` or a `deprecated` meta condition.
if ( ! $include_deprecated && null !== $ability->get_meta_item( 'deprecated' ) ) {
continue;
}

// Step 1b: Filter by category.
if ( '' !== $category && $ability->get_category() !== $category ) {
continue;
}

// Step 1b: Filter by namespace prefix.
// Step 1c: Filter by namespace prefix.
if ( '' !== $namespace && ! str_starts_with( $ability->get_name(), $namespace ) ) {
continue;
}

// Step 1c: Filter by meta key/value pairs (AND logic, supports nested arrays).
// Step 1d: Filter by meta key/value pairs (AND logic, supports nested arrays).
if ( ! empty( $meta ) && ! _wp_get_abilities_match_meta( $ability->get_meta(), $meta ) ) {
continue;
}
Expand Down
29 changes: 28 additions & 1 deletion src/wp-includes/abilities-api/class-wp-abilities-registry.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ final class WP_Abilities_Registry {
* @since 6.9.0
* @since 7.1.0 Added the `public` meta argument.
* @since 7.2.0 The `category` argument is now optional and defaults to `uncategorized`.
* @since 7.2.0 Added the `deprecated` meta property.
* @since 7.2.0 Added the `required_capabilities` argument.
*
* @see wp_register_ability()
Expand Down Expand Up @@ -82,6 +83,15 @@ final class WP_Abilities_Registry {
* to clients such as the REST API, MCP, or AI agents.
* Seeds the default for per-channel flags like
* `$show_in_rest`. Defaults to false.
* @type null|array<string, string> $deprecated {
* Optional. Deprecation details. If set, mark the ability as deprecated. Deprecated abilities are hidden
* from discovery by default, but can be retrieved by exact name or explicitly included in discovery
* with the `include_deprecated` argument. Default null.
*
* @type string $since Version of the ability provider that deprecated the ability.
* @type string $replacement Optional. Namespaced ability to use instead.
* @type string $message Optional. Additional migration guidance.
* }
* @type bool $show_in_rest Optional. Whether to expose this ability in the REST API.
* Default is the value of `$public` when set, false otherwise.
* }
Expand Down Expand Up @@ -119,6 +129,7 @@ public function register( string $name, array $args ): ?WP_Ability {
* @since 6.9.0
* @since 7.1.0 Added the `public` meta argument.
* @since 7.2.0 The `category` argument is now optional and defaults to `uncategorized`.
* @since 7.2.0 Added the `deprecated` meta property.
* @since 7.2.0 Added the `required_capabilities` argument.
*
* @param array<string, mixed> $args {
Expand All @@ -145,6 +156,15 @@ public function register( string $name, array $args ): ?WP_Ability {
* available to clients such as the REST API, MCP, or AI
* agents. Seeds the default for per-channel flags like
* `$show_in_rest`. Defaults to false.
* @type null|array<string, string> $deprecated {
* Optional. Deprecation details. If set, mark the ability as deprecated. Deprecated abilities are hidden
* from discovery by default, but can be retrieved by exact name or explicitly included in discovery
* with the `include_deprecated` argument. Default null.
*
* @type string $since Version of the ability provider that deprecated the ability.
* @type string $replacement Optional. Namespaced ability to use instead.
* @type string $message Optional. Additional migration guidance.
* }
* @type bool $show_in_rest Optional. Whether to expose this ability in the REST API.
* Default is the value of `$public` when set, false otherwise.
* }
Expand Down Expand Up @@ -277,6 +297,7 @@ public function is_registered( string $name ): bool {
* Do not use this method directly. Instead, use the `wp_get_ability()` function.
*
* @since 6.9.0
* @since 7.2.0 Added deprecation notices for abilities with the `deprecated` meta property.
*
* @see wp_get_ability()
*
Expand All @@ -293,7 +314,13 @@ public function get_registered( string $name ): ?WP_Ability {
);
return null;
}
return $this->registered_abilities[ $name ];

$ability = $this->registered_abilities[ $name ];

// Check if the ability is deprecated and handle it accordingly.
$ability->_handle_ability_deprecation();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The run controller calls wp_get_ability three times per request (permission check, input sanitization and execute), so the deprecated_ability_run action and the REST header handler run three times for a single POST to the run endpoint. Should we only trigger the notice on execute, or also apply the "once per lifecycle" logic to the action and not just to the error?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(My gut reaction without having the source code open in front of me is to fix the run controller in a separate PR to reuse the same instance. Will look at this with fresh eyes in the morning)


return $ability;
}

/**
Expand Down
Loading
Loading