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
61 changes: 49 additions & 12 deletions src/Abilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,7 @@ public function register_wordpress_abilities() {
return function_exists( 'current_user_can' ) ? current_user_can( $capability ) : false;
},
'meta' => array(
'annotations' => array(
'readonly' => ! empty( $ability['annotations']['readOnlyHint'] ),
'destructive' => empty( $ability['annotations']['readOnlyHint'] ),
'idempotent' => ! empty( $ability['annotations']['readOnlyHint'] ),
),
'annotations' => $ability['meta_annotations'],
'show_in_rest' => true,
),
)
Expand Down Expand Up @@ -259,16 +255,57 @@ public function tool_to_ability_name( $name ) {
* @param callable $callback Callback.
* @param bool $read_only Whether ability is read-only.
* @param string $capability Required WordPress capability.
* @param array<string,bool> $annotations Core ability annotation overrides.
* @return void
*/
private function add_ability( $name, $label, $description, $input_schema, $callback, $read_only = true, $capability = 'edit_posts' ) {
private function add_ability( $name, $label, $description, $input_schema, $callback, $read_only = true, $capability = 'edit_posts', $annotations = array() ) {
$core_annotations = $this->core_ability_annotations( $read_only, $annotations );

$this->abilities[ $name ] = array(
'label' => $label,
'description' => $description,
'input_schema' => $input_schema,
'callback' => $callback,
'annotations' => array( 'readOnlyHint' => (bool) $read_only ),
'capability' => $capability,
'label' => $label,
'description' => $description,
'input_schema' => $input_schema,
'callback' => $callback,
'annotations' => $this->mcp_tool_annotations( $core_annotations ),
'meta_annotations' => $core_annotations,
'capability' => $capability,
);
}

/**
* Build WordPress Abilities API annotations for a tool.
*
* @param bool $read_only Whether ability is read-only.
* @param array<string,bool> $overrides Annotation overrides.
* @return array<string,bool>
*/
private function core_ability_annotations( $read_only, $overrides = array() ) {
$annotations = array(
'readonly' => (bool) $read_only,
'destructive' => false,
'idempotent' => (bool) $read_only,
);

foreach ( array( 'readonly', 'destructive', 'idempotent' ) as $key ) {
if ( array_key_exists( $key, $overrides ) ) {
$annotations[ $key ] = (bool) $overrides[ $key ];
}
}

return $annotations;
}

/**
* Convert core ability annotations to MCP tool hints.
*
* @param array<string,bool> $annotations Core ability annotations.
* @return array<string,bool>
*/
private function mcp_tool_annotations( $annotations ) {
return array(
'readOnlyHint' => ! empty( $annotations['readonly'] ),
'destructiveHint' => ! empty( $annotations['destructive'] ),
'idempotentHint' => ! empty( $annotations['idempotent'] ),
);
}

Expand Down
2 changes: 1 addition & 1 deletion src/Tools/CommentManagementTools.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ private function add_comment_abilities() {

$this->add_ability( self::INTERNAL_PREFIX . 'comment-delete', 'Delete Comment', 'Delete a WordPress comment by ID', $id_schema, function ( $params ) {
return $this->delete_comment_tool( (int) $params['id'] );
}, false, 'moderate_comments' );
}, false, 'moderate_comments', array( 'destructive' => true, 'idempotent' => true ) );
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/Tools/ContentManagementTools.php
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,6 @@ private function add_content_abilities() {
}, false );
$this->add_ability( self::INTERNAL_PREFIX . 'content-delete', 'Delete Content', 'Delete content from any registered post type', $delete_content_schema, function ( $params ) {
return $this->delete_content( $params );
}, false );
}, false, 'edit_posts', array( 'destructive' => true, 'idempotent' => true ) );
}
}
2 changes: 1 addition & 1 deletion src/Tools/GlobalStylesTools.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ private function add_style_abilities() {
}, true, 'edit_theme_options' );
$this->add_ability( self::INTERNAL_PREFIX . 'global-styles-update', 'Update Global Styles', 'Update a global styles configuration', $update_schema, function ( $params ) {
return $this->update_global_styles( (int) $params['id'], $params );
}, false, 'edit_theme_options' );
}, false, 'edit_theme_options', array( 'idempotent' => true ) );
$this->add_ability( self::INTERNAL_PREFIX . 'global-styles-active-get', 'Get Active Global Styles', 'Get the currently active global styles for the current theme', $this->schema(), function () {
return $this->get_active_global_styles();
}, true, 'edit_theme_options' );
Expand Down
4 changes: 2 additions & 2 deletions src/Tools/MediaTools.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ private function add_media_abilities() {
}, false, 'upload_files' );
$this->add_ability( self::INTERNAL_PREFIX . 'media-update', 'Update Media', 'Update a WordPress media item', $update_schema, function ( $params ) {
return $this->update_media( (int) $params['id'], $params );
}, false, 'upload_files' );
}, false, 'upload_files', array( 'idempotent' => true ) );
$this->add_ability( self::INTERNAL_PREFIX . 'media-delete', 'Delete Media', 'Delete a WordPress media item permanently', $id_schema, function ( $params ) {
return $this->delete_content_item( (int) $params['id'], 'attachment' );
}, false, 'upload_files' );
}, false, 'upload_files', array( 'destructive' => true, 'idempotent' => true ) );
$this->add_ability( self::INTERNAL_PREFIX . 'media-search', 'Search Media', 'Search WordPress media by title, caption, or description', $list_schema, function ( $params ) {
return $this->search_content_items( 'attachment', array_merge( array( 'status' => 'inherit' ), $params ) );
}, true, 'upload_files' );
Expand Down
4 changes: 2 additions & 2 deletions src/Tools/OptionManagementTools.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,11 @@ private function add_option_abilities() {
array( 'option_name', 'value' )
), function ( $params ) {
return $this->update_option_tool( $params );
}, false, 'manage_options' );
}, false, 'manage_options', array( 'idempotent' => true ) );

