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

1,630 lines 59.3 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 * Per-call `configure` grant. Writing credential/secret fields over MCP is
77 * gated on this and it is OFF by default — even a write-scoped connection
78 * cannot rewrite an API token or password unless it was granted the
79 * explicit `configure` scope. Null means "no per-call grant" (the pairing
80 * token / JSON-RPC path), where it falls back to a filter. (#116)
81 *
82 * @var bool|null
83 */
84 private static $configure_override = null;
85
86 /**
87 * Set whether the active credential may write secret fields (the OAuth
88 * `configure` scope). Passing null clears it back to the filter default.
89 *
90 * @param bool|null $can_configure Whether the credential carries `configure`.
91 */
92 public static function set_configure_override( ?bool $can_configure ): void {
93 self::$configure_override = $can_configure;
94 }
95
96 /**
97 * Whether the active MCP credential may write credential/secret fields.
98 * Uses the per-call override (OAuth `configure` scope) when set; otherwise
99 * the `xspeed_mcp_allow_credential_writes` filter, which defaults to false
100 * so credential writes are off by default on every connection — including
101 * the pairing token. A site owner who wants an agent to manage credentials
102 * opts in by returning true from that filter. (#116)
103 */
104 public static function can_configure(): bool {
105 if ( null !== self::$configure_override ) {
106 return self::$configure_override;
107 }
108 /**
109 * Allow MCP connections to write credential (secret) fields. Off by
110 * default; see docs/MCP-SERVER.md. Applies to pairing-token connections
111 * and any OAuth grant lacking the `configure` scope.
112 *
113 * @param bool $allow Whether credential writes over MCP are permitted.
114 */
115 return (bool) apply_filters( 'xspeed_mcp_allow_credential_writes', false );
116 }
117
118 /**
119 * Full tool catalog: name => descriptor. `handler` is a callable
120 * ( array $args ) : array|\WP_Error. `write` marks tools that mutate
121 * state (used for read-only scope enforcement).
122 *
123 * @return array<string, array{description:string, inputSchema:array, handler:callable, write:bool}>
124 */
125 public static function catalog(): array {
126 $catalog = array(
127 'get_cache_status' => array(
128 '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.',
129 'inputSchema' => self::object_schema( array(), array() ),
130 'write' => false,
131 'handler' => array( self::class, 'get_cache_status' ),
132 ),
133 'list_modules' => array(
134 'description' => 'List all xSpeed modules (free and Pro) with their settings schema and status.',
135 'inputSchema' => self::object_schema( array(), array() ),
136 'write' => false,
137 'handler' => array( self::class, 'list_modules' ),
138 ),
139 'run_benchmark' => array(
140 '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).',
141 'inputSchema' => self::object_schema( array(), array() ),
142 'write' => false,
143 'handler' => array( self::class, 'run_benchmark' ),
144 ),
145 'get_pro_audit' => array(
146 'description' => 'Personalized list of Pro features that would benefit THIS site, from its current settings and cache stats.',
147 'inputSchema' => self::object_schema( array(), array() ),
148 'write' => false,
149 'handler' => array( self::class, 'get_pro_audit' ),
150 ),
151 'purge_cache' => array(
152 'description' => 'Purge the site cache. "type" selects what to purge: all, page, assets, object, or rest. Defaults to all.',
153 'inputSchema' => self::object_schema(
154 array(
155 'type' => array(
156 'type' => 'string',
157 'enum' => self::PURGE_TYPES,
158 'description' => 'What to purge. Defaults to "all".',
159 ),
160 ),
161 array()
162 ),
163 'write' => true,
164 'handler' => array( self::class, 'purge_cache' ),
165 ),
166 'toggle_cache' => array(
167 'description' => 'Enable or disable page caching. Installs/removes the cache drop-in and WP_CACHE constant as needed.',
168 'inputSchema' => self::object_schema(
169 array(
170 'enabled' => array(
171 'type' => 'boolean',
172 'description' => 'true to enable caching, false to disable.',
173 ),
174 ),
175 array( 'enabled' )
176 ),
177 'write' => true,
178 'handler' => array( self::class, 'toggle_cache' ),
179 ),
180 'get_settings' => array(
181 'description' => 'Read the settings for a given xSpeed module (e.g. "minify", "gzip"). Returns schema-validated values.',
182 'inputSchema' => self::object_schema(
183 array(
184 'module' => array(
185 'type' => 'string',
186 'description' => 'The module slug, e.g. "minify".',
187 ),
188 ),
189 array( 'module' )
190 ),
191 'write' => false,
192 'handler' => array( self::class, 'get_settings' ),
193 ),
194 'update_settings' => array(
195 '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.',
196 'inputSchema' => self::object_schema(
197 array(
198 'module' => array(
199 'type' => 'string',
200 'description' => 'The module slug, e.g. "minify".',
201 ),
202 'values' => array(
203 'type' => 'object',
204 'description' => 'Map of setting keys to new values.',
205 ),
206 ),
207 array( 'module', 'values' )
208 ),
209 'write' => true,
210 'handler' => array( self::class, 'update_settings' ),
211 ),
212 // --- Promoted high-value actions: dedicated typed tools so the AI
213 // calls them directly (no run_command hop). Each is a thin wrapper
214 // over Cli_Bridge, so Free tools can drive Pro actions (psi, ccss)
215 // without a cross-repo class reference, and none can drift from the
216 // CLI. ---
217 'purge_cloudflare' => array(
218 'description' => 'Purge the Cloudflare edge cache for this site (requires Cloudflare connected in the Cloudflare module).',
219 'inputSchema' => self::object_schema( array(), array() ),
220 'write' => true,
221 'handler' => array( self::class, 'purge_cloudflare' ),
222 ),
223 'scan_database' => array(
224 'description' => 'Scan the database for bloat (post revisions, auto-drafts, trashed posts, spam comments, expired transients, orphaned meta) without deleting anything.',
225 'inputSchema' => self::object_schema( array(), array() ),
226 'write' => false,
227 'handler' => array( self::class, 'scan_database' ),
228 ),
229 'clean_database' => array(
230 'description' => 'Clean database bloat. Removes the categories currently enabled in the Database module settings. Destructive — run scan_database first to preview.',
231 'inputSchema' => self::object_schema( array(), array() ),
232 'write' => true,
233 'handler' => array( self::class, 'clean_database' ),
234 ),
235 'flush_object_cache' => array(
236 'description' => 'Flush the persistent object cache (Redis / Memcached), if enabled.',
237 'inputSchema' => self::object_schema( array(), array() ),
238 'write' => true,
239 'handler' => array( self::class, 'flush_object_cache' ),
240 ),
241 'start_preloader' => array(
242 'description' => 'Start the cache preloader — crawls the sitemap to warm the page cache in the background.',
243 'inputSchema' => self::object_schema( array(), array() ),
244 'write' => true,
245 'handler' => array( self::class, 'start_preloader' ),
246 ),
247 'run_pagespeed' => array(
248 '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.',
249 'inputSchema' => self::object_schema(
250 array(
251 'url' => array(
252 'type' => 'string',
253 'description' => 'URL to audit. Defaults to the site home page.',
254 ),
255 'strategy' => array(
256 'type' => 'string',
257 'enum' => array( 'mobile', 'desktop' ),
258 'description' => 'Audit strategy. Defaults to "mobile".',
259 ),
260 'force' => array(
261 'type' => 'boolean',
262 'description' => 'Re-run even when a recent cached result exists. Use after a change you want measured immediately.',
263 ),
264 ),
265 array()
266 ),
267 'write' => false,
268 'handler' => array( self::class, 'run_pagespeed' ),
269 ),
270 'generate_critical_css' => array(
271 'description' => 'Generate above-the-fold Critical CSS for the site (Pro). Calls the external generator and stores the result.',
272 'inputSchema' => self::object_schema( array(), array() ),
273 'write' => true,
274 'handler' => array( self::class, 'generate_critical_css' ),
275 ),
276 'get_health' => array(
277 '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.',
278 'inputSchema' => self::object_schema( array(), array() ),
279 'write' => false,
280 'handler' => array( self::class, 'get_health' ),
281 ),
282 'get_benchmark_history' => array(
283 '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.',
284 'inputSchema' => self::object_schema(
285 array(
286 'limit' => array(
287 'type' => 'integer',
288 'description' => 'Max runs to return (default 100).',
289 ),
290 ),
291 array()
292 ),
293 'write' => false,
294 'handler' => array( self::class, 'get_benchmark_history' ),
295 ),
296 'get_score_history' => array(
297 '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.',
298 'inputSchema' => self::object_schema(
299 array(
300 'limit' => array(
301 'type' => 'integer',
302 'description' => 'Max runs to return, newest first (default 100).',
303 ),
304 ),
305 array()
306 ),
307 'write' => false,
308 'handler' => array( self::class, 'get_score_history' ),
309 ),
310 // --- Actions promoted out of the generated `xspeed_*` aliases.
311 // Each was previously reachable ONLY as an `action` string on a
312 // coarse generated tool that was marked write regardless, so a
313 // read-only connection lost the read ones. Typed here with an
314 // honest kind so the AI stops guessing and the deny-list has one
315 // name per action. ---
316 'get_cache_inventory' => array(
317 '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.',
318 'inputSchema' => self::object_schema(
319 array(
320 'detail' => array(
321 'type' => 'string',
322 'enum' => array( 'pages', 'size' ),
323 'description' => '"pages" lists cached pages and their age; "size" breaks down disk usage. Defaults to "pages".',
324 ),
325 'limit' => array(
326 'type' => 'string',
327 'description' => 'Max rows to return (pages only).',
328 ),
329 ),
330 array()
331 ),
332 'write' => false,
333 'handler' => array( self::class, 'get_cache_inventory' ),
334 ),
335 'get_purge_log' => array(
336 'description' => 'Recent cache purges and what triggered each one. Use it to explain why a page stopped being cached. Read-only.',
337 'inputSchema' => self::object_schema(
338 array(
339 'limit' => array(
340 'type' => 'string',
341 'description' => 'Max entries to return.',
342 ),
343 ),
344 array()
345 ),
346 'write' => false,
347 'handler' => array( self::class, 'get_purge_log' ),
348 ),
349 'recheck_rewrite_rules' => array(
350 'description' => 'Re-verify the server rewrite rules that route requests to the cache, and repair them if they drifted.',
351 'inputSchema' => self::object_schema( array(), array() ),
352 'write' => true,
353 'handler' => array( self::class, 'recheck_rewrite_rules' ),
354 ),
355 'set_cloudflare_dev_mode' => array(
356 'description' => 'Turn Cloudflare development mode on or off. On bypasses the edge cache for ~3 hours so origin changes show immediately.',
357 'inputSchema' => self::object_schema(
358 array(
359 'enabled' => array(
360 'type' => 'boolean',
361 'description' => 'true turns development mode on, false turns it off.',
362 ),
363 ),
364 array( 'enabled' )
365 ),
366 'write' => true,
367 'handler' => array( self::class, 'set_cloudflare_dev_mode' ),
368 ),
369 'optimize_database' => array(
370 'description' => 'Run table optimization on the WordPress database (reclaims space after cleanup). Separate from clean_database, which deletes bloat rows.',
371 'inputSchema' => self::object_schema( array(), array() ),
372 'write' => true,
373 'handler' => array( self::class, 'optimize_database' ),
374 ),
375 'get_object_cache_status' => array(
376 'description' => 'Object cache state: whether the drop-in is installed, which backend is configured, and the server snippet needed to enable it. Read-only.',
377 'inputSchema' => self::object_schema(
378 array(
379 'detail' => array(
380 'type' => 'string',
381 'enum' => array( 'status', 'snippet' ),
382 'description' => '"status" reports the current state; "snippet" returns the server config to enable it. Defaults to "status".',
383 ),
384 ),
385 array()
386 ),
387 'write' => false,
388 'handler' => array( self::class, 'get_object_cache_status' ),
389 ),
390 'toggle_object_cache' => array(
391 '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.',
392 'inputSchema' => self::object_schema(
393 array(
394 'enabled' => array(
395 'type' => 'boolean',
396 'description' => 'true installs the drop-in, false removes it.',
397 ),
398 ),
399 array( 'enabled' )
400 ),
401 'write' => true,
402 'handler' => array( self::class, 'toggle_object_cache' ),
403 ),
404 'manage_critical_css' => array(
405 'description' => 'List the stored Critical CSS entries, or clear them so they regenerate. Use generate_critical_css to create them.',
406 'inputSchema' => self::object_schema(
407 array(
408 'action' => array(
409 'type' => 'string',
410 'enum' => array( 'list', 'clear' ),
411 'description' => '"list" returns what is stored; "clear" deletes it.',
412 ),
413 ),
414 array( 'action' )
415 ),
416 'write' => true,
417 'handler' => array( self::class, 'manage_critical_css' ),
418 ),
419 'get_preloader_status' => array(
420 'description' => 'Cache preloader progress: whether a run is active, how far through the URL list it is. Read-only.',
421 'inputSchema' => self::object_schema( array(), array() ),
422 'write' => false,
423 'handler' => array( self::class, 'get_preloader_status' ),
424 ),
425 'stop_preloader' => array(
426 'description' => 'Stop a running cache preload. Safe mid-run — already-warmed pages stay cached.',
427 'inputSchema' => self::object_schema( array(), array() ),
428 'write' => true,
429 'handler' => array( self::class, 'stop_preloader' ),
430 ),
431 'purge_url' => array(
432 '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.',
433 'inputSchema' => self::object_schema(
434 array(
435 'url' => array(
436 'type' => 'string',
437 'description' => 'Absolute URL or site-relative path, e.g. "https://site.com/about/" or "/about/".',
438 ),
439 ),
440 array( 'url' )
441 ),
442 'write' => true,
443 'handler' => array( self::class, 'purge_url' ),
444 ),
445 'test_object_cache' => array(
446 '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.',
447 'inputSchema' => self::object_schema( array(), array() ),
448 'write' => false,
449 'handler' => array( self::class, 'test_object_cache' ),
450 ),
451 'cloudflare_verify' => array(
452 'description' => 'Verify the saved Cloudflare credentials against the Cloudflare API (token/zone check). Read-only — use purge_cloudflare to purge the edge.',
453 'inputSchema' => self::object_schema( array(), array() ),
454 'write' => false,
455 'handler' => array( self::class, 'cloudflare_verify' ),
456 ),
457 'list_commands' => array(
458 '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.',
459 'inputSchema' => self::object_schema( array(), array() ),
460 'write' => false,
461 'handler' => array( self::class, 'list_commands' ),
462 ),
463 'run_command' => array(
464 '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"}).',
465 'inputSchema' => self::object_schema(
466 array(
467 'command' => array(
468 'type' => 'string',
469 'description' => 'Command name, e.g. "cloudflare purge" or "database scan" (the "xspeed " prefix is optional).',
470 ),
471 'args' => array(
472 'type' => 'array',
473 'description' => 'Positional arguments, if the command takes any.',
474 'items' => array( 'type' => 'string' ),
475 ),
476 'options' => array(
477 'type' => 'object',
478 'description' => 'Named options / flags, e.g. { "url": "https://site.com", "strategy": "mobile", "force": true }.',
479 ),
480 ),
481 array( 'command' )
482 ),
483 'write' => true,
484 'handler' => array( self::class, 'run_command' ),
485 ),
486 );
487
488 // Dedicated tools that wrap a command only present when a given
489 // module is active (e.g. Pro): drop them if the command isn't
490 // registered, so we never advertise a tool that always fails. The
491 // action stays reachable via run_command if the command exists.
492 $conditional = array(
493 'generate_critical_css' => 'xspeed ccss',
494 'purge_cloudflare' => 'xspeed cf',
495 'cloudflare_verify' => 'xspeed cf',
496 'flush_object_cache' => 'xspeed objcache',
497 'test_object_cache' => 'xspeed objcache',
498 'start_preloader' => 'xspeed preloader',
499 'scan_database' => 'xspeed db',
500 'clean_database' => 'xspeed db',
501 'purge_url' => 'xspeed cache',
502 'get_cache_inventory' => 'xspeed cache',
503 'get_purge_log' => 'xspeed cache',
504 'recheck_rewrite_rules' => 'xspeed cache',
505 'set_cloudflare_dev_mode' => 'xspeed cf',
506 'optimize_database' => 'xspeed db',
507 'get_object_cache_status' => 'xspeed objcache',
508 'toggle_object_cache' => 'xspeed objcache',
509 'manage_critical_css' => 'xspeed ccss',
510 'get_preloader_status' => 'xspeed preloader',
511 'stop_preloader' => 'xspeed preloader',
512 );
513
514 /*
515 * Tools that are ALWAYS in the catalog, mapped to the command they
516 * cover. These are listed separately from $conditional because the
517 * two roles are different and used to be conflated in one map: this
518 * set only tells the alias generator "don't emit an alias for this
519 * command, a typed tool already covers it" — it must never drop a
520 * tool.
521 *
522 * That conflation is exactly what broke get_settings/update_settings:
523 * they were mapped to `xspeed settings`, a command that did not exist,
524 * so the drop loop unset them on every request and they never reached
525 * tools/list. `xspeed settings` now exists (SettingsModule), but the
526 * split is what stops the class of bug recurring — an unconditional
527 * tool can no longer be removed by a command going away. (#149/#153)
528 */
529 $always = array(
530 'get_settings' => 'xspeed settings',
531 'update_settings' => 'xspeed settings',
532 'run_pagespeed' => 'xspeed psi',
533 'get_health' => 'xspeed health',
534 'get_score_history' => 'xspeed score',
535 );
536
537 $commands = Cli_Bridge::commands();
538 foreach ( $conditional as $tool => $command ) {
539 if ( ! isset( $commands[ $command ] ) ) {
540 unset( $catalog[ $tool ] );
541 }
542 }
543 // Alias generation reads both maps; the drop loop above reads only
544 // $conditional.
545 $conditional = array_merge( $conditional, $always );
546
547 /*
548 * One dedicated tool per xSpeed CLI command, generated from the same
549 * Cli_Bridge catalog the CLI registers from — so the AI can reach the
550 * long tail without the list_commands -> run_command hop, and the
551 * generated set can never drift from the CLI.
552 *
553 * Commands already covered by a typed tool above are SKIPPED. The
554 * `isset()` guard below only catches NAME collisions, and a generated
555 * name never collides — `xspeed cf` becomes `xspeed_cf`, which is not
556 * `purge_cloudflare`. So both used to ship: two tools for one action,
557 * with the generated one marked write even when it wrapped a read,
558 * and a per-tool permission on one name silently bypassable via the
559 * other. $conditional already maps every typed tool to its command;
560 * inverted, that IS the skip list.
561 */
562 foreach ( self::cli_generated_tools( array_flip( $conditional ) ) as $name => $spec ) {
563 if ( ! isset( $catalog[ $name ] ) ) {
564 $catalog[ $name ] = $spec;
565 }
566 }
567
568 return $catalog;
569 }
570
571 /**
572 * Generate one MCP tool per registered xSpeed CLI command. Each wraps
573 * Cli_Bridge::run(): the tool's `action` (the command's first positional,
574 * e.g. `verify`/`purge` for `xspeed cf`) plus any named options are passed
575 * straight through. Tool names are the command with the `xspeed ` prefix
576 * dropped and spaces -> underscores (`xspeed cf` -> `xspeed_cf`).
577 *
578 * @return array<string, array{description:string, inputSchema:array, write:bool, handler:callable}>
579 */
580 private static function cli_generated_tools( array $covered = array() ): array {
581 $tools = array();
582 foreach ( Cli_Bridge::commands() as $command => $spec ) {
583 // Already exposed as typed tools with real schemas and honest
584 // read/write kinds — generating a coarse alias too would give the
585 // AI two ways to do one thing and make a per-tool permission on
586 // the typed name bypassable via the generated one.
587 if ( isset( $covered[ $command ] ) ) {
588 continue;
589 }
590 $tool_name = self::cli_tool_name( $command );
591 if ( '' === $tool_name ) {
592 continue;
593 }
594
595 // Build the input schema from the command's synopsis: positional
596 // args become string properties (the first is usually the action,
597 // exposed with its allowed values as an enum); assoc args become
598 // named options.
599 $properties = array();
600 $required = array();
601 foreach ( $spec['synopsis'] as $arg ) {
602 if ( ! isset( $arg['name'] ) ) {
603 continue;
604 }
605 $arg_name = (string) $arg['name'];
606 $prop = array(
607 'type' => 'string',
608 'description' => isset( $arg['description'] ) ? (string) $arg['description'] : '',
609 );
610 if ( isset( $arg['options'] ) && is_array( $arg['options'] ) && ! empty( $arg['options'] ) ) {
611 $prop['enum'] = array_values( array_map( 'strval', $arg['options'] ) );
612 }
613 $properties[ $arg_name ] = $prop;
614 $is_optional = ! empty( $arg['optional'] );
615 $is_flag = isset( $arg['type'] ) && 'flag' === $arg['type'];
616 if ( ! $is_optional && ! $is_flag ) {
617 $required[] = $arg_name;
618 }
619 }
620
621 $description = '' !== $spec['shortdesc']
622 ? $spec['shortdesc']
623 : sprintf( 'Run the "%s" xSpeed command.', $command );
624
625 list( $write, $write_actions, $read_actions ) = self::cli_write_profile( $command, $spec['synopsis'] );
626
627 $tools[ $tool_name ] = array(
628 'description' => $description,
629 'inputSchema' => self::object_schema( $properties, $required ),
630 'write' => $write,
631 // The action values that mutate state. When set, read-only
632 // enforcement is per-ACTION (a read-only grant may still call
633 // the tool with a read action like "status"/"scan").
634 'write_actions' => $write_actions,
635 // The complement — actions positively classified as reads.
636 // action_writes() allowlists against THIS rather than negating
637 // write_actions, so an action added to a command later is
638 // refused under a read-only grant until it has been
639 // classified, instead of silently becoming callable.
640 'read_actions' => $read_actions,
641 'handler' => self::cli_handler_for( $command, $spec['synopsis'] ),
642 );
643 }
644 return $tools;
645 }
646
647 /** Derive an MCP tool name from a CLI command ("xspeed cf" -> "xspeed_cf"). */
648 private static function cli_tool_name( string $command ): string {
649 $command = trim( preg_replace( '/\s+/', ' ', $command ) ?? '' );
650 if ( '' === $command ) {
651 return '';
652 }
653 return str_replace( ' ', '_', $command );
654 }
655
656 /** Action verbs that only inspect state (never mutate). */
657 private const CLI_READ_VERBS = array( 'status', 'scan', 'list', 'verify', 'get', 'show', 'info', 'export', 'preview', 'check', 'snippet', 'test' );
658
659 /** Commands with NO action enum that are nonetheless pure inspection. */
660 private const CLI_READ_ONLY_COMMANDS = array( 'xspeed health', 'xspeed support' );
661
662 /**
663 * Compute the write profile for a generated command tool:
664 * [ $write_bool, $write_actions ]
665 * where $write_actions is the list of action values that mutate state
666 * (empty when the tool has no action enum). $write_bool is the tool-level
667 * flag: true if ANY action writes (so read-only clients see it flagged),
668 * but per-action enforcement in invoke() still lets a read-only grant run
669 * the tool's read actions (e.g. `minify status` while `minify purge` is
670 * refused).
671 *
672 * @param string $command Full command name.
673 * @param array $synopsis Command synopsis.
674 * @return array{0:bool,1:string[],2:string[]} write flag, write actions, read actions
675 */
676 /**
677 * Does THIS call mutate state, given the action the caller submitted?
678 *
679 * The tool-level `write` flag is true when ANY of a command's actions
680 * write, so read-only clients can see the tool is capable of mutating.
681 * Enforcing on that flag alone refuses the whole tool — which is how a
682 * read-only grant lost the ability to run `xspeed_minify status` even
683 * though only `purge` writes. `write_actions` records exactly which
684 * action values mutate; this is what reads it.
685 *
686 * Fails CLOSED in every ambiguous case. An action that isn't in the
687 * schema, an absent action, or a tool with no per-action profile all fall
688 * back to the coarse flag and are refused. A read-only grant may end up
689 * with less access than strictly necessary; it must never end up with
690 * more.
691 *
692 * @param array $tool The catalog entry.
693 * @param array $args The submitted arguments.
694 */
695 private static function action_writes( array $tool, array $args ): bool {
696 $write_actions = isset( $tool['write_actions'] ) && is_array( $tool['write_actions'] )
697 ? $tool['write_actions']
698 : array();
699
700 // No per-action profile — the coarse flag is all we have.
701 if ( empty( $write_actions ) ) {
702 return true;
703 }
704
705 $action = isset( $args['action'] ) && is_scalar( $args['action'] )
706 ? strtolower( trim( (string) $args['action'] ) )
707 : '';
708
709 // No action supplied: the command's own default is unknown here, so
710 // treat it as a write rather than guessing.
711 if ( '' === $action ) {
712 return true;
713 }
714
715 // Only an action we positively recognise as read is allowed through.
716 // Anything unknown is refused, so a future action added to a command
717 // can't silently become callable under a read-only grant before it has
718 // been classified.
719 $known = array_map(
720 static function ( $a ) {
721 return strtolower( trim( (string) $a ) );
722 },
723 isset( $tool['read_actions'] ) && is_array( $tool['read_actions'] ) ? $tool['read_actions'] : array()
724 );
725
726 return ! in_array( $action, $known, true );
727 }
728
729 private static function cli_write_profile( string $command, array $synopsis ): array {
730 // Command with an action enum → classify each action.
731 foreach ( $synopsis as $arg ) {
732 if ( isset( $arg['type'], $arg['options'] ) && 'positional' === $arg['type'] && is_array( $arg['options'] ) ) {
733 $write_actions = array();
734 $read_actions = array();
735 foreach ( $arg['options'] as $opt ) {
736 if ( in_array( strtolower( (string) $opt ), self::CLI_READ_VERBS, true ) ) {
737 $read_actions[] = (string) $opt;
738 } else {
739 $write_actions[] = (string) $opt;
740 }
741 }
742 return array( ! empty( $write_actions ), $write_actions, $read_actions );
743 }
744 }
745
746 // No action enum: a small allow-list of pure-inspection commands is
747 // read-only; everything else defaults to write (safe — a read-only
748 // grant never mutates).
749 $is_read = in_array( trim( $command ), self::CLI_READ_ONLY_COMMANDS, true );
750 return array( ! $is_read, array(), array() );
751 }
752
753 /**
754 * Build the handler for a generated command tool. It maps the tool's
755 * arguments back to Cli_Bridge::run(): positional synopsis args (in order)
756 * become $args; everything else is passed as named options.
757 *
758 * @param string $command Full command name.
759 * @param array $synopsis Command synopsis.
760 * @return callable
761 */
762 private static function cli_handler_for( string $command, array $synopsis ): callable {
763 // Names of the positional args, in declared order.
764 $positionals = array();
765 foreach ( $synopsis as $arg ) {
766 if ( isset( $arg['name'] ) && ( ! isset( $arg['type'] ) || 'positional' === $arg['type'] ) ) {
767 $positionals[] = (string) $arg['name'];
768 }
769 }
770
771 return static function ( array $tool_args ) use ( $command, $positionals ) {
772 $args = array();
773 $assoc = $tool_args;
774 // Pull positionals out (in order) into $args; the rest are options.
775 foreach ( $positionals as $pname ) {
776 if ( array_key_exists( $pname, $assoc ) && '' !== (string) $assoc[ $pname ] ) {
777 $args[] = (string) $assoc[ $pname ];
778 }
779 unset( $assoc[ $pname ] );
780 }
781 return Cli_Bridge::run( $command, $args, $assoc );
782 };
783 }
784
785 /**
786 * The tool list in MCP `tools/list` shape.
787 *
788 * @return array<int, array{name:string, description:string, inputSchema:array}>
789 */
790 public static function list(): array {
791 $out = array();
792 foreach ( self::catalog() as $name => $spec ) {
793 $out[] = array(
794 'name' => $name,
795 'description' => $spec['description'],
796 'inputSchema' => $spec['inputSchema'],
797 );
798 }
799 return $out;
800 }
801
802 /**
803 * Invoke a tool by name with decoded arguments.
804 *
805 * @param string $name Tool name.
806 * @param array $args Decoded arguments.
807 * @return array|\WP_Error Result payload or error.
808 */
809 public static function invoke( string $name, array $args ) {
810 $catalog = self::catalog();
811 if ( ! isset( $catalog[ $name ] ) ) {
812 $error = new \WP_Error(
813 'xspeed_mcp_unknown_tool',
814 sprintf(
815 /* translators: %s: tool name. */
816 __( 'Unknown tool: %s', 'xspeed' ),
817 $name
818 ),
819 array( 'status' => 404 )
820 );
821
822 // A call for a tool that doesn't exist is still something that
823 // happened to this site, and a run of them is the shape of a
824 // probe. Recording it is the difference between a trail that
825 // shows what was ATTEMPTED and one that only shows what
826 // succeeded. Scope is unknowable here, so log the conservative
827 // one rather than implying the attempt was read-only.
828 Mcp_Activity_Log::record( $name, $args, false, $error->get_error_message(), 'write', self::$channel );
829
830 return $error;
831 }
832
833 // Scope enforcement: a read-only connection cannot invoke a tool that
834 // mutates state. run_command is a gateway to the full CLI surface, so
835 // it's treated as write regardless of the wrapped command. The active
836 // credential's scope (pairing token OR OAuth access token) is carried
837 // in self::$scope_override; it falls back to the pairing global for
838 // callers that don't set a per-call scope.
839 if ( ! empty( $catalog[ $name ]['write'] ) && self::is_read_only() && self::action_writes( $catalog[ $name ], $args ) ) {
840 return new \WP_Error(
841 'xspeed_mcp_read_only',
842 sprintf(
843 /* translators: %s: tool name. */
844 __( 'This MCP connection is read-only; the "%s" tool changes state and is not permitted. Reconnect with write access to use it.', 'xspeed' ),
845 $name
846 ),
847 array( 'status' => 403 )
848 );
849 }
850
851 self::$dispatching = true;
852 try {
853 $result = call_user_func( $catalog[ $name ]['handler'], $args );
854
855 // Audit every dispatched call — this is the record the admin
856 // reads to answer "what did the assistant do to my site?".
857 // Recorded here (not per-handler) so a new tool is covered the
858 // moment it joins the catalog.
859 [ $ok, $error ] = self::outcome( $result );
860
861 $scope = empty( $catalog[ $name ]['write'] ) ? 'read' : 'write';
862
863 Mcp_Activity_Log::record( $name, $args, $ok, $error, $scope, self::$channel );
864
865 return $result;
866 } finally {
867 self::$dispatching = false;
868 }
869 }
870
871 /**
872 * Read success/failure out of a handler result.
873 *
874 * Two failure shapes reach here. A handler that validates its own
875 * input returns WP_Error. A handler that delegates to Cli_Bridge gets
876 * back an ARRAY carrying `ok => false` plus `error`, because a
877 * `WP_CLI::error()` inside the shim is a controlled failure rather
878 * than an exception. Reading only the first shape logged every failed
879 * command — a refused purge, a Cloudflare call with no credentials —
880 * as a success.
881 *
882 * @param mixed $result Handler return value.
883 * @return array{0:bool,1:string}
884 */
885 private static function outcome( $result ): array {
886 if ( is_wp_error( $result ) ) {
887 return array( false, $result->get_error_message() );
888 }
889
890 if ( is_array( $result ) && array_key_exists( 'ok', $result ) && ! $result['ok'] ) {
891 $error = isset( $result['error'] ) ? (string) $result['error'] : '';
892 return array( false, '' === $error ? 'Command reported failure.' : $error );
893 }
894
895 return array( true, '' );
896 }
897
898 /** @var string Transport that carried the current call (for the audit log). */
899 private static $channel = 'mcp';
900
901 /**
902 * Name the transport for subsequent invokes — the JSON-RPC endpoint and
903 * the hosted-broker REST routes share this catalog, and the audit trail
904 * should say which one a call arrived on.
905 */
906 public static function set_channel( string $channel ): void {
907 self::$channel = '' === $channel ? 'mcp' : $channel;
908 }
909
910 /** @var bool True while an MCP tool handler is executing. */
911 private static $dispatching = false;
912
913 /**
914 * True while a tool call is being dispatched — lets deeper layers
915 * (e.g. the settings change-log) attribute a mutation to MCP.
916 */
917 public static function in_dispatch(): bool {
918 return self::$dispatching;
919 }
920
921 /*
922 * Handlers — thin proxies to the Free engine. Each takes decoded tool
923 * arguments and returns an array payload (or WP_Error on bad input).
924 */
925
926 /**
927 * Cache status, stats, and detected server.
928 *
929 * @param array $args Unused.
930 * @return array
931 */
932 public static function get_cache_status( array $args ) {
933 unset( $args );
934 $opts = Settings::get();
935 return array(
936 'cache_enabled' => (bool) ( $opts['cache_enabled'] ?? false ),
937 'stats' => Cache::get_stats(),
938 'server' => Server::type(),
939 );
940 }
941
942 /**
943 * All registered module descriptors.
944 *
945 * @param array $args Unused.
946 * @return array
947 */
948 public static function list_modules( array $args ) {
949 unset( $args );
950 return Admin::modules_payload();
951 }
952
953 /**
954 * Before/after cache benchmark timings.
955 *
956 * @param array $args Unused.
957 * @return array
958 */
959 public static function run_benchmark( array $args ) {
960 unset( $args );
961 return Cache_Benchmark::run();
962 }
963
964 /**
965 * Personalized Pro-feature suggestions for this site.
966 *
967 * @param array $args Unused.
968 * @return array
969 */
970 public static function get_pro_audit( array $args ) {
971 unset( $args );
972 return array( 'suggestions' => Pro_Audit::run() );
973 }
974
975 /**
976 * Purge the cache by type.
977 *
978 * @param array $args { type?:string } — one of PURGE_TYPES; default all.
979 * @return array|\WP_Error
980 */
981 public static function purge_cache( array $args ) {
982 $type = isset( $args['type'] ) ? (string) $args['type'] : 'all';
983 if ( '' === $type ) {
984 $type = 'all';
985 }
986 if ( ! in_array( $type, self::PURGE_TYPES, true ) ) {
987 return new \WP_Error(
988 'xspeed_mcp_bad_type',
989 sprintf(
990 /* translators: %s: comma-separated list of valid purge types. */
991 __( 'Invalid purge type. Expected one of: %s', 'xspeed' ),
992 implode( ', ', self::PURGE_TYPES )
993 ),
994 array( 'status' => 400 )
995 );
996 }
997 // Named source, not the default "manual": the purge log's whole job
998 // is to let an admin see that the cache cleared because an assistant
999 // asked, not because someone clicked.
1000 $count = Cache::purge_type( $type, __( 'AI assistant', 'xspeed' ) );
1001 return array(
1002 'purged' => $type,
1003 'count' => $count,
1004 'stats' => Cache::get_stats(),
1005 );
1006 }
1007
1008 /**
1009 * Enable or disable page caching.
1010 *
1011 * @param array $args { enabled:bool }.
1012 * @return array|\WP_Error
1013 */
1014 public static function toggle_cache( array $args ) {
1015 if ( ! array_key_exists( 'enabled', $args ) ) {
1016 return new \WP_Error(
1017 'xspeed_mcp_missing_enabled',
1018 __( 'The "enabled" parameter is required (true or false).', 'xspeed' ),
1019 array( 'status' => 400 )
1020 );
1021 }
1022 $enabled = rest_sanitize_boolean( $args['enabled'] );
1023 $install = Cache::toggle( $enabled );
1024
1025 // Persist cache_enabled the same way the Free /cache/toggle route
1026 // does (class-rest-api.php:235) — Cache::toggle handles the drop-in
1027 // + wp-config; Settings owns the option flag.
1028 Settings::update( array( 'cache_enabled' => $enabled ) );
1029
1030 return array(
1031 'cache_enabled' => $enabled,
1032 'install_state' => $install,
1033 'stats' => Cache::get_stats(),
1034 );
1035 }
1036
1037 /**
1038 * Is this module reachable over MCP right now?
1039 *
1040 * Mirrors SettingsModule::module_reachable(). Registration is not
1041 * enough: Module_Registry::available() only asks whether Pro is LOADED,
1042 * not whether it is LICENSED, so an unlicensed Pro site had every Pro
1043 * module readable and writable over MCP while the dashboard showed it
1044 * locked — reachable by any agent holding a write token. (QA M2)
1045 *
1046 * The licence answer comes through the `xspeed_module_descriptor` filter
1047 * Pro registers, so Free never names a Pro class. (NOT
1048 * `xspeed_pro_licensed` — Pro only ever APPLIES that one as an override
1049 * and nothing listens to it, so gating on it silently passed everything.)
1050 * `license` is exempt for the same reason Pro exempts it: locking it
1051 * would remove the only surface that can fix an expired licence.
1052 */
1053 private static function settings_module_reachable( string $slug ): bool {
1054 $module = \XSpeed\Module_Registry::available()[ $slug ] ?? null;
1055 if ( ! $module ) {
1056 return false;
1057 }
1058 if ( \XSpeed\Module::TIER_PRO !== $module->tier() || 'license' === $slug ) {
1059 return true;
1060 }
1061
1062 // Ask the SAME question the dashboard asks. `xspeed_pro_licensed` is
1063 // only ever APPLIED by Pro as an override hook — nothing registers it
1064 // — so calling it here returned the default `true` and gated nothing.
1065 // Pro DOES register `xspeed_module_descriptor`, and sets
1066 // `locked => 'license'` on every Pro entry when the licence is
1067 // inactive. Reusing that keeps one definition of "locked" instead of
1068 // a second one in Free that can drift from the panel. (QA M2)
1069 $entry = apply_filters(
1070 'xspeed_module_descriptor',
1071 array(
1072 'slug' => $slug,
1073 'tier' => $module->tier(),
1074 ),
1075 $module
1076 );
1077
1078 return empty( $entry['locked'] );
1079 }
1080
1081 /**
1082 * Read a module's schema-validated settings.
1083 *
1084 * @param array $args { module:string }.
1085 * @return array|\WP_Error
1086 */
1087 public static function get_settings( array $args ) {
1088 $module = isset( $args['module'] ) ? (string) $args['module'] : '';
1089 if ( '' === $module ) {
1090 return new \WP_Error(
1091 'xspeed_mcp_missing_module',
1092 __( 'The "module" parameter is required.', 'xspeed' ),
1093 array( 'status' => 400 )
1094 );
1095 }
1096 if ( ! self::settings_module_reachable( $module ) ) {
1097 return new \WP_Error(
1098 'xspeed_mcp_unknown_module',
1099 sprintf(
1100 /* translators: %s: module slug. */
1101 __( 'Unknown module "%s".', 'xspeed' ),
1102 $module
1103 ),
1104 array( 'status' => 404 )
1105 );
1106 }
1107 return array(
1108 'module' => $module,
1109 // Public view — secret fields masked. An MCP agent must never be able
1110 // to read stored credentials back in plaintext. (#115)
1111 'settings' => Settings_Manager::get_public( $module ),
1112 );
1113 }
1114
1115 /**
1116 * Update a module's settings (schema-validated).
1117 *
1118 * @param array $args { module:string, values:array }.
1119 * @return array|\WP_Error
1120 */
1121 public static function update_settings( array $args ) {
1122 $module = isset( $args['module'] ) ? (string) $args['module'] : '';
1123 $values = $args['values'] ?? null;
1124 if ( '' === $module ) {
1125 return new \WP_Error(
1126 'xspeed_mcp_missing_module',
1127 __( 'The "module" parameter is required.', 'xspeed' ),
1128 array( 'status' => 400 )
1129 );
1130 }
1131 if ( ! is_array( $values ) ) {
1132 return new \WP_Error(
1133 'xspeed_mcp_bad_values',
1134 __( 'The "values" parameter must be an object of setting keys.', 'xspeed' ),
1135 array( 'status' => 400 )
1136 );
1137 }
1138 if ( ! self::settings_module_reachable( $module ) ) {
1139 return new \WP_Error(
1140 'xspeed_mcp_unknown_module',
1141 sprintf(
1142 /* translators: %s: module slug. */
1143 __( 'Unknown module "%s".', 'xspeed' ),
1144 $module
1145 ),
1146 array( 'status' => 404 )
1147 );
1148 }
1149 // Writing credentials over MCP requires the explicit `configure` grant —
1150 // off by default even for a write-scoped connection — so an agent can't
1151 // silently repoint the Cloudflare/object-cache backend at an attacker
1152 // endpoint. Refuse with a message naming exactly which fields need it.
1153 // (Settings_Manager::update also strips these as a backstop covering the
1154 // run_command → CLI path.) (#116)
1155 if ( ! self::can_configure() ) {
1156 $secret_fields = Settings_Manager::secret_keys_in( $module, $values );
1157 if ( ! empty( $secret_fields ) ) {
1158 return new \WP_Error(
1159 'xspeed_mcp_configure_required',
1160 sprintf(
1161 /* translators: 1: comma-separated field names, 2: module slug. */
1162 __( 'Writing credential fields (%1$s) on "%2$s" needs the "configure" scope, which is off by default. Reconnect the MCP client granting the configure scope, or set these credentials from the xSpeed dashboard.', 'xspeed' ),
1163 implode( ', ', $secret_fields ),
1164 $module
1165 ),
1166 array(
1167 'status' => 403,
1168 'refused_fields' => $secret_fields,
1169 )
1170 );
1171 }
1172 }
1173 return array(
1174 'module' => $module,
1175 // Return value is already masked (Settings_Manager::update returns the
1176 // public view), so a written secret isn't echoed back either. (#115)
1177 'settings' => Settings_Manager::update( $module, $values ),
1178 );
1179 }
1180
1181 /**
1182 * List every command run_command can invoke (the full CLI surface).
1183 *
1184 * @param array $args Unused.
1185 * @return array
1186 */
1187 public static function list_commands( array $args ) {
1188 unset( $args );
1189 return array( 'commands' => Cli_Bridge::catalog() );
1190 }
1191
1192 /**
1193 * Run any registered xSpeed command via the CLI bridge.
1194 *
1195 * @param array $args { command:string, args?:array, options?:array }.
1196 * @return array|\WP_Error
1197 */
1198 public static function run_command( array $args ) {
1199 $command = isset( $args['command'] ) ? (string) $args['command'] : '';
1200 if ( '' === $command ) {
1201 return new \WP_Error(
1202 'xspeed_mcp_missing_command',
1203 __( 'The "command" parameter is required.', 'xspeed' ),
1204 array( 'status' => 400 )
1205 );
1206 }
1207 $positional = isset( $args['args'] ) && is_array( $args['args'] ) ? $args['args'] : array();
1208 $options = isset( $args['options'] ) && is_array( $args['options'] ) ? $args['options'] : array();
1209 return Cli_Bridge::run( $command, $positional, $options );
1210 }
1211
1212 /* --------------------------------------------------------------------- */
1213 /* Promoted action handlers — typed wrappers over Cli_Bridge. */
1214 /* Delegating to the bridge lets a Free tool drive a Pro action (psi, */
1215 /* ccss) with no cross-repo class reference, and keeps zero drift. */
1216 /* --------------------------------------------------------------------- */
1217
1218 /**
1219 * Purge the Cloudflare edge cache.
1220 *
1221 * @param array $args Unused.
1222 * @return array|\WP_Error
1223 */
1224 public static function purge_cloudflare( array $args ) {
1225 unset( $args );
1226 return Cli_Bridge::run( 'cf', array( 'purge' ) );
1227 }
1228
1229 /**
1230 * Scan the database for bloat (no deletion).
1231 *
1232 * @param array $args Unused.
1233 * @return array|\WP_Error
1234 */
1235 public static function scan_database( array $args ) {
1236 unset( $args );
1237 return Cli_Bridge::run( 'db', array( 'scan' ) );
1238 }
1239
1240 /**
1241 * Clean database bloat (destructive).
1242 *
1243 * @param array $args Unused.
1244 * @return array|\WP_Error
1245 */
1246 public static function clean_database( array $args ) {
1247 unset( $args );
1248 return Cli_Bridge::run( 'db', array( 'clean' ) );
1249 }
1250
1251 /**
1252 * Flush the persistent object cache.
1253 *
1254 * @param array $args Unused.
1255 * @return array|\WP_Error
1256 */
1257 public static function flush_object_cache( array $args ) {
1258 unset( $args );
1259 return Cli_Bridge::run( 'objcache', array( 'flush' ) );
1260 }
1261
1262 /**
1263 * Start the cache preloader.
1264 *
1265 * @param array $args Unused.
1266 * @return array|\WP_Error
1267 */
1268 public static function start_preloader( array $args ) {
1269 unset( $args );
1270 return Cli_Bridge::run( 'preloader', array( 'start' ) );
1271 }
1272
1273 /**
1274 * Full health diagnostics (checks + stats + buckets + activity).
1275 * Direct typed payload — same tier as get_cache_status — so the agent
1276 * gets structured tones/ids instead of parsing CLI log lines.
1277 *
1278 * @param array $args Unused.
1279 * @return array
1280 */
1281 public static function get_health( array $args ) {
1282 unset( $args );
1283 return array(
1284 'checks' => \XSpeed\Health::checks(),
1285 'stats' => Cache::get_stats(),
1286 'buckets' => \XSpeed\Hit_Counter::buckets(),
1287 'hit_daily' => \XSpeed\Hit_Counter::daily_series( 30 ),
1288 'activity' => \XSpeed\Activity_Log::entries(),
1289 );
1290 }
1291
1292 /**
1293 * Stored benchmark runs + settings-change events (trend data).
1294 *
1295 * @param array $args { limit?:int }.
1296 * @return array
1297 */
1298 public static function get_benchmark_history( array $args ) {
1299 $limit = isset( $args['limit'] ) ? max( 1, min( 100, (int) $args['limit'] ) ) : 100;
1300 $changes = array();
1301 foreach ( \XSpeed\Activity_Log::entries() as $entry ) {
1302 if ( 'settings_changed' === ( $entry['type'] ?? '' ) ) {
1303 $changes[] = array(
1304 'ts' => (int) $entry['ts'],
1305 'message' => (string) $entry['message'],
1306 );
1307 }
1308 }
1309 return array(
1310 'runs' => Cache_Benchmark::history( $limit ),
1311 'changes' => $changes,
1312 );
1313 }
1314
1315 /**
1316 * Purge a single URL's cache entries.
1317 *
1318 * @param array $args { url:string }.
1319 * @return array|\WP_Error
1320 */
1321 /**
1322 * Inspect what is in the page cache (pages + age, or size breakdown).
1323 *
1324 * @param array $args detail: pages|size, limit.
1325 * @return array|\WP_Error
1326 */
1327 public static function get_cache_inventory( array $args ) {
1328 $detail = isset( $args['detail'] ) ? (string) $args['detail'] : 'pages';
1329 $action = 'size' === $detail ? 'size' : 'inventory';
1330 $assoc = array();
1331 if ( isset( $args['limit'] ) && '' !== $args['limit'] ) {
1332 $assoc['limit'] = (string) $args['limit'];
1333 }
1334 return Cli_Bridge::run( 'cache', array( $action ), $assoc );
1335 }
1336
1337 /**
1338 * Recent cache purges and their causes.
1339 *
1340 * @param array $args limit.
1341 * @return array|\WP_Error
1342 */
1343 public static function get_purge_log( array $args ) {
1344 $assoc = array();
1345 if ( isset( $args['limit'] ) && '' !== $args['limit'] ) {
1346 $assoc['limit'] = (string) $args['limit'];
1347 }
1348 return Cli_Bridge::run( 'cache', array( 'purge-log' ), $assoc );
1349 }
1350
1351 /**
1352 * Re-verify (and repair) the server rewrite rules.
1353 *
1354 * @param array $args Unused.
1355 * @return array|\WP_Error
1356 */
1357 public static function recheck_rewrite_rules( array $args ) {
1358 unset( $args );
1359 return Cli_Bridge::run( 'cache', array( 'recheck-rewrite' ) );
1360 }
1361
1362 /**
1363 * Turn Cloudflare development mode on or off.
1364 *
1365 * A boolean rather than two tools: dev-on and dev-off are one decision,
1366 * and offering them separately doubles the surface for no gain.
1367 *
1368 * @param array $args enabled (bool, required).
1369 * @return array|\WP_Error
1370 */
1371 public static function set_cloudflare_dev_mode( array $args ) {
1372 if ( ! array_key_exists( 'enabled', $args ) ) {
1373 return new \WP_Error( 'xspeed_mcp_missing_enabled', __( 'The enabled argument is required.', 'xspeed' ), array( 'status' => 400 ) );
1374 }
1375 $on = filter_var( $args['enabled'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
1376 if ( null === $on ) {
1377 return new \WP_Error( 'xspeed_mcp_invalid_enabled', __( 'The enabled argument must be true or false.', 'xspeed' ), array( 'status' => 400 ) );
1378 }
1379 return Cli_Bridge::run( 'cf', array( $on ? 'dev-on' : 'dev-off' ) );
1380 }
1381
1382 /**
1383 * Optimize database tables (distinct from clean_database, which deletes).
1384 *
1385 * @param array $args Unused.
1386 * @return array|\WP_Error
1387 */
1388 public static function optimize_database( array $args ) {
1389 unset( $args );
1390 return Cli_Bridge::run( 'db', array( 'optimize' ) );
1391 }
1392
1393 /**
1394 * Object cache state, or the server snippet that enables it.
1395 *
1396 * @param array $args detail: status|snippet.
1397 * @return array|\WP_Error
1398 */
1399 public static function get_object_cache_status( array $args ) {
1400 $detail = isset( $args['detail'] ) ? (string) $args['detail'] : 'status';
1401 $action = 'snippet' === $detail ? 'snippet' : 'status';
1402 return Cli_Bridge::run( 'objcache', array( $action ) );
1403 }
1404
1405 /**
1406 * Install or remove the object-cache drop-in.
1407 *
1408 * @param array $args enabled (bool, required).
1409 * @return array|\WP_Error
1410 */
1411 public static function toggle_object_cache( array $args ) {
1412 if ( ! array_key_exists( 'enabled', $args ) ) {
1413 return new \WP_Error( 'xspeed_mcp_missing_enabled', __( 'The enabled argument is required.', 'xspeed' ), array( 'status' => 400 ) );
1414 }
1415 $on = filter_var( $args['enabled'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
1416 if ( null === $on ) {
1417 return new \WP_Error( 'xspeed_mcp_invalid_enabled', __( 'The enabled argument must be true or false.', 'xspeed' ), array( 'status' => 400 ) );
1418 }
1419 return Cli_Bridge::run( 'objcache', array( $on ? 'enable' : 'disable' ) );
1420 }
1421
1422 /**
1423 * List or clear stored Critical CSS.
1424 *
1425 * @param array $args action: list|clear.
1426 * @return array|\WP_Error
1427 */
1428 public static function manage_critical_css( array $args ) {
1429 $action = isset( $args['action'] ) ? (string) $args['action'] : '';
1430 if ( ! in_array( $action, array( 'list', 'clear' ), true ) ) {
1431 return new \WP_Error( 'xspeed_mcp_invalid_action', __( 'The action argument must be "list" or "clear".', 'xspeed' ), array( 'status' => 400 ) );
1432 }
1433 return Cli_Bridge::run( 'ccss', array( $action ) );
1434 }
1435
1436 /**
1437 * Preloader progress.
1438 *
1439 * @param array $args Unused.
1440 * @return array|\WP_Error
1441 */
1442 public static function get_preloader_status( array $args ) {
1443 unset( $args );
1444 return Cli_Bridge::run( 'preloader', array( 'status' ) );
1445 }
1446
1447 /**
1448 * Stop a running preload.
1449 *
1450 * @param array $args Unused.
1451 * @return array|\WP_Error
1452 */
1453 public static function stop_preloader( array $args ) {
1454 unset( $args );
1455 return Cli_Bridge::run( 'preloader', array( 'stop' ) );
1456 }
1457
1458 /**
1459 * Stored external audit runs (PSI / GTmetrix).
1460 *
1461 * Read-only by construction: it reads the option Score already wrote. No
1462 * outbound call is made, which is what lets the Hub poll this on a
1463 * schedule without spending the site owner's PSI or GTmetrix quota.
1464 *
1465 * @param array $args limit.
1466 * @return array|\WP_Error
1467 */
1468 public static function get_score_history( array $args ) {
1469 if ( ! class_exists( '\\XSpeed\\Score' ) ) {
1470 return new \WP_Error( 'xspeed_mcp_no_score', __( 'External scores are not available on this site.', 'xspeed' ), array( 'status' => 404 ) );
1471 }
1472
1473 $limit = isset( $args['limit'] ) ? (int) $args['limit'] : 100;
1474 $limit = max( 1, min( 500, $limit ) );
1475
1476 $runs = array();
1477 foreach ( array_slice( \XSpeed\Score::history(), 0, $limit ) as $run ) {
1478 if ( ! is_array( $run ) ) {
1479 continue;
1480 }
1481 $metrics = isset( $run['metrics'] ) && is_array( $run['metrics'] ) ? $run['metrics'] : array();
1482 $runs[] = array(
1483 'provider' => isset( $run['provider'] ) ? (string) $run['provider'] : 'unknown',
1484 'ts' => isset( $run['ts'] ) ? (int) $run['ts'] : 0,
1485 'url' => isset( $run['url'] ) ? (string) $run['url'] : '',
1486 'strategy' => isset( $run['strategy'] ) ? (string) $run['strategy'] : null,
1487 // Null, never 0: Score distinguishes "no score" from "scored
1488 // zero", and flattening that reports a failed audit as a
1489 // catastrophic result.
1490 'score' => isset( $run['score'] ) && is_numeric( $run['score'] ) ? (int) $run['score'] : null,
1491 'metrics' => array(
1492 'lcp' => self::metric_or_null( $metrics, 'lcp' ),
1493 'fcp' => self::metric_or_null( $metrics, 'fcp' ),
1494 'cls' => self::metric_or_null( $metrics, 'cls' ),
1495 'tbt' => self::metric_or_null( $metrics, 'tbt' ),
1496 'si' => self::metric_or_null( $metrics, 'si' ),
1497 'ttfb' => self::metric_or_null( $metrics, 'ttfb' ),
1498 ),
1499 'report_url' => self::report_url_for( $run ),
1500 );
1501 }
1502
1503 return array(
1504 'runs' => $runs,
1505 'total' => count( \XSpeed\Score::history() ),
1506 );
1507 }
1508
1509 /**
1510 * One metric as a float, or null when absent/non-numeric.
1511 *
1512 * @param array $metrics Metric bag.
1513 * @param string $key Metric id.
1514 */
1515 private static function metric_or_null( array $metrics, string $key ): ?float {
1516 return isset( $metrics[ $key ] ) && is_numeric( $metrics[ $key ] ) ? (float) $metrics[ $key ] : null;
1517 }
1518
1519 /**
1520 * Deep link to the provider's own report, when one exists.
1521 *
1522 * GTmetrix hosts a durable report per test, so its id is enough to build
1523 * the link. PSI does NOT — a Lighthouse result is returned to the caller
1524 * and never hosted, so there is genuinely nothing to link to and this
1525 * returns null rather than inventing a URL that 404s.
1526 *
1527 * @param array $run One stored run.
1528 */
1529 private static function report_url_for( array $run ): ?string {
1530 $provider = isset( $run['provider'] ) ? (string) $run['provider'] : '';
1531 if ( 'gtmetrix' !== $provider ) {
1532 return null;
1533 }
1534 $test_id = isset( $run['test_id'] ) ? trim( (string) $run['test_id'] ) : '';
1535 if ( '' === $test_id ) {
1536 return null;
1537 }
1538 return 'https://gtmetrix.com/reports/' . rawurlencode( $test_id );
1539 }
1540
1541 public static function purge_url( array $args ) {
1542 $url = isset( $args['url'] ) ? trim( (string) $args['url'] ) : '';
1543 if ( '' === $url ) {
1544 return new \WP_Error( 'xspeed_mcp_missing_url', __( 'The url argument is required.', 'xspeed' ), array( 'status' => 400 ) );
1545 }
1546 return Cli_Bridge::run( 'cache', array( 'purge-url', $url ), array( 'cause' => __( 'AI assistant', 'xspeed' ) ) );
1547 }
1548
1549 /**
1550 * Probe the configured object-cache backend (connect + read/write).
1551 *
1552 * @param array $args Unused.
1553 * @return array|\WP_Error
1554 */
1555 public static function test_object_cache( array $args ) {
1556 unset( $args );
1557 return Cli_Bridge::run( 'objcache', array( 'test' ) );
1558 }
1559
1560 /**
1561 * Verify the saved Cloudflare credentials.
1562 *
1563 * @param array $args Unused.
1564 * @return array|\WP_Error
1565 */
1566 public static function cloudflare_verify( array $args ) {
1567 unset( $args );
1568 return Cli_Bridge::run( 'cf', array( 'verify' ) );
1569 }
1570
1571 /**
1572 * Run a PageSpeed Insights audit (Pro).
1573 *
1574 * @param array $args { url?:string, strategy?:string }.
1575 * @return array|\WP_Error
1576 */
1577 public static function run_pagespeed( array $args ) {
1578 $options = array();
1579 if ( ! empty( $args['url'] ) ) {
1580 $options['url'] = (string) $args['url'];
1581 }
1582 if ( ! empty( $args['strategy'] ) ) {
1583 $options['strategy'] = (string) $args['strategy'];
1584 }
1585 // Was reachable only via the generated xspeed_psi alias, which this
1586 // change removes — so it moves onto the typed tool rather than being
1587 // lost with it.
1588 if ( ! empty( $args['force'] ) && filter_var( $args['force'], FILTER_VALIDATE_BOOLEAN ) ) {
1589 $options['force'] = true;
1590 }
1591
1592 // Prefer the richer Pro engine when it's installed; otherwise drive
1593 // Free's own score command. Same tool name either way — an assistant
1594 // asking for a PageSpeed audit shouldn't have to know which tier the
1595 // site runs, and the two write to the same run history.
1596 if ( isset( Cli_Bridge::commands()['xspeed psi'] ) ) {
1597 return Cli_Bridge::run( 'psi', array(), $options );
1598 }
1599 return Cli_Bridge::run( 'score', array( 'run' ), $options );
1600 }
1601
1602 /**
1603 * Generate Critical CSS (Pro).
1604 *
1605 * @param array $args Unused.
1606 * @return array|\WP_Error
1607 */
1608 public static function generate_critical_css( array $args ) {
1609 unset( $args );
1610 return Cli_Bridge::run( 'ccss', array( 'generate' ) );
1611 }
1612
1613 /**
1614 * Build a JSON Schema object node.
1615 *
1616 * @param array $properties Property map.
1617 * @param string[] $required Required property names.
1618 */
1619 private static function object_schema( array $properties, array $required ): array {
1620 $schema = array(
1621 'type' => 'object',
1622 'properties' => (object) $properties,
1623 );
1624 if ( ! empty( $required ) ) {
1625 $schema['required'] = array_values( $required );
1626 }
1627 return $schema;
1628 }
1629 }
1630