*/ protected $meta = array(); /** * Whether the ability is destructive or state-changing. * If true, it supports dry_run by default. * * @var bool */ protected $is_destructive = false; /** * Requests allowed per user per minute for this ability. * * The default suits interactive chat. Override it on the abilities the * agent drives in bulk — a website build routes every page create, style * guide push, template upsert and chrome write through ONE ability, so a * single build legitimately spends hundreds of calls a minute. * * @var int */ protected $rate_limit = 100; /** * Read-only sub-action names on a multiplexed ability. * * Many abilities expose multiple operations through a single tool via * an `action` enum (e.g. `zipai/run-snippet` has `create|list|get|...`). * Marking the whole tool as `is_destructive=true` is correct for the * default classification but trips the writes-require-approval * gate even on pure read sub-actions like `list` or `get`. Override this * property on subclasses to enumerate which `action` values are safe * reads. The MCP server forwards the list to the client, which reads it * generically — no per-tool hardcoding on the client side. * * @var array */ protected $read_only_actions = array(); /** * Tool version (semantic versioning). * Increment when tool behavior or schema changes. * * @var string */ protected $version = '1.0.0'; /** * Required plugin slug for this tool (e.g., 'starter-templates', 'spectra', 'sureforms'). * Set this when the tool depends on a specific plugin being installed. * * @var string|null */ protected $required_plugin = null; /** * Minimum version of the required plugin. * Only used when $required_plugin is set. * * @var string|null */ protected $required_plugin_version = null; /** * Admin screens where this tool should be boosted in search results. * Use WordPress screen IDs or bases (e.g., 'plugins', 'edit-post', 'upload'). * * @var list */ protected $boost_screens = array(); /** * Resource identifier for read-first-write pattern matching. * Tools that operate on the same resource should share the same identifier. * E.g., 'site-setting', 'posts', 'media', 'menus', 'plugins', 'themes'. * * @var string|null */ protected $resource = null; /** * Constructor. */ public function __construct() { $this->configure(); } /** * Configure the ability (set ID, label, description, etc.). * * @return void */ abstract public function configure(); /** * Get the input schema for the ability. * * @return array */ abstract public function get_input_schema(); /** * Get the output schema for the ability. * * Default returns an empty array (no output validation). Override in child * classes to declare a JSON Schema for the tool's successful response shape. * When non-empty, WordPress core's Abilities API validates tool output * against this schema after execute(). * * @since 0.0.5 * @return array */ public function get_output_schema() { return array(); } /** * Execute the ability. * * @param array $args Input arguments. * @return array Result array. */ abstract public function execute( $args ); /** * Get the final input schema, including any automatically added parameters. * * @return array */ public function get_final_input_schema() { $schema = $this->get_input_schema(); if ( ! isset( $schema['properties'] ) || ! is_array( $schema['properties'] ) ) { $schema['properties'] = array(); } if ( $this->is_destructive && ! isset( $schema['properties']['dry_run'] ) ) { $schema['properties']['dry_run'] = array( 'type' => 'boolean', 'description' => 'If true, will only simulate the changes without applying them.', 'default' => false, ); } return $schema; } /** * Get the required capability. * * @return string */ public function get_capability() { return $this->capability; } /** * Handle execution of the ability with centralized validation. * * @param array $args Input arguments. * @return array Result array. */ public function handle_execute( $args ) { $start_time = microtime( true ); $start_mem = memory_get_usage(); try { // Rate limiting — $rate_limit req/min per user+ability. Skipped only under // the ZIPAI_TESTING constant (CLI batch imports). The dev-time `false &&` // short-circuit (ZIPAI_RATE_LIMIT_DISABLED) was removed before ship. // // The window is FIXED: the transient carries its own start time and the // TTL is whatever is LEFT of the minute. Storing a bare counter with a // flat 60s TTL made the window slide — set_transient refreshes the // timeout on every call, so continuous traffic (a website build fires // REST writes back-to-back) renewed the transient forever and the // counter became a per-SESSION budget that only cleared after 60s of // total silence. if ( ! defined( 'ZIPAI_TESTING' ) ) { $user_id = get_current_user_id(); $rate_key = 'zip_ai_rate_' . $user_id . '_' . $this->id; $now = time(); $stored = get_transient( $rate_key ); // The window is read into typed LOCALS rather than cast at each // use. get_transient() returns mixed, and `isset` proves only // that the keys exist, not that they hold numbers — so casting // off the raw value is unprovable at PHPStan level 10. // // `is_numeric` keeps the tolerance this guard was written for: a // bare-integer transient from the pre-window version fails // is_array and simply starts a fresh window, and an object cache // that returns numeric strings still counts correctly. $start = $now; $count = 0; if ( is_array( $stored ) && isset( $stored['start'], $stored['count'] ) && is_numeric( $stored['start'] ) && is_numeric( $stored['count'] ) && ( $now - (int) $stored['start'] ) < 60 ) { $start = (int) $stored['start']; $count = (int) $stored['count']; } if ( $count >= $this->rate_limit ) { return Response::error( 'Rate limit exceeded. Please try again in a minute.' ); } ++$count; set_transient( $rate_key, array( 'start' => $start, 'count' => $count, ), max( 1, 60 - ( $now - $start ) ) ); } // Validate and sanitize input against the final schema. $validated_args = Validator::validate( $this->get_final_input_schema(), $args ); if ( is_wp_error( $validated_args ) ) { return Response::from_wp_error( $validated_args ); } // Handle dry_run if requested. if ( $this->is_destructive && ! empty( $validated_args['dry_run'] ) ) { $response = $this->dry_run( $validated_args ); $performance = array( 'execution_time' => round( ( microtime( true ) - $start_time ) * 1000, 2 ) . 'ms', 'memory_peak' => round( ( memory_get_peak_usage() - $start_mem ) / 1024, 2 ) . 'KB', ); Event_Logger::log( $this->id, $args, $response, $performance ); return $response; } // Call the actual tool execution logic. $response = $this->execute( $validated_args ); // Capture metrics. $performance = array( 'execution_time' => round( ( microtime( true ) - $start_time ) * 1000, 2 ) . 'ms', 'memory_peak' => round( ( memory_get_peak_usage() - $start_mem ) / 1024, 2 ) . 'KB', ); // Log the execution. Event_Logger::log( $this->id, $args, $response, $performance ); return $response; } catch ( \Exception $e ) { // Never send exception text to the client — this wrapper catches EVERY // ability, so a raw message leaks DB errors, file paths, class names. // Keep the detail server-side (WP_DEBUG) and return a static message. $this->log_execution_throwable( $e ); $response = Response::error( 'An unexpected error occurred while running this action. Please try again.' ); $performance = array( 'execution_time' => round( ( microtime( true ) - $start_time ) * 1000, 2 ) . 'ms', 'memory_peak' => round( ( memory_get_peak_usage() - $start_mem ) / 1024, 2 ) . 'KB', ); Event_Logger::log( $this->id, $args, $response, $performance ); return $response; } catch ( \Error $e ) { $this->log_execution_throwable( $e ); $response = Response::error( 'A system error occurred while running this action. Please try again.' ); $performance = array( 'execution_time' => round( ( microtime( true ) - $start_time ) * 1000, 2 ) . 'ms', 'memory_peak' => round( ( memory_get_peak_usage() - $start_mem ) / 1024, 2 ) . 'KB', ); Event_Logger::log( $this->id, $args, $response, $performance ); return $response; } } /** * Record an ability execution failure server-side without leaking the raw * message to the client. WP_DEBUG-gated so production logs stay quiet. * * @param \Throwable $e The caught exception or error. * @return void */ private function log_execution_throwable( $e ) { Utils::debug_log( sprintf( 'Ability "%s" failed', $this->id ), $e->getMessage() ); } /** * Default dry run implementation. * Abilities should override this if they support is_destructive. * * The default is honest about its limits: claiming "dry run completed * successfully" fabricated a preview that inspected nothing, and callers * treated it as evidence the real run was safe. * * @param array $args Input arguments. * @return array Result array. */ protected function dry_run( $args ) { return Response::success( 'This ability has no dry-run preview, nothing was inspected and no changes were made. Treat the real run as unpreviewed.', array( 'dry_run' => true, 'preview_available' => false, ) ); } /** * Get usage examples. * * @return array */ public function get_examples() { return array(); } /** * Get tool type (read, write, list, search, action, delete). * Override in child classes for a specific type. * * Fail-SAFE default: a destructive / state-changing ability that does NOT * override this is classified as a mutating ACTION (not READ), so it can * never silently skip the approval gate by omission. Previously the default * was READ, so a new mutating ability that forgot to override * get_tool_type() was treated as read-only and bypassed approval entirely. * Read abilities keep the READ default; abilities that override this win. * * @return string */ public function get_tool_type() { return $this->is_destructive ? Tool_Types::ACTION : Tool_Types::READ; } /** * MCP tool annotations for this ability. * * DERIVED from what each ability already declares — its `get_tool_type()` and * its `$is_destructive` flag. Nothing new to maintain per ability, and no * central table to drift out of sync with the files it describes. * * Clients read these to decide what needs a human approval prompt. Override * only where the tool type does not capture the consequences — a WRITE that * REPLACES existing site design is destructive even though writing normally * is not. * * @return array{readonly: bool, destructive: bool, idempotent: bool} */ public function get_annotations() { $type = $this->get_tool_type(); $is_read = in_array( $type, array( Tool_Types::READ, Tool_Types::LIST, Tool_Types::SEARCH ), true ); return array( 'readonly' => $is_read, // DELETE destroys by definition; anything already flagged destructive // for the dry-run gate is destructive here too — one declaration, // both consumers. 'destructive' => $this->is_destructive || Tool_Types::DELETE === $type, // A read can be repeated safely. A write makes no such claim unless // the ability says so. 'idempotent' => $is_read, ); } /** * Get the read-only sub-action allowlist for multiplexed abilities. Default * is the protected `$read_only_actions` array (empty unless overridden). * * @return array */ public function get_read_only_actions() { return array_values( $this->read_only_actions ); } /** * Get API endpoint configuration. * * @return array{url:string,method:string,auth:string} */ public function get_api_endpoint() { return array( 'url' => rest_url( 'mcp/v1/tools/call' ), 'method' => 'POST', 'auth' => 'bearer', ); } /** * Check permissions. * * @param \WP_REST_Request $request REST Request. * @return bool|\WP_Error */ public function check_permission( $request ) { return current_user_can( $this->capability ); } /** * Get the ability ID. * * @return string */ public function get_id() { return $this->id; } /** * Get the ability label. * * @return string */ public function get_label() { return $this->label; } /** * Get the ability description. * * @return string */ public function get_description() { return $this->description; } /** * Get the category. * * @return string */ public function get_category() { return $this->category; } /** * Get the meta data. * * @return array */ public function get_meta_data() { return $this->meta; } /** * Get the tool version. * * @return string */ public function get_version() { return $this->version; } /** * Get the required plugin slug. * * @return string|null */ public function get_required_plugin() { return $this->required_plugin; } /** * Get the required plugin version. * * @return string|null */ public function get_required_plugin_version() { return $this->required_plugin_version; } /** * Get admin screens where this tool should be boosted. * * Override this method in a child class to set boost screens at runtime. * You can also set the $boost_screens property in configure(). * * Examples of screen values: * - 'plugins' - Plugins list screen * - 'plugin-install' - Add new plugin screen * - 'edit-post' - Posts list screen * - 'post' - Post editor screen * - 'edit-page' - Pages list screen * - 'page' - Page editor screen * - 'upload' - Media library screen * - 'themes' - Themes screen * - 'site-editor' - Site editor screen * - 'nav-menus' - Menus screen * - 'widgets' - Widgets screen * - 'users' - Users list screen * - 'options-general' - General settings screen * * @return list Array of WordPress admin screen IDs/bases. */ public function get_boost_screens() { return $this->boost_screens; } /** * Get the resource identifier for read-first-write pattern matching. * * Tools that operate on the same resource should return the same identifier. * This allows the context fulfillment service to match read tools with write tools. * * @return string|null Resource identifier or null if not set. */ public function get_resource() { return $this->resource; } }