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/McpModule.php +916 -42 1.1.0 → 1.3.7 View file →
@@ -36,9 +36,11 @@
36 36 declare(strict_types=1);
37 37
38 38 namespace XSpeed\Modules\Mcp;
39 39
40 +use XSpeed\Activity_Log;
40 41 use XSpeed\Module;
42 +use XSpeed\Onboarding;
41 43
42 44 defined( 'ABSPATH' ) || exit;
43 45
44 46 final class McpModule extends Module {
@@ -46,11 +48,77 @@
46 48 public const SLUG = 'mcp';
47 49 public const TIER = self::TIER_FREE;
48 50 public const VERSION = '1.0.0';
49 51
50 - /** REST namespace shared with Free. */
51 - private const NS = 'xspeed/v1';
52 + /**
53 + * Every rewrite rule this module registers, in registration order.
54 + *
55 + * Single source of truth: add_rewrite() registers these, and the self-heal
56 + * guard re-flushes when any is missing from the stored table. They were two
57 + * hand-maintained lists before, which is a silent drift risk — a rule
58 + * dropped from one and not the other leaves the guard restoring a rule
59 + * nothing registers, or never firing for one that is registered.
60 + *
61 + * @var string[] Rewrite regexes. The query each maps to is built in
62 + * add_rewrite(), which also fixes their order.
63 + */
64 + public const REWRITE_RULES = array(
65 + '^xspeed/mcp/([a-f0-9]{64})/?$',
66 + '^xspeed/mcp/?$',
67 + '^xspeed/mcp/attach/?$',
68 + // OAuth discovery. RFC 9728 §3.1 / RFC 8414 §3.1 put the
69 + // `.well-known` segment BEFORE the resource/issuer path, and both of
70 + // our identifiers are /xspeed/mcp — so this pair of URLs, and only
71 + // this pair, is ours. It names our own path explicitly: a catch-all
72 + // tail here also matched other MCP plugins' discovery URLs on the
73 + // same site and answered them with our metadata, which broke their
74 + // connectors. The bare root form is registered conditionally and so
75 + // lives apart, in ROOT_DISCOVERY_RULE.
76 + '^\.well-known/oauth-(protected-resource|authorization-server)/xspeed/mcp/?$',
77 + '^xspeed/authorize/?$',
78 + );
52 79
80 + /**
81 + * The root-form discovery rule — registered CONDITIONALLY, which is why
82 + * it is not in REWRITE_RULES.
83 + *
84 + * It is the address a client built to the 2025-03-26 MCP spec looks at,
85 + * and the only address it looks at; current clients read the
86 + * protected-resource document first and follow it to the path form. So
87 + * dropping it outright would cut off older clients on every site,
88 + * including the single-plugin sites where the collision #266 exists to
89 + * fix never happened. We answer it while it is uncontested and stand
90 + * down the moment another plugin's rule claims it —
91 + * root_discovery_contested().
92 + */
93 + public const ROOT_DISCOVERY_RULE = '^\.well-known/oauth-(protected-resource|authorization-server)/?$';
94 +
95 + /**
96 + * Rules earlier builds registered that we never register again under any
97 + * condition. The self-heal guard flushes once when it finds OUR copy of
98 + * one still in the stored table.
99 + *
100 + * The catch-all below shipped in an intermediate build and matched every
101 + * path-suffixed discovery URL on the site, including other MCP plugins'
102 + * (#264). Nothing brings it back, so its removal is unconditional —
103 + * unlike ROOT_DISCOVERY_RULE, which is a rule we still register when the
104 + * root URL is uncontested and therefore cannot live in this list.
105 + *
106 + * Ownership is read from the rule's TARGET, never from the regex alone:
107 + * a sibling may register the same regex for its own document, its rule
108 + * comes back from every flush, and a guard that treated that as stale
109 + * would flush on every request forever.
110 + *
111 + * @var string[]
112 + */
113 + public const RETIRED_REWRITE_RULES = array(
114 + '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$',
115 + );
116 +
117 + /** REST namespace shared with Free. Public: Mcp_Server builds the
118 + * discovery fallback URL from it. */
119 + public const NS = 'xspeed/v1';
120 +
53 121 /** Query var flagging a pretty /xspeed/mcp request. */
54 122 private const QUERY_VAR = 'xspeed_mcp';
55 123
56 124 /** Query var carrying the token when embedded in the URL path. */
@@ -77,12 +145,13 @@
77 145 private const ATTACH_QUERY_VAR = 'xspeed_mcp_attach';
78 146
79 147 public function ui_metadata(): array {
80 148 return array(
81 - 'label' => 'MCP Server',
149 + 'label' => __( 'MCP Server', 'xspeed' ),
82 150 'icon' => 'Sparkles',
83 - 'description' => 'Control this site\'s cache from Claude and other AI agents.',
151 + 'description' => __( 'Let Claude or another AI assistant clear the cache, check stats and change settings.', 'xspeed' ),
84 152 'custom_panel' => 'McpPanel',
153 + 'group' => 'ai-agents',
85 154 );
86 155 }
87 156
88 157 /**
@@ -103,16 +172,128 @@
103 172 }
104 173
105 174 public function boot(): void {
106 175 add_action( 'rest_api_init', array( $this, 'register_rest' ) );
176 + add_action( Mcp_Pairing::TOKEN_CHANGED_ACTION, array( Mcp_Hub::class, 'on_token_changed' ) );
107 177
178 + // The only place the body cap can run before WordPress decodes it.
179 + // WP_REST_Server::dispatch() fires rest_pre_dispatch, and only after
180 + // that calls has_valid_params() — which json_decode()s the whole body
181 + // for any application/json request, ahead of the permission callback
182 + // and the handler. A cap inside a handler is therefore a second line,
183 + // not the bound it reads like.
184 + add_filter( 'rest_pre_dispatch', array( $this, 'cap_request_body' ), 10, 3 );
185 +
108 186 // Pretty per-site endpoint: /xspeed/mcp → MCP JSON-RPC handler.
109 - add_action( 'init', array( $this, 'add_rewrite' ) );
187 + // `wp_loaded`, not `init`: add_rewrite() decides whether to claim the
188 + // root discovery URL by looking at the rewrite table, and on `init` that
189 + // view is incomplete -- a sibling MCP plugin hooked at the same priority
190 + // but loaded after us has not registered yet. Root then looks
191 + // uncontested, we register our rule, and the self-heal guard concludes
192 + // nothing is stale, so it never flushes. That is a fixed point: the
193 + // table never converges, and because our rule is the one WordPress
194 + // matches, the sibling never sees the request either.
195 + //
196 + // By `wp_loaded` every init callback on every request type has run, so
197 + // the contested check sees the sibling and the guard flushes once.
198 + // WP_Rewrite::flush_rules() already defers itself to `wp_loaded`, so
199 + // nothing is lost by deciding here, and did_action('wp_loaded') is
200 + // truthy inside this callback, so the flush lands in time for
201 + // parse_request in the same request. (#266 QA)
202 + // Priority 0: still after every `init` callback, but ahead of the
203 + // widely copied `add_action( 'wp_loaded', 'flush_rewrite_rules' )`
204 + // snippet. If such a plugin flushed first it would write a table
205 + // without our rules, our guard would find them missing and flush
206 + // again -- two flushes and two option writes on every request.
207 + add_action( 'wp_loaded', array( $this, 'add_rewrite' ), 0 );
110 208 add_filter( 'query_vars', array( $this, 'register_query_var' ) );
111 - add_action( 'parse_request', array( $this, 'maybe_handle_pretty_endpoint' ) );
209 + // Priority 1: a sibling MCP plugin that also claims /.well-known/ gets
210 + // to answer first at the default priority 10, and whoever answers
211 + // first calls exit(). Running early means the URL is decided by WHOSE
212 + // path it is, not by which plugin happened to load last.
213 + add_action( 'parse_request', array( $this, 'maybe_handle_pretty_endpoint' ), 1 );
214 +
215 + // Hub redirect-return: after the user approves on the Hub, it sends the
216 + // browser back to a plugin admin URL carrying ?xspeed_connected=1 plus
217 + // the account email + the SAME signed nonce we minted. We verify our own
218 + // nonce and mark this admin attached — no server-to-server callback
219 + // needed, so it works for local/firewalled sites too.
220 + add_action( 'admin_init', array( $this, 'maybe_handle_hub_return' ) );
221 +
222 + // An attached admin who is DELETED (or removed from the blog) never
223 + // runs disconnect(), so the site-level attached mirror would report
224 + // hub:true forever. deleted_user fires after both wp_delete_user()
225 + // and wpmu_delete_user() drop the user, so a plain recompute is
226 + // honest there. remove_user_from_blog is core's only removal action
227 + // and fires BEFORE removal, so its handler clears the departing
228 + // user's record before recomputing (see Mcp_Hub::handle_user_removed).
229 + add_action( 'deleted_user', array( Mcp_Hub::class, 'refresh_site_attached' ) );
230 + add_action( 'remove_user_from_blog', array( Mcp_Hub::class, 'handle_user_removed' ) );
112 231 }
113 232
114 233 /**
234 + * Handle the browser landing back from the Hub after a connect. Idempotent
235 + * and safe to run on every admin page load: it only acts when the return
236 + * markers are present and the nonce verifies.
237 + */
238 + public function maybe_handle_hub_return(): void {
239 + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- auth is the signed HMAC nonce below, not a WP nonce; this is a read-only routing check.
240 + $nonce = isset( $_GET['xspeed_hub_nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['xspeed_hub_nonce'] ) ) : '';
241 + $email = isset( $_GET['xspeed_hub_email'] ) ? sanitize_email( wp_unslash( $_GET['xspeed_hub_email'] ) ) : '';
242 +
243 + /*
244 + * Trigger on the signed nonce, not on `xspeed_connected`.
245 + *
246 + * The Hub bounces the browser back with xspeed_hub_nonce +
247 + * xspeed_hub_email, but it does NOT always append xspeed_connected —
248 + * that marker only survives when the return_url we handed it carried
249 + * one. Gating on it meant a real, correctly-signed return was ignored:
250 + * the attach was never recorded, the params were never stripped, and
251 + * the card kept showing "Not connected" while the nonce sat in the
252 + * address bar. The nonce is the actual proof of a genuine round trip,
253 + * so it is what this handler keys on. (FBS-84086)
254 + */
255 + if ( '' === $nonce && empty( $_GET['xspeed_connected'] ) ) {
256 + return;
257 + }
258 + // phpcs:enable WordPress.Security.NonceVerification.Recommended
259 +
260 + if ( ! current_user_can( 'manage_options' ) ) {
261 + return;
262 + }
263 +
264 + // Verify OUR own signed nonce (proves the round-trip went through the
265 + // Hub with a token we minted), then record the connection.
266 + 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();
270 + Mcp_Hub::mark_attached( $email, $uid ?: null );
271 + }
272 + }
273 +
274 + // ALWAYS strip the one-time return markers from the URL and redirect to
275 + // the clean address. These params are single-use; if they persist in the
276 + // browser URL, a later reload re-triggers the "just connected" path and
277 + // flashes a stale connected state even after the user has disconnected.
278 + $clean = remove_query_arg( array( 'xspeed_connected', 'xspeed_hub_nonce', 'xspeed_hub_email' ) );
279 +
280 + // The setup wizard keeps its current step in component state, so a
281 + // redirect remounts it at step 1 — dumping the user back at the START of
282 + // onboarding immediately after they finished its LAST step. Carry a
283 + // durable hint so the wizard resumes on Connect instead. It's a plain
284 + // step marker, not an auth signal (the nonce above did that job), and
285 + // it's safe to leave in the URL: re-loading it just re-opens the same
286 + // step rather than re-running the connect path. (PM feedback)
287 + if ( false !== strpos( (string) $clean, 'page=' . Onboarding::PAGE_SLUG ) ) {
288 + $clean = add_query_arg( 'xspeed_step', 'connect', $clean );
289 + }
290 +
291 + wp_safe_redirect( $clean );
292 + exit;
293 + }
294 +
295 + /**
115 296 * Flush rewrites once when the module first boots so /xspeed/mcp works
116 297 * without a manual permalink re-save. Cheap: gated on a one-shot flag.
117 298 */
118 299 public function activate(): void {
@@ -126,8 +307,15 @@
126 307
127 308 // -- Pretty endpoint: /xspeed/mcp --
128 309
129 310 public function add_rewrite(): void {
311 + // The stored table is WordPress's routing table AND the only durable
312 + // record of which plugin owns which discovery URL, so both the
313 + // conditional registration below and the self-heal guard at the
314 + // bottom read the SAME snapshot of it. Deciding twice from two reads
315 + // is how a guard ends up flushing away a rule it just registered.
316 + $stored_rules = get_option( 'rewrite_rules' );
317 +
130 318 // Token-in-URL form: /xspeed/mcp/<token> — a single string the user
131 319 // pastes into their AI client (no separate token field). The bare
132 320 // /xspeed/mcp still works with a Bearer/header token.
133 321 add_rewrite_rule(
@@ -144,48 +332,275 @@
144 332 // (that rule requires 64 hex chars), so ordering is safe.
145 333 add_rewrite_rule( '^xspeed/mcp/attach/?$', 'index.php?' . self::ATTACH_QUERY_VAR . '=1', 'top' );
146 334
147 335 // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place the
148 - // `.well-known` segment BEFORE the resource path, so a resource at
149 - // /xspeed/mcp is discovered at BOTH:
150 - // /.well-known/oauth-protected-resource (root form)
151 - // /.well-known/oauth-protected-resource/xspeed/mcp (path-suffixed)
152 - // Real clients (e.g. Claude Desktop) request the path-suffixed form;
153 - // serving only the root form 404s them and the connection aborts. The
154 - // optional `(?:/.*)?` tail matches both without caring about the exact
155 - // resource path (we only serve one resource).
336 + // `.well-known` segment BEFORE the resource/issuer path, and both of
337 + // our canonical identifiers are the MCP endpoint URL, so our
338 + // documents live at:
339 + // /.well-known/oauth-protected-resource/xspeed/mcp
340 + // /.well-known/oauth-authorization-server/xspeed/mcp
341 + //
342 + // Matched EXACTLY. A `(?:/.*)?` tail covers our URLs in one rule, but
343 + // also matches every OTHER plugin's discovery URL on the same site —
344 + // and WordPress matches rewrite rules in table order rather than by
345 + // specificity, so a sibling's own exact rule never gets reached. Its
346 + // clients then receive OUR metadata, find a resource and issuer that
347 + // do not match what they are connecting to, and abort before the
348 + // login screen.
156 349 add_rewrite_rule(
157 - '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$',
350 + '^\\.well-known/oauth-(protected-resource|authorization-server)/xspeed/mcp/?$',
158 351 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
159 352 'top'
160 353 );
161 354
355 + // The bare root form, ONLY while no other plugin claims it. A client
356 + // written to the 2025-03-26 MCP spec looks there and nowhere else, so
357 + // giving it up unconditionally would break those clients on every
358 + // site — including the single-plugin sites where the collision never
359 + // happened. When a sibling's rule is present the URL is theirs and we
360 + // register nothing, which is the case #266 is about. Current clients
361 + // read the protected-resource document first and follow it wherever
362 + // it points, so they are unaffected either way. The document served
363 + // at root carries the LEGACY
364 + // host-only issuer, because that is the identifier a client used to
365 + // derive that URL (RFC 8414 §3.3).
366 + $root_contested = self::root_discovery_contested( $stored_rules );
367 + if ( ! $root_contested ) {
368 + add_rewrite_rule(
369 + self::ROOT_DISCOVERY_RULE,
370 + 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
371 + 'top'
372 + );
373 + }
374 +
162 375 // Browser-facing OAuth consent page — served OUTSIDE REST so cookie
163 376 // auth (is_user_logged_in) works after the wp-login round-trip.
164 377 add_rewrite_rule( '^xspeed/authorize/?$', 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', 'top' );
165 378
166 - // Self-heal: flush once if ANY of our rules is missing from the stored
167 - // rewrite table. Checking only the first rule is not enough — a site
168 - // flushed under an older build (which had /xspeed/mcp but not the
169 - // later /xspeed/authorize + /.well-known rules) keeps that first rule,
170 - // so the guard never fires and OAuth discovery 404s forever. Guard on
171 - // the full set so any newly-added rule triggers a re-flush.
172 - $expected = array(
173 - '^xspeed/mcp/([a-f0-9]{64})/?$',
174 - '^xspeed/mcp/?$',
175 - '^xspeed/mcp/attach/?$',
176 - '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$',
177 - '^xspeed/authorize/?$',
178 - );
379 + // Self-heal: flush once if the stored rewrite table disagrees with the
380 + // rules we just registered. Checking only the first rule is not
381 + // enough — a site flushed under an older build (which had /xspeed/mcp
382 + // but not the later /xspeed/authorize + /.well-known rules) keeps that
383 + // first rule, so the guard never fires and OAuth discovery 404s
384 + // forever. Guard on the full set so any newly-added rule triggers a
385 + // re-flush.
386 + //
387 + // The guard must mirror the registration decisions EXACTLY, or it
388 + // never reaches a fixed point:
389 + //
390 + // - Uncontested root: we register it, so the table must hold it
391 + // with OUR target. A flush produces exactly that, and the next
392 + // request reads the same table and stays uncontested — our own
393 + // target never counts as a sibling's.
394 + // - Contested root: we register nothing, so OUR copy must be gone.
395 + // A flush regenerates the sibling's rule (they register it every
396 + // request) but not ours, so the next request is quiet.
397 + //
398 + // Ownership is read from the TARGET in both directions. Keying on the
399 + // regex alone is what produced a flush on every request forever when
400 + // a sibling held that regex: their rule comes back from every flush.
401 + //
402 + // This runs on `wp_loaded` for every request, so the first request after an
403 + // upgrade flushes once and the guard is quiet from then on. It cannot
404 + // move to Plugin::maybe_upgrade() — that is admin-only and runs at
405 + // plugins_loaded 21, i.e. BEFORE init, so a flush there would write a
406 + // table without our rules and this guard would flush a second time.
407 + if ( ! is_array( $stored_rules ) ) {
408 + return;
409 + }
410 +
411 + $stale = false;
412 + $retiring = false;
413 + foreach ( self::REWRITE_RULES as $rule ) {
414 + if ( ! isset( $stored_rules[ $rule ] ) ) {
415 + $stale = true;
416 + break;
417 + }
418 + }
419 +
420 + // Our copy of the root rule must be present exactly when we register
421 + // it. Present-and-unwanted is the #266 upgrade; absent-and-wanted is
422 + // an older table, or a sibling that has since gone away.
423 + $root_is_ours = isset( $stored_rules[ self::ROOT_DISCOVERY_RULE ] )
424 + && self::is_our_rule_target( $stored_rules[ self::ROOT_DISCOVERY_RULE ] );
425 + if ( $root_is_ours === $root_contested ) {
426 + $stale = true;
427 + $retiring = $root_contested;
428 + }
429 +
430 + // Not an identity move — nothing a site owner can act on — so this
431 + // one flushes quietly.
432 + foreach ( self::RETIRED_REWRITE_RULES as $rule ) {
433 + if ( isset( $stored_rules[ $rule ] ) && self::is_our_rule_target( $stored_rules[ $rule ] ) ) {
434 + $stale = true;
435 + break;
436 + }
437 + }
438 +
439 + if ( ! $stale ) {
440 + return;
441 + }
442 +
443 + if ( $retiring ) {
444 + // The one moment the identity move is observable to a site owner,
445 + // and it happens on a front-end request with no UI attached. Fires
446 + // once: after the flush our rule is gone, so the next request
447 + // finds nothing to hand over.
448 + Activity_Log::record(
449 + 'mcp_discovery_moved',
450 + __( 'Another plugin now claims the site-wide OAuth discovery address, so xSpeed handed it over. Its own is /.well-known/oauth-protected-resource/xspeed/mcp — AI assistants already connected may ask for approval once more.', 'xspeed' ),
451 + Activity_Log::INFO
452 + );
453 + }
454 +
455 + flush_rewrite_rules( false );
456 + }
457 +
458 + /**
459 + * Whether another plugin's rewrite rule already routes the ROOT discovery
460 + * URLs, making them theirs rather than ours.
461 + *
462 + * Read from two views of the rewrite table, because neither alone is
463 + * complete at `init`:
464 + *
465 + * - the STORED table, which is what WordPress actually routes with and
466 + * the only view that survives the request. If a sibling registered
467 + * the same regex after us at the last flush, its target is what is
468 + * stored, and that is precisely "the sibling owns this URL now".
469 + * - the IN-MEMORY rules registered so far this request, which catches a
470 + * sibling that hooks `init` earlier than we do and has therefore not
471 + * reached the stored table yet.
472 + *
473 + * A sibling that registers LATER than us used to be the one case neither
474 + * view saw, and it did NOT resolve itself: the guard is what triggers a
475 + * flush, so a guard reading an incomplete view simply never fires. That
476 + * is why add_rewrite() now runs on `wp_loaded` rather than `init` — by
477 + * then every plugin has registered, whatever its load order.
478 + *
479 + * @param mixed $stored The stored rewrite table, if already read.
480 + */
481 + public static function root_discovery_contested( $stored = null ): bool {
482 + $tables = array();
483 + if ( is_array( $stored ) ) {
484 + $tables[] = $stored;
485 + } elseif ( null === $stored ) {
486 + $option = get_option( 'rewrite_rules' );
487 + if ( is_array( $option ) ) {
488 + $tables[] = $option;
489 + }
490 + }
491 +
492 + if ( isset( $GLOBALS['wp_rewrite'] ) && is_object( $GLOBALS['wp_rewrite'] ) ) {
493 + foreach ( array( 'extra_rules_top', 'extra_rules' ) as $prop ) {
494 + if ( isset( $GLOBALS['wp_rewrite']->$prop ) && is_array( $GLOBALS['wp_rewrite']->$prop ) ) {
495 + $tables[] = $GLOBALS['wp_rewrite']->$prop;
496 + }
497 + }
498 + }
499 +
500 + foreach ( $tables as $rules ) {
501 + foreach ( $rules as $pattern => $target ) {
502 + if ( ! self::is_a_wellknown_rule( (string) $pattern ) || self::is_our_rule_target( $target ) ) {
503 + continue;
504 + }
505 + foreach ( array( 'protected-resource', 'authorization-server' ) as $doc ) {
506 + $probe = '.well-known/oauth-' . $doc;
507 + if ( preg_match( '#' . str_replace( '#', '\\#', (string) $pattern ) . '#', $probe ) ) {
508 + return true;
509 + }
510 + }
511 + }
512 + }
513 +
514 + return false;
515 + }
516 +
517 + /**
518 + * Whether a rewrite regex was written FOR a .well-known discovery URL,
519 + * as opposed to merely matching one.
520 + *
521 + * WordPress's own page rule -- `(.?.+?)/?$` => `index.php?pagename=...`
522 + * -- is in the stored table of every site using pretty permalinks, and
523 + * it matches `.well-known/oauth-protected-resource` exactly as it
524 + * matches every other path on the site. Reading that as a sibling's
525 + * claim would report root as contested EVERYWHERE: the root document
526 + * would be retired on every install, including the single-plugin sites
527 + * this change exists to leave alone, and each of them would log a
528 + * hand-over that never happened.
529 + *
530 + * A rule that routes these URLs on purpose spells the segment out, so
531 + * that is the signal. Backslashes are stripped first because the regex
532 + * carries them as escapes (`^\\.well-known/...`) and a rule is free to
533 + * escape the hyphen too.
534 + *
535 + * @param string $pattern The stored rewrite regex.
536 + */
537 + private static function is_a_wellknown_rule( string $pattern ): bool {
538 + return false !== stripos( str_replace( '\\', '', $pattern ), 'well-known' );
539 + }
540 +
541 + /**
542 + * Whether a stored rewrite target was written by this module.
543 + *
544 + * Every rule we register resolves to `index.php?<one of our query
545 + * vars>=…`, and no other plugin sets those. Used to tell OUR leftover
546 + * copy of a retired rule from a sibling's rule that happens to share the
547 + * regex — only the first is ours to flush away.
548 + *
549 + * @param mixed $target The stored rewrite target.
550 + */
551 + private static function is_our_rule_target( $target ): bool {
552 + if ( ! is_string( $target ) ) {
553 + return false;
554 + }
555 +
556 + foreach ( array( self::QUERY_VAR, self::TOKEN_QUERY_VAR, self::WELLKNOWN_QUERY_VAR, self::AUTHORIZE_QUERY_VAR, self::ATTACH_QUERY_VAR ) as $var ) {
557 + if ( false !== strpos( $target, $var . '=' ) ) {
558 + return true;
559 + }
560 + }
561 +
562 + return false;
563 + }
564 +
565 + /**
566 + * True when a request for our discovery URL can reach WordPress at all.
567 + *
568 + * Since maybe_handle_pretty_endpoint() claims the document by REQUEST
569 + * PATH, a sibling plugin winning the rewrite match no longer matters —
570 + * we answer either way. What still breaks the pretty URL is there being
571 + * no rewrite for it in the first place (plain permalinks), because then
572 + * nothing routes the path to index.php and parse_request never runs.
573 + *
574 + * Blind to upstream interception: a host that owns the /.well-known/
575 + * prefix (an nginx ACME block, an edge redirect rule) answers before
576 + * WordPress loads, and WP cannot see that. Use the
577 + * `xspeed_mcp_resource_metadata_url` filter on such hosts.
578 + */
579 + public static function wellknown_rewrites_active(): bool {
179 580 $rules = get_option( 'rewrite_rules' );
180 - if ( is_array( $rules ) ) {
181 - foreach ( $expected as $rule ) {
182 - if ( ! isset( $rules[ $rule ] ) ) {
183 - flush_rewrite_rules( false );
184 - break;
185 - }
581 + if ( ! is_array( $rules ) || array() === $rules ) {
582 + return false;
583 + }
584 +
585 + // Any rule that routes our discovery path to index.php will do — ours
586 + // or a sibling's — because the path check inside the handler decides
587 + // the outcome once the request lands.
588 + //
589 + // Probe the URL the 401 challenge actually advertises: the
590 + // path-suffixed form, which is the canonical identity since #266.
591 + // Probing root would answer a different question — whether ANY plugin
592 + // routes the contested URL — and on a site where a sibling owns it
593 + // that answer says nothing about whether our own document is
594 + // reachable.
595 + $probe = '.well-known/oauth-protected-resource/' . Mcp_Pairing::SITE_ENDPOINT_PATH;
596 + foreach ( $rules as $pattern => $target ) {
597 + if ( preg_match( '#' . str_replace( '#', '\\#', $pattern ) . '#', $probe ) ) {
598 + return true;
186 599 }
187 600 }
601 +
602 + return false;
188 603 }
189 604
190 605 /**
191 606 * @param string[] $vars Registered query vars.
@@ -200,8 +615,110 @@
200 615 return $vars;
201 616 }
202 617
203 618 /**
619 + * Which discovery document the CURRENT request path asks for, if any.
620 + *
621 + * Claims only URLs that are unambiguously ours, mirroring the rewrite
622 + * rules exactly: the RFC 9728 §3.1 / RFC 8414 §3.1 path-suffixed form
623 + * naming our own resource and issuer (`/xspeed/mcp`). A suffix belonging
624 + * to a sibling plugin is deliberately NOT claimed — answering
625 + * `/.well-known/oauth-protected-resource/betterlinks/mcp` with xSpeed
626 + * metadata is the same bug that broke this site, just pointed the other
627 + * way.
628 + *
629 + * The bare root form is claimed only while no other plugin's rewrite rule
630 + * claims it. Leaving the suffix optional here took the root document from
631 + * a sibling even on a build that had stopped registering its own root
632 + * rule, so the path check is exact.
633 + *
634 + * The TABLE is what hands root over -- add_rewrite() stops registering
635 + * the rule and flushes it away. This claim only releases it, and only
636 + * while a sibling's rule actually owns the URL: once WordPress has
637 + * routed the request to OUR query var, no other plugin's handler can see
638 + * it, so releasing it would abandon the request to the front page rather
639 + * than pass it on. Note that is about the winning rule's TARGET, not its
640 + * regex -- a sibling can hold the same pattern. The document served at root carries the LEGACY host-only
641 + * issuer, the identifier a client used to derive that URL. (#266)
642 + *
643 + * @param string $matched_query The query the matched rule resolved to.
644 + * @return array{doc:string,issuer:string} Doc name ('' when not ours)
645 + * and the identity to stamp on it.
646 + */
647 + private function wellknown_claim_from_path( string $matched_query = '' ): array {
648 + $none = array(
649 + 'doc' => '',
650 + 'issuer' => '',
651 + );
652 +
653 + $uri = isset( $_SERVER['REQUEST_URI'] )
654 + ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) )
655 + : '';
656 + if ( '' === $uri ) {
657 + return $none;
658 + }
659 +
660 + $path = (string) wp_parse_url( $uri, PHP_URL_PATH );
661 +
662 + // Sites in a subdirectory carry that prefix on every request.
663 + $home = (string) wp_parse_url( home_url(), PHP_URL_PATH );
664 + if ( '' !== $home && '/' !== $home && 0 === strpos( $path, $home ) ) {
665 + $path = substr( $path, strlen( $home ) );
666 + }
667 +
668 + $path = trim( $path, '/' );
669 +
670 + // trim() above already dropped a trailing slash, so `/xspeed/mcp/`
671 + // still matches — and so does the root form with one.
672 + $ours = '#^\.well-known/oauth-(protected-resource|authorization-server)'
673 + . '/' . preg_quote( Mcp_Pairing::SITE_ENDPOINT_PATH, '#' ) . '$#';
674 + if ( preg_match( $ours, $path, $m ) ) {
675 + return array(
676 + 'doc' => $m[1],
677 + 'issuer' => Mcp_OAuth::issuer(),
678 + );
679 + }
680 +
681 + // Releasing root is only safe when somebody else can pick it up. If
682 + // OUR rule is what WordPress matched, nobody can: the sibling's query
683 + // var is unset, so its handler never runs, and the request falls
684 + // through to the front page -- a 301 to the homepage where dev
685 + // returns JSON. Answering with the legacy document is the pre-#266
686 + // behaviour. (#266 QA)
687 + //
688 + // Keyed on the query the matched rule RESOLVED TO -- not on
689 + // $wp->query_vars, and not on which regex matched.
690 + //
691 + // query_vars is wrong because the var is public and WP::parse_request
692 + // lets $_GET override anything a rule set, so `?xspeed_mcp_wellknown=1`
693 + // would let anyone force our metadata onto a URL a sibling owns.
694 + //
695 + // matched_rule is wrong because the rewrite table is keyed BY regex:
696 + // a sibling that registered this same pattern replaces our entry and
697 + // the key still reads as ours, while the target behind it is theirs.
698 + // That is a live case here -- is_our_rule_target() exists for it --
699 + // and keying on the rule would answer for the sibling, which is the
700 + // bug this whole change is about.
701 + //
702 + // matched_query is built from the winning rule's TARGET (class-wp.php,
703 + // before the parse_request action) and $_GET never touches it. If it
704 + // sets our query var, our rule genuinely won. (#266 QA)
705 + $routed_to_us = 1 === preg_match(
706 + '#(?:^|&)' . preg_quote( self::WELLKNOWN_QUERY_VAR, '#' ) . '=#',
707 + $matched_query
708 + );
709 + $root = '#^\.well-known/oauth-(protected-resource|authorization-server)$#';
710 + if ( preg_match( $root, $path, $m ) && ( $routed_to_us || ! self::root_discovery_contested() ) ) {
711 + return array(
712 + 'doc' => $m[1],
713 + 'issuer' => Mcp_OAuth::legacy_issuer(),
714 + );
715 + }
716 +
717 + return $none;
718 + }
719 +
720 + /**
204 721 * Serve the MCP endpoint on the pretty path. Runs on parse_request so
205 722 * it fires before the main query, and short-circuits WP entirely.
206 723 *
207 724 * @param \WP $wp The WP request object.
@@ -206,18 +723,40 @@
206 723 *
207 724 * @param \WP $wp The WP request object.
208 725 */
209 726 public function maybe_handle_pretty_endpoint( $wp ): void {
210 - // OAuth discovery documents (served at the site root).
211 - if ( ! empty( $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] ) ) {
212 - $doc = (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ];
727 + // OAuth discovery documents.
728 + //
729 + // Read the doc name from the REQUEST PATH, and ONLY from the path.
730 + // `add_rewrite_rule( …, 'top' )` only means "top at the moment it
731 + // runs", so whichever MCP plugin hooks `init` last ends up first in
732 + // the table — an order set by plugin load order, which no plugin
733 + // controls. A sibling's catch-all
734 + // (`…(protected-resource|authorization-server)(?:/.*)?/?$`) then wins
735 + // the match and our query var is never set, even though the URL is
736 + // unambiguously ours. Observed live with two different plugins on one
737 + // site. parse_request runs AFTER matching, so the path is the one
738 + // signal no sibling rule can take away from us.
739 + //
740 + // The query VAR is deliberately never consulted. It is public, so
741 + // $_GET can set it on any URL, and answering from it would put our
742 + // metadata on somebody else's address — the whole bug. Which rewrite
743 + // RULE matched is a different thing: WordPress decides it, the query
744 + // string cannot influence it, and it is only read to tell "a sibling
745 + // owns this URL" from "we own it and nobody else can answer".
746 + $matched_query = is_object( $wp ) && isset( $wp->matched_query ) ? (string) $wp->matched_query : '';
747 + $claim = $this->wellknown_claim_from_path( $matched_query );
748 + $doc = $claim['doc'];
749 + if ( '' !== $doc ) {
213 750 $data = 'authorization-server' === $doc
214 - ? Mcp_OAuth::authorization_server_metadata()
215 - : Mcp_OAuth::protected_resource_metadata();
751 + ? Mcp_OAuth::authorization_server_metadata( $claim['issuer'] )
752 + : Mcp_OAuth::protected_resource_metadata( $claim['issuer'] );
216 753 status_header( 200 );
217 754 header( 'Content-Type: application/json; charset=utf-8' );
218 - // Discovery metadata is public + cacheable.
219 - header( 'Cache-Control: public, max-age=3600' );
755 + // Public and cacheable, but short: this document IS the server's
756 + // identity, and a cached copy outliving an issuer change is the
757 + // one failure a client cannot recover from on its own. (#266)
758 + header( 'Cache-Control: public, max-age=300' );
220 759 echo wp_json_encode( $data );
221 760 exit;
222 761 }
223 762
@@ -233,8 +772,10 @@
233 772 if ( null === $result ) {
234 773 status_header( 403 );
235 774 echo wp_json_encode( array( 'error' => 'invalid_or_expired_attach_request' ) );
236 775 } else {
776 + // The Hub needs only the credential, as on the REST route.
777 + unset( $result['user_id'] );
237 778 status_header( 200 );
238 779 echo wp_json_encode( $result );
239 780 }
240 781 exit;
@@ -290,8 +831,26 @@
290 831 'permission_callback' => '__return_true',
291 832 )
292 833 );
293 834
835 + // --- Public scan signals -----------------------------------------
836 + // One tiny unauthenticated JSON body for external audit tools (the
837 + // speed scanner on xspeedcache.com): plugin version, whether the MCP
838 + // server is connected, and whether the site is attached to xSpeed
839 + // Hub. Everything except `hub` is already publicly discoverable —
840 + // the cache signature carries the version and /mcp answers 401 when
841 + // connected — and `hub` is a bare boolean. No tokens, accounts or
842 + // emails leave through this route.
843 + register_rest_route(
844 + self::NS,
845 + '/signals',
846 + array(
847 + 'methods' => 'GET',
848 + 'callback' => array( $this, 'rest_signals' ),
849 + 'permission_callback' => '__return_true',
850 + )
851 + );
852 +
294 853 // --- Admin-only management routes (dashboard) --------------------
295 854 register_rest_route(
296 855 self::NS,
297 856 '/mcp/connection',
@@ -302,8 +861,34 @@
302 861 )
303 862 );
304 863 register_rest_route(
305 864 self::NS,
865 + '/mcp/activity',
866 + array(
867 + 'methods' => 'GET',
868 + 'callback' => array( $this, 'rest_activity' ),
869 + 'permission_callback' => array( $this, 'admin_permission' ),
870 + 'args' => array(
871 + 'limit' => array(
872 + 'type' => 'integer',
873 + 'required' => false,
874 + 'default' => 50,
875 + 'description' => 'Maximum entries to return (newest first).',
876 + ),
877 + ),
878 + )
879 + );
880 + register_rest_route(
881 + self::NS,
882 + '/mcp/activity/clear',
883 + array(
884 + 'methods' => 'POST',
885 + 'callback' => array( $this, 'rest_activity_clear' ),
886 + 'permission_callback' => array( $this, 'admin_permission' ),
887 + )
888 + );
889 + register_rest_route(
890 + self::NS,
306 891 '/mcp/connect',
307 892 array(
308 893 'methods' => 'POST',
309 894 'callback' => array( $this, 'rest_connect' ),
@@ -450,13 +1035,49 @@
450 1035 'permission_callback' => '__return_true',
451 1036 )
452 1037 );
453 1038
1039 + // --- OAuth discovery, REST fallback ------------------------------
1040 + // The canonical documents live at /.well-known/… via rewrite rules.
1041 + // Many hosts own that prefix for ACME/Let's Encrypt (an nginx
1042 + // `location ^~ /.well-known` block, or an edge redirect rule), which
1043 + // swallows the request before WordPress ever runs — the pretty URL
1044 + // then 404s or redirects to the homepage no matter how the plugin is
1045 + // configured, and OAuth discovery dead-ends with no way back.
1046 + // Serving the same two documents under /wp-json puts them on a path
1047 + // no ACME tooling claims, so discovery still completes there.
1048 + register_rest_route(
1049 + self::NS,
1050 + '/mcp/.well-known/oauth-protected-resource',
1051 + array(
1052 + 'methods' => 'GET',
1053 + 'callback' => array( $this, 'rest_protected_resource_metadata' ),
1054 + 'permission_callback' => '__return_true',
1055 + )
1056 + );
1057 + register_rest_route(
1058 + self::NS,
1059 + '/mcp/.well-known/oauth-authorization-server',
1060 + array(
1061 + 'methods' => 'GET',
1062 + 'callback' => array( $this, 'rest_authorization_server_metadata' ),
1063 + 'permission_callback' => '__return_true',
1064 + )
1065 + );
1066 +
454 1067 // --- MCP-token-only tool routes (optional hosted-broker path) ----
455 1068 $tool_perm = array( Mcp_Auth::class, 'permission' );
456 1069 register_rest_route(
457 1070 self::NS,
458 - '/mcp/tool/(?P<tool>[a-z_]+)',
1071 + // [a-z0-9_-]+ — the HYPHEN is the one that matters, not the digit.
1072 + // Generated tool names carry their module slug verbatim, and 33 of
1073 + // the 92 in the catalog have a hyphenated slug
1074 + // (xspeed_cache-404_status, xspeed_migration-pro_apply,
1075 + // xspeed_smart-predict_status …). Every one of those returned
1076 + // rest_no_route through the broker path. The earlier widening to
1077 + // [a-z0-9_]+ un-blocked nothing: the only digit-bearing name is
1078 + // cache-404, whose problem was the hyphen. (QA on #158) */
1079 + '/mcp/tool/(?P<tool>[a-z0-9_-]+)',
459 1080 array(
460 1081 array(
461 1082 'methods' => 'GET',
462 1083 'callback' => array( $this, 'rest_tool' ),
@@ -496,8 +1117,35 @@
496 1117 return $response;
497 1118 }
498 1119
499 1120 /**
1121 + * GET /signals — the public scan-signals body. See the route
1122 + * registration for what may (and may not) leave through it.
1123 + *
1124 + * @return \WP_REST_Response
1125 + */
1126 + public function rest_signals() {
1127 + $signals = array(
1128 + 'xspeed' => XSPEED_VERSION,
1129 + 'mcp' => '' !== Mcp_Pairing::site_token(),
1130 + 'hub' => Mcp_Hub::site_attached(),
1131 + );
1132 +
1133 + /**
1134 + * Filter the public scan signals.
1135 + *
1136 + * Lets an add-on append its own public facts (e.g. its version
1137 + * under `pro`). Values returned here are served UNAUTHENTICATED —
1138 + * never add tokens, accounts, emails, or paths.
1139 + *
1140 + * @param array<string,mixed> $signals The signals body.
1141 + */
1142 + $signals = (array) apply_filters( 'xspeed_scan_signals', $signals );
1143 +
1144 + return rest_ensure_response( $signals );
1145 + }
1146 +
1147 + /**
500 1148 * GET /mcp/connection — pairing status for the dashboard.
501 1149 *
502 1150 * @param \WP_REST_Request $request Unused.
503 1151 * @return \WP_REST_Response
@@ -507,8 +1155,44 @@
507 1155 return rest_ensure_response( Mcp_Pairing::public_status() );
508 1156 }
509 1157
510 1158 /**
1159 + * GET /mcp/activity — the audit trail of AI tool calls.
1160 + *
1161 + * @param \WP_REST_Request $request Carries the optional limit.
1162 + * @return \WP_REST_Response|\WP_Error
1163 + */
1164 + public function rest_activity( \WP_REST_Request $request ) {
1165 + $limit = (int) $request->get_param( 'limit' );
1166 +
1167 + return rest_ensure_response(
1168 + array(
1169 + 'entries' => Mcp_Activity_Log::entries( $limit > 0 ? $limit : 50 ),
1170 + 'summary' => Mcp_Activity_Log::summary(),
1171 + )
1172 + );
1173 + }
1174 +
1175 + /**
1176 + * POST /mcp/activity/clear — wipe the audit trail.
1177 + *
1178 + * @param \WP_REST_Request $request Unused.
1179 + * @return \WP_REST_Response|\WP_Error
1180 + */
1181 + public function rest_activity_clear( \WP_REST_Request $request ) {
1182 + unset( $request );
1183 + $cleared = Mcp_Activity_Log::clear();
1184 +
1185 + return rest_ensure_response(
1186 + array(
1187 + 'cleared' => $cleared,
1188 + 'entries' => Mcp_Activity_Log::entries(),
1189 + 'summary' => Mcp_Activity_Log::summary(),
1190 + )
1191 + );
1192 + }
1193 +
1194 + /**
511 1195 * POST /mcp/connect — mint a connection token.
512 1196 *
513 1197 * @param \WP_REST_Request $request Unused.
514 1198 * @return \WP_REST_Response|\WP_Error
@@ -652,8 +1336,43 @@
652 1336
653 1337 // -- OAuth 2.1 handlers ------------------------------------------------
654 1338
655 1339 /**
1340 + * GET /mcp/.well-known/oauth-protected-resource — RFC 9728 metadata.
1341 + *
1342 + * Byte-identical to what the /.well-known rewrite serves; both call the
1343 + * same builder so the two locations can never drift.
1344 + *
1345 + * @return \WP_REST_Response
1346 + */
1347 + public function rest_protected_resource_metadata(): \WP_REST_Response {
1348 + return $this->discovery_response( Mcp_OAuth::protected_resource_metadata() );
1349 + }
1350 +
1351 + /**
1352 + * GET /mcp/.well-known/oauth-authorization-server — RFC 8414 metadata.
1353 + *
1354 + * @return \WP_REST_Response
1355 + */
1356 + public function rest_authorization_server_metadata(): \WP_REST_Response {
1357 + return $this->discovery_response( Mcp_OAuth::authorization_server_metadata() );
1358 + }
1359 +
1360 + /**
1361 + * Wrap a discovery document in a public, cacheable REST response.
1362 + *
1363 + * @param array<string,mixed> $data The metadata document.
1364 + * @return \WP_REST_Response
1365 + */
1366 + private function discovery_response( array $data ): \WP_REST_Response {
1367 + $response = new \WP_REST_Response( $data, 200 );
1368 + // Same short window as the /.well-known/ emit site, same reason: the
1369 + // document carries the server's identity. (#266)
1370 + $response->header( 'Cache-Control', 'public, max-age=300' );
1371 + return $response;
1372 + }
1373 +
1374 + /**
656 1375 * POST /mcp/oauth/register — RFC 7591 dynamic client registration.
657 1376 *
658 1377 * @param \WP_REST_Request $request JSON body with redirect_uris.
659 1378 * @return \WP_REST_Response|\WP_Error
@@ -768,8 +1487,61 @@
768 1487 return $response;
769 1488 }
770 1489
771 1490 /**
1491 + * Reject an over-sized MCP request body before WordPress decodes it.
1492 + *
1493 + * `rest_pre_dispatch` is the last hook that runs before
1494 + * `WP_REST_Server::dispatch()` calls `has_valid_params()`, and that is
1495 + * what `json_decode()`s the body — before the permission callback, so an
1496 + * unauthenticated caller already pays for the decode. Capping here is the
1497 + * difference between reading a length and parsing megabytes of JSON.
1498 + *
1499 + * Refusing is not enough on its own. Core reads request params again on
1500 + * the way out — `rest_filter_response_fields()` on `rest_post_dispatch`
1501 + * looks up `_fields` — and for an `application/json` request that lookup
1502 + * runs `parse_json_params()` over whatever body is still attached. So a
1503 + * refused body is also emptied, and the 413 goes out with nothing left to
1504 + * decode. QA measured a refused 3 MB body peaking near 90 MB without this.
1505 + *
1506 + * Scoped to the two routes that carry tool payloads, compared
1507 + * case-insensitively: `WP_REST_Server::match_request_to_handler()` matches
1508 + * routes with the `i` flag, so `/XSPEED/v1/MCP` reaches the same handler
1509 + * and has to meet the same cap. Returning null leaves the request alone,
1510 + * which is what this filter does for everything else.
1511 + *
1512 + * @param mixed $result A short-circuit response, if one is set.
1513 + * @param mixed $server Unused; the REST server instance.
1514 + * @param \WP_REST_Request $request Incoming request.
1515 + * @return mixed Null to continue, or a WP_Error to refuse.
1516 + */
1517 + public function cap_request_body( $result, $server = null, $request = null ) {
1518 + if ( null !== $result || ! $request instanceof \WP_REST_Request ) {
1519 + return $result;
1520 + }
1521 +
1522 + $route = strtolower( (string) $request->get_route() );
1523 + $mcp = '/' . self::NS . '/mcp';
1524 + if ( $route !== $mcp && 0 !== strpos( $route, $mcp . '/tool/' ) ) {
1525 + return $result;
1526 + }
1527 +
1528 + if ( strlen( (string) $request->get_body() ) <= Mcp_Tools::MAX_TOOL_BODY_BYTES ) {
1529 + return $result;
1530 + }
1531 +
1532 + // Drop the body before refusing. Nothing downstream needs it, and
1533 + // core's response pipeline would otherwise json_decode() it anyway.
1534 + $request->set_body( '' );
1535 +
1536 + return new \WP_Error(
1537 + 'xspeed_mcp_tool_payload_too_large',
1538 + __( 'The MCP tool payload is too large.', 'xspeed' ),
1539 + array( 'status' => 413 )
1540 + );
1541 + }
1542 +
1543 + /**
772 1544 * Token-authenticated tool route for the hosted broker. Maps a broker
773 1545 * tool call (e.g. GET /mcp/tool/get_cache_status) onto the shared
774 1546 * Mcp_Tools catalog, so the broker path and the JSON-RPC path never
775 1547 * drift. GET params + JSON body both feed the tool's arguments.
@@ -775,8 +1547,18 @@
775 1547 * drift. GET params + JSON body both feed the tool's arguments.
776 1548 */
777 1549 public function rest_tool( \WP_REST_Request $request ) {
778 1550 $tool = (string) $request->get_param( 'tool' );
1551 + // Second line behind cap_request_body(). This one still matters: the
1552 + // pretty front-door path builds its own WP_REST_Request and calls the
1553 + // handler without going through WP_REST_Server::dispatch() at all.
1554 + if ( strlen( $request->get_body() ) > Mcp_Tools::MAX_TOOL_BODY_BYTES ) {
1555 + return new \WP_Error(
1556 + 'xspeed_mcp_tool_payload_too_large',
1557 + __( 'The MCP tool payload is too large.', 'xspeed' ),
1558 + array( 'status' => 413 )
1559 + );
1560 + }
779 1561 $args = $request->get_json_params();
780 1562 if ( ! is_array( $args ) ) {
781 1563 $args = array();
782 1564 }
@@ -786,8 +1568,9 @@
786 1568 $args[ $k ] = $v;
787 1569 }
788 1570 }
789 1571
1572 + Mcp_Tools::set_channel( 'broker' );
790 1573 $result = Mcp_Tools::invoke( $tool, $args );
791 1574 if ( is_wp_error( $result ) ) {
792 1575 return $result;
793 1576 }
@@ -944,8 +1727,27 @@
944 1727 'shortdesc' => 'Show MCP connection status and the paste-in endpoint URL.',
945 1728 'synopsis' => array(),
946 1729 ),
947 1730 array(
1731 + 'name' => 'xspeed mcp activity',
1732 + 'callback' => array( $this, 'cli_activity' ),
1733 + 'shortdesc' => 'List recent MCP tool calls (the AI audit trail).',
1734 + 'synopsis' => array(
1735 + array(
1736 + 'name' => 'limit',
1737 + 'type' => 'assoc',
1738 + 'optional' => true,
1739 + 'description' => 'Maximum entries to show (default 20).',
1740 + ),
1741 + array(
1742 + 'name' => 'clear',
1743 + 'type' => 'flag',
1744 + 'optional' => true,
1745 + 'description' => 'Wipe the audit trail instead of listing it.',
1746 + ),
1747 + ),
1748 + ),
1749 + array(
948 1750 'name' => 'xspeed mcp connect',
949 1751 'callback' => array( $this, 'cli_connect' ),
950 1752 'shortdesc' => 'Generate a connection token for this site\'s MCP endpoint.',
951 1753 'synopsis' => array(
@@ -998,8 +1800,59 @@
998 1800 }
999 1801 }
1000 1802
1001 1803 /**
1804 + * `wp xspeed mcp activity` — read (or clear) the AI audit trail.
1805 + *
1806 + * @param array $args Positional args (unused).
1807 + * @param array $assoc --limit=<n>, --clear.
1808 + */
1809 + public function cli_activity( array $args, array $assoc ): void {
1810 + unset( $args );
1811 +
1812 + if ( ! empty( $assoc['clear'] ) ) {
1813 + if ( ! Mcp_Activity_Log::clear() ) {
1814 + // Reached via MCP run_command — the assistant is asking to
1815 + // erase the record of its own calls. Mcp_Activity_Log::clear()
1816 + // declines and logs the attempt; say so plainly.
1817 + \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.' );
1818 + return;
1819 + }
1820 + \WP_CLI::success( 'MCP activity log cleared.' );
1821 + return;
1822 + }
1823 +
1824 + $limit = isset( $assoc['limit'] ) ? (int) $assoc['limit'] : 20;
1825 + $summary = Mcp_Activity_Log::summary();
1826 + $entries = Mcp_Activity_Log::entries( $limit > 0 ? $limit : 20 );
1827 +
1828 + \WP_CLI::log( sprintf( '%-18s %d', 'total_calls', $summary['total'] ) );
1829 + \WP_CLI::log( sprintf( '%-18s %d', 'failed', $summary['failed'] ) );
1830 + \WP_CLI::log( sprintf( '%-18s %s', 'top_tool', '' === $summary['top_tool'] ? '-' : $summary['top_tool'] ) );
1831 +
1832 + if ( empty( $entries ) ) {
1833 + \WP_CLI::log( '' );
1834 + \WP_CLI::log( 'No MCP tool calls recorded yet.' );
1835 + return;
1836 + }
1837 +
1838 + \WP_CLI::log( '' );
1839 + foreach ( $entries as $entry ) {
1840 + \WP_CLI::log(
1841 + sprintf(
1842 + '%s %-22s %-5s %-6s %s%s',
1843 + gmdate( 'Y-m-d H:i:s', $entry['ts'] ),
1844 + $entry['tool'],
1845 + $entry['scope'],
1846 + $entry['ok'] ? 'ok' : 'FAIL',
1847 + $entry['args'],
1848 + '' === $entry['error'] ? '' : ' — ' . $entry['error']
1849 + )
1850 + );
1851 + }
1852 + }
1853 +
1854 + /**
1002 1855 * `wp xspeed mcp connect` — mint a token and print the paste-in URL.
1003 1856 *
1004 1857 * @param array $args Positional args (unused).
1005 1858 * @param array $assoc Associative args (unused).
@@ -1042,6 +1895,27 @@
1042 1895 public function cli_disconnect( array $args, array $assoc ): void {
1043 1896 unset( $args, $assoc );
1044 1897 Mcp_Pairing::disconnect();
1045 1898 \WP_CLI::success( 'Disconnected and revoked the MCP token.' );
1899 + }
1900 +
1901 + /**
1902 + * MCP is on when a connection token exists -- it has no `enabled`
1903 + * setting, so the sidebar counted the AI group as empty on a site with
1904 + * a live read-write AI connection. Reads the same
1905 + * `Mcp_Pairing::public_status()` the CLI and the panel do, so the count
1906 + * cannot disagree with the badge on the panel. (#363)
1907 + */
1908 + public function is_active(): ?bool {
1909 + $status = Mcp_Pairing::public_status();
1910 + return ! empty( $status['connected'] );
1911 + }
1912 +
1913 + /**
1914 + * MCP has no on/off setting -- it is on when a connection exists.
1915 + */
1916 + public function active_reason(): ?string {
1917 + return $this->is_active()
1918 + ? __( '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' )
1919 + : __( 'No AI assistant is connected. This module counts as on once you connect one.', 'xspeed' );
1046 1920 }
1047 1921 }