PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.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 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / modules / Mcp / Mcp_Tools.php

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

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