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

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

1,013 lines 35.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP tool registry — the single source of truth for the tools xSpeed
4 * exposes to AI assistants.
5 *
6 * Each tool declares an MCP-style descriptor (name, description, JSON
7 * Schema inputSchema) and a handler that runs against the Free engine.
8 * Consumed by BOTH:
9 * - Mcp_Server (the per-site JSON-RPC endpoint at /xspeed/mcp), and
10 * - McpModule's REST tool routes (the optional hosted-broker path),
11 * so the two transports can never drift.
12 *
13 * Handlers take an associative array of already-decoded arguments and
14 * return either a plain array (serialized to JSON in the MCP result) or
15 * a WP_Error (surfaced as an MCP tool error).
16 *
17 * The plugin adds ZERO cache logic here — every handler is a thin proxy
18 * to Cache / Settings / Settings_Manager / Server / Admin / Pro_Audit /
19 * Cache_Benchmark.
20 *
21 * @package XSpeed
22 */
23
24 declare(strict_types=1);
25
26 namespace XSpeed\Modules\Mcp;
27
28 use XSpeed\Cache;
29 use XSpeed\Server;
30 use XSpeed\Admin;
31 use XSpeed\Settings;
32 use XSpeed\Settings_Manager;
33 use XSpeed\Pro_Audit;
34 use XSpeed\Cache_Benchmark;
35
36 defined( 'ABSPATH' ) || exit;
37
38 final class Mcp_Tools {
39
40 /** Valid cache purge types. */
41 public const PURGE_TYPES = array( 'all', 'page', 'assets', 'object', 'rest' );
42
43 /**
44 * Per-call read-only override. Null means "defer to the pairing token's
45 * scope" (the JSON-RPC path that predates OAuth). true/false is set by
46 * Mcp_Server when an OAuth access token (with its own scope) authorized
47 * the request, so a read-only OAuth grant is enforced even though the
48 * pairing token may be read-write (or absent).
49 *
50 * @var bool|null
51 */
52 private static $read_only_override = null;
53
54 /**
55 * Set the active credential's read-only state for the current request.
56 * Passing null clears the override (back to the pairing-token default).
57 *
58 * @param bool|null $read_only Whether the active credential is read-only.
59 */
60 public static function set_read_only_override( ?bool $read_only ): void {
61 self::$read_only_override = $read_only;
62 }
63
64 /**
65 * Whether the active MCP credential is limited to read-only tools. Uses
66 * the per-call override when set, else the pairing token's scope.
67 */
68 private static function is_read_only(): bool {
69 if ( null !== self::$read_only_override ) {
70 return self::$read_only_override;
71 }
72 return Mcp_Pairing::is_read_only();
73 }
74
75 /**
76 * Full tool catalog: name => descriptor. `handler` is a callable
77 * ( array $args ) : array|\WP_Error. `write` marks tools that mutate
78 * state (used for read-only scope enforcement).
79 *
80 * @return array<string, array{description:string, inputSchema:array, handler:callable, write:bool}>
81 */
82 public static function catalog(): array {
83 $catalog = array(
84 'get_cache_status' => array(
85 '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.',
86 'inputSchema' => self::object_schema( array(), array() ),
87 'write' => false,
88 'handler' => array( self::class, 'get_cache_status' ),
89 ),
90 'list_modules' => array(
91 'description' => 'List all xSpeed modules (free and Pro) with their settings schema and status.',
92 'inputSchema' => self::object_schema( array(), array() ),
93 'write' => false,
94 'handler' => array( self::class, 'list_modules' ),
95 ),
96 'run_benchmark' => array(
97 'description' => 'Run a before/after cache benchmark on the home page and return the timings. Each side reports bytes (decoded payload) and bytes_transferred (compressed wire size).',
98 'inputSchema' => self::object_schema( array(), array() ),
99 'write' => false,
100 'handler' => array( self::class, 'run_benchmark' ),
101 ),
102 'get_pro_audit' => array(
103 'description' => 'Personalized list of Pro features that would benefit THIS site, from its current settings and cache stats.',
104 'inputSchema' => self::object_schema( array(), array() ),
105 'write' => false,
106 'handler' => array( self::class, 'get_pro_audit' ),
107 ),
108 'purge_cache' => array(
109 'description' => 'Purge the site cache. "type" selects what to purge: all, page, assets, object, or rest. Defaults to all.',
110 'inputSchema' => self::object_schema(
111 array(
112 'type' => array(
113 'type' => 'string',
114 'enum' => self::PURGE_TYPES,
115 'description' => 'What to purge. Defaults to "all".',
116 ),
117 ),
118 array()
119 ),
120 'write' => true,
121 'handler' => array( self::class, 'purge_cache' ),
122 ),
123 'toggle_cache' => array(
124 'description' => 'Enable or disable page caching. Installs/removes the cache drop-in and WP_CACHE constant as needed.',
125 'inputSchema' => self::object_schema(
126 array(
127 'enabled' => array(
128 'type' => 'boolean',
129 'description' => 'true to enable caching, false to disable.',
130 ),
131 ),
132 array( 'enabled' )
133 ),
134 'write' => true,
135 'handler' => array( self::class, 'toggle_cache' ),
136 ),
137 'get_settings' => array(
138 'description' => 'Read the settings for a given xSpeed module (e.g. "minify", "gzip"). Returns schema-validated values.',
139 'inputSchema' => self::object_schema(
140 array(
141 'module' => array(
142 'type' => 'string',
143 'description' => 'The module slug, e.g. "minify".',
144 ),
145 ),
146 array( 'module' )
147 ),
148 'write' => false,
149 'handler' => array( self::class, 'get_settings' ),
150 ),
151 'update_settings' => array(
152 '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.',
153 'inputSchema' => self::object_schema(
154 array(
155 'module' => array(
156 'type' => 'string',
157 'description' => 'The module slug, e.g. "minify".',
158 ),
159 'values' => array(
160 'type' => 'object',
161 'description' => 'Map of setting keys to new values.',
162 ),
163 ),
164 array( 'module', 'values' )
165 ),
166 'write' => true,
167 'handler' => array( self::class, 'update_settings' ),
168 ),
169 // --- Promoted high-value actions: dedicated typed tools so the AI
170 // calls them directly (no run_command hop). Each is a thin wrapper
171 // over Cli_Bridge, so Free tools can drive Pro actions (psi, ccss)
172 // without a cross-repo class reference, and none can drift from the
173 // CLI. ---
174 'purge_cloudflare' => array(
175 'description' => 'Purge the Cloudflare edge cache for this site (requires Cloudflare connected in the Cloudflare module).',
176 'inputSchema' => self::object_schema( array(), array() ),
177 'write' => true,
178 'handler' => array( self::class, 'purge_cloudflare' ),
179 ),
180 'scan_database' => array(
181 'description' => 'Scan the database for bloat (post revisions, auto-drafts, trashed posts, spam comments, expired transients, orphaned meta) without deleting anything.',
182 'inputSchema' => self::object_schema( array(), array() ),
183 'write' => false,
184 'handler' => array( self::class, 'scan_database' ),
185 ),
186 'clean_database' => array(
187 'description' => 'Clean database bloat. Removes the categories currently enabled in the Database module settings. Destructive — run scan_database first to preview.',
188 'inputSchema' => self::object_schema( array(), array() ),
189 'write' => true,
190 'handler' => array( self::class, 'clean_database' ),
191 ),
192 'flush_object_cache' => array(
193 'description' => 'Flush the persistent object cache (Redis / Memcached), if enabled.',
194 'inputSchema' => self::object_schema( array(), array() ),
195 'write' => true,
196 'handler' => array( self::class, 'flush_object_cache' ),
197 ),
198 'start_preloader' => array(
199 'description' => 'Start the cache preloader — crawls the sitemap to warm the page cache in the background.',
200 'inputSchema' => self::object_schema( array(), array() ),
201 'write' => true,
202 'handler' => array( self::class, 'start_preloader' ),
203 ),
204 'run_pagespeed' => array(
205 'description' => 'Run an external performance audit (PageSpeed Insights, or GTmetrix when configured) and return the score + Core Web Vitals. Defaults to the site home page, mobile strategy. Requires external scores to be enabled in settings — the plugin makes no outbound calls otherwise.',
206 'inputSchema' => self::object_schema(
207 array(
208 'url' => array(
209 'type' => 'string',
210 'description' => 'URL to audit. Defaults to the site home page.',
211 ),
212 'strategy' => array(
213 'type' => 'string',
214 'enum' => array( 'mobile', 'desktop' ),
215 'description' => 'Audit strategy. Defaults to "mobile".',
216 ),
217 ),
218 array()
219 ),
220 'write' => false,
221 'handler' => array( self::class, 'run_pagespeed' ),
222 ),
223 'generate_critical_css' => array(
224 'description' => 'Generate above-the-fold Critical CSS for the site (Pro). Calls the external generator and stores the result.',
225 'inputSchema' => self::object_schema( array(), array() ),
226 'write' => true,
227 'handler' => array( self::class, 'generate_critical_css' ),
228 ),
229 'get_health' => array(
230 '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.',
231 'inputSchema' => self::object_schema( array(), array() ),
232 'write' => false,
233 'handler' => array( self::class, 'get_health' ),
234 ),
235 'get_benchmark_history' => array(
236 '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.',
237 'inputSchema' => self::object_schema(
238 array(
239 'limit' => array(
240 'type' => 'integer',
241 'description' => 'Max runs to return (default 100).',
242 ),
243 ),
244 array()
245 ),
246 'write' => false,
247 'handler' => array( self::class, 'get_benchmark_history' ),
248 ),
249 'purge_url' => array(
250 'description' => 'Purge the cache for ONE URL only (all its variants: device buckets, trailing-slash forms, static-tree copy). Surgical alternative to purge_cache when a single page changed.',
251 'inputSchema' => self::object_schema(
252 array(
253 'url' => array(
254 'type' => 'string',
255 'description' => 'Absolute URL or site-relative path, e.g. "https://site.com/about/" or "/about/".',
256 ),
257 ),
258 array( 'url' )
259 ),
260 'write' => true,
261 'handler' => array( self::class, 'purge_url' ),
262 ),
263 'test_object_cache' => array(
264 '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.',
265 'inputSchema' => self::object_schema( array(), array() ),
266 'write' => false,
267 'handler' => array( self::class, 'test_object_cache' ),
268 ),
269 'cloudflare_verify' => array(
270 'description' => 'Verify the saved Cloudflare credentials against the Cloudflare API (token/zone check). Read-only — use purge_cloudflare to purge the edge.',
271 'inputSchema' => self::object_schema( array(), array() ),
272 'write' => false,
273 'handler' => array( self::class, 'cloudflare_verify' ),
274 ),
275 'list_commands' => array(
276 '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.',
277 'inputSchema' => self::object_schema( array(), array() ),
278 'write' => false,
279 'handler' => array( self::class, 'list_commands' ),
280 ),
281 'run_command' => array(
282 'description' => 'Run any xSpeed command — the full CLI surface (~50 commands across every module: cache, cloudflare, database, critical/unused CSS, pagespeed, images, migration, preloader, object cache, analytics, RUM, smart-* and more). Call list_commands first to discover names + options. Examples: run_command("cloudflare purge"), run_command("database clean"), run_command("psi", {}, {"url":"https://site.com","strategy":"mobile"}).',
283 'inputSchema' => self::object_schema(
284 array(
285 'command' => array(
286 'type' => 'string',
287 'description' => 'Command name, e.g. "cloudflare purge" or "database scan" (the "xspeed " prefix is optional).',
288 ),
289 'args' => array(
290 'type' => 'array',
291 'description' => 'Positional arguments, if the command takes any.',
292 'items' => array( 'type' => 'string' ),
293 ),
294 'options' => array(
295 'type' => 'object',
296 'description' => 'Named options / flags, e.g. { "url": "https://site.com", "strategy": "mobile", "force": true }.',
297 ),
298 ),
299 array( 'command' )
300 ),
301 'write' => true,
302 'handler' => array( self::class, 'run_command' ),
303 ),
304 );
305
306 // Dedicated tools that wrap a command only present when a given
307 // module is active (e.g. Pro): drop them if the command isn't
308 // registered, so we never advertise a tool that always fails. The
309 // action stays reachable via run_command if the command exists.
310 $conditional = array(
311 'generate_critical_css' => 'xspeed ccss',
312 'purge_cloudflare' => 'xspeed cf',
313 'cloudflare_verify' => 'xspeed cf',
314 'flush_object_cache' => 'xspeed objcache',
315 'test_object_cache' => 'xspeed objcache',
316 'start_preloader' => 'xspeed preloader',
317 'scan_database' => 'xspeed db',
318 'clean_database' => 'xspeed db',
319 'purge_url' => 'xspeed cache',
320 );
321 $commands = Cli_Bridge::commands();
322 foreach ( $conditional as $tool => $command ) {
323 if ( ! isset( $commands[ $command ] ) ) {
324 unset( $catalog[ $tool ] );
325 }
326 }
327
328 // One dedicated tool per xSpeed CLI command, generated from the same
329 // Cli_Bridge catalog the CLI registers from — so the AI can call any
330 // command directly without the list_commands -> run_command discovery
331 // hop, and the generated set can never drift from the CLI. Curated /
332 // promoted tools above win on any name collision (they have richer
333 // schemas), so e.g. `xspeed cf` doesn't shadow purge_cloudflare.
334 foreach ( self::cli_generated_tools() as $name => $spec ) {
335 if ( ! isset( $catalog[ $name ] ) ) {
336 $catalog[ $name ] = $spec;
337 }
338 }
339
340 return $catalog;
341 }
342
343 /**
344 * Generate one MCP tool per registered xSpeed CLI command. Each wraps
345 * Cli_Bridge::run(): the tool's `action` (the command's first positional,
346 * e.g. `verify`/`purge` for `xspeed cf`) plus any named options are passed
347 * straight through. Tool names are the command with the `xspeed ` prefix
348 * dropped and spaces -> underscores (`xspeed cf` -> `xspeed_cf`).
349 *
350 * @return array<string, array{description:string, inputSchema:array, write:bool, handler:callable}>
351 */
352 private static function cli_generated_tools(): array {
353 $tools = array();
354 foreach ( Cli_Bridge::commands() as $command => $spec ) {
355 $tool_name = self::cli_tool_name( $command );
356 if ( '' === $tool_name ) {
357 continue;
358 }
359
360 // Build the input schema from the command's synopsis: positional
361 // args become string properties (the first is usually the action,
362 // exposed with its allowed values as an enum); assoc args become
363 // named options.
364 $properties = array();
365 $required = array();
366 foreach ( $spec['synopsis'] as $arg ) {
367 if ( ! isset( $arg['name'] ) ) {
368 continue;
369 }
370 $arg_name = (string) $arg['name'];
371 $prop = array(
372 'type' => 'string',
373 'description' => isset( $arg['description'] ) ? (string) $arg['description'] : '',
374 );
375 if ( isset( $arg['options'] ) && is_array( $arg['options'] ) && ! empty( $arg['options'] ) ) {
376 $prop['enum'] = array_values( array_map( 'strval', $arg['options'] ) );
377 }
378 $properties[ $arg_name ] = $prop;
379 $is_optional = ! empty( $arg['optional'] );
380 $is_flag = isset( $arg['type'] ) && 'flag' === $arg['type'];
381 if ( ! $is_optional && ! $is_flag ) {
382 $required[] = $arg_name;
383 }
384 }
385
386 $description = '' !== $spec['shortdesc']
387 ? $spec['shortdesc']
388 : sprintf( 'Run the "%s" xSpeed command.', $command );
389
390 list( $write, $write_actions ) = self::cli_write_profile( $command, $spec['synopsis'] );
391
392 $tools[ $tool_name ] = array(
393 'description' => $description,
394 'inputSchema' => self::object_schema( $properties, $required ),
395 'write' => $write,
396 // The action values that mutate state. When set, read-only
397 // enforcement is per-ACTION (a read-only grant may still call
398 // the tool with a read action like "status"/"scan").
399 'write_actions' => $write_actions,
400 'handler' => self::cli_handler_for( $command, $spec['synopsis'] ),
401 );
402 }
403 return $tools;
404 }
405
406 /** Derive an MCP tool name from a CLI command ("xspeed cf" -> "xspeed_cf"). */
407 private static function cli_tool_name( string $command ): string {
408 $command = trim( preg_replace( '/\s+/', ' ', $command ) ?? '' );
409 if ( '' === $command ) {
410 return '';
411 }
412 return str_replace( ' ', '_', $command );
413 }
414
415 /** Action verbs that only inspect state (never mutate). */
416 private const CLI_READ_VERBS = array( 'status', 'scan', 'list', 'verify', 'get', 'show', 'info', 'export', 'preview', 'check', 'snippet', 'test' );
417
418 /** Commands with NO action enum that are nonetheless pure inspection. */
419 private const CLI_READ_ONLY_COMMANDS = array( 'xspeed health', 'xspeed support' );
420
421 /**
422 * Compute the write profile for a generated command tool:
423 * [ $write_bool, $write_actions ]
424 * where $write_actions is the list of action values that mutate state
425 * (empty when the tool has no action enum). $write_bool is the tool-level
426 * flag: true if ANY action writes (so read-only clients see it flagged),
427 * but per-action enforcement in invoke() still lets a read-only grant run
428 * the tool's read actions (e.g. `minify status` while `minify purge` is
429 * refused).
430 *
431 * @param string $command Full command name.
432 * @param array $synopsis Command synopsis.
433 * @return array{0:bool,1:string[]}
434 */
435 private static function cli_write_profile( string $command, array $synopsis ): array {
436 // Command with an action enum → classify each action.
437 foreach ( $synopsis as $arg ) {
438 if ( isset( $arg['type'], $arg['options'] ) && 'positional' === $arg['type'] && is_array( $arg['options'] ) ) {
439 $write_actions = array();
440 foreach ( $arg['options'] as $opt ) {
441 if ( ! in_array( strtolower( (string) $opt ), self::CLI_READ_VERBS, true ) ) {
442 $write_actions[] = (string) $opt;
443 }
444 }
445 return array( ! empty( $write_actions ), $write_actions );
446 }
447 }
448
449 // No action enum: a small allow-list of pure-inspection commands is
450 // read-only; everything else defaults to write (safe — a read-only
451 // grant never mutates).
452 $is_read = in_array( trim( $command ), self::CLI_READ_ONLY_COMMANDS, true );
453 return array( ! $is_read, array() );
454 }
455
456 /**
457 * Build the handler for a generated command tool. It maps the tool's
458 * arguments back to Cli_Bridge::run(): positional synopsis args (in order)
459 * become $args; everything else is passed as named options.
460 *
461 * @param string $command Full command name.
462 * @param array $synopsis Command synopsis.
463 * @return callable
464 */
465 private static function cli_handler_for( string $command, array $synopsis ): callable {
466 // Names of the positional args, in declared order.
467 $positionals = array();
468 foreach ( $synopsis as $arg ) {
469 if ( isset( $arg['name'] ) && ( ! isset( $arg['type'] ) || 'positional' === $arg['type'] ) ) {
470 $positionals[] = (string) $arg['name'];
471 }
472 }
473
474 return static function ( array $tool_args ) use ( $command, $positionals ) {
475 $args = array();
476 $assoc = $tool_args;
477 // Pull positionals out (in order) into $args; the rest are options.
478 foreach ( $positionals as $pname ) {
479 if ( array_key_exists( $pname, $assoc ) && '' !== (string) $assoc[ $pname ] ) {
480 $args[] = (string) $assoc[ $pname ];
481 }
482 unset( $assoc[ $pname ] );
483 }
484 return Cli_Bridge::run( $command, $args, $assoc );
485 };
486 }
487
488 /**
489 * The tool list in MCP `tools/list` shape.
490 *
491 * @return array<int, array{name:string, description:string, inputSchema:array}>
492 */
493 public static function list(): array {
494 $out = array();
495 foreach ( self::catalog() as $name => $spec ) {
496 $out[] = array(
497 'name' => $name,
498 'description' => $spec['description'],
499 'inputSchema' => $spec['inputSchema'],
500 );
501 }
502 return $out;
503 }
504
505 /**
506 * Invoke a tool by name with decoded arguments.
507 *
508 * @param string $name Tool name.
509 * @param array $args Decoded arguments.
510 * @return array|\WP_Error Result payload or error.
511 */
512 public static function invoke( string $name, array $args ) {
513 $catalog = self::catalog();
514 if ( ! isset( $catalog[ $name ] ) ) {
515 $error = new \WP_Error(
516 'xspeed_mcp_unknown_tool',
517 sprintf(
518 /* translators: %s: tool name. */
519 __( 'Unknown tool: %s', 'xspeed' ),
520 $name
521 ),
522 array( 'status' => 404 )
523 );
524
525 // A call for a tool that doesn't exist is still something that
526 // happened to this site, and a run of them is the shape of a
527 // probe. Recording it is the difference between a trail that
528 // shows what was ATTEMPTED and one that only shows what
529 // succeeded. Scope is unknowable here, so log the conservative
530 // one rather than implying the attempt was read-only.
531 Mcp_Activity_Log::record( $name, $args, false, $error->get_error_message(), 'write', self::$channel );
532
533 return $error;
534 }
535
536 // Scope enforcement: a read-only connection cannot invoke a tool that
537 // mutates state. run_command is a gateway to the full CLI surface, so
538 // it's treated as write regardless of the wrapped command. The active
539 // credential's scope (pairing token OR OAuth access token) is carried
540 // in self::$scope_override; it falls back to the pairing global for
541 // callers that don't set a per-call scope.
542 if ( ! empty( $catalog[ $name ]['write'] ) && self::is_read_only() ) {
543 return new \WP_Error(
544 'xspeed_mcp_read_only',
545 sprintf(
546 /* translators: %s: tool name. */
547 __( 'This MCP connection is read-only; the "%s" tool changes state and is not permitted. Reconnect with write access to use it.', 'xspeed' ),
548 $name
549 ),
550 array( 'status' => 403 )
551 );
552 }
553
554 self::$dispatching = true;
555 try {
556 $result = call_user_func( $catalog[ $name ]['handler'], $args );
557
558 // Audit every dispatched call — this is the record the admin
559 // reads to answer "what did the assistant do to my site?".
560 // Recorded here (not per-handler) so a new tool is covered the
561 // moment it joins the catalog.
562 [ $ok, $error ] = self::outcome( $result );
563
564 $scope = empty( $catalog[ $name ]['write'] ) ? 'read' : 'write';
565
566 Mcp_Activity_Log::record( $name, $args, $ok, $error, $scope, self::$channel );
567
568 return $result;
569 } finally {
570 self::$dispatching = false;
571 }
572 }
573
574 /**
575 * Read success/failure out of a handler result.
576 *
577 * Two failure shapes reach here. A handler that validates its own
578 * input returns WP_Error. A handler that delegates to Cli_Bridge gets
579 * back an ARRAY carrying `ok => false` plus `error`, because a
580 * `WP_CLI::error()` inside the shim is a controlled failure rather
581 * than an exception. Reading only the first shape logged every failed
582 * command — a refused purge, a Cloudflare call with no credentials —
583 * as a success.
584 *
585 * @param mixed $result Handler return value.
586 * @return array{0:bool,1:string}
587 */
588 private static function outcome( $result ): array {
589 if ( is_wp_error( $result ) ) {
590 return array( false, $result->get_error_message() );
591 }
592
593 if ( is_array( $result ) && array_key_exists( 'ok', $result ) && ! $result['ok'] ) {
594 $error = isset( $result['error'] ) ? (string) $result['error'] : '';
595 return array( false, '' === $error ? 'Command reported failure.' : $error );
596 }
597
598 return array( true, '' );
599 }
600
601 /** @var string Transport that carried the current call (for the audit log). */
602 private static $channel = 'mcp';
603
604 /**
605 * Name the transport for subsequent invokes — the JSON-RPC endpoint and
606 * the hosted-broker REST routes share this catalog, and the audit trail
607 * should say which one a call arrived on.
608 */
609 public static function set_channel( string $channel ): void {
610 self::$channel = '' === $channel ? 'mcp' : $channel;
611 }
612
613 /** @var bool True while an MCP tool handler is executing. */
614 private static $dispatching = false;
615
616 /**
617 * True while a tool call is being dispatched — lets deeper layers
618 * (e.g. the settings change-log) attribute a mutation to MCP.
619 */
620 public static function in_dispatch(): bool {
621 return self::$dispatching;
622 }
623
624 /*
625 * Handlers — thin proxies to the Free engine. Each takes decoded tool
626 * arguments and returns an array payload (or WP_Error on bad input).
627 */
628
629 /**
630 * Cache status, stats, and detected server.
631 *
632 * @param array $args Unused.
633 * @return array
634 */
635 public static function get_cache_status( array $args ) {
636 unset( $args );
637 $opts = Settings::get();
638 return array(
639 'cache_enabled' => (bool) ( $opts['cache_enabled'] ?? false ),
640 'stats' => Cache::get_stats(),
641 'server' => Server::type(),
642 );
643 }
644
645 /**
646 * All registered module descriptors.
647 *
648 * @param array $args Unused.
649 * @return array
650 */
651 public static function list_modules( array $args ) {
652 unset( $args );
653 return Admin::modules_payload();
654 }
655
656 /**
657 * Before/after cache benchmark timings.
658 *
659 * @param array $args Unused.
660 * @return array
661 */
662 public static function run_benchmark( array $args ) {
663 unset( $args );
664 return Cache_Benchmark::run();
665 }
666
667 /**
668 * Personalized Pro-feature suggestions for this site.
669 *
670 * @param array $args Unused.
671 * @return array
672 */
673 public static function get_pro_audit( array $args ) {
674 unset( $args );
675 return array( 'suggestions' => Pro_Audit::run() );
676 }
677
678 /**
679 * Purge the cache by type.
680 *
681 * @param array $args { type?:string } — one of PURGE_TYPES; default all.
682 * @return array|\WP_Error
683 */
684 public static function purge_cache( array $args ) {
685 $type = isset( $args['type'] ) ? (string) $args['type'] : 'all';
686 if ( '' === $type ) {
687 $type = 'all';
688 }
689 if ( ! in_array( $type, self::PURGE_TYPES, true ) ) {
690 return new \WP_Error(
691 'xspeed_mcp_bad_type',
692 sprintf(
693 /* translators: %s: comma-separated list of valid purge types. */
694 __( 'Invalid purge type. Expected one of: %s', 'xspeed' ),
695 implode( ', ', self::PURGE_TYPES )
696 ),
697 array( 'status' => 400 )
698 );
699 }
700 // Named source, not the default "manual": the purge log's whole job
701 // is to let an admin see that the cache cleared because an assistant
702 // asked, not because someone clicked.
703 $count = Cache::purge_type( $type, __( 'AI assistant', 'xspeed' ) );
704 return array(
705 'purged' => $type,
706 'count' => $count,
707 'stats' => Cache::get_stats(),
708 );
709 }
710
711 /**
712 * Enable or disable page caching.
713 *
714 * @param array $args { enabled:bool }.
715 * @return array|\WP_Error
716 */
717 public static function toggle_cache( array $args ) {
718 if ( ! array_key_exists( 'enabled', $args ) ) {
719 return new \WP_Error(
720 'xspeed_mcp_missing_enabled',
721 __( 'The "enabled" parameter is required (true or false).', 'xspeed' ),
722 array( 'status' => 400 )
723 );
724 }
725 $enabled = rest_sanitize_boolean( $args['enabled'] );
726 $install = Cache::toggle( $enabled );
727
728 // Persist cache_enabled the same way the Free /cache/toggle route
729 // does (class-rest-api.php:235) — Cache::toggle handles the drop-in
730 // + wp-config; Settings owns the option flag.
731 Settings::update( array( 'cache_enabled' => $enabled ) );
732
733 return array(
734 'cache_enabled' => $enabled,
735 'install_state' => $install,
736 'stats' => Cache::get_stats(),
737 );
738 }
739
740 /**
741 * Read a module's schema-validated settings.
742 *
743 * @param array $args { module:string }.
744 * @return array|\WP_Error
745 */
746 public static function get_settings( array $args ) {
747 $module = isset( $args['module'] ) ? (string) $args['module'] : '';
748 if ( '' === $module ) {
749 return new \WP_Error(
750 'xspeed_mcp_missing_module',
751 __( 'The "module" parameter is required.', 'xspeed' ),
752 array( 'status' => 400 )
753 );
754 }
755 return array(
756 'module' => $module,
757 'settings' => Settings_Manager::get( $module ),
758 );
759 }
760
761 /**
762 * Update a module's settings (schema-validated).
763 *
764 * @param array $args { module:string, values:array }.
765 * @return array|\WP_Error
766 */
767 public static function update_settings( array $args ) {
768 $module = isset( $args['module'] ) ? (string) $args['module'] : '';
769 $values = $args['values'] ?? null;
770 if ( '' === $module ) {
771 return new \WP_Error(
772 'xspeed_mcp_missing_module',
773 __( 'The "module" parameter is required.', 'xspeed' ),
774 array( 'status' => 400 )
775 );
776 }
777 if ( ! is_array( $values ) ) {
778 return new \WP_Error(
779 'xspeed_mcp_bad_values',
780 __( 'The "values" parameter must be an object of setting keys.', 'xspeed' ),
781 array( 'status' => 400 )
782 );
783 }
784 return array(
785 'module' => $module,
786 'settings' => Settings_Manager::update( $module, $values ),
787 );
788 }
789
790 /**
791 * List every command run_command can invoke (the full CLI surface).
792 *
793 * @param array $args Unused.
794 * @return array
795 */
796 public static function list_commands( array $args ) {
797 unset( $args );
798 return array( 'commands' => Cli_Bridge::catalog() );
799 }
800
801 /**
802 * Run any registered xSpeed command via the CLI bridge.
803 *
804 * @param array $args { command:string, args?:array, options?:array }.
805 * @return array|\WP_Error
806 */
807 public static function run_command( array $args ) {
808 $command = isset( $args['command'] ) ? (string) $args['command'] : '';
809 if ( '' === $command ) {
810 return new \WP_Error(
811 'xspeed_mcp_missing_command',
812 __( 'The "command" parameter is required.', 'xspeed' ),
813 array( 'status' => 400 )
814 );
815 }
816 $positional = isset( $args['args'] ) && is_array( $args['args'] ) ? $args['args'] : array();
817 $options = isset( $args['options'] ) && is_array( $args['options'] ) ? $args['options'] : array();
818 return Cli_Bridge::run( $command, $positional, $options );
819 }
820
821 /* --------------------------------------------------------------------- */
822 /* Promoted action handlers — typed wrappers over Cli_Bridge. */
823 /* Delegating to the bridge lets a Free tool drive a Pro action (psi, */
824 /* ccss) with no cross-repo class reference, and keeps zero drift. */
825 /* --------------------------------------------------------------------- */
826
827 /**
828 * Purge the Cloudflare edge cache.
829 *
830 * @param array $args Unused.
831 * @return array|\WP_Error
832 */
833 public static function purge_cloudflare( array $args ) {
834 unset( $args );
835 return Cli_Bridge::run( 'cf', array( 'purge' ) );
836 }
837
838 /**
839 * Scan the database for bloat (no deletion).
840 *
841 * @param array $args Unused.
842 * @return array|\WP_Error
843 */
844 public static function scan_database( array $args ) {
845 unset( $args );
846 return Cli_Bridge::run( 'db', array( 'scan' ) );
847 }
848
849 /**
850 * Clean database bloat (destructive).
851 *
852 * @param array $args Unused.
853 * @return array|\WP_Error
854 */
855 public static function clean_database( array $args ) {
856 unset( $args );
857 return Cli_Bridge::run( 'db', array( 'clean' ) );
858 }
859
860 /**
861 * Flush the persistent object cache.
862 *
863 * @param array $args Unused.
864 * @return array|\WP_Error
865 */
866 public static function flush_object_cache( array $args ) {
867 unset( $args );
868 return Cli_Bridge::run( 'objcache', array( 'flush' ) );
869 }
870
871 /**
872 * Start the cache preloader.
873 *
874 * @param array $args Unused.
875 * @return array|\WP_Error
876 */
877 public static function start_preloader( array $args ) {
878 unset( $args );
879 return Cli_Bridge::run( 'preloader', array( 'start' ) );
880 }
881
882 /**
883 * Full health diagnostics (checks + stats + buckets + activity).
884 * Direct typed payload — same tier as get_cache_status — so the agent
885 * gets structured tones/ids instead of parsing CLI log lines.
886 *
887 * @param array $args Unused.
888 * @return array
889 */
890 public static function get_health( array $args ) {
891 unset( $args );
892 return array(
893 'checks' => \XSpeed\Health::checks(),
894 'stats' => Cache::get_stats(),
895 'buckets' => \XSpeed\Hit_Counter::buckets(),
896 'hit_daily' => \XSpeed\Hit_Counter::daily_series( 30 ),
897 'activity' => \XSpeed\Activity_Log::entries(),
898 );
899 }
900
901 /**
902 * Stored benchmark runs + settings-change events (trend data).
903 *
904 * @param array $args { limit?:int }.
905 * @return array
906 */
907 public static function get_benchmark_history( array $args ) {
908 $limit = isset( $args['limit'] ) ? max( 1, min( 100, (int) $args['limit'] ) ) : 100;
909 $changes = array();
910 foreach ( \XSpeed\Activity_Log::entries() as $entry ) {
911 if ( 'settings_changed' === ( $entry['type'] ?? '' ) ) {
912 $changes[] = array(
913 'ts' => (int) $entry['ts'],
914 'message' => (string) $entry['message'],
915 );
916 }
917 }
918 return array(
919 'runs' => Cache_Benchmark::history( $limit ),
920 'changes' => $changes,
921 );
922 }
923
924 /**
925 * Purge a single URL's cache entries.
926 *
927 * @param array $args { url:string }.
928 * @return array|\WP_Error
929 */
930 public static function purge_url( array $args ) {
931 $url = isset( $args['url'] ) ? trim( (string) $args['url'] ) : '';
932 if ( '' === $url ) {
933 return new \WP_Error( 'xspeed_mcp_missing_url', __( 'The url argument is required.', 'xspeed' ), array( 'status' => 400 ) );
934 }
935 return Cli_Bridge::run( 'cache', array( 'purge-url', $url ), array( 'cause' => __( 'AI assistant', 'xspeed' ) ) );
936 }
937
938 /**
939 * Probe the configured object-cache backend (connect + read/write).
940 *
941 * @param array $args Unused.
942 * @return array|\WP_Error
943 */
944 public static function test_object_cache( array $args ) {
945 unset( $args );
946 return Cli_Bridge::run( 'objcache', array( 'test' ) );
947 }
948
949 /**
950 * Verify the saved Cloudflare credentials.
951 *
952 * @param array $args Unused.
953 * @return array|\WP_Error
954 */
955 public static function cloudflare_verify( array $args ) {
956 unset( $args );
957 return Cli_Bridge::run( 'cf', array( 'verify' ) );
958 }
959
960 /**
961 * Run a PageSpeed Insights audit (Pro).
962 *
963 * @param array $args { url?:string, strategy?:string }.
964 * @return array|\WP_Error
965 */
966 public static function run_pagespeed( array $args ) {
967 $options = array();
968 if ( ! empty( $args['url'] ) ) {
969 $options['url'] = (string) $args['url'];
970 }
971 if ( ! empty( $args['strategy'] ) ) {
972 $options['strategy'] = (string) $args['strategy'];
973 }
974
975 // Prefer the richer Pro engine when it's installed; otherwise drive
976 // Free's own score command. Same tool name either way — an assistant
977 // asking for a PageSpeed audit shouldn't have to know which tier the
978 // site runs, and the two write to the same run history.
979 if ( isset( Cli_Bridge::commands()['xspeed psi'] ) ) {
980 return Cli_Bridge::run( 'psi', array(), $options );
981 }
982 return Cli_Bridge::run( 'score', array( 'run' ), $options );
983 }
984
985 /**
986 * Generate Critical CSS (Pro).
987 *
988 * @param array $args Unused.
989 * @return array|\WP_Error
990 */
991 public static function generate_critical_css( array $args ) {
992 unset( $args );
993 return Cli_Bridge::run( 'ccss', array( 'generate' ) );
994 }
995
996 /**
997 * Build a JSON Schema object node.
998 *
999 * @param array $properties Property map.
1000 * @param string[] $required Required property names.
1001 */
1002 private static function object_schema( array $properties, array $required ): array {
1003 $schema = array(
1004 'type' => 'object',
1005 'properties' => (object) $properties,
1006 );
1007 if ( ! empty( $required ) ) {
1008 $schema['required'] = array_values( $required );
1009 }
1010 return $schema;
1011 }
1012 }
1013