$this->add_ability( self::INTERNAL_PREFIX . 'option-delete', 'Delete Option', 'Delete a WordPress option by name', $option_schema, function ( $params ) {
return $this->delete_option_tool( $params['option_name'] );
}, false, 'manage_options' );
}, false, 'manage_options', array( 'destructive' => true, 'idempotent' => true ) );
}

/**
Expand Down
6 changes: 3 additions & 3 deletions src/Tools/PluginManagementTools.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,15 @@ private function add_plugin_abilities() {

$this->add_ability( self::INTERNAL_PREFIX . 'plugin-activate', 'Activate Plugin', 'Activate an installed WordPress plugin by plugin file path', $plugin_file_schema, function ( $params ) {
return $this->activate_plugin_tool( $params['plugin_file'] );
}, false, 'activate_plugins' );
}, false, 'activate_plugins', array( 'idempotent' => true ) );

$this->add_ability( self::INTERNAL_PREFIX . 'plugin-deactivate', 'Deactivate Plugin', 'Deactivate an active WordPress plugin by plugin file path', $plugin_file_schema, function ( $params ) {
return $this->deactivate_plugin_tool( $params['plugin_file'] );
}, false, 'activate_plugins' );
}, false, 'activate_plugins', array( 'idempotent' => true ) );

$this->add_ability( self::INTERNAL_PREFIX . 'plugin-uninstall', 'Uninstall Plugin', 'Deactivate and delete an installed WordPress plugin by plugin file path', $plugin_file_schema, function ( $params ) {
return $this->uninstall_plugin( $params['plugin_file'] );
}, false, 'delete_plugins' );
}, false, 'delete_plugins', array( 'destructive' => true, 'idempotent' => true ) );
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/Tools/SiteManagementTools.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ private function add_site_abilities() {
}, false, 'edit_users' );
$this->add_ability( self::INTERNAL_PREFIX . 'user-delete', 'Delete User', 'Delete a WordPress user by ID', $user_get_schema, function ( $params ) {
return $this->delete_user( (int) $params['id'] );
}, false, 'delete_users' );
}, false, 'delete_users', array( 'destructive' => true, 'idempotent' => true ) );

$settings_schema = $this->schema(
array(
Expand All @@ -78,7 +78,7 @@ private function add_site_abilities() {
}, true, 'manage_options' );
$this->add_ability( self::INTERNAL_PREFIX . 'general-settings-save', 'Save General Settings', 'Save WordPress general site settings', $settings_schema, function ( $params ) {
return $this->update_general_settings( $params );
}, false, 'manage_options' );
}, false, 'manage_options', array( 'idempotent' => true ) );
$this->add_ability( self::INTERNAL_PREFIX . 'site-info-get', 'Get Site Info', 'Get detailed site information', $this->schema(), function () {
return $this->get_site_info();
} );
Expand Down
2 changes: 1 addition & 1 deletion src/Tools/TaxonomyTools.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ private function add_taxonomy_abilities() {

$this->add_ability( self::INTERNAL_PREFIX . 'taxonomy-term-delete', 'Delete Taxonomy Term', 'Delete a term from a registered taxonomy', $term_get_schema, function ( $params ) {
return $this->delete_term( $params['taxonomy'], (int) $params['id'] );
}, false, 'manage_categories' );
}, false, 'manage_categories', array( 'destructive' => true, 'idempotent' => true ) );
}

/**
Expand Down
4 changes: 2 additions & 2 deletions src/Tools/ThemeManagementTools.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@ private function add_theme_abilities() {

$this->add_ability( self::INTERNAL_PREFIX . 'theme-activate', 'Activate Theme', 'Activate an installed WordPress theme by stylesheet directory name', $stylesheet_schema, function ( $params ) {
return $this->activate_theme_tool( $params['stylesheet'] );
}, false, 'switch_themes' );
}, false, 'switch_themes', array( 'idempotent' => true ) );

$this->add_ability( self::INTERNAL_PREFIX . 'theme-delete', 'Delete Theme', 'Delete an installed WordPress theme by stylesheet directory name', $stylesheet_schema, function ( $params ) {
return $this->delete_theme_tool( $params['stylesheet'] );
}, false, 'delete_themes' );
}, false, 'delete_themes', array( 'destructive' => true, 'idempotent' => true ) );
}

/**
Expand Down
18 changes: 18 additions & 0 deletions tests/run.php
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,13 @@ function assert_same( $expected, $actual, $message ) {
assert_true( $site_info_tool['inputSchema']['properties'] instanceof stdClass, 'No-argument tool properties should serialize as a JSON object.' );
assert_same( true, $site_info_tool['annotations']['readOnlyHint'], 'Read-only tools should use the MCP readOnlyHint annotation.' );

$content_delete_tool = array_values( array_filter( $direct_tools, static function ( $tool ) {
return 'wp-forge-content-delete' === $tool['name'];
} ) )[0];
assert_same( false, $content_delete_tool['annotations']['readOnlyHint'], 'Delete tools should not use the MCP readOnlyHint annotation.' );
assert_same( true, $content_delete_tool['annotations']['destructiveHint'], 'Delete tools should use the MCP destructiveHint annotation.' );
assert_same( true, $content_delete_tool['annotations']['idempotentHint'], 'Delete tools should use the MCP idempotentHint annotation.' );

$missing_plugin_runtime = $abilities->call( 'wp-forge-plugin-list', array() );
assert_same( 'error', $missing_plugin_runtime['status'], 'Plugin tools should report missing runtime in unit tests.' );
assert_same( 500, $missing_plugin_runtime['statusCode'], 'Missing WordPress plugin runtime should be a server-side ability error.' );
Expand Down Expand Up @@ -471,6 +478,17 @@ function assert_same( $expected, $actual, $message ) {
assert_same( 'Search and filter content for any registered post type', $registered_abilities['wp-forge/content-search']['description'], 'Registered ability should preserve descriptions.' );
assert_same( true, $registered_abilities['wp-forge/content-search']['meta']['show_in_rest'], 'Registered abilities should be exposed through the Abilities REST API.' );
assert_same( true, $registered_abilities['wp-forge/content-search']['meta']['annotations']['readonly'], 'Read-only abilities should use core ability annotations.' );
assert_same( false, $registered_abilities['wp-forge/content-search']['meta']['annotations']['destructive'], 'Read-only abilities should not be marked destructive.' );
assert_same( true, $registered_abilities['wp-forge/content-search']['meta']['annotations']['idempotent'], 'Read-only abilities should be marked idempotent.' );
assert_same( false, $registered_abilities['wp-forge/content-save']['meta']['annotations']['readonly'], 'Save abilities should not be marked read-only.' );
assert_same( false, $registered_abilities['wp-forge/content-save']['meta']['annotations']['destructive'], 'Create-or-update abilities should not be marked destructive by default.' );
assert_same( false, $registered_abilities['wp-forge/content-save']['meta']['annotations']['idempotent'], 'Create-or-update abilities should not be marked idempotent by default.' );
assert_same( true, $registered_abilities['wp-forge/content-delete']['meta']['annotations']['destructive'], 'Delete abilities should be marked destructive.' );
assert_same( true, $registered_abilities['wp-forge/content-delete']['meta']['annotations']['idempotent'], 'Delete abilities should be marked effect-idempotent.' );
assert_same( true, $registered_abilities['wp-forge/general-settings-save']['meta']['annotations']['idempotent'], 'State-setting save abilities should be marked idempotent.' );
assert_same( false, $registered_abilities['wp-forge/plugin-activate']['meta']['annotations']['destructive'], 'Activation should not be marked destructive.' );
assert_same( true, $registered_abilities['wp-forge/plugin-activate']['meta']['annotations']['idempotent'], 'Activation should be marked idempotent.' );
assert_same( true, $registered_abilities['wp-forge/plugin-uninstall']['meta']['annotations']['destructive'], 'Uninstall should be marked destructive.' );
assert_same( true, $registered_abilities['wp-forge/content-search']['permission_callback'](), 'Permission callback should allow users with the ability capability.' );

$registered_result = $registered_abilities['wp-forge/content-search']['execute_callback']( array( 'post_type' => 'post' ) );
Expand Down