PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 1.3.4 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 All 33 releases
← All changes | includes/modules/Mcp/Mcp_Tools.php +1909 -33 1.1.0 → 1.3.7 View file →
@@ -29,17 +29,25 @@
29 29 use XSpeed\Server;
30 30 use XSpeed\Admin;
31 31 use XSpeed\Settings;
32 32 use XSpeed\Settings_Manager;
33 +use XSpeed\Module_Registry;
33 34 use XSpeed\Pro_Audit;
34 35 use XSpeed\Cache_Benchmark;
36 +use XSpeed\Tier_Registry;
37 +use XSpeed\Database_Cleaner;
35 38
36 39 defined( 'ABSPATH' ) || exit;
37 40
38 41 final class Mcp_Tools {
42 + /** Pro extension contract supported by this Free build. */
43 + public const EXTENSION_API = 1;
39 44
45 + /** Raw broker tool envelope cap, enforced before JSON decoding. */
46 + public const MAX_TOOL_BODY_BYTES = 2 * 1024 * 1024;
47 +
40 48 /** Valid cache purge types. */
41 - public const PURGE_TYPES = array( 'all', 'page', 'assets', 'object', 'rest' );
49 + public const PURGE_TYPES = array( 'all', 'page', 'assets', 'object', 'rest', 'cloudflare', 'cdn' );
42 50
43 51 /**
44 52 * Per-call read-only override. Null means "defer to the pairing token's
45 53 * scope" (the JSON-RPC path that predates OAuth). true/false is set by
@@ -72,8 +80,51 @@
72 80 return Mcp_Pairing::is_read_only();
73 81 }
74 82
75 83 /**
84 + * Per-call `configure` grant. Writing credential/secret fields over MCP is
85 + * gated on this and it is OFF by default — even a write-scoped connection
86 + * cannot rewrite an API token or password unless it was granted the
87 + * explicit `configure` scope. Null means "no per-call grant" (the pairing
88 + * token / JSON-RPC path), where it falls back to a filter. (#116)
89 + *
90 + * @var bool|null
91 + */
92 + private static $configure_override = null;
93 +
94 + /**
95 + * Set whether the active credential may write secret fields (the OAuth
96 + * `configure` scope). Passing null clears it back to the filter default.
97 + *
98 + * @param bool|null $can_configure Whether the credential carries `configure`.
99 + */
100 + public static function set_configure_override( ?bool $can_configure ): void {
101 + self::$configure_override = $can_configure;
102 + }
103 +
104 + /**
105 + * Whether the active MCP credential may write credential/secret fields.
106 + * Uses the per-call override (OAuth `configure` scope) when set; otherwise
107 + * the `xspeed_mcp_allow_credential_writes` filter, which defaults to false
108 + * so credential writes are off by default on every connection — including
109 + * the pairing token. A site owner who wants an agent to manage credentials
110 + * opts in by returning true from that filter. (#116)
111 + */
112 + public static function can_configure(): bool {
113 + if ( null !== self::$configure_override ) {
114 + return self::$configure_override;
115 + }
116 + /**
117 + * Allow MCP connections to write credential (secret) fields. Off by
118 + * default; see docs/MCP-SERVER.md. Applies to pairing-token connections
119 + * and any OAuth grant lacking the `configure` scope.
120 + *
121 + * @param bool $allow Whether credential writes over MCP are permitted.
122 + */
123 + return (bool) apply_filters( 'xspeed_mcp_allow_credential_writes', false );
124 + }
125 +
126 + /**
76 127 * Full tool catalog: name => descriptor. `handler` is a callable
77 128 * ( array $args ) : array|\WP_Error. `write` marks tools that mutate
78 129 * state (used for read-only scope enforcement).
79 130 *
@@ -92,10 +143,51 @@
92 143 'inputSchema' => self::object_schema( array(), array() ),
93 144 'write' => false,
94 145 'handler' => array( self::class, 'list_modules' ),
95 146 ),
147 + 'get_site_info' => array(
148 + '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.',
149 + 'inputSchema' => self::object_schema( array(), array() ),
150 + 'write' => false,
151 + 'handler' => array( self::class, 'get_site_info' ),
152 + ),
153 + 'optimize_site' => array(
154 + 'description' => 'Make this site faster, end to end: measure, apply the recommended settings ONE AT A TIME, check the page still renders after each, and undo any change that breaks it. Returns what was applied, the site\'s performance score, `next_steps` (riskier settings that could help but are NOT applied automatically), and `unfixable` (problems no caching plugin can reach). ALWAYS relay all three to the user: report the score and what is still wrong, then — if `next_steps` is non-empty — describe each one WITH its stated `risk` and ASK whether to run again with aggressiveness "aggressive". Never enable aggressive settings without the user agreeing first, and never present `unfixable` items as things you can solve; they need the site owner or the host. A site where nothing was left to do is a real, good answer — say so plainly rather than apologising or retrying. Use `dry_run` to preview the plan. The `score` object carries `age_seconds` and `stale`: quote the score WITH how recently it was measured, and never describe a stale score as the result this run produced — when a measurement could not be taken, say the number is old rather than implying it is current. WHEN CHANGES WERE APPLIED the response carries `verify_urls` and `verify_note`: the safety checks read HTML in PHP and cannot execute JavaScript, so a page can pass every one of them and still be broken in a browser. Before reporting success, OPEN each URL in `verify_urls` if you have any way to load a page and confirm it renders with no console errors; if you cannot, tell the user those URLs need checking and what a problem would look like. `verified: true` means the HTML checks passed — it is not a statement that the site works.',
155 + 'inputSchema' => self::object_schema(
156 + array(
157 + 'aggressiveness' => array(
158 + 'type' => 'string',
159 + 'enum' => array( 'safe', 'standard', 'aggressive' ),
160 + 'description' => 'How far to go. Defaults to standard.',
161 + ),
162 + 'dry_run' => array(
163 + 'type' => 'boolean',
164 + 'description' => 'Return the plan without changing anything.',
165 + ),
166 + 'measure_score' => array(
167 + 'type' => 'string',
168 + 'enum' => array( 'auto', 'never', 'always' ),
169 + 'description' => 'Whether to take a fresh PageSpeed measurement. auto (default) measures when the stored score is stale and after changes land; never reuses the stored score; always measures even for a dry run. Measurements are rate-limited, so a run inside the cooldown returns the stored score with its age rather than a new one.',
170 + ),
171 + 'target_score' => array(
172 + 'type' => 'integer',
173 + 'minimum' => 1,
174 + 'maximum' => 100,
175 + 'description' => 'Repeat the optimize cycle toward this score instead of running a single pass. Each round costs a real PageSpeed measurement and up to two minutes, so pass it only when the user asked for a specific number. Requires the iterative tuner; without it the run is a single pass and the report says so in `stopped_because`. The run also stops early when further rounds stop helping — either way, read `stopped_because` and relay it rather than retrying.',
176 + ),
177 + 'max_rounds' => array(
178 + 'type' => 'integer',
179 + 'minimum' => 1,
180 + 'description' => 'Ceiling on rounds when target_score is set. Clamped to what the tuner allows.',
181 + ),
182 + ),
183 + array()
184 + ),
185 + 'write' => true,
186 + 'handler' => array( self::class, 'optimize_site' ),
187 + ),
96 188 'run_benchmark' => array(
97 - 'description' => 'Run a before/after cache benchmark on the home page and return the timings.',
189 + 'description' => 'Run a before/after cache benchmark on the home page and return the timings. Each side reports bytes (decoded payload) and bytes_transferred (compressed wire size).',
98 190 'inputSchema' => self::object_schema( array(), array() ),
99 191 'write' => false,
100 192 'handler' => array( self::class, 'run_benchmark' ),
101 193 ),
@@ -105,9 +197,9 @@
105 197 'write' => false,
106 198 'handler' => array( self::class, 'get_pro_audit' ),
107 199 ),
108 200 'purge_cache' => array(
109 - 'description' => 'Purge the site cache. "type" selects what to purge: all, page, assets, object, or rest. Defaults to all.',
201 + 'description' => 'Purge the site cache and report what was actually cleared, what was skipped and why. "type" selects what to purge: all, page, assets, object, rest, cloudflare, or cdn. Defaults to all.',
110 202 'inputSchema' => self::object_schema(
111 203 array(
112 204 'type' => array(
113 205 'type' => 'string',
@@ -177,16 +269,24 @@
177 269 'write' => true,
178 270 'handler' => array( self::class, 'purge_cloudflare' ),
179 271 ),
180 272 'scan_database' => array(
181 - 'description' => 'Scan the database for bloat (post revisions, auto-drafts, trashed posts, spam comments, expired transients, orphaned meta) without deleting anything.',
273 + 'description' => 'Preview database bloat — post revisions, auto-drafts, trashed posts, spam comments, expired transients, orphaned meta — with a count per category. Deletes NOTHING. Also returns the confirm_token that clean_database requires, so this is always the first step before any deletion.',
182 274 'inputSchema' => self::object_schema( array(), array() ),
183 275 'write' => false,
184 276 'handler' => array( self::class, 'scan_database' ),
185 277 ),
186 278 'clean_database' => array(
187 - 'description' => 'Clean database bloat. Removes the categories currently enabled in the Database module settings. Destructive — run scan_database first to preview.',
188 - 'inputSchema' => self::object_schema( array(), array() ),
279 + 'description' => 'PERMANENTLY DELETE database bloat — post revisions, trashed posts, spam comments and the other categories enabled in the Database module settings. This is not a cache purge: it destroys real content and CANNOT be undone. Requires a confirm_token from scan_database, which shows the caller exactly what would be removed; the call is refused without one.',
280 + 'inputSchema' => self::object_schema(
281 + array(
282 + 'confirm_token' => array(
283 + 'type' => 'string',
284 + 'description' => 'The token returned by scan_database. Required — it proves the caller has seen what will be deleted. Expires after 5 minutes and is invalidated if the database changes.',
285 + ),
286 + ),
287 + array( 'confirm_token' )
288 + ),
189 289 'write' => true,
190 290 'handler' => array( self::class, 'clean_database' ),
191 291 ),
192 292 'flush_object_cache' => array(
@@ -200,10 +300,48 @@
200 300 'inputSchema' => self::object_schema( array(), array() ),
201 301 'write' => true,
202 302 'handler' => array( self::class, 'start_preloader' ),
203 303 ),
304 + 'run_score' => array(
305 + 'description' => 'Run an external performance audit (PageSpeed Insights, or GTmetrix when configured) against this site. Running it counts as the opt-in for external scores. With the site\'s own API key the score plus Core Web Vitals come back in the response; a keyless PageSpeed audit on a Hub-connected site is queued instead — the response says so, and the result lands in the history a minute or two later (poll with run_command "score status", or read get_score_history). Spends the site\'s own configured API quota when a key is set.',
306 + 'inputSchema' => self::object_schema(
307 + array(
308 + 'target' => array(
309 + 'type' => 'string',
310 + 'description' => 'URL to audit. Defaults to the configured URL, then the home page.',
311 + ),
312 + 'strategy' => array(
313 + 'type' => 'string',
314 + 'enum' => array( 'mobile', 'desktop' ),
315 + 'description' => 'mobile (default) or desktop. PageSpeed Insights only.',
316 + ),
317 + 'provider' => array(
318 + 'type' => 'string',
319 + 'description' => 'Audit provider, when the site has more than one configured.',
320 + ),
321 + 'force' => array(
322 + 'type' => 'boolean',
323 + 'description' => 'Re-run even when a recent cached result exists. Use after a change you want measured immediately.',
324 + ),
325 + ),
326 + array()
327 + ),
328 + // Classified `write`, deliberately. #147 asked whether a
329 + // read-only grant should be able to call this, since a run
330 + // changes no site CONFIGURATION. But it spends the site's own
331 + // metered PSI/GTmetrix quota and persists a Score_Store row,
332 + // and "read-only" should mean a call cannot cost the owner
333 + // anything. The gap this closes is that Free had NO typed
334 + // trigger at all: run_pagespeed is conditional on the Pro-only
335 + // `xspeed psi` command and silently drops off tools/list here,
336 + // leaving only run_command — also write, and a gateway to the
337 + // entire CLI surface. A write-scoped Hub connection now gets a
338 + // first-class trigger instead of the blunt instrument.
339 + 'write' => true,
340 + 'handler' => array( self::class, 'run_score' ),
341 + ),
204 342 'run_pagespeed' => array(
205 - 'description' => 'Run a Google PageSpeed Insights audit on a URL (Pro). Returns the performance score + Core Web Vitals. Defaults to the site home page, mobile strategy.',
343 + '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. Running it counts as the opt-in for external scores; a keyless PageSpeed audit routes through xSpeed Hub when the site is connected.',
206 344 'inputSchema' => self::object_schema(
207 345 array(
208 346 'url' => array(
209 347 'type' => 'string',
@@ -213,20 +351,224 @@
213 351 'type' => 'string',
214 352 'enum' => array( 'mobile', 'desktop' ),
215 353 'description' => 'Audit strategy. Defaults to "mobile".',
216 354 ),
355 + 'force' => array(
356 + 'type' => 'boolean',
357 + 'description' => 'Re-run even when a recent cached result exists. Use after a change you want measured immediately.',
358 + ),
359 + 'provider' => array(
360 + 'type' => 'string',
361 + 'description' => 'Audit provider, when the site has more than one configured.',
362 + ),
217 363 ),
218 364 array()
219 365 ),
220 - 'write' => false,
366 + // `write`, matching run_score — the two dispatch to the same
367 + // `xspeed psi` and cost the owner the same metered quota, so
368 + // classifying them oppositely let a read-only grant make a real
369 + // outbound audit through this one while the other refused it.
370 + // Aligned toward write rather than read: generate_critical_css
371 + // sets the precedent that spending an external quota is a write
372 + // even when no site configuration changes. (QA B2 on #162)
373 + 'write' => true,
221 374 'handler' => array( self::class, 'run_pagespeed' ),
222 375 ),
223 376 'generate_critical_css' => array(
224 - 'description' => 'Generate above-the-fold Critical CSS for the site (Pro). Calls the external generator and stores the result.',
377 + 'description' => 'Generate above-the-fold Critical CSS for one page (Pro). Calls the external generator and stores the result for that page\'s template. Defaults to the site home page; pass url to build it for another page or template.',
378 + 'inputSchema' => self::object_schema(
379 + array(
380 + 'url' => array(
381 + 'type' => 'string',
382 + 'description' => 'Page on this site to generate Critical CSS for, as a full URL or a path such as /pricing/. Defaults to the site home page.',
383 + ),
384 + ),
385 + array()
386 + ),
387 + 'write' => true,
388 + 'handler' => array( self::class, 'generate_critical_css' ),
389 + ),
390 + 'get_health' => array(
391 + '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.',
225 392 'inputSchema' => self::object_schema( array(), array() ),
393 + 'write' => false,
394 + 'handler' => array( self::class, 'get_health' ),
395 + ),
396 + 'get_benchmark_history' => array(
397 + '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.',
398 + 'inputSchema' => self::object_schema(
399 + array(
400 + 'limit' => array(
401 + 'type' => 'integer',
402 + 'description' => 'Max runs to return (default 100).',
403 + ),
404 + ),
405 + array()
406 + ),
407 + 'write' => false,
408 + 'handler' => array( self::class, 'get_benchmark_history' ),
409 + ),
410 + 'get_score_history' => array(
411 + '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. Failed runs are included: ok is false and error says why, with score null. Never average or trend a run whose ok is false — it measured nothing. Read-only — returns what this site already measured and never starts a new audit. Use run_score to actually run one.',
412 + 'inputSchema' => self::object_schema(
413 + array(
414 + 'limit' => array(
415 + 'type' => 'integer',
416 + 'description' => 'Max runs to return, newest first (default 100).',
417 + ),
418 + ),
419 + array()
420 + ),
421 + 'write' => false,
422 + 'handler' => array( self::class, 'get_score_history' ),
423 + ),
424 + // --- Actions promoted out of the generated `xspeed_*` aliases.
425 + // Each was previously reachable ONLY as an `action` string on a
426 + // coarse generated tool that was marked write regardless, so a
427 + // read-only connection lost the read ones. Typed here with an
428 + // honest kind so the AI stops guessing and the deny-list has one
429 + // name per action. ---
430 + 'get_cache_inventory' => array(
431 + '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.',
432 + 'inputSchema' => self::object_schema(
433 + array(
434 + 'detail' => array(
435 + 'type' => 'string',
436 + 'enum' => array( 'pages', 'size' ),
437 + 'description' => '"pages" lists cached pages and their age; "size" breaks down disk usage. Defaults to "pages".',
438 + ),
439 + 'limit' => array(
440 + 'type' => 'string',
441 + 'description' => 'Max rows to return (pages only).',
442 + ),
443 + ),
444 + array()
445 + ),
446 + 'write' => false,
447 + 'handler' => array( self::class, 'get_cache_inventory' ),
448 + ),
449 + 'get_purge_log' => array(
450 + 'description' => 'Recent cache purges and what triggered each one. Use it to explain why a page stopped being cached. Read-only.',
451 + 'inputSchema' => self::object_schema(
452 + array(
453 + 'limit' => array(
454 + 'type' => 'string',
455 + 'description' => 'Max entries to return.',
456 + ),
457 + ),
458 + array()
459 + ),
460 + 'write' => false,
461 + 'handler' => array( self::class, 'get_purge_log' ),
462 + ),
463 + 'recheck_rewrite_rules' => array(
464 + 'description' => 'Re-verify the server rewrite rules that route requests to the cache, and repair them if they drifted.',
465 + 'inputSchema' => self::object_schema( array(), array() ),
226 466 'write' => true,
227 - 'handler' => array( self::class, 'generate_critical_css' ),
467 + 'handler' => array( self::class, 'recheck_rewrite_rules' ),
228 468 ),
469 + 'set_cloudflare_dev_mode' => array(
470 + 'description' => 'Turn Cloudflare development mode on or off. On bypasses the edge cache for ~3 hours so origin changes show immediately.',
471 + 'inputSchema' => self::object_schema(
472 + array(
473 + 'enabled' => array(
474 + 'type' => 'boolean',
475 + 'description' => 'true turns development mode on, false turns it off.',
476 + ),
477 + ),
478 + array( 'enabled' )
479 + ),
480 + 'write' => true,
481 + 'handler' => array( self::class, 'set_cloudflare_dev_mode' ),
482 + ),
483 + 'optimize_database' => array(
484 + 'description' => 'Run table optimization on the WordPress database (reclaims space after cleanup). Separate from clean_database, which deletes bloat rows.',
485 + 'inputSchema' => self::object_schema( array(), array() ),
486 + 'write' => true,
487 + 'handler' => array( self::class, 'optimize_database' ),
488 + ),
489 + 'get_object_cache_status' => array(
490 + 'description' => 'Object cache state: whether the drop-in is installed, which backend is configured, and the server snippet needed to enable it. Read-only.',
491 + 'inputSchema' => self::object_schema(
492 + array(
493 + 'detail' => array(
494 + 'type' => 'string',
495 + 'enum' => array( 'status', 'snippet' ),
496 + 'description' => '"status" reports the current state; "snippet" returns the server config to enable it. Defaults to "status".',
497 + ),
498 + ),
499 + array()
500 + ),
501 + 'write' => false,
502 + 'handler' => array( self::class, 'get_object_cache_status' ),
503 + ),
504 + 'toggle_object_cache' => array(
505 + '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.',
506 + 'inputSchema' => self::object_schema(
507 + array(
508 + 'enabled' => array(
509 + 'type' => 'boolean',
510 + 'description' => 'true installs the drop-in, false removes it.',
511 + ),
512 + ),
513 + array( 'enabled' )
514 + ),
515 + 'write' => true,
516 + 'handler' => array( self::class, 'toggle_object_cache' ),
517 + ),
518 + 'manage_critical_css' => array(
519 + 'description' => 'List the stored Critical CSS entries, or clear them so they regenerate. Use generate_critical_css to create them.',
520 + 'inputSchema' => self::object_schema(
521 + array(
522 + 'action' => array(
523 + 'type' => 'string',
524 + 'enum' => array( 'list', 'clear' ),
525 + 'description' => '"list" returns what is stored; "clear" deletes it.',
526 + ),
527 + ),
528 + array( 'action' )
529 + ),
530 + 'write' => true,
531 + 'handler' => array( self::class, 'manage_critical_css' ),
532 + ),
533 + 'get_preloader_status' => array(
534 + 'description' => 'Cache preloader progress: whether a run is active, how far through the URL list it is. Read-only.',
535 + 'inputSchema' => self::object_schema( array(), array() ),
536 + 'write' => false,
537 + 'handler' => array( self::class, 'get_preloader_status' ),
538 + ),
539 + 'stop_preloader' => array(
540 + 'description' => 'Stop a running cache preload. Safe mid-run — already-warmed pages stay cached.',
541 + 'inputSchema' => self::object_schema( array(), array() ),
542 + 'write' => true,
543 + 'handler' => array( self::class, 'stop_preloader' ),
544 + ),
545 + 'purge_url' => array(
546 + '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.',
547 + 'inputSchema' => self::object_schema(
548 + array(
549 + 'url' => array(
550 + 'type' => 'string',
551 + 'description' => 'Absolute URL or site-relative path, e.g. "https://site.com/about/" or "/about/".',
552 + ),
553 + ),
554 + array( 'url' )
555 + ),
556 + 'write' => true,
557 + 'handler' => array( self::class, 'purge_url' ),
558 + ),
559 + 'test_object_cache' => array(
560 + '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.',
561 + 'inputSchema' => self::object_schema( array(), array() ),
562 + 'write' => false,
563 + 'handler' => array( self::class, 'test_object_cache' ),
564 + ),
565 + 'cloudflare_verify' => array(
566 + 'description' => 'Verify the saved Cloudflare credentials against the Cloudflare API (token/zone check). Read-only — use purge_cloudflare to purge the edge.',
567 + 'inputSchema' => self::object_schema( array(), array() ),
568 + 'write' => false,
569 + 'handler' => array( self::class, 'cloudflare_verify' ),
570 + ),
229 571 'list_commands' => array(
230 572 'description' => 'List every xSpeed command that run_command can invoke (name, description, module, options). Use this to discover the full action surface beyond the curated + dedicated tools.',
231 573 'inputSchema' => self::object_schema( array(), array() ),
232 574 'write' => false,
@@ -232,9 +574,9 @@
232 574 'write' => false,
233 575 'handler' => array( self::class, 'list_commands' ),
234 576 ),
235 577 'run_command' => array(
236 - 'description' => 'Run any xSpeed command — the full CLI surface (~50 commands across every module: cache, cloudflare, database, critical/unused CSS, pagespeed, images, migration, preloader, object cache, analytics, RUM, smart-* and more). Call list_commands first to discover names + options. Examples: run_command("cloudflare purge"), run_command("database clean"), run_command("psi", {}, {"url":"https://site.com","strategy":"mobile"}).',
578 + '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("psi", {}, {"url":"https://site.com","strategy":"mobile"}). Permanently destructive commands ("database clean") additionally require a confirm_token from scan_database and are refused without one — this gateway is not a way around that confirmation.',
237 579 'inputSchema' => self::object_schema(
238 580 array(
239 581 'command' => array(
240 582 'type' => 'string',
@@ -248,8 +590,12 @@
248 590 'options' => array(
249 591 'type' => 'object',
250 592 'description' => 'Named options / flags, e.g. { "url": "https://site.com", "strategy": "mobile", "force": true }.',
251 593 ),
594 + 'confirm_token' => array(
595 + 'type' => 'string',
596 + 'description' => 'Required ONLY for permanently destructive commands such as "database clean". Obtain it from scan_database, which previews exactly what would be deleted. Without it those commands are refused.',
597 + ),
252 598 ),
253 599 array( 'command' )
254 600 ),
255 601 'write' => true,
@@ -261,16 +607,53 @@
261 607 // module is active (e.g. Pro): drop them if the command isn't
262 608 // registered, so we never advertise a tool that always fails. The
263 609 // action stays reachable via run_command if the command exists.
264 610 $conditional = array(
265 - 'run_pagespeed' => 'xspeed psi',
266 611 'generate_critical_css' => 'xspeed ccss',
267 612 'purge_cloudflare' => 'xspeed cf',
613 + 'cloudflare_verify' => 'xspeed cf',
268 614 'flush_object_cache' => 'xspeed objcache',
615 + 'test_object_cache' => 'xspeed objcache',
269 616 'start_preloader' => 'xspeed preloader',
270 617 'scan_database' => 'xspeed db',
271 618 'clean_database' => 'xspeed db',
619 + 'purge_url' => 'xspeed cache',
620 + 'get_cache_inventory' => 'xspeed cache',
621 + 'get_purge_log' => 'xspeed cache',
622 + 'recheck_rewrite_rules' => 'xspeed cache',
623 + 'set_cloudflare_dev_mode' => 'xspeed cf',
624 + 'optimize_database' => 'xspeed db',
625 + 'get_object_cache_status' => 'xspeed objcache',
626 + 'toggle_object_cache' => 'xspeed objcache',
627 + 'manage_critical_css' => 'xspeed ccss',
628 + 'get_preloader_status' => 'xspeed preloader',
629 + 'stop_preloader' => 'xspeed preloader',
272 630 );
631 +
632 + /*
633 + * Tools that are ALWAYS in the catalog, mapped to the command they
634 + * cover. These are listed separately from $conditional because the
635 + * two roles are different and used to be conflated in one map: this
636 + * set only tells the alias generator "don't emit an alias for this
637 + * command, a typed tool already covers it" — it must never drop a
638 + * tool.
639 + *
640 + * That conflation is exactly what broke get_settings/update_settings:
641 + * they were mapped to `xspeed settings`, a command that did not exist,
642 + * so the drop loop unset them on every request and they never reached
643 + * tools/list. `xspeed settings` now exists (SettingsModule), but the
644 + * split is what stops the class of bug recurring — an unconditional
645 + * tool can no longer be removed by a command going away. (#149/#153)
646 + */
647 + $always = array(
648 + 'get_settings' => 'xspeed settings',
649 + 'update_settings' => 'xspeed settings',
650 + 'run_pagespeed' => 'xspeed psi',
651 + 'get_health' => 'xspeed health',
652 + 'get_score_history' => 'xspeed score',
653 + 'purge_cache' => 'xspeed purge',
654 + );
655 +
273 656 $commands = Cli_Bridge::commands();
274 657 foreach ( $conditional as $tool => $command ) {
275 658 if ( ! isset( $commands[ $command ] ) ) {
276 659 unset( $catalog[ $tool ] );
@@ -275,13 +658,301 @@
275 658 if ( ! isset( $commands[ $command ] ) ) {
276 659 unset( $catalog[ $tool ] );
277 660 }
278 661 }
662 + // Alias generation reads both maps; the drop loop above reads only
663 + // $conditional.
664 + $conditional = array_merge( $conditional, $always );
279 665
666 + /*
667 + * One dedicated tool per xSpeed CLI command, generated from the same
668 + * Cli_Bridge catalog the CLI registers from — so the AI can reach the
669 + * long tail without the list_commands -> run_command hop, and the
670 + * generated set can never drift from the CLI.
671 + *
672 + * Commands already covered by a typed tool above are SKIPPED. The
673 + * `isset()` guard below only catches NAME collisions, and a generated
674 + * name never collides — `xspeed cf` becomes `xspeed_cf`, which is not
675 + * `purge_cloudflare`. So both used to ship: two tools for one action,
676 + * with the generated one marked write even when it wrapped a read,
677 + * and a per-tool permission on one name silently bypassable via the
678 + * other. $conditional already maps every typed tool to its command;
679 + * inverted, that IS the skip list.
680 + */
681 + foreach ( self::cli_generated_tools( array_flip( $conditional ) ) as $name => $spec ) {
682 + if ( ! isset( $catalog[ $name ] ) ) {
683 + $catalog[ $name ] = $spec;
684 + }
685 + }
686 +
687 + /**
688 + * Add private tools owned by an installed extension.
689 + *
690 + * Extensions receive an EMPTY map and may only add new names. Core tool
691 + * definitions cannot be replaced through this seam. A `hidden` tool is
692 + * callable through the authenticated per-site proxy but omitted from
693 + * tools/list. This is for broker-run workflows whose public tool must not
694 + * be advertised until the broker-side worker exists.
695 + *
696 + * `hidden` is not a permission: the proxy takes the same pairing token
697 + * the public channel does. It withholds a tool from discovery and from
698 + * an OAuth grant, nothing more. See the gate in invoke().
699 + *
700 + * `hidden` must be a real bool when present. It decides whether a tool
701 + * is reachable from the public channel at all, so a truthy string is
702 + * the kind of near-miss that should be rejected rather than guessed at.
703 + * Specs that fail any check here are skipped, not repaired.
704 + *
705 + * @param array<string,array<string,mixed>> $tools Extension tool specs.
706 + */
707 + $extensions = apply_filters( 'xspeed_mcp_extension_tools', array() );
708 + if ( is_array( $extensions ) ) {
709 + foreach ( $extensions as $name => $spec ) {
710 + if (
711 + ! is_string( $name )
712 + || 1 !== preg_match( '/^[a-z][a-z0-9_]{0,63}$/', $name )
713 + || isset( $catalog[ $name ] )
714 + || ! is_array( $spec )
715 + || ! isset( $spec['description'], $spec['inputSchema'], $spec['handler'], $spec['write'] )
716 + || ! is_string( $spec['description'] )
717 + || ! is_array( $spec['inputSchema'] )
718 + || ! is_callable( $spec['handler'] )
719 + || ! is_bool( $spec['write'] )
720 + || ( isset( $spec['hidden'] ) && ! is_bool( $spec['hidden'] ) )
721 + ) {
722 + continue;
723 + }
724 + $catalog[ $name ] = $spec;
725 + }
726 + }
727 +
280 728 return $catalog;
281 729 }
282 730
283 731 /**
732 + * Generate one MCP tool per registered xSpeed CLI command. Each wraps
733 + * Cli_Bridge::run(): the tool's `action` (the command's first positional,
734 + * e.g. `verify`/`purge` for `xspeed cf`) plus any named options are passed
735 + * straight through. Tool names are the command with the `xspeed ` prefix
736 + * dropped and spaces -> underscores (`xspeed cf` -> `xspeed_cf`).
737 + *
738 + * @return array<string, array{description:string, inputSchema:array, write:bool, handler:callable}>
739 + */
740 + private static function cli_generated_tools( array $covered = array() ): array {
741 + $tools = array();
742 + foreach ( Cli_Bridge::commands() as $command => $spec ) {
743 + // Already exposed as typed tools with real schemas and honest
744 + // read/write kinds — generating a coarse alias too would give the
745 + // AI two ways to do one thing and make a per-tool permission on
746 + // the typed name bypassable via the generated one.
747 + if ( isset( $covered[ $command ] ) ) {
748 + continue;
749 + }
750 + $tool_name = self::cli_tool_name( $command );
751 + if ( '' === $tool_name ) {
752 + continue;
753 + }
754 +
755 + // Build the input schema from the command's synopsis: positional
756 + // args become string properties (the first is usually the action,
757 + // exposed with its allowed values as an enum); assoc args become
758 + // named options.
759 + $properties = array();
760 + $required = array();
761 + foreach ( $spec['synopsis'] as $arg ) {
762 + if ( ! isset( $arg['name'] ) ) {
763 + continue;
764 + }
765 + $arg_name = (string) $arg['name'];
766 + $prop = array(
767 + 'type' => 'string',
768 + 'description' => isset( $arg['description'] ) ? (string) $arg['description'] : '',
769 + );
770 + if ( isset( $arg['options'] ) && is_array( $arg['options'] ) && ! empty( $arg['options'] ) ) {
771 + $prop['enum'] = array_values( array_map( 'strval', $arg['options'] ) );
772 + }
773 + $properties[ $arg_name ] = $prop;
774 + $is_optional = ! empty( $arg['optional'] );
775 + $is_flag = isset( $arg['type'] ) && 'flag' === $arg['type'];
776 + if ( ! $is_optional && ! $is_flag ) {
777 + $required[] = $arg_name;
778 + }
779 + }
780 +
781 + // Prefer the AI-facing hint. `shortdesc` is CLI help — written for
782 + // someone who already chose the command — so it says what the
783 + // output looks like, never when to reach for it. That is exactly
784 + // the question a model is answering when it reads tools/list, and
785 + // it is why 36 of these descriptions open with "Show". A module
786 + // that has not been given a hint yet keeps its shortdesc, so this
787 + // improves incrementally instead of needing all 40 at once. (#184)
788 + $description = '' !== ( $spec['ai_hint'] ?? '' )
789 + ? $spec['ai_hint']
790 + : ( '' !== $spec['shortdesc']
791 + ? $spec['shortdesc']
792 + : sprintf( 'Run the "%s" xSpeed command.', $command ) );
793 +
794 + list( $write, $write_actions, $read_actions ) = self::cli_write_profile( $command, $spec['synopsis'] );
795 +
796 + $tools[ $tool_name ] = array(
797 + 'description' => $description,
798 + 'inputSchema' => self::object_schema( $properties, $required ),
799 + 'write' => $write,
800 + // The action values that mutate state. When set, read-only
801 + // enforcement is per-ACTION (a read-only grant may still call
802 + // the tool with a read action like "status"/"scan").
803 + 'write_actions' => $write_actions,
804 + // The complement — actions positively classified as reads.
805 + // action_writes() allowlists against THIS rather than negating
806 + // write_actions, so an action added to a command later is
807 + // refused under a read-only grant until it has been
808 + // classified, instead of silently becoming callable.
809 + 'read_actions' => $read_actions,
810 + 'handler' => self::cli_handler_for( $command, $spec['synopsis'] ),
811 + );
812 + }
813 + return $tools;
814 + }
815 +
816 + /** Derive an MCP tool name from a CLI command ("xspeed cf" -> "xspeed_cf"). */
817 + private static function cli_tool_name( string $command ): string {
818 + $command = trim( preg_replace( '/\s+/', ' ', $command ) ?? '' );
819 + if ( '' === $command ) {
820 + return '';
821 + }
822 + return str_replace( ' ', '_', $command );
823 + }
824 +
825 + /** Action verbs that only inspect state (never mutate). */
826 + private const CLI_READ_VERBS = array( 'status', 'scan', 'list', 'verify', 'get', 'show', 'info', 'export', 'preview', 'check', 'snippet', 'test' );
827 +
828 + /** Commands with NO action enum that are nonetheless pure inspection. */
829 + private const CLI_READ_ONLY_COMMANDS = array( 'xspeed health', 'xspeed support' );
830 +
831 + /**
832 + * Compute the write profile for a generated command tool:
833 + * [ $write_bool, $write_actions ]
834 + * where $write_actions is the list of action values that mutate state
835 + * (empty when the tool has no action enum). $write_bool is the tool-level
836 + * flag: true if ANY action writes (so read-only clients see it flagged),
837 + * but per-action enforcement in invoke() still lets a read-only grant run
838 + * the tool's read actions (e.g. `minify status` while `minify purge` is
839 + * refused).
840 + *
841 + * @param string $command Full command name.
842 + * @param array $synopsis Command synopsis.
843 + * @return array{0:bool,1:string[],2:string[]} write flag, write actions, read actions
844 + */
845 + /**
846 + * Does THIS call mutate state, given the action the caller submitted?
847 + *
848 + * The tool-level `write` flag is true when ANY of a command's actions
849 + * write, so read-only clients can see the tool is capable of mutating.
850 + * Enforcing on that flag alone refuses the whole tool — which is how a
851 + * read-only grant lost the ability to run `xspeed_minify status` even
852 + * though only `purge` writes. `write_actions` records exactly which
853 + * action values mutate; this is what reads it.
854 + *
855 + * Fails CLOSED in every ambiguous case. An action that isn't in the
856 + * schema, an absent action, or a tool with no per-action profile all fall
857 + * back to the coarse flag and are refused. A read-only grant may end up
858 + * with less access than strictly necessary; it must never end up with
859 + * more.
860 + *
861 + * @param array $tool The catalog entry.
862 + * @param array $args The submitted arguments.
863 + */
864 + private static function action_writes( array $tool, array $args ): bool {
865 + $write_actions = isset( $tool['write_actions'] ) && is_array( $tool['write_actions'] )
866 + ? $tool['write_actions']
867 + : array();
868 +
869 + // No per-action profile — the coarse flag is all we have.
870 + if ( empty( $write_actions ) ) {
871 + return true;
872 + }
873 +
874 + $action = isset( $args['action'] ) && is_scalar( $args['action'] )
875 + ? strtolower( trim( (string) $args['action'] ) )
876 + : '';
877 +
878 + // No action supplied: the command's own default is unknown here, so
879 + // treat it as a write rather than guessing.
880 + if ( '' === $action ) {
881 + return true;
882 + }
883 +
884 + // Only an action we positively recognise as read is allowed through.
885 + // Anything unknown is refused, so a future action added to a command
886 + // can't silently become callable under a read-only grant before it has
887 + // been classified.
888 + $known = array_map(
889 + static function ( $a ) {
890 + return strtolower( trim( (string) $a ) );
891 + },
892 + isset( $tool['read_actions'] ) && is_array( $tool['read_actions'] ) ? $tool['read_actions'] : array()
893 + );
894 +
895 + return ! in_array( $action, $known, true );
896 + }
897 +
898 + private static function cli_write_profile( string $command, array $synopsis ): array {
899 + // Command with an action enum → classify each action.
900 + foreach ( $synopsis as $arg ) {
901 + if ( isset( $arg['type'], $arg['options'] ) && 'positional' === $arg['type'] && is_array( $arg['options'] ) ) {
902 + $write_actions = array();
903 + $read_actions = array();
904 + foreach ( $arg['options'] as $opt ) {
905 + if ( in_array( strtolower( (string) $opt ), self::CLI_READ_VERBS, true ) ) {
906 + $read_actions[] = (string) $opt;
907 + } else {
908 + $write_actions[] = (string) $opt;
909 + }
910 + }
911 + return array( ! empty( $write_actions ), $write_actions, $read_actions );
912 + }
913 + }
914 +
915 + // No action enum: a small allow-list of pure-inspection commands is
916 + // read-only; everything else defaults to write (safe — a read-only
917 + // grant never mutates).
918 + $is_read = in_array( trim( $command ), self::CLI_READ_ONLY_COMMANDS, true );
919 + return array( ! $is_read, array(), array() );
920 + }
921 +
922 + /**
923 + * Build the handler for a generated command tool. It maps the tool's
924 + * arguments back to Cli_Bridge::run(): positional synopsis args (in order)
925 + * become $args; everything else is passed as named options.
926 + *
927 + * @param string $command Full command name.
928 + * @param array $synopsis Command synopsis.
929 + * @return callable
930 + */
931 + private static function cli_handler_for( string $command, array $synopsis ): callable {
932 + // Names of the positional args, in declared order.
933 + $positionals = array();
934 + foreach ( $synopsis as $arg ) {
935 + if ( isset( $arg['name'] ) && ( ! isset( $arg['type'] ) || 'positional' === $arg['type'] ) ) {
936 + $positionals[] = (string) $arg['name'];
937 + }
938 + }
939 +
940 + return static function ( array $tool_args ) use ( $command, $positionals ) {
941 + $args = array();
942 + $assoc = $tool_args;
943 + // Pull positionals out (in order) into $args; the rest are options.
944 + foreach ( $positionals as $pname ) {
945 + if ( array_key_exists( $pname, $assoc ) && '' !== (string) $assoc[ $pname ] ) {
946 + $args[] = (string) $assoc[ $pname ];
947 + }
948 + unset( $assoc[ $pname ] );
949 + }
950 + return Cli_Bridge::run( $command, $args, $assoc );
951 + };
952 + }
953 +
954 + /**
284 955 * The tool list in MCP `tools/list` shape.
285 956 *
286 957 * @return array<int, array{name:string, description:string, inputSchema:array}>
287 958 */
@@ -287,8 +958,11 @@
287 958 */
288 959 public static function list(): array {
289 960 $out = array();
290 961 foreach ( self::catalog() as $name => $spec ) {
962 + if ( ! empty( $spec['hidden'] ) ) {
963 + continue;
964 + }
291 965 $out[] = array(
292 966 'name' => $name,
293 967 'description' => $spec['description'],
294 968 'inputSchema' => $spec['inputSchema'],
@@ -306,9 +980,9 @@
306 980 */
307 981 public static function invoke( string $name, array $args ) {
308 982 $catalog = self::catalog();
309 983 if ( ! isset( $catalog[ $name ] ) ) {
310 - return new \WP_Error(
984 + $error = new \WP_Error(
311 985 'xspeed_mcp_unknown_tool',
312 986 sprintf(
313 987 /* translators: %s: tool name. */
314 988 __( 'Unknown tool: %s', 'xspeed' ),
@@ -315,10 +989,51 @@
315 989 $name
316 990 ),
317 991 array( 'status' => 404 )
318 992 );
993 +
994 + // A call for a tool that doesn't exist is still something that
995 + // happened to this site, and a run of them is the shape of a
996 + // probe. Recording it is the difference between a trail that
997 + // shows what was ATTEMPTED and one that only shows what
998 + // succeeded. Scope is unknowable here, so log the conservative
999 + // one rather than implying the attempt was read-only.
1000 + Mcp_Activity_Log::record( $name, $args, false, $error->get_error_message(), 'write', self::$channel );
1001 +
1002 + return $error;
319 1003 }
320 1004
1005 + // Hidden tools are private broker stages, not undiscoverable public MCP
1006 + // tools. Omitting one from tools/list is only presentation, so this
1007 + // closes the JSON-RPC channel to them as well, returning the same 404 a
1008 + // nonexistent name returns. Both entry paths set the channel before
1009 + // every invoke, so a previous broker call cannot widen a later one.
1010 + //
1011 + // What this is NOT: a permission boundary. The broker REST route
1012 + // authenticates with the SAME pairing token as the JSON-RPC route
1013 + // (Mcp_Auth::permission and Mcp_Server::authorize both compare against
1014 + // Mcp_Pairing::site_token()), so anyone holding that token — every
1015 + // client the dashboard's connection recipes are written for — can call
1016 + // a hidden tool by name on the broker route, and tell it from a
1017 + // nonexistent one by the status. `hidden` keeps a tool off the
1018 + // advertised surface and out of an OAuth grant's reach; it does not
1019 + // make it safe for a pairing-token holder to run. Anything gated only
1020 + // by `hidden` must be something that holder may already do.
1021 + if ( ! empty( $catalog[ $name ]['hidden'] ) && 'broker' !== self::$channel ) {
1022 + $error = new \WP_Error(
1023 + 'xspeed_mcp_unknown_tool',
1024 + sprintf(
1025 + /* translators: %s: tool name. */
1026 + __( 'Unknown tool: %s', 'xspeed' ),
1027 + $name
1028 + ),
1029 + array( 'status' => 404 )
1030 + );
1031 + Mcp_Activity_Log::record( $name, $args, false, $error->get_error_message(), 'write', self::$channel );
1032 +
1033 + return $error;
1034 + }
1035 +
321 1036 // Scope enforcement: a read-only connection cannot invoke a tool that
322 1037 // mutates state. run_command is a gateway to the full CLI surface, so
323 1038 // it's treated as write regardless of the wrapped command. The active
324 1039 // credential's scope (pairing token OR OAuth access token) is carried
@@ -323,9 +1038,9 @@
323 1038 // it's treated as write regardless of the wrapped command. The active
324 1039 // credential's scope (pairing token OR OAuth access token) is carried
325 1040 // in self::$scope_override; it falls back to the pairing global for
326 1041 // callers that don't set a per-call scope.
327 - if ( ! empty( $catalog[ $name ]['write'] ) && self::is_read_only() ) {
1042 + if ( ! empty( $catalog[ $name ]['write'] ) && self::is_read_only() && self::action_writes( $catalog[ $name ], $args ) ) {
328 1043 return new \WP_Error(
329 1044 'xspeed_mcp_read_only',
330 1045 sprintf(
331 1046 /* translators: %s: tool name. */
@@ -335,11 +1050,104 @@
335 1050 array( 'status' => 403 )
336 1051 );
337 1052 }
338 1053
339 - return call_user_func( $catalog[ $name ]['handler'], $args );
1054 + /*
1055 + * Scan-before-clean, enforced at the dispatcher rather than in one
1056 + * handler.
1057 + *
1058 + * The guard used to live inside clean_database(). That protected a
1059 + * TOOL NAME, not the action: run_command("db", ["clean"]) reaches the
1060 + * same Cli_Bridge::run('db', ['clean']) with no token, no preview and
1061 + * no warning, and list_commands advertises the route to the assistant
1062 + * in plainer words ("Scan or clean WordPress bloat") than the tool
1063 + * that just refused it. Measured on a live site, that second door
1064 + * permanently destroyed 3,007 rows in a single call and reported
1065 + * success.
1066 + *
1067 + * Every tool passes through invoke(), so a confirmation checked here
1068 + * covers each door at once — including any future tool that wraps the
1069 + * same command. (#184)
1070 + */
1071 + $destructive = self::destructive_action( $name, $args );
1072 + if ( '' !== $destructive ) {
1073 + $confirmed = self::verify_clean_token( $args );
1074 + if ( is_wp_error( $confirmed ) ) {
1075 + Mcp_Activity_Log::record( $name, $args, false, $confirmed->get_error_message(), 'write', self::$channel );
1076 + return $confirmed;
1077 + }
1078 + }
1079 +
1080 + self::$dispatching = true;
1081 + try {
1082 + $result = call_user_func( $catalog[ $name ]['handler'], $args );
1083 +
1084 + // Audit every dispatched call — this is the record the admin
1085 + // reads to answer "what did the assistant do to my site?".
1086 + // Recorded here (not per-handler) so a new tool is covered the
1087 + // moment it joins the catalog.
1088 + [ $ok, $error ] = self::outcome( $result );
1089 +
1090 + $scope = empty( $catalog[ $name ]['write'] ) ? 'read' : 'write';
1091 +
1092 + Mcp_Activity_Log::record( $name, $args, $ok, $error, $scope, self::$channel );
1093 +
1094 + return $result;
1095 + } finally {
1096 + self::$dispatching = false;
1097 + }
340 1098 }
341 1099
1100 + /**
1101 + * Read success/failure out of a handler result.
1102 + *
1103 + * Two failure shapes reach here. A handler that validates its own
1104 + * input returns WP_Error. A handler that delegates to Cli_Bridge gets
1105 + * back an ARRAY carrying `ok => false` plus `error`, because a
1106 + * `WP_CLI::error()` inside the shim is a controlled failure rather
1107 + * than an exception. Reading only the first shape logged every failed
1108 + * command — a refused purge, a Cloudflare call with no credentials —
1109 + * as a success.
1110 + *
1111 + * @param mixed $result Handler return value.
1112 + * @return array{0:bool,1:string}
1113 + */
1114 + private static function outcome( $result ): array {
1115 + if ( is_wp_error( $result ) ) {
1116 + return array( false, $result->get_error_message() );
1117 + }
1118 +
1119 + if ( is_array( $result ) && array_key_exists( 'ok', $result ) && ! $result['ok'] ) {
1120 + $error = isset( $result['error'] ) ? (string) $result['error'] : '';
1121 + return array( false, '' === $error ? 'Command reported failure.' : $error );
1122 + }
1123 +
1124 + return array( true, '' );
1125 + }
1126 +
1127 + /** @var string Transport that carried the current call (for the audit log). */
1128 + private static $channel = 'mcp';
1129 +
1130 + /**
1131 + * Name the transport for subsequent invokes — the JSON-RPC endpoint and
1132 + * the hosted-broker REST routes share this catalog, and the audit trail
1133 + * should say which one a call arrived on.
1134 + */
1135 + public static function set_channel( string $channel ): void {
1136 + self::$channel = '' === $channel ? 'mcp' : $channel;
1137 + }
1138 +
1139 + /** @var bool True while an MCP tool handler is executing. */
1140 + private static $dispatching = false;
1141 +
1142 + /**
1143 + * True while a tool call is being dispatched — lets deeper layers
1144 + * (e.g. the settings change-log) attribute a mutation to MCP.
1145 + */
1146 + public static function in_dispatch(): bool {
1147 + return self::$dispatching;
1148 + }
1149 +
342 1150 /*
343 1151 * Handlers — thin proxies to the Free engine. Each takes decoded tool
344 1152 * arguments and returns an array payload (or WP_Error on bad input).
345 1153 */
@@ -346,8 +1154,13 @@
346 1154
347 1155 /**
348 1156 * Cache status, stats, and detected server.
349 1157 *
1158 + * `site_icon` is the Site Icon set under Appearance, or '' when none is
1159 + * set. The Hub shows it beside the site's name. Asking the site for
1160 + * /favicon.ico instead failed on nginx hosts, which answer .ico
1161 + * requests as static files and never reach WordPress.
1162 + *
350 1163 * @param array $args Unused.
351 1164 * @return array
352 1165 */
353 1166 public static function get_cache_status( array $args ) {
@@ -356,12 +1169,87 @@
356 1169 return array(
357 1170 'cache_enabled' => (bool) ( $opts['cache_enabled'] ?? false ),
358 1171 'stats' => Cache::get_stats(),
359 1172 'server' => Server::type(),
1173 + 'site_icon' => (string) get_site_icon_url( 64 ),
360 1174 );
361 1175 }
362 1176
363 1177 /**
1178 + * Facts about this site and install, stated explicitly.
1179 + *
1180 + * The Hub's fleet dashboard needed two things no tool reported directly.
1181 + * It had been INFERRING Pro's presence from `list_modules` — "any entry
1182 + * with tier: pro" — which works only because the registry returns just
1183 + * available modules. That is an inference riding an implementation
1184 + * detail, and it breaks the day a Pro install registers zero Pro modules.
1185 + *
1186 + * `pro_active` is "the Pro plugin is loaded and API-compatible";
1187 + * `licensed` is a separate question, since Pro can be active but
1188 + * unlicensed (its modules then boot but their settings are locked). Both
1189 + * are reported so a consumer never has to guess which one it wanted.
1190 + * (#146)
1191 + *
1192 + * @param array $args Unused.
1193 + * @return array
1194 + */
1195 + /**
1196 + * Is Pro licensed right now?
1197 + *
1198 + * Resolved through the `xspeed_module_descriptor` filter — the one Pro
1199 + * actually registers — by running a minimal Pro descriptor through it and
1200 + * reading back the `locked` flag Pro sets when the licence is inactive.
1201 + *
1202 + * `license` is deliberately not used as the probe slug: Pro exempts that
1203 + * module from locking so an expired site can still reach the screen where
1204 + * a new key is entered, so it would always come back unlocked.
1205 + */
1206 + private static function pro_licensed(): bool {
1207 + $probe = apply_filters(
1208 + 'xspeed_module_descriptor',
1209 + array(
1210 + 'slug' => '__license_probe__',
1211 + 'tier' => 'pro',
1212 + ),
1213 + null
1214 + );
1215 +
1216 + return empty( $probe['locked'] );
1217 + }
1218 +
1219 + public static function get_site_info( array $args ) {
1220 + unset( $args );
1221 +
1222 + $pro_active = Tier_Registry::pro_active();
1223 +
1224 + return array(
1225 + 'pro_active' => $pro_active,
1226 + 'pro_version' => defined( 'XSPEED_PRO_VERSION' ) ? (string) constant( 'XSPEED_PRO_VERSION' ) : null,
1227 + // Distinct from pro_active: Pro can be installed and running
1228 + // while its license is expired or absent.
1229 + //
1230 + // NOT `apply_filters( 'xspeed_pro_licensed', true )`. That hook is
1231 + // only ever APPLIED by Pro as an override point — no released
1232 + // version registers it — so with nothing listening the `true`
1233 + // default stood and this reported `licensed: true` on a fully
1234 + // revoked licence: the exact misreport the tool exists to
1235 + // eliminate. (QA blocker on #158)
1236 + //
1237 + // Ask the question the dashboard asks instead. Pro DOES register
1238 + // `xspeed_module_descriptor` and stamps `locked => 'license'` on
1239 + // every Pro entry when the licence is inactive, so reading that
1240 + // back is a real signal, and it cannot drift from what the panel
1241 + // shows because it IS what the panel shows.
1242 + 'licensed' => $pro_active ? self::pro_licensed() : false,
1243 + 'plugin_version' => defined( 'XSPEED_VERSION' ) ? (string) constant( 'XSPEED_VERSION' ) : null,
1244 + 'wp_version' => get_bloginfo( 'version' ),
1245 + 'php_version' => PHP_VERSION,
1246 + 'server' => Server::type(),
1247 + 'multisite' => is_multisite(),
1248 + );
1249 + }
1250 +
1251 + /**
364 1252 * All registered module descriptors.
365 1253 *
366 1254 * @param array $args Unused.
367 1255 * @return array
@@ -371,8 +1259,42 @@
371 1259 return Admin::modules_payload();
372 1260 }
373 1261
374 1262 /**
1263 + * Run the optimization autopilot.
1264 + *
1265 + * A thin wrapper: everything — the plan, the verification, the revert —
1266 + * lives in Optimize_Runner, so the CLI and this tool cannot drift into
1267 + * making different decisions about the same site.
1268 + *
1269 + * @param array<string,mixed> $args Tool arguments.
1270 + * @return array<string,mixed>|\WP_Error
1271 + */
1272 + public static function optimize_site( array $args = array() ) {
1273 + $run = array(
1274 + 'aggressiveness' => (string) ( $args['aggressiveness'] ?? 'standard' ),
1275 + 'dry_run' => (bool) ( $args['dry_run'] ?? false ),
1276 + 'measure_score' => (string) ( $args['measure_score'] ?? 'auto' ),
1277 + );
1278 +
1279 + // Tuning arguments are forwarded ONLY when present, and are not
1280 + // understood by Free — a listener on `xspeed_optimize_report` reads
1281 + // them from that filter's `$context`. Passing them through rather
1282 + // than naming them in the array above is deliberate: this handler
1283 + // builds an explicit whitelist, so an argument it does not list is
1284 + // silently dropped. A caller asking to reach a score would have got a
1285 + // single pass and a success response — wrong behaviour with no error,
1286 + // which is the expensive kind to diagnose.
1287 + foreach ( array( 'target_score', 'max_rounds' ) as $key ) {
1288 + if ( isset( $args[ $key ] ) && is_numeric( $args[ $key ] ) ) {
1289 + $run[ $key ] = (int) $args[ $key ];
1290 + }
1291 + }
1292 +
1293 + return \XSpeed\Optimize_Runner::run( $run );
1294 + }
1295 +
1296 + /**
375 1297 * Before/after cache benchmark timings.
376 1298 *
377 1299 * @param array $args Unused.
378 1300 * @return array
@@ -414,12 +1336,46 @@
414 1336 ),
415 1337 array( 'status' => 400 )
416 1338 );
417 1339 }
418 - $count = Cache::purge_type( $type );
1340 + // Named source, not the default "manual": the purge log's whole job
1341 + // is to let an admin see that the cache cleared because an assistant
1342 + // asked, not because someone clicked.
1343 + $cause = __( 'AI assistant', 'xspeed' );
1344 +
1345 + /*
1346 + * `page`, `assets` and `rest` are fine-grained slices of the local
1347 + * sweep with no target of their own, and they predate this tool's
1348 + * per-store report — an assistant asking for `page` means the HTML,
1349 + * not the HTML plus the minified bundles plus every purge listener.
1350 + * They stay on purge_type() so their meaning does not change under
1351 + * callers already relying on it.
1352 + *
1353 + * Everything else routes through the runner — the same core function
1354 + * the CLI and the REST callback use — so an assistant told "cache
1355 + * cleared" is reading the same per-store verdict a human would get,
1356 + * including a Cloudflare zone that refused the purge.
1357 + */
1358 + if ( in_array( $type, array( 'page', 'assets', 'rest' ), true ) ) {
1359 + return array(
1360 + 'purged' => $type,
1361 + 'count' => Cache::purge_type( $type, $cause ),
1362 + 'ok' => true,
1363 + 'stats' => Cache::get_stats(),
1364 + );
1365 + }
1366 +
1367 + $report = \XSpeed\Purge_Runner::run( array( $type ), $cause );
1368 + $count = 0;
1369 + foreach ( $report['types'] as $row ) {
1370 + $count += (int) $row['entries'];
1371 + }
1372 +
419 1373 return array(
420 1374 'purged' => $type,
421 1375 'count' => $count,
1376 + 'ok' => $report['ok'],
1377 + 'report' => $report['types'],
422 1378 'stats' => Cache::get_stats(),
423 1379 );
424 1380 }
425 1381
@@ -442,18 +1398,68 @@
442 1398
443 1399 // Persist cache_enabled the same way the Free /cache/toggle route
444 1400 // does (class-rest-api.php:235) — Cache::toggle handles the drop-in
445 1401 // + wp-config; Settings owns the option flag.
446 - Settings::update( array( 'cache_enabled' => $enabled ) );
1402 + //
1403 + // From the RESULT, not from $enabled: toggle() refuses to enable when
1404 + // another caching plugin owns the drop-in, and writing the requested
1405 + // value regardless left the site reporting a cache it had not
1406 + // installed — over MCP, with no human reading the response.
447 1407
448 1408 return array(
449 - 'cache_enabled' => $enabled,
450 - 'install_state' => $install,
451 - 'stats' => Cache::get_stats(),
1409 + 'cache_enabled' => $install['enabled'],
1410 + 'blocked' => ! empty( $install['blocked'] ),
1411 + 'blocked_reason' => $install['blocked_reason'] ?? null,
1412 + 'install_state' => $install,
1413 + 'stats' => Cache::get_stats(),
452 1414 );
453 1415 }
454 1416
455 1417 /**
1418 + * Is this module reachable over MCP right now?
1419 + *
1420 + * Mirrors SettingsModule::module_reachable(). Registration is not
1421 + * enough: Module_Registry::available() only asks whether Pro is LOADED,
1422 + * not whether it is LICENSED, so an unlicensed Pro site had every Pro
1423 + * module readable and writable over MCP while the dashboard showed it
1424 + * locked — reachable by any agent holding a write token. (QA M2)
1425 + *
1426 + * The licence answer comes through the `xspeed_module_descriptor` filter
1427 + * Pro registers, so Free never names a Pro class. (NOT
1428 + * `xspeed_pro_licensed` — Pro only ever APPLIES that one as an override
1429 + * and nothing listens to it, so gating on it silently passed everything.)
1430 + * `license` is exempt for the same reason Pro exempts it: locking it
1431 + * would remove the only surface that can fix an expired licence.
1432 + */
1433 + private static function settings_module_reachable( string $slug ): bool {
1434 + $module = \XSpeed\Module_Registry::available()[ $slug ] ?? null;
1435 + if ( ! $module ) {
1436 + return false;
1437 + }
1438 + if ( \XSpeed\Module::TIER_PRO !== $module->tier() || 'license' === $slug ) {
1439 + return true;
1440 + }
1441 +
1442 + // Ask the SAME question the dashboard asks. `xspeed_pro_licensed` is
1443 + // only ever APPLIED by Pro as an override hook — nothing registers it
1444 + // — so calling it here returned the default `true` and gated nothing.
1445 + // Pro DOES register `xspeed_module_descriptor`, and sets
1446 + // `locked => 'license'` on every Pro entry when the licence is
1447 + // inactive. Reusing that keeps one definition of "locked" instead of
1448 + // a second one in Free that can drift from the panel. (QA M2)
1449 + $entry = apply_filters(
1450 + 'xspeed_module_descriptor',
1451 + array(
1452 + 'slug' => $slug,
1453 + 'tier' => $module->tier(),
1454 + ),
1455 + $module
1456 + );
1457 +
1458 + return empty( $entry['locked'] );
1459 + }
1460 +
1461 + /**
456 1462 * Read a module's schema-validated settings.
457 1463 *
458 1464 * @param array $args { module:string }.
459 1465 * @return array|\WP_Error
@@ -466,11 +1472,43 @@
466 1472 __( 'The "module" parameter is required.', 'xspeed' ),
467 1473 array( 'status' => 400 )
468 1474 );
469 1475 }
470 - return array(
471 - 'module' => $module,
472 - 'settings' => Settings_Manager::get( $module ),
1476 + if ( ! self::settings_module_reachable( $module ) ) {
1477 + return new \WP_Error(
1478 + 'xspeed_mcp_unknown_module',
1479 + sprintf(
1480 + /* translators: %s: module slug. */
1481 + __( 'Unknown module "%s".', 'xspeed' ),
1482 + $module
1483 + ),
1484 + array( 'status' => 404 )
1485 + );
1486 + }
1487 + /**
1488 + * Filter the get_settings MCP payload for one module.
1489 + *
1490 + * Lets the module that owns the settings attach state the stored
1491 + * values alone cannot express — a toggle that is on but resolves to
1492 + * no effect on this host (Brotli without ngx_brotli), a configured
1493 + * generator that has never succeeded. Free never names Pro classes,
1494 + * so this seam is how a Pro module reaches the response an agent
1495 + * reads.
1496 + *
1497 + * @param array<string,mixed> $payload The response: module + settings.
1498 + * @param string $module Module slug.
1499 + * @param string $action 'get' here; 'update' on writes.
1500 + */
1501 + return apply_filters(
1502 + 'xspeed_mcp_settings_payload',
1503 + array(
1504 + 'module' => $module,
1505 + // Public view — secret fields masked. An MCP agent must never be able
1506 + // to read stored credentials back in plaintext. (#115)
1507 + 'settings' => Settings_Manager::get_public( $module ),
1508 + ),
1509 + $module,
1510 + 'get'
473 1511 );
474 1512 }
475 1513
476 1514 /**
@@ -495,15 +1533,197 @@
495 1533 __( 'The "values" parameter must be an object of setting keys.', 'xspeed' ),
496 1534 array( 'status' => 400 )
497 1535 );
498 1536 }
499 - return array(
500 - 'module' => $module,
501 - 'settings' => Settings_Manager::update( $module, $values ),
1537 + if ( ! self::settings_module_reachable( $module ) ) {
1538 + return new \WP_Error(
1539 + 'xspeed_mcp_unknown_module',
1540 + sprintf(
1541 + /* translators: %s: module slug. */
1542 + __( 'Unknown module "%s".', 'xspeed' ),
1543 + $module
1544 + ),
1545 + array( 'status' => 404 )
1546 + );
1547 + }
1548 + // Writing credentials over MCP requires the explicit `configure` grant —
1549 + // off by default even for a write-scoped connection — so an agent can't
1550 + // silently repoint the Cloudflare/object-cache backend at an attacker
1551 + // endpoint. Refuse with a message naming exactly which fields need it.
1552 + // (Settings_Manager::update also strips these as a backstop covering the
1553 + // run_command → CLI path.) (#116)
1554 + if ( ! self::can_configure() ) {
1555 + $secret_fields = Settings_Manager::secret_keys_in( $module, $values );
1556 + if ( ! empty( $secret_fields ) ) {
1557 + return new \WP_Error(
1558 + 'xspeed_mcp_configure_required',
1559 + sprintf(
1560 + /* translators: 1: comma-separated field names, 2: module slug. */
1561 + __( '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' ),
1562 + implode( ', ', $secret_fields ),
1563 + $module
1564 + ),
1565 + array(
1566 + 'status' => 403,
1567 + 'refused_fields' => $secret_fields,
1568 + )
1569 + );
1570 + }
1571 + }
1572 + // The Pro licence WRITE gate. `settings_module_reachable()` above already
1573 + // hides locked Pro modules, but that is a VISIBILITY check answered by
1574 + // the `xspeed_module_descriptor` filter — a different question from "may
1575 + // this be written", and one that drifts the moment Pro changes how it
1576 + // flags `locked`. Ask the write gate itself, the same one REST consults
1577 + // via Module::update_settings(), so the two can't disagree.
1578 + //
1579 + // This is not theoretical: with the descriptor's `locked` flag removed,
1580 + // this handler wrote `enabled: false -> true` to a module whose
1581 + // is_license_locked() was true, because it persists through
1582 + // Settings_Manager::update() and never reaches Module::update_settings().
1583 + // (#185)
1584 + $module_object = \XSpeed\Module_Registry::get( $module );
1585 + if ( $module_object && $module_object->is_license_locked() ) {
1586 + // Match the REST path's audit trail — a refused write is a security
1587 + // event and must be visible in the activity log wherever it came
1588 + // from. Module::license_write_refusal() records the same type.
1589 + \XSpeed\Activity_Log::record(
1590 + 'license_write_refused',
1591 + sprintf(
1592 + /* translators: %s: module slug. */
1593 + __( 'Refused an MCP settings write to the Pro module "%s" — no valid license.', 'xspeed' ),
1594 + $module
1595 + ),
1596 + \XSpeed\Activity_Log::WARN
1597 + );
1598 +
1599 + return new \WP_Error(
1600 + 'xspeed_license_required',
1601 + sprintf(
1602 + /* translators: %s: module slug. */
1603 + __( '"%s" is a Pro module and this site has no active license, so the write was refused. Nothing was changed.', 'xspeed' ),
1604 + $module
1605 + ),
1606 + array(
1607 + 'status' => 403,
1608 + 'module' => $module,
1609 + )
1610 + );
1611 + }
1612 +
1613 + // An agent cannot tell a silent no-op from a real write, so refuse
1614 + // instead of returning a success payload. update() walks the schema:
1615 + // an out-of-schema key is never written and never mentioned, and an
1616 + // in-schema key with a rejected value quietly keeps the stored one.
1617 + // The realistic case is `cache_enabled` on the `cache` module — the
1618 + // most natural way to ask for caching, and a complete no-op. (#206)
1619 + $report = self::inspect_or_error( $module, $values );
1620 + if ( is_wp_error( $report ) ) {
1621 + return $report;
1622 + }
1623 +
1624 + /**
1625 + * Filter the update_settings MCP payload for one module.
1626 + *
1627 + * The write path's twin of the get filter above — this is where a
1628 + * module can say "stored, but inert on this host" in the same
1629 + * response that reports the write, instead of returning a plain
1630 + * success an agent relays as "enabled". Documented in
1631 + * docs/guides/hooks-and-filters.md.
1632 + *
1633 + * @param array<string,mixed> $payload The response: module + settings.
1634 + * @param string $module Module slug.
1635 + * @param string $action 'update' here; 'get' on reads.
1636 + */
1637 + return apply_filters(
1638 + 'xspeed_mcp_settings_payload',
1639 + array(
1640 + 'module' => $module,
1641 + // Return value is already masked (Settings_Manager::update returns the
1642 + // public view), so a written secret isn't echoed back either. (#115)
1643 + 'settings' => Settings_Manager::update( $module, $values ),
1644 + ),
1645 + $module,
1646 + 'update'
502 1647 );
503 1648 }
504 1649
505 1650 /**
1651 + * Refuse a settings payload carrying keys that would be silently dropped.
1652 + *
1653 + * @param string $module Module slug.
1654 + * @param array<string,mixed> $values Proposed values.
1655 + * @return true|\WP_Error True when every key would be applied.
1656 + */
1657 + private static function inspect_or_error( string $module, array $values ) {
1658 + $report = Settings_Manager::inspect_input( $module, $values );
1659 + if ( empty( $report['unknown'] ) && empty( $report['invalid'] ) && empty( $report['locked'] ) ) {
1660 + return true;
1661 + }
1662 +
1663 + $parts = array();
1664 + // A field pinned by a wp-config.php constant cannot be written. Say so
1665 + // rather than returning a success the agent relays as "changed" over a
1666 + // write that update() would silently drop. (#398)
1667 + foreach ( $report['locked'] as $key ) {
1668 + $spec = ( Module_Registry::get( $module ) ? Module_Registry::get( $module )->settings_schema()[ $key ] ?? array() : array() );
1669 + $constant = Settings_Manager::effective_constant( $module, $key, is_array( $spec ) ? $spec : array() );
1670 + $parts[] = sprintf(
1671 + /* translators: 1: setting key, 2: wp-config.php constant name. */
1672 + __( '"%1$s" is defined in wp-config.php as %2$s and cannot be changed here', 'xspeed' ),
1673 + $key,
1674 + (string) $constant
1675 + );
1676 + }
1677 + foreach ( $report['unknown'] as $key ) {
1678 + $detail = sprintf(
1679 + /* translators: 1: setting key, 2: module slug. */
1680 + __( '"%1$s" is not a setting of module "%2$s"', 'xspeed' ),
1681 + $key,
1682 + $module
1683 + );
1684 + $hint = Settings_Manager::hint_for_unknown_key( $key );
1685 + if ( '' !== $hint ) {
1686 + $detail .= ' — ' . $hint;
1687 + } else {
1688 + $near = Settings_Manager::did_you_mean( $module, $key );
1689 + if ( ! empty( $near ) ) {
1690 + $detail .= sprintf(
1691 + /* translators: %s: comma-separated setting names. */
1692 + __( ' — did you mean: %s?', 'xspeed' ),
1693 + implode( ', ', $near )
1694 + );
1695 + }
1696 + }
1697 + $parts[] = $detail;
1698 + }
1699 + foreach ( $report['invalid'] as $key ) {
1700 + $parts[] = sprintf(
1701 + /* translators: %s: setting key. */
1702 + __( '"%s" was rejected by the schema (wrong type, or outside the allowed range/options)', 'xspeed' ),
1703 + $key
1704 + );
1705 + }
1706 +
1707 + return new \WP_Error(
1708 + 'xspeed_settings_refused',
1709 + sprintf(
1710 + /* translators: 1: module slug, 2: reasons. */
1711 + __( 'Refused to update %1$s — nothing was written. %2$s', 'xspeed' ),
1712 + $module,
1713 + implode( '; ', $parts )
1714 + ),
1715 + array(
1716 + 'status' => 400,
1717 + 'refused_unknown' => $report['unknown'],
1718 + 'refused_invalid' => $report['invalid'],
1719 + 'refused_locked' => $report['locked'],
1720 + 'would_apply' => $report['applied'],
1721 + )
1722 + );
1723 + }
1724 +
1725 + /**
506 1726 * List every command run_command can invoke (the full CLI surface).
507 1727 *
508 1728 * @param array $args Unused.
509 1729 * @return array
@@ -557,12 +1777,261 @@
557 1777 * @return array|\WP_Error
558 1778 */
559 1779 public static function scan_database( array $args ) {
560 1780 unset( $args );
561 - return Cli_Bridge::run( 'db', array( 'scan' ) );
1781 + $result = Cli_Bridge::run( 'db', array( 'scan' ) );
1782 + if ( is_wp_error( $result ) || empty( $result['ok'] ) ) {
1783 + return $result;
1784 + }
1785 +
1786 + /*
1787 + * Mint the token clean_database will demand, and state what it covers.
1788 + *
1789 + * The scan is the only place the caller can see what is about to be
1790 + * destroyed, so it is the only honest place to authorise the delete.
1791 + * The token is bound to the CATEGORIES ENABLED and the COUNTS FOUND at
1792 + * this moment: if either moves before the delete lands, the token no
1793 + * longer describes reality and clean_database refuses. That closes the
1794 + * window where a scan is shown to a human, something changes, and the
1795 + * delete removes more than was agreed to. (#184)
1796 + */
1797 + $result['confirm_token'] = self::mint_clean_token();
1798 + $result['confirm_note'] = __( 'This preview deletes nothing. To delete what is listed, call clean_database with this confirm_token. It expires in 5 minutes and stops working if the database changes.', 'xspeed' );
1799 +
1800 + return $result;
562 1801 }
563 1802
1803 + /** Categories currently enabled for deletion, with what a scan found in each. */
1804 + private static function clean_scope(): array {
1805 + $enabled = array_keys( array_filter( Settings_Manager::get( 'database' ), static fn( $v ) => true === $v ) );
1806 + sort( $enabled );
1807 +
1808 + $counts = array();
1809 + foreach ( Database_Cleaner::scan() as $key => $row ) {
1810 + $counts[ $key ] = is_array( $row ) ? (int) ( $row['count'] ?? 0 ) : (int) $row;
1811 + }
1812 + ksort( $counts );
1813 +
1814 + return array(
1815 + 'enabled' => $enabled,
1816 + 'counts' => $counts,
1817 + );
1818 + }
1819 +
564 1820 /**
1821 + * Actions that permanently destroy content and therefore require a
1822 + * confirm_token, keyed by canonical command name.
1823 + *
1824 + * Keyed by ACTION, not by tool name, because the same action is
1825 + * reachable through several tools (the typed clean_database, the
1826 + * run_command gateway, and any future wrapper).
1827 + *
1828 + * @return array<string, string[]>
1829 + */
1830 + private static function destructive_actions(): array {
1831 + /**
1832 + * Filter the command actions that require an explicit confirmation.
1833 + *
1834 + * @since 1.1.6
1835 + * @param array<string, string[]> $actions Action names keyed by command.
1836 + */
1837 + return (array) apply_filters(
1838 + 'xspeed_mcp_destructive_actions',
1839 + array( 'xspeed db' => array( 'clean' ) )
1840 + );
1841 + }
1842 +
1843 + /**
1844 + * Name the destructive action a call would run, or '' if it is harmless.
1845 + *
1846 + * @param string $name Tool name.
1847 + * @param array $args Decoded tool arguments.
1848 + * @return string Canonical "<command> <action>", or '' when not destructive.
1849 + */
1850 + private static function destructive_action( string $name, array $args ): string {
1851 + // The gateway carries the real command in its arguments; a typed tool
1852 + // is identified by the command it is mapped to.
1853 + if ( 'run_command' === $name ) {
1854 + $command = isset( $args['command'] ) ? (string) $args['command'] : '';
1855 + if ( '' === $command ) {
1856 + return '';
1857 + }
1858 + $positional = isset( $args['args'] ) && is_array( $args['args'] ) ? $args['args'] : array();
1859 + $resolved = Cli_Bridge::classify( $command, $positional );
1860 + } elseif ( 'clean_database' === $name ) {
1861 + $resolved = Cli_Bridge::classify( 'db', array( 'clean' ) );
1862 + } else {
1863 + return '';
1864 + }
1865 +
1866 + if ( '' === $resolved['name'] ) {
1867 + return '';
1868 + }
1869 +
1870 + $destructive = self::destructive_actions();
1871 + if ( ! isset( $destructive[ $resolved['name'] ] ) ) {
1872 + return '';
1873 + }
1874 + if ( ! in_array( $resolved['action'], (array) $destructive[ $resolved['name'] ], true ) ) {
1875 + return '';
1876 + }
1877 +
1878 + return trim( $resolved['name'] . ' ' . $resolved['action'] );
1879 + }
1880 +
1881 + /**
1882 + * Verify (and consume) the confirm_token minted by scan_database.
1883 + *
1884 + * @param array $args Decoded tool arguments.
1885 + * @return true|\WP_Error
1886 + */
1887 + private static function verify_clean_token( array $args ) {
1888 + $token = isset( $args['confirm_token'] ) ? (string) $args['confirm_token'] : '';
1889 + if ( '' === $token ) {
1890 + return new \WP_Error(
1891 + 'xspeed_mcp_confirm_required',
1892 + __( 'This permanently deletes content and cannot be undone. Call scan_database first to see exactly what would be removed, then pass the confirm_token it returns.', 'xspeed' ),
1893 + array( 'status' => 400 )
1894 + );
1895 + }
1896 +
1897 + // Single use: consumed whether or not the delete goes ahead, so one
1898 + // approval can never authorise a second, different deletion.
1899 + $sealed = self::consume_clean_token( $token );
1900 + if ( '' === $sealed ) {
1901 + return new \WP_Error(
1902 + 'xspeed_mcp_confirm_invalid',
1903 + __( 'That confirm_token is unknown or has expired (they last 5 minutes). Run scan_database again and use the fresh token.', 'xspeed' ),
1904 + array( 'status' => 400 )
1905 + );
1906 + }
1907 +
1908 + if ( ! hash_equals( $sealed, self::clean_fingerprint() ) ) {
1909 + return new \WP_Error(
1910 + 'xspeed_mcp_confirm_stale',
1911 + __( 'The database changed since that scan, so the preview no longer describes what would be deleted. Run scan_database again and confirm against the new result.', 'xspeed' ),
1912 + array( 'status' => 409 )
1913 + );
1914 + }
1915 +
1916 + return true;
1917 + }
1918 +
1919 + /** Fingerprint of the scope, so a token cannot outlive what it described. */
1920 + private static function clean_fingerprint(): string {
1921 + return hash( 'sha256', (string) wp_json_encode( self::clean_scope() ) );
1922 + }
1923 +
1924 + /** Lifetime of a confirm_token, from mint to refusal. */
1925 + private const CLEAN_TOKEN_TTL = 5 * MINUTE_IN_SECONDS;
1926 +
1927 + /** Storage key for a minted token (the token itself is never stored). */
1928 + private static function clean_token_key( string $token ): string {
1929 + return 'xspeed_mcp_clean_' . hash( 'sha256', $token );
1930 + }
1931 +
1932 + /*
1933 + * The token is held in an OPTION, not a transient.
1934 + *
1935 + * scan_database and clean_database are two separate HTTP requests, so the
1936 + * token has to survive between them. With an external object cache
1937 + * installed, set_transient() writes to that cache ONLY and never touches
1938 + * the options table — so on any site whose object cache is
1939 + * non-persistent, flushed between requests, or simply orphaned (a stale
1940 + * W3TC/Redis drop-in pointing at a dead backend), the token evaporates the
1941 + * moment it is minted.
1942 + *
1943 + * That does not fail safe. It makes the confirmation UNSATISFIABLE:
1944 + * clean_database can never be authorised by any sequence of calls, and the
1945 + * operator's only remaining route to the feature is the admin panel. A
1946 + * guard that cannot be passed is a broken feature, and the pressure it
1947 + * creates is to remove the guard. Reproduced on a stack running W3 Total
1948 + * Cache's object-cache drop-in: every freshly minted token was refused as
1949 + * "unknown or expired" on the very next request. (#184)
1950 + *
1951 + * Options are backed by the database, so the token persists whatever the
1952 + * object cache does. Expiry is carried in the stored value and checked on
1953 + * read, since options have no TTL of their own.
1954 + */
1955 +
1956 + private static function mint_clean_token(): string {
1957 + $token = wp_generate_password( 32, false );
1958 +
1959 + // autoload=no: this is read once, by one request, minutes from now.
1960 + add_option(
1961 + self::clean_token_key( $token ),
1962 + wp_json_encode(
1963 + array(
1964 + 'fingerprint' => self::clean_fingerprint(),
1965 + 'expires' => time() + self::CLEAN_TOKEN_TTL,
1966 + )
1967 + ),
1968 + '',
1969 + 'no'
1970 + );
1971 +
1972 + self::purge_expired_clean_tokens();
1973 +
1974 + return $token;
1975 + }
1976 +
1977 + /**
1978 + * Read a minted token's sealed fingerprint, or '' if unknown/expired.
1979 + *
1980 + * Consumes the record either way: a token is single use, so one approval
1981 + * can never authorise a second, different deletion.
1982 + */
1983 + private static function consume_clean_token( string $token ): string {
1984 + $key = self::clean_token_key( $token );
1985 + $stored = get_option( $key );
1986 + if ( ! is_string( $stored ) || '' === $stored ) {
1987 + return '';
1988 + }
1989 +
1990 + delete_option( $key );
1991 +
1992 + $data = json_decode( $stored, true );
1993 + if ( ! is_array( $data ) || empty( $data['fingerprint'] ) ) {
1994 + return '';
1995 + }
1996 + if ( ! isset( $data['expires'] ) || time() > (int) $data['expires'] ) {
1997 + return '';
1998 + }
1999 +
2000 + return (string) $data['fingerprint'];
2001 + }
2002 +
2003 + /**
2004 + * Drop token rows nobody consumed.
2005 + *
2006 + * Options have no TTL, so an unused token would otherwise sit in
2007 + * wp_options forever — a scan that is never followed by a clean is the
2008 + * normal case, not the exception.
2009 + */
2010 + private static function purge_expired_clean_tokens(): void {
2011 + global $wpdb;
2012 +
2013 + if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
2014 + return;
2015 + }
2016 +
2017 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- no options API for "select by key prefix"; runs only when a token is minted.
2018 + $names = $wpdb->get_col(
2019 + $wpdb->prepare(
2020 + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
2021 + $wpdb->esc_like( 'xspeed_mcp_clean_' ) . '%'
2022 + )
2023 + );
2024 +
2025 + foreach ( (array) $names as $name ) {
2026 + $data = json_decode( (string) get_option( $name ), true );
2027 + if ( ! is_array( $data ) || ! isset( $data['expires'] ) || time() > (int) $data['expires'] ) {
2028 + delete_option( $name );
2029 + }
2030 + }
2031 + }
2032 +
2033 + /**
565 2034 * Clean database bloat (destructive).
566 2035 *
567 2036 * @param array $args Unused.
568 2037 * @return array|\WP_Error
@@ -567,8 +2036,17 @@
567 2036 * @param array $args Unused.
568 2037 * @return array|\WP_Error
569 2038 */
570 2039 public static function clean_database( array $args ) {
2040 + /*
2041 + * The scan-before-clean confirmation is enforced in invoke(), which
2042 + * every tool passes through — see destructive_action(). It is NOT
2043 + * repeated here: the token is single-use, so checking it twice would
2044 + * consume it on the first check and reject the caller on the second.
2045 + *
2046 + * Reaching this line means the dispatcher already verified a token
2047 + * bound to a scan of the current database state. (#184)
2048 + */
571 2049 unset( $args );
572 2050 return Cli_Bridge::run( 'db', array( 'clean' ) );
573 2051 }
574 2052
@@ -594,13 +2072,341 @@
594 2072 return Cli_Bridge::run( 'preloader', array( 'start' ) );
595 2073 }
596 2074
597 2075 /**
598 - * Run a PageSpeed Insights audit (Pro).
2076 + * Full health diagnostics (checks + stats + buckets + activity).
2077 + * Direct typed payload — same tier as get_cache_status — so the agent
2078 + * gets structured tones/ids instead of parsing CLI log lines.
599 2079 *
600 - * @param array $args { url?:string, strategy?:string }.
2080 + * @param array $args Unused.
2081 + * @return array
2082 + */
2083 + public static function get_health( array $args ) {
2084 + unset( $args );
2085 + return array(
2086 + 'checks' => \XSpeed\Health::checks(),
2087 + 'stats' => Cache::get_stats(),
2088 + 'buckets' => \XSpeed\Hit_Counter::buckets(),
2089 + 'hit_daily' => \XSpeed\Hit_Counter::daily_series( 30 ),
2090 + 'activity' => \XSpeed\Activity_Log::entries(),
2091 + );
2092 + }
2093 +
2094 + /**
2095 + * Stored benchmark runs + settings-change events (trend data).
2096 + *
2097 + * @param array $args { limit?:int }.
2098 + * @return array
2099 + */
2100 + public static function get_benchmark_history( array $args ) {
2101 + $limit = isset( $args['limit'] ) ? max( 1, min( 100, (int) $args['limit'] ) ) : 100;
2102 + $changes = array();
2103 + foreach ( \XSpeed\Activity_Log::entries() as $entry ) {
2104 + if ( 'settings_changed' === ( $entry['type'] ?? '' ) ) {
2105 + $changes[] = array(
2106 + 'ts' => (int) $entry['ts'],
2107 + 'message' => (string) $entry['message'],
2108 + );
2109 + }
2110 + }
2111 + return array(
2112 + 'runs' => Cache_Benchmark::history( $limit ),
2113 + 'changes' => $changes,
2114 + );
2115 + }
2116 +
2117 + /**
2118 + * Purge a single URL's cache entries.
2119 + *
2120 + * @param array $args { url:string }.
601 2121 * @return array|\WP_Error
602 2122 */
2123 + /**
2124 + * Inspect what is in the page cache (pages + age, or size breakdown).
2125 + *
2126 + * @param array $args detail: pages|size, limit.
2127 + * @return array|\WP_Error
2128 + */
2129 + public static function get_cache_inventory( array $args ) {
2130 + $detail = isset( $args['detail'] ) ? (string) $args['detail'] : 'pages';
2131 + $action = 'size' === $detail ? 'size' : 'inventory';
2132 + $assoc = array();
2133 + if ( isset( $args['limit'] ) && '' !== $args['limit'] ) {
2134 + $assoc['limit'] = (string) $args['limit'];
2135 + }
2136 + return Cli_Bridge::run( 'cache', array( $action ), $assoc );
2137 + }
2138 +
2139 + /**
2140 + * Recent cache purges and their causes.
2141 + *
2142 + * @param array $args limit.
2143 + * @return array|\WP_Error
2144 + */
2145 + public static function get_purge_log( array $args ) {
2146 + $assoc = array();
2147 + if ( isset( $args['limit'] ) && '' !== $args['limit'] ) {
2148 + $assoc['limit'] = (string) $args['limit'];
2149 + }
2150 + return Cli_Bridge::run( 'cache', array( 'purge-log' ), $assoc );
2151 + }
2152 +
2153 + /**
2154 + * Re-verify (and repair) the server rewrite rules.
2155 + *
2156 + * @param array $args Unused.
2157 + * @return array|\WP_Error
2158 + */
2159 + public static function recheck_rewrite_rules( array $args ) {
2160 + unset( $args );
2161 + return Cli_Bridge::run( 'cache', array( 'recheck-rewrite' ) );
2162 + }
2163 +
2164 + /**
2165 + * Turn Cloudflare development mode on or off.
2166 + *
2167 + * A boolean rather than two tools: dev-on and dev-off are one decision,
2168 + * and offering them separately doubles the surface for no gain.
2169 + *
2170 + * @param array $args enabled (bool, required).
2171 + * @return array|\WP_Error
2172 + */
2173 + public static function set_cloudflare_dev_mode( array $args ) {
2174 + if ( ! array_key_exists( 'enabled', $args ) ) {
2175 + return new \WP_Error( 'xspeed_mcp_missing_enabled', __( 'The enabled argument is required.', 'xspeed' ), array( 'status' => 400 ) );
2176 + }
2177 + $on = filter_var( $args['enabled'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
2178 + if ( null === $on ) {
2179 + return new \WP_Error( 'xspeed_mcp_invalid_enabled', __( 'The enabled argument must be true or false.', 'xspeed' ), array( 'status' => 400 ) );
2180 + }
2181 + return Cli_Bridge::run( 'cf', array( $on ? 'dev-on' : 'dev-off' ) );
2182 + }
2183 +
2184 + /**
2185 + * Optimize database tables (distinct from clean_database, which deletes).
2186 + *
2187 + * @param array $args Unused.
2188 + * @return array|\WP_Error
2189 + */
2190 + public static function optimize_database( array $args ) {
2191 + unset( $args );
2192 + return Cli_Bridge::run( 'db', array( 'optimize' ) );
2193 + }
2194 +
2195 + /**
2196 + * Object cache state, or the server snippet that enables it.
2197 + *
2198 + * @param array $args detail: status|snippet.
2199 + * @return array|\WP_Error
2200 + */
2201 + public static function get_object_cache_status( array $args ) {
2202 + $detail = isset( $args['detail'] ) ? (string) $args['detail'] : 'status';
2203 + $action = 'snippet' === $detail ? 'snippet' : 'status';
2204 + return Cli_Bridge::run( 'objcache', array( $action ) );
2205 + }
2206 +
2207 + /**
2208 + * Install or remove the object-cache drop-in.
2209 + *
2210 + * @param array $args enabled (bool, required).
2211 + * @return array|\WP_Error
2212 + */
2213 + public static function toggle_object_cache( array $args ) {
2214 + if ( ! array_key_exists( 'enabled', $args ) ) {
2215 + return new \WP_Error( 'xspeed_mcp_missing_enabled', __( 'The enabled argument is required.', 'xspeed' ), array( 'status' => 400 ) );
2216 + }
2217 + $on = filter_var( $args['enabled'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
2218 + if ( null === $on ) {
2219 + return new \WP_Error( 'xspeed_mcp_invalid_enabled', __( 'The enabled argument must be true or false.', 'xspeed' ), array( 'status' => 400 ) );
2220 + }
2221 + return Cli_Bridge::run( 'objcache', array( $on ? 'enable' : 'disable' ) );
2222 + }
2223 +
2224 + /**
2225 + * List or clear stored Critical CSS.
2226 + *
2227 + * @param array $args action: list|clear.
2228 + * @return array|\WP_Error
2229 + */
2230 + public static function manage_critical_css( array $args ) {
2231 + $action = isset( $args['action'] ) ? (string) $args['action'] : '';
2232 + if ( ! in_array( $action, array( 'list', 'clear' ), true ) ) {
2233 + return new \WP_Error( 'xspeed_mcp_invalid_action', __( 'The action argument must be "list" or "clear".', 'xspeed' ), array( 'status' => 400 ) );
2234 + }
2235 + return Cli_Bridge::run( 'ccss', array( $action ) );
2236 + }
2237 +
2238 + /**
2239 + * Preloader progress.
2240 + *
2241 + * @param array $args Unused.
2242 + * @return array|\WP_Error
2243 + */
2244 + public static function get_preloader_status( array $args ) {
2245 + unset( $args );
2246 + return Cli_Bridge::run( 'preloader', array( 'status' ) );
2247 + }
2248 +
2249 + /**
2250 + * Stop a running preload.
2251 + *
2252 + * @param array $args Unused.
2253 + * @return array|\WP_Error
2254 + */
2255 + public static function stop_preloader( array $args ) {
2256 + unset( $args );
2257 + return Cli_Bridge::run( 'preloader', array( 'stop' ) );
2258 + }
2259 +
2260 + /**
2261 + * Stored external audit runs (PSI / GTmetrix).
2262 + *
2263 + * Read-only by construction: it reads the option Score already wrote. No
2264 + * outbound call is made, which is what lets the Hub poll this on a
2265 + * schedule without spending the site owner's PSI or GTmetrix quota.
2266 + *
2267 + * @param array $args limit.
2268 + * @return array|\WP_Error
2269 + */
2270 + public static function get_score_history( array $args ) {
2271 + if ( ! class_exists( '\\XSpeed\\Score' ) ) {
2272 + return new \WP_Error( 'xspeed_mcp_no_score', __( 'External scores are not available on this site.', 'xspeed' ), array( 'status' => 404 ) );
2273 + }
2274 +
2275 + $limit = isset( $args['limit'] ) ? (int) $args['limit'] : 100;
2276 + $limit = max( 1, min( 500, $limit ) );
2277 +
2278 + $history = \XSpeed\Score::history();
2279 +
2280 + $runs = array();
2281 + foreach ( array_slice( $history, 0, $limit ) as $run ) {
2282 + if ( ! is_array( $run ) ) {
2283 + continue;
2284 + }
2285 + $metrics = isset( $run['metrics'] ) && is_array( $run['metrics'] ) ? $run['metrics'] : array();
2286 + $runs[] = array(
2287 + 'provider' => isset( $run['provider'] ) ? (string) $run['provider'] : 'unknown',
2288 + 'ts' => isset( $run['ts'] ) ? (int) $run['ts'] : 0,
2289 + 'url' => isset( $run['url'] ) ? (string) $run['url'] : '',
2290 + 'strategy' => isset( $run['strategy'] ) ? (string) $run['strategy'] : null,
2291 + // A failed audit and a successful one that returned no score
2292 + // both project to score null — ok is the only field that
2293 + // tells them apart, and error says why it failed.
2294 + 'ok' => ! empty( $run['ok'] ),
2295 + 'error' => isset( $run['error'] ) && '' !== $run['error'] ? (string) $run['error'] : null,
2296 + // Null, never 0: Score distinguishes "no score" from "scored
2297 + // zero", and flattening that reports a failed audit as a
2298 + // catastrophic result.
2299 + 'score' => isset( $run['score'] ) && is_numeric( $run['score'] ) ? (int) $run['score'] : null,
2300 + 'metrics' => array(
2301 + 'lcp' => self::metric_or_null( $metrics, 'lcp' ),
2302 + 'fcp' => self::metric_or_null( $metrics, 'fcp' ),
2303 + 'cls' => self::metric_or_null( $metrics, 'cls' ),
2304 + 'tbt' => self::metric_or_null( $metrics, 'tbt' ),
2305 + 'si' => self::metric_or_null( $metrics, 'si' ),
2306 + 'ttfb' => self::metric_or_null( $metrics, 'ttfb' ),
2307 + ),
2308 + 'report_url' => self::report_url_for( $run ),
2309 + );
2310 + }
2311 +
2312 + return array(
2313 + 'runs' => $runs,
2314 + 'total' => count( $history ),
2315 + );
2316 + }
2317 +
2318 + /**
2319 + * One metric as a float, or null when absent/non-numeric.
2320 + *
2321 + * @param array $metrics Metric bag.
2322 + * @param string $key Metric id.
2323 + */
2324 + private static function metric_or_null( array $metrics, string $key ): ?float {
2325 + return isset( $metrics[ $key ] ) && is_numeric( $metrics[ $key ] ) ? (float) $metrics[ $key ] : null;
2326 + }
2327 +
2328 + /**
2329 + * Deep link to the provider's own report, when one exists.
2330 + *
2331 + * GTmetrix hosts a durable report per test, so its id is enough to build
2332 + * the link. PSI does NOT — a Lighthouse result is returned to the caller
2333 + * and never hosted, so there is genuinely nothing to link to and this
2334 + * returns null rather than inventing a URL that 404s.
2335 + *
2336 + * @param array $run One stored run.
2337 + */
2338 + private static function report_url_for( array $run ): ?string {
2339 + $provider = isset( $run['provider'] ) ? (string) $run['provider'] : '';
2340 + if ( 'gtmetrix' !== $provider ) {
2341 + return null;
2342 + }
2343 + $test_id = isset( $run['test_id'] ) ? trim( (string) $run['test_id'] ) : '';
2344 + if ( '' === $test_id ) {
2345 + return null;
2346 + }
2347 + return 'https://gtmetrix.com/reports/' . rawurlencode( $test_id );
2348 + }
2349 +
2350 + public static function purge_url( array $args ) {
2351 + $url = isset( $args['url'] ) ? trim( (string) $args['url'] ) : '';
2352 + if ( '' === $url ) {
2353 + return new \WP_Error( 'xspeed_mcp_missing_url', __( 'The url argument is required.', 'xspeed' ), array( 'status' => 400 ) );
2354 + }
2355 + return Cli_Bridge::run( 'cache', array( 'purge-url', $url ), array( 'cause' => __( 'AI assistant', 'xspeed' ) ) );
2356 + }
2357 +
2358 + /**
2359 + * Probe the configured object-cache backend (connect + read/write).
2360 + *
2361 + * @param array $args Unused.
2362 + * @return array|\WP_Error
2363 + */
2364 + public static function test_object_cache( array $args ) {
2365 + unset( $args );
2366 + return Cli_Bridge::run( 'objcache', array( 'test' ) );
2367 + }
2368 +
2369 + /**
2370 + * Verify the saved Cloudflare credentials.
2371 + *
2372 + * @param array $args Unused.
2373 + * @return array|\WP_Error
2374 + */
2375 + public static function cloudflare_verify( array $args ) {
2376 + unset( $args );
2377 + return Cli_Bridge::run( 'cf', array( 'verify' ) );
2378 + }
2379 +
2380 + /**
2381 + * Run an external audit on any install.
2382 + *
2383 + * Shares run_pagespeed's body: that handler ALREADY falls back to
2384 + * `xspeed score run` when the Pro `xspeed psi` command is absent, so the
2385 + * engine could always do this on Free — the tool was simply dropped from
2386 + * the catalog before anyone could call it. The only thing missing was a
2387 + * name that survives on a Free install. (#147)
2388 + *
2389 + * @param array $args target / strategy / provider.
2390 + * @return array|\WP_Error
2391 + */
2392 + public static function run_score( array $args ) {
2393 + // `target` is the CLI's name for it (--url is a reserved WP-CLI global,
2394 + // so the score command deliberately uses --target). Accept both here
2395 + // and normalise, so an assistant that guessed `url` still works.
2396 + if ( ! empty( $args['target'] ) && empty( $args['url'] ) ) {
2397 + $args['url'] = (string) $args['target'];
2398 + }
2399 + return self::run_pagespeed( $args );
2400 + }
2401 +
2402 + /**
2403 + * Run an external performance audit. Prefers the Pro engine when present,
2404 + * otherwise drives Free's own score command.
2405 + *
2406 + * @param array $args { url?:string, strategy?:string, provider?:string, force?:bool }.
2407 + * @return array|\WP_Error
2408 + */
603 2409 public static function run_pagespeed( array $args ) {
604 2410 $options = array();
605 2411 if ( ! empty( $args['url'] ) ) {
606 2412 $options['url'] = (string) $args['url'];
@@ -607,20 +2413,90 @@
607 2413 }
608 2414 if ( ! empty( $args['strategy'] ) ) {
609 2415 $options['strategy'] = (string) $args['strategy'];
610 2416 }
611 - return Cli_Bridge::run( 'psi', array(), $options );
2417 + // Advertised in run_score's schema, and the Free score handler already
2418 + // branches on it (ScoreModule::cli_handler reads $assoc['provider']),
2419 + // so dropping it here meant a GTmetrix request ran a PSI audit and
2420 + // reported ok:true — spending the wrong provider's quota with nothing
2421 + // in the response to say so. (QA B1 on #162)
2422 + if ( ! empty( $args['provider'] ) ) {
2423 + $options['provider'] = (string) $args['provider'];
2424 + }
2425 + // Was reachable only via the generated xspeed_psi alias, which this
2426 + // change removes — so it moves onto the typed tool rather than being
2427 + // lost with it.
2428 + if ( ! empty( $args['force'] ) && filter_var( $args['force'], FILTER_VALIDATE_BOOLEAN ) ) {
2429 + $options['force'] = true;
2430 + }
2431 +
2432 + /*
2433 + * Prefer the richer Pro engine when it's installed; otherwise drive
2434 + * Free's own score command. Same tool name either way — an assistant
2435 + * asking for a PageSpeed audit shouldn't have to know which tier the
2436 + * site runs, and the two write to the same run history.
2437 + *
2438 + * EXCEPT when a provider was named that the Pro engine cannot serve.
2439 + * `xspeed psi` is PageSpeed-only: it declares no --provider and
2440 + * discards the option, so preferring it purely because it exists made
2441 + * `provider: "gtmetrix"` run PSI and answer ok:true — the same silent
2442 + * wrong-provider bug this tool just fixed on Free, reappearing only on
2443 + * Pro. A site that configures GTmetrix would have stopped getting it
2444 + * the moment Pro activated. Free's `score` command reads $assoc
2445 + * ['provider'] and branches, so route there instead. (QA R1 on #162)
2446 + */
2447 + $wants_non_psi = isset( $options['provider'] ) && 'psi' !== strtolower( (string) $options['provider'] );
2448 + if ( isset( Cli_Bridge::commands()['xspeed psi'] ) && ! $wants_non_psi ) {
2449 + return Cli_Bridge::run( 'psi', array(), $options );
2450 + }
2451 +
2452 + // The Free `score` command reads --target, not --url: `url` is a
2453 + // reserved WP-CLI global, so a value passed as `url` never reaches the
2454 + // handler and the requested page is silently ignored in favour of the
2455 + // default. Translate rather than passing it through. (#147)
2456 + if ( isset( $options['url'] ) ) {
2457 + $options['target'] = $options['url'];
2458 + unset( $options['url'] );
2459 + }
2460 + return Cli_Bridge::run( 'score', array( 'run' ), $options );
612 2461 }
613 2462
614 2463 /**
615 2464 * Generate Critical CSS (Pro).
616 2465 *
617 - * @param array $args Unused.
2466 + * The tool took no arguments, so it could only build the home page's
2467 + * blob, while `wp xspeed ccss generate --page-url` could target any page.
2468 + * Passed as `page-url`: `url` is a WP-CLI global the command never sees
2469 + * from a real command line. (#559)
2470 + *
2471 + * @param array $args { url?:string } Full URL or site path.
618 2472 * @return array|\WP_Error
619 2473 */
620 2474 public static function generate_critical_css( array $args ) {
621 - unset( $args );
622 - return Cli_Bridge::run( 'ccss', array( 'generate' ) );
2475 + $options = array();
2476 + if ( ! empty( $args['url'] ) ) {
2477 + $url = trim( (string) $args['url'] );
2478 + // A site path is a page on this site, as it is for run_pagespeed.
2479 + if ( '/' === substr( $url, 0, 1 ) && '//' !== substr( $url, 0, 2 ) ) {
2480 + $url = home_url( $url );
2481 + } elseif ( ! preg_match( '#^https?://#i', $url ) ) {
2482 + $url = ''; // `about/`, `//host/x`: neither a path nor a full URL.
2483 + }
2484 + $url = '' === $url ? '' : esc_url_raw( $url, array( 'http', 'https' ) );
2485 + // Only pages of this site. A render spends the site's quota, and
2486 + // another host is not a page this site's Critical CSS can serve.
2487 + $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
2488 + if ( '' !== $url && strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) !== $host ) {
2489 + $url = '';
2490 + }
2491 + // Refused rather than dropped: an empty value would quietly
2492 + // build the home page and report success for the wrong page.
2493 + if ( '' === $url ) {
2494 + return new \WP_Error( 'xspeed_mcp_invalid_url', __( 'The url argument must be a page on this site, as a full http(s) URL or a path starting with /.', 'xspeed' ), array( 'status' => 400 ) );
2495 + }
2496 + $options['page-url'] = $url;
2497 + }
2498 + return Cli_Bridge::run( 'ccss', array( 'generate' ), $options );
623 2499 }
624 2500
625 2501 /**
626 2502 * Build a JSON Schema object node.