PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 All 33 releases
xspeed / includes / modules / Mcp / Mcp_Tools.php

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

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