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
3 changes: 2 additions & 1 deletion 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`, `meta`, and `ability_class`.
* `permission_callback`, `annotations`, `meta`, and `ability_class`.
* @return ?\WP_Ability An instance of registered ability on success, null on failure.
*
* @phpstan-param array{
Expand All @@ -35,6 +35,7 @@
* permission_callback?: callable( mixed $input= ): (bool|\WP_Error),
* input_schema?: array<string,mixed>,
* output_schema?: array<string,mixed>,
* annotations?: array<string,mixed>,
* meta?: array<string,mixed>,
* ability_class?: class-string<\WP_Ability>,
* ...<string, mixed>
Expand Down
1 change: 1 addition & 0 deletions includes/abilities-api/class-wp-abilities-registry.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ 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,mixed>,
* meta?: array<string,mixed>,
* ability_class?: class-string<\WP_Ability>,
* ...<string, mixed>
Expand Down
57 changes: 56 additions & 1 deletion includes/abilities-api/class-wp-ability.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,29 @@
* @see WP_Abilities_Registry
*/
class WP_Ability {
/**
Copy link
Contributor

Choose a reason for hiding this comment

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

I want to suggest we align our internal naming conventions with the established MCP protocol standards to reduce developer cognitive load and maintain consistency across the systems.

Current approach:

  • Internally: read_only, destructive, idempotent
  • MCP: readOnlyHint, destructiveHint, idempotentHint

Suggested approach:
Since MCP is a well-established protocol with defined conventions, I think we should adopt the MCP naming directly in our abilities API:

  • Use: readOnlyHint, destructiveHint, idempotentHint internally
  • This eliminates the need for translation in the MCP adapter
  • Developers working with both our API and MCP won't need to context-switch between naming conventions
  • Minimizes developer confusion when working across both systems

Additional consideration - Instructions handling:
For the instructions field, since MCP uses the tool description as instructions, we can handle this in the MCP adapter by appending our instructions content to the description field.

Copy link
Member Author

Choose a reason for hiding this comment

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

The counterpoint would be that WordPress uses snake case convention for naming properties. REST API is a good reflection of that. It's also inconvenient with JavaScript that uses camel case similarly to the MCP protocol. Sometimes, we even mix the two when functionality spans all layers, as with block types that also support the JSON format, which again uses camel case.

@felixarntz, and @swissspidy, what are your thoughts on that aspect?

Copy link
Member

@felixarntz felixarntz Oct 7, 2025

Choose a reason for hiding this comment

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

+1 on aligning with WordPress's snake case convention:

  • For the reason @gziolo mentioned: We shouldn't discard WordPress Coding Standards when it's convenient :)
  • Another counterpoint is that this is the Abilities API which was from the beginning decided to be decoupled from MCP, and rightfully so.

IMO it's perfectly reasonable to have a translation layer in place as part of the MCP adapter, we shouldn't change the Abilities API to tie in particularly well with MCP when it leads to other problems.

Aside: I do agree it's painful to deal with snake_case from the server in the client-side JavaScript world where everything is camelCase. My recommendation for that reason is to use single-word names (like readonly, destructive, idempotent) wherever possible, as those would be both valid snake_case and valid camelCase. But obviously this is not the problem discussed here - it doesn't allow us to align with the MCP naming.

Copy link
Member Author

Choose a reason for hiding this comment

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

Happy to rename from read_only to readonly. I'm not even 100% sure whether "read-only" is the ultimate correct English spelling 😄 I definitely saw many times readonly in the context of programming, for example, in TypeScript.

Copy link
Contributor

Choose a reason for hiding this comment

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

Whatever you decide to do is ok with me. Also, adapting those keys to the MCP standards is not a big problem.

I also understand your points on WP coding standards and the abilities-api being decoupled from MCP.

One thing I want to avoid when creating abilities for MCP usage:

$annotations = array(
    'read_only' => true, // translated to readOnlyHint
    'destructive' => false, // translated to destructiveHint
    'idempotent' => true, // translated to idempotentHint
    'openWorldHint' => false,
    'lastModified' => '...',
    'title' => '...',
    'audience' => '...',
    'priority' => '...',
)

This is inconsistent, non-standard, and ugly.

So, what do you think we should do to avoid this situation?

Copy link
Member

@felixarntz felixarntz Oct 7, 2025

Choose a reason for hiding this comment

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

Completely agree, we shouldn't have inconsistencies like this.

Where does this example list of identifiers come from? As far as this PR is concerned, I think we can use readonly to work around the difference between snake_case and camelCase.

If there are terms where we need multi-word identifiers, I'd say we need to use snake_case if their source of truth is on the server (i.e. in PHP).

If in some JavaScript use-cases we need to translate stuff to be camelCase, we could probably do that consistently as well.

Copy link
Member

@JasonTheAdams JasonTheAdams Oct 7, 2025

Choose a reason for hiding this comment

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

I agree with @felixarntz. We shouldn't build anything in Abilities around the MCP standard. The MCP Adapter is a consumer of the Abilities API and should handle any translations and such needed to work. Any parameters added by the MCP Adapter should follow the pattern established here.

As a grammatical note, it kills me every time I see "readonly" in various contexts. We're just lying to ourselves. 😆

Copy link
Contributor

Choose a reason for hiding this comment

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

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 used the same docs to shape basic needs for abilities based on the discussion in #62.

Copy link
Member Author

Choose a reason for hiding this comment

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

Renamed read_only key to readonly, and adjusted docs to use "read-only" form in 9f3a00c.

* The default ability annotations.
* They are not guaranteed to provide a faithful description of ability behavior.
*
* @since n.e.x.t
* @var array<string,(bool|string)>
*/
protected static $default_annotations = array(
// Instructions on how to use the ability.
'instructions' => '',
// If true, the ability does not modify its environment.
'readonly' => false,
/*
* If true, the ability may perform destructive updates to its environment.
* If false, the ability performs only additive updates.
*/
'destructive' => true,
/*
* If true, calling the ability repeatedly with the same arguments will have no additional effect
* on its environment.
*/
'idempotent' => false,
);

/**
* The name of the ability, with its namespace.
Expand Down Expand Up @@ -77,6 +100,14 @@ 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 @@ -99,7 +130,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`, and `meta`.
* `execute_callback`, `permission_callback`, `annotations`, and `meta`.
*/
public function __construct( string $name, array $args ) {
$this->name = $name;
Expand Down Expand Up @@ -147,6 +178,7 @@ 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,mixed>,
* meta?: array<string,mixed>,
* ...<string, mixed>,
* } $args
Expand Down Expand Up @@ -190,12 +222,24 @@ protected function prepare_properties( array $args ): array {
);
}

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

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

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

