From 21f69af185fddaa6c02b3cb825884e092388bbc9 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 1 Sep 2026 16:02:47 +0200 Subject: [PATCH 1/2] Abilities API: Add an eligibility callback for situational relevance. An ability can declare an eligibility_callback at registration. The callback receives an associative array describing the caller's usage context and returns whether the ability applies there. wp_get_abilities() accepts an eligibility_context argument and drops abilities whose callback returns false. The abilities REST collection accepts the same context through an eligibility_context query parameter. Eligibility is consulted only when listing abilities, never on execute, and it is not a security boundary. Co-Authored-By: Claude Fable 5 --- src/wp-includes/abilities-api.php | 57 ++++++- .../abilities-api/class-wp-ability.php | 86 ++++++++++ ...s-wp-rest-abilities-v1-list-controller.php | 23 ++- .../phpunit/tests/abilities-api/wpAbility.php | 148 +++++++++++++++++ .../tests/abilities-api/wpGetAbilities.php | 149 ++++++++++++++++++ .../tests/abilities-api/wpRegisterAbility.php | 20 +++ .../wpRestAbilitiesV1ListController.php | 137 ++++++++++++++++ 7 files changed, 606 insertions(+), 14 deletions(-) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index 393e40b56ed8c..a899839ad9c8f 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -234,6 +234,7 @@ * @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 `eligibility_callback` argument. * * @see WP_Abilities_Registry::register() * @see wp_register_ability_category() @@ -258,6 +259,14 @@ * Receives optional mixed input data (same as `execute_callback`) and * must return `true`/`false` for simple checks, or `WP_Error` for * detailed error responses. + * @type callable $eligibility_callback Optional. A callback function that decides whether the ability + * applies in a given usage context, such as a specific admin screen + * or frontend page. Receives an associative array of context values + * and must return a boolean. Consulted only when listing abilities, + * never on execution, and it is not a security boundary because the + * context values come from the caller unverified. When the context + * lacks a key the callback cares about, the callback should return + * true, so passing more context can only narrow a result. * @type array $input_schema Optional. JSON Schema definition for validating the ability's input. * Must be a valid JSON Schema object defining the structure and * constraints for input data. Used for automatic validation and @@ -423,13 +432,15 @@ function wp_get_ability( string $name ): ?WP_Ability { * * 1. Declarative filters (`category`, `namespace`, `meta`) — per-item, AND logic between * arg 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 + * 2. Eligibility — per-item, ability-scoped. When an `eligibility_context` is passed, each + * ability's `eligibility_callback` decides whether the ability applies in that context. + * 3. `item_include_callback` — per-item, caller-scoped. Return true to include, false to exclude. + * 4. `wp_get_abilities_item_include` filter — per-item, ecosystem-scoped. Plugins can enforce * universal inclusion rules regardless of what the caller passed. - * 4. `result_callback` — on the full matched array, caller-scoped. Sort, slice, or reshape. - * 5. `wp_get_abilities_result` filter — on the full array, ecosystem-scoped. + * 5. `result_callback` — on the full matched array, caller-scoped. Sort, slice, or reshape. + * 6. `wp_get_abilities_result` filter — on the full array, ecosystem-scoped. * - * Steps 1–3 run inside a single loop over the registry — no extra iteration. + * Steps 1–4 run inside a single loop over the registry — no extra iteration. * * Examples: * @@ -452,6 +463,14 @@ function wp_get_ability( string $name ): ?WP_Ability { * 'meta' => array( 'show_in_rest' => true ), * ) ); * + * // Narrow to abilities eligible in a usage context. + * $abilities = wp_get_abilities( array( + * 'eligibility_context' => array( + * 'surface' => 'webmcp-admin', + * 'post_type' => 'product', + * ), + * ) ); + * * // Caller-scoped per-item callback. * $abilities = wp_get_abilities( array( * 'item_include_callback' => function ( WP_Ability $ability ) { @@ -475,6 +494,7 @@ 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 Added the `eligibility_context` argument. * * @see WP_Abilities_Registry::get_all_registered() * @@ -489,6 +509,16 @@ function wp_get_ability( string $name ): ?WP_Ability { * @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 ) )`. + * @type array $eligibility_context Optional. An associative array describing the caller's + * usage context, e.g. `array( 'surface' => 'webmcp-admin' )`. + * Core does not define the keys. Callers and ability authors + * agree on them, and plugin specific keys should be + * namespaced like `plugin-slug/key`. Abilities whose + * `eligibility_callback` returns false for this context are + * excluded. When omitted or empty, eligibility is not + * evaluated and every ability is kept, so a context can + * only narrow the result. The context values come from the + * caller unverified, so this is not a security boundary. * @type callable $item_include_callback Optional. A callback invoked per ability after declarative * filters. Receives a WP_Ability instance, returns bool. * Return true to include, false to exclude. @@ -512,6 +542,7 @@ function wp_get_abilities( array $args = array() ): 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(); + $eligibility_context = isset( $args['eligibility_context'] ) && is_array( $args['eligibility_context'] ) ? $args['eligibility_context'] : array(); $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; @@ -533,7 +564,16 @@ function wp_get_abilities( array $args = array() ): array { continue; } - // Step 2: Caller-scoped per-item callback. + /* + * Step 2: Ability-scoped eligibility for the given usage context. + * Skipped entirely when no context is passed, so a context can only + * narrow the result relative to a call without one. + */ + if ( array() !== $eligibility_context && ! $ability->is_eligible( $eligibility_context ) ) { + continue; + } + + // Step 3: Caller-scoped per-item callback. $include = true; if ( null !== $item_include_callback ) { $include = (bool) call_user_func( $item_include_callback, $ability ); @@ -542,7 +582,8 @@ function wp_get_abilities( array $args = array() ): array { /** * Filters whether an individual ability should be included in the result set. * - * Fires after the declarative filters and the caller-scoped item_include_callback. + * Fires after the declarative filters, the eligibility check, and the + * caller-scoped item_include_callback. * Plugins can use this to enforce universal inclusion rules regardless of * what the caller passed in $args. * @@ -559,7 +600,7 @@ function wp_get_abilities( array $args = array() ): array { } } - // Step 4: Caller-scoped result callback. + // Step 5: Caller-scoped result callback. if ( null !== $result_callback ) { $matched = (array) call_user_func( $result_callback, $matched ); } diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index 8f2d431f05318..0e6326877e8d3 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -123,6 +123,14 @@ class WP_Ability { */ protected $permission_callback; + /** + * The optional ability eligibility callback. + * + * @since 7.2.0 + * @var callable(array): bool + */ + protected $eligibility_callback; + /** * The optional ability metadata. * @@ -140,6 +148,7 @@ class WP_Ability { * * @since 6.9.0 * @since 7.1.0 Added the `public` meta argument. + * @since 7.2.0 Added the `eligibility_callback` argument. * * @see wp_register_ability() * @@ -154,6 +163,9 @@ class WP_Ability { * Receives optional mixed input and returns mixed result or WP_Error. * @type callable $permission_callback A callback function to check permissions before execution. * Receives optional mixed input and returns bool or WP_Error. + * @type callable $eligibility_callback Optional. A callback function that decides whether the ability + * applies in a given usage context. Receives an associative array + * of context values and must return a boolean. * @type array $input_schema Optional. JSON Schema definition for the ability's input. * @type array $output_schema Optional. JSON Schema definition for the ability's output. * @type array $meta { @@ -211,6 +223,7 @@ public function __construct( string $name, array $args ) { * * @since 6.9.0 * @since 7.1.0 Added the `public` meta argument. + * @since 7.2.0 Added the `eligibility_callback` argument. * * @see WP_Abilities_Registry::register() * @@ -224,6 +237,9 @@ public function __construct( string $name, array $args ) { * Receives optional mixed input and returns mixed result or WP_Error. * @type callable $permission_callback A callback function to check permissions before execution. * Receives optional mixed input and returns bool or WP_Error. + * @type callable $eligibility_callback Optional. A callback function that decides whether the ability + * applies in a given usage context. Receives an associative array + * of context values and must return a boolean. * @type array $input_schema Optional. JSON Schema definition for the ability's input. Required if ability accepts an input. * @type array $output_schema Optional. JSON Schema definition for the ability's output. * @type array $meta { @@ -257,6 +273,9 @@ public function __construct( string $name, array $args ) { * Receives optional mixed input and returns mixed result or WP_Error. * @type callable $permission_callback A callback function to check permissions before execution. * Receives optional mixed input and returns bool or WP_Error. + * @type callable $eligibility_callback Optional. A callback function that decides whether the ability + * applies in a given usage context. Receives an associative array + * of context values and must return a boolean. * @type array $input_schema Optional. JSON Schema definition for the ability's input. * @type array $output_schema Optional. JSON Schema definition for the ability's output. * @type array $meta { @@ -315,6 +334,12 @@ protected function prepare_properties( array $args ): array { } // Optional args only need to be of the correct type if they are present. + if ( isset( $args['eligibility_callback'] ) && ! is_callable( $args['eligibility_callback'] ) ) { + throw new InvalidArgumentException( + __( 'The ability properties should provide a valid `eligibility_callback` function.' ) + ); + } + if ( isset( $args['input_schema'] ) && ! is_array( $args['input_schema'] ) ) { throw new InvalidArgumentException( __( 'The ability properties should provide a valid `input_schema` definition.' ) @@ -660,6 +685,67 @@ public function check_permissions( $input = null ) { return $result; } + /** + * Checks whether the ability applies in the given usage context. + * + * Eligibility is consulted when listing abilities, for example by `wp_get_abilities()` + * when it receives an `eligibility_context` argument. It is not consulted when the + * ability is executed. + * + * Eligibility is not a security boundary. The context values come from the caller and + * are not verified, so nothing security related may depend on them. Permission checks + * belong in the `permission_callback` instead. + * + * An ability without an `eligibility_callback` is always eligible. A callback that + * returns anything other than a boolean is treated as eligible. When the context lacks + * a key the callback cares about, the callback should return true. This way adding + * context keys can only narrow a result, never expand it. + * + * The {@see 'wp_ability_eligibility_result'} filter fires after the registered + * `eligibility_callback` returns, allowing plugins to override the result. + * + * @since 7.2.0 + * + * @param array $eligibility_context Optional. An associative array describing the caller's + * usage context. Default empty array. + * @return bool Whether the ability applies in the given usage context. + */ + public function is_eligible( array $eligibility_context = array() ): bool { + $is_eligible = true; + if ( is_callable( $this->eligibility_callback ) ) { + $is_eligible = call_user_func( $this->eligibility_callback, $eligibility_context ); + } + + /** + * Filters the eligibility result for an ability. + * + * Fires after the registered `eligibility_callback` returns, or with `true` when the + * ability does not declare one. Plugins can use this to adjust in which usage contexts + * an ability is listed. + * + * Eligibility is not a security boundary. The context values come from the caller + * and are not verified. + * + * Filters can return `true` to keep the ability eligible or `false` to exclude it. + * Any other return value is treated as eligible. + * + * @since 7.2.0 + * + * @param mixed $is_eligible The eligibility result returned by `eligibility_callback`, + * or `true` when the ability does not declare one. + * @param string $ability_name The name of the ability. + * @param array $eligibility_context The usage context being evaluated. + * @param WP_Ability $ability The ability instance. + */ + $result = apply_filters( 'wp_ability_eligibility_result', $is_eligible, $this->name, $eligibility_context, $this ); + + if ( ! is_bool( $result ) ) { + return true; + } + + return $result; + } + /** * Executes the ability callback. * diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php index 5547e7e002f02..252d700c39fde 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php @@ -81,6 +81,7 @@ public function register_routes(): void { * Retrieves all abilities. * * @since 6.9.0 + * @since 7.2.0 Added support for the `eligibility_context` query parameter. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response Response object on success. @@ -103,6 +104,10 @@ public function get_items( $request ) { $query_args['meta'] = array_merge( $request['meta'], $query_args['meta'] ); } + if ( ! empty( $request['eligibility_context'] ) ) { + $query_args['eligibility_context'] = $request['eligibility_context']; + } + $abilities = wp_get_abilities( $query_args ); $page = $request['page']; @@ -332,38 +337,44 @@ public function get_item_schema(): array { * * @since 6.9.0 * @since 7.1.0 Added the `namespace` and `meta` parameters and the `rest_abilities_collection_params` filter. + * @since 7.2.0 Added the `eligibility_context` parameter. * * @return array Collection parameters. */ public function get_collection_params(): array { $query_params = array( - 'context' => $this->get_context_param( array( 'default' => 'view' ) ), - 'page' => array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + 'page' => array( 'description' => __( 'Current page of the collection.' ), 'type' => 'integer', 'default' => 1, 'minimum' => 1, ), - 'per_page' => array( + 'per_page' => array( 'description' => __( 'Maximum number of items to be returned in result set.' ), 'type' => 'integer', 'default' => 50, 'minimum' => 1, 'maximum' => 100, ), - 'category' => array( + 'category' => array( 'description' => __( 'Limit results to abilities in specific ability category.' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ), - 'namespace' => array( + 'namespace' => array( 'description' => __( 'Limit results to abilities in a specific namespace.' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ), - 'meta' => array( + 'eligibility_context' => array( + 'description' => __( 'Limit results to abilities eligible in the given usage context. The keys and values are agreed on between clients and ability authors. They are provided by the client and are not verified.' ), + 'type' => 'object', + 'additionalProperties' => true, + ), + 'meta' => array( 'description' => __( 'Limit results to abilities matching all of the given meta fields.' ), 'type' => 'object', 'properties' => array( diff --git a/tests/phpunit/tests/abilities-api/wpAbility.php b/tests/phpunit/tests/abilities-api/wpAbility.php index f613736211f77..44d00e5a36e41 100644 --- a/tests/phpunit/tests/abilities-api/wpAbility.php +++ b/tests/phpunit/tests/abilities-api/wpAbility.php @@ -2119,4 +2119,152 @@ public function test_execute_ignores_schema_sanitize_callback() { $this->assertSame( 'raw value', $result, 'Input should reach the execute callback unmodified (no sanitization).' ); $this->assertFalse( $callback_invoked, 'Schema sanitize_callback must not run.' ); } + + /** + * Tests that an ability without an eligibility callback is always eligible. + * + * @covers WP_Ability::is_eligible + */ + public function test_is_eligible_defaults_to_true_without_callback() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertTrue( $ability->is_eligible() ); + $this->assertTrue( $ability->is_eligible( array( 'surface' => 'rest' ) ) ); + } + + /** + * Tests that the registered eligibility callback decides the result. + * + * @covers WP_Ability::is_eligible + */ + public function test_is_eligible_uses_registered_callback() { + $ability = new WP_Ability( + self::$test_ability_name, + array_merge( + self::$test_ability_properties, + array( + 'eligibility_callback' => static function ( array $context ): bool { + return isset( $context['post_type'] ) && 'product' === $context['post_type']; + }, + ) + ) + ); + + $this->assertTrue( $ability->is_eligible( array( 'post_type' => 'product' ) ) ); + $this->assertFalse( $ability->is_eligible( array( 'post_type' => 'page' ) ) ); + } + + /** + * Tests that a non-boolean callback return is treated as eligible. + * + * @covers WP_Ability::is_eligible + */ + public function test_is_eligible_non_boolean_callback_return_is_treated_as_eligible() { + $ability = new WP_Ability( + self::$test_ability_name, + array_merge( + self::$test_ability_properties, + array( + 'eligibility_callback' => static function (): string { + return 'not-a-boolean'; + }, + ) + ) + ); + + $this->assertTrue( $ability->is_eligible( array( 'surface' => 'rest' ) ) ); + } + + /** + * Tests that an invalid eligibility callback throws an exception. + * + * @covers WP_Ability::prepare_properties + */ + public function test_invalid_eligibility_callback_throws_exception() { + $this->expectException( InvalidArgumentException::class ); + new WP_Ability( + self::$test_ability_name, + array_merge( + self::$test_ability_properties, + array( + 'eligibility_callback' => 'this_function_does_not_exist', + ) + ) + ); + } + + /** + * Tests that the eligibility result filter can override the callback result. + * + * @covers WP_Ability::is_eligible + */ + public function test_eligibility_result_filter_can_override_result() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + + $received = array(); + add_filter( + 'wp_ability_eligibility_result', + static function ( $is_eligible, $ability_name, $context, $ability_instance ) use ( &$received ) { + $received = array( $is_eligible, $ability_name, $context, $ability_instance ); + return false; + }, + 10, + 4 + ); + + $context = array( 'surface' => 'mcp' ); + + $this->assertFalse( $ability->is_eligible( $context ) ); + $this->assertTrue( $received[0], 'The filter should receive true when the ability declares no callback.' ); + $this->assertSame( self::$test_ability_name, $received[1] ); + $this->assertSame( $context, $received[2] ); + $this->assertSame( $ability, $received[3] ); + } + + /** + * Tests that a non-boolean filter return is treated as eligible. + * + * @covers WP_Ability::is_eligible + */ + public function test_eligibility_result_filter_non_boolean_return_is_treated_as_eligible() { + $ability = new WP_Ability( + self::$test_ability_name, + array_merge( + self::$test_ability_properties, + array( + 'eligibility_callback' => '__return_false', + ) + ) + ); + + add_filter( 'wp_ability_eligibility_result', '__return_null' ); + + $this->assertTrue( $ability->is_eligible( array( 'surface' => 'rest' ) ) ); + } + + /** + * Tests that the eligibility callback is not consulted when executing an ability. + * + * @covers WP_Ability::execute + */ + public function test_eligibility_callback_not_consulted_on_execute() { + $calls = 0; + $ability = new WP_Ability( + self::$test_ability_name, + array_merge( + self::$test_ability_properties, + array( + 'eligibility_callback' => static function () use ( &$calls ): bool { + ++$calls; + return false; + }, + ) + ) + ); + + $result = $ability->execute(); + + $this->assertSame( 0, $result, 'The ability should execute normally.' ); + $this->assertSame( 0, $calls, 'The eligibility callback must not run on execute.' ); + } } diff --git a/tests/phpunit/tests/abilities-api/wpGetAbilities.php b/tests/phpunit/tests/abilities-api/wpGetAbilities.php index 475ef341d869f..1a42f9ea6d673 100644 --- a/tests/phpunit/tests/abilities-api/wpGetAbilities.php +++ b/tests/phpunit/tests/abilities-api/wpGetAbilities.php @@ -380,6 +380,155 @@ static function ( WP_Ability $a ) { $this->assertNotContains( 'test/ability-no-meta', $names ); } + // ------------------------------------------------------------------------- + // eligibility_context + // ------------------------------------------------------------------------- + + /** + * Tests that eligibility callbacks are not consulted when no context is passed. + * + * The result without a context is the upper bound. Even an ability whose + * callback always returns false must be included. + */ + public function test_no_eligibility_context_returns_every_ability(): void { + $this->simulate_wp_abilities_init(); + + $this->register_test_ability( + 'test/never-eligible', + array( 'eligibility_callback' => '__return_false' ) + ); + $this->register_test_ability( 'test/no-callback' ); + + $result = wp_get_abilities(); + + $this->assertArrayHasKey( 'test/never-eligible', $result ); + $this->assertArrayHasKey( 'test/no-callback', $result ); + } + + /** + * Tests that a context excludes abilities whose callback returns false. + */ + public function test_eligibility_context_excludes_ineligible_abilities(): void { + $this->simulate_wp_abilities_init(); + + $this->register_test_ability( + 'test/product-only', + array( + 'eligibility_callback' => static function ( array $context ): bool { + return isset( $context['post_type'] ) && 'product' === $context['post_type']; + }, + ) + ); + $this->register_test_ability( 'test/no-callback' ); + + $result = wp_get_abilities( array( 'eligibility_context' => array( 'post_type' => 'page' ) ) ); + + $this->assertArrayNotHasKey( 'test/product-only', $result ); + $this->assertArrayHasKey( 'test/no-callback', $result, 'An ability without an eligibility callback should always be included.' ); + + $result = wp_get_abilities( array( 'eligibility_context' => array( 'post_type' => 'product' ) ) ); + + $this->assertArrayHasKey( 'test/product-only', $result ); + $this->assertArrayHasKey( 'test/no-callback', $result ); + } + + /** + * Tests that a context lacking the key a callback cares about keeps the ability. + * + * Callbacks follow the upper bound rule and return true for a missing key, so + * adding context keys can only narrow a result. + */ + public function test_eligibility_context_missing_key_keeps_ability(): void { + $this->simulate_wp_abilities_init(); + + $this->register_test_ability( + 'test/product-only', + array( + 'eligibility_callback' => static function ( array $context ): bool { + return ! isset( $context['post_type'] ) || 'product' === $context['post_type']; + }, + ) + ); + + $result = wp_get_abilities( array( 'eligibility_context' => array( 'surface' => 'rest' ) ) ); + + $this->assertArrayHasKey( 'test/product-only', $result ); + } + + /** + * Tests that a non-boolean callback return is treated as eligible. + */ + public function test_eligibility_callback_non_boolean_return_keeps_ability(): void { + $this->simulate_wp_abilities_init(); + + $this->register_test_ability( + 'test/non-boolean', + array( + 'eligibility_callback' => static function (): string { + return 'not-a-boolean'; + }, + ) + ); + + $result = wp_get_abilities( array( 'eligibility_context' => array( 'surface' => 'rest' ) ) ); + + $this->assertArrayHasKey( 'test/non-boolean', $result ); + } + + /** + * Tests that the eligibility callback receives the context passed by the caller. + */ + public function test_eligibility_callback_receives_context(): void { + $this->simulate_wp_abilities_init(); + + $received = null; + $this->register_test_ability( + 'test/receives-context', + array( + 'eligibility_callback' => static function ( array $context ) use ( &$received ): bool { + $received = $context; + return true; + }, + ) + ); + + $context = array( + 'surface' => 'webmcp-admin', + 'screen' => 'post.php', + 'my-plugin/color' => 'blue', + ); + wp_get_abilities( array( 'eligibility_context' => $context ) ); + + $this->assertSame( $context, $received ); + } + + /** + * Tests that the context is available to the item include filter through $args. + */ + public function test_eligibility_context_available_to_item_include_filter(): void { + $this->simulate_wp_abilities_init(); + + $this->register_test_ability( 'test/ability-one' ); + + $received_args = null; + add_filter( + 'wp_get_abilities_item_include', + static function ( bool $should_include, WP_Ability $ability, array $args ) use ( &$received_args ): bool { + $received_args = $args; + return $should_include; + }, + 10, + 3 + ); + + $context = array( 'surface' => 'webmcp-frontend' ); + wp_get_abilities( array( 'eligibility_context' => $context ) ); + + $this->assertIsArray( $received_args ); + $this->assertArrayHasKey( 'eligibility_context', $received_args ); + $this->assertSame( $context, $received_args['eligibility_context'] ); + } + // ------------------------------------------------------------------------- // item_include_callback // ------------------------------------------------------------------------- diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php index 7243171da1da4..88e3e1af4c152 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -296,6 +296,26 @@ public function test_register_ability_no_permissions(): void { $this->assertSame( 'ability_invalid_permissions', $actual->get_error_code() ); } + /** + * Tests that registering an ability with an invalid eligibility callback fails. + */ + public function test_register_ability_invalid_eligibility_callback(): void { + $this->simulate_doing_wp_abilities_init_action(); + $this->setExpectedIncorrectUsage( WP_Abilities_Registry::class . '::register' ); + + $result = wp_register_ability( + self::$test_ability_name, + array_merge( + self::$test_ability_args, + array( + 'eligibility_callback' => 'this_function_does_not_exist', + ) + ) + ); + + $this->assertNull( $result ); + } + /** * Tests registering an ability with a custom ability class. * diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index 8e7ad3ea515bf..4292e2b37255f 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -1267,4 +1267,141 @@ public function test_required_property_booleans_converted_to_draft_04_array(): v $this->assertSame( array( 'id' ), $data['output_schema']['required'] ); $this->assertArrayNotHasKey( 'required', $data['output_schema']['properties']['id'] ); } + + /** + * Helper to register an ability whose eligibility depends on the context post type. + * + * @param callable|null $eligibility_callback Optional. Eligibility callback override. + */ + private function register_product_only_ability( ?callable $eligibility_callback = null ): void { + $this->register_test_ability( + 'test/product-only', + array( + 'label' => 'Product Only', + 'description' => 'Only relevant when a product is being edited.', + 'category' => 'general', + 'execute_callback' => '__return_true', + 'permission_callback' => '__return_true', + 'eligibility_callback' => $eligibility_callback ?? static function ( array $context ): bool { + return ! isset( $context['post_type'] ) || 'product' === $context['post_type']; + }, + 'meta' => array( 'show_in_rest' => true ), + ) + ); + } + + /** + * Test that the eligibility_context parameter narrows the collection. + */ + public function test_filter_by_eligibility_context(): void { + $this->register_product_only_ability(); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_param( 'eligibility_context', array( 'post_type' => 'page' ) ); + $request->set_param( 'per_page', 100 ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $names = wp_list_pluck( $response->get_data(), 'name' ); + + $this->assertNotContains( 'test/product-only', $names, 'An ineligible ability should be excluded.' ); + $this->assertContains( 'test/calculator', $names, 'An ability without an eligibility callback should stay included.' ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_param( 'eligibility_context', array( 'post_type' => 'product' ) ); + $request->set_param( 'per_page', 100 ); + $response = $this->server->dispatch( $request ); + + $names = wp_list_pluck( $response->get_data(), 'name' ); + + $this->assertContains( 'test/product-only', $names ); + } + + /** + * Test that requests without an eligibility_context keep ineligible abilities. + * + * The result without a context is the upper bound, so clients that pass no + * context, such as an MCP tools/list call, always see the full collection. + */ + public function test_no_eligibility_context_includes_ineligible_abilities(): void { + $this->register_product_only_ability( '__return_false' ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_param( 'per_page', 100 ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $names = wp_list_pluck( $response->get_data(), 'name' ); + + $this->assertContains( 'test/product-only', $names ); + } + + /** + * Test that bracket syntax parses into a context array. + * + * Core declares no keys for the parameter, so values reach the callback + * exactly as the query string provides them, as strings. + */ + public function test_eligibility_context_bracket_syntax_parses(): void { + $received = null; + $this->register_product_only_ability( + static function ( array $context ) use ( &$received ): bool { + $received = $context; + return true; + } + ); + + parse_str( 'eligibility_context[post_type]=product&eligibility_context[post_id]=5', $query_params ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_query_params( $query_params ); + $request->set_param( 'per_page', 100 ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( + array( + 'post_type' => 'product', + 'post_id' => '5', + ), + $received + ); + } + + /** + * Test that a non object eligibility_context is rejected. + * + * Core declares no keys, so any object shaped value is accepted, but the + * parameter itself must be an object. + */ + public function test_eligibility_context_rejects_non_object_value(): void { + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_query_params( array( 'eligibility_context' => 'product' ) ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 400, $response->get_status() ); + $this->assertSame( 'rest_invalid_param', $response->get_data()['code'] ); + } + + /** + * Test that the single ability route never consults eligibility. + */ + public function test_get_item_ignores_eligibility(): void { + $calls = 0; + $this->register_product_only_ability( + static function () use ( &$calls ): bool { + ++$calls; + return false; + } + ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/product-only' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'test/product-only', $response->get_data()['name'] ); + $this->assertSame( 0, $calls, 'The eligibility callback must not run on the single ability route.' ); + } } From ad082f831ce495c915d12c46e74bd2baf9c51278 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 1 Sep 2026 17:16:56 +0200 Subject: [PATCH 2/2] Regenerate the REST API schema fixture for eligibility_context. Co-Authored-By: Claude Fable 5 --- tests/qunit/fixtures/wp-api-generated.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 4a2d5a3ac7ea8..23ab45a834330 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12710,6 +12710,12 @@ mockedApiResponse.Schema = { "type": "string", "required": false }, + "eligibility_context": { + "description": "Limit results to abilities eligible in the given usage context. The keys and values are agreed on between clients and ability authors. They are provided by the client and are not verified.", + "type": "object", + "additionalProperties": true, + "required": false + }, "meta": { "description": "Limit results to abilities matching all of the given meta fields.", "type": "object",