PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.0
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / modules / Mcp / Mcp_Tools.php

Mcp_Tools.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.1.0, at includes/modules/Mcp/Mcp_Tools.php

642 lines 20.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP tool registry — the single source of truth for the tools xSpeed
4 * exposes to AI assistants.
5 *
6 * Each tool declares an MCP-style descriptor (name, description, JSON
7 * Schema inputSchema) and a handler that runs against the Free engine.
8 * Consumed by BOTH:
9 * - Mcp_Server (the per-site JSON-RPC endpoint at /xspeed/mcp), and
10 * - McpModule's REST tool routes (the optional hosted-broker path),
11 * so the two transports can never drift.
12 *
13 * Handlers take an associative array of already-decoded arguments and
14 * return either a plain array (serialized to JSON in the MCP result) or
15 * a WP_Error (surfaced as an MCP tool error).
16 *
17 * The plugin adds ZERO cache logic here — every handler is a thin proxy
18 * to Cache / Settings / Settings_Manager / Server / Admin / Pro_Audit /
19 * Cache_Benchmark.
20 *
21 * @package XSpeed
22 */
23
24 declare(strict_types=1);
25
26 namespace XSpeed\Modules\Mcp;
27
28 use XSpeed\Cache;
29 use XSpeed\Server;
30 use XSpeed\Admin;
31 use XSpeed\Settings;
32 use XSpeed\Settings_Manager;
33 use XSpeed\Pro_Audit;
34 use XSpeed\Cache_Benchmark;
35
36 defined( 'ABSPATH' ) || exit;
37
38 final class Mcp_Tools {
39
40 /** Valid cache purge types. */
41 public const PURGE_TYPES = array( 'all', 'page', 'assets', 'object', 'rest' );
42
43 /**
44 * Per-call read-only override. Null means "defer to the pairing token's
45 * scope" (the JSON-RPC path that predates OAuth). true/false is set by
46 * Mcp_Server when an OAuth access token (with its own scope) authorized
47 * the request, so a read-only OAuth grant is enforced even though the
48 * pairing token may be read-write (or absent).
49 *
50 * @var bool|null
51 */
52 private static $read_only_override = null;
53
54 /**
55 * Set the active credential's read-only state for the current request.
56 * Passing null clears the override (back to the pairing-token default).
57 *
58 * @param bool|null $read_only Whether the active credential is read-only.
59 */
60 public static function set_read_only_override( ?bool $read_only ): void {
61 self::$read_only_override = $read_only;
62 }
63
64 /**
65 * Whether the active MCP credential is limited to read-only tools. Uses
66 * the per-call override when set, else the pairing token's scope.
67 */
68 private static function is_read_only(): bool {
69 if ( null !== self::$read_only_override ) {
70 return self::$read_only_override;
71 }
72 return Mcp_Pairing::is_read_only();
73 }
74
75 /**
76 * Full tool catalog: name => descriptor. `handler` is a callable
77 * ( array $args ) : array|\WP_Error. `write` marks tools that mutate
78 * state (used for read-only scope enforcement).
79 *
80 * @return array<string, array{description:string, inputSchema:array, handler:callable, write:bool}>
81 */
82 public static function catalog(): array {
83 $catalog = array(
84 'get_cache_status' => array(
85 'description' => 'Get cache status for this WordPress site: whether caching is enabled, cache stats (cached pages, size, hit ratio, last purge), and the detected web server.',
86 'inputSchema' => self::object_schema( array(), array() ),
87 'write' => false,
88 'handler' => array( self::class, 'get_cache_status' ),
89 ),
90 'list_modules' => array(
91 'description' => 'List all xSpeed modules (free and Pro) with their settings schema and status.',
92 'inputSchema' => self::object_schema( array(), array() ),
93 'write' => false,
94 'handler' => array( self::class, 'list_modules' ),
95 ),
96 'run_benchmark' => array(
97 'description' => 'Run a before/after cache benchmark on the home page and return the timings.',
98 'inputSchema' => self::object_schema( array(), array() ),
99 'write' => false,
100 'handler' => array( self::class, 'run_benchmark' ),
101 ),
102 'get_pro_audit' => array(
103 'description' => 'Personalized list of Pro features that would benefit THIS site, from its current settings and cache stats.',
104 'inputSchema' => self::object_schema( array(), array() ),
105 'write' => false,
106 'handler' => array( self::class, 'get_pro_audit' ),
107 ),
108 'purge_cache' => array(
109 'description' => 'Purge the site cache. "type" selects what to purge: all, page, assets, object, or rest. Defaults to all.',
110 'inputSchema' => self::object_schema(
111 array(
112 'type' => array(
113 'type' => 'string',
114 'enum' => self::PURGE_TYPES,
115 'description' => 'What to purge. Defaults to "all".',
116 ),
117 ),
118 array()
119 ),
120 'write' => true,
121 'handler' => array( self::class, 'purge_cache' ),
122 ),
123 'toggle_cache' => array(
124 'description' => 'Enable or disable page caching. Installs/removes the cache drop-in and WP_CACHE constant as needed.',
125 'inputSchema' => self::object_schema(
126 array(
127 'enabled' => array(
128 'type' => 'boolean',
129 'description' => 'true to enable caching, false to disable.',
130 ),
131 ),
132 array( 'enabled' )
133 ),
134 'write' => true,
135 'handler' => array( self::class, 'toggle_cache' ),
136 ),
137 'get_settings' => array(
138 'description' => 'Read the settings for a given xSpeed module (e.g. "minify", "gzip"). Returns schema-validated values.',
139 'inputSchema' => self::object_schema(
140 array(
141 'module' => array(
142 'type' => 'string',
143 'description' => 'The module slug, e.g. "minify".',
144 ),
145 ),
146 array( 'module' )
147 ),
148 'write' => false,
149 'handler' => array( self::class, 'get_settings' ),
150 ),
151 'update_settings' => array(
152 'description' => 'Update settings for a given xSpeed module. "values" is an object of setting keys to new values; unknown keys are stripped and invalid values rejected by the module schema.',
153 'inputSchema' => self::object_schema(
154 array(
155 'module' => array(
156 'type' => 'string',
157 'description' => 'The module slug, e.g. "minify".',
158 ),
159 'values' => array(
160 'type' => 'object',
161 'description' => 'Map of setting keys to new values.',
162 ),
163 ),
164 array( 'module', 'values' )
165 ),
166 'write' => true,
167 'handler' => array( self::class, 'update_settings' ),
168 ),
169 // --- Promoted high-value actions: dedicated typed tools so the AI
170 // calls them directly (no run_command hop). Each is a thin wrapper
171 // over Cli_Bridge, so Free tools can drive Pro actions (psi, ccss)
172 // without a cross-repo class reference, and none can drift from the
173 // CLI. ---
174 'purge_cloudflare' => array(
175 'description' => 'Purge the Cloudflare edge cache for this site (requires Cloudflare connected in the Cloudflare module).',
176 'inputSchema' => self::object_schema( array(), array() ),
177 'write' => true,
178 'handler' => array( self::class, 'purge_cloudflare' ),
179 ),
180 'scan_database' => array(
181 'description' => 'Scan the database for bloat (post revisions, auto-drafts, trashed posts, spam comments, expired transients, orphaned meta) without deleting anything.',
182 'inputSchema' => self::object_schema( array(), array() ),
183 'write' => false,
184 'handler' => array( self::class, 'scan_database' ),
185 ),
186 'clean_database' => array(
187 'description' => 'Clean database bloat. Removes the categories currently enabled in the Database module settings. Destructive — run scan_database first to preview.',
188 'inputSchema' => self::object_schema( array(), array() ),
189 'write' => true,
190 'handler' => array( self::class, 'clean_database' ),
191 ),
192 'flush_object_cache' => array(
193 'description' => 'Flush the persistent object cache (Redis / Memcached), if enabled.',
194 'inputSchema' => self::object_schema( array(), array() ),
195 'write' => true,
196 'handler' => array( self::class, 'flush_object_cache' ),
197 ),
198 'start_preloader' => array(
199 'description' => 'Start the cache preloader — crawls the sitemap to warm the page cache in the background.',
200 'inputSchema' => self::object_schema( array(), array() ),
201 'write' => true,
202 'handler' => array( self::class, 'start_preloader' ),
203 ),
204 'run_pagespeed' => array(
205 'description' => 'Run a Google PageSpeed Insights audit on a URL (Pro). Returns the performance score + Core Web Vitals. Defaults to the site home page, mobile strategy.',
206 'inputSchema' => self::object_schema(
207 array(
208 'url' => array(
209 'type' => 'string',
210 'description' => 'URL to audit. Defaults to the site home page.',
211 ),
212 'strategy' => array(
213 'type' => 'string',
214 'enum' => array( 'mobile', 'desktop' ),
215 'description' => 'Audit strategy. Defaults to "mobile".',
216 ),
217 ),
218 array()
219 ),
220 'write' => false,
221 'handler' => array( self::class, 'run_pagespeed' ),
222 ),
223 'generate_critical_css' => array(
224 'description' => 'Generate above-the-fold Critical CSS for the site (Pro). Calls the external generator and stores the result.',
225 'inputSchema' => self::object_schema( array(), array() ),
226 'write' => true,
227 'handler' => array( self::class, 'generate_critical_css' ),
228 ),
229 'list_commands' => array(
230 'description' => 'List every xSpeed command that run_command can invoke (name, description, module, options). Use this to discover the full action surface beyond the curated + dedicated tools.',
231 'inputSchema' => self::object_schema( array(), array() ),
232 'write' => false,
233 'handler' => array( self::class, 'list_commands' ),
234 ),
235 'run_command' => array(
236 'description' => 'Run any xSpeed command — the full CLI surface (~50 commands across every module: cache, cloudflare, database, critical/unused CSS, pagespeed, images, migration, preloader, object cache, analytics, RUM, smart-* and more). Call list_commands first to discover names + options. Examples: run_command("cloudflare purge"), run_command("database clean"), run_command("psi", {}, {"url":"https://site.com","strategy":"mobile"}).',
237 'inputSchema' => self::object_schema(
238 array(
239 'command' => array(
240 'type' => 'string',
241 'description' => 'Command name, e.g. "cloudflare purge" or "database scan" (the "xspeed " prefix is optional).',
242 ),
243 'args' => array(
244 'type' => 'array',
245 'description' => 'Positional arguments, if the command takes any.',
246 'items' => array( 'type' => 'string' ),
247 ),
248 'options' => array(
249 'type' => 'object',
250 'description' => 'Named options / flags, e.g. { "url": "https://site.com", "strategy": "mobile", "force": true }.',
251 ),
252 ),
253 array( 'command' )
254 ),
255 'write' => true,
256 'handler' => array( self::class, 'run_command' ),
257 ),
258 );
259
260 // Dedicated tools that wrap a command only present when a given
261 // module is active (e.g. Pro): drop them if the command isn't
262 // registered, so we never advertise a tool that always fails. The
263 // action stays reachable via run_command if the command exists.
264 $conditional = array(
265 'run_pagespeed' => 'xspeed psi',
266 'generate_critical_css' => 'xspeed ccss',
267 'purge_cloudflare' => 'xspeed cf',
268 'flush_object_cache' => 'xspeed objcache',
269 'start_preloader' => 'xspeed preloader',
270 'scan_database' => 'xspeed db',
271 'clean_database' => 'xspeed db',
272 );
273 $commands = Cli_Bridge::commands();
274 foreach ( $conditional as $tool => $command ) {
275 if ( ! isset( $commands[ $command ] ) ) {
276 unset( $catalog[ $tool ] );
277 }
278 }
279
280 return $catalog;
281 }
282
283 /**
284 * The tool list in MCP `tools/list` shape.
285 *
286 * @return array<int, array{name:string, description:string, inputSchema:array}>
287 */
288 public static function list(): array {
289 $out = array();
290 foreach ( self::catalog() as $name => $spec ) {
291 $out[] = array(
292 'name' => $name,
293 'description' => $spec['description'],
294 'inputSchema' => $spec['inputSchema'],
295 );
296 }
297 return $out;
298 }
299
300 /**
301 * Invoke a tool by name with decoded arguments.
302 *
303 * @param string $name Tool name.
304 * @param array $args Decoded arguments.
305 * @return array|\WP_Error Result payload or error.
306 */
307 public static function invoke( string $name, array $args ) {
308 $catalog = self::catalog();
309 if ( ! isset( $catalog[ $name ] ) ) {
310 return new \WP_Error(
311 'xspeed_mcp_unknown_tool',
312 sprintf(
313 /* translators: %s: tool name. */
314 __( 'Unknown tool: %s', 'xspeed' ),
315 $name
316 ),
317 array( 'status' => 404 )
318 );
319 }
320
321 // Scope enforcement: a read-only connection cannot invoke a tool that
322 // mutates state. run_command is a gateway to the full CLI surface, so
323 // it's treated as write regardless of the wrapped command. The active
324 // credential's scope (pairing token OR OAuth access token) is carried
325 // in self::$scope_override; it falls back to the pairing global for
326 // callers that don't set a per-call scope.
327 if ( ! empty( $catalog[ $name ]['write'] ) && self::is_read_only() ) {
328 return new \WP_Error(
329 'xspeed_mcp_read_only',
330 sprintf(
331 /* translators: %s: tool name. */
332 __( 'This MCP connection is read-only; the "%s" tool changes state and is not permitted. Reconnect with write access to use it.', 'xspeed' ),
333 $name
334 ),
335 array( 'status' => 403 )
336 );
337 }
338
339 return call_user_func( $catalog[ $name ]['handler'], $args );
340 }
341
342 /*
343 * Handlers — thin proxies to the Free engine. Each takes decoded tool
344 * arguments and returns an array payload (or WP_Error on bad input).
345 */
346
347 /**
348 * Cache status, stats, and detected server.
349 *
350 * @param array $args Unused.
351 * @return array
352 */
353 public static function get_cache_status( array $args ) {
354 unset( $args );
355 $opts = Settings::get();
356 return array(
357 'cache_enabled' => (bool) ( $opts['cache_enabled'] ?? false ),
358 'stats' => Cache::get_stats(),
359 'server' => Server::type(),
360 );
361 }
362
363 /**
364 * All registered module descriptors.
365 *
366 * @param array $args Unused.
367 * @return array
368 */
369 public static function list_modules( array $args ) {
370 unset( $args );
371 return Admin::modules_payload();
372 }
373
374 /**
375 * Before/after cache benchmark timings.
376 *
377 * @param array $args Unused.
378 * @return array
379 */
380 public static function run_benchmark( array $args ) {
381 unset( $args );
382 return Cache_Benchmark::run();
383 }
384
385 /**
386 * Personalized Pro-feature suggestions for this site.
387 *
388 * @param array $args Unused.
389 * @return array
390 */
391 public static function get_pro_audit( array $args ) {
392 unset( $args );
393 return array( 'suggestions' => Pro_Audit::run() );
394 }
395
396 /**
397 * Purge the cache by type.
398 *
399 * @param array $args { type?:string } — one of PURGE_TYPES; default all.
400 * @return array|\WP_Error
401 */
402 public static function purge_cache( array $args ) {
403 $type = isset( $args['type'] ) ? (string) $args['type'] : 'all';
404 if ( '' === $type ) {
405 $type = 'all';
406 }
407 if ( ! in_array( $type, self::PURGE_TYPES, true ) ) {
408 return new \WP_Error(
409 'xspeed_mcp_bad_type',
410 sprintf(
411 /* translators: %s: comma-separated list of valid purge types. */
412 __( 'Invalid purge type. Expected one of: %s', 'xspeed' ),
413 implode( ', ', self::PURGE_TYPES )
414 ),
415 array( 'status' => 400 )
416 );
417 }
418 $count = Cache::purge_type( $type );
419 return array(
420 'purged' => $type,
421 'count' => $count,
422 'stats' => Cache::get_stats(),
423 );
424 }
425
426 /**
427 * Enable or disable page caching.
428 *
429 * @param array $args { enabled:bool }.
430 * @return array|\WP_Error
431 */
432 public static function toggle_cache( array $args ) {
433 if ( ! array_key_exists( 'enabled', $args ) ) {
434 return new \WP_Error(
435 'xspeed_mcp_missing_enabled',
436 __( 'The "enabled" parameter is required (true or false).', 'xspeed' ),
437 array( 'status' => 400 )
438 );
439 }
440 $enabled = rest_sanitize_boolean( $args['enabled'] );
441 $install = Cache::toggle( $enabled );
442
443 // Persist cache_enabled the same way the Free /cache/toggle route
444 // does (class-rest-api.php:235) — Cache::toggle handles the drop-in
445 // + wp-config; Settings owns the option flag.
446 Settings::update( array( 'cache_enabled' => $enabled ) );
447
448 return array(
449 'cache_enabled' => $enabled,
450 'install_state' => $install,
451 'stats' => Cache::get_stats(),
452 );
453 }
454
455 /**
456 * Read a module's schema-validated settings.
457 *
458 * @param array $args { module:string }.
459 * @return array|\WP_Error
460 */
461 public static function get_settings( array $args ) {
462 $module = isset( $args['module'] ) ? (string) $args['module'] : '';
463 if ( '' === $module ) {
464 return new \WP_Error(
465 'xspeed_mcp_missing_module',
466 __( 'The "module" parameter is required.', 'xspeed' ),
467 array( 'status' => 400 )
468 );
469 }
470 return array(
471 'module' => $module,
472 'settings' => Settings_Manager::get( $module ),
473 );
474 }
475
476 /**
477 * Update a module's settings (schema-validated).
478 *
479 * @param array $args { module:string, values:array }.
480 * @return array|\WP_Error
481 */
482 public static function update_settings( array $args ) {
483 $module = isset( $args['module'] ) ? (string) $args['module'] : '';
484 $values = $args['values'] ?? null;
485 if ( '' === $module ) {
486 return new \WP_Error(
487 'xspeed_mcp_missing_module',
488 __( 'The "module" parameter is required.', 'xspeed' ),
489 array( 'status' => 400 )
490 );
491 }
492 if ( ! is_array( $values ) ) {
493 return new \WP_Error(
494 'xspeed_mcp_bad_values',
495 __( 'The "values" parameter must be an object of setting keys.', 'xspeed' ),
496 array( 'status' => 400 )
497 );
498 }
499 return array(
500 'module' => $module,
501 'settings' => Settings_Manager::update( $module, $values ),
502 );
503 }
504
505 /**
506 * List every command run_command can invoke (the full CLI surface).
507 *
508 * @param array $args Unused.
509 * @return array
510 */
511 public static function list_commands( array $args ) {
512 unset( $args );
513 return array( 'commands' => Cli_Bridge::catalog() );
514 }
515
516 /**
517 * Run any registered xSpeed command via the CLI bridge.
518 *
519 * @param array $args { command:string, args?:array, options?:array }.
520 * @return array|\WP_Error
521 */
522 public static function run_command( array $args ) {
523 $command = isset( $args['command'] ) ? (string) $args['command'] : '';
524 if ( '' === $command ) {
525 return new \WP_Error(
526 'xspeed_mcp_missing_command',
527 __( 'The "command" parameter is required.', 'xspeed' ),
528 array( 'status' => 400 )
529 );
530 }
531 $positional = isset( $args['args'] ) && is_array( $args['args'] ) ? $args['args'] : array();
532 $options = isset( $args['options'] ) && is_array( $args['options'] ) ? $args['options'] : array();
533 return Cli_Bridge::run( $command, $positional, $options );
534 }
535
536 /* --------------------------------------------------------------------- */
537 /* Promoted action handlers — typed wrappers over Cli_Bridge. */
538 /* Delegating to the bridge lets a Free tool drive a Pro action (psi, */
539 /* ccss) with no cross-repo class reference, and keeps zero drift. */
540 /* --------------------------------------------------------------------- */
541
542 /**
543 * Purge the Cloudflare edge cache.
544 *
545 * @param array $args Unused.
546 * @return array|\WP_Error
547 */
548 public static function purge_cloudflare( array $args ) {
549 unset( $args );
550 return Cli_Bridge::run( 'cf', array( 'purge' ) );
551 }
552
553 /**
554 * Scan the database for bloat (no deletion).
555 *
556 * @param array $args Unused.
557 * @return array|\WP_Error
558 */
559 public static function scan_database( array $args ) {
560 unset( $args );
561 return Cli_Bridge::run( 'db', array( 'scan' ) );
562 }
563
564 /**
565 * Clean database bloat (destructive).
566 *
567 * @param array $args Unused.
568 * @return array|\WP_Error
569 */
570 public static function clean_database( array $args ) {
571 unset( $args );
572 return Cli_Bridge::run( 'db', array( 'clean' ) );
573 }
574
575 /**
576 * Flush the persistent object cache.
577 *
578 * @param array $args Unused.
579 * @return array|\WP_Error
580 */
581 public static function flush_object_cache( array $args ) {
582 unset( $args );
583 return Cli_Bridge::run( 'objcache', array( 'flush' ) );
584 }
585
586 /**
587 * Start the cache preloader.
588 *
589 * @param array $args Unused.
590 * @return array|\WP_Error
591 */
592 public static function start_preloader( array $args ) {
593 unset( $args );
594 return Cli_Bridge::run( 'preloader', array( 'start' ) );
595 }
596
597 /**
598 * Run a PageSpeed Insights audit (Pro).
599 *
600 * @param array $args { url?:string, strategy?:string }.
601 * @return array|\WP_Error
602 */
603 public static function run_pagespeed( array $args ) {
604 $options = array();
605 if ( ! empty( $args['url'] ) ) {
606 $options['url'] = (string) $args['url'];
607 }
608 if ( ! empty( $args['strategy'] ) ) {
609 $options['strategy'] = (string) $args['strategy'];
610 }
611 return Cli_Bridge::run( 'psi', array(), $options );
612 }
613
614 /**
615 * Generate Critical CSS (Pro).
616 *
617 * @param array $args Unused.
618 * @return array|\WP_Error
619 */
620 public static function generate_critical_css( array $args ) {
621 unset( $args );
622 return Cli_Bridge::run( 'ccss', array( 'generate' ) );
623 }
624
625 /**
626 * Build a JSON Schema object node.
627 *
628 * @param array $properties Property map.
629 * @param string[] $required Required property names.
630 */
631 private static function object_schema( array $properties, array $required ): array {
632 $schema = array(
633 'type' => 'object',
634 'properties' => (object) $properties,
635 );
636 if ( ! empty( $required ) ) {
637 $schema['required'] = array_values( $required );
638 }
639 return $schema;
640 }
641 }
642