PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
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 1.1.5 1.1.6 All 31 releases
← All changes | includes/modules/Mcp/McpModule.php +670 -20 1.0.81.3.3 View file →
@@ -37,8 +37,9 @@
37 37
38 38 namespace XSpeed\Modules\Mcp;
39 39
40 40 use XSpeed\Module;
41 +use XSpeed\Onboarding;
41 42
42 43 defined( 'ABSPATH' ) || exit;
43 44
44 45 final class McpModule extends Module {
@@ -46,11 +47,40 @@
46 47 public const SLUG = 'mcp';
47 48 public const TIER = self::TIER_FREE;
48 49 public const VERSION = '1.0.0';
49 50
50 - /** REST namespace shared with Free. */
51 - private const NS = 'xspeed/v1';
51 + /**
52 + * Every rewrite rule this module registers, in registration order.
53 + *
54 + * Single source of truth: add_rewrite() registers these, and the self-heal
55 + * guard re-flushes when any is missing from the stored table. They were two
56 + * hand-maintained lists before, which is a silent drift risk — a rule
57 + * dropped from one and not the other leaves the guard restoring a rule
58 + * nothing registers, or never firing for one that is registered.
59 + *
60 + * @var string[] Rewrite regexes. The query each maps to is built in
61 + * add_rewrite(), which also fixes their order.
62 + */
63 + public const REWRITE_RULES = array(
64 + '^xspeed/mcp/([a-f0-9]{64})/?$',
65 + '^xspeed/mcp/?$',
66 + '^xspeed/mcp/attach/?$',
67 + // OAuth discovery, root form. RFC 9728 §3.1 / RFC 8414 §3.1 put the
68 + // `.well-known` segment BEFORE the resource path.
69 + '^\.well-known/oauth-(protected-resource|authorization-server)/?$',
70 + // OAuth discovery, path-suffixed form. Real clients (Claude Desktop
71 + // among them) request THIS one; serving only the root form 404s them.
72 + // It names our own resource path explicitly: a catch-all tail here
73 + // also matched other MCP plugins' discovery URLs on the same site and
74 + // answered them with our metadata, which broke their connectors.
75 + '^\.well-known/oauth-(protected-resource|authorization-server)/xspeed/mcp/?$',
76 + '^xspeed/authorize/?$',
77 + );
52 78
79 + /** REST namespace shared with Free. Public: Mcp_Server builds the
80 + * discovery fallback URL from it. */
81 + public const NS = 'xspeed/v1';
82 +
53 83 /** Query var flagging a pretty /xspeed/mcp request. */
54 84 private const QUERY_VAR = 'xspeed_mcp';
55 85
56 86 /** Query var carrying the token when embedded in the URL path. */
@@ -72,13 +102,16 @@
72 102
73 103 /** Front-end path of the browser-facing authorize page. */
74 104 private const AUTHORIZE_PATH = 'xspeed/authorize';
75 105
106 + /** Query var flagging the pretty /xspeed/mcp/attach callback. */
107 + private const ATTACH_QUERY_VAR = 'xspeed_mcp_attach';
108 +
76 109 public function ui_metadata(): array {
77 110 return array(
78 - 'label' => 'MCP Server',
111 + 'label' => __( 'MCP Server', 'xspeed' ),
79 112 'icon' => 'Sparkles',
80 - 'description' => 'Control this site\'s cache from Claude and other AI agents.',
113 + 'description' => __( 'Control this site\'s cache from Claude and other AI agents.', 'xspeed' ),
81 114 'custom_panel' => 'McpPanel',
82 115 );
83 116 }
84 117
@@ -104,12 +137,95 @@
104 137
105 138 // Pretty per-site endpoint: /xspeed/mcp → MCP JSON-RPC handler.
106 139 add_action( 'init', array( $this, 'add_rewrite' ) );
107 140 add_filter( 'query_vars', array( $this, 'register_query_var' ) );
108 - add_action( 'parse_request', array( $this, 'maybe_handle_pretty_endpoint' ) );
141 + // Priority 1: a sibling MCP plugin that also claims /.well-known/ gets
142 + // to answer first at the default priority 10, and whoever answers
143 + // first calls exit(). Running early means the URL is decided by WHOSE
144 + // path it is, not by which plugin happened to load last.
145 + add_action( 'parse_request', array( $this, 'maybe_handle_pretty_endpoint' ), 1 );
146 +
147 + // Hub redirect-return: after the user approves on the Hub, it sends the
148 + // browser back to a plugin admin URL carrying ?xspeed_connected=1 plus
149 + // the account email + the SAME signed nonce we minted. We verify our own
150 + // nonce and mark this admin attached — no server-to-server callback
151 + // needed, so it works for local/firewalled sites too.
152 + add_action( 'admin_init', array( $this, 'maybe_handle_hub_return' ) );
153 +
154 + // An attached admin who is DELETED (or removed from the blog) never
155 + // runs disconnect(), so the site-level attached mirror would report
156 + // hub:true forever. deleted_user fires after both wp_delete_user()
157 + // and wpmu_delete_user() drop the user, so a plain recompute is
158 + // honest there. remove_user_from_blog is core's only removal action
159 + // and fires BEFORE removal, so its handler clears the departing
160 + // user's record before recomputing (see Mcp_Hub::handle_user_removed).
161 + add_action( 'deleted_user', array( Mcp_Hub::class, 'refresh_site_attached' ) );
162 + add_action( 'remove_user_from_blog', array( Mcp_Hub::class, 'handle_user_removed' ) );
109 163 }
110 164
111 165 /**
166 + * Handle the browser landing back from the Hub after a connect. Idempotent
167 + * and safe to run on every admin page load: it only acts when the return
168 + * markers are present and the nonce verifies.
169 + */
170 + public function maybe_handle_hub_return(): void {
171 + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- auth is the signed HMAC nonce below, not a WP nonce; this is a read-only routing check.
172 + $nonce = isset( $_GET['xspeed_hub_nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['xspeed_hub_nonce'] ) ) : '';
173 + $email = isset( $_GET['xspeed_hub_email'] ) ? sanitize_email( wp_unslash( $_GET['xspeed_hub_email'] ) ) : '';
174 +
175 + /*
176 + * Trigger on the signed nonce, not on `xspeed_connected`.
177 + *
178 + * The Hub bounces the browser back with xspeed_hub_nonce +
179 + * xspeed_hub_email, but it does NOT always append xspeed_connected —
180 + * that marker only survives when the return_url we handed it carried
181 + * one. Gating on it meant a real, correctly-signed return was ignored:
182 + * the attach was never recorded, the params were never stripped, and
183 + * the card kept showing "Not connected" while the nonce sat in the
184 + * address bar. The nonce is the actual proof of a genuine round trip,
185 + * so it is what this handler keys on. (FBS-84086)
186 + */
187 + if ( '' === $nonce && empty( $_GET['xspeed_connected'] ) ) {
188 + return;
189 + }
190 + // phpcs:enable WordPress.Security.NonceVerification.Recommended
191 +
192 + if ( ! current_user_can( 'manage_options' ) ) {
193 + return;
194 + }
195 +
196 + // Verify OUR own signed nonce (proves the round-trip went through the
197 + // Hub with a token we minted), then record the connection.
198 + if ( '' !== $nonce ) {
199 + $verified = Mcp_Hub::verify_attach_nonce( $nonce );
200 + if ( null !== $verified ) {
201 + $uid = isset( $verified['user_id'] ) ? (int) $verified['user_id'] : get_current_user_id();
202 + Mcp_Hub::mark_attached( $email, $uid ?: null );
203 + }
204 + }
205 +
206 + // ALWAYS strip the one-time return markers from the URL and redirect to
207 + // the clean address. These params are single-use; if they persist in the
208 + // browser URL, a later reload re-triggers the "just connected" path and
209 + // flashes a stale connected state even after the user has disconnected.
210 + $clean = remove_query_arg( array( 'xspeed_connected', 'xspeed_hub_nonce', 'xspeed_hub_email' ) );
211 +
212 + // The setup wizard keeps its current step in component state, so a
213 + // redirect remounts it at step 1 — dumping the user back at the START of
214 + // onboarding immediately after they finished its LAST step. Carry a
215 + // durable hint so the wizard resumes on Connect instead. It's a plain
216 + // step marker, not an auth signal (the nonce above did that job), and
217 + // it's safe to leave in the URL: re-loading it just re-opens the same
218 + // step rather than re-running the connect path. (PM feedback)
219 + if ( false !== strpos( (string) $clean, 'page=' . Onboarding::PAGE_SLUG ) ) {
220 + $clean = add_query_arg( 'xspeed_step', 'connect', $clean );
221 + }
222 +
223 + wp_safe_redirect( $clean );
224 + exit;
225 + }
226 +
227 + /**
112 228 * Flush rewrites once when the module first boots so /xspeed/mcp works
113 229 * without a manual permalink re-save. Cheap: gated on a one-shot flag.
114 230 */
115 231 public function activate(): void {
@@ -133,22 +249,40 @@
133 249 'top'
134 250 );
135 251 add_rewrite_rule( '^xspeed/mcp/?$', 'index.php?' . self::QUERY_VAR . '=1', 'top' );
136 252
253 + // Pretty attach-callback endpoint: /xspeed/mcp/attach — the hub POSTs
254 + // the signed nonce here to verify + fetch the token. Uses the plugin's
255 + // own rewrite (consistent with the MCP URL, survives hosts that block
256 + // /wp-json). Placed BEFORE the token rule would never match "attach"
257 + // (that rule requires 64 hex chars), so ordering is safe.
258 + add_rewrite_rule( '^xspeed/mcp/attach/?$', 'index.php?' . self::ATTACH_QUERY_VAR . '=1', 'top' );
259 +
137 260 // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place the
138 - // `.well-known` segment BEFORE the resource path, so a resource at
261 + // `.well-known` segment BEFORE the resource path, so our resource at
139 262 // /xspeed/mcp is discovered at BOTH:
140 263 // /.well-known/oauth-protected-resource (root form)
141 264 // /.well-known/oauth-protected-resource/xspeed/mcp (path-suffixed)
142 - // Real clients (e.g. Claude Desktop) request the path-suffixed form;
143 - // serving only the root form 404s them and the connection aborts. The
144 - // optional `(?:/.*)?` tail matches both without caring about the exact
145 - // resource path (we only serve one resource).
265 + // Real clients (Claude Desktop among them) request the path-suffixed
266 + // form; serving only the root form 404s them and the connection aborts.
267 + //
268 + // Both are matched EXACTLY. A `(?:/.*)?` tail covers the same two URLs
269 + // in one rule, but also matches every OTHER plugin's discovery URL on
270 + // the same site — and WordPress matches rewrite rules in table order
271 + // rather than by specificity, so a sibling's own exact rule never gets
272 + // reached. Its clients then receive OUR metadata, find a resource and
273 + // issuer that do not match what they are connecting to, and abort
274 + // before the login screen.
146 275 add_rewrite_rule(
147 - '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$',
276 + '^\\.well-known/oauth-(protected-resource|authorization-server)/?$',
148 277 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
149 278 'top'
150 279 );
280 + add_rewrite_rule(
281 + '^\\.well-known/oauth-(protected-resource|authorization-server)/xspeed/mcp/?$',
282 + 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
283 + 'top'
284 + );
151 285
152 286 // Browser-facing OAuth consent page — served OUTSIDE REST so cookie
153 287 // auth (is_user_logged_in) works after the wp-login round-trip.
154 288 add_rewrite_rule( '^xspeed/authorize/?$', 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', 'top' );
@@ -158,17 +292,11 @@
158 292 // flushed under an older build (which had /xspeed/mcp but not the
159 293 // later /xspeed/authorize + /.well-known rules) keeps that first rule,
160 294 // so the guard never fires and OAuth discovery 404s forever. Guard on
161 295 // the full set so any newly-added rule triggers a re-flush.
162 - $expected = array(
163 - '^xspeed/mcp/([a-f0-9]{64})/?$',
164 - '^xspeed/mcp/?$',
165 - '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$',
166 - '^xspeed/authorize/?$',
167 - );
168 296 $rules = get_option( 'rewrite_rules' );
169 297 if ( is_array( $rules ) ) {
170 - foreach ( $expected as $rule ) {
298 + foreach ( self::REWRITE_RULES as $rule ) {
171 299 if ( ! isset( $rules[ $rule ] ) ) {
172 300 flush_rewrite_rules( false );
173 301 break;
174 302 }
@@ -176,8 +304,41 @@
176 304 }
177 305 }
178 306
179 307 /**
308 + * True when a request for our discovery URL can reach WordPress at all.
309 + *
310 + * Since maybe_handle_pretty_endpoint() claims the document by REQUEST
311 + * PATH, a sibling plugin winning the rewrite match no longer matters —
312 + * we answer either way. What still breaks the pretty URL is there being
313 + * no rewrite for it in the first place (plain permalinks), because then
314 + * nothing routes the path to index.php and parse_request never runs.
315 + *
316 + * Blind to upstream interception: a host that owns the /.well-known/
317 + * prefix (an nginx ACME block, an edge redirect rule) answers before
318 + * WordPress loads, and WP cannot see that. Use the
319 + * `xspeed_mcp_resource_metadata_url` filter on such hosts.
320 + */
321 + public static function wellknown_rewrites_active(): bool {
322 + $rules = get_option( 'rewrite_rules' );
323 + if ( ! is_array( $rules ) || array() === $rules ) {
324 + return false;
325 + }
326 +
327 + // Any rule that routes our discovery path to index.php will do — ours
328 + // or a sibling's — because the path check inside the handler decides
329 + // the outcome once the request lands.
330 + $probe = '.well-known/oauth-protected-resource';
331 + foreach ( $rules as $pattern => $target ) {
332 + if ( preg_match( '#' . str_replace( '#', '\\#', $pattern ) . '#', $probe ) ) {
333 + return true;
334 + }
335 + }
336 +
337 + return false;
338 + }
339 +
340 + /**
180 341 * @param string[] $vars Registered query vars.
181 342 * @return string[]
182 343 */
183 344 public function register_query_var( array $vars ): array {
@@ -184,12 +345,50 @@
184 345 $vars[] = self::QUERY_VAR;
185 346 $vars[] = self::TOKEN_QUERY_VAR;
186 347 $vars[] = self::WELLKNOWN_QUERY_VAR;
187 348 $vars[] = self::AUTHORIZE_QUERY_VAR;
349 + $vars[] = self::ATTACH_QUERY_VAR;
188 350 return $vars;
189 351 }
190 352
191 353 /**
354 + * Which discovery document the CURRENT request path asks for, if any.
355 + *
356 + * Claims only URLs that are unambiguously ours, mirroring the rewrite
357 + * rules exactly: the bare root form, and the RFC 9728 §3.1 path-suffixed
358 + * form naming our own resource (`/xspeed/mcp`). A suffix belonging to a
359 + * sibling plugin is deliberately NOT claimed — answering
360 + * `/.well-known/oauth-protected-resource/betterlinks/mcp` with xSpeed
361 + * metadata is the same bug that broke this site, just pointed the other
362 + * way.
363 + *
364 + * @return string 'protected-resource', 'authorization-server', or ''.
365 + */
366 + private function wellknown_doc_from_path(): string {
367 + $uri = isset( $_SERVER['REQUEST_URI'] )
368 + ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) )
369 + : '';
370 + if ( '' === $uri ) {
371 + return '';
372 + }
373 +
374 + $path = (string) wp_parse_url( $uri, PHP_URL_PATH );
375 +
376 + // Sites in a subdirectory carry that prefix on every request.
377 + $home = (string) wp_parse_url( home_url(), PHP_URL_PATH );
378 + if ( '' !== $home && '/' !== $home && 0 === strpos( $path, $home ) ) {
379 + $path = substr( $path, strlen( $home ) );
380 + }
381 +
382 + $path = trim( $path, '/' );
383 +
384 + $pattern = '#^\.well-known/oauth-(protected-resource|authorization-server)'
385 + . '(?:/xspeed/mcp)?$#';
386 +
387 + return preg_match( $pattern, $path, $m ) ? $m[1] : '';
388 + }
389 +
390 + /**
192 391 * Serve the MCP endpoint on the pretty path. Runs on parse_request so
193 392 * it fires before the main query, and short-circuits WP entirely.
194 393 *
195 394 * @param \WP $wp The WP request object.
@@ -195,10 +394,24 @@
195 394 * @param \WP $wp The WP request object.
196 395 */
197 396 public function maybe_handle_pretty_endpoint( $wp ): void {
198 397 // OAuth discovery documents (served at the site root).
199 - if ( ! empty( $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] ) ) {
398 + //
399 + // Read the doc name from the REQUEST PATH, not just our query var.
400 + // `add_rewrite_rule( …, 'top' )` only means "top at the moment it
401 + // runs", so whichever MCP plugin hooks `init` last ends up first in
402 + // the table — an order set by plugin load order, which no plugin
403 + // controls. A sibling's catch-all
404 + // (`…(protected-resource|authorization-server)(?:/.*)?/?$`) then wins
405 + // the match and our query var is never set, even though the URL is
406 + // unambiguously ours. Observed live with two different plugins on one
407 + // site. parse_request runs AFTER matching, so the path is the one
408 + // signal no sibling rule can take away from us.
409 + $doc = $this->wellknown_doc_from_path();
410 + if ( '' === $doc && ! empty( $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] ) ) {
200 411 $doc = (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ];
412 + }
413 + if ( '' !== $doc ) {
201 414 $data = 'authorization-server' === $doc
202 415 ? Mcp_OAuth::authorization_server_metadata()
203 416 : Mcp_OAuth::protected_resource_metadata();
204 417 status_header( 200 );
@@ -208,8 +421,27 @@
208 421 echo wp_json_encode( $data );
209 422 exit;
210 423 }
211 424
425 + // Pretty attach-callback: /xspeed/mcp/attach. The hub POSTs the signed
426 + // nonce; we verify it and return this site's URL + token. Auth is the
427 + // nonce itself (admin-minted, HMAC-signed), so no credential needed.
428 + if ( ! empty( $wp->query_vars[ self::ATTACH_QUERY_VAR ] ) ) {
429 + $body = json_decode( (string) file_get_contents( 'php://input' ), true );
430 + $nonce = is_array( $body ) && isset( $body['nonce'] ) ? (string) $body['nonce'] : '';
431 + $result = Mcp_Hub::verify_attach_nonce( $nonce );
432 + header( 'Content-Type: application/json; charset=utf-8' );
433 + header( 'Cache-Control: no-store' );
434 + if ( null === $result ) {
435 + status_header( 403 );
436 + echo wp_json_encode( array( 'error' => 'invalid_or_expired_attach_request' ) );
437 + } else {
438 + status_header( 200 );
439 + echo wp_json_encode( $result );
440 + }
441 + exit;
442 + }
443 +
212 444 // Browser-facing OAuth consent page (cookie auth applies here).
213 445 if ( ! empty( $wp->query_vars[ self::AUTHORIZE_QUERY_VAR ] ) ) {
214 446 $this->handle_authorize_page();
215 447 return;
@@ -259,8 +491,26 @@
259 491 'permission_callback' => '__return_true',
260 492 )
261 493 );
262 494
495 + // --- Public scan signals -----------------------------------------
496 + // One tiny unauthenticated JSON body for external audit tools (the
497 + // speed scanner on xspeedcache.com): plugin version, whether the MCP
498 + // server is connected, and whether the site is attached to xSpeed
499 + // Hub. Everything except `hub` is already publicly discoverable —
500 + // the cache signature carries the version and /mcp answers 401 when
501 + // connected — and `hub` is a bare boolean. No tokens, accounts or
502 + // emails leave through this route.
503 + register_rest_route(
504 + self::NS,
505 + '/signals',
506 + array(
507 + 'methods' => 'GET',
508 + 'callback' => array( $this, 'rest_signals' ),
509 + 'permission_callback' => '__return_true',
510 + )
511 + );
512 +
263 513 // --- Admin-only management routes (dashboard) --------------------
264 514 register_rest_route(
265 515 self::NS,
266 516 '/mcp/connection',
@@ -271,8 +521,34 @@
271 521 )
272 522 );
273 523 register_rest_route(
274 524 self::NS,
525 + '/mcp/activity',
526 + array(
527 + 'methods' => 'GET',
528 + 'callback' => array( $this, 'rest_activity' ),
529 + 'permission_callback' => array( $this, 'admin_permission' ),
530 + 'args' => array(
531 + 'limit' => array(
532 + 'type' => 'integer',
533 + 'required' => false,
534 + 'default' => 50,
535 + 'description' => 'Maximum entries to return (newest first).',
536 + ),
537 + ),
538 + )
539 + );
540 + register_rest_route(
541 + self::NS,
542 + '/mcp/activity/clear',
543 + array(
544 + 'methods' => 'POST',
545 + 'callback' => array( $this, 'rest_activity_clear' ),
546 + 'permission_callback' => array( $this, 'admin_permission' ),
547 + )
548 + );
549 + register_rest_route(
550 + self::NS,
275 551 '/mcp/connect',
276 552 array(
277 553 'methods' => 'POST',
278 554 'callback' => array( $this, 'rest_connect' ),
@@ -328,8 +604,72 @@
328 604 'permission_callback' => array( $this, 'admin_permission' ),
329 605 )
330 606 );
331 607
608 + // --- xSpeed Hub (multi-site) attach routes ------------------------
609 + register_rest_route(
610 + self::NS,
611 + '/mcp/hub',
612 + array(
613 + 'methods' => 'GET',
614 + 'callback' => array( $this, 'rest_hub_status' ),
615 + 'permission_callback' => array( $this, 'admin_permission' ),
616 + )
617 + );
618 + register_rest_route(
619 + self::NS,
620 + '/mcp/hub/token',
621 + array(
622 + 'methods' => 'POST',
623 + 'callback' => array( $this, 'rest_hub_token' ),
624 + 'permission_callback' => array( $this, 'admin_permission' ),
625 + )
626 + );
627 + register_rest_route(
628 + self::NS,
629 + '/mcp/hub/attached',
630 + array(
631 + 'methods' => 'POST',
632 + 'callback' => array( $this, 'rest_hub_attached' ),
633 + 'permission_callback' => array( $this, 'admin_permission' ),
634 + 'args' => array(
635 + 'account_email' => array(
636 + 'type' => 'string',
637 + 'required' => true,
638 + 'description' => 'The hub account email this site was attached to.',
639 + ),
640 + ),
641 + )
642 + );
643 + register_rest_route(
644 + self::NS,
645 + '/mcp/hub/disconnect',
646 + array(
647 + 'methods' => 'POST',
648 + 'callback' => array( $this, 'rest_hub_disconnect' ),
649 + 'permission_callback' => array( $this, 'admin_permission' ),
650 + )
651 + );
652 + // OAuth-attach callback: the hub calls this with the signed nonce the
653 + // plugin issued. Auth is the nonce itself (no pre-shared token), so
654 + // permission_callback is open — the handler validates the nonce.
655 + register_rest_route(
656 + self::NS,
657 + '/mcp/attach',
658 + array(
659 + 'methods' => 'POST',
660 + 'callback' => array( $this, 'rest_hub_attach_callback' ),
661 + 'permission_callback' => '__return_true',
662 + 'args' => array(
663 + 'nonce' => array(
664 + 'type' => 'string',
665 + 'required' => true,
666 + 'description' => 'The signed attach nonce the plugin issued.',
667 + ),
668 + ),
669 + )
670 + );
671 +
332 672 // --- OAuth 2.1 authorization server (the "paste a URL only" path) -
333 673 // Discovery, dynamic client registration, and the token endpoint are
334 674 // all public (permission enforced inside): a client must reach them
335 675 // BEFORE it holds any credential. The authorize endpoint gates on a
@@ -355,13 +695,49 @@
355 695 'permission_callback' => '__return_true',
356 696 )
357 697 );
358 698
699 + // --- OAuth discovery, REST fallback ------------------------------
700 + // The canonical documents live at /.well-known/… via rewrite rules.
701 + // Many hosts own that prefix for ACME/Let's Encrypt (an nginx
702 + // `location ^~ /.well-known` block, or an edge redirect rule), which
703 + // swallows the request before WordPress ever runs — the pretty URL
704 + // then 404s or redirects to the homepage no matter how the plugin is
705 + // configured, and OAuth discovery dead-ends with no way back.
706 + // Serving the same two documents under /wp-json puts them on a path
707 + // no ACME tooling claims, so discovery still completes there.
708 + register_rest_route(
709 + self::NS,
710 + '/mcp/.well-known/oauth-protected-resource',
711 + array(
712 + 'methods' => 'GET',
713 + 'callback' => array( $this, 'rest_protected_resource_metadata' ),
714 + 'permission_callback' => '__return_true',
715 + )
716 + );
717 + register_rest_route(
718 + self::NS,
719 + '/mcp/.well-known/oauth-authorization-server',
720 + array(
721 + 'methods' => 'GET',
722 + 'callback' => array( $this, 'rest_authorization_server_metadata' ),
723 + 'permission_callback' => '__return_true',
724 + )
725 + );
726 +
359 727 // --- MCP-token-only tool routes (optional hosted-broker path) ----
360 728 $tool_perm = array( Mcp_Auth::class, 'permission' );
361 729 register_rest_route(
362 730 self::NS,
363 - '/mcp/tool/(?P<tool>[a-z_]+)',
731 + // [a-z0-9_-]+ — the HYPHEN is the one that matters, not the digit.
732 + // Generated tool names carry their module slug verbatim, and 33 of
733 + // the 92 in the catalog have a hyphenated slug
734 + // (xspeed_cache-404_status, xspeed_migration-pro_apply,
735 + // xspeed_smart-predict_status …). Every one of those returned
736 + // rest_no_route through the broker path. The earlier widening to
737 + // [a-z0-9_]+ un-blocked nothing: the only digit-bearing name is
738 + // cache-404, whose problem was the hyphen. (QA on #158) */
739 + '/mcp/tool/(?P<tool>[a-z0-9_-]+)',
364 740 array(
365 741 array(
366 742 'methods' => 'GET',
367 743 'callback' => array( $this, 'rest_tool' ),
@@ -401,8 +777,35 @@
401 777 return $response;
402 778 }
403 779
404 780 /**
781 + * GET /signals — the public scan-signals body. See the route
782 + * registration for what may (and may not) leave through it.
783 + *
784 + * @return \WP_REST_Response
785 + */
786 + public function rest_signals() {
787 + $signals = array(
788 + 'xspeed' => XSPEED_VERSION,
789 + 'mcp' => '' !== Mcp_Pairing::site_token(),
790 + 'hub' => Mcp_Hub::site_attached(),
791 + );
792 +
793 + /**
794 + * Filter the public scan signals.
795 + *
796 + * Lets an add-on append its own public facts (e.g. its version
797 + * under `pro`). Values returned here are served UNAUTHENTICATED —
798 + * never add tokens, accounts, emails, or paths.
799 + *
800 + * @param array<string,mixed> $signals The signals body.
801 + */
802 + $signals = (array) apply_filters( 'xspeed_scan_signals', $signals );
803 +
804 + return rest_ensure_response( $signals );
805 + }
806 +
807 + /**
405 808 * GET /mcp/connection — pairing status for the dashboard.
406 809 *
407 810 * @param \WP_REST_Request $request Unused.
408 811 * @return \WP_REST_Response
@@ -412,8 +815,44 @@
412 815 return rest_ensure_response( Mcp_Pairing::public_status() );
413 816 }
414 817
415 818 /**
819 + * GET /mcp/activity — the audit trail of AI tool calls.
820 + *
821 + * @param \WP_REST_Request $request Carries the optional limit.
822 + * @return \WP_REST_Response|\WP_Error
823 + */
824 + public function rest_activity( \WP_REST_Request $request ) {
825 + $limit = (int) $request->get_param( 'limit' );
826 +
827 + return rest_ensure_response(
828 + array(
829 + 'entries' => Mcp_Activity_Log::entries( $limit > 0 ? $limit : 50 ),
830 + 'summary' => Mcp_Activity_Log::summary(),
831 + )
832 + );
833 + }
834 +
835 + /**
836 + * POST /mcp/activity/clear — wipe the audit trail.
837 + *
838 + * @param \WP_REST_Request $request Unused.
839 + * @return \WP_REST_Response|\WP_Error
840 + */
841 + public function rest_activity_clear( \WP_REST_Request $request ) {
842 + unset( $request );
843 + $cleared = Mcp_Activity_Log::clear();
844 +
845 + return rest_ensure_response(
846 + array(
847 + 'cleared' => $cleared,
848 + 'entries' => Mcp_Activity_Log::entries(),
849 + 'summary' => Mcp_Activity_Log::summary(),
850 + )
851 + );
852 + }
853 +
854 + /**
416 855 * POST /mcp/connect — mint a connection token.
417 856 *
418 857 * @param \WP_REST_Request $request Unused.
419 858 * @return \WP_REST_Response|\WP_Error
@@ -468,11 +907,130 @@
468 907 unset( $request );
469 908 return rest_ensure_response( Mcp_Pairing::disconnect() );
470 909 }
471 910
911 + // -- xSpeed Hub (multi-site) handlers ----------------------------------
912 +
913 + /**
914 + * GET /mcp/hub — hub-link status + the Method-1 paste-in values.
915 + *
916 + * @param \WP_REST_Request $request Unused.
917 + * @return \WP_REST_Response
918 + */
919 + public function rest_hub_status( \WP_REST_Request $request ) {
920 + // Self-heal from the Hub (source of truth) so the connected badge is
921 + // reliable even if the attach callback never fired. Force a fresh check
922 + // when the panel asks via the X-XSpeed-Reconcile header (e.g. the admin
923 + // returned to the tab after connecting).
924 + $force = '1' === (string) $request->get_header( 'x_xspeed_reconcile' );
925 + Mcp_Hub::reconcile_with_hub( $force );
926 + return rest_ensure_response( Mcp_Hub::public_status() );
927 + }
928 +
929 + /**
930 + * POST /mcp/hub/token — ensure a site_token exists and return the
931 + * paste-in values (this site's URL + token) for the hub's Add-site form.
932 + *
933 + * @param \WP_REST_Request $request Unused.
934 + * @return \WP_REST_Response
935 + */
936 + public function rest_hub_token( \WP_REST_Request $request ) {
937 + unset( $request );
938 + return rest_ensure_response( Mcp_Hub::generate_token() );
939 + }
940 +
941 + /**
942 + * POST /mcp/hub/attached — record which hub account this site is
943 + * attached to (bookkeeping for the panel's status line).
944 + *
945 + * @param \WP_REST_Request $request Carries account_email.
946 + * @return \WP_REST_Response
947 + */
948 + public function rest_hub_attached( \WP_REST_Request $request ) {
949 + $email = sanitize_email( (string) $request->get_param( 'account_email' ) );
950 + return rest_ensure_response( Mcp_Hub::mark_attached( $email ) );
951 + }
952 +
953 + /**
954 + * POST /mcp/hub/disconnect — clear the local hub-link bookkeeping.
955 + *
956 + * @param \WP_REST_Request $request Unused.
957 + * @return \WP_REST_Response
958 + */
959 + public function rest_hub_disconnect( \WP_REST_Request $request ) {
960 + unset( $request );
961 + return rest_ensure_response( Mcp_Hub::disconnect() );
962 + }
963 +
964 + /**
965 + * POST /mcp/attach — the OAuth-attach callback. The hub presents the
966 + * signed nonce the plugin issued; on success we return this site's URL +
967 + * token so the hub can record it. Nonce is the auth (admin-minted,
968 + * HMAC-signed, time-bound), so no pre-shared token is required.
969 + *
970 + * @param \WP_REST_Request $request Carries the nonce.
971 + * @return \WP_REST_Response|\WP_Error
972 + */
973 + public function rest_hub_attach_callback( \WP_REST_Request $request ) {
974 + $nonce = (string) $request->get_param( 'nonce' );
975 + $result = Mcp_Hub::verify_attach_nonce( $nonce );
976 + if ( null === $result ) {
977 + return new \WP_Error(
978 + 'xspeed_attach_invalid',
979 + __( 'Invalid or expired attach request.', 'xspeed' ),
980 + array( 'status' => 403 )
981 + );
982 + }
983 + // A valid nonce proves this is a real hub-initiated attach, so record it
984 + // now — the hub passes the account email so the panel can show
985 + // "Connected via <email>". The nonce carries the minting admin's user
986 + // id (no WP session exists in this server-to-server call), so the state
987 + // is recorded PER-USER — each admin sees their own connection.
988 + $account_email = sanitize_email( (string) $request->get_param( 'account_email' ) );
989 + $user_id = isset( $result['user_id'] ) ? (int) $result['user_id'] : 0;
990 + Mcp_Hub::mark_attached( $account_email, $user_id ?: null );
991 +
992 + // The hub only needs the credential; don't leak the internal user id.
993 + unset( $result['user_id'] );
994 + return rest_ensure_response( $result );
995 + }
996 +
472 997 // -- OAuth 2.1 handlers ------------------------------------------------
473 998
474 999 /**
1000 + * GET /mcp/.well-known/oauth-protected-resource — RFC 9728 metadata.
1001 + *
1002 + * Byte-identical to what the /.well-known rewrite serves; both call the
1003 + * same builder so the two locations can never drift.
1004 + *
1005 + * @return \WP_REST_Response
1006 + */
1007 + public function rest_protected_resource_metadata(): \WP_REST_Response {
1008 + return $this->discovery_response( Mcp_OAuth::protected_resource_metadata() );
1009 + }
1010 +
1011 + /**
1012 + * GET /mcp/.well-known/oauth-authorization-server — RFC 8414 metadata.
1013 + *
1014 + * @return \WP_REST_Response
1015 + */
1016 + public function rest_authorization_server_metadata(): \WP_REST_Response {
1017 + return $this->discovery_response( Mcp_OAuth::authorization_server_metadata() );
1018 + }
1019 +
1020 + /**
1021 + * Wrap a discovery document in a public, cacheable REST response.
1022 + *
1023 + * @param array<string,mixed> $data The metadata document.
1024 + * @return \WP_REST_Response
1025 + */
1026 + private function discovery_response( array $data ): \WP_REST_Response {
1027 + $response = new \WP_REST_Response( $data, 200 );
1028 + $response->header( 'Cache-Control', 'public, max-age=3600' );
1029 + return $response;
1030 + }
1031 +
1032 + /**
475 1033 * POST /mcp/oauth/register — RFC 7591 dynamic client registration.
476 1034 *
477 1035 * @param \WP_REST_Request $request JSON body with redirect_uris.
478 1036 * @return \WP_REST_Response|\WP_Error
@@ -605,8 +1163,9 @@
605 1163 $args[ $k ] = $v;
606 1164 }
607 1165 }
608 1166
1167 + Mcp_Tools::set_channel( 'broker' );
609 1168 $result = Mcp_Tools::invoke( $tool, $args );
610 1169 if ( is_wp_error( $result ) ) {
611 1170 return $result;
612 1171 }
@@ -763,8 +1322,27 @@
763 1322 'shortdesc' => 'Show MCP connection status and the paste-in endpoint URL.',
764 1323 'synopsis' => array(),
765 1324 ),
766 1325 array(
1326 + 'name' => 'xspeed mcp activity',
1327 + 'callback' => array( $this, 'cli_activity' ),
1328 + 'shortdesc' => 'List recent MCP tool calls (the AI audit trail).',
1329 + 'synopsis' => array(
1330 + array(
1331 + 'name' => 'limit',
1332 + 'type' => 'assoc',
1333 + 'optional' => true,
1334 + 'description' => 'Maximum entries to show (default 20).',
1335 + ),
1336 + array(
1337 + 'name' => 'clear',
1338 + 'type' => 'flag',
1339 + 'optional' => true,
1340 + 'description' => 'Wipe the audit trail instead of listing it.',
1341 + ),
1342 + ),
1343 + ),
1344 + array(
767 1345 'name' => 'xspeed mcp connect',
768 1346 'callback' => array( $this, 'cli_connect' ),
769 1347 'shortdesc' => 'Generate a connection token for this site\'s MCP endpoint.',
770 1348 'synopsis' => array(
@@ -817,8 +1395,59 @@
817 1395 }
818 1396 }
819 1397
820 1398 /**
1399 + * `wp xspeed mcp activity` — read (or clear) the AI audit trail.
1400 + *
1401 + * @param array $args Positional args (unused).
1402 + * @param array $assoc --limit=<n>, --clear.
1403 + */
1404 + public function cli_activity( array $args, array $assoc ): void {
1405 + unset( $args );
1406 +
1407 + if ( ! empty( $assoc['clear'] ) ) {
1408 + if ( ! Mcp_Activity_Log::clear() ) {
1409 + // Reached via MCP run_command — the assistant is asking to
1410 + // erase the record of its own calls. Mcp_Activity_Log::clear()
1411 + // declines and logs the attempt; say so plainly.
1412 + \WP_CLI::error( 'The MCP activity log cannot be cleared from an MCP tool call. Clear it from the xSpeed dashboard or from WP-CLI on the server.' );
1413 + return;
1414 + }
1415 + \WP_CLI::success( 'MCP activity log cleared.' );
1416 + return;
1417 + }
1418 +
1419 + $limit = isset( $assoc['limit'] ) ? (int) $assoc['limit'] : 20;
1420 + $summary = Mcp_Activity_Log::summary();
1421 + $entries = Mcp_Activity_Log::entries( $limit > 0 ? $limit : 20 );
1422 +
1423 + \WP_CLI::log( sprintf( '%-18s %d', 'total_calls', $summary['total'] ) );
1424 + \WP_CLI::log( sprintf( '%-18s %d', 'failed', $summary['failed'] ) );
1425 + \WP_CLI::log( sprintf( '%-18s %s', 'top_tool', '' === $summary['top_tool'] ? '-' : $summary['top_tool'] ) );
1426 +
1427 + if ( empty( $entries ) ) {
1428 + \WP_CLI::log( '' );
1429 + \WP_CLI::log( 'No MCP tool calls recorded yet.' );
1430 + return;
1431 + }
1432 +
1433 + \WP_CLI::log( '' );
1434 + foreach ( $entries as $entry ) {
1435 + \WP_CLI::log(
1436 + sprintf(
1437 + '%s %-22s %-5s %-6s %s%s',
1438 + gmdate( 'Y-m-d H:i:s', $entry['ts'] ),
1439 + $entry['tool'],
1440 + $entry['scope'],
1441 + $entry['ok'] ? 'ok' : 'FAIL',
1442 + $entry['args'],
1443 + '' === $entry['error'] ? '' : ' — ' . $entry['error']
1444 + )
1445 + );
1446 + }
1447 + }
1448 +
1449 + /**
821 1450 * `wp xspeed mcp connect` — mint a token and print the paste-in URL.
822 1451 *
823 1452 * @param array $args Positional args (unused).
824 1453 * @param array $assoc Associative args (unused).
@@ -861,6 +1490,27 @@
861 1490 public function cli_disconnect( array $args, array $assoc ): void {
862 1491 unset( $args, $assoc );
863 1492 Mcp_Pairing::disconnect();
864 1493 \WP_CLI::success( 'Disconnected and revoked the MCP token.' );
1494 + }
1495 +
1496 + /**
1497 + * MCP is on when a connection token exists -- it has no `enabled`
1498 + * setting, so the sidebar counted the AI group as empty on a site with
1499 + * a live read-write AI connection. Reads the same
1500 + * `Mcp_Pairing::public_status()` the CLI and the panel do, so the count
1501 + * cannot disagree with the badge on the panel. (#363)
1502 + */
1503 + public function is_active(): ?bool {
1504 + $status = Mcp_Pairing::public_status();
1505 + return ! empty( $status['connected'] );
1506 + }
1507 +
1508 + /**
1509 + * MCP has no on/off setting -- it is on when a connection exists.
1510 + */
1511 + public function active_reason(): ?string {
1512 + return $this->is_active()
1513 + ? __( 'An AI assistant is connected to this site. This module counts as on whenever a connection token exists, rather than having its own on/off setting.', 'xspeed' )
1514 + : __( 'No AI assistant is connected. This module counts as on once you connect one.', 'xspeed' );
865 1515 }
866 1516 }