diff --git a/src/Abilities.php b/src/Abilities.php index 3af8ad2..ce3f3ff 100644 --- a/src/Abilities.php +++ b/src/Abilities.php @@ -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, ), ) @@ -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 $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 $overrides Annotation overrides. + * @return array + */ + 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 $annotations Core ability annotations. + * @return array + */ + private function mcp_tool_annotations( $annotations ) { + return array( + 'readOnlyHint' => ! empty( $annotations['readonly'] ), + 'destructiveHint' => ! empty( $annotations['destructive'] ), + 'idempotentHint' => ! empty( $annotations['idempotent'] ), ); } diff --git a/src/Tools/CommentManagementTools.php b/src/Tools/CommentManagementTools.php index eec9230..583b3f9 100644 --- a/src/Tools/CommentManagementTools.php +++ b/src/Tools/CommentManagementTools.php @@ -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 ) ); } /** diff --git a/src/Tools/ContentManagementTools.php b/src/Tools/ContentManagementTools.php index 305ccb1..7a25079 100644 --- a/src/Tools/ContentManagementTools.php +++ b/src/Tools/ContentManagementTools.php @@ -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 ) ); } } diff --git a/src/Tools/GlobalStylesTools.php b/src/Tools/GlobalStylesTools.php index 0ed1804..a6de415 100644 --- a/src/Tools/GlobalStylesTools.php +++ b/src/Tools/GlobalStylesTools.php @@ -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' ); diff --git a/src/Tools/MediaTools.php b/src/Tools/MediaTools.php index 32518eb..1392cf5 100644 --- a/src/Tools/MediaTools.php +++ b/src/Tools/MediaTools.php @@ -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' ); diff --git a/src/Tools/OptionManagementTools.php b/src/Tools/OptionManagementTools.php index 88e3956..b39f262 100644 --- a/src/Tools/OptionManagementTools.php +++ b/src/Tools/OptionManagementTools.php @@ -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 ) ); } /** diff --git a/src/Tools/PluginManagementTools.php b/src/Tools/PluginManagementTools.php index adbf930..151353a 100644 --- a/src/Tools/PluginManagementTools.php +++ b/src/Tools/PluginManagementTools.php @@ -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 ) ); } /** diff --git a/src/Tools/SiteManagementTools.php b/src/Tools/SiteManagementTools.php index 1fe0c32..0405c54 100644 --- a/src/Tools/SiteManagementTools.php +++ b/src/Tools/SiteManagementTools.php @@ -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( @@ -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(); } ); diff --git a/src/Tools/TaxonomyTools.php b/src/Tools/TaxonomyTools.php index 625f77f..2efe363 100644 --- a/src/Tools/TaxonomyTools.php +++ b/src/Tools/TaxonomyTools.php @@ -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 ) ); } /** diff --git a/src/Tools/ThemeManagementTools.php b/src/Tools/ThemeManagementTools.php index 69e8b97..78c239d 100644 --- a/src/Tools/ThemeManagementTools.php +++ b/src/Tools/ThemeManagementTools.php @@ -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 ) ); } /** diff --git a/tests/run.php b/tests/run.php index c4684e8..098fd13 100644 --- a/tests/run.php +++ b/tests/run.php @@ -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.' ); @@ -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' ) );