PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.1
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.1, at includes/modules/Mcp/Mcp_Tools.php

785 lines 26.0 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. Each side reports bytes (decoded payload) and bytes_transferred (compressed wire size).',
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 'get_health' => array(
230 'description' => 'Full health diagnostics: every Health check (drop-in, WP_CACHE, server rewrite, expiry-vs-preload, Set-Cookie poisoning, conflicts), cache stats, hourly hit/miss buckets, the daily hit-ratio series, and recent activity. The single best first call when diagnosing a low hit ratio.',
231 'inputSchema' => self::object_schema( array(), array() ),
232 'write' => false,
233 'handler' => array( self::class, 'get_health' ),
234 ),
235 'get_benchmark_history' => array(
236 'description' => 'Stored benchmark runs (oldest to newest: timestamps, uncached/cached ms, savings, transfer bytes) plus recent settings-change events for correlating a change with its performance effect.',
237 'inputSchema' => self::object_schema(
238 array(
239 'limit' => array(
240 'type' => 'integer',
241 'description' => 'Max runs to return (default 100).',
242 ),
243 ),
244 array()
245 ),
246 'write' => false,
247 'handler' => array( self::class, 'get_benchmark_history' ),
248 ),
249 'purge_url' => array(
250 'description' => 'Purge the cache for ONE URL only (all its variants: device buckets, trailing-slash forms, static-tree copy). Surgical alternative to purge_cache when a single page changed.',
251 'inputSchema' => self::object_schema(
252 array(
253 'url' => array(
254 'type' => 'string',
255 'description' => 'Absolute URL or site-relative path, e.g. "https://site.com/about/" or "/about/".',
256 ),
257 ),
258 array( 'url' )
259 ),
260 'write' => true,
261 'handler' => array( self::class, 'purge_url' ),
262 ),
263 'test_object_cache' => array(
264 'description' => 'Live connect + read/write probe of the configured Redis/Memcached backend using the saved Object Cache settings. Verifies the credentials actually work — writing settings alone does not.',
265 'inputSchema' => self::object_schema( array(), array() ),
266 'write' => false,
267 'handler' => array( self::class, 'test_object_cache' ),
268 ),
269 'cloudflare_verify' => array(
270 'description' => 'Verify the saved Cloudflare credentials against the Cloudflare API (token/zone check). Read-only — use purge_cloudflare to purge the edge.',
271 'inputSchema' => self::object_schema( array(), array() ),
272 'write' => false,
273 'handler' => array( self::class, 'cloudflare_verify' ),
274 ),
275 'list_commands' => array(
276 '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.',
277 'inputSchema' => self::object_schema( array(), array() ),
278 'write' => false,
279 'handler' => array( self::class, 'list_commands' ),
280 ),
281 'run_command' => array(
282 '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"}).',
283 'inputSchema' => self::object_schema(
284 array(
285 'command' => array(
286 'type' => 'string',
287 'description' => 'Command name, e.g. "cloudflare purge" or "database scan" (the "xspeed " prefix is optional).',
288 ),
289 'args' => array(
290 'type' => 'array',
291 'description' => 'Positional arguments, if the command takes any.',
292 'items' => array( 'type' => 'string' ),
293 ),
294 'options' => array(
295 'type' => 'object',
296 'description' => 'Named options / flags, e.g. { "url": "https://site.com", "strategy": "mobile", "force": true }.',
297 ),
298 ),
299 array( 'command' )
300 ),
301 'write' => true,
302 'handler' => array( self::class, 'run_command' ),
303 ),
304 );
305
306 // Dedicated tools that wrap a command only present when a given
307 // module is active (e.g. Pro): drop them if the command isn't
308 // registered, so we never advertise a tool that always fails. The
309 // action stays reachable via run_command if the command exists.
310 $conditional = array(
311 'run_pagespeed' => 'xspeed psi',
312 'generate_critical_css' => 'xspeed ccss',
313 'purge_cloudflare' => 'xspeed cf',
314 'cloudflare_verify' => 'xspeed cf',
315 'flush_object_cache' => 'xspeed objcache',
316 'test_object_cache' => 'xspeed objcache',
317 'start_preloader' => 'xspeed preloader',
318 'scan_database' => 'xspeed db',
319 'clean_database' => 'xspeed db',
320 'purge_url' => 'xspeed cache',
321 );
322 $commands = Cli_Bridge::commands();
323 foreach ( $conditional as $tool => $command ) {
324 if ( ! isset( $commands[ $command ] ) ) {
325 unset( $catalog[ $tool ] );
326 }
327 }
328
329 return $catalog;
330 }
331
332 /**
333 * The tool list in MCP `tools/list` shape.
334 *
335 * @return array<int, array{name:string, description:string, inputSchema:array}>
336 */
337 public static function list(): array {
338 $out = array();
339 foreach ( self::catalog() as $name => $spec ) {
340 $out[] = array(
341 'name' => $name,
342 'description' => $spec['description'],
343 'inputSchema' => $spec['inputSchema'],
344 );
345 }
346 return $out;
347 }
348
349 /**
350 * Invoke a tool by name with decoded arguments.
351 *
352 * @param string $name Tool name.
353 * @param array $args Decoded arguments.
354 * @return array|\WP_Error Result payload or error.
355 */
356 public static function invoke( string $name, array $args ) {
357 $catalog = self::catalog();
358 if ( ! isset( $catalog[ $name ] ) ) {
359 return new \WP_Error(
360 'xspeed_mcp_unknown_tool',
361 sprintf(
362 /* translators: %s: tool name. */
363 __( 'Unknown tool: %s', 'xspeed' ),
364 $name
365 ),
366 array( 'status' => 404 )
367 );
368 }
369
370 // Scope enforcement: a read-only connection cannot invoke a tool that
371 // mutates state. run_command is a gateway to the full CLI surface, so
372 // it's treated as write regardless of the wrapped command. The active
373 // credential's scope (pairing token OR OAuth access token) is carried
374 // in self::$scope_override; it falls back to the pairing global for
375 // callers that don't set a per-call scope.
376 if ( ! empty( $catalog[ $name ]['write'] ) && self::is_read_only() ) {
377 return new \WP_Error(
378 'xspeed_mcp_read_only',
379 sprintf(
380 /* translators: %s: tool name. */
381 __( 'This MCP connection is read-only; the "%s" tool changes state and is not permitted. Reconnect with write access to use it.', 'xspeed' ),
382 $name
383 ),
384 array( 'status' => 403 )
385 );
386 }
387
388 self::$dispatching = true;
389 try {
390 return call_user_func( $catalog[ $name ]['handler'], $args );
391 } finally {
392 self::$dispatching = false;
393 }
394 }
395
396 /** @var bool True while an MCP tool handler is executing. */
397 private static $dispatching = false;
398
399 /**
400 * True while a tool call is being dispatched — lets deeper layers
401 * (e.g. the settings change-log) attribute a mutation to MCP.
402 */
403 public static function in_dispatch(): bool {
404 return self::$dispatching;
405 }
406
407 /*
408 * Handlers — thin proxies to the Free engine. Each takes decoded tool
409 * arguments and returns an array payload (or WP_Error on bad input).
410 */
411
412 /**
413 * Cache status, stats, and detected server.
414 *
415 * @param array $args Unused.
416 * @return array
417 */
418 public static function get_cache_status( array $args ) {
419 unset( $args );
420 $opts = Settings::get();
421 return array(
422 'cache_enabled' => (bool) ( $opts['cache_enabled'] ?? false ),
423 'stats' => Cache::get_stats(),
424 'server' => Server::type(),
425 );
426 }
427
428 /**
429 * All registered module descriptors.
430 *
431 * @param array $args Unused.
432 * @return array
433 */
434 public static function list_modules( array $args ) {
435 unset( $args );
436 return Admin::modules_payload();
437 }
438
439 /**
440 * Before/after cache benchmark timings.
441 *
442 * @param array $args Unused.
443 * @return array
444 */
445 public static function run_benchmark( array $args ) {
446 unset( $args );
447 return Cache_Benchmark::run();
448 }
449
450 /**
451 * Personalized Pro-feature suggestions for this site.
452 *
453 * @param array $args Unused.
454 * @return array
455 */
456 public static function get_pro_audit( array $args ) {
457 unset( $args );
458 return array( 'suggestions' => Pro_Audit::run() );
459 }
460
461 /**
462 * Purge the cache by type.
463 *
464 * @param array $args { type?:string } — one of PURGE_TYPES; default all.
465 * @return array|\WP_Error
466 */
467 public static function purge_cache( array $args ) {
468 $type = isset( $args['type'] ) ? (string) $args['type'] : 'all';
469 if ( '' === $type ) {
470 $type = 'all';
471 }
472 if ( ! in_array( $type, self::PURGE_TYPES, true ) ) {
473 return new \WP_Error(
474 'xspeed_mcp_bad_type',
475 sprintf(
476 /* translators: %s: comma-separated list of valid purge types. */
477 __( 'Invalid purge type. Expected one of: %s', 'xspeed' ),
478 implode( ', ', self::PURGE_TYPES )
479 ),
480 array( 'status' => 400 )
481 );
482 }
483 $count = Cache::purge_type( $type );
484 return array(
485 'purged' => $type,
486 'count' => $count,
487 'stats' => Cache::get_stats(),
488 );
489 }
490
491 /**
492 * Enable or disable page caching.
493 *
494 * @param array $args { enabled:bool }.
495 * @return array|\WP_Error
496 */
497 public static function toggle_cache( array $args ) {
498 if ( ! array_key_exists( 'enabled', $args ) ) {
499 return new \WP_Error(
500 'xspeed_mcp_missing_enabled',
501 __( 'The "enabled" parameter is required (true or false).', 'xspeed' ),
502 array( 'status' => 400 )
503 );
504 }
505 $enabled = rest_sanitize_boolean( $args['enabled'] );
506 $install = Cache::toggle( $enabled );
507
508 // Persist cache_enabled the same way the Free /cache/toggle route
509 // does (class-rest-api.php:235) — Cache::toggle handles the drop-in
510 // + wp-config; Settings owns the option flag.
511 Settings::update( array( 'cache_enabled' => $enabled ) );
512
513 return array(
514 'cache_enabled' => $enabled,
515 'install_state' => $install,
516 'stats' => Cache::get_stats(),
517 );
518 }
519
520 /**
521 * Read a module's schema-validated settings.
522 *
523 * @param array $args { module:string }.
524 * @return array|\WP_Error
525 */
526 public static function get_settings( array $args ) {
527 $module = isset( $args['module'] ) ? (string) $args['module'] : '';
528 if ( '' === $module ) {
529 return new \WP_Error(
530 'xspeed_mcp_missing_module',
531 __( 'The "module" parameter is required.', 'xspeed' ),
532 array( 'status' => 400 )
533 );
534 }
535 return array(
536 'module' => $module,
537 'settings' => Settings_Manager::get( $module ),
538 );
539 }
540
541 /**
542 * Update a module's settings (schema-validated).
543 *
544 * @param array $args { module:string, values:array }.
545 * @return array|\WP_Error
546 */
547 public static function update_settings( array $args ) {
548 $module = isset( $args['module'] ) ? (string) $args['module'] : '';
549 $values = $args['values'] ?? null;
550 if ( '' === $module ) {
551 return new \WP_Error(
552 'xspeed_mcp_missing_module',
553 __( 'The "module" parameter is required.', 'xspeed' ),
554 array( 'status' => 400 )
555 );
556 }
557 if ( ! is_array( $values ) ) {
558 return new \WP_Error(
559 'xspeed_mcp_bad_values',
560 __( 'The "values" parameter must be an object of setting keys.', 'xspeed' ),
561 array( 'status' => 400 )
562 );
563 }
564 return array(
565 'module' => $module,
566 'settings' => Settings_Manager::update( $module, $values ),
567 );
568 }
569
570 /**
571 * List every command run_command can invoke (the full CLI surface).
572 *
573 * @param array $args Unused.
574 * @return array
575 */
576 public static function list_commands( array $args ) {
577 unset( $args );
578 return array( 'commands' => Cli_Bridge::catalog() );
579 }
580
581 /**
582 * Run any registered xSpeed command via the CLI bridge.
583 *
584 * @param array $args { command:string, args?:array, options?:array }.
585 * @return array|\WP_Error
586 */
587 public static function run_command( array $args ) {
588 $command = isset( $args['command'] ) ? (string) $args['command'] : '';
589 if ( '' === $command ) {
590 return new \WP_Error(
591 'xspeed_mcp_missing_command',
592 __( 'The "command" parameter is required.', 'xspeed' ),
593 array( 'status' => 400 )
594 );
595 }
596 $positional = isset( $args['args'] ) && is_array( $args['args'] ) ? $args['args'] : array();
597 $options = isset( $args['options'] ) && is_array( $args['options'] ) ? $args['options'] : array();
598 return Cli_Bridge::run( $command, $positional, $options );
599 }
600
601 /* --------------------------------------------------------------------- */
602 /* Promoted action handlers — typed wrappers over Cli_Bridge. */
603 /* Delegating to the bridge lets a Free tool drive a Pro action (psi, */
604 /* ccss) with no cross-repo class reference, and keeps zero drift. */
605 /* --------------------------------------------------------------------- */
606
607 /**
608 * Purge the Cloudflare edge cache.
609 *
610 * @param array $args Unused.
611 * @return array|\WP_Error
612 */
613 public static function purge_cloudflare( array $args ) {
614 unset( $args );
615 return Cli_Bridge::run( 'cf', array( 'purge' ) );
616 }
617
618 /**
619 * Scan the database for bloat (no deletion).
620 *
621 * @param array $args Unused.
622 * @return array|\WP_Error
623 */
624 public static function scan_database( array $args ) {
625 unset( $args );
626 return Cli_Bridge::run( 'db', array( 'scan' ) );
627 }
628
629 /**
630 * Clean database bloat (destructive).
631 *
632 * @param array $args Unused.
633 * @return array|\WP_Error
634 */
635 public static function clean_database( array $args ) {
636 unset( $args );
637 return Cli_Bridge::run( 'db', array( 'clean' ) );
638 }
639
640 /**
641 * Flush the persistent object cache.
642 *
643 * @param array $args Unused.
644 * @return array|\WP_Error
645 */
646 public static function flush_object_cache( array $args ) {
647 unset( $args );
648 return Cli_Bridge::run( 'objcache', array( 'flush' ) );
649 }
650
651 /**
652 * Start the cache preloader.
653 *
654 * @param array $args Unused.
655 * @return array|\WP_Error
656 */
657 public static function start_preloader( array $args ) {
658 unset( $args );
659 return Cli_Bridge::run( 'preloader', array( 'start' ) );
660 }
661
662 /**
663 * Full health diagnostics (checks + stats + buckets + activity).
664 * Direct typed payload — same tier as get_cache_status — so the agent
665 * gets structured tones/ids instead of parsing CLI log lines.
666 *
667 * @param array $args Unused.
668 * @return array
669 */
670 public static function get_health( array $args ) {
671 unset( $args );
672 return array(
673 'checks' => \XSpeed\Health::checks(),
674 'stats' => Cache::get_stats(),
675 'buckets' => \XSpeed\Hit_Counter::buckets(),
676 'hit_daily' => \XSpeed\Hit_Counter::daily_series( 30 ),
677 'activity' => \XSpeed\Activity_Log::entries(),
678 );
679 }
680
681 /**
682 * Stored benchmark runs + settings-change events (trend data).
683 *
684 * @param array $args { limit?:int }.
685 * @return array
686 */
687 public static function get_benchmark_history( array $args ) {
688 $limit = isset( $args['limit'] ) ? max( 1, min( 100, (int) $args['limit'] ) ) : 100;
689 $changes = array();
690 foreach ( \XSpeed\Activity_Log::entries() as $entry ) {
691 if ( 'settings_changed' === ( $entry['type'] ?? '' ) ) {
692 $changes[] = array(
693 'ts' => (int) $entry['ts'],
694 'message' => (string) $entry['message'],
695 );
696 }
697 }
698 return array(
699 'runs' => Cache_Benchmark::history( $limit ),
700 'changes' => $changes,
701 );
702 }
703
704 /**
705 * Purge a single URL's cache entries.
706 *
707 * @param array $args { url:string }.
708 * @return array|\WP_Error
709 */
710 public static function purge_url( array $args ) {
711 $url = isset( $args['url'] ) ? trim( (string) $args['url'] ) : '';
712 if ( '' === $url ) {
713 return new \WP_Error( 'xspeed_mcp_missing_url', __( 'The url argument is required.', 'xspeed' ), array( 'status' => 400 ) );
714 }
715 return Cli_Bridge::run( 'cache', array( 'purge-url', $url ) );
716 }
717
718 /**
719 * Probe the configured object-cache backend (connect + read/write).
720 *
721 * @param array $args Unused.
722 * @return array|\WP_Error
723 */
724 public static function test_object_cache( array $args ) {
725 unset( $args );
726 return Cli_Bridge::run( 'objcache', array( 'test' ) );
727 }
728
729 /**
730 * Verify the saved Cloudflare credentials.
731 *
732 * @param array $args Unused.
733 * @return array|\WP_Error
734 */
735 public static function cloudflare_verify( array $args ) {
736 unset( $args );
737 return Cli_Bridge::run( 'cf', array( 'verify' ) );
738 }
739
740 /**
741 * Run a PageSpeed Insights audit (Pro).
742 *
743 * @param array $args { url?:string, strategy?:string }.
744 * @return array|\WP_Error
745 */
746 public static function run_pagespeed( array $args ) {
747 $options = array();
748 if ( ! empty( $args['url'] ) ) {
749 $options['url'] = (string) $args['url'];
750 }
751 if ( ! empty( $args['strategy'] ) ) {
752 $options['strategy'] = (string) $args['strategy'];
753 }
754 return Cli_Bridge::run( 'psi', array(), $options );
755 }
756
757 /**
758 * Generate Critical CSS (Pro).
759 *
760 * @param array $args Unused.
761 * @return array|\WP_Error
762 */
763 public static function generate_critical_css( array $args ) {
764 unset( $args );
765 return Cli_Bridge::run( 'ccss', array( 'generate' ) );
766 }
767
768 /**
769 * Build a JSON Schema object node.
770 *
771 * @param array $properties Property map.
772 * @param string[] $required Required property names.
773 */
774 private static function object_schema( array $properties, array $required ): array {
775 $schema = array(
776 'type' => 'object',
777 'properties' => (object) $properties,
778 );
779 if ( ! empty( $required ) ) {
780 $schema['required'] = array_values( $required );
781 }
782 return $schema;
783 }
784 }
785