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

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