`; disabled abilities are not * registered at all (invisible to agents). Thin wrappers — every callback * funnels straight into Agent_Toolbelt::execute(), which owns the guardrail * and the audit log. */ // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { exit; } if ( ! class_exists( 'Agent_Toolbelt_Abilities' ) ) { class Agent_Toolbelt_Abilities { /** * Ability namespace prefix (matches the plugin slug). */ const NAMESPACE_PREFIX = 'agent-toolbelt/'; /** * Registers all enabled abilities. No-op on WordPress without the * Abilities API, or when the kill switch is active. */ public static function register() { if ( ! function_exists( 'wp_register_ability' ) ) { return; } if ( Agent_Toolbelt::is_kill_switch_active() ) { return; } // get_operations() already hides disabled abilities (spec §2.6). foreach ( Agent_Toolbelt::get_operations() as $atb_slug => $atb_operation ) { self::register_one( $atb_slug, $atb_operation ); } } /** * Registers a single operation as an ability. * * @param string $atb_slug * @param ATB_Operation $atb_operation */ protected static function register_one( $atb_slug, ATB_Operation $atb_operation ) { $atb_meta = $atb_operation->describe(); // Complete the annotation triple. The MCP Adapter maps the // WordPress-format keys readonly/destructive/idempotent onto the // MCP ToolAnnotations *Hint fields but drops null/absent ones, so // a partial declaration loses the safety signal on that transport. // Declared keys always win; the fallbacks assume a read-only op is // harmless and an undeclared mutating op is not. $atb_annotations = isset( $atb_meta['annotations'] ) && is_array( $atb_meta['annotations'] ) ? $atb_meta['annotations'] : array(); $atb_is_readonly = ! empty( $atb_annotations['readonly'] ); $atb_annotations += array( 'readonly' => false, 'destructive' => ! $atb_is_readonly, 'idempotent' => $atb_is_readonly, // MCP's fourth hint has no WordPress-format name; the adapter // passes the MCP key through (McpAnnotationMapper). Default // false = closed world (this site only); the five operations // that call wordpress.org declare true (v1.6.0). 'openWorldHint' => false, ); $atb_public = (bool) apply_filters( 'agent_toolbelt_mcp_public', true, $atb_slug ); $atb_ability_meta = array( 'show_in_rest' => true, 'annotations' => $atb_annotations, // The official MCP Adapter's default server only exposes // abilities flagged meta.mcp.public; permission_callback // still gates every call on every transport. The filter // lets a site keep an ability REST-only. 'mcp' => array( 'public' => $atb_public, ), // Core 7.1 introduced meta.public (REST visibility // defaults from it) and MCP Adapter 0.6+ reads it as // the primary exposure flag, with meta.mcp.public as // the alternative (verified in adapter 0.7.0 source). // Same filter, same value on both keys (v1.5.2, F6). 'public' => $atb_public, ); // The same example the input schema carries as `examples`, for // REST readers: core's REST listing strips every keyword outside // its draft-04 list (wp_prepare_json_schema_for_client), while // meta travels verbatim (v1.6.0, plan 16 Δ3). if ( isset( $atb_meta['example_input'] ) && is_array( $atb_meta['example_input'] ) && array() !== $atb_meta['example_input'] ) { $atb_ability_meta['example_input'] = $atb_meta['example_input']; } wp_register_ability( self::NAMESPACE_PREFIX . $atb_slug, array( 'label' => self::label_for( $atb_slug ), 'description' => isset( $atb_meta['summary'] ) ? (string) $atb_meta['summary'] : '', 'category' => 'site', 'input_schema' => self::input_schema( $atb_meta ), 'output_schema' => self::output_schema(), 'execute_callback' => static function ( $atb_input = array() ) use ( $atb_slug ) { return Agent_Toolbelt::execute( $atb_slug, is_array( $atb_input ) ? $atb_input : array() ); }, 'permission_callback' => static function () use ( $atb_operation ) { return Agent_Toolbelt_Abilities::user_can_run( $atb_operation ); }, 'meta' => $atb_ability_meta, ) ); } /** * Permission gate (spec §2.5): every operation needs `manage_options`; * an operation may declare one extra required capability in * describe()['capability'] (update-plugin declares `update_plugins` — * a domain cap, not a blanket high-risk one: on hosts with * DISALLOW_FILE_MODS that cap is denied to everyone, which must block * plugin updates but not unrelated high-risk operations like * cleanup-database). Core's WP_Ability::execute() enforces this before * our callback runs. * * @param ATB_Operation $atb_operation * @return bool */ public static function user_can_run( ATB_Operation $atb_operation ) { if ( ! current_user_can( 'manage_options' ) ) { return false; } $atb_meta = $atb_operation->describe(); if ( ! empty( $atb_meta['capability'] ) && ! current_user_can( (string) $atb_meta['capability'] ) ) { return false; } return true; } /** * Builds the ability input schema from the operation's own schema, * injecting the guardrail fields: `dry_run` for any mutating (non * read-only) operation, and `confirm` for high-risk ones. * * @param array $atb_meta describe() output. * @return array */ protected static function input_schema( $atb_meta ) { $atb_schema = isset( $atb_meta['input_schema'] ) && is_array( $atb_meta['input_schema'] ) ? $atb_meta['input_schema'] : array( 'type' => 'object', 'properties' => array() ); if ( ! isset( $atb_schema['properties'] ) || ! is_array( $atb_schema['properties'] ) ) { $atb_schema['properties'] = array(); } $atb_annotations = isset( $atb_meta['annotations'] ) && is_array( $atb_meta['annotations'] ) ? $atb_meta['annotations'] : array(); $atb_readonly = ! empty( $atb_annotations['readonly'] ); $atb_high = isset( $atb_meta['risk'] ) && 'high' === $atb_meta['risk']; // Declared for EVERY ability, read-only included: the executor has // always accepted dry_run as a protocol key, and with // additionalProperties: false an undeclared key is no longer // tolerated but rejected — a cautious agent (or the Run tab, which // always sends the checkbox state) calling a read-only ability // with dry_run: true must not get a validation error (1.5.0 // regression, caught in the wp.org Playground preview). if ( ! isset( $atb_schema['properties']['dry_run'] ) ) { $atb_schema['properties']['dry_run'] = array( 'type' => 'boolean', 'description' => $atb_readonly ? 'Accepted for symmetry; read-only abilities return the same result either way.' : 'Preview the operation without changing anything.', 'default' => $atb_high, ); } if ( $atb_high && ! isset( $atb_schema['properties']['confirm'] ) ) { $atb_schema['properties']['confirm'] = array( 'type' => 'string', 'description' => 'The confirm token returned by a prior dry run. Required to execute.', ); } // Advertise the strictness the executor enforces: unknown input // keys are refused, so schema-reading agents see the exact // contract instead of discovering it from an unknown_input_key // refusal. if ( ! isset( $atb_schema['additionalProperties'] ) ) { $atb_schema['additionalProperties'] = false; } // A working input per ability (JSON Schema `examples`), from the // same example the Run tab prefills. Reaches MCP clients through // the adapter (direct tools and get-ability-info pass the schema // through raw); REST readers get it as meta.example_input (v1.6.0). if ( ! isset( $atb_schema['examples'] ) && isset( $atb_meta['example_input'] ) && is_array( $atb_meta['example_input'] ) && array() !== $atb_meta['example_input'] ) { $atb_schema['examples'] = array( $atb_meta['example_input'] ); } return $atb_schema; } /** * The common mutating-output shape (spec §2.1), shared by every ability. * * @return array */ protected static function output_schema() { return array( 'type' => 'object', 'properties' => array( 'performed' => array( 'type' => 'boolean' ), 'dry_run' => array( 'type' => 'boolean' ), 'details' => array( 'type' => 'object' ), 'warnings' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), ), ), ); } /** * Short human label for the admin/REST listing, per slug. * * @param string $atb_slug * @return string */ protected static function label_for( $atb_slug ) { $atb_labels = array( 'purge-all-caches' => __( 'Purge all caches', 'agent-toolbelt' ), 'flush-rewrite-rules' => __( 'Flush rewrite rules', 'agent-toolbelt' ), 'site-status' => __( 'Site status', 'agent-toolbelt' ), 'maintenance-mode' => __( 'Maintenance mode', 'agent-toolbelt' ), 'update-plugin' => __( 'Update plugin', 'agent-toolbelt' ), 'read-audit-log' => __( 'Read audit log', 'agent-toolbelt' ), 'regenerate-thumbnails' => __( 'Regenerate thumbnails', 'agent-toolbelt' ), 'cleanup-database' => __( 'Clean up database', 'agent-toolbelt' ), 'inspect-cron' => __( 'Inspect cron', 'agent-toolbelt' ), 'verify-checksums' => __( 'Verify checksums', 'agent-toolbelt' ), 'run-site-health-checks' => __( 'Run Site Health checks', 'agent-toolbelt' ), 'list-available-updates' => __( 'List available updates', 'agent-toolbelt' ), 'read-debug-log' => __( 'Read debug log', 'agent-toolbelt' ), 'check-site-response' => __( 'Check site response', 'agent-toolbelt' ), 'toggle-plugin' => __( 'Toggle plugin', 'agent-toolbelt' ), 'rollback-plugin' => __( 'Roll back plugin', 'agent-toolbelt' ), 'update-theme' => __( 'Update theme', 'agent-toolbelt' ), ); return isset( $atb_labels[ $atb_slug ] ) ? $atb_labels[ $atb_slug ] : $atb_slug; } } }