PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
1.4.1 1.4.0 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 All 35 releases
← All changes | includes/modules/Mcp/Mcp_Tools.php +1997 -49 1.1.2 → 1.4.0 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,8 +143,49 @@
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. `addons` maps each separately licensed add-on to `{licensed, status}`. `licensed` says whether the add-on\'s licence is active. `status` is the licence status the site has stored (`valid`, `expired`, `inactive`, and so on), or null when no key is stored. Both are read from stored state, never from the licence server. `addons` is `{}` when no add-on reports. A licensed add-on may still have nothing set up.',
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 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,
@@ -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 an external performance audit (PageSpeed Insights, or GTmetrix when configured) and return the score + Core Web Vitals. Defaults to the site home page, mobile strategy. Requires external scores to be enabled in settings — the plugin makes no outbound calls otherwise.',
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,17 +351,40 @@
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.',
225 - 'inputSchema' => self::object_schema( array(), array() ),
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 + ),
226 387 'write' => true,
227 388 'handler' => array( self::class, 'generate_critical_css' ),
228 389 ),
229 390 'get_health' => array(
@@ -245,8 +406,151 @@
245 406 ),
246 407 'write' => false,
247 408 'handler' => array( self::class, 'get_benchmark_history' ),
248 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() ),
466 + 'write' => true,
467 + 'handler' => array( self::class, 'recheck_rewrite_rules' ),
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 + 'takeover' => array(
513 + 'type' => 'boolean',
514 + 'description' => 'With enabled=true: switch from the plugin that owns object-cache.php. Without it, enabling refuses while another plugin owns the file.',
515 + ),
516 + 'restore' => array(
517 + 'type' => 'boolean',
518 + 'description' => 'With enabled=false: put back the plugin xSpeed switched from.',
519 + ),
520 + ),
521 + array( 'enabled' )
522 + ),
523 + 'write' => true,
524 + 'handler' => array( self::class, 'toggle_object_cache' ),
525 + ),
526 + 'manage_critical_css' => array(
527 + 'description' => 'List the stored Critical CSS entries, or clear them so they regenerate. Use generate_critical_css to create them.',
528 + 'inputSchema' => self::object_schema(
529 + array(
530 + 'action' => array(
531 + 'type' => 'string',
532 + 'enum' => array( 'list', 'clear' ),
533 + 'description' => '"list" returns what is stored; "clear" deletes it.',
534 + ),
535 + ),
536 + array( 'action' )
537 + ),
538 + 'write' => true,
539 + 'handler' => array( self::class, 'manage_critical_css' ),
540 + ),
541 + 'get_preloader_status' => array(
542 + 'description' => 'Cache preloader progress: whether a run is active, how far through the URL list it is. Read-only.',
543 + 'inputSchema' => self::object_schema( array(), array() ),
544 + 'write' => false,
545 + 'handler' => array( self::class, 'get_preloader_status' ),
546 + ),
547 + 'stop_preloader' => array(
548 + 'description' => 'Stop a running cache preload. Safe mid-run — already-warmed pages stay cached.',
549 + 'inputSchema' => self::object_schema( array(), array() ),
550 + 'write' => true,
551 + 'handler' => array( self::class, 'stop_preloader' ),
552 + ),
249 553 'purge_url' => array(
250 554 'description' => 'Purge the cache for ONE URL only (all its variants: device buckets, trailing-slash forms, static-tree copy). Surgical alternative to purge_cache when a single page changed.',
251 555 'inputSchema' => self::object_schema(
252 556 array(
@@ -278,9 +582,9 @@
278 582 'write' => false,
279 583 'handler' => array( self::class, 'list_commands' ),
280 584 ),
281 585 'run_command' => array(
282 - 'description' => 'Run any xSpeed command — the full CLI surface (~50 commands across every module: cache, cloudflare, database, critical/unused CSS, pagespeed, images, migration, preloader, object cache, analytics, RUM, smart-* and more). Call list_commands first to discover names + options. Examples: run_command("cloudflare purge"), run_command("database clean"), run_command("psi", {}, {"url":"https://site.com","strategy":"mobile"}).',
586 + '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 additionally require a confirm_token and are refused without one — this gateway is not a way around that confirmation. "database clean" takes its token from scan_database, which previews exactly what would be deleted; every other destructive command is refused once and the refusal carries a token, so repeating the same call with it confirms the action.',
283 587 'inputSchema' => self::object_schema(
284 588 array(
285 589 'command' => array(
286 590 'type' => 'string',
@@ -294,8 +598,12 @@
294 598 'options' => array(
295 599 'type' => 'object',
296 600 'description' => 'Named options / flags, e.g. { "url": "https://site.com", "strategy": "mobile", "force": true }.',
297 601 ),
602 + 'confirm_token' => array(
603 + 'type' => 'string',
604 + 'description' => 'Required ONLY for permanently destructive commands. For "database clean" obtain it from scan_database, which previews exactly what would be deleted; for any other destructive command, make the call once without this argument and the refusal returns the token to repeat it with. Without it those commands are refused.',
605 + ),
298 606 ),
299 607 array( 'command' )
300 608 ),
301 609 'write' => true,
@@ -316,9 +624,44 @@
316 624 'start_preloader' => 'xspeed preloader',
317 625 'scan_database' => 'xspeed db',
318 626 'clean_database' => 'xspeed db',
319 627 'purge_url' => 'xspeed cache',
628 + 'get_cache_inventory' => 'xspeed cache',
629 + 'get_purge_log' => 'xspeed cache',
630 + 'recheck_rewrite_rules' => 'xspeed cache',
631 + 'set_cloudflare_dev_mode' => 'xspeed cf',
632 + 'optimize_database' => 'xspeed db',
633 + 'get_object_cache_status' => 'xspeed objcache',
634 + 'toggle_object_cache' => 'xspeed objcache',
635 + 'manage_critical_css' => 'xspeed ccss',
636 + 'get_preloader_status' => 'xspeed preloader',
637 + 'stop_preloader' => 'xspeed preloader',
320 638 );
639 +
640 + /*
641 + * Tools that are ALWAYS in the catalog, mapped to the command they
642 + * cover. These are listed separately from $conditional because the
643 + * two roles are different and used to be conflated in one map: this
644 + * set only tells the alias generator "don't emit an alias for this
645 + * command, a typed tool already covers it" — it must never drop a
646 + * tool.
647 + *
648 + * That conflation is exactly what broke get_settings/update_settings:
649 + * they were mapped to `xspeed settings`, a command that did not exist,
650 + * so the drop loop unset them on every request and they never reached
651 + * tools/list. `xspeed settings` now exists (SettingsModule), but the
652 + * split is what stops the class of bug recurring — an unconditional
653 + * tool can no longer be removed by a command going away. (#149/#153)
654 + */
655 + $always = array(
656 + 'get_settings' => 'xspeed settings',
657 + 'update_settings' => 'xspeed settings',
658 + 'run_pagespeed' => 'xspeed psi',
659 + 'get_health' => 'xspeed health',
660 + 'get_score_history' => 'xspeed score',
661 + 'purge_cache' => 'xspeed purge',
662 + );
663 +
321 664 $commands = Cli_Bridge::commands();
322 665 foreach ( $conditional as $tool => $command ) {
323 666 if ( ! isset( $commands[ $command ] ) ) {
324 667 unset( $catalog[ $tool ] );
@@ -323,21 +666,74 @@
323 666 if ( ! isset( $commands[ $command ] ) ) {
324 667 unset( $catalog[ $tool ] );
325 668 }
326 669 }
670 + // Alias generation reads both maps; the drop loop above reads only
671 + // $conditional.
672 + $conditional = array_merge( $conditional, $always );
327 673
328 - // One dedicated tool per xSpeed CLI command, generated from the same
329 - // Cli_Bridge catalog the CLI registers from — so the AI can call any
330 - // command directly without the list_commands -> run_command discovery
331 - // hop, and the generated set can never drift from the CLI. Curated /
332 - // promoted tools above win on any name collision (they have richer
333 - // schemas), so e.g. `xspeed cf` doesn't shadow purge_cloudflare.
334 - foreach ( self::cli_generated_tools() as $name => $spec ) {
674 + /*
675 + * One dedicated tool per xSpeed CLI command, generated from the same
676 + * Cli_Bridge catalog the CLI registers from — so the AI can reach the
677 + * long tail without the list_commands -> run_command hop, and the
678 + * generated set can never drift from the CLI.
679 + *
680 + * Commands already covered by a typed tool above are SKIPPED. The
681 + * `isset()` guard below only catches NAME collisions, and a generated
682 + * name never collides — `xspeed cf` becomes `xspeed_cf`, which is not
683 + * `purge_cloudflare`. So both used to ship: two tools for one action,
684 + * with the generated one marked write even when it wrapped a read,
685 + * and a per-tool permission on one name silently bypassable via the
686 + * other. $conditional already maps every typed tool to its command;
687 + * inverted, that IS the skip list.
688 + */
689 + foreach ( self::cli_generated_tools( array_flip( $conditional ) ) as $name => $spec ) {
335 690 if ( ! isset( $catalog[ $name ] ) ) {
336 691 $catalog[ $name ] = $spec;
337 692 }
338 693 }
339 694
695 + /**
696 + * Add private tools owned by an installed extension.
697 + *
698 + * Extensions receive an EMPTY map and may only add new names. Core tool
699 + * definitions cannot be replaced through this seam. A `hidden` tool is
700 + * callable through the authenticated per-site proxy but omitted from
701 + * tools/list. This is for broker-run workflows whose public tool must not
702 + * be advertised until the broker-side worker exists.
703 + *
704 + * `hidden` is not a permission: the proxy takes the same pairing token
705 + * the public channel does. It withholds a tool from discovery and from
706 + * an OAuth grant, nothing more. See the gate in invoke().
707 + *
708 + * `hidden` must be a real bool when present. It decides whether a tool
709 + * is reachable from the public channel at all, so a truthy string is
710 + * the kind of near-miss that should be rejected rather than guessed at.
711 + * Specs that fail any check here are skipped, not repaired.
712 + *
713 + * @param array<string,array<string,mixed>> $tools Extension tool specs.
714 + */
715 + $extensions = apply_filters( 'xspeed_mcp_extension_tools', array() );
716 + if ( is_array( $extensions ) ) {
717 + foreach ( $extensions as $name => $spec ) {
718 + if (
719 + ! is_string( $name )
720 + || 1 !== preg_match( '/^[a-z][a-z0-9_]{0,63}$/', $name )
721 + || isset( $catalog[ $name ] )
722 + || ! is_array( $spec )
723 + || ! isset( $spec['description'], $spec['inputSchema'], $spec['handler'], $spec['write'] )
724 + || ! is_string( $spec['description'] )
725 + || ! is_array( $spec['inputSchema'] )
726 + || ! is_callable( $spec['handler'] )
727 + || ! is_bool( $spec['write'] )
728 + || ( isset( $spec['hidden'] ) && ! is_bool( $spec['hidden'] ) )
729 + ) {
730 + continue;
731 + }
732 + $catalog[ $name ] = $spec;
733 + }
734 + }
735 +
340 736 return $catalog;
341 737 }
342 738
343 739 /**
@@ -348,11 +744,18 @@
348 744 * dropped and spaces -> underscores (`xspeed cf` -> `xspeed_cf`).
349 745 *
350 746 * @return array<string, array{description:string, inputSchema:array, write:bool, handler:callable}>
351 747 */
352 - private static function cli_generated_tools(): array {
748 + private static function cli_generated_tools( array $covered = array() ): array {
353 749 $tools = array();
354 750 foreach ( Cli_Bridge::commands() as $command => $spec ) {
751 + // Already exposed as typed tools with real schemas and honest
752 + // read/write kinds — generating a coarse alias too would give the
753 + // AI two ways to do one thing and make a per-tool permission on
754 + // the typed name bypassable via the generated one.
755 + if ( isset( $covered[ $command ] ) ) {
756 + continue;
757 + }
355 758 $tool_name = self::cli_tool_name( $command );
356 759 if ( '' === $tool_name ) {
357 760 continue;
358 761 }
@@ -382,13 +785,22 @@
382 785 $required[] = $arg_name;
383 786 }
384 787 }
385 788
386 - $description = '' !== $spec['shortdesc']
387 - ? $spec['shortdesc']
388 - : sprintf( 'Run the "%s" xSpeed command.', $command );
789 + // Prefer the AI-facing hint. `shortdesc` is CLI help — written for
790 + // someone who already chose the command — so it says what the
791 + // output looks like, never when to reach for it. That is exactly
792 + // the question a model is answering when it reads tools/list, and
793 + // it is why 36 of these descriptions open with "Show". A module
794 + // that has not been given a hint yet keeps its shortdesc, so this
795 + // improves incrementally instead of needing all 40 at once. (#184)
796 + $description = '' !== ( $spec['ai_hint'] ?? '' )
797 + ? $spec['ai_hint']
798 + : ( '' !== $spec['shortdesc']
799 + ? $spec['shortdesc']
800 + : sprintf( 'Run the "%s" xSpeed command.', $command ) );
389 801
390 - list( $write, $write_actions ) = self::cli_write_profile( $command, $spec['synopsis'] );
802 + list( $write, $write_actions, $read_actions ) = self::cli_write_profile( $command, $spec['synopsis'] );
391 803
392 804 $tools[ $tool_name ] = array(
393 805 'description' => $description,
394 806 'inputSchema' => self::object_schema( $properties, $required ),
@@ -396,8 +808,14 @@
396 808 // The action values that mutate state. When set, read-only
397 809 // enforcement is per-ACTION (a read-only grant may still call
398 810 // the tool with a read action like "status"/"scan").
399 811 'write_actions' => $write_actions,
812 + // The complement — actions positively classified as reads.
813 + // action_writes() allowlists against THIS rather than negating
814 + // write_actions, so an action added to a command later is
815 + // refused under a read-only grant until it has been
816 + // classified, instead of silently becoming callable.
817 + 'read_actions' => $read_actions,
400 818 'handler' => self::cli_handler_for( $command, $spec['synopsis'] ),
401 819 );
402 820 }
403 821 return $tools;
@@ -429,21 +847,77 @@
429 847 * refused).
430 848 *
431 849 * @param string $command Full command name.
432 850 * @param array $synopsis Command synopsis.
433 - * @return array{0:bool,1:string[]}
851 + * @return array{0:bool,1:string[],2:string[]} write flag, write actions, read actions
434 852 */
853 + /**
854 + * Does THIS call mutate state, given the action the caller submitted?
855 + *
856 + * The tool-level `write` flag is true when ANY of a command's actions
857 + * write, so read-only clients can see the tool is capable of mutating.
858 + * Enforcing on that flag alone refuses the whole tool — which is how a
859 + * read-only grant lost the ability to run `xspeed_minify status` even
860 + * though only `purge` writes. `write_actions` records exactly which
861 + * action values mutate; this is what reads it.
862 + *
863 + * Fails CLOSED in every ambiguous case. An action that isn't in the
864 + * schema, an absent action, or a tool with no per-action profile all fall
865 + * back to the coarse flag and are refused. A read-only grant may end up
866 + * with less access than strictly necessary; it must never end up with
867 + * more.
868 + *
869 + * @param array $tool The catalog entry.
870 + * @param array $args The submitted arguments.
871 + */
872 + private static function action_writes( array $tool, array $args ): bool {
873 + $write_actions = isset( $tool['write_actions'] ) && is_array( $tool['write_actions'] )
874 + ? $tool['write_actions']
875 + : array();
876 +
877 + // No per-action profile — the coarse flag is all we have.
878 + if ( empty( $write_actions ) ) {
879 + return true;
880 + }
881 +
882 + $action = isset( $args['action'] ) && is_scalar( $args['action'] )
883 + ? strtolower( trim( (string) $args['action'] ) )
884 + : '';
885 +
886 + // No action supplied: the command's own default is unknown here, so
887 + // treat it as a write rather than guessing.
888 + if ( '' === $action ) {
889 + return true;
890 + }
891 +
892 + // Only an action we positively recognise as read is allowed through.
893 + // Anything unknown is refused, so a future action added to a command
894 + // can't silently become callable under a read-only grant before it has
895 + // been classified.
896 + $known = array_map(
897 + static function ( $a ) {
898 + return strtolower( trim( (string) $a ) );
899 + },
900 + isset( $tool['read_actions'] ) && is_array( $tool['read_actions'] ) ? $tool['read_actions'] : array()
901 + );
902 +
903 + return ! in_array( $action, $known, true );
904 + }
905 +
435 906 private static function cli_write_profile( string $command, array $synopsis ): array {
436 907 // Command with an action enum → classify each action.
437 908 foreach ( $synopsis as $arg ) {
438 909 if ( isset( $arg['type'], $arg['options'] ) && 'positional' === $arg['type'] && is_array( $arg['options'] ) ) {
439 910 $write_actions = array();
911 + $read_actions = array();
440 912 foreach ( $arg['options'] as $opt ) {
441 - if ( ! in_array( strtolower( (string) $opt ), self::CLI_READ_VERBS, true ) ) {
913 + if ( in_array( strtolower( (string) $opt ), self::CLI_READ_VERBS, true ) ) {
914 + $read_actions[] = (string) $opt;
915 + } else {
442 916 $write_actions[] = (string) $opt;
443 917 }
444 918 }
445 - return array( ! empty( $write_actions ), $write_actions );
919 + return array( ! empty( $write_actions ), $write_actions, $read_actions );
446 920 }
447 921 }
448 922
449 923 // No action enum: a small allow-list of pure-inspection commands is
@@ -449,9 +923,9 @@
449 923 // No action enum: a small allow-list of pure-inspection commands is
450 924 // read-only; everything else defaults to write (safe — a read-only
451 925 // grant never mutates).
452 926 $is_read = in_array( trim( $command ), self::CLI_READ_ONLY_COMMANDS, true );
453 - return array( ! $is_read, array() );
927 + return array( ! $is_read, array(), array() );
454 928 }
455 929
456 930 /**
457 931 * Build the handler for a generated command tool. It maps the tool's
@@ -492,8 +966,11 @@
492 966 */
493 967 public static function list(): array {
494 968 $out = array();
495 969 foreach ( self::catalog() as $name => $spec ) {
970 + if ( ! empty( $spec['hidden'] ) ) {
971 + continue;
972 + }
496 973 $out[] = array(
497 974 'name' => $name,
498 975 'description' => $spec['description'],
499 976 'inputSchema' => $spec['inputSchema'],
@@ -532,8 +1009,39 @@
532 1009
533 1010 return $error;
534 1011 }
535 1012
1013 + // Hidden tools are private broker stages, not undiscoverable public MCP
1014 + // tools. Omitting one from tools/list is only presentation, so this
1015 + // closes the JSON-RPC channel to them as well, returning the same 404 a
1016 + // nonexistent name returns. Both entry paths set the channel before
1017 + // every invoke, so a previous broker call cannot widen a later one.
1018 + //
1019 + // What this is NOT: a permission boundary. The broker REST route
1020 + // authenticates with the SAME pairing token as the JSON-RPC route
1021 + // (Mcp_Auth::permission and Mcp_Server::authorize both compare against
1022 + // Mcp_Pairing::site_token()), so anyone holding that token — every
1023 + // client the dashboard's connection recipes are written for — can call
1024 + // a hidden tool by name on the broker route, and tell it from a
1025 + // nonexistent one by the status. `hidden` keeps a tool off the
1026 + // advertised surface and out of an OAuth grant's reach; it does not
1027 + // make it safe for a pairing-token holder to run. Anything gated only
1028 + // by `hidden` must be something that holder may already do.
1029 + if ( ! empty( $catalog[ $name ]['hidden'] ) && 'broker' !== self::$channel ) {
1030 + $error = new \WP_Error(
1031 + 'xspeed_mcp_unknown_tool',
1032 + sprintf(
1033 + /* translators: %s: tool name. */
1034 + __( 'Unknown tool: %s', 'xspeed' ),
1035 + $name
1036 + ),
1037 + array( 'status' => 404 )
1038 + );
1039 + Mcp_Activity_Log::record( $name, $args, false, $error->get_error_message(), 'write', self::$channel );
1040 +
1041 + return $error;
1042 + }
1043 +
536 1044 // Scope enforcement: a read-only connection cannot invoke a tool that
537 1045 // mutates state. run_command is a gateway to the full CLI surface, so
538 1046 // it's treated as write regardless of the wrapped command. The active
539 1047 // credential's scope (pairing token OR OAuth access token) is carried
@@ -538,9 +1046,9 @@
538 1046 // it's treated as write regardless of the wrapped command. The active
539 1047 // credential's scope (pairing token OR OAuth access token) is carried
540 1048 // in self::$scope_override; it falls back to the pairing global for
541 1049 // callers that don't set a per-call scope.
542 - if ( ! empty( $catalog[ $name ]['write'] ) && self::is_read_only() ) {
1050 + if ( ! empty( $catalog[ $name ]['write'] ) && self::is_read_only() && self::action_writes( $catalog[ $name ], $args ) ) {
543 1051 return new \WP_Error(
544 1052 'xspeed_mcp_read_only',
545 1053 sprintf(
546 1054 /* translators: %s: tool name. */
@@ -550,8 +1058,50 @@
550 1058 array( 'status' => 403 )
551 1059 );
552 1060 }
553 1061
1062 + /*
1063 + * Scan-before-clean, enforced at the dispatcher rather than in one
1064 + * handler.
1065 + *
1066 + * The guard used to live inside clean_database(). That protected a
1067 + * TOOL NAME, not the action: run_command("db", ["clean"]) reaches the
1068 + * same Cli_Bridge::run('db', ['clean']) with no token, no preview and
1069 + * no warning, and list_commands advertises the route to the assistant
1070 + * in plainer words ("Scan or clean WordPress bloat") than the tool
1071 + * that just refused it. Measured on a live site, that second door
1072 + * permanently destroyed 3,007 rows in a single call and reported
1073 + * success.
1074 + *
1075 + * Every tool passes through invoke(), so a confirmation checked here
1076 + * covers each door at once — including any future tool that wraps the
1077 + * same command. (#184)
1078 + */
1079 + $destructive = self::destructive_action( $name, $args );
1080 + if ( ! empty( $destructive ) ) {
1081 + $confirmed = self::verify_confirm_token( $args, $name, $destructive );
1082 + if ( is_wp_error( $confirmed ) ) {
1083 + // The refusal message can carry a freshly minted token (see
1084 + // confirm_required()); the log gets the redacted twin so a
1085 + // live token is never persisted in wp_options twice over.
1086 + Mcp_Activity_Log::record( $name, $args, false, self::loggable_error( $confirmed ), 'write', self::$channel );
1087 + return $confirmed;
1088 + }
1089 + }
1090 +
1091 + /*
1092 + * The token authorised the call; it is not an argument any handler
1093 + * takes. A generated command tool forwards every property it does not
1094 + * recognise as a positional straight to Cli_Bridge::run() as a CLI
1095 + * option, so leaving it in place would turn a correctly confirmed
1096 + * `xspeed_cfe` call into `--confirm_token=…` and an unknown-option
1097 + * failure: the gate satisfied and the action still unreachable, which
1098 + * is the same broken feature by a later route. Dropped for every tool,
1099 + * not just the gated ones, and dropped before the audit record so a
1100 + * live token is never written into the activity log.
1101 + */
1102 + unset( $args['confirm_token'] );
1103 +
554 1104 self::$dispatching = true;
555 1105 try {
556 1106 $result = call_user_func( $catalog[ $name ]['handler'], $args );
557 1107
@@ -628,8 +1178,13 @@
628 1178
629 1179 /**
630 1180 * Cache status, stats, and detected server.
631 1181 *
1182 + * `site_icon` is the Site Icon set under Appearance, or '' when none is
1183 + * set. The Hub shows it beside the site's name. Asking the site for
1184 + * /favicon.ico instead failed on nginx hosts, which answer .ico
1185 + * requests as static files and never reach WordPress.
1186 + *
632 1187 * @param array $args Unused.
633 1188 * @return array
634 1189 */
635 1190 public static function get_cache_status( array $args ) {
@@ -638,12 +1193,168 @@
638 1193 return array(
639 1194 'cache_enabled' => (bool) ( $opts['cache_enabled'] ?? false ),
640 1195 'stats' => Cache::get_stats(),
641 1196 'server' => Server::type(),
1197 + 'site_icon' => (string) get_site_icon_url( 64 ),
642 1198 );
643 1199 }
644 1200
645 1201 /**
1202 + * Facts about this site and install, stated explicitly.
1203 + *
1204 + * The Hub's fleet dashboard needed two things no tool reported directly.
1205 + * It had been INFERRING Pro's presence from `list_modules` — "any entry
1206 + * with tier: pro" — which works only because the registry returns just
1207 + * available modules. That is an inference riding an implementation
1208 + * detail, and it breaks the day a Pro install registers zero Pro modules.
1209 + *
1210 + * `pro_active` is "the Pro plugin is loaded and API-compatible";
1211 + * `licensed` is a separate question, since Pro can be active but
1212 + * unlicensed (its modules then boot but their settings are locked). Both
1213 + * are reported so a consumer never has to guess which one it wanted.
1214 + * (#146)
1215 + *
1216 + * @param array $args Unused.
1217 + * @return array
1218 + */
1219 + /**
1220 + * Is Pro licensed right now?
1221 + *
1222 + * Resolved through the `xspeed_module_descriptor` filter — the one Pro
1223 + * actually registers — by running a minimal Pro descriptor through it and
1224 + * reading back the `locked` flag Pro sets when the licence is inactive.
1225 + *
1226 + * `license` is deliberately not used as the probe slug: Pro exempts that
1227 + * module from locking so an expired site can still reach the screen where
1228 + * a new key is entered, so it would always come back unlocked.
1229 + */
1230 + private static function pro_licensed(): bool {
1231 + $probe = apply_filters(
1232 + 'xspeed_module_descriptor',
1233 + array(
1234 + 'slug' => '__license_probe__',
1235 + 'tier' => 'pro',
1236 + ),
1237 + null
1238 + );
1239 +
1240 + return empty( $probe['locked'] );
1241 + }
1242 +
1243 + public static function get_site_info( array $args ) {
1244 + unset( $args );
1245 +
1246 + $pro_active = Tier_Registry::pro_active();
1247 +
1248 + return array(
1249 + 'pro_active' => $pro_active,
1250 + 'pro_version' => defined( 'XSPEED_PRO_VERSION' ) ? (string) constant( 'XSPEED_PRO_VERSION' ) : null,
1251 + // Distinct from pro_active: Pro can be installed and running
1252 + // while its license is expired or absent.
1253 + //
1254 + // NOT `apply_filters( 'xspeed_pro_licensed', true )`. That hook is
1255 + // only ever APPLIED by Pro as an override point — no released
1256 + // version registers it — so with nothing listening the `true`
1257 + // default stood and this reported `licensed: true` on a fully
1258 + // revoked licence: the exact misreport the tool exists to
1259 + // eliminate. (QA blocker on #158)
1260 + //
1261 + // Ask the question the dashboard asks instead. Pro DOES register
1262 + // `xspeed_module_descriptor` and stamps `locked => 'license'` on
1263 + // every Pro entry when the licence is inactive, so reading that
1264 + // back is a real signal, and it cannot drift from what the panel
1265 + // shows because it IS what the panel shows.
1266 + 'licensed' => $pro_active ? self::pro_licensed() : false,
1267 + 'plugin_version' => defined( 'XSPEED_VERSION' ) ? (string) constant( 'XSPEED_VERSION' ) : null,
1268 + 'wp_version' => get_bloginfo( 'version' ),
1269 + 'php_version' => PHP_VERSION,
1270 + 'server' => Server::type(),
1271 + 'multisite' => is_multisite(),
1272 + 'addons' => self::addon_licences(),
1273 + );
1274 + }
1275 +
1276 + /** Most add-on entries `get_site_info` reports. */
1277 + private const MAX_ADDONS = 20;
1278 +
1279 + /** Longest licence status kept. The library's statuses are short slugs. */
1280 + private const MAX_ADDON_STATUS_LENGTH = 40;
1281 +
1282 + /**
1283 + * The licence state of each separately licensed add-on, keyed by slug.
1284 + *
1285 + * Free cannot know which add-ons exist, so it asks. An add-on answers from
1286 + * the licence state it has stored. Callers poll this tool, and a
1287 + * licence-server round trip here would make every call wait on it.
1288 + *
1289 + * Always an object, `{}` when nothing answers. An empty PHP array encodes
1290 + * as `[]`, and a consumer reading `addons.some_slug` should not have to
1291 + * handle a list as well.
1292 + *
1293 + * @return object Slug => `{licensed: bool, status: ?string}`.
1294 + */
1295 + private static function addon_licences(): object {
1296 + /**
1297 + * Filter: xspeed_site_info_addons
1298 + *
1299 + * Licence state for add-ons sold separately, reported by the
1300 + * `get_site_info` MCP tool. Seeded empty; add your own entry and
1301 + * return the array:
1302 + *
1303 + * $addons['my_addon'] = array( 'licensed' => true, 'status' => 'valid' );
1304 + *
1305 + * `licensed` is whether the add-on may run. `status` is the licence
1306 + * status as stored, or null when no key is stored. Read stored state
1307 + * only, never the licence server. Keys go through sanitize_key(),
1308 + * statuses are trimmed to short slugs, and any other field is dropped.
1309 + *
1310 + * @param array $addons Entries gathered so far, keyed by slug.
1311 + */
1312 + try {
1313 + $raw = apply_filters( 'xspeed_site_info_addons', array() );
1314 + } catch ( \Throwable $e ) {
1315 + // A broken add-on costs the add-on entries, not the whole tool.
1316 + // The other facts are still true and the caller still needs them.
1317 + // Pop our hook off the stack the throw left it on, for the reason
1318 + // Pro_Audit::contributed() gives.
1319 + if ( isset( $GLOBALS['wp_current_filter'] )
1320 + && is_array( $GLOBALS['wp_current_filter'] )
1321 + && end( $GLOBALS['wp_current_filter'] ) === 'xspeed_site_info_addons' ) {
1322 + array_pop( $GLOBALS['wp_current_filter'] );
1323 + }
1324 + return (object) array();
1325 + }
1326 +
1327 + $out = array();
1328 + foreach ( is_array( $raw ) ? $raw : array() as $slug => $entry ) {
1329 + if ( count( $out ) >= self::MAX_ADDONS ) {
1330 + break;
1331 + }
1332 + if ( ! is_string( $slug ) || ! is_array( $entry ) ) {
1333 + continue;
1334 + }
1335 + $slug = sanitize_key( $slug );
1336 + if ( '' === $slug || isset( $out[ $slug ] ) ) {
1337 + continue;
1338 + }
1339 +
1340 + $status = $entry['status'] ?? null;
1341 + $status = is_string( $status )
1342 + ? substr( sanitize_key( $status ), 0, self::MAX_ADDON_STATUS_LENGTH )
1343 + : '';
1344 +
1345 + $out[ $slug ] = array(
1346 + 'licensed' => (bool) ( $entry['licensed'] ?? false ),
1347 + // An empty status says nothing a null does not, and two ways
1348 + // of saying "none" is one more case for every consumer.
1349 + 'status' => '' === $status ? null : $status,
1350 + );
1351 + }
1352 +
1353 + return (object) $out;
1354 + }
1355 +
1356 + /**
646 1357 * All registered module descriptors.
647 1358 *
648 1359 * @param array $args Unused.
649 1360 * @return array
@@ -653,8 +1364,42 @@
653 1364 return Admin::modules_payload();
654 1365 }
655 1366
656 1367 /**
1368 + * Run the optimization autopilot.
1369 + *
1370 + * A thin wrapper: everything — the plan, the verification, the revert —
1371 + * lives in Optimize_Runner, so the CLI and this tool cannot drift into
1372 + * making different decisions about the same site.
1373 + *
1374 + * @param array<string,mixed> $args Tool arguments.
1375 + * @return array<string,mixed>|\WP_Error
1376 + */
1377 + public static function optimize_site( array $args = array() ) {
1378 + $run = array(
1379 + 'aggressiveness' => (string) ( $args['aggressiveness'] ?? 'standard' ),
1380 + 'dry_run' => (bool) ( $args['dry_run'] ?? false ),
1381 + 'measure_score' => (string) ( $args['measure_score'] ?? 'auto' ),
1382 + );
1383 +
1384 + // Tuning arguments are forwarded ONLY when present, and are not
1385 + // understood by Free — a listener on `xspeed_optimize_report` reads
1386 + // them from that filter's `$context`. Passing them through rather
1387 + // than naming them in the array above is deliberate: this handler
1388 + // builds an explicit whitelist, so an argument it does not list is
1389 + // silently dropped. A caller asking to reach a score would have got a
1390 + // single pass and a success response — wrong behaviour with no error,
1391 + // which is the expensive kind to diagnose.
1392 + foreach ( array( 'target_score', 'max_rounds' ) as $key ) {
1393 + if ( isset( $args[ $key ] ) && is_numeric( $args[ $key ] ) ) {
1394 + $run[ $key ] = (int) $args[ $key ];
1395 + }
1396 + }
1397 +
1398 + return \XSpeed\Optimize_Runner::run( $run );
1399 + }
1400 +
1401 + /**
657 1402 * Before/after cache benchmark timings.
658 1403 *
659 1404 * @param array $args Unused.
660 1405 * @return array
@@ -699,12 +1444,43 @@
699 1444 }
700 1445 // Named source, not the default "manual": the purge log's whole job
701 1446 // is to let an admin see that the cache cleared because an assistant
702 1447 // asked, not because someone clicked.
703 - $count = Cache::purge_type( $type, __( 'AI assistant', 'xspeed' ) );
1448 + $cause = __( 'AI assistant', 'xspeed' );
1449 +
1450 + /*
1451 + * `page`, `assets` and `rest` are fine-grained slices of the local
1452 + * sweep with no target of their own, and they predate this tool's
1453 + * per-store report — an assistant asking for `page` means the HTML,
1454 + * not the HTML plus the minified bundles plus every purge listener.
1455 + * They stay on purge_type() so their meaning does not change under
1456 + * callers already relying on it.
1457 + *
1458 + * Everything else routes through the runner — the same core function
1459 + * the CLI and the REST callback use — so an assistant told "cache
1460 + * cleared" is reading the same per-store verdict a human would get,
1461 + * including a Cloudflare zone that refused the purge.
1462 + */
1463 + if ( in_array( $type, array( 'page', 'assets', 'rest' ), true ) ) {
1464 + return array(
1465 + 'purged' => $type,
1466 + 'count' => Cache::purge_type( $type, $cause ),
1467 + 'ok' => true,
1468 + 'stats' => Cache::get_stats(),
1469 + );
1470 + }
1471 +
1472 + $report = \XSpeed\Purge_Runner::run( array( $type ), $cause );
1473 + $count = 0;
1474 + foreach ( $report['types'] as $row ) {
1475 + $count += (int) $row['entries'];
1476 + }
1477 +
704 1478 return array(
705 1479 'purged' => $type,
706 1480 'count' => $count,
1481 + 'ok' => $report['ok'],
1482 + 'report' => $report['types'],
707 1483 'stats' => Cache::get_stats(),
708 1484 );
709 1485 }
710 1486
@@ -727,18 +1503,68 @@
727 1503
728 1504 // Persist cache_enabled the same way the Free /cache/toggle route
729 1505 // does (class-rest-api.php:235) — Cache::toggle handles the drop-in
730 1506 // + wp-config; Settings owns the option flag.
731 - Settings::update( array( 'cache_enabled' => $enabled ) );
1507 + //
1508 + // From the RESULT, not from $enabled: toggle() refuses to enable when
1509 + // another caching plugin owns the drop-in, and writing the requested
1510 + // value regardless left the site reporting a cache it had not
1511 + // installed — over MCP, with no human reading the response.
732 1512
733 1513 return array(
734 - 'cache_enabled' => $enabled,
735 - 'install_state' => $install,
736 - 'stats' => Cache::get_stats(),
1514 + 'cache_enabled' => $install['enabled'],
1515 + 'blocked' => ! empty( $install['blocked'] ),
1516 + 'blocked_reason' => $install['blocked_reason'] ?? null,
1517 + 'install_state' => $install,
1518 + 'stats' => Cache::get_stats(),
737 1519 );
738 1520 }
739 1521
740 1522 /**
1523 + * Is this module reachable over MCP right now?
1524 + *
1525 + * Mirrors SettingsModule::module_reachable(). Registration is not
1526 + * enough: Module_Registry::available() only asks whether Pro is LOADED,
1527 + * not whether it is LICENSED, so an unlicensed Pro site had every Pro
1528 + * module readable and writable over MCP while the dashboard showed it
1529 + * locked — reachable by any agent holding a write token. (QA M2)
1530 + *
1531 + * The licence answer comes through the `xspeed_module_descriptor` filter
1532 + * Pro registers, so Free never names a Pro class. (NOT
1533 + * `xspeed_pro_licensed` — Pro only ever APPLIES that one as an override
1534 + * and nothing listens to it, so gating on it silently passed everything.)
1535 + * `license` is exempt for the same reason Pro exempts it: locking it
1536 + * would remove the only surface that can fix an expired licence.
1537 + */
1538 + private static function settings_module_reachable( string $slug ): bool {
1539 + $module = \XSpeed\Module_Registry::available()[ $slug ] ?? null;
1540 + if ( ! $module ) {
1541 + return false;
1542 + }
1543 + if ( \XSpeed\Module::TIER_PRO !== $module->tier() || 'license' === $slug ) {
1544 + return true;
1545 + }
1546 +
1547 + // Ask the SAME question the dashboard asks. `xspeed_pro_licensed` is
1548 + // only ever APPLIED by Pro as an override hook — nothing registers it
1549 + // — so calling it here returned the default `true` and gated nothing.
1550 + // Pro DOES register `xspeed_module_descriptor`, and sets
1551 + // `locked => 'license'` on every Pro entry when the licence is
1552 + // inactive. Reusing that keeps one definition of "locked" instead of
1553 + // a second one in Free that can drift from the panel. (QA M2)
1554 + $entry = apply_filters(
1555 + 'xspeed_module_descriptor',
1556 + array(
1557 + 'slug' => $slug,
1558 + 'tier' => $module->tier(),
1559 + ),
1560 + $module
1561 + );
1562 +
1563 + return empty( $entry['locked'] );
1564 + }
1565 +
1566 + /**
741 1567 * Read a module's schema-validated settings.
742 1568 *
743 1569 * @param array $args { module:string }.
744 1570 * @return array|\WP_Error
@@ -751,11 +1577,43 @@
751 1577 __( 'The "module" parameter is required.', 'xspeed' ),
752 1578 array( 'status' => 400 )
753 1579 );
754 1580 }
755 - return array(
756 - 'module' => $module,
757 - 'settings' => Settings_Manager::get( $module ),
1581 + if ( ! self::settings_module_reachable( $module ) ) {
1582 + return new \WP_Error(
1583 + 'xspeed_mcp_unknown_module',
1584 + sprintf(
1585 + /* translators: %s: module slug. */
1586 + __( 'Unknown module "%s".', 'xspeed' ),
1587 + $module
1588 + ),
1589 + array( 'status' => 404 )
1590 + );
1591 + }
1592 + /**
1593 + * Filter the get_settings MCP payload for one module.
1594 + *
1595 + * Lets the module that owns the settings attach state the stored
1596 + * values alone cannot express — a toggle that is on but resolves to
1597 + * no effect on this host (Brotli without ngx_brotli), a configured
1598 + * generator that has never succeeded. Free never names Pro classes,
1599 + * so this seam is how a Pro module reaches the response an agent
1600 + * reads.
1601 + *
1602 + * @param array<string,mixed> $payload The response: module + settings.
1603 + * @param string $module Module slug.
1604 + * @param string $action 'get' here; 'update' on writes.
1605 + */
1606 + return apply_filters(
1607 + 'xspeed_mcp_settings_payload',
1608 + array(
1609 + 'module' => $module,
1610 + // Public view — secret fields masked. An MCP agent must never be able
1611 + // to read stored credentials back in plaintext. (#115)
1612 + 'settings' => Settings_Manager::get_public( $module ),
1613 + ),
1614 + $module,
1615 + 'get'
758 1616 );
759 1617 }
760 1618
761 1619 /**
@@ -780,15 +1638,197 @@
780 1638 __( 'The "values" parameter must be an object of setting keys.', 'xspeed' ),
781 1639 array( 'status' => 400 )
782 1640 );
783 1641 }
784 - return array(
785 - 'module' => $module,
786 - 'settings' => Settings_Manager::update( $module, $values ),
1642 + if ( ! self::settings_module_reachable( $module ) ) {
1643 + return new \WP_Error(
1644 + 'xspeed_mcp_unknown_module',
1645 + sprintf(
1646 + /* translators: %s: module slug. */
1647 + __( 'Unknown module "%s".', 'xspeed' ),
1648 + $module
1649 + ),
1650 + array( 'status' => 404 )
1651 + );
1652 + }
1653 + // Writing credentials over MCP requires the explicit `configure` grant —
1654 + // off by default even for a write-scoped connection — so an agent can't
1655 + // silently repoint the Cloudflare/object-cache backend at an attacker
1656 + // endpoint. Refuse with a message naming exactly which fields need it.
1657 + // (Settings_Manager::update also strips these as a backstop covering the
1658 + // run_command → CLI path.) (#116)
1659 + if ( ! self::can_configure() ) {
1660 + $secret_fields = Settings_Manager::secret_keys_in( $module, $values );
1661 + if ( ! empty( $secret_fields ) ) {
1662 + return new \WP_Error(
1663 + 'xspeed_mcp_configure_required',
1664 + sprintf(
1665 + /* translators: 1: comma-separated field names, 2: module slug. */
1666 + __( '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' ),
1667 + implode( ', ', $secret_fields ),
1668 + $module
1669 + ),
1670 + array(
1671 + 'status' => 403,
1672 + 'refused_fields' => $secret_fields,
1673 + )
1674 + );
1675 + }
1676 + }
1677 + // The Pro licence WRITE gate. `settings_module_reachable()` above already
1678 + // hides locked Pro modules, but that is a VISIBILITY check answered by
1679 + // the `xspeed_module_descriptor` filter — a different question from "may
1680 + // this be written", and one that drifts the moment Pro changes how it
1681 + // flags `locked`. Ask the write gate itself, the same one REST consults
1682 + // via Module::update_settings(), so the two can't disagree.
1683 + //
1684 + // This is not theoretical: with the descriptor's `locked` flag removed,
1685 + // this handler wrote `enabled: false -> true` to a module whose
1686 + // is_license_locked() was true, because it persists through
1687 + // Settings_Manager::update() and never reaches Module::update_settings().
1688 + // (#185)
1689 + $module_object = \XSpeed\Module_Registry::get( $module );
1690 + if ( $module_object && $module_object->is_license_locked() ) {
1691 + // Match the REST path's audit trail — a refused write is a security
1692 + // event and must be visible in the activity log wherever it came
1693 + // from. Module::license_write_refusal() records the same type.
1694 + \XSpeed\Activity_Log::record(
1695 + 'license_write_refused',
1696 + sprintf(
1697 + /* translators: %s: module slug. */
1698 + __( 'Refused an MCP settings write to the Pro module "%s" — no valid license.', 'xspeed' ),
1699 + $module
1700 + ),
1701 + \XSpeed\Activity_Log::WARN
1702 + );
1703 +
1704 + return new \WP_Error(
1705 + 'xspeed_license_required',
1706 + sprintf(
1707 + /* translators: %s: module slug. */
1708 + __( '"%s" is a Pro module and this site has no active license, so the write was refused. Nothing was changed.', 'xspeed' ),
1709 + $module
1710 + ),
1711 + array(
1712 + 'status' => 403,
1713 + 'module' => $module,
1714 + )
1715 + );
1716 + }
1717 +
1718 + // An agent cannot tell a silent no-op from a real write, so refuse
1719 + // instead of returning a success payload. update() walks the schema:
1720 + // an out-of-schema key is never written and never mentioned, and an
1721 + // in-schema key with a rejected value quietly keeps the stored one.
1722 + // The realistic case is `cache_enabled` on the `cache` module — the
1723 + // most natural way to ask for caching, and a complete no-op. (#206)
1724 + $report = self::inspect_or_error( $module, $values );
1725 + if ( is_wp_error( $report ) ) {
1726 + return $report;
1727 + }
1728 +
1729 + /**
1730 + * Filter the update_settings MCP payload for one module.
1731 + *
1732 + * The write path's twin of the get filter above — this is where a
1733 + * module can say "stored, but inert on this host" in the same
1734 + * response that reports the write, instead of returning a plain
1735 + * success an agent relays as "enabled". Documented in
1736 + * docs/guides/hooks-and-filters.md.
1737 + *
1738 + * @param array<string,mixed> $payload The response: module + settings.
1739 + * @param string $module Module slug.
1740 + * @param string $action 'update' here; 'get' on reads.
1741 + */
1742 + return apply_filters(
1743 + 'xspeed_mcp_settings_payload',
1744 + array(
1745 + 'module' => $module,
1746 + // Return value is already masked (Settings_Manager::update returns the
1747 + // public view), so a written secret isn't echoed back either. (#115)
1748 + 'settings' => Settings_Manager::update( $module, $values ),
1749 + ),
1750 + $module,
1751 + 'update'
787 1752 );
788 1753 }
789 1754
790 1755 /**
1756 + * Refuse a settings payload carrying keys that would be silently dropped.
1757 + *
1758 + * @param string $module Module slug.
1759 + * @param array<string,mixed> $values Proposed values.
1760 + * @return true|\WP_Error True when every key would be applied.
1761 + */
1762 + private static function inspect_or_error( string $module, array $values ) {
1763 + $report = Settings_Manager::inspect_input( $module, $values );
1764 + if ( empty( $report['unknown'] ) && empty( $report['invalid'] ) && empty( $report['locked'] ) ) {
1765 + return true;
1766 + }
1767 +
1768 + $parts = array();
1769 + // A field pinned by a wp-config.php constant cannot be written. Say so
1770 + // rather than returning a success the agent relays as "changed" over a
1771 + // write that update() would silently drop. (#398)
1772 + foreach ( $report['locked'] as $key ) {
1773 + $spec = ( Module_Registry::get( $module ) ? Module_Registry::get( $module )->settings_schema()[ $key ] ?? array() : array() );
1774 + $constant = Settings_Manager::effective_constant( $module, $key, is_array( $spec ) ? $spec : array() );
1775 + $parts[] = sprintf(
1776 + /* translators: 1: setting key, 2: wp-config.php constant name. */
1777 + __( '"%1$s" is defined in wp-config.php as %2$s and cannot be changed here', 'xspeed' ),
1778 + $key,
1779 + (string) $constant
1780 + );
1781 + }
1782 + foreach ( $report['unknown'] as $key ) {
1783 + $detail = sprintf(
1784 + /* translators: 1: setting key, 2: module slug. */
1785 + __( '"%1$s" is not a setting of module "%2$s"', 'xspeed' ),
1786 + $key,
1787 + $module
1788 + );
1789 + $hint = Settings_Manager::hint_for_unknown_key( $key );
1790 + if ( '' !== $hint ) {
1791 + $detail .= ' — ' . $hint;
1792 + } else {
1793 + $near = Settings_Manager::did_you_mean( $module, $key );
1794 + if ( ! empty( $near ) ) {
1795 + $detail .= sprintf(
1796 + /* translators: %s: comma-separated setting names. */
1797 + __( ' — did you mean: %s?', 'xspeed' ),
1798 + implode( ', ', $near )
1799 + );
1800 + }
1801 + }
1802 + $parts[] = $detail;
1803 + }
1804 + foreach ( $report['invalid'] as $key ) {
1805 + $parts[] = sprintf(
1806 + /* translators: %s: setting key. */
1807 + __( '"%s" was rejected by the schema (wrong type, or outside the allowed range/options)', 'xspeed' ),
1808 + $key
1809 + );
1810 + }
1811 +
1812 + return new \WP_Error(
1813 + 'xspeed_settings_refused',
1814 + sprintf(
1815 + /* translators: 1: module slug, 2: reasons. */
1816 + __( 'Refused to update %1$s — nothing was written. %2$s', 'xspeed' ),
1817 + $module,
1818 + implode( '; ', $parts )
1819 + ),
1820 + array(
1821 + 'status' => 400,
1822 + 'refused_unknown' => $report['unknown'],
1823 + 'refused_invalid' => $report['invalid'],
1824 + 'refused_locked' => $report['locked'],
1825 + 'would_apply' => $report['applied'],
1826 + )
1827 + );
1828 + }
1829 +
1830 + /**
791 1831 * List every command run_command can invoke (the full CLI surface).
792 1832 *
793 1833 * @param array $args Unused.
794 1834 * @return array
@@ -842,12 +1882,592 @@
842 1882 * @return array|\WP_Error
843 1883 */
844 1884 public static function scan_database( array $args ) {
845 1885 unset( $args );
846 - return Cli_Bridge::run( 'db', array( 'scan' ) );
1886 + $result = Cli_Bridge::run( 'db', array( 'scan' ) );
1887 + if ( is_wp_error( $result ) || empty( $result['ok'] ) ) {
1888 + return $result;
1889 + }
1890 +
1891 + /*
1892 + * Mint the token clean_database will demand, and state what it covers.
1893 + *
1894 + * The scan is the only place the caller can see what is about to be
1895 + * destroyed, so it is the only honest place to authorise the delete.
1896 + * The token is bound to the CATEGORIES ENABLED and the COUNTS FOUND at
1897 + * this moment: if either moves before the delete lands, the token no
1898 + * longer describes reality and clean_database refuses. That closes the
1899 + * window where a scan is shown to a human, something changes, and the
1900 + * delete removes more than was agreed to. (#184)
1901 + */
1902 + $result['confirm_token'] = self::mint_confirm_token( self::clean_fingerprint() );
1903 + $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' );
1904 +
1905 + return $result;
847 1906 }
848 1907
1908 + /** Categories currently enabled for deletion, with what a scan found in each. */
1909 + private static function clean_scope(): array {
1910 + $enabled = array_keys( array_filter( Settings_Manager::get( 'database' ), static fn( $v ) => true === $v ) );
1911 + sort( $enabled );
1912 +
1913 + $counts = array();
1914 + foreach ( Database_Cleaner::scan() as $key => $row ) {
1915 + $counts[ $key ] = is_array( $row ) ? (int) ( $row['count'] ?? 0 ) : (int) $row;
1916 + }
1917 + ksort( $counts );
1918 +
1919 + return array(
1920 + 'enabled' => $enabled,
1921 + 'counts' => $counts,
1922 + );
1923 + }
1924 +
849 1925 /**
1926 + * Actions that permanently destroy content and therefore require a
1927 + * confirm_token, keyed by canonical command name.
1928 + *
1929 + * Keyed by ACTION, not by tool name, because the same action is
1930 + * reachable through several tools (the typed clean_database, the
1931 + * run_command gateway, and any future wrapper).
1932 + *
1933 + * @return array<string, string[]>
1934 + */
1935 + private static function destructive_actions(): array {
1936 + /**
1937 + * Filter the command actions that require an explicit confirmation.
1938 + *
1939 + * @since 1.1.6
1940 + * @param array<string, string[]> $actions Action names keyed by command.
1941 + */
1942 + return (array) apply_filters(
1943 + 'xspeed_mcp_destructive_actions',
1944 + array( 'xspeed db' => array( 'clean' ) )
1945 + );
1946 + }
1947 +
1948 + /**
1949 + * Name the destructive action a call would run, or an empty array if it
1950 + * is harmless.
1951 + *
1952 + * @param string $name Tool name.
1953 + * @param array $args Decoded tool arguments.
1954 + * @return array{name?:string,action?:string} The classified pair, or array() when not destructive.
1955 + */
1956 + private static function destructive_action( string $name, array $args ): array {
1957 + // The gateway carries the real command in its arguments; a typed tool
1958 + // is identified by the command it is mapped to.
1959 + if ( 'run_command' === $name ) {
1960 + $command = isset( $args['command'] ) ? (string) $args['command'] : '';
1961 + if ( '' === $command ) {
1962 + return array();
1963 + }
1964 + $positional = isset( $args['args'] ) && is_array( $args['args'] ) ? $args['args'] : array();
1965 + return self::destructive_hit( Cli_Bridge::classify( $command, $positional ) );
1966 + }
1967 +
1968 + if ( 'clean_database' === $name ) {
1969 + return self::destructive_hit( Cli_Bridge::classify( 'db', array( 'clean' ) ) );
1970 + }
1971 +
1972 + /*
1973 + * The third door: the tool generated for every registered CLI command.
1974 + *
1975 + * run_command and clean_database were the two names this guard knew,
1976 + * so a command whose destructive action the filter names was still
1977 + * reachable without a confirm_token by calling its own generated tool
1978 + * — `xspeed_cfe` with action `remove` ran what
1979 + * run_command("cfe", ["remove"]) would have refused. The generated
1980 + * tool takes its action as the `action` property, so resolve the name
1981 + * back to a command and classify that action exactly as the gateway
1982 + * classifies its own.
1983 + */
1984 + $action = isset( $args['action'] ) ? (string) $args['action'] : '';
1985 + if ( '' === $action ) {
1986 + return array();
1987 + }
1988 +
1989 + $candidates = self::cli_commands_by_tool_name()[ $name ] ?? array();
1990 + if ( empty( $candidates ) ) {
1991 + /*
1992 + * The map has no entry for this tool name. Two ways to get here
1993 + * and neither may answer "harmless":
1994 + *
1995 + * - the registry is unavailable (an unbooted module, a call
1996 + * arriving before modules register, a bare unit-test context);
1997 + * - the registry is POPULATED but does not contain the command
1998 + * this tool name came from — Cli_Bridge::commands() memoises
1999 + * in a function-local static with no reset, so a module that
2000 + * registers its commands after the first call is invisible to
2001 + * the map while its generated tool is still dispatchable.
2002 + *
2003 + * This condition used to also require `array() === commands()`,
2004 + * which made the second case fall through with no candidates at
2005 + * all and return "not destructive" — the gate opening precisely
2006 + * where it was least able to see. Fall back on the naive inverse
2007 + * of cli_tool_name() in both cases. It is the mapping that can be
2008 + * wrong (underscores in a command name), but wrong here means
2009 + * asking for a confirm_token that was not strictly needed, never
2010 + * skipping one that was.
2011 + */
2012 + $candidates = array( str_replace( '_', ' ', $name ) );
2013 + }
2014 +
2015 + foreach ( $candidates as $command ) {
2016 + $hit = self::destructive_hit( Cli_Bridge::classify( $command, array( $action ) ) );
2017 + if ( ! empty( $hit ) ) {
2018 + return $hit;
2019 + }
2020 + }
2021 +
2022 + return array();
2023 + }
2024 +
2025 + /**
2026 + * Render a classified pair as the canonical "<command> <action>".
2027 + *
2028 + * @param array{name?:string,action?:string} $action Pair from destructive_action().
2029 + * @return string Canonical name, or '' for the empty (harmless) pair.
2030 + */
2031 + private static function canonical_action( array $action ): string {
2032 + if ( empty( $action['name'] ) ) {
2033 + return '';
2034 + }
2035 + return trim( $action['name'] . ' ' . ( $action['action'] ?? '' ) );
2036 + }
2037 +
2038 + /**
2039 + * Is a classified (command, action) pair one the filter calls destructive?
2040 + *
2041 + * @param array{name:string,action:string} $resolved Output of Cli_Bridge::classify().
2042 + * @return array{name?:string,action?:string} The pair, or array() when not destructive.
2043 + */
2044 + private static function destructive_hit( array $resolved ): array {
2045 + if ( '' === $resolved['name'] ) {
2046 + return array();
2047 + }
2048 +
2049 + $destructive = self::destructive_actions();
2050 + if ( ! isset( $destructive[ $resolved['name'] ] ) ) {
2051 + return array();
2052 + }
2053 + if ( ! in_array( $resolved['action'], (array) $destructive[ $resolved['name'] ], true ) ) {
2054 + return array();
2055 + }
2056 +
2057 + return array(
2058 + 'name' => $resolved['name'],
2059 + 'action' => $resolved['action'],
2060 + );
2061 + }
2062 +
2063 + /**
2064 + * Which CLI command(s) a generated tool name could have come from.
2065 + *
2066 + * cli_tool_name() replaces spaces with underscores, which is not
2067 + * injective: a command named "xspeed foo bar" and one named
2068 + * "xspeed foo_bar" produce the same tool name, and splitting the tool
2069 + * name back on underscores cannot tell them apart. So the map is built
2070 + * forwards — cli_tool_name() applied to every registered command, the
2071 + * same function over the same input set that named the tools in the
2072 + * first place. No Free command carries an underscore today; a Pro or
2073 + * third-party module registering one must not quietly disarm this guard.
2074 + *
2075 + * A tool name claimed by two commands keeps both, and the caller treats
2076 + * the call as destructive if any of them is — the fail-closed direction,
2077 + * the same reasoning Cli_Bridge::classify() applies when the registry
2078 + * cannot resolve an input at all.
2079 + *
2080 + * @return array<string,string[]> Tool name => the commands that produce it.
2081 + */
2082 + private static function cli_commands_by_tool_name(): array {
2083 + $map = array();
2084 + foreach ( array_keys( Cli_Bridge::commands() ) as $command ) {
2085 + $tool_name = self::cli_tool_name( (string) $command );
2086 + if ( '' === $tool_name ) {
2087 + continue;
2088 + }
2089 + $map[ $tool_name ][] = (string) $command;
2090 + }
2091 + return $map;
2092 + }
2093 +
2094 + /**
2095 + * Marker: this build gates destructive command actions on a confirm_token
2096 + * whichever tool they are reached through, generated tools included.
2097 + *
2098 + * Add-ons that register a destructive CLI action need to know whether the
2099 + * host plugin will demand the second factor for them. Where this method
2100 + * is absent they have to refuse the action themselves when it arrives
2101 + * from anywhere but the dashboard or real wp-cli; where it answers true
2102 + * they can let the confirmation gate do the work. Keep it — an add-on
2103 + * keys its refusal on the absence, so removing it is a behaviour change
2104 + * in another plugin.
2105 + */
2106 + public static function supports_command_confirmation(): bool {
2107 + return true;
2108 + }
2109 +
2110 + /**
2111 + * The one destructive action whose token comes from a preview tool.
2112 + *
2113 + * `scan_database` is the only surface that shows what a delete would
2114 + * remove, so its token is sealed to that preview and nothing else may
2115 + * mint one — including `xspeed_db` with `action: clean`, which is just
2116 + * another door onto the same rows.
2117 + */
2118 + private const PREVIEW_CONFIRMED_ACTION = 'xspeed db clean';
2119 +
2120 + /**
2121 + * Verify (and consume) the confirm_token for a destructive action.
2122 + *
2123 + * @param array $args Decoded tool arguments.
2124 + * @param string $tool Tool name the call arrived on.
2125 + * @param array $action Classified pair from destructive_action().
2126 + * @return true|\WP_Error
2127 + */
2128 + private static function verify_confirm_token( array $args, string $tool, array $action ) {
2129 + $token = isset( $args['confirm_token'] ) ? (string) $args['confirm_token'] : '';
2130 + if ( '' === $token ) {
2131 + return self::confirm_required( $tool, $action );
2132 + }
2133 +
2134 + // Computed only now: for the database clean the fingerprint is a full
2135 + // bloat scan, and a call refused for having no token at all should not
2136 + // pay for one.
2137 + $expected = self::confirm_fingerprint( $action );
2138 +
2139 + // Single use: consumed whether or not the action goes ahead, so one
2140 + // approval can never authorise a second, different call.
2141 + $sealed = self::consume_confirm_token( $token );
2142 + if ( '' === $sealed ) {
2143 + return new \WP_Error(
2144 + 'xspeed_mcp_confirm_invalid',
2145 + __( 'That confirm_token is unknown or has expired (they last 5 minutes). Ask for a fresh one by making the same call without a confirm_token.', 'xspeed' ),
2146 + array( 'status' => 400 )
2147 + );
2148 + }
2149 +
2150 + if ( ! hash_equals( $sealed, $expected ) ) {
2151 + return new \WP_Error(
2152 + 'xspeed_mcp_confirm_stale',
2153 + self::PREVIEW_CONFIRMED_ACTION === self::canonical_action( $action )
2154 + ? __( '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' )
2155 + : __( 'That confirm_token was issued for a different action, so it does not authorise this one. Repeat this exact call without a confirm_token to get one that does.', 'xspeed' ),
2156 + array( 'status' => 409 )
2157 + );
2158 + }
2159 +
2160 + return true;
2161 + }
2162 +
2163 + /**
2164 + * Refuse a destructive call that arrived without a confirm_token.
2165 + *
2166 + * Two shapes, because the two kinds of destructive action differ in where
2167 + * a token can honestly come from.
2168 + *
2169 + * `xspeed db clean` has a preview — `scan_database` — and the token is
2170 + * sealed to what that preview showed. Refuse and point at it; minting one
2171 + * here would authorise a delete nobody had been shown.
2172 + *
2173 + * Every other destructive action has no preview, and until now borrowed
2174 + * the database scope: the caller was told to "call scan_database first"
2175 + * for a token sealed to a fingerprint of row counts, which
2176 + * verify_confirm_token() would then reject as not matching. On an install
2177 + * where `xspeed db` is not registered there was no minting path at all,
2178 + * so the gate could not be passed by any sequence of calls. A guard that
2179 + * cannot be satisfied is a broken feature, and the pressure it creates is
2180 + * to delete the guard. (#184)
2181 + *
2182 + * So the refusal itself mints the token, sealed to this action. That is
2183 + * not authentication — the connection is already authenticated and holds
2184 + * write scope — it is a deliberate second round trip: the caller has to
2185 + * read a message naming what it is about to destroy and decide to send
2186 + * the call again. The token is single use, expires in five minutes, and
2187 + * confirms nothing but the action it names.
2188 + *
2189 + * The token travels in the MESSAGE, not just the error data:
2190 + * Mcp_Server::call_tool() renders a WP_Error as its message text alone
2191 + * and drops the data, so a token that lived only in the data would never
2192 + * reach the caller and the gate would stay unsatisfiable over MCP.
2193 + *
2194 + * @param string $tool Tool name the call arrived on.
2195 + * @param array $action Classified pair from destructive_action().
2196 + * @return \WP_Error
2197 + */
2198 + private static function confirm_required( string $tool, array $action ): \WP_Error {
2199 + $canonical = self::canonical_action( $action );
2200 +
2201 + if ( self::PREVIEW_CONFIRMED_ACTION === $canonical ) {
2202 + return new \WP_Error(
2203 + 'xspeed_mcp_confirm_required',
2204 + __( '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' ),
2205 + array(
2206 + 'status' => 400,
2207 + 'action' => $canonical,
2208 + 'log_message' => sprintf( 'Refused %s: no confirm_token (scan_database mints it).', $canonical ),
2209 + )
2210 + );
2211 + }
2212 +
2213 + $token = self::mint_confirm_token( self::confirm_fingerprint( $action ) );
2214 +
2215 + $repeat = 'run_command' === $tool
2216 + ? sprintf(
2217 + /* translators: %s: the confirm_token to send back. */
2218 + __( 'Call run_command again with the same command plus confirm_token: %s', 'xspeed' ),
2219 + $token
2220 + )
2221 + : sprintf(
2222 + /* translators: 1: tool name, 2: action value, 3: the confirm_token to send back. */
2223 + __( 'Call %1$s again with action: %2$s plus confirm_token: %3$s', 'xspeed' ),
2224 + $tool,
2225 + (string) ( $action['action'] ?? '' ),
2226 + $token
2227 + );
2228 +
2229 + return new \WP_Error(
2230 + 'xspeed_mcp_confirm_required',
2231 + sprintf(
2232 + /* translators: 1: canonical "<command> <action>", 2: how to repeat the call. */
2233 + __( '"%1$s" is destructive and cannot be undone, so it needs a second, deliberate call. %2$s — the token is single use and expires in 5 minutes.', 'xspeed' ),
2234 + $canonical,
2235 + $repeat
2236 + ),
2237 + array(
2238 + 'status' => 400,
2239 + 'action' => $canonical,
2240 + 'confirm_token' => $token,
2241 + 'expires_in' => self::CONFIRM_TOKEN_TTL,
2242 + 'log_message' => sprintf( 'Refused %s: awaiting confirm_token.', $canonical ),
2243 + )
2244 + );
2245 + }
2246 +
2247 + /**
2248 + * The activity-log text for a refusal.
2249 + *
2250 + * A refusal that mints a token puts that token in its message, because
2251 + * that is the only channel the caller can read it on. The activity log is
2252 + * a different audience and a durable one, so it takes the redacted twin
2253 + * the error carries alongside.
2254 + *
2255 + * @param \WP_Error $error Refusal to describe.
2256 + * @return string
2257 + */
2258 + private static function loggable_error( \WP_Error $error ): string {
2259 + $data = $error->get_error_data();
2260 + if ( is_array( $data ) && ! empty( $data['log_message'] ) ) {
2261 + return (string) $data['log_message'];
2262 + }
2263 + return $error->get_error_message();
2264 + }
2265 +
2266 + /**
2267 + * The scope a confirm_token for this action is sealed to.
2268 + *
2269 + * `xspeed db clean` is sealed to the preview: the categories enabled and
2270 + * the counts found, so the token dies the moment the database stops
2271 + * matching what the operator was shown.
2272 + *
2273 + * Every other action is sealed to itself — the (command, action) pair —
2274 + * because there is nothing else to bind to and binding to the database
2275 + * would make the token both meaningless (it proves nothing about the
2276 + * action) and fragile (any unrelated write invalidates it). A token for
2277 + * `xspeed cfe pause` therefore does not confirm `xspeed cfe remove`.
2278 + *
2279 + * @param array $action Classified pair from destructive_action().
2280 + * @return string
2281 + */
2282 + private static function confirm_fingerprint( array $action ): string {
2283 + if ( self::PREVIEW_CONFIRMED_ACTION === self::canonical_action( $action ) ) {
2284 + return self::clean_fingerprint();
2285 + }
2286 +
2287 + return hash(
2288 + 'sha256',
2289 + (string) wp_json_encode(
2290 + array(
2291 + (string) ( $action['name'] ?? '' ),
2292 + (string) ( $action['action'] ?? '' ),
2293 + )
2294 + )
2295 + );
2296 + }
2297 +
2298 + /** Fingerprint of the scan scope, so a token cannot outlive what it described. */
2299 + private static function clean_fingerprint(): string {
2300 + return hash( 'sha256', (string) wp_json_encode( self::clean_scope() ) );
2301 + }
2302 +
2303 + /** Lifetime of a confirm_token, from mint to refusal. */
2304 + private const CONFIRM_TOKEN_TTL = 5 * MINUTE_IN_SECONDS;
2305 +
2306 + /**
2307 + * Storage key for a minted token (the token itself is never stored).
2308 + *
2309 + * The `xspeed_mcp_clean_` prefix predates tokens for actions other than
2310 + * the database clean. It is the on-disk format, and purge_expired_confirm_
2311 + * tokens() sweeps by exactly this prefix, so renaming it would strand
2312 + * every token minted by the running build.
2313 + */
2314 + private static function confirm_token_key( string $token ): string {
2315 + return 'xspeed_mcp_clean_' . hash( 'sha256', $token );
2316 + }
2317 +
2318 + /*
2319 + * The token is held in an OPTION, not a transient.
2320 + *
2321 + * scan_database and clean_database are two separate HTTP requests, so the
2322 + * token has to survive between them. With an external object cache
2323 + * installed, set_transient() writes to that cache ONLY and never touches
2324 + * the options table — so on any site whose object cache is
2325 + * non-persistent, flushed between requests, or simply orphaned (a stale
2326 + * W3TC/Redis drop-in pointing at a dead backend), the token evaporates the
2327 + * moment it is minted.
2328 + *
2329 + * That does not fail safe. It makes the confirmation UNSATISFIABLE:
2330 + * clean_database can never be authorised by any sequence of calls, and the
2331 + * operator's only remaining route to the feature is the admin panel. A
2332 + * guard that cannot be passed is a broken feature, and the pressure it
2333 + * creates is to remove the guard. Reproduced on a stack running W3 Total
2334 + * Cache's object-cache drop-in: every freshly minted token was refused as
2335 + * "unknown or expired" on the very next request. (#184)
2336 + *
2337 + * Options are backed by the database, so the token persists whatever the
2338 + * object cache does. Expiry is carried in the stored value and checked on
2339 + * read, since options have no TTL of their own.
2340 + */
2341 +
2342 + /**
2343 + * Mint a single-use token sealed to a scope fingerprint.
2344 + *
2345 + * @param string $fingerprint Scope the token confirms — see confirm_fingerprint().
2346 + * @return string
2347 + */
2348 + private static function mint_confirm_token( string $fingerprint ): string {
2349 + $token = wp_generate_password( 32, false );
2350 +
2351 + // autoload=no: this is read once, by one request, minutes from now.
2352 + add_option(
2353 + self::confirm_token_key( $token ),
2354 + wp_json_encode(
2355 + array(
2356 + 'fingerprint' => $fingerprint,
2357 + 'expires' => time() + self::CONFIRM_TOKEN_TTL,
2358 + )
2359 + ),
2360 + '',
2361 + 'no'
2362 + );
2363 +
2364 + self::purge_expired_confirm_tokens();
2365 +
2366 + return $token;
2367 + }
2368 +
2369 + /**
2370 + * Read a minted token's sealed fingerprint, or '' if unknown/expired.
2371 + *
2372 + * Consumes the record either way: a token is single use, so one approval
2373 + * can never authorise a second, different deletion.
2374 + */
2375 + private static function consume_confirm_token( string $token ): string {
2376 + global $wpdb;
2377 +
2378 + $key = self::confirm_token_key( $token );
2379 + $stored = get_option( $key );
2380 + if ( ! is_string( $stored ) || '' === $stored ) {
2381 + return '';
2382 + }
2383 +
2384 + /*
2385 + * The claim is the DELETE, and nothing before it.
2386 + *
2387 + * This used to read the option, decide it existed, and then call
2388 + * delete_option() — check-then-act, with the whole verification
2389 + * sitting inside the gap. Twenty calls carrying one token, arriving
2390 + * together, all read the row before any of them removed it, and all
2391 + * twenty passed. QA measured six getting through and the database
2392 + * clean running six times. A persistent object cache widens it
2393 + * further: get_option() keeps answering from cache after the row is
2394 + * gone, so the read cannot be the gate under any timing.
2395 + *
2396 + * A single DELETE is the only step here MySQL makes atomic. InnoDB
2397 + * takes a row lock, exactly one statement reports a row affected, and
2398 + * every other concurrent caller sees zero however they got here. So
2399 + * the row's disappearance IS the permission, and the read above is
2400 + * demoted to what it should always have been — a way to recover the
2401 + * fingerprint, not evidence of anything.
2402 + *
2403 + * Raw rather than delete_option() because delete_option() reports
2404 + * whether it thinks a row existed, not whether THIS caller removed
2405 + * it, and it decides that from a cache. rows_affected comes from the
2406 + * server.
2407 + */
2408 + // No database, no atomic claim, no confirmation. Refusing here costs a
2409 + // caller one retry; the alternative is granting permission on the
2410 + // strength of a read that was never proof of anything.
2411 + if ( ! $wpdb instanceof \wpdb && ! is_object( $wpdb ) ) {
2412 + return '';
2413 + }
2414 +
2415 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the atomic claim needs rows_affected from the server; the cache entry is dropped right after.
2416 + $claimed = $wpdb->query(
2417 + $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s", $key )
2418 + );
2419 +
2420 + // Whoever won, the row is gone for everyone; a cache still holding it
2421 + // would let a later read look live.
2422 + wp_cache_delete( $key, 'options' );
2423 +
2424 + if ( 1 !== (int) $claimed ) {
2425 + return '';
2426 + }
2427 +
2428 + $data = json_decode( $stored, true );
2429 + if ( ! is_array( $data ) || empty( $data['fingerprint'] ) ) {
2430 + return '';
2431 + }
2432 + if ( ! isset( $data['expires'] ) || time() > (int) $data['expires'] ) {
2433 + return '';
2434 + }
2435 +
2436 + return (string) $data['fingerprint'];
2437 + }
2438 +
2439 + /**
2440 + * Drop token rows nobody consumed.
2441 + *
2442 + * Options have no TTL, so an unused token would otherwise sit in
2443 + * wp_options forever — a scan that is never followed by a clean is the
2444 + * normal case, not the exception.
2445 + */
2446 + private static function purge_expired_confirm_tokens(): void {
2447 + global $wpdb;
2448 +
2449 + if ( ! isset( $wpdb ) || ! is_object( $wpdb ) ) {
2450 + return;
2451 + }
2452 +
2453 + // 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.
2454 + $names = $wpdb->get_col(
2455 + $wpdb->prepare(
2456 + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
2457 + $wpdb->esc_like( 'xspeed_mcp_clean_' ) . '%'
2458 + )
2459 + );
2460 +
2461 + foreach ( (array) $names as $name ) {
2462 + $data = json_decode( (string) get_option( $name ), true );
2463 + if ( ! is_array( $data ) || ! isset( $data['expires'] ) || time() > (int) $data['expires'] ) {
2464 + delete_option( $name );
2465 + }
2466 + }
2467 + }
2468 +
2469 + /**
850 2470 * Clean database bloat (destructive).
851 2471 *
852 2472 * @param array $args Unused.
853 2473 * @return array|\WP_Error
@@ -852,8 +2472,17 @@
852 2472 * @param array $args Unused.
853 2473 * @return array|\WP_Error
854 2474 */
855 2475 public static function clean_database( array $args ) {
2476 + /*
2477 + * The scan-before-clean confirmation is enforced in invoke(), which
2478 + * every tool passes through — see destructive_action(). It is NOT
2479 + * repeated here: the token is single-use, so checking it twice would
2480 + * consume it on the first check and reject the caller on the second.
2481 + *
2482 + * Reaching this line means the dispatcher already verified a token
2483 + * bound to a scan of the current database state. (#184)
2484 + */
856 2485 unset( $args );
857 2486 return Cli_Bridge::run( 'db', array( 'clean' ) );
858 2487 }
859 2488
@@ -926,8 +2555,242 @@
926 2555 *
927 2556 * @param array $args { url:string }.
928 2557 * @return array|\WP_Error
929 2558 */
2559 + /**
2560 + * Inspect what is in the page cache (pages + age, or size breakdown).
2561 + *
2562 + * @param array $args detail: pages|size, limit.
2563 + * @return array|\WP_Error
2564 + */
2565 + public static function get_cache_inventory( array $args ) {
2566 + $detail = isset( $args['detail'] ) ? (string) $args['detail'] : 'pages';
2567 + $action = 'size' === $detail ? 'size' : 'inventory';
2568 + $assoc = array();
2569 + if ( isset( $args['limit'] ) && '' !== $args['limit'] ) {
2570 + $assoc['limit'] = (string) $args['limit'];
2571 + }
2572 + return Cli_Bridge::run( 'cache', array( $action ), $assoc );
2573 + }
2574 +
2575 + /**
2576 + * Recent cache purges and their causes.
2577 + *
2578 + * @param array $args limit.
2579 + * @return array|\WP_Error
2580 + */
2581 + public static function get_purge_log( array $args ) {
2582 + $assoc = array();
2583 + if ( isset( $args['limit'] ) && '' !== $args['limit'] ) {
2584 + $assoc['limit'] = (string) $args['limit'];
2585 + }
2586 + return Cli_Bridge::run( 'cache', array( 'purge-log' ), $assoc );
2587 + }
2588 +
2589 + /**
2590 + * Re-verify (and repair) the server rewrite rules.
2591 + *
2592 + * @param array $args Unused.
2593 + * @return array|\WP_Error
2594 + */
2595 + public static function recheck_rewrite_rules( array $args ) {
2596 + unset( $args );
2597 + return Cli_Bridge::run( 'cache', array( 'recheck-rewrite' ) );
2598 + }
2599 +
2600 + /**
2601 + * Turn Cloudflare development mode on or off.
2602 + *
2603 + * A boolean rather than two tools: dev-on and dev-off are one decision,
2604 + * and offering them separately doubles the surface for no gain.
2605 + *
2606 + * @param array $args enabled (bool, required).
2607 + * @return array|\WP_Error
2608 + */
2609 + public static function set_cloudflare_dev_mode( array $args ) {
2610 + if ( ! array_key_exists( 'enabled', $args ) ) {
2611 + return new \WP_Error( 'xspeed_mcp_missing_enabled', __( 'The enabled argument is required.', 'xspeed' ), array( 'status' => 400 ) );
2612 + }
2613 + $on = filter_var( $args['enabled'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
2614 + if ( null === $on ) {
2615 + return new \WP_Error( 'xspeed_mcp_invalid_enabled', __( 'The enabled argument must be true or false.', 'xspeed' ), array( 'status' => 400 ) );
2616 + }
2617 + return Cli_Bridge::run( 'cf', array( $on ? 'dev-on' : 'dev-off' ) );
2618 + }
2619 +
2620 + /**
2621 + * Optimize database tables (distinct from clean_database, which deletes).
2622 + *
2623 + * @param array $args Unused.
2624 + * @return array|\WP_Error
2625 + */
2626 + public static function optimize_database( array $args ) {
2627 + unset( $args );
2628 + return Cli_Bridge::run( 'db', array( 'optimize' ) );
2629 + }
2630 +
2631 + /**
2632 + * Object cache state, or the server snippet that enables it.
2633 + *
2634 + * @param array $args detail: status|snippet.
2635 + * @return array|\WP_Error
2636 + */
2637 + public static function get_object_cache_status( array $args ) {
2638 + $detail = isset( $args['detail'] ) ? (string) $args['detail'] : 'status';
2639 + $action = 'snippet' === $detail ? 'snippet' : 'status';
2640 + return Cli_Bridge::run( 'objcache', array( $action ) );
2641 + }
2642 +
2643 + /**
2644 + * Install or remove the object-cache drop-in.
2645 + *
2646 + * @param array $args enabled (bool, required).
2647 + * @return array|\WP_Error
2648 + */
2649 + public static function toggle_object_cache( array $args ) {
2650 + if ( ! array_key_exists( 'enabled', $args ) ) {
2651 + return new \WP_Error( 'xspeed_mcp_missing_enabled', __( 'The enabled argument is required.', 'xspeed' ), array( 'status' => 400 ) );
2652 + }
2653 + $on = filter_var( $args['enabled'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
2654 + if ( null === $on ) {
2655 + return new \WP_Error( 'xspeed_mcp_invalid_enabled', __( 'The enabled argument must be true or false.', 'xspeed' ), array( 'status' => 400 ) );
2656 + }
2657 + $assoc = array();
2658 + if ( $on && ! empty( $args['takeover'] ) && filter_var( $args['takeover'], FILTER_VALIDATE_BOOLEAN ) ) {
2659 + $assoc['takeover'] = true;
2660 + }
2661 + if ( ! $on && ! empty( $args['restore'] ) && filter_var( $args['restore'], FILTER_VALIDATE_BOOLEAN ) ) {
2662 + $assoc['restore'] = true;
2663 + }
2664 + return Cli_Bridge::run( 'objcache', array( $on ? 'enable' : 'disable' ), $assoc );
2665 + }
2666 +
2667 + /**
2668 + * List or clear stored Critical CSS.
2669 + *
2670 + * @param array $args action: list|clear.
2671 + * @return array|\WP_Error
2672 + */
2673 + public static function manage_critical_css( array $args ) {
2674 + $action = isset( $args['action'] ) ? (string) $args['action'] : '';
2675 + if ( ! in_array( $action, array( 'list', 'clear' ), true ) ) {
2676 + return new \WP_Error( 'xspeed_mcp_invalid_action', __( 'The action argument must be "list" or "clear".', 'xspeed' ), array( 'status' => 400 ) );
2677 + }
2678 + return Cli_Bridge::run( 'ccss', array( $action ) );
2679 + }
2680 +
2681 + /**
2682 + * Preloader progress.
2683 + *
2684 + * @param array $args Unused.
2685 + * @return array|\WP_Error
2686 + */
2687 + public static function get_preloader_status( array $args ) {
2688 + unset( $args );
2689 + return Cli_Bridge::run( 'preloader', array( 'status' ) );
2690 + }
2691 +
2692 + /**
2693 + * Stop a running preload.
2694 + *
2695 + * @param array $args Unused.
2696 + * @return array|\WP_Error
2697 + */
2698 + public static function stop_preloader( array $args ) {
2699 + unset( $args );
2700 + return Cli_Bridge::run( 'preloader', array( 'stop' ) );
2701 + }
2702 +
2703 + /**
2704 + * Stored external audit runs (PSI / GTmetrix).
2705 + *
2706 + * Read-only by construction: it reads the option Score already wrote. No
2707 + * outbound call is made, which is what lets the Hub poll this on a
2708 + * schedule without spending the site owner's PSI or GTmetrix quota.
2709 + *
2710 + * @param array $args limit.
2711 + * @return array|\WP_Error
2712 + */
2713 + public static function get_score_history( array $args ) {
2714 + if ( ! class_exists( '\\XSpeed\\Score' ) ) {
2715 + return new \WP_Error( 'xspeed_mcp_no_score', __( 'External scores are not available on this site.', 'xspeed' ), array( 'status' => 404 ) );
2716 + }
2717 +
2718 + $limit = isset( $args['limit'] ) ? (int) $args['limit'] : 100;
2719 + $limit = max( 1, min( 500, $limit ) );
2720 +
2721 + $history = \XSpeed\Score::history();
2722 +
2723 + $runs = array();
2724 + foreach ( array_slice( $history, 0, $limit ) as $run ) {
2725 + if ( ! is_array( $run ) ) {
2726 + continue;
2727 + }
2728 + $metrics = isset( $run['metrics'] ) && is_array( $run['metrics'] ) ? $run['metrics'] : array();
2729 + $runs[] = array(
2730 + 'provider' => isset( $run['provider'] ) ? (string) $run['provider'] : 'unknown',
2731 + 'ts' => isset( $run['ts'] ) ? (int) $run['ts'] : 0,
2732 + 'url' => isset( $run['url'] ) ? (string) $run['url'] : '',
2733 + 'strategy' => isset( $run['strategy'] ) ? (string) $run['strategy'] : null,
2734 + // A failed audit and a successful one that returned no score
2735 + // both project to score null — ok is the only field that
2736 + // tells them apart, and error says why it failed.
2737 + 'ok' => ! empty( $run['ok'] ),
2738 + 'error' => isset( $run['error'] ) && '' !== $run['error'] ? (string) $run['error'] : null,
2739 + // Null, never 0: Score distinguishes "no score" from "scored
2740 + // zero", and flattening that reports a failed audit as a
2741 + // catastrophic result.
2742 + 'score' => isset( $run['score'] ) && is_numeric( $run['score'] ) ? (int) $run['score'] : null,
2743 + 'metrics' => array(
2744 + 'lcp' => self::metric_or_null( $metrics, 'lcp' ),
2745 + 'fcp' => self::metric_or_null( $metrics, 'fcp' ),
2746 + 'cls' => self::metric_or_null( $metrics, 'cls' ),
2747 + 'tbt' => self::metric_or_null( $metrics, 'tbt' ),
2748 + 'si' => self::metric_or_null( $metrics, 'si' ),
2749 + 'ttfb' => self::metric_or_null( $metrics, 'ttfb' ),
2750 + ),
2751 + 'report_url' => self::report_url_for( $run ),
2752 + );
2753 + }
2754 +
2755 + return array(
2756 + 'runs' => $runs,
2757 + 'total' => count( $history ),
2758 + );
2759 + }
2760 +
2761 + /**
2762 + * One metric as a float, or null when absent/non-numeric.
2763 + *
2764 + * @param array $metrics Metric bag.
2765 + * @param string $key Metric id.
2766 + */
2767 + private static function metric_or_null( array $metrics, string $key ): ?float {
2768 + return isset( $metrics[ $key ] ) && is_numeric( $metrics[ $key ] ) ? (float) $metrics[ $key ] : null;
2769 + }
2770 +
2771 + /**
2772 + * Deep link to the provider's own report, when one exists.
2773 + *
2774 + * GTmetrix hosts a durable report per test, so its id is enough to build
2775 + * the link. PSI does NOT — a Lighthouse result is returned to the caller
2776 + * and never hosted, so there is genuinely nothing to link to and this
2777 + * returns null rather than inventing a URL that 404s.
2778 + *
2779 + * @param array $run One stored run.
2780 + */
2781 + private static function report_url_for( array $run ): ?string {
2782 + $provider = isset( $run['provider'] ) ? (string) $run['provider'] : '';
2783 + if ( 'gtmetrix' !== $provider ) {
2784 + return null;
2785 + }
2786 + $test_id = isset( $run['test_id'] ) ? trim( (string) $run['test_id'] ) : '';
2787 + if ( '' === $test_id ) {
2788 + return null;
2789 + }
2790 + return 'https://gtmetrix.com/reports/' . rawurlencode( $test_id );
2791 + }
2792 +
930 2793 public static function purge_url( array $args ) {
931 2794 $url = isset( $args['url'] ) ? trim( (string) $args['url'] ) : '';
932 2795 if ( '' === $url ) {
933 2796 return new \WP_Error( 'xspeed_mcp_missing_url', __( 'The url argument is required.', 'xspeed' ), array( 'status' => 400 ) );
@@ -957,13 +2820,36 @@
957 2820 return Cli_Bridge::run( 'cf', array( 'verify' ) );
958 2821 }
959 2822
960 2823 /**
961 - * Run a PageSpeed Insights audit (Pro).
2824 + * Run an external audit on any install.
962 2825 *
963 - * @param array $args { url?:string, strategy?:string }.
2826 + * Shares run_pagespeed's body: that handler ALREADY falls back to
2827 + * `xspeed score run` when the Pro `xspeed psi` command is absent, so the
2828 + * engine could always do this on Free — the tool was simply dropped from
2829 + * the catalog before anyone could call it. The only thing missing was a
2830 + * name that survives on a Free install. (#147)
2831 + *
2832 + * @param array $args target / strategy / provider.
964 2833 * @return array|\WP_Error
965 2834 */
2835 + public static function run_score( array $args ) {
2836 + // `target` is the CLI's name for it (--url is a reserved WP-CLI global,
2837 + // so the score command deliberately uses --target). Accept both here
2838 + // and normalise, so an assistant that guessed `url` still works.
2839 + if ( ! empty( $args['target'] ) && empty( $args['url'] ) ) {
2840 + $args['url'] = (string) $args['target'];
2841 + }
2842 + return self::run_pagespeed( $args );
2843 + }
2844 +
2845 + /**
2846 + * Run an external performance audit. Prefers the Pro engine when present,
2847 + * otherwise drives Free's own score command.
2848 + *
2849 + * @param array $args { url?:string, strategy?:string, provider?:string, force?:bool }.
2850 + * @return array|\WP_Error
2851 + */
966 2852 public static function run_pagespeed( array $args ) {
967 2853 $options = array();
968 2854 if ( ! empty( $args['url'] ) ) {
969 2855 $options['url'] = (string) $args['url'];
@@ -970,16 +2856,51 @@
970 2856 }
971 2857 if ( ! empty( $args['strategy'] ) ) {
972 2858 $options['strategy'] = (string) $args['strategy'];
973 2859 }
2860 + // Advertised in run_score's schema, and the Free score handler already
2861 + // branches on it (ScoreModule::cli_handler reads $assoc['provider']),
2862 + // so dropping it here meant a GTmetrix request ran a PSI audit and
2863 + // reported ok:true — spending the wrong provider's quota with nothing
2864 + // in the response to say so. (QA B1 on #162)
2865 + if ( ! empty( $args['provider'] ) ) {
2866 + $options['provider'] = (string) $args['provider'];
2867 + }
2868 + // Was reachable only via the generated xspeed_psi alias, which this
2869 + // change removes — so it moves onto the typed tool rather than being
2870 + // lost with it.
2871 + if ( ! empty( $args['force'] ) && filter_var( $args['force'], FILTER_VALIDATE_BOOLEAN ) ) {
2872 + $options['force'] = true;
2873 + }
974 2874
975 - // Prefer the richer Pro engine when it's installed; otherwise drive
976 - // Free's own score command. Same tool name either way — an assistant
977 - // asking for a PageSpeed audit shouldn't have to know which tier the
978 - // site runs, and the two write to the same run history.
979 - if ( isset( Cli_Bridge::commands()['xspeed psi'] ) ) {
2875 + /*
2876 + * Prefer the richer Pro engine when it's installed; otherwise drive
2877 + * Free's own score command. Same tool name either way — an assistant
2878 + * asking for a PageSpeed audit shouldn't have to know which tier the
2879 + * site runs, and the two write to the same run history.
2880 + *
2881 + * EXCEPT when a provider was named that the Pro engine cannot serve.
2882 + * `xspeed psi` is PageSpeed-only: it declares no --provider and
2883 + * discards the option, so preferring it purely because it exists made
2884 + * `provider: "gtmetrix"` run PSI and answer ok:true — the same silent
2885 + * wrong-provider bug this tool just fixed on Free, reappearing only on
2886 + * Pro. A site that configures GTmetrix would have stopped getting it
2887 + * the moment Pro activated. Free's `score` command reads $assoc
2888 + * ['provider'] and branches, so route there instead. (QA R1 on #162)
2889 + */
2890 + $wants_non_psi = isset( $options['provider'] ) && 'psi' !== strtolower( (string) $options['provider'] );
2891 + if ( isset( Cli_Bridge::commands()['xspeed psi'] ) && ! $wants_non_psi ) {
980 2892 return Cli_Bridge::run( 'psi', array(), $options );
981 2893 }
2894 +
2895 + // The Free `score` command reads --target, not --url: `url` is a
2896 + // reserved WP-CLI global, so a value passed as `url` never reaches the
2897 + // handler and the requested page is silently ignored in favour of the
2898 + // default. Translate rather than passing it through. (#147)
2899 + if ( isset( $options['url'] ) ) {
2900 + $options['target'] = $options['url'];
2901 + unset( $options['url'] );
2902 + }
982 2903 return Cli_Bridge::run( 'score', array( 'run' ), $options );
983 2904 }
984 2905
985 2906 /**
@@ -984,14 +2905,41 @@
984 2905
985 2906 /**
986 2907 * Generate Critical CSS (Pro).
987 2908 *
988 - * @param array $args Unused.
2909 + * The tool took no arguments, so it could only build the home page's
2910 + * blob, while `wp xspeed ccss generate --page-url` could target any page.
2911 + * Passed as `page-url`: `url` is a WP-CLI global the command never sees
2912 + * from a real command line. (#559)
2913 + *
2914 + * @param array $args { url?:string } Full URL or site path.
989 2915 * @return array|\WP_Error
990 2916 */
991 2917 public static function generate_critical_css( array $args ) {
992 - unset( $args );
993 - return Cli_Bridge::run( 'ccss', array( 'generate' ) );
2918 + $options = array();
2919 + if ( ! empty( $args['url'] ) ) {
2920 + $url = trim( (string) $args['url'] );
2921 + // A site path is a page on this site, as it is for run_pagespeed.
2922 + if ( '/' === substr( $url, 0, 1 ) && '//' !== substr( $url, 0, 2 ) ) {
2923 + $url = home_url( $url );
2924 + } elseif ( ! preg_match( '#^https?://#i', $url ) ) {
2925 + $url = ''; // `about/`, `//host/x`: neither a path nor a full URL.
2926 + }
2927 + $url = '' === $url ? '' : esc_url_raw( $url, array( 'http', 'https' ) );
2928 + // Only pages of this site. A render spends the site's quota, and
2929 + // another host is not a page this site's Critical CSS can serve.
2930 + $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
2931 + if ( '' !== $url && strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) !== $host ) {
2932 + $url = '';
2933 + }
2934 + // Refused rather than dropped: an empty value would quietly
2935 + // build the home page and report success for the wrong page.
2936 + if ( '' === $url ) {
2937 + 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 ) );
2938 + }
2939 + $options['page-url'] = $url;
2940 + }
2941 + return Cli_Bridge::run( 'ccss', array( 'generate' ), $options );
994 2942 }
995 2943
996 2944 /**
997 2945 * Build a JSON Schema object node.