return $args;
}

Expand Down Expand Up @@ -255,6 +299,17 @@ 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 @@ -191,6 +191,7 @@ 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 Expand Up @@ -264,6 +265,12 @@ public function get_item_schema(): array {
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'annotations' => array(
'description' => __( 'Annotations for the ability.' ),
'type' => 'object',
'context' => array( 'view', 'edit' ),
'readonly' => true,
),
'meta' => array(
'description' => __( 'Meta information about the ability.' ),
'type' => 'object',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ public function register_routes(): void {
),
),

// TODO: We register ALLMETHODS because at route registration time, we don't know
// which abilities exist or their types (resource vs tool). This is due to WordPress
// TODO: We register ALLMETHODS because at route registration time, we don't know which abilities
// exist or their annotations (`destructive`, `idempotent`, `readonly`). This is due to WordPress
// load order - routes are registered early, before plugins have registered their abilities.
// This approach works but could be improved with lazy route registration or a different
// architecture that allows type-specific routes after abilities are registered.
Expand Down Expand Up @@ -90,23 +90,23 @@ public function run_ability_with_method_check( $request ) {
);
}

// Check if the HTTP method matches the ability type.
$meta = $ability->get_meta();
$type = isset( $meta['type'] ) ? $meta['type'] : 'tool';
$method = $request->get_method();
// Check if the HTTP method matches the ability annotations.
$annotations = $ability->get_annotations();
$is_readonly = ! empty( $annotations['readonly'] );
$method = $request->get_method();

