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

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

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