Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions docs/3.registering-abilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ The `$args` array accepts the following keys:
- The callback should return a boolean (`true` if the user has permission, `false` otherwise), or a `WP_Error` object on failure.
- If the input does not validate against the input schema, the permission callback will not be called, and a `WP_Error` will be returned instead.
- `meta` (`array`, **Optional**): An associative array for storing arbitrary additional metadata about the ability.
- `annotations` (`array`, **Optional**): An associative array of annotations providing hints about the ability's behavior characteristics. Supports the following keys:
- `instructions` (`string`, **Optional**): Custom instructions or guidance for using the ability (default: `''`).
- `readonly` (`boolean`, **Optional**): Whether the ability only reads data without modifying its environment (default: `false`).
- `destructive` (`boolean`, **Optional**): Whether the ability may perform destructive updates to its environment. If `true`, the ability may perform any type of modification, including deletions or other destructive changes. If `false`, the ability performs only additive updates (default: `true`).
- `idempotent` (`boolean`, **Optional**): Whether calling the ability repeatedly with the same arguments will have no additional effect on its environment (default: `false`).
- `show_in_rest` (`boolean`, **Optional**): Whether to expose this ability via the REST API. Default: `false`.
- When `true`, the ability will be listed in REST API responses and can be executed via REST endpoints.
- When `false`, the ability will be hidden from REST API listings and cannot be executed via REST endpoints, but remains available for internal PHP usage.
Expand Down Expand Up @@ -80,7 +85,13 @@ function my_plugin_register_site_info_ability() {
'url' => home_url()
);
},
'permission_callback' => '__return_true'
'permission_callback' => '__return_true',
'meta' => array(
'annotations' => array(
'readonly' => true,
'destructive' => false
),
),
));
}
```
Expand Down Expand Up @@ -134,7 +145,13 @@ function my_plugin_register_update_option_ability() {
},
'permission_callback' => function() {
return current_user_can( 'manage_options' );
}
},
'meta' => array(
'annotations' => array(
'destructive' => false,
'idempotent' => true
),
),
));
}
```
Expand Down
54 changes: 46 additions & 8 deletions docs/5.rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,14 @@ Abilities are represented in JSON with the following structure:
}
}
},
"meta": {}
"meta": {
"annotations": {
"instructions": "",
"readonly": true,
"destructive": false,
"idempotent": false
}
}
}
```

Expand Down Expand Up @@ -85,7 +92,14 @@ curl https://example.com/wp-json/wp/v2/abilities
}
}
},
"meta": {}
"meta": {
"annotations": {
"instructions": "",
"readonly": false,
"destructive": true,
"idempotent": false
}
}
}
]
```
Expand Down Expand Up @@ -128,31 +142,55 @@ curl https://example.com/wp-json/wp/v2/abilities/my-plugin/get-site-info
}
}
},
"meta": {}
"meta": {
"annotations": {
"instructions": "",
"readonly": true,
"destructive": false,
"idempotent": false
}
}
}
```

## Execute an Ability

Abilities are executed via the `/run` endpoint. The required HTTP method depends on the ability's `readonly` annotation:

- **Read-only abilities** (`readonly: true`) must use **GET**
- **Regular abilities** (default) must use **POST**

This distinction ensures read-only operations use safe HTTP methods that can be cached and don't modify server state.

### Definition

`POST /wp/v2/abilities/(?P<namespace>[a-z0-9-]+)/(?P<ability>[a-z0-9-]+)/run`
`GET|POST /wp/v2/abilities/(?P<namespace>[a-z0-9-]+)/(?P<ability>[a-z0-9-]+)/run`

### Arguments

- `namespace` _(string)_: The namespace part of the ability name.
- `ability` _(string)_: The ability name part.
- `input` _(integer|number|boolean|string|array|object|null)_: Optional input data for the ability as defined by its input schema.
- For **GET requests**: pass as `input` query parameter (URL-encoded JSON)
- For **POST requests**: pass in JSON body

### Example Request (No Input)
### Example Request (Read-only, GET)

```bash
curl -X POST https://example.com/wp-json/wp/v2/abilities/my-plugin/get-site-info/run
# No input
curl https://example.com/wp-json/wp/v2/abilities/my-plugin/get-site-info/run

