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

1,414 lines 50.8 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 an external performance audit (PageSpeed Insights, or GTmetrix when configured) and return the score + Core Web Vitals. Defaults to the site home page, mobile strategy. Requires external scores to be enabled in settings — the plugin makes no outbound calls otherwise.',
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 'force' => array(
218 'type' => 'boolean',
219 'description' => 'Re-run even when a recent cached result exists. Use after a change you want measured immediately.',
220 ),
221 ),
222 array()
223 ),
224 'write' => false,
225 'handler' => array( self::class, 'run_pagespeed' ),
226 ),
227 'generate_critical_css' => array(
228 'description' => 'Generate above-the-fold Critical CSS for the site (Pro). Calls the external generator and stores the result.',
229 'inputSchema' => self::object_schema( array(), array() ),
230 'write' => true,
231 'handler' => array( self::class, 'generate_critical_css' ),
232 ),
233 'get_health' => array(
234 '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.',
235 'inputSchema' => self::object_schema( array(), array() ),
236 'write' => false,
237 'handler' => array( self::class, 'get_health' ),
238 ),
239 'get_benchmark_history' => array(
240 '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.',
241 'inputSchema' => self::object_schema(
242 array(
243 'limit' => array(
244 'type' => 'integer',
245 'description' => 'Max runs to return (default 100).',
246 ),
247 ),
248 array()
249 ),
250 'write' => false,
251 'handler' => array( self::class, 'get_benchmark_history' ),
252 ),
253 'get_score_history' => array(
254 'description' => 'Stored EXTERNAL audit runs (PageSpeed Insights / GTmetrix): score, Core Web Vitals (LCP/FCP/CLS/TBT/SI/TTFB), which tool ran it, and the report link where one exists. Read-only — returns what this site already measured and never starts a new audit. Use run_pagespeed to actually run one.',
255 'inputSchema' => self::object_schema(
256 array(
257 'limit' => array(
258 'type' => 'integer',
259 'description' => 'Max runs to return, newest first (default 100).',
260 ),
261 ),
262 array()
263 ),
264 'write' => false,
265 'handler' => array( self::class, 'get_score_history' ),
266 ),
267 // --- Actions promoted out of the generated `xspeed_*` aliases.
268 // Each was previously reachable ONLY as an `action` string on a
269 // coarse generated tool that was marked write regardless, so a
270 // read-only connection lost the read ones. Typed here with an
271 // honest kind so the AI stops guessing and the deny-list has one
272 // name per action. ---
273 'get_cache_inventory' => array(
274 'description' => 'Inspect what is actually in the page cache: which pages are cached and how old they are, or where the disk usage goes. Read-only.',
275 'inputSchema' => self::object_schema(
276 array(
277 'detail' => array(
278 'type' => 'string',
279 'enum' => array( 'pages', 'size' ),
280 'description' => '"pages" lists cached pages and their age; "size" breaks down disk usage. Defaults to "pages".',
281 ),
282 'limit' => array(
283 'type' => 'string',
284 'description' => 'Max rows to return (pages only).',
285 ),
286 ),
287 array()
288 ),
289 'write' => false,
290 'handler' => array( self::class, 'get_cache_inventory' ),
291 ),
292 'get_purge_log' => array(
293 'description' => 'Recent cache purges and what triggered each one. Use it to explain why a page stopped being cached. Read-only.',
294 'inputSchema' => self::object_schema(
295 array(
296 'limit' => array(
297 'type' => 'string',
298 'description' => 'Max entries to return.',
299 ),
300 ),
301 array()
302 ),
303 'write' => false,
304 'handler' => array( self::class, 'get_purge_log' ),
305 ),
306 'recheck_rewrite_rules' => array(
307 'description' => 'Re-verify the server rewrite rules that route requests to the cache, and repair them if they drifted.',
308 'inputSchema' => self::object_schema( array(), array() ),
309 'write' => true,
310 'handler' => array( self::class, 'recheck_rewrite_rules' ),
311 ),
312 'set_cloudflare_dev_mode' => array(
313 'description' => 'Turn Cloudflare development mode on or off. On bypasses the edge cache for ~3 hours so origin changes show immediately.',
314 'inputSchema' => self::object_schema(
315 array(
316 'enabled' => array(
317 'type' => 'boolean',
318 'description' => 'true turns development mode on, false turns it off.',
319 ),
320 ),
321 array( 'enabled' )
322 ),
323 'write' => true,
324 'handler' => array( self::class, 'set_cloudflare_dev_mode' ),
325 ),
326 'optimize_database' => array(
327 'description' => 'Run table optimization on the WordPress database (reclaims space after cleanup). Separate from clean_database, which deletes bloat rows.',
328 'inputSchema' => self::object_schema( array(), array() ),
329 'write' => true,
330 'handler' => array( self::class, 'optimize_database' ),
331 ),
332 'get_object_cache_status' => array(
333 'description' => 'Object cache state: whether the drop-in is installed, which backend is configured, and the server snippet needed to enable it. Read-only.',
334 'inputSchema' => self::object_schema(
335 array(
336 'detail' => array(
337 'type' => 'string',
338 'enum' => array( 'status', 'snippet' ),
339 'description' => '"status" reports the current state; "snippet" returns the server config to enable it. Defaults to "status".',
340 ),
341 ),
342 array()
343 ),
344 'write' => false,
345 'handler' => array( self::class, 'get_object_cache_status' ),
346 ),
347 'toggle_object_cache' => array(
348 'description' => 'Enable or disable the object cache drop-in. Verify the backend with test_object_cache first — enabling against an unreachable server slows every request.',
349 'inputSchema' => self::object_schema(
350 array(
351 'enabled' => array(
352 'type' => 'boolean',
353 'description' => 'true installs the drop-in, false removes it.',
354 ),
355 ),
356 array( 'enabled' )
357 ),
358 'write' => true,
359 'handler' => array( self::class, 'toggle_object_cache' ),
360 ),
361 'manage_critical_css' => array(
362 'description' => 'List the stored Critical CSS entries, or clear them so they regenerate. Use generate_critical_css to create them.',
363 'inputSchema' => self::object_schema(
364 array(
365 'action' => array(
366 'type' => 'string',
367 'enum' => array( 'list', 'clear' ),
368 'description' => '"list" returns what is stored; "clear" deletes it.',
369 ),
370 ),
371 array( 'action' )
372 ),
373 'write' => true,
374 'handler' => array( self::class, 'manage_critical_css' ),
375 ),
376 'get_preloader_status' => array(
377 'description' => 'Cache preloader progress: whether a run is active, how far through the URL list it is. Read-only.',
378 'inputSchema' => self::object_schema( array(), array() ),
379 'write' => false,
380 'handler' => array( self::class, 'get_preloader_status' ),
381 ),
382 'stop_preloader' => array(
383 'description' => 'Stop a running cache preload. Safe mid-run — already-warmed pages stay cached.',
384 'inputSchema' => self::object_schema( array(), array() ),
385 'write' => true,
386 'handler' => array( self::class, 'stop_preloader' ),
387 ),
388 'purge_url' => array(
389 '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.',
390 'inputSchema' => self::object_schema(
391 array(
392 'url' => array(
393 'type' => 'string',
394 'description' => 'Absolute URL or site-relative path, e.g. "https://site.com/about/" or "/about/".',
395 ),
396 ),
397 array( 'url' )
398 ),
399 'write' => true,
400 'handler' => array( self::class, 'purge_url' ),
401 ),
402 'test_object_cache' => array(
403 '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.',
404 'inputSchema' => self::object_schema( array(), array() ),
405 'write' => false,
406 'handler' => array( self::class, 'test_object_cache' ),
407 ),
408 'cloudflare_verify' => array(
409 'description' => 'Verify the saved Cloudflare credentials against the Cloudflare API (token/zone check). Read-only — use purge_cloudflare to purge the edge.',
410 'inputSchema' => self::object_schema( array(), array() ),
411 'write' => false,
412 'handler' => array( self::class, 'cloudflare_verify' ),
413 ),
414 'list_commands' => array(
415 '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.',
416 'inputSchema' => self::object_schema( array(), array() ),
417 'write' => false,
418 'handler' => array( self::class, 'list_commands' ),
419 ),
420 'run_command' => array(
421 '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"}).',
422 'inputSchema' => self::object_schema(
423 array(
424 'command' => array(
425 'type' => 'string',
426 'description' => 'Command name, e.g. "cloudflare purge" or "database scan" (the "xspeed " prefix is optional).',
427 ),
428 'args' => array(
429 'type' => 'array',
430 'description' => 'Positional arguments, if the command takes any.',
431 'items' => array( 'type' => 'string' ),
432 ),
433 'options' => array(
434 'type' => 'object',
435 'description' => 'Named options / flags, e.g. { "url": "https://site.com", "strategy": "mobile", "force": true }.',
436 ),
437 ),
438 array( 'command' )
439 ),
440 'write' => true,
441 'handler' => array( self::class, 'run_command' ),
442 ),
443 );
444
445 // Dedicated tools that wrap a command only present when a given
446 // module is active (e.g. Pro): drop them if the command isn't
447 // registered, so we never advertise a tool that always fails. The
448 // action stays reachable via run_command if the command exists.
449 $conditional = array(
450 'generate_critical_css' => 'xspeed ccss',
451 'purge_cloudflare' => 'xspeed cf',
452 'cloudflare_verify' => 'xspeed cf',
453 'flush_object_cache' => 'xspeed objcache',
454 'test_object_cache' => 'xspeed objcache',
455 'start_preloader' => 'xspeed preloader',
456 'scan_database' => 'xspeed db',
457 'clean_database' => 'xspeed db',
458 'purge_url' => 'xspeed cache',
459 'get_cache_inventory' => 'xspeed cache',
460 'get_purge_log' => 'xspeed cache',
461 'recheck_rewrite_rules' => 'xspeed cache',
462 'set_cloudflare_dev_mode' => 'xspeed cf',
463 'optimize_database' => 'xspeed db',
464 'get_object_cache_status' => 'xspeed objcache',
465 'toggle_object_cache' => 'xspeed objcache',
466 'manage_critical_css' => 'xspeed ccss',
467 'get_preloader_status' => 'xspeed preloader',
468 'stop_preloader' => 'xspeed preloader',
469 // These three have no module dependency — get_settings /
470 // run_pagespeed / get_health are always present — but they belong
471 // here so the generator skips their commands too. $conditional is
472 // read for BOTH purposes: drop a tool when its command is gone,
473 // and never generate an alias for a command a tool already covers.
474 'get_settings' => 'xspeed settings',
475 'update_settings' => 'xspeed settings',
476 'run_pagespeed' => 'xspeed psi',
477 'get_health' => 'xspeed health',
478 'get_score_history' => 'xspeed score',
479 );
480 $commands = Cli_Bridge::commands();
481 foreach ( $conditional as $tool => $command ) {
482 if ( ! isset( $commands[ $command ] ) ) {
483 unset( $catalog[ $tool ] );
484 }
485 }
486
487 /*
488 * One dedicated tool per xSpeed CLI command, generated from the same
489 * Cli_Bridge catalog the CLI registers from — so the AI can reach the
490 * long tail without the list_commands -> run_command hop, and the
491 * generated set can never drift from the CLI.
492 *
493 * Commands already covered by a typed tool above are SKIPPED. The
494 * `isset()` guard below only catches NAME collisions, and a generated
495 * name never collides — `xspeed cf` becomes `xspeed_cf`, which is not
496 * `purge_cloudflare`. So both used to ship: two tools for one action,
497 * with the generated one marked write even when it wrapped a read,
498 * and a per-tool permission on one name silently bypassable via the
499 * other. $conditional already maps every typed tool to its command;
500 * inverted, that IS the skip list.
501 */
502 foreach ( self::cli_generated_tools( array_flip( $conditional ) ) as $name => $spec ) {
503 if ( ! isset( $catalog[ $name ] ) ) {
504 $catalog[ $name ] = $spec;
505 }
506 }
507
508 return $catalog;
509 }
510
511 /**
512 * Generate one MCP tool per registered xSpeed CLI command. Each wraps
513 * Cli_Bridge::run(): the tool's `action` (the command's first positional,
514 * e.g. `verify`/`purge` for `xspeed cf`) plus any named options are passed
515 * straight through. Tool names are the command with the `xspeed ` prefix
516 * dropped and spaces -> underscores (`xspeed cf` -> `xspeed_cf`).
517 *
518 * @return array<string, array{description:string, inputSchema:array, write:bool, handler:callable}>
519 */
520 private static function cli_generated_tools( array $covered = array() ): array {
521 $tools = array();
522 foreach ( Cli_Bridge::commands() as $command => $spec ) {
523 // Already exposed as typed tools with real schemas and honest
524 // read/write kinds — generating a coarse alias too would give the
525 // AI two ways to do one thing and make a per-tool permission on
526 // the typed name bypassable via the generated one.
527 if ( isset( $covered[ $command ] ) ) {
528 continue;
529 }
530 $tool_name = self::cli_tool_name( $command );
531 if ( '' === $tool_name ) {
532 continue;
533 }
534
535 // Build the input schema from the command's synopsis: positional
536 // args become string properties (the first is usually the action,
537 // exposed with its allowed values as an enum); assoc args become
538 // named options.
539 $properties = array();
540 $required = array();
541 foreach ( $spec['synopsis'] as $arg ) {
542 if ( ! isset( $arg['name'] ) ) {
543 continue;
544 }
545 $arg_name = (string) $arg['name'];
546 $prop = array(
547 'type' => 'string',
548 'description' => isset( $arg['description'] ) ? (string) $arg['description'] : '',
549 );
550 if ( isset( $arg['options'] ) && is_array( $arg['options'] ) && ! empty( $arg['options'] ) ) {
551 $prop['enum'] = array_values( array_map( 'strval', $arg['options'] ) );
552 }
553 $properties[ $arg_name ] = $prop;
554 $is_optional = ! empty( $arg['optional'] );
555 $is_flag = isset( $arg['type'] ) && 'flag' === $arg['type'];
556 if ( ! $is_optional && ! $is_flag ) {
557 $required[] = $arg_name;
558 }
559 }
560
561 $description = '' !== $spec['shortdesc']
562 ? $spec['shortdesc']
563 : sprintf( 'Run the "%s" xSpeed command.', $command );
564
565 list( $write, $write_actions ) = self::cli_write_profile( $command, $spec['synopsis'] );
566
567 $tools[ $tool_name ] = array(
568 'description' => $description,
569 'inputSchema' => self::object_schema( $properties, $required ),
570 'write' => $write,
571 // The action values that mutate state. When set, read-only
572 // enforcement is per-ACTION (a read-only grant may still call
573 // the tool with a read action like "status"/"scan").
574 'write_actions' => $write_actions,
575 'handler' => self::cli_handler_for( $command, $spec['synopsis'] ),
576 );
577 }
578 return $tools;
579 }
580
581 /** Derive an MCP tool name from a CLI command ("xspeed cf" -> "xspeed_cf"). */
582 private static function cli_tool_name( string $command ): string {
583 $command = trim( preg_replace( '/\s+/', ' ', $command ) ?? '' );
584 if ( '' === $command ) {
585 return '';
586 }
587 return str_replace( ' ', '_', $command );
588 }
589
590 /** Action verbs that only inspect state (never mutate). */
591 private const CLI_READ_VERBS = array( 'status', 'scan', 'list', 'verify', 'get', 'show', 'info', 'export', 'preview', 'check', 'snippet', 'test' );
592
593 /** Commands with NO action enum that are nonetheless pure inspection. */
594 private const CLI_READ_ONLY_COMMANDS = array( 'xspeed health', 'xspeed support' );
595
596 /**
597 * Compute the write profile for a generated command tool:
598 * [ $write_bool, $write_actions ]
599 * where $write_actions is the list of action values that mutate state
600 * (empty when the tool has no action enum). $write_bool is the tool-level
601 * flag: true if ANY action writes (so read-only clients see it flagged),
602 * but per-action enforcement in invoke() still lets a read-only grant run
603 * the tool's read actions (e.g. `minify status` while `minify purge` is
604 * refused).
605 *
606 * @param string $command Full command name.
607 * @param array $synopsis Command synopsis.
608 * @return array{0:bool,1:string[]}
609 */
610 private static function cli_write_profile( string $command, array $synopsis ): array {
611 // Command with an action enum → classify each action.
612 foreach ( $synopsis as $arg ) {
613 if ( isset( $arg['type'], $arg['options'] ) && 'positional' === $arg['type'] && is_array( $arg['options'] ) ) {
614 $write_actions = array();
615 foreach ( $arg['options'] as $opt ) {
616 if ( ! in_array( strtolower( (string) $opt ), self::CLI_READ_VERBS, true ) ) {
617 $write_actions[] = (string) $opt;
618 }
619 }
620 return array( ! empty( $write_actions ), $write_actions );
621 }
622 }
623
624 // No action enum: a small allow-list of pure-inspection commands is
625 // read-only; everything else defaults to write (safe — a read-only
626 // grant never mutates).
627 $is_read = in_array( trim( $command ), self::CLI_READ_ONLY_COMMANDS, true );
628 return array( ! $is_read, array() );
629 }
630
631 /**
632 * Build the handler for a generated command tool. It maps the tool's
633 * arguments back to Cli_Bridge::run(): positional synopsis args (in order)
634 * become $args; everything else is passed as named options.
635 *
636 * @param string $command Full command name.
637 * @param array $synopsis Command synopsis.
638 * @return callable
639 */
640 private static function cli_handler_for( string $command, array $synopsis ): callable {
641 // Names of the positional args, in declared order.
642 $positionals = array();
643 foreach ( $synopsis as $arg ) {
644 if ( isset( $arg['name'] ) && ( ! isset( $arg['type'] ) || 'positional' === $arg['type'] ) ) {
645 $positionals[] = (string) $arg['name'];
646 }
647 }
648
649 return static function ( array $tool_args ) use ( $command, $positionals ) {
650 $args = array();
651 $assoc = $tool_args;
652 // Pull positionals out (in order) into $args; the rest are options.
653 foreach ( $positionals as $pname ) {
654 if ( array_key_exists( $pname, $assoc ) && '' !== (string) $assoc[ $pname ] ) {
655 $args[] = (string) $assoc[ $pname ];
656 }
657 unset( $assoc[ $pname ] );
658 }
659 return Cli_Bridge::run( $command, $args, $assoc );
660 };
661 }
662
663 /**
664 * The tool list in MCP `tools/list` shape.
665 *
666 * @return array<int, array{name:string, description:string, inputSchema:array}>
667 */
668 public static function list(): array {
669 $out = array();
670 foreach ( self::catalog() as $name => $spec ) {
671 $out[] = array(
672 'name' => $name,
673 'description' => $spec['description'],
674 'inputSchema' => $spec['inputSchema'],
675 );
676 }
677 return $out;
678 }
679
680 /**
681 * Invoke a tool by name with decoded arguments.
682 *
683 * @param string $name Tool name.
684 * @param array $args Decoded arguments.
685 * @return array|\WP_Error Result payload or error.
686 */
687 public static function invoke( string $name, array $args ) {
688 $catalog = self::catalog();
689 if ( ! isset( $catalog[ $name ] ) ) {
690 $error = new \WP_Error(
691 'xspeed_mcp_unknown_tool',
692 sprintf(
693 /* translators: %s: tool name. */
694 __( 'Unknown tool: %s', 'xspeed' ),
695 $name
696 ),
697 array( 'status' => 404 )
698 );
699
700 // A call for a tool that doesn't exist is still something that
701 // happened to this site, and a run of them is the shape of a
702 // probe. Recording it is the difference between a trail that
703 // shows what was ATTEMPTED and one that only shows what
704 // succeeded. Scope is unknowable here, so log the conservative
705 // one rather than implying the attempt was read-only.
706 Mcp_Activity_Log::record( $name, $args, false, $error->get_error_message(), 'write', self::$channel );
707
708 return $error;
709 }
710
711 // Scope enforcement: a read-only connection cannot invoke a tool that
712 // mutates state. run_command is a gateway to the full CLI surface, so
713 // it's treated as write regardless of the wrapped command. The active
714 // credential's scope (pairing token OR OAuth access token) is carried
715 // in self::$scope_override; it falls back to the pairing global for
716 // callers that don't set a per-call scope.
717 if ( ! empty( $catalog[ $name ]['write'] ) && self::is_read_only() ) {
718 return new \WP_Error(
719 'xspeed_mcp_read_only',
720 sprintf(
721 /* translators: %s: tool name. */
722 __( 'This MCP connection is read-only; the "%s" tool changes state and is not permitted. Reconnect with write access to use it.', 'xspeed' ),
723 $name
724 ),
725 array( 'status' => 403 )
726 );
727 }
728
729 self::$dispatching = true;
730 try {
731 $result = call_user_func( $catalog[ $name ]['handler'], $args );
732
733 // Audit every dispatched call — this is the record the admin
734 // reads to answer "what did the assistant do to my site?".
735 // Recorded here (not per-handler) so a new tool is covered the
736 // moment it joins the catalog.
737 [ $ok, $error ] = self::outcome( $result );
738
739 $scope = empty( $catalog[ $name ]['write'] ) ? 'read' : 'write';
740
741 Mcp_Activity_Log::record( $name, $args, $ok, $error, $scope, self::$channel );
742
743 return $result;
744 } finally {
745 self::$dispatching = false;
746 }
747 }
748
749 /**
750 * Read success/failure out of a handler result.
751 *
752 * Two failure shapes reach here. A handler that validates its own
753 * input returns WP_Error. A handler that delegates to Cli_Bridge gets
754 * back an ARRAY carrying `ok => false` plus `error`, because a
755 * `WP_CLI::error()` inside the shim is a controlled failure rather
756 * than an exception. Reading only the first shape logged every failed
757 * command — a refused purge, a Cloudflare call with no credentials —
758 * as a success.
759 *
760 * @param mixed $result Handler return value.
761 * @return array{0:bool,1:string}
762 */
763 private static function outcome( $result ): array {
764 if ( is_wp_error( $result ) ) {
765 return array( false, $result->get_error_message() );
766 }
767
768 if ( is_array( $result ) && array_key_exists( 'ok', $result ) && ! $result['ok'] ) {
769 $error = isset( $result['error'] ) ? (string) $result['error'] : '';
770 return array( false, '' === $error ? 'Command reported failure.' : $error );
771 }
772
773 return array( true, '' );
774 }
775
776 /** @var string Transport that carried the current call (for the audit log). */
777 private static $channel = 'mcp';
778
779 /**
780 * Name the transport for subsequent invokes — the JSON-RPC endpoint and
781 * the hosted-broker REST routes share this catalog, and the audit trail
782 * should say which one a call arrived on.
783 */
784 public static function set_channel( string $channel ): void {
785 self::$channel = '' === $channel ? 'mcp' : $channel;
786 }
787
788 /** @var bool True while an MCP tool handler is executing. */
789 private static $dispatching = false;
790
791 /**
792 * True while a tool call is being dispatched — lets deeper layers
793 * (e.g. the settings change-log) attribute a mutation to MCP.
794 */
795 public static function in_dispatch(): bool {
796 return self::$dispatching;
797 }
798
799 /*
800 * Handlers — thin proxies to the Free engine. Each takes decoded tool
801 * arguments and returns an array payload (or WP_Error on bad input).
802 */
803
804 /**
805 * Cache status, stats, and detected server.
806 *
807 * @param array $args Unused.
808 * @return array
809 */
810 public static function get_cache_status( array $args ) {
811 unset( $args );
812 $opts = Settings::get();
813 return array(
814 'cache_enabled' => (bool) ( $opts['cache_enabled'] ?? false ),
815 'stats' => Cache::get_stats(),
816 'server' => Server::type(),
817 );
818 }
819
820 /**
821 * All registered module descriptors.
822 *
823 * @param array $args Unused.
824 * @return array
825 */
826 public static function list_modules( array $args ) {
827 unset( $args );
828 return Admin::modules_payload();
829 }
830
831 /**
832 * Before/after cache benchmark timings.
833 *
834 * @param array $args Unused.
835 * @return array
836 */
837 public static function run_benchmark( array $args ) {
838 unset( $args );
839 return Cache_Benchmark::run();
840 }
841
842 /**
843 * Personalized Pro-feature suggestions for this site.
844 *
845 * @param array $args Unused.
846 * @return array
847 */
848 public static function get_pro_audit( array $args ) {
849 unset( $args );
850 return array( 'suggestions' => Pro_Audit::run() );
851 }
852
853 /**
854 * Purge the cache by type.
855 *
856 * @param array $args { type?:string } — one of PURGE_TYPES; default all.
857 * @return array|\WP_Error
858 */
859 public static function purge_cache( array $args ) {
860 $type = isset( $args['type'] ) ? (string) $args['type'] : 'all';
861 if ( '' === $type ) {
862 $type = 'all';
863 }
864 if ( ! in_array( $type, self::PURGE_TYPES, true ) ) {
865 return new \WP_Error(
866 'xspeed_mcp_bad_type',
867 sprintf(
868 /* translators: %s: comma-separated list of valid purge types. */
869 __( 'Invalid purge type. Expected one of: %s', 'xspeed' ),
870 implode( ', ', self::PURGE_TYPES )
871 ),
872 array( 'status' => 400 )
873 );
874 }
875 // Named source, not the default "manual": the purge log's whole job
876 // is to let an admin see that the cache cleared because an assistant
877 // asked, not because someone clicked.
878 $count = Cache::purge_type( $type, __( 'AI assistant', 'xspeed' ) );
879 return array(
880 'purged' => $type,
881 'count' => $count,
882 'stats' => Cache::get_stats(),
883 );
884 }
885
886 /**
887 * Enable or disable page caching.
888 *
889 * @param array $args { enabled:bool }.
890 * @return array|\WP_Error
891 */
892 public static function toggle_cache( array $args ) {
893 if ( ! array_key_exists( 'enabled', $args ) ) {
894 return new \WP_Error(
895 'xspeed_mcp_missing_enabled',
896 __( 'The "enabled" parameter is required (true or false).', 'xspeed' ),
897 array( 'status' => 400 )
898 );
899 }
900 $enabled = rest_sanitize_boolean( $args['enabled'] );
901 $install = Cache::toggle( $enabled );
902
903 // Persist cache_enabled the same way the Free /cache/toggle route
904 // does (class-rest-api.php:235) — Cache::toggle handles the drop-in
905 // + wp-config; Settings owns the option flag.
906 Settings::update( array( 'cache_enabled' => $enabled ) );
907
908 return array(
909 'cache_enabled' => $enabled,
910 'install_state' => $install,
911 'stats' => Cache::get_stats(),
912 );
913 }
914
915 /**
916 * Read a module's schema-validated settings.
917 *
918 * @param array $args { module:string }.
919 * @return array|\WP_Error
920 */
921 public static function get_settings( array $args ) {
922 $module = isset( $args['module'] ) ? (string) $args['module'] : '';
923 if ( '' === $module ) {
924 return new \WP_Error(
925 'xspeed_mcp_missing_module',
926 __( 'The "module" parameter is required.', 'xspeed' ),
927 array( 'status' => 400 )
928 );
929 }
930 return array(
931 'module' => $module,
932 'settings' => Settings_Manager::get( $module ),
933 );
934 }
935
936 /**
937 * Update a module's settings (schema-validated).
938 *
939 * @param array $args { module:string, values:array }.
940 * @return array|\WP_Error
941 */
942 public static function update_settings( array $args ) {
943 $module = isset( $args['module'] ) ? (string) $args['module'] : '';
944 $values = $args['values'] ?? null;
945 if ( '' === $module ) {
946 return new \WP_Error(
947 'xspeed_mcp_missing_module',
948 __( 'The "module" parameter is required.', 'xspeed' ),
949 array( 'status' => 400 )
950 );
951 }
952 if ( ! is_array( $values ) ) {
953 return new \WP_Error(
954 'xspeed_mcp_bad_values',
955 __( 'The "values" parameter must be an object of setting keys.', 'xspeed' ),
956 array( 'status' => 400 )
957 );
958 }
959 return array(
960 'module' => $module,
961 'settings' => Settings_Manager::update( $module, $values ),
962 );
963 }
964
965 /**
966 * List every command run_command can invoke (the full CLI surface).
967 *
968 * @param array $args Unused.
969 * @return array
970 */
971 public static function list_commands( array $args ) {
972 unset( $args );
973 return array( 'commands' => Cli_Bridge::catalog() );
974 }
975
976 /**
977 * Run any registered xSpeed command via the CLI bridge.
978 *
979 * @param array $args { command:string, args?:array, options?:array }.
980 * @return array|\WP_Error
981 */
982 public static function run_command( array $args ) {
983 $command = isset( $args['command'] ) ? (string) $args['command'] : '';
984 if ( '' === $command ) {
985 return new \WP_Error(
986 'xspeed_mcp_missing_command',
987 __( 'The "command" parameter is required.', 'xspeed' ),
988 array( 'status' => 400 )
989 );
990 }
991 $positional = isset( $args['args'] ) && is_array( $args['args'] ) ? $args['args'] : array();
992 $options = isset( $args['options'] ) && is_array( $args['options'] ) ? $args['options'] : array();
993 return Cli_Bridge::run( $command, $positional, $options );
994 }
995
996 /* --------------------------------------------------------------------- */
997 /* Promoted action handlers — typed wrappers over Cli_Bridge. */
998 /* Delegating to the bridge lets a Free tool drive a Pro action (psi, */
999 /* ccss) with no cross-repo class reference, and keeps zero drift. */
1000 /* --------------------------------------------------------------------- */
1001
1002 /**
1003 * Purge the Cloudflare edge cache.
1004 *
1005 * @param array $args Unused.
1006 * @return array|\WP_Error
1007 */
1008 public static function purge_cloudflare( array $args ) {
1009 unset( $args );
1010 return Cli_Bridge::run( 'cf', array( 'purge' ) );
1011 }
1012
1013 /**
1014 * Scan the database for bloat (no deletion).
1015 *
1016 * @param array $args Unused.
1017 * @return array|\WP_Error
1018 */
1019 public static function scan_database( array $args ) {
1020 unset( $args );
1021 return Cli_Bridge::run( 'db', array( 'scan' ) );
1022 }
1023
1024 /**
1025 * Clean database bloat (destructive).
1026 *
1027 * @param array $args Unused.
1028 * @return array|\WP_Error
1029 */
1030 public static function clean_database( array $args ) {
1031 unset( $args );
1032 return Cli_Bridge::run( 'db', array( 'clean' ) );
1033 }
1034
1035 /**
1036 * Flush the persistent object cache.
1037 *
1038 * @param array $args Unused.
1039 * @return array|\WP_Error
1040 */
1041 public static function flush_object_cache( array $args ) {
1042 unset( $args );
1043 return Cli_Bridge::run( 'objcache', array( 'flush' ) );
1044 }
1045
1046 /**
1047 * Start the cache preloader.
1048 *
1049 * @param array $args Unused.
1050 * @return array|\WP_Error
1051 */
1052 public static function start_preloader( array $args ) {
1053 unset( $args );
1054 return Cli_Bridge::run( 'preloader', array( 'start' ) );
1055 }
1056
1057 /**
1058 * Full health diagnostics (checks + stats + buckets + activity).
1059 * Direct typed payload — same tier as get_cache_status — so the agent
1060 * gets structured tones/ids instead of parsing CLI log lines.
1061 *
1062 * @param array $args Unused.
1063 * @return array
1064 */
1065 public static function get_health( array $args ) {
1066 unset( $args );
1067 return array(
1068 'checks' => \XSpeed\Health::checks(),
1069 'stats' => Cache::get_stats(),
1070 'buckets' => \XSpeed\Hit_Counter::buckets(),
1071 'hit_daily' => \XSpeed\Hit_Counter::daily_series( 30 ),
1072 'activity' => \XSpeed\Activity_Log::entries(),
1073 );
1074 }
1075
1076 /**
1077 * Stored benchmark runs + settings-change events (trend data).
1078 *
1079 * @param array $args { limit?:int }.
1080 * @return array
1081 */
1082 public static function get_benchmark_history( array $args ) {
1083 $limit = isset( $args['limit'] ) ? max( 1, min( 100, (int) $args['limit'] ) ) : 100;
1084 $changes = array();
1085 foreach ( \XSpeed\Activity_Log::entries() as $entry ) {
1086 if ( 'settings_changed' === ( $entry['type'] ?? '' ) ) {
1087 $changes[] = array(
1088 'ts' => (int) $entry['ts'],
1089 'message' => (string) $entry['message'],
1090 );
1091 }
1092 }
1093 return array(
1094 'runs' => Cache_Benchmark::history( $limit ),
1095 'changes' => $changes,
1096 );
1097 }
1098
1099 /**
1100 * Purge a single URL's cache entries.
1101 *
1102 * @param array $args { url:string }.
1103 * @return array|\WP_Error
1104 */
1105 /**
1106 * Inspect what is in the page cache (pages + age, or size breakdown).
1107 *
1108 * @param array $args detail: pages|size, limit.
1109 * @return array|\WP_Error
1110 */
1111 public static function get_cache_inventory( array $args ) {
1112 $detail = isset( $args['detail'] ) ? (string) $args['detail'] : 'pages';
1113 $action = 'size' === $detail ? 'size' : 'inventory';
1114 $assoc = array();
1115 if ( isset( $args['limit'] ) && '' !== $args['limit'] ) {
1116 $assoc['limit'] = (string) $args['limit'];
1117 }
1118 return Cli_Bridge::run( 'cache', array( $action ), $assoc );
1119 }
1120
1121 /**
1122 * Recent cache purges and their causes.
1123 *
1124 * @param array $args limit.
1125 * @return array|\WP_Error
1126 */
1127 public static function get_purge_log( array $args ) {
1128 $assoc = array();
1129 if ( isset( $args['limit'] ) && '' !== $args['limit'] ) {
1130 $assoc['limit'] = (string) $args['limit'];
1131 }
1132 return Cli_Bridge::run( 'cache', array( 'purge-log' ), $assoc );
1133 }
1134
1135 /**
1136 * Re-verify (and repair) the server rewrite rules.
1137 *
1138 * @param array $args Unused.
1139 * @return array|\WP_Error
1140 */
1141 public static function recheck_rewrite_rules( array $args ) {
1142 unset( $args );
1143 return Cli_Bridge::run( 'cache', array( 'recheck-rewrite' ) );
1144 }
1145
1146 /**
1147 * Turn Cloudflare development mode on or off.
1148 *
1149 * A boolean rather than two tools: dev-on and dev-off are one decision,
1150 * and offering them separately doubles the surface for no gain.
1151 *
1152 * @param array $args enabled (bool, required).
1153 * @return array|\WP_Error
1154 */
1155 public static function set_cloudflare_dev_mode( array $args ) {
1156 if ( ! array_key_exists( 'enabled', $args ) ) {
1157 return new \WP_Error( 'xspeed_mcp_missing_enabled', __( 'The enabled argument is required.', 'xspeed' ), array( 'status' => 400 ) );
1158 }
1159 $on = filter_var( $args['enabled'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
1160 if ( null === $on ) {
1161 return new \WP_Error( 'xspeed_mcp_invalid_enabled', __( 'The enabled argument must be true or false.', 'xspeed' ), array( 'status' => 400 ) );
1162 }
1163 return Cli_Bridge::run( 'cf', array( $on ? 'dev-on' : 'dev-off' ) );
1164 }
1165
1166 /**
1167 * Optimize database tables (distinct from clean_database, which deletes).
1168 *
1169 * @param array $args Unused.
1170 * @return array|\WP_Error
1171 */
1172 public static function optimize_database( array $args ) {
1173 unset( $args );
1174 return Cli_Bridge::run( 'db', array( 'optimize' ) );
1175 }
1176
1177 /**
1178 * Object cache state, or the server snippet that enables it.
1179 *
1180 * @param array $args detail: status|snippet.
1181 * @return array|\WP_Error
1182 */
1183 public static function get_object_cache_status( array $args ) {
1184 $detail = isset( $args['detail'] ) ? (string) $args['detail'] : 'status';
1185 $action = 'snippet' === $detail ? 'snippet' : 'status';
1186 return Cli_Bridge::run( 'objcache', array( $action ) );
1187 }
1188
1189 /**
1190 * Install or remove the object-cache drop-in.
1191 *
1192 * @param array $args enabled (bool, required).
1193 * @return array|\WP_Error
1194 */
1195 public static function toggle_object_cache( array $args ) {
1196 if ( ! array_key_exists( 'enabled', $args ) ) {
1197 return new \WP_Error( 'xspeed_mcp_missing_enabled', __( 'The enabled argument is required.', 'xspeed' ), array( 'status' => 400 ) );
1198 }
1199 $on = filter_var( $args['enabled'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
1200 if ( null === $on ) {
1201 return new \WP_Error( 'xspeed_mcp_invalid_enabled', __( 'The enabled argument must be true or false.', 'xspeed' ), array( 'status' => 400 ) );
1202 }
1203 return Cli_Bridge::run( 'objcache', array( $on ? 'enable' : 'disable' ) );
1204 }
1205
1206 /**
1207 * List or clear stored Critical CSS.
1208 *
1209 * @param array $args action: list|clear.
1210 * @return array|\WP_Error
1211 */
1212 public static function manage_critical_css( array $args ) {
1213 $action = isset( $args['action'] ) ? (string) $args['action'] : '';
1214 if ( ! in_array( $action, array( 'list', 'clear' ), true ) ) {
1215 return new \WP_Error( 'xspeed_mcp_invalid_action', __( 'The action argument must be "list" or "clear".', 'xspeed' ), array( 'status' => 400 ) );
1216 }
1217 return Cli_Bridge::run( 'ccss', array( $action ) );
1218 }
1219
1220 /**
1221 * Preloader progress.
1222 *
1223 * @param array $args Unused.
1224 * @return array|\WP_Error
1225 */
1226 public static function get_preloader_status( array $args ) {
1227 unset( $args );
1228 return Cli_Bridge::run( 'preloader', array( 'status' ) );
1229 }
1230
1231 /**
1232 * Stop a running preload.
1233 *
1234 * @param array $args Unused.
1235 * @return array|\WP_Error
1236 */
1237 public static function stop_preloader( array $args ) {
1238 unset( $args );
1239 return Cli_Bridge::run( 'preloader', array( 'stop' ) );
1240 }
1241
1242 /**
1243 * Stored external audit runs (PSI / GTmetrix).
1244 *
1245 * Read-only by construction: it reads the option Score already wrote. No
1246 * outbound call is made, which is what lets the Hub poll this on a
1247 * schedule without spending the site owner's PSI or GTmetrix quota.
1248 *
1249 * @param array $args limit.
1250 * @return array|\WP_Error
1251 */
1252 public static function get_score_history( array $args ) {
1253 if ( ! class_exists( '\\XSpeed\\Score' ) ) {
1254 return new \WP_Error( 'xspeed_mcp_no_score', __( 'External scores are not available on this site.', 'xspeed' ), array( 'status' => 404 ) );
1255 }
1256
1257 $limit = isset( $args['limit'] ) ? (int) $args['limit'] : 100;
1258 $limit = max( 1, min( 500, $limit ) );
1259
1260 $runs = array();
1261 foreach ( array_slice( \XSpeed\Score::history(), 0, $limit ) as $run ) {
1262 if ( ! is_array( $run ) ) {
1263 continue;
1264 }
1265 $metrics = isset( $run['metrics'] ) && is_array( $run['metrics'] ) ? $run['metrics'] : array();
1266 $runs[] = array(
1267 'provider' => isset( $run['provider'] ) ? (string) $run['provider'] : 'unknown',
1268 'ts' => isset( $run['ts'] ) ? (int) $run['ts'] : 0,
1269 'url' => isset( $run['url'] ) ? (string) $run['url'] : '',
1270 'strategy' => isset( $run['strategy'] ) ? (string) $run['strategy'] : null,
1271 // Null, never 0: Score distinguishes "no score" from "scored
1272 // zero", and flattening that reports a failed audit as a
1273 // catastrophic result.
1274 'score' => isset( $run['score'] ) && is_numeric( $run['score'] ) ? (int) $run['score'] : null,
1275 'metrics' => array(
1276 'lcp' => self::metric_or_null( $metrics, 'lcp' ),
1277 'fcp' => self::metric_or_null( $metrics, 'fcp' ),
1278 'cls' => self::metric_or_null( $metrics, 'cls' ),
1279 'tbt' => self::metric_or_null( $metrics, 'tbt' ),
1280 'si' => self::metric_or_null( $metrics, 'si' ),
1281 'ttfb' => self::metric_or_null( $metrics, 'ttfb' ),
1282 ),
1283 'report_url' => self::report_url_for( $run ),
1284 );
1285 }
1286
1287 return array(
1288 'runs' => $runs,
1289 'total' => count( \XSpeed\Score::history() ),
1290 );
1291 }
1292
1293 /**
1294 * One metric as a float, or null when absent/non-numeric.
1295 *
1296 * @param array $metrics Metric bag.
1297 * @param string $key Metric id.
1298 */
1299 private static function metric_or_null( array $metrics, string $key ): ?float {
1300 return isset( $metrics[ $key ] ) && is_numeric( $metrics[ $key ] ) ? (float) $metrics[ $key ] : null;
1301 }
1302
1303 /**
1304 * Deep link to the provider's own report, when one exists.
1305 *
1306 * GTmetrix hosts a durable report per test, so its id is enough to build
1307 * the link. PSI does NOT — a Lighthouse result is returned to the caller
1308 * and never hosted, so there is genuinely nothing to link to and this
1309 * returns null rather than inventing a URL that 404s.
1310 *
1311 * @param array $run One stored run.
1312 */
1313 private static function report_url_for( array $run ): ?string {
1314 $provider = isset( $run['provider'] ) ? (string) $run['provider'] : '';
1315 if ( 'gtmetrix' !== $provider ) {
1316 return null;
1317 }
1318 $test_id = isset( $run['test_id'] ) ? trim( (string) $run['test_id'] ) : '';
1319 if ( '' === $test_id ) {
1320 return null;
1321 }
1322 return 'https://gtmetrix.com/reports/' . rawurlencode( $test_id );
1323 }
1324
1325 public static function purge_url( array $args ) {
1326 $url = isset( $args['url'] ) ? trim( (string) $args['url'] ) : '';
1327 if ( '' === $url ) {
1328 return new \WP_Error( 'xspeed_mcp_missing_url', __( 'The url argument is required.', 'xspeed' ), array( 'status' => 400 ) );
1329 }
1330 return Cli_Bridge::run( 'cache', array( 'purge-url', $url ), array( 'cause' => __( 'AI assistant', 'xspeed' ) ) );
1331 }
1332
1333 /**
1334 * Probe the configured object-cache backend (connect + read/write).
1335 *
1336 * @param array $args Unused.
1337 * @return array|\WP_Error
1338 */
1339 public static function test_object_cache( array $args ) {
1340 unset( $args );
1341 return Cli_Bridge::run( 'objcache', array( 'test' ) );
1342 }
1343
1344 /**
1345 * Verify the saved Cloudflare credentials.
1346 *
1347 * @param array $args Unused.
1348 * @return array|\WP_Error
1349 */
1350 public static function cloudflare_verify( array $args ) {
1351 unset( $args );
1352 return Cli_Bridge::run( 'cf', array( 'verify' ) );
1353 }
1354
1355 /**
1356 * Run a PageSpeed Insights audit (Pro).
1357 *
1358 * @param array $args { url?:string, strategy?:string }.
1359 * @return array|\WP_Error
1360 */
1361 public static function run_pagespeed( array $args ) {
1362 $options = array();
1363 if ( ! empty( $args['url'] ) ) {
1364 $options['url'] = (string) $args['url'];
1365 }
1366 if ( ! empty( $args['strategy'] ) ) {
1367 $options['strategy'] = (string) $args['strategy'];
1368 }
1369 // Was reachable only via the generated xspeed_psi alias, which this
1370 // change removes — so it moves onto the typed tool rather than being
1371 // lost with it.
1372 if ( ! empty( $args['force'] ) && filter_var( $args['force'], FILTER_VALIDATE_BOOLEAN ) ) {
1373 $options['force'] = true;
1374 }
1375
1376 // Prefer the richer Pro engine when it's installed; otherwise drive
1377 // Free's own score command. Same tool name either way — an assistant
1378 // asking for a PageSpeed audit shouldn't have to know which tier the
1379 // site runs, and the two write to the same run history.
1380 if ( isset( Cli_Bridge::commands()['xspeed psi'] ) ) {
1381 return Cli_Bridge::run( 'psi', array(), $options );
1382 }
1383 return Cli_Bridge::run( 'score', array( 'run' ), $options );
1384 }
1385
1386 /**
1387 * Generate Critical CSS (Pro).
1388 *
1389 * @param array $args Unused.
1390 * @return array|\WP_Error
1391 */
1392 public static function generate_critical_css( array $args ) {
1393 unset( $args );
1394 return Cli_Bridge::run( 'ccss', array( 'generate' ) );
1395 }
1396
1397 /**
1398 * Build a JSON Schema object node.
1399 *
1400 * @param array $properties Property map.
1401 * @param string[] $required Required property names.
1402 */
1403 private static function object_schema( array $properties, array $required ): array {
1404 $schema = array(
1405 'type' => 'object',
1406 'properties' => (object) $properties,
1407 );
1408 if ( ! empty( $required ) ) {
1409 $schema['required'] = array_values( $required );
1410 }
1411 return $schema;
1412 }
1413 }
1414