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 +126 -5 1.3.0 → 1.3.7 View file →
@@ -38,9 +38,14 @@
38 38
39 39 defined( 'ABSPATH' ) || exit;
40 40
41 41 final class Mcp_Tools {
42 + /** Pro extension contract supported by this Free build. */
43 + public const EXTENSION_API = 1;
42 44
45 + /** Raw broker tool envelope cap, enforced before JSON decoding. */
46 + public const MAX_TOOL_BODY_BYTES = 2 * 1024 * 1024;
47 +
43 48 /** Valid cache purge types. */
44 49 public const PURGE_TYPES = array( 'all', 'page', 'assets', 'object', 'rest', 'cloudflare', 'cdn' );
45 50
46 51 /**
@@ -368,10 +373,18 @@
368 373 'write' => true,
369 374 'handler' => array( self::class, 'run_pagespeed' ),
370 375 ),
371 376 'generate_critical_css' => array(
372 - 'description' => 'Generate above-the-fold Critical CSS for the site (Pro). Calls the external generator and stores the result.',
373 - '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 + ),
374 387 'write' => true,
375 388 'handler' => array( self::class, 'generate_critical_css' ),
376 389 ),
377 390 'get_health' => array(
@@ -670,8 +683,49 @@
670 683 $catalog[ $name ] = $spec;
671 684 }
672 685 }
673 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 +
674 728 return $catalog;
675 729 }
676 730
677 731 /**
@@ -904,8 +958,11 @@
904 958 */
905 959 public static function list(): array {
906 960 $out = array();
907 961 foreach ( self::catalog() as $name => $spec ) {
962 + if ( ! empty( $spec['hidden'] ) ) {
963 + continue;
964 + }
908 965 $out[] = array(
909 966 'name' => $name,
910 967 'description' => $spec['description'],
911 968 'inputSchema' => $spec['inputSchema'],
@@ -944,8 +1001,39 @@
944 1001
945 1002 return $error;
946 1003 }
947 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 +
948 1036 // Scope enforcement: a read-only connection cannot invoke a tool that
949 1037 // mutates state. run_command is a gateway to the full CLI surface, so
950 1038 // it's treated as write regardless of the wrapped command. The active
951 1039 // credential's scope (pairing token OR OAuth access token) is carried
@@ -1066,8 +1154,13 @@
1066 1154
1067 1155 /**
1068 1156 * Cache status, stats, and detected server.
1069 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 + *
1070 1163 * @param array $args Unused.
1071 1164 * @return array
1072 1165 */
1073 1166 public static function get_cache_status( array $args ) {
@@ -1076,8 +1169,9 @@
1076 1169 return array(
1077 1170 'cache_enabled' => (bool) ( $opts['cache_enabled'] ?? false ),
1078 1171 'stats' => Cache::get_stats(),
1079 1172 'server' => Server::type(),
1173 + 'site_icon' => (string) get_site_icon_url( 64 ),
1080 1174 );
1081 1175 }
1082 1176
1083 1177 /**
@@ -2368,14 +2462,41 @@
2368 2462
2369 2463 /**
2370 2464 * Generate Critical CSS (Pro).
2371 2465 *
2372 - * @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.
2373 2472 * @return array|\WP_Error
2374 2473 */
2375 2474 public static function generate_critical_css( array $args ) {
2376 - unset( $args );
2377 - 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 );
2378 2499 }
2379 2500
2380 2501 /**
2381 2502 * Build a JSON Schema object node.