# With input (URL-encoded)
curl "https://example.com/wp-json/wp/v2/abilities/my-plugin/get-user-info/run?input=%7B%22user_id%22%3A1%7D"
```

### Example Request (With Input)
### Example Request (Regular, POST)

```bash
# No input
curl -X POST https://example.com/wp-json/wp/v2/abilities/my-plugin/create-draft/run

# With input
curl -X POST \
-H "Content-Type: application/json" \
-d '{"input":{"option_name":"blogname","option_value":"New Site Name"}}' \
Expand Down Expand Up @@ -205,5 +243,5 @@ The API returns standard WordPress REST API error responses with these common co
- `ability_invalid_output` - output validation failed according to the ability's schema.
- `ability_invalid_execute_callback` - the ability's execute callback is not callable.
- `rest_ability_not_found` - the requested ability is not registered.
- `rest_ability_invalid_method` - the requested HTTP method is not allowed for executing the selected ability.
- `rest_ability_invalid_method` - the requested HTTP method is not allowed for executing the selected ability (e.g., using GET on a read-only ability, or POST on a regular ability).
- `rest_ability_cannot_execute` - the ability cannot be executed due to insufficient permissions.
4 changes: 2 additions & 2 deletions includes/abilities-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
* alphanumeric characters, dashes and the forward slash.
* @param array<string,mixed> $args An associative array of arguments for the ability. This should include
* `label`, `description`, `input_schema`, `output_schema`, `execute_callback`,
* `permission_callback`, `annotations`, `meta`, and `ability_class`.
* `permission_callback`, `meta`, and `ability_class`.
* @return ?\WP_Ability An instance of registered ability on success, null on failure.
*
* @phpstan-param array{
Expand All @@ -35,8 +35,8 @@
* permission_callback?: callable( mixed $input= ): (bool|\WP_Error),
* input_schema?: array<string,mixed>,
* output_schema?: array<string,mixed>,
* annotations?: array<string,(bool|string)>,
* meta?: array{
* annotations?: array<string,(bool|string)>,
* show_in_rest?: bool,
* ...<string,mixed>,
* },
Expand Down
2 changes: 1 addition & 1 deletion includes/abilities-api/class-wp-abilities-registry.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ final class WP_Abilities_Registry {
* permission_callback?: callable( mixed $input= ): (bool|\WP_Error),
* input_schema?: array<string,mixed>,
* output_schema?: array<string,mixed>,
* annotations?: array<string,(bool|string)>,
* meta?: array{
* annotations?: array<string,(bool|string)>,
* show_in_rest?: bool,
* ...<string, mixed>
* },
Expand Down
42 changes: 12 additions & 30 deletions includes/abilities-api/class-wp-ability.php
Copy link
Member

Choose a reason for hiding this comment

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

What is the typical WordPress approach to redundancy? I personally wouldn't mind having a getter and setter for annotations, while still storing them in meta. Yes, one could use get_meta() to retrieve the annotations, but for built-in meta it's nice to have more declarative methods.

Copy link
Member Author

Choose a reason for hiding this comment

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

I’m very much open for adding a getter to simplify access to individual annotations. I don’t think WordPress core has many good reference points in that regard because older code is often composed of functions chained together. I can spin a follow up PR. To confirm we are on the same page, are we talking about something like:

$this->get_annotation( ‘readonly’ );

Copy link
Member

Choose a reason for hiding this comment

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

Yup! Exactly. Just convenience methods.

Copy link
Contributor

@justlevine justlevine Oct 10, 2025

Choose a reason for hiding this comment

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

If possible, I'd like to save convenience methods for 7.0.

I know nested arrays are messy so I see why we might want to make an exception for annotations (at least in their current shape), but FWIW @gziolo did just introduce ::get_meta_item() in #107, so the method would only be at the convenience of $ability->get_meta_item( 'annotations' )['readonly']

Copy link
Member

Choose a reason for hiding this comment

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

I don't see a reason to hesitate on these things unless you foresee something like annotations going away or moving.

Copy link
Contributor

@justlevine justlevine Oct 10, 2025

Choose a reason for hiding this comment

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

@JasonTheAdams more details in the comment and subsequent discussion I linked to above in #62 but tl;dr I expect at minimum "annotation" to be renamed to something that makes sense semanticaly in non-MCP contexts, as well as the shape/location/keys for some of the meta based on what gets decided in #106 . Hopefully in time for 6.9 so it too doesn't become cumulative tech debt.

But also if I couldn't think of ways to possibly improve the semantics/dx, I'd hope the fact that we're not dogfooding the function internally would be reason enough to pause. We're already navelgazing these props into existence, it's counterproductive to keep expanding our API with compounding layers of conjecture, and slows down our ability to iterate since once something is in core we need to work around it forever.

(Like the linked comment it's not a big deal if we add it now in time for beta1 and then remove during core review. But I do feel it's pretty likely this method will be obviated before rc )

Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,6 @@ class WP_Ability {
*/
protected $permission_callback;

/**
* The ability annotations.
*
* @since n.e.x.t
* @var array<string,(bool|string)>
*/
protected $annotations = array();

/**
* The optional ability metadata.
*
Expand All @@ -139,7 +131,7 @@ class WP_Ability {
* @param string $name The name of the ability, with its namespace.
* @param array<string,mixed> $args An associative array of arguments for the ability. This should include
* `label`, `description`, `input_schema`, `output_schema`, `execute_callback`,
* `permission_callback`, `annotations`, and `meta`.
* `permission_callback`, and `meta`.
*/
public function __construct( string $name, array $args ) {
$this->name = $name;
Expand Down Expand Up @@ -187,8 +179,8 @@ public function __construct( string $name, array $args ) {
* permission_callback: callable( mixed $input= ): (bool|\WP_Error),
* input_schema?: array<string,mixed>,
* output_schema?: array<string,mixed>,
* annotations?: array<string,(bool|string)>,
* meta?: array{
* annotations?: array<string,(bool|string)>,
* show_in_rest?: bool,
* ...<string, mixed>
* },
Expand Down Expand Up @@ -234,15 +226,15 @@ protected function prepare_properties( array $args ): array {
);
}

if ( isset( $args['annotations'] ) && ! is_array( $args['annotations'] ) ) {
if ( isset( $args['meta'] ) && ! is_array( $args['meta'] ) ) {
throw new \InvalidArgumentException(
esc_html__( 'The ability properties should provide a valid `annotations` array.' )
esc_html__( 'The ability properties should provide a valid `meta` array.' )
);
}

if ( isset( $args['meta'] ) && ! is_array( $args['meta'] ) ) {
if ( isset( $args['meta']['annotations'] ) && ! is_array( $args['meta']['annotations'] ) ) {
throw new \InvalidArgumentException(
esc_html__( 'The ability properties should provide a valid `meta` array.' )
esc_html__( 'The ability meta should provide a valid `annotations` array.' )
);
}

Expand All @@ -253,16 +245,17 @@ protected function prepare_properties( array $args ): array {
}

// Set defaults for optional meta.
$args['annotations'] = wp_parse_args(
$args['annotations'] ?? array(),
static::$default_annotations
);
$args['meta'] = wp_parse_args(
$args['meta'] = wp_parse_args(
$args['meta'] ?? array(),
array(
'annotations' => static::$default_annotations,
'show_in_rest' => self::DEFAULT_SHOW_IN_REST,
)
);
$args['meta']['annotations'] = wp_parse_args(
$args['meta']['annotations'],
static::$default_annotations
);

return $args;
}
Expand Down Expand Up @@ -323,17 +316,6 @@ public function get_output_schema(): array {
return $this->output_schema;
}

/**
* Retrieves the annotations for the ability.
*
* @since n.e.x.t
*
* @return array<string,(bool|string)> The annotations for the ability.
*/
public function get_annotations(): array {
return $this->annotations;
}

/**
* Retrieves the metadata for the ability.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,6 @@ public function prepare_item_for_response( $ability, $request ) {
'description' => $ability->get_description(),
'input_schema' => $ability->get_input_schema(),
'output_schema' => $ability->get_output_schema(),
'annotations' => $ability->get_annotations(),
'meta' => $ability->get_meta(),
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ public function run_ability_with_method_check( $request ) {
}

// Check if the HTTP method matches the ability annotations.
$annotations = $ability->get_annotations();
$annotations = $ability->get_meta_item( 'annotations' );
$is_readonly = ! empty( $annotations['readonly'] );
$method = $request->get_method();

Expand Down
8 changes: 6 additions & 2 deletions packages/client/src/__tests__/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,6 @@ describe( 'API functions', () => {
name: 'test/read-only',
label: 'Read-only Ability',
description: 'Test read-only ability.',
annotations: { readonly: true },
input_schema: {
type: 'object',
properties: {
Expand All @@ -273,6 +272,9 @@ describe( 'API functions', () => {
},
},
output_schema: { type: 'object' },
meta: {
annotations: { readonly: true },
},
};

const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
Expand Down Expand Up @@ -300,9 +302,11 @@ describe( 'API functions', () => {
name: 'test/read-only',
label: 'Read-only Ability',
description: 'Test read-only ability.',
annotations: { readonly: true },
input_schema: { type: 'object' },
output_schema: { type: 'object' },
meta: {
annotations: { readonly: true },
}
};

const mockGetAbility = jest.fn().mockResolvedValue( mockAbility );
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ async function executeServerAbility(
ability: Ability,
input: AbilityInput
): Promise< AbilityOutput > {
const method = !! ability.annotations?.readonly ? 'GET' : 'POST';
const method = !! ability.meta?.annotations?.readonly ? 'GET' : 'POST';

let path = `/wp/v2/abilities/${ ability.name }/run`;
const options: {
Expand Down
6 changes: 3 additions & 3 deletions packages/client/src/store/__tests__/reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,9 @@ describe( 'Store Reducer', () => {
description: 'Full test ability.',
input_schema: { type: 'object' },
output_schema: { type: 'object' },
annotations: { readonly: true },
meta: { category: 'test' },
meta: {
category: 'test',
},
callback: () => Promise.resolve( {} ),
permissionCallback: () => true,
// Extra properties that should be filtered out
Expand All @@ -154,7 +155,6 @@ describe( 'Store Reducer', () => {
expect( ability.description ).toBe( 'Full test ability.' );
expect( ability.input_schema ).toEqual( { type: 'object' } );
expect( ability.output_schema ).toEqual( { type: 'object' } );
expect( ability.annotations ).toEqual( { readonly: true } );
expect( ability.meta ).toEqual( { category: 'test' } );
expect( ability.callback ).toBeDefined();
expect( ability.permissionCallback ).toBeDefined();
Expand Down
1 change: 0 additions & 1 deletion packages/client/src/store/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ const ABILITY_KEYS = [
'description',
'input_schema',
'output_schema',
'annotations',
'meta',
'callback',
'permissionCallback',
Expand Down
17 changes: 6 additions & 11 deletions packages/client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,22 +68,17 @@ export interface Ability {
*/
permissionCallback?: PermissionCallback;

/**
* Annotations for the ability.
* @see WP_Ability::get_annotations()
*/
annotations?: {
instructions?: string;
readonly?: boolean;
destructive?: boolean;
idempotent?: boolean;
};

/**
* Metadata about the ability.
* @see WP_Ability::get_meta()
*/
meta?: {
annotations?: {
instructions?: string;
readonly?: boolean;
destructive?: boolean;
idempotent?: boolean;
},
[ key: string ]: any;
};
}
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/abilities-api/wpAbilitiesRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ public function test_register_incorrect_output_schema_type() {
* @expectedIncorrectUsage WP_Abilities_Registry::register
*/
public function test_register_invalid_annotations_type() {
self::$test_ability_args['annotations'] = false;
self::$test_ability_args['meta']['annotations'] = false;

$result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
$this->assertNull( $result );
Expand Down
Loading