| @@ -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. |