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
← All changes | includes/modules/Mcp/McpModule.php +152 -26 1.3.7 → 1.4.0 View file →
@@ -44,8 +44,14 @@
44 44 defined( 'ABSPATH' ) || exit;
45 45
46 46 final class McpModule extends Module {
47 47
48 + /**
49 + * The stylesheet of the standalone consent pages: the OAuth authorize
50 + * screen here and the Hub connect screen (Mcp_Hub_Connect).
51 + */
52 + public const CONSENT_CSS = 'body{font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e2e8f0;margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center}.card{background:#1e293b;border:1px solid #334155;border-radius:16px;max-width:440px;padding:32px;box-shadow:0 10px 40px rgba(0,0,0,.4)}h1{font-size:20px;margin:0 0 4px}.sub{color:#94a3b8;font-size:13px;margin:0 0 24px}.row{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid #334155;font-size:13px}.row span:first-child{color:#94a3b8}.row span:last-child{font-weight:600;text-align:right;max-width:60%;word-break:break-word}.actions{display:flex;gap:12px;margin-top:24px}button{flex:1;padding:12px;border-radius:10px;border:0;font-size:14px;font-weight:600;cursor:pointer}.approve{background:#f5cd47;color:#1b2533}.deny{background:transparent;color:#94a3b8;border:1px solid #334155}';
53 +
48 54 public const SLUG = 'mcp';
49 55 public const TIER = self::TIER_FREE;
50 56 public const VERSION = '1.0.0';
51 57
@@ -64,8 +70,9 @@
64 70 public const REWRITE_RULES = array(
65 71 '^xspeed/mcp/([a-f0-9]{64})/?$',
66 72 '^xspeed/mcp/?$',
67 73 '^xspeed/mcp/attach/?$',
74 + '^xspeed/mcp/connect-info/?$',
68 75 // OAuth discovery. RFC 9728 §3.1 / RFC 8414 §3.1 put the
69 76 // `.well-known` segment BEFORE the resource/issuer path, and both of
70 77 // our identifiers are /xspeed/mcp — so this pair of URLs, and only
71 78 // this pair, is ours. It names our own path explicitly: a catch-all
@@ -143,8 +150,11 @@
143 150
144 151 /** Query var flagging the pretty /xspeed/mcp/attach callback. */
145 152 private const ATTACH_QUERY_VAR = 'xspeed_mcp_attach';
146 153
154 + /** Query var flagging the pretty /xspeed/mcp/connect-info document (xspeed-hub#307). */
155 + private const CONNECT_INFO_QUERY_VAR = 'xspeed_mcp_connect_info';
156 +
147 157 public function ui_metadata(): array {
148 158 return array(
149 159 'label' => __( 'MCP Server', 'xspeed' ),
150 160 'icon' => 'Sparkles',
@@ -163,8 +173,31 @@
163 173 return array();
164 174 }
165 175
166 176 /**
177 + * The pairing state, declared so a settings write cannot delete it.
178 + *
179 + * `Mcp_Pairing` keeps its credentials in this module's settings option,
180 + * and the schema above is empty — so `Settings_Manager::get()` strips
181 + * every one of these keys, and an `update()` for this slug then writes
182 + * that stripped array back. The connection token, the scopes and the
183 + * connected flag all disappear in a single save, and the only visible
184 + * result is a site that has silently lost MCP access while the Hub still
185 + * lists it as attached.
186 + *
187 + * Nothing offers a settings form for this module, which is why it went
188 + * unnoticed, but `update()` takes its slug from data on three paths that
189 + * do not: a recommendation action, an optimize plan, and the MCP
190 + * `update_settings` tool. `preserved_keys()` is the mechanism for exactly
191 + * this — out-of-schema keys a schema-driven save must carry through.
192 + *
193 + * @return string[]
194 + */
195 + public function preserved_keys(): array {
196 + return array( 'site_token', 'connection_token', 'connected', 'connected_at', 'scopes' );
197 + }
198 +
199 + /**
167 200 * All MCP routes register directly (see class docblock). Returning an
168 201 * empty array keeps Rest_Manager out of the token-auth path entirely.
169 202 */
170 203 public function rest_routes(): array {
@@ -217,8 +250,12 @@
217 250 // the account email + the SAME signed nonce we minted. We verify our own
218 251 // nonce and mark this admin attached — no server-to-server callback
219 252 // needed, so it works for local/firewalled sites too.
220 253 add_action( 'admin_init', array( $this, 'maybe_handle_hub_return' ) );
254 + // The Hub connect consent page (xspeed-hub#307). Priority 1: it emits a
255 + // standalone page and exits before anything else on admin_init runs.
256 + add_action( 'admin_init', array( Mcp_Hub_Connect::class, 'maybe_handle_page' ), 1 );
257 + add_action( 'admin_menu', array( Mcp_Hub_Connect::class, 'register_page' ) );
221 258
222 259 // An attached admin who is DELETED (or removed from the blog) never
223 260 // runs disconnect(), so the site-level attached mirror would report
224 261 // hub:true forever. deleted_user fires after both wp_delete_user()
@@ -262,12 +299,13 @@
262 299 }
263 300
264 301 // Verify OUR own signed nonce (proves the round-trip went through the
265 302 // Hub with a token we minted), then record the connection.
303 + // Signature only: the Hub has already spent this nonce on the attach
304 + // callback, and recording the link needs no credential.
266 305 if ( '' !== $nonce ) {
267 - $verified = Mcp_Hub::verify_attach_nonce( $nonce );
268 - if ( null !== $verified ) {
269 - $uid = isset( $verified['user_id'] ) ? (int) $verified['user_id'] : get_current_user_id();
306 + $uid = Mcp_Hub::check_attach_nonce( $nonce );
307 + if ( null !== $uid ) {
270 308 Mcp_Hub::mark_attached( $email, $uid ?: null );
271 309 }
272 310 }
273 311
@@ -330,8 +368,11 @@
330 368 // own rewrite (consistent with the MCP URL, survives hosts that block
331 369 // /wp-json). Placed BEFORE the token rule would never match "attach"
332 370 // (that rule requires 64 hex chars), so ordering is safe.
333 371 add_rewrite_rule( '^xspeed/mcp/attach/?$', 'index.php?' . self::ATTACH_QUERY_VAR . '=1', 'top' );
372 + // The Hub connect document, outside /wp-json for the same reason: a
373 + // site whose security plugin refuses anonymous REST still answers it.
374 + add_rewrite_rule( '^xspeed/mcp/connect-info/?$', 'index.php?' . self::CONNECT_INFO_QUERY_VAR . '=1', 'top' );
334 375
335 376 // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place the
336 377 // `.well-known` segment BEFORE the resource/issuer path, and both of
337 378 // our canonical identifiers are the MCP endpoint URL, so our
@@ -552,9 +593,9 @@
552 593 if ( ! is_string( $target ) ) {
553 594 return false;
554 595 }
555 596
556 - foreach ( array( self::QUERY_VAR, self::TOKEN_QUERY_VAR, self::WELLKNOWN_QUERY_VAR, self::AUTHORIZE_QUERY_VAR, self::ATTACH_QUERY_VAR ) as $var ) {
597 + foreach ( array( self::QUERY_VAR, self::TOKEN_QUERY_VAR, self::WELLKNOWN_QUERY_VAR, self::AUTHORIZE_QUERY_VAR, self::ATTACH_QUERY_VAR, self::CONNECT_INFO_QUERY_VAR ) as $var ) {
557 598 if ( false !== strpos( $target, $var . '=' ) ) {
558 599 return true;
559 600 }
560 601 }
@@ -611,8 +652,9 @@
611 652 $vars[] = self::TOKEN_QUERY_VAR;
612 653 $vars[] = self::WELLKNOWN_QUERY_VAR;
613 654 $vars[] = self::AUTHORIZE_QUERY_VAR;
614 655 $vars[] = self::ATTACH_QUERY_VAR;
656 + $vars[] = self::CONNECT_INFO_QUERY_VAR;
615 657 return $vars;
616 658 }
617 659
618 660 /**
@@ -759,15 +801,30 @@
759 801 echo wp_json_encode( $data );
760 802 exit;
761 803 }
762 804
805 + // Pretty Hub connect document: /xspeed/mcp/connect-info (xspeed-hub#307).
806 + if ( ! empty( $wp->query_vars[ self::CONNECT_INFO_QUERY_VAR ] ) ) {
807 + header( 'Content-Type: application/json; charset=utf-8' );
808 + header( 'Cache-Control: no-store' );
809 + status_header( 200 );
810 + echo wp_json_encode( Mcp_Hub_Connect::connect_info() );
811 + exit;
812 + }
813 +
763 814 // Pretty attach-callback: /xspeed/mcp/attach. The hub POSTs the signed
764 815 // nonce; we verify it and return this site's URL + token. Auth is the
765 816 // nonce itself (admin-minted, HMAC-signed), so no credential needed.
766 817 if ( ! empty( $wp->query_vars[ self::ATTACH_QUERY_VAR ] ) ) {
767 - $body = json_decode( (string) file_get_contents( 'php://input' ), true );
768 - $nonce = is_array( $body ) && isset( $body['nonce'] ) ? (string) $body['nonce'] : '';
769 - $result = Mcp_Hub::verify_attach_nonce( $nonce );
818 + $body = json_decode( (string) file_get_contents( 'php://input' ), true );
819 + $body = is_array( $body ) ? $body : array();
820 + $field = static fn( string $k ): string => isset( $body[ $k ] ) && is_string( $body[ $k ] ) ? $body[ $k ] : '';
821 + $result = self::attach_result( $field( 'nonce' ), $field( 'code' ), $field( 'code_verifier' ) );
822 + // A Hub-started connect lands the browser on the Hub, not here, so
823 + // this call is the only place the per-user link can be recorded.
824 + if ( null !== $result && '' !== $field( 'code' ) ) {
825 + Mcp_Hub::mark_attached( sanitize_email( $field( 'account_email' ) ), (int) $result['user_id'] ?: null );
826 + }
770 827 header( 'Content-Type: application/json; charset=utf-8' );
771 828 header( 'Cache-Control: no-store' );
772 829 if ( null === $result ) {
773 830 status_header( 403 );
@@ -986,10 +1043,28 @@
986 1043 array(
987 1044 'methods' => 'POST',
988 1045 'callback' => array( $this, 'rest_hub_disconnect' ),
989 1046 'permission_callback' => array( $this, 'admin_permission' ),
1047 + 'args' => array(
1048 + 'acknowledge' => array(
1049 + 'type' => 'boolean',
1050 + 'default' => false,
1051 + 'description' => 'Continue even though Mcp_Hub::disconnect_blockers() reported a reason not to. The caller has shown those reasons to a human.',
1052 + ),
1053 + ),
990 1054 )
991 1055 );
1056 + // Hub connect discovery (xspeed-hub#307): public, so the Hub can ask
1057 + // before sending anyone to the consent page. Names no secret.
1058 + register_rest_route(
1059 + self::NS,
1060 + '/hub/connect-info',
1061 + array(
1062 + 'methods' => 'GET',
1063 + 'callback' => array( $this, 'rest_hub_connect_info' ),
1064 + 'permission_callback' => '__return_true',
1065 + )
1066 + );
992 1067 // OAuth-attach callback: the hub calls this with the signed nonce the
993 1068 // plugin issued. Auth is the nonce itself (no pre-shared token), so
994 1069 // permission_callback is open — the handler validates the nonce.
995 1070 register_rest_route(
@@ -999,13 +1074,25 @@
999 1074 'methods' => 'POST',
1000 1075 'callback' => array( $this, 'rest_hub_attach_callback' ),
1001 1076 'permission_callback' => '__return_true',
1002 1077 'args' => array(
1003 - 'nonce' => array(
1078 + // One of: the signed nonce (plugin-started attach), or a
1079 + // code plus its PKCE verifier (Hub-started connect, #307).
1080 + 'nonce' => array(
1004 1081 'type' => 'string',
1005 - 'required' => true,
1082 + 'required' => false,
1006 1083 'description' => 'The signed attach nonce the plugin issued.',
1007 1084 ),
1085 + 'code' => array(
1086 + 'type' => 'string',
1087 + 'required' => false,
1088 + 'description' => 'The single-use code from the Hub connect consent page.',
1089 + ),
1090 + 'code_verifier' => array(
1091 + 'type' => 'string',
1092 + 'required' => false,
1093 + 'description' => 'The PKCE verifier for that code.',
1094 + ),
1008 1095 ),
1009 1096 )
1010 1097 );
1011 1098
@@ -1290,19 +1377,64 @@
1290 1377 return rest_ensure_response( Mcp_Hub::mark_attached( $email ) );
1291 1378 }
1292 1379
1293 1380 /**
1294 - * POST /mcp/hub/disconnect — clear the local hub-link bookkeeping.
1381 + * POST /mcp/hub/disconnect — tell the hub to drop this admin's link and
1382 + * clear the local bookkeeping.
1295 1383 *
1296 - * @param \WP_REST_Request $request Unused.
1297 - * @return \WP_REST_Response
1384 + * Answers 409 while something reports a reason not to disconnect and the
1385 + * request did not send `acknowledge`. The blockers travel in the error
1386 + * data so a non-UI client gets the same reason a human would read.
1387 + *
1388 + * @param \WP_REST_Request $request Carries the optional `acknowledge` flag.
1389 + * @return \WP_REST_Response|\WP_Error
1298 1390 */
1299 1391 public function rest_hub_disconnect( \WP_REST_Request $request ) {
1300 - unset( $request );
1301 - return rest_ensure_response( Mcp_Hub::disconnect() );
1392 + $result = Mcp_Hub::disconnect( (bool) $request->get_param( 'acknowledge' ) );
1393 +
1394 + if ( ! empty( $result['blocked'] ) ) {
1395 + $blockers = isset( $result['blockers'] ) && is_array( $result['blockers'] )
1396 + ? $result['blockers']
1397 + : array();
1398 + $reasons = trim( implode( ' ', array_column( $blockers, 'message' ) ) );
1399 +
1400 + return new \WP_Error(
1401 + 'xspeed_hub_disconnect_blocked',
1402 + '' !== $reasons
1403 + ? $reasons
1404 + : __( 'Disconnecting is blocked while this site depends on the hub connection.', 'xspeed' ),
1405 + array(
1406 + 'status' => 409,
1407 + 'blockers' => $blockers,
1408 + )
1409 + );
1410 + }
1411 +
1412 + return rest_ensure_response( $result );
1302 1413 }
1303 1414
1415 + /** GET /hub/connect-info — whether this site supports connecting from the Hub. */
1416 + public function rest_hub_connect_info(): \WP_REST_Response {
1417 + return rest_ensure_response( Mcp_Hub_Connect::connect_info() );
1418 + }
1419 +
1304 1420 /**
1421 + * Check an attach callback: a nonce (plugin-started) or a code with its
1422 + * PKCE verifier (Hub-started, xspeed-hub#307). Null when neither holds.
1423 + *
1424 + * @return array{site_url:string,site_token:string,user_id:int}|null
1425 + */
1426 + private static function attach_result( string $nonce, string $code, string $verifier ): ?array {
1427 + if ( '' !== $nonce ) {
1428 + return Mcp_Hub::verify_attach_nonce( $nonce );
1429 + }
1430 + if ( '' !== $code ) {
1431 + return Mcp_Hub_Connect::redeem_code( $code, $verifier );
1432 + }
1433 + return null;
1434 + }
1435 +
1436 + /**
1305 1437 * POST /mcp/attach — the OAuth-attach callback. The hub presents the
1306 1438 * signed nonce the plugin issued; on success we return this site's URL +
1307 1439 * token so the hub can record it. Nonce is the auth (admin-minted,
1308 1440 * HMAC-signed, time-bound), so no pre-shared token is required.
@@ -1310,10 +1442,13 @@
1310 1442 * @param \WP_REST_Request $request Carries the nonce.
1311 1443 * @return \WP_REST_Response|\WP_Error
1312 1444 */
1313 1445 public function rest_hub_attach_callback( \WP_REST_Request $request ) {
1314 - $nonce = (string) $request->get_param( 'nonce' );
1315 - $result = Mcp_Hub::verify_attach_nonce( $nonce );
1446 + $result = self::attach_result(
1447 + (string) $request->get_param( 'nonce' ),
1448 + (string) $request->get_param( 'code' ),
1449 + (string) $request->get_param( 'code_verifier' )
1450 + );
1316 1451 if ( null === $result ) {
1317 1452 return new \WP_Error(
1318 1453 'xspeed_attach_invalid',
1319 1454 __( 'Invalid or expired attach request.', 'xspeed' ),
@@ -1650,18 +1785,9 @@
1650 1785 header( 'Content-Type: text/html; charset=utf-8' );
1651 1786 header( 'Cache-Control: no-store' );
1652 1787
1653 1788 echo '<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' . esc_html__( 'Authorize AI access', 'xspeed' ) . '</title>';
1654 - echo '<style>'
1655 - . 'body{font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e2e8f0;margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center}'
1656 - . '.card{background:#1e293b;border:1px solid #334155;border-radius:16px;max-width:440px;padding:32px;box-shadow:0 10px 40px rgba(0,0,0,.4)}'
1657 - . 'h1{font-size:20px;margin:0 0 4px}.sub{color:#94a3b8;font-size:13px;margin:0 0 24px}'
1658 - . '.row{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid #334155;font-size:13px}'
1659 - . '.row span:first-child{color:#94a3b8}.row span:last-child{font-weight:600;text-align:right;max-width:60%;word-break:break-word}'
1660 - . '.actions{display:flex;gap:12px;margin-top:24px}'
1661 - . 'button{flex:1;padding:12px;border-radius:10px;border:0;font-size:14px;font-weight:600;cursor:pointer}'
1662 - . '.approve{background:#f5cd47;color:#1b2533}.deny{background:transparent;color:#94a3b8;border:1px solid #334155}'
1663 - . '</style></head><body><div class="card">';
1789 + echo '<style>' . self::CONSENT_CSS . '</style></head><body><div class="card">'; // phpcs:ignore WordPress.Security.EscapeOutput -- static stylesheet constant.
1664 1790 echo '<h1>' . esc_html__( 'Connect to xSpeed', 'xspeed' ) . '</h1>';
1665 1791 /* translators: %s: AI client name. */
1666 1792 echo '<p class="sub">' . esc_html( sprintf( __( '%s wants to manage the cache on this site.', 'xspeed' ), $client ) ) . '</p>';
1667 1793 echo '<div class="row"><span>' . esc_html__( 'Site', 'xspeed' ) . '</span><span>' . esc_html( wp_parse_url( home_url(), PHP_URL_HOST ) ) . '</span></div>';