PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / trunk
ZIP AI – AI Website Builder & AI Agent (Beta) vtrunk
0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / abilities / abstract-ability.php

abstract-ability.php in ZIP AI – AI Website Builder & AI Agent (Beta) trunk, at inc/abilities/abstract-ability.php

581 lines 16.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Abstract Ability Class
4 *
5 * @package zip-ai
6 */
7
8 namespace ZipAI\MCP\Classes\Abilities;
9
10 use ZipAI\MCP\Classes\Core\Tool_Types;
11 use ZipAI\MCP\Classes\Core\Validator;
12 use ZipAI\MCP\Classes\Core\Response;
13 use ZipAI\MCP\Classes\Core\Event_Logger;
14 use ZipAI\MCP\Classes\Core\Utils;
15
16 // Exit if accessed directly.
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit;
19 }
20
21 /**
22 * Abstract Class Abstract_Ability
23 */
24 abstract class Abstract_Ability {
25
26 /**
27 * Ability ID (e.g. 'zipai/list-media').
28 *
29 * @var string
30 */
31 protected $id;
32
33 /**
34 * Ability Category.
35 *
36 * @var string
37 */
38 protected $category = 'zipai';
39
40 /**
41 * Ability Label.
42 *
43 * @var string
44 */
45 protected $label;
46
47 /**
48 * Ability Description.
49 *
50 * @var string
51 */
52 protected $description;
53
54 /**
55 * Required capability for this ability. Every ability MUST set it in
56 * `configure()`; this default only makes forgetting fail CLOSED. It mirrors
57 * `REST_API::INGRESS_CAPABILITY` (pinned by AbilityCapabilityPolicyTest), so
58 * an omission can never grant more than the ingress already required. It was
59 * `edit_posts` — looser than the ingress — which is DSA-18.
60 *
61 * Do NOT flatten the per-ability values to this default: core maps the
62 * plugin/theme file-mod caps to `do_not_allow` under `DISALLOW_FILE_MODS`
63 * and for non-super-admins on multisite, where `manage_options` still
64 * passes (`capabilities.php:619-642`).
65 *
66 * @var string
67 */
68 protected $capability = 'manage_options';
69
70 /**
71 * Ability Meta Data.
72 *
73 * @var array<string,mixed>
74 */
75 protected $meta = array();
76
77 /**
78 * Whether the ability is destructive or state-changing.
79 * If true, it supports dry_run by default.
80 *
81 * @var bool
82 */
83 protected $is_destructive = false;
84
85 /**
86 * Requests allowed per user per minute for this ability.
87 *
88 * The default suits interactive chat. Override it on the abilities the
89 * agent drives in bulk — a website build routes every page create, style
90 * guide push, template upsert and chrome write through ONE ability, so a
91 * single build legitimately spends hundreds of calls a minute.
92 *
93 * @var int
94 */
95 protected $rate_limit = 100;
96
97 /**
98 * Read-only sub-action names on a multiplexed ability.
99 *
100 * Many abilities expose multiple operations through a single tool via
101 * an `action` enum (e.g. `zipai/run-snippet` has `create|list|get|...`).
102 * Marking the whole tool as `is_destructive=true` is correct for the
103 * default classification but trips the writes-require-approval
104 * gate even on pure read sub-actions like `list` or `get`. Override this
105 * property on subclasses to enumerate which `action` values are safe
106 * reads. The MCP server forwards the list to the client, which reads it
107 * generically — no per-tool hardcoding on the client side.
108 *
109 * @var array<int,string>
110 */
111 protected $read_only_actions = array();
112
113 /**
114 * Tool version (semantic versioning).
115 * Increment when tool behavior or schema changes.
116 *
117 * @var string
118 */
119 protected $version = '1.0.0';
120
121 /**
122 * Required plugin slug for this tool (e.g., 'starter-templates', 'spectra', 'sureforms').
123 * Set this when the tool depends on a specific plugin being installed.
124 *
125 * @var string|null
126 */
127 protected $required_plugin = null;
128
129 /**
130 * Minimum version of the required plugin.
131 * Only used when $required_plugin is set.
132 *
133 * @var string|null
134 */
135 protected $required_plugin_version = null;
136
137 /**
138 * Admin screens where this tool should be boosted in search results.
139 * Use WordPress screen IDs or bases (e.g., 'plugins', 'edit-post', 'upload').
140 *
141 * @var list<string>
142 */
143 protected $boost_screens = array();
144
145 /**
146 * Resource identifier for read-first-write pattern matching.
147 * Tools that operate on the same resource should share the same identifier.
148 * E.g., 'site-setting', 'posts', 'media', 'menus', 'plugins', 'themes'.
149 *
150 * @var string|null
151 */
152 protected $resource = null;
153
154 /**
155 * Constructor.
156 */
157 public function __construct() {
158 $this->configure();
159 }
160
161 /**
162 * Configure the ability (set ID, label, description, etc.).
163 *
164 * @return void
165 */
166 abstract public function configure();
167
168 /**
169 * Get the input schema for the ability.
170 *
171 * @return array<string,mixed>
172 */
173 abstract public function get_input_schema();
174
175 /**
176 * Get the output schema for the ability.
177 *
178 * Default returns an empty array (no output validation). Override in child
179 * classes to declare a JSON Schema for the tool's successful response shape.
180 * When non-empty, WordPress core's Abilities API validates tool output
181 * against this schema after execute().
182 *
183 * @since 0.0.5
184 * @return array<string,mixed>
185 */
186 public function get_output_schema() {
187 return array();
188 }
189
190 /**
191 * Execute the ability.
192 *
193 * @param array<string,mixed> $args Input arguments.
194 * @return array<string,mixed> Result array.
195 */
196 abstract public function execute( $args );
197
198 /**
199 * Get the final input schema, including any automatically added parameters.
200 *
201 * @return array<string,mixed>
202 */
203 public function get_final_input_schema() {
204 $schema = $this->get_input_schema();
205
206 if ( ! isset( $schema['properties'] ) || ! is_array( $schema['properties'] ) ) {
207 $schema['properties'] = array();
208 }
209
210 if ( $this->is_destructive && ! isset( $schema['properties']['dry_run'] ) ) {
211 $schema['properties']['dry_run'] = array(
212 'type' => 'boolean',
213 'description' => 'If true, will only simulate the changes without applying them.',
214 'default' => false,
215 );
216 }
217 return $schema;
218 }
219
220 /**
221 * Get the required capability.
222 *
223 * @return string
224 */
225 public function get_capability() {
226 return $this->capability;
227 }
228
229 /**
230 * Handle execution of the ability with centralized validation.
231 *
232 * @param array<string,mixed> $args Input arguments.
233 * @return array<string,mixed> Result array.
234 */
235 public function handle_execute( $args ) {
236 $start_time = microtime( true );
237 $start_mem = memory_get_usage();
238
239 try {
240 // Rate limiting — $rate_limit req/min per user+ability. Skipped only under
241 // the ZIPAI_TESTING constant (CLI batch imports). The dev-time `false &&`
242 // short-circuit (ZIPAI_RATE_LIMIT_DISABLED) was removed before ship.
243 //
244 // The window is FIXED: the transient carries its own start time and the
245 // TTL is whatever is LEFT of the minute. Storing a bare counter with a
246 // flat 60s TTL made the window slide — set_transient refreshes the
247 // timeout on every call, so continuous traffic (a website build fires
248 // REST writes back-to-back) renewed the transient forever and the
249 // counter became a per-SESSION budget that only cleared after 60s of
250 // total silence.
251 if ( ! defined( 'ZIPAI_TESTING' ) ) {
252 $user_id = get_current_user_id();
253 $rate_key = 'zip_ai_rate_' . $user_id . '_' . $this->id;
254 $now = time();
255 $stored = get_transient( $rate_key );
256
257 // The window is read into typed LOCALS rather than cast at each
258 // use. get_transient() returns mixed, and `isset` proves only
259 // that the keys exist, not that they hold numbers — so casting
260 // off the raw value is unprovable at PHPStan level 10.
261 //
262 // `is_numeric` keeps the tolerance this guard was written for: a
263 // bare-integer transient from the pre-window version fails
264 // is_array and simply starts a fresh window, and an object cache
265 // that returns numeric strings still counts correctly.
266 $start = $now;
267 $count = 0;
268 if (
269 is_array( $stored )
270 && isset( $stored['start'], $stored['count'] )
271 && is_numeric( $stored['start'] )
272 && is_numeric( $stored['count'] )
273 && ( $now - (int) $stored['start'] ) < 60
274 ) {
275 $start = (int) $stored['start'];
276 $count = (int) $stored['count'];
277 }
278
279 if ( $count >= $this->rate_limit ) {
280 return Response::error( 'Rate limit exceeded. Please try again in a minute.' );
281 }
282 ++$count;
283 set_transient(
284 $rate_key,
285 array(
286 'start' => $start,
287 'count' => $count,
288 ),
289 max( 1, 60 - ( $now - $start ) )
290 );
291 }
292
293 // Validate and sanitize input against the final schema.
294 $validated_args = Validator::validate( $this->get_final_input_schema(), $args );
295
296 if ( is_wp_error( $validated_args ) ) {
297 return Response::from_wp_error( $validated_args );
298 }
299
300 // Handle dry_run if requested.
301 if ( $this->is_destructive && ! empty( $validated_args['dry_run'] ) ) {
302 $response = $this->dry_run( $validated_args );
303 $performance = array(
304 'execution_time' => round( ( microtime( true ) - $start_time ) * 1000, 2 ) . 'ms',
305 'memory_peak' => round( ( memory_get_peak_usage() - $start_mem ) / 1024, 2 ) . 'KB',
306 );
307 Event_Logger::log( $this->id, $args, $response, $performance );
308 return $response;
309 }
310
311 // Call the actual tool execution logic.
312 $response = $this->execute( $validated_args );
313
314 // Capture metrics.
315 $performance = array(
316 'execution_time' => round( ( microtime( true ) - $start_time ) * 1000, 2 ) . 'ms',
317 'memory_peak' => round( ( memory_get_peak_usage() - $start_mem ) / 1024, 2 ) . 'KB',
318 );
319
320 // Log the execution.
321 Event_Logger::log( $this->id, $args, $response, $performance );
322
323 return $response;
324
325 } catch ( \Exception $e ) {
326 // Never send exception text to the client — this wrapper catches EVERY
327 // ability, so a raw message leaks DB errors, file paths, class names.
328 // Keep the detail server-side (WP_DEBUG) and return a static message.
329 $this->log_execution_throwable( $e );
330 $response = Response::error( 'An unexpected error occurred while running this action. Please try again.' );
331 $performance = array(
332 'execution_time' => round( ( microtime( true ) - $start_time ) * 1000, 2 ) . 'ms',
333 'memory_peak' => round( ( memory_get_peak_usage() - $start_mem ) / 1024, 2 ) . 'KB',
334 );
335 Event_Logger::log( $this->id, $args, $response, $performance );
336 return $response;
337 } catch ( \Error $e ) {
338 $this->log_execution_throwable( $e );
339 $response = Response::error( 'A system error occurred while running this action. Please try again.' );
340 $performance = array(
341 'execution_time' => round( ( microtime( true ) - $start_time ) * 1000, 2 ) . 'ms',
342 'memory_peak' => round( ( memory_get_peak_usage() - $start_mem ) / 1024, 2 ) . 'KB',
343 );
344 Event_Logger::log( $this->id, $args, $response, $performance );
345 return $response;
346 }
347 }
348
349 /**
350 * Record an ability execution failure server-side without leaking the raw
351 * message to the client. WP_DEBUG-gated so production logs stay quiet.
352 *
353 * @param \Throwable $e The caught exception or error.
354 * @return void
355 */
356 private function log_execution_throwable( $e ) {
357 Utils::debug_log( sprintf( 'Ability "%s" failed', $this->id ), $e->getMessage() );
358 }
359
360 /**
361 * Default dry run implementation.
362 * Abilities should override this if they support is_destructive.
363 *
364 * The default is honest about its limits: claiming "dry run completed
365 * successfully" fabricated a preview that inspected nothing, and callers
366 * treated it as evidence the real run was safe.
367 *
368 * @param array<string,mixed> $args Input arguments.
369 * @return array<string,mixed> Result array.
370 */
371 protected function dry_run( $args ) {
372 return Response::success(
373 'This ability has no dry-run preview, nothing was inspected and no changes were made. Treat the real run as unpreviewed.',
374 array(
375 'dry_run' => true,
376 'preview_available' => false,
377 )
378 );
379 }
380
381 /**
382 * Get usage examples.
383 *
384 * @return array<int,mixed>
385 */
386 public function get_examples() {
387 return array();
388 }
389
390 /**
391 * Get tool type (read, write, list, search, action, delete).
392 * Override in child classes for a specific type.
393 *
394 * Fail-SAFE default: a destructive / state-changing ability that does NOT
395 * override this is classified as a mutating ACTION (not READ), so it can
396 * never silently skip the approval gate by omission. Previously the default
397 * was READ, so a new mutating ability that forgot to override
398 * get_tool_type() was treated as read-only and bypassed approval entirely.
399 * Read abilities keep the READ default; abilities that override this win.
400 *
401 * @return string
402 */
403 public function get_tool_type() {
404 return $this->is_destructive ? Tool_Types::ACTION : Tool_Types::READ;
405 }
406
407 /**
408 * MCP tool annotations for this ability.
409 *
410 * DERIVED from what each ability already declares — its `get_tool_type()` and
411 * its `$is_destructive` flag. Nothing new to maintain per ability, and no
412 * central table to drift out of sync with the files it describes.
413 *
414 * Clients read these to decide what needs a human approval prompt. Override
415 * only where the tool type does not capture the consequences — a WRITE that
416 * REPLACES existing site design is destructive even though writing normally
417 * is not.
418 *
419 * @return array{readonly: bool, destructive: bool, idempotent: bool}
420 */
421 public function get_annotations() {
422 $type = $this->get_tool_type();
423 $is_read = in_array( $type, array( Tool_Types::READ, Tool_Types::LIST, Tool_Types::SEARCH ), true );
424
425 return array(
426 'readonly' => $is_read,
427 // DELETE destroys by definition; anything already flagged destructive
428 // for the dry-run gate is destructive here too — one declaration,
429 // both consumers.
430 'destructive' => $this->is_destructive || Tool_Types::DELETE === $type,
431 // A read can be repeated safely. A write makes no such claim unless
432 // the ability says so.
433 'idempotent' => $is_read,
434 );
435 }
436
437 /**
438 * Get the read-only sub-action allowlist for multiplexed abilities. Default
439 * is the protected `$read_only_actions` array (empty unless overridden).
440 *
441 * @return array<int,string>
442 */
443 public function get_read_only_actions() {
444 return array_values( $this->read_only_actions );
445 }
446
447 /**
448 * Get API endpoint configuration.
449 *
450 * @return array{url:string,method:string,auth:string}
451 */
452 public function get_api_endpoint() {
453 return array(
454 'url' => rest_url( 'mcp/v1/tools/call' ),
455 'method' => 'POST',
456 'auth' => 'bearer',
457 );
458 }
459
460 /**
461 * Check permissions.
462 *
463 * @param \WP_REST_Request $request REST Request.
464 * @return bool|\WP_Error
465 */
466 public function check_permission( $request ) {
467 return current_user_can( $this->capability );
468 }
469
470 /**
471 * Get the ability ID.
472 *
473 * @return string
474 */
475 public function get_id() {
476 return $this->id;
477 }
478
479 /**
480 * Get the ability label.
481 *
482 * @return string
483 */
484 public function get_label() {
485 return $this->label;
486 }
487
488 /**
489 * Get the ability description.
490 *
491 * @return string
492 */
493 public function get_description() {
494 return $this->description;
495 }
496
497 /**
498 * Get the category.
499 *
500 * @return string
501 */
502 public function get_category() {
503 return $this->category;
504 }
505
506 /**
507 * Get the meta data.
508 *
509 * @return array<string,mixed>
510 */
511 public function get_meta_data() {
512 return $this->meta;
513 }
514
515 /**
516 * Get the tool version.
517 *
518 * @return string
519 */
520 public function get_version() {
521 return $this->version;
522 }
523
524 /**
525 * Get the required plugin slug.
526 *
527 * @return string|null
528 */
529 public function get_required_plugin() {
530 return $this->required_plugin;
531 }
532
533 /**
534 * Get the required plugin version.
535 *
536 * @return string|null
537 */
538 public function get_required_plugin_version() {
539 return $this->required_plugin_version;
540 }
541
542 /**
543 * Get admin screens where this tool should be boosted.
544 *
545 * Override this method in a child class to set boost screens at runtime.
546 * You can also set the $boost_screens property in configure().
547 *
548 * Examples of screen values:
549 * - 'plugins' - Plugins list screen
550 * - 'plugin-install' - Add new plugin screen
551 * - 'edit-post' - Posts list screen
552 * - 'post' - Post editor screen
553 * - 'edit-page' - Pages list screen
554 * - 'page' - Page editor screen
555 * - 'upload' - Media library screen
556 * - 'themes' - Themes screen
557 * - 'site-editor' - Site editor screen
558 * - 'nav-menus' - Menus screen
559 * - 'widgets' - Widgets screen
560 * - 'users' - Users list screen
561 * - 'options-general' - General settings screen
562 *
563 * @return list<string> Array of WordPress admin screen IDs/bases.
564 */
565 public function get_boost_screens() {
566 return $this->boost_screens;
567 }
568
569 /**
570 * Get the resource identifier for read-first-write pattern matching.
571 *
572 * Tools that operate on the same resource should return the same identifier.
573 * This allows the context fulfillment service to match read tools with write tools.
574 *
575 * @return string|null Resource identifier or null if not set.
576 */
577 public function get_resource() {
578 return $this->resource;
579 }
580 }
581