PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / modules / Mcp / Mcp_Tools.php

Mcp_Tools.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.3, at includes/modules/Mcp/Mcp_Tools.php

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