if ( 'resource' === $type && 'GET' !== $method ) {
if ( $is_readonly && 'GET' !== $method ) {
return new \WP_Error(
'rest_ability_invalid_method',
__( 'Resource abilities require GET method.' ),
__( 'Read-only abilities require GET method.' ),
array( 'status' => 405 )
);
}

if ( 'tool' === $type && 'POST' !== $method ) {
if ( ! $is_readonly && 'POST' !== $method ) {
return new \WP_Error(
'rest_ability_invalid_method',
__( 'Tool abilities require POST method.' ),
__( 'Abilities that perform updates require POST method.' ),
array( 'status' => 405 )
);
}
Expand Down
44 changes: 22 additions & 22 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@ const { getAbilities, getAbility, executeAbility } = wp.abilities;
const abilities = await getAbilities();

// Get a specific ability
const ability = await getAbility('my-plugin/my-ability');
const ability = await getAbility( 'my-plugin/my-ability' );

// Execute an ability
const result = await executeAbility('my-plugin/my-ability', {
const result = await executeAbility( 'my-plugin/my-ability', {
param1: 'value1',
param2: 'value2',
});
} );
```

### Using with React and WordPress Data
Expand All @@ -47,24 +47,24 @@ import { store as abilitiesStore } from '@wordpress/abilities';

function MyComponent() {
const abilities = useSelect(
(select) => select(abilitiesStore).getAbilities(),
( select ) => select( abilitiesStore ).getAbilities(),
[]
);

const specificAbility = useSelect(
(select) => select(abilitiesStore).getAbility('my-plugin/my-ability'),
( select ) => select( abilitiesStore ).getAbility( 'my-plugin/my-ability' ),
[]
);

return (
<div>
<h2>All Abilities</h2>
<ul>
{abilities.map((ability) => (
<li key={ability.name}>
<strong>{ability.label}</strong>: {ability.description}
{ abilities.map( ( ability ) => (
<li key={ ability.name }>
<strong>{ ability.label }</strong>: { ability.description }
</li>
))}
) ) }
</ul>
</div>
);
Expand All @@ -81,38 +81,38 @@ Returns all registered abilities. Automatically handles pagination to fetch all

```javascript
const abilities = await getAbilities();
console.log(`Found ${abilities.length} abilities`);
console.log( `Found ${ abilities.length } abilities` );
```

#### `getAbility(name: string): Promise<Ability | null>`

Returns a specific ability by name, or null if not found.

```javascript
const ability = await getAbility('my-plugin/create-post');
if (ability) {
console.log(`Found ability: ${ability.label}`);
const ability = await getAbility( 'my-plugin/create-post' );
if ( ability ) {
console.log( `Found ability: ${ ability.label }` );
}
```

#### `executeAbility(name: string, input?: Record<string, any>): Promise<any>`

Executes an ability with optional input parameters. The HTTP method is automatically determined based on the ability's type:
Executes an ability with optional input parameters. The HTTP method is automatically determined based on the ability's annotations:

- `resource` type abilities use GET (read-only operations)
- `tool` type abilities use POST (write operations)
- `readonly` abilities use GET (read-only operations)
- regular abilities use POST (write operations)

```javascript
// Execute a resource ability (GET)
const data = await executeAbility('my-plugin/get-data', {
// Execute a read-only ability (GET)
const data = await executeAbility( 'my-plugin/get-data', {
id: 123,
});
} );

// Execute a tool ability (POST)
const result = await executeAbility('my-plugin/create-item', {
// Execute a regular ability (POST)
const result = await executeAbility( 'my-plugin/create-item', {
title: 'New Item',
content: 'Item content',
});
} );
```

### Store Selectors
Expand Down
32 changes: 16 additions & 16 deletions packages/client/src/__tests__/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,12 +259,12 @@ describe( 'API functions', () => {
).rejects.toThrow( 'invalid input' );
} );

it( 'should execute a resource-type ability via GET', async () => {
it( 'should execute a read-only ability via GET', async () => {
const mockAbility: Ability = {
name: 'test/resource',
label: 'Resource Ability',
description: 'Test resource ability',
meta: { type: 'resource' },
name: 'test/read-only',
label: 'Read-only Ability',
description: 'Test read-only ability.',
annotations: { readonly: true },
input_schema: {
type: 'object',
properties: {
Expand All @@ -280,27 +280,27 @@ describe( 'API functions', () => {
getAbility: mockGetAbility,
} );

const mockResponse = { data: 'resource data' };
const mockResponse = { data: 'read-only data' };
( apiFetch as unknown as jest.Mock ).mockResolvedValue(
mockResponse
);

const input = { id: '123', format: 'json' };
const result = await executeAbility( 'test/resource', input );
const result = await executeAbility( 'test/read-only', input );

expect( apiFetch ).toHaveBeenCalledWith( {
path: '/wp/v2/abilities/test/resource/run?input%5Bid%5D=123&input%5Bformat%5D=json',
path: '/wp/v2/abilities/test/read-only/run?input%5Bid%5D=123&input%5Bformat%5D=json',
method: 'GET',
} );
expect( result ).toEqual( mockResponse );
} );

it( 'should execute a resource-type ability with empty input', async () => {
it( 'should execute a read-only ability with empty input', async () => {
const mockAbility: Ability = {
name: 'test/resource',
label: 'Resource Ability',
description: 'Test resource ability',
meta: { type: 'resource' },
name: 'test/read-only',
label: 'Read-only Ability',
description: 'Test read-only ability.',
annotations: { readonly: true },
input_schema: { type: 'object' },
output_schema: { type: 'object' },
};
Expand All @@ -310,15 +310,15 @@ describe( 'API functions', () => {
getAbility: mockGetAbility,
} );

const mockResponse = { data: 'all resources' };
const mockResponse = { data: 'read-only data' };
( apiFetch as unknown as jest.Mock ).mockResolvedValue(
mockResponse
);

const result = await executeAbility( 'test/resource', {} );
const result = await executeAbility( 'test/read-only', {} );

expect( apiFetch ).toHaveBeenCalledWith( {
path: '/wp/v2/abilities/test/resource/run?',
path: '/wp/v2/abilities/test/read-only/run?',
method: 'GET',
} );
expect( result ).toEqual( mockResponse );
Expand Down
3 changes: 1 addition & 2 deletions packages/client/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,7 @@ async function executeServerAbility(
ability: Ability,
input: AbilityInput
): Promise< AbilityOutput > {
const isResource = ability.meta?.type === 'resource';
const method = isResource ? 'GET' : 'POST';
const method = !! ability.annotations?.readonly ? 'GET' : 'POST';

let path = `/wp/v2/abilities/${ ability.name }/run`;
const options: {
Expand Down
Loading