PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 All 33 releases
← All changes | includes/modules/Mcp/Mcp_Tools.php +216 -17 1.2.4 → 1.3.7 View file →
@@ -29,8 +29,9 @@
29 29 use XSpeed\Server;
30 30 use XSpeed\Admin;
31 31 use XSpeed\Settings;
32 32 use XSpeed\Settings_Manager;
33 +use XSpeed\Module_Registry;
33 34 use XSpeed\Pro_Audit;
34 35 use XSpeed\Cache_Benchmark;
35 36 use XSpeed\Tier_Registry;
36 37 use XSpeed\Database_Cleaner;
@@ -37,11 +38,16 @@
37 38
38 39 defined( 'ABSPATH' ) || exit;
39 40
40 41 final class Mcp_Tools {
42 + /** Pro extension contract supported by this Free build. */
43 + public const EXTENSION_API = 1;
41 44
45 + /** Raw broker tool envelope cap, enforced before JSON decoding. */
46 + public const MAX_TOOL_BODY_BYTES = 2 * 1024 * 1024;
47 +
42 48 /** Valid cache purge types. */
43 - public const PURGE_TYPES = array( 'all', 'page', 'assets', 'object', 'rest' );
49 + public const PURGE_TYPES = array( 'all', 'page', 'assets', 'object', 'rest', 'cloudflare', 'cdn' );
44 50
45 51 /**
46 52 * Per-call read-only override. Null means "defer to the pairing token's
47 53 * scope" (the JSON-RPC path that predates OAuth). true/false is set by
@@ -144,9 +150,9 @@
144 150 'write' => false,
145 151 'handler' => array( self::class, 'get_site_info' ),
146 152 ),
147 153 'optimize_site' => array(
148 - 'description' => 'Make this site faster, end to end: measure, apply the recommended settings ONE AT A TIME, check the page still renders after each, and undo any change that breaks it. Returns what was applied, the site\'s last recorded score, `next_steps` (riskier settings that could help but are NOT applied automatically), and `unfixable` (problems no caching plugin can reach). ALWAYS relay all three to the user: report the score and what is still wrong, then — if `next_steps` is non-empty — describe each one WITH its stated `risk` and ASK whether to run again with aggressiveness "aggressive". Never enable aggressive settings without the user agreeing first, and never present `unfixable` items as things you can solve; they need the site owner or the host. A site where nothing was left to do is a real, good answer — say so plainly rather than apologising or retrying. Use `dry_run` to preview the plan.',
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.',
149 155 'inputSchema' => self::object_schema(
150 156 array(
151 157 'aggressiveness' => array(
152 158 'type' => 'string',
@@ -156,8 +162,24 @@
156 162 'dry_run' => array(
157 163 'type' => 'boolean',
158 164 'description' => 'Return the plan without changing anything.',
159 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 + ),
160 182 ),
161 183 array()
162 184 ),
163 185 'write' => true,
@@ -175,9 +197,9 @@
175 197 'write' => false,
176 198 'handler' => array( self::class, 'get_pro_audit' ),
177 199 ),
178 200 'purge_cache' => array(
179 - 'description' => 'Purge the site cache. "type" selects what to purge: all, page, assets, object, or rest. Defaults to all.',
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.',
180 202 'inputSchema' => self::object_schema(
181 203 array(
182 204 'type' => array(
183 205 'type' => 'string',
@@ -279,9 +301,9 @@
279 301 'write' => true,
280 302 'handler' => array( self::class, 'start_preloader' ),
281 303 ),
282 304 'run_score' => array(
283 - 'description' => 'Run an external performance audit (PageSpeed Insights, or GTmetrix when configured) against this site and return the score plus Core Web Vitals. Available on every install. Spends the site\'s own configured API quota. Use get_score_history to read past runs without starting a new one.',
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.',
284 306 'inputSchema' => self::object_schema(
285 307 array(
286 308 'target' => array(
287 309 'type' => 'string',
@@ -317,9 +339,9 @@
317 339 'write' => true,
318 340 'handler' => array( self::class, 'run_score' ),
319 341 ),
320 342 'run_pagespeed' => array(
321 - 'description' => 'Run an external performance audit (PageSpeed Insights, or GTmetrix when configured) and return the score + Core Web Vitals. Defaults to the site home page, mobile strategy. Requires external scores to be enabled in settings — the plugin makes no outbound calls otherwise.',
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.',
322 344 'inputSchema' => self::object_schema(
323 345 array(
324 346 'url' => array(
325 347 'type' => 'string',
@@ -351,10 +373,18 @@
351 373 'write' => true,
352 374 'handler' => array( self::class, 'run_pagespeed' ),
353 375 ),
354 376 'generate_critical_css' => array(
355 - 'description' => 'Generate above-the-fold Critical CSS for the site (Pro). Calls the external generator and stores the result.',
356 - 'inputSchema' => self::object_schema( array(), array() ),
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 + ),
357 387 'write' => true,
358 388 'handler' => array( self::class, 'generate_critical_css' ),
359 389 ),
360 390 'get_health' => array(
@@ -619,8 +649,9 @@
619 649 'update_settings' => 'xspeed settings',
620 650 'run_pagespeed' => 'xspeed psi',
621 651 'get_health' => 'xspeed health',
622 652 'get_score_history' => 'xspeed score',
653 + 'purge_cache' => 'xspeed purge',
623 654 );
624 655
625 656 $commands = Cli_Bridge::commands();
626 657 foreach ( $conditional as $tool => $command ) {
@@ -652,8 +683,49 @@
652 683 $catalog[ $name ] = $spec;
653 684 }
654 685 }
655 686
687 + /**
688 + * Add private tools owned by an installed extension.
689 + *
690 + * Extensions receive an EMPTY map and may only add new names. Core tool
691 + * definitions cannot be replaced through this seam. A `hidden` tool is
692 + * callable through the authenticated per-site proxy but omitted from
693 + * tools/list. This is for broker-run workflows whose public tool must not
694 + * be advertised until the broker-side worker exists.
695 + *
696 + * `hidden` is not a permission: the proxy takes the same pairing token
697 + * the public channel does. It withholds a tool from discovery and from
698 + * an OAuth grant, nothing more. See the gate in invoke().
699 + *
700 + * `hidden` must be a real bool when present. It decides whether a tool
701 + * is reachable from the public channel at all, so a truthy string is
702 + * the kind of near-miss that should be rejected rather than guessed at.
703 + * Specs that fail any check here are skipped, not repaired.
704 + *
705 + * @param array<string,array<string,mixed>> $tools Extension tool specs.
706 + */
707 + $extensions = apply_filters( 'xspeed_mcp_extension_tools', array() );
708 + if ( is_array( $extensions ) ) {
709 + foreach ( $extensions as $name => $spec ) {
710 + if (
711 + ! is_string( $name )
712 + || 1 !== preg_match( '/^[a-z][a-z0-9_]{0,63}$/', $name )
713 + || isset( $catalog[ $name ] )
714 + || ! is_array( $spec )
715 + || ! isset( $spec['description'], $spec['inputSchema'], $spec['handler'], $spec['write'] )
716 + || ! is_string( $spec['description'] )
717 + || ! is_array( $spec['inputSchema'] )
718 + || ! is_callable( $spec['handler'] )
719 + || ! is_bool( $spec['write'] )
720 + || ( isset( $spec['hidden'] ) && ! is_bool( $spec['hidden'] ) )
721 + ) {
722 + continue;
723 + }
724 + $catalog[ $name ] = $spec;
725 + }
726 + }
727 +
656 728 return $catalog;
657 729 }
658 730
659 731 /**
@@ -886,8 +958,11 @@
886 958 */
887 959 public static function list(): array {
888 960 $out = array();
889 961 foreach ( self::catalog() as $name => $spec ) {
962 + if ( ! empty( $spec['hidden'] ) ) {
963 + continue;
964 + }
890 965 $out[] = array(
891 966 'name' => $name,
892 967 'description' => $spec['description'],
893 968 'inputSchema' => $spec['inputSchema'],
@@ -926,8 +1001,39 @@
926 1001
927 1002 return $error;
928 1003 }
929 1004
1005 + // Hidden tools are private broker stages, not undiscoverable public MCP
1006 + // tools. Omitting one from tools/list is only presentation, so this
1007 + // closes the JSON-RPC channel to them as well, returning the same 404 a
1008 + // nonexistent name returns. Both entry paths set the channel before
1009 + // every invoke, so a previous broker call cannot widen a later one.
1010 + //
1011 + // What this is NOT: a permission boundary. The broker REST route
1012 + // authenticates with the SAME pairing token as the JSON-RPC route
1013 + // (Mcp_Auth::permission and Mcp_Server::authorize both compare against
1014 + // Mcp_Pairing::site_token()), so anyone holding that token — every
1015 + // client the dashboard's connection recipes are written for — can call
1016 + // a hidden tool by name on the broker route, and tell it from a
1017 + // nonexistent one by the status. `hidden` keeps a tool off the
1018 + // advertised surface and out of an OAuth grant's reach; it does not
1019 + // make it safe for a pairing-token holder to run. Anything gated only
1020 + // by `hidden` must be something that holder may already do.
1021 + if ( ! empty( $catalog[ $name ]['hidden'] ) && 'broker' !== self::$channel ) {
1022 + $error = new \WP_Error(
1023 + 'xspeed_mcp_unknown_tool',
1024 + sprintf(
1025 + /* translators: %s: tool name. */
1026 + __( 'Unknown tool: %s', 'xspeed' ),
1027 + $name
1028 + ),
1029 + array( 'status' => 404 )
1030 + );
1031 + Mcp_Activity_Log::record( $name, $args, false, $error->get_error_message(), 'write', self::$channel );
1032 +
1033 + return $error;
1034 + }
1035 +
930 1036 // Scope enforcement: a read-only connection cannot invoke a tool that
931 1037 // mutates state. run_command is a gateway to the full CLI surface, so
932 1038 // it's treated as write regardless of the wrapped command. The active
933 1039 // credential's scope (pairing token OR OAuth access token) is carried
@@ -1048,8 +1154,13 @@
1048 1154
1049 1155 /**
1050 1156 * Cache status, stats, and detected server.
1051 1157 *
1158 + * `site_icon` is the Site Icon set under Appearance, or '' when none is
1159 + * set. The Hub shows it beside the site's name. Asking the site for
1160 + * /favicon.ico instead failed on nginx hosts, which answer .ico
1161 + * requests as static files and never reach WordPress.
1162 + *
1052 1163 * @param array $args Unused.
1053 1164 * @return array
1054 1165 */
1055 1166 public static function get_cache_status( array $args ) {
@@ -1058,8 +1169,9 @@
1058 1169 return array(
1059 1170 'cache_enabled' => (bool) ( $opts['cache_enabled'] ?? false ),
1060 1171 'stats' => Cache::get_stats(),
1061 1172 'server' => Server::type(),
1173 + 'site_icon' => (string) get_site_icon_url( 64 ),
1062 1174 );
1063 1175 }
1064 1176
1065 1177 /**
@@ -1157,14 +1269,29 @@
1157 1269 * @param array<string,mixed> $args Tool arguments.
1158 1270 * @return array<string,mixed>|\WP_Error
1159 1271 */
1160 1272 public static function optimize_site( array $args = array() ) {
1161 - return \XSpeed\Optimize_Runner::run(
1162 - array(
1163 - 'aggressiveness' => (string) ( $args['aggressiveness'] ?? 'standard' ),
1164 - 'dry_run' => (bool) ( $args['dry_run'] ?? false ),
1165 - )
1273 + $run = array(
1274 + 'aggressiveness' => (string) ( $args['aggressiveness'] ?? 'standard' ),
1275 + 'dry_run' => (bool) ( $args['dry_run'] ?? false ),
1276 + 'measure_score' => (string) ( $args['measure_score'] ?? 'auto' ),
1166 1277 );
1278 +
1279 + // Tuning arguments are forwarded ONLY when present, and are not
1280 + // understood by Free — a listener on `xspeed_optimize_report` reads
1281 + // them from that filter's `$context`. Passing them through rather
1282 + // than naming them in the array above is deliberate: this handler
1283 + // builds an explicit whitelist, so an argument it does not list is
1284 + // silently dropped. A caller asking to reach a score would have got a
1285 + // single pass and a success response — wrong behaviour with no error,
1286 + // which is the expensive kind to diagnose.
1287 + foreach ( array( 'target_score', 'max_rounds' ) as $key ) {
1288 + if ( isset( $args[ $key ] ) && is_numeric( $args[ $key ] ) ) {
1289 + $run[ $key ] = (int) $args[ $key ];
1290 + }
1291 + }
1292 +
1293 + return \XSpeed\Optimize_Runner::run( $run );
1167 1294 }
1168 1295
1169 1296 /**
1170 1297 * Before/after cache benchmark timings.
@@ -1212,12 +1339,43 @@
1212 1339 }
1213 1340 // Named source, not the default "manual": the purge log's whole job
1214 1341 // is to let an admin see that the cache cleared because an assistant
1215 1342 // asked, not because someone clicked.
1216 - $count = Cache::purge_type( $type, __( 'AI assistant', 'xspeed' ) );
1343 + $cause = __( 'AI assistant', 'xspeed' );
1344 +
1345 + /*
1346 + * `page`, `assets` and `rest` are fine-grained slices of the local
1347 + * sweep with no target of their own, and they predate this tool's
1348 + * per-store report — an assistant asking for `page` means the HTML,
1349 + * not the HTML plus the minified bundles plus every purge listener.
1350 + * They stay on purge_type() so their meaning does not change under
1351 + * callers already relying on it.
1352 + *
1353 + * Everything else routes through the runner — the same core function
1354 + * the CLI and the REST callback use — so an assistant told "cache
1355 + * cleared" is reading the same per-store verdict a human would get,
1356 + * including a Cloudflare zone that refused the purge.
1357 + */
1358 + if ( in_array( $type, array( 'page', 'assets', 'rest' ), true ) ) {
1359 + return array(
1360 + 'purged' => $type,
1361 + 'count' => Cache::purge_type( $type, $cause ),
1362 + 'ok' => true,
1363 + 'stats' => Cache::get_stats(),
1364 + );
1365 + }
1366 +
1367 + $report = \XSpeed\Purge_Runner::run( array( $type ), $cause );
1368 + $count = 0;
1369 + foreach ( $report['types'] as $row ) {
1370 + $count += (int) $row['entries'];
1371 + }
1372 +
1217 1373 return array(
1218 1374 'purged' => $type,
1219 1375 'count' => $count,
1376 + 'ok' => $report['ok'],
1377 + 'report' => $report['types'],
1220 1378 'stats' => Cache::get_stats(),
1221 1379 );
1222 1380 }
1223 1381
@@ -1497,13 +1655,26 @@
1497 1655 * @return true|\WP_Error True when every key would be applied.
1498 1656 */
1499 1657 private static function inspect_or_error( string $module, array $values ) {
1500 1658 $report = Settings_Manager::inspect_input( $module, $values );
1501 - if ( empty( $report['unknown'] ) && empty( $report['invalid'] ) ) {
1659 + if ( empty( $report['unknown'] ) && empty( $report['invalid'] ) && empty( $report['locked'] ) ) {
1502 1660 return true;
1503 1661 }
1504 1662
1505 1663 $parts = array();
1664 + // A field pinned by a wp-config.php constant cannot be written. Say so
1665 + // rather than returning a success the agent relays as "changed" over a
1666 + // write that update() would silently drop. (#398)
1667 + foreach ( $report['locked'] as $key ) {
1668 + $spec = ( Module_Registry::get( $module ) ? Module_Registry::get( $module )->settings_schema()[ $key ] ?? array() : array() );
1669 + $constant = Settings_Manager::effective_constant( $module, $key, is_array( $spec ) ? $spec : array() );
1670 + $parts[] = sprintf(
1671 + /* translators: 1: setting key, 2: wp-config.php constant name. */
1672 + __( '"%1$s" is defined in wp-config.php as %2$s and cannot be changed here', 'xspeed' ),
1673 + $key,
1674 + (string) $constant
1675 + );
1676 + }
1506 1677 foreach ( $report['unknown'] as $key ) {
1507 1678 $detail = sprintf(
1508 1679 /* translators: 1: setting key, 2: module slug. */
1509 1680 __( '"%1$s" is not a setting of module "%2$s"', 'xspeed' ),
@@ -1544,8 +1715,9 @@
1544 1715 array(
1545 1716 'status' => 400,
1546 1717 'refused_unknown' => $report['unknown'],
1547 1718 'refused_invalid' => $report['invalid'],
1719 + 'refused_locked' => $report['locked'],
1548 1720 'would_apply' => $report['applied'],
1549 1721 )
1550 1722 );
1551 1723 }
@@ -2290,14 +2462,41 @@
2290 2462
2291 2463 /**
2292 2464 * Generate Critical CSS (Pro).
2293 2465 *
2294 - * @param array $args Unused.
2466 + * The tool took no arguments, so it could only build the home page's
2467 + * blob, while `wp xspeed ccss generate --page-url` could target any page.
2468 + * Passed as `page-url`: `url` is a WP-CLI global the command never sees
2469 + * from a real command line. (#559)
2470 + *
2471 + * @param array $args { url?:string } Full URL or site path.
2295 2472 * @return array|\WP_Error
2296 2473 */
2297 2474 public static function generate_critical_css( array $args ) {
2298 - unset( $args );
2299 - return Cli_Bridge::run( 'ccss', array( 'generate' ) );
2475 + $options = array();
2476 + if ( ! empty( $args['url'] ) ) {
2477 + $url = trim( (string) $args['url'] );
2478 + // A site path is a page on this site, as it is for run_pagespeed.
2479 + if ( '/' === substr( $url, 0, 1 ) && '//' !== substr( $url, 0, 2 ) ) {
2480 + $url = home_url( $url );
2481 + } elseif ( ! preg_match( '#^https?://#i', $url ) ) {
2482 + $url = ''; // `about/`, `//host/x`: neither a path nor a full URL.
2483 + }
2484 + $url = '' === $url ? '' : esc_url_raw( $url, array( 'http', 'https' ) );
2485 + // Only pages of this site. A render spends the site's quota, and
2486 + // another host is not a page this site's Critical CSS can serve.
2487 + $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
2488 + if ( '' !== $url && strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) !== $host ) {
2489 + $url = '';
2490 + }
2491 + // Refused rather than dropped: an empty value would quietly
2492 + // build the home page and report success for the wrong page.
2493 + if ( '' === $url ) {
2494 + return new \WP_Error( 'xspeed_mcp_invalid_url', __( 'The url argument must be a page on this site, as a full http(s) URL or a path starting with /.', 'xspeed' ), array( 'status' => 400 ) );
2495 + }
2496 + $options['page-url'] = $url;
2497 + }
2498 + return Cli_Bridge::run( 'ccss', array( 'generate' ), $options );
2300 2499 }
2301 2500
2302 2501 /**
2303 2502 * Build a JSON Schema object node.