PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.4
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 1.1.7 All 30 releases
xspeed / includes / modules / Mcp / McpModule.php

McpModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.4, at includes/modules/Mcp/McpModule.php

1,847 lines 72.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP module — the site side of xSpeed's MCP integration.
4 *
5 * PRIMARY path (no hosted infra): the plugin speaks the MCP protocol
6 * DIRECTLY at this site's own URL. The user pastes their own site's MCP
7 * endpoint + connection token into their AI client:
8 *
9 * https://thissite.com/xspeed/mcp (pretty, via rewrite)
10 * https://thissite.com/wp-json/xspeed/v1/mcp (always-on fallback)
11 *
12 * The MCP JSON-RPC handling lives in Mcp_Server; the tool catalog in
13 * Mcp_Tools. Auth is the per-site connection token (Mcp_Auth /
14 * Mcp_Pairing). See IMPLEMENTATION.md §17.
15 *
16 * OPTIONAL path (hosted broker, api.xspeedcache.com): the same tool
17 * catalog is also exposed as token-authenticated REST routes under
18 * /xspeed/v1/mcp/tool/* so a hosted broker can proxy to it for a single
19 * shared vanity URL. Not required for the product to work.
20 *
21 * Admin-only management routes (manage_options) drive the dashboard
22 * "Connect AI" panel: /mcp/connection, /mcp/connect, /mcp/disconnect.
23 *
24 * These routes register DIRECTLY on rest_api_init (NOT via Rest_Manager,
25 * whose wrap_permission() forces a current_user_can() gate that MCP's
26 * token-only calls can never satisfy).
27 *
28 * Tier: Free. The ONLY gate is possession of the per-site connection
29 * token, which an admin (manage_options) must explicitly mint via
30 * Connect. A fresh install ships with no token → every MCP call is 401
31 * until the admin opts in. Adds ZERO cache logic.
32 *
33 * @package XSpeed
34 */
35
36 declare(strict_types=1);
37
38 namespace XSpeed\Modules\Mcp;
39
40 use XSpeed\Activity_Log;
41 use XSpeed\Module;
42 use XSpeed\Onboarding;
43
44 defined( 'ABSPATH' ) || exit;
45
46 final class McpModule extends Module {
47
48 public const SLUG = 'mcp';
49 public const TIER = self::TIER_FREE;
50 public const VERSION = '1.0.0';
51
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 );
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
121 /** Query var flagging a pretty /xspeed/mcp request. */
122 private const QUERY_VAR = 'xspeed_mcp';
123
124 /** Query var carrying the token when embedded in the URL path. */
125 private const TOKEN_QUERY_VAR = 'xspeed_mcp_token';
126
127 /** Query var flagging a /.well-known/ OAuth discovery request. */
128 private const WELLKNOWN_QUERY_VAR = 'xspeed_mcp_wellknown';
129
130 /**
131 * Query var flagging the browser-facing OAuth authorize page. This is
132 * served OUTSIDE the REST API on purpose: a REST route only honors cookie
133 * auth when a REST nonce accompanies it, but a browser arriving from
134 * wp-login carries the cookie with NO nonce — so is_user_logged_in() would
135 * be false there and the consent screen would loop back to login forever.
136 * A normal front-end URL (rewrite + parse_request) sees standard cookie
137 * auth, so the logged-in admin check works.
138 */
139 private const AUTHORIZE_QUERY_VAR = 'xspeed_mcp_authorize';
140
141 /** Front-end path of the browser-facing authorize page. */
142 private const AUTHORIZE_PATH = 'xspeed/authorize';
143
144 /** Query var flagging the pretty /xspeed/mcp/attach callback. */
145 private const ATTACH_QUERY_VAR = 'xspeed_mcp_attach';
146
147 public function ui_metadata(): array {
148 return array(
149 'label' => __( 'MCP Server', 'xspeed' ),
150 'icon' => 'Sparkles',
151 'description' => __( 'Let Claude or another AI agent run this site — purge, check stats, change settings. This is the only thing you connect an AI to, and it is free with no API key.', 'xspeed' ),
152 'custom_panel' => 'McpPanel',
153 );
154 }
155
156 /**
157 * MCP pairing state lives in xspeed_module_mcp but is managed by
158 * Mcp_Pairing, not the schema engine. Empty schema so the base class
159 * doesn't auto-register generic settings routes.
160 */
161 public function settings_schema(): array {
162 return array();
163 }
164
165 /**
166 * All MCP routes register directly (see class docblock). Returning an
167 * empty array keeps Rest_Manager out of the token-auth path entirely.
168 */
169 public function rest_routes(): array {
170 return array();
171 }
172
173 public function boot(): void {
174 add_action( 'rest_api_init', array( $this, 'register_rest' ) );
175
176 // Pretty per-site endpoint: /xspeed/mcp → MCP JSON-RPC handler.
177 // `wp_loaded`, not `init`: add_rewrite() decides whether to claim the
178 // root discovery URL by looking at the rewrite table, and on `init` that
179 // view is incomplete -- a sibling MCP plugin hooked at the same priority
180 // but loaded after us has not registered yet. Root then looks
181 // uncontested, we register our rule, and the self-heal guard concludes
182 // nothing is stale, so it never flushes. That is a fixed point: the
183 // table never converges, and because our rule is the one WordPress
184 // matches, the sibling never sees the request either.
185 //
186 // By `wp_loaded` every init callback on every request type has run, so
187 // the contested check sees the sibling and the guard flushes once.
188 // WP_Rewrite::flush_rules() already defers itself to `wp_loaded`, so
189 // nothing is lost by deciding here, and did_action('wp_loaded') is
190 // truthy inside this callback, so the flush lands in time for
191 // parse_request in the same request. (#266 QA)
192 // Priority 0: still after every `init` callback, but ahead of the
193 // widely copied `add_action( 'wp_loaded', 'flush_rewrite_rules' )`
194 // snippet. If such a plugin flushed first it would write a table
195 // without our rules, our guard would find them missing and flush
196 // again -- two flushes and two option writes on every request.
197 add_action( 'wp_loaded', array( $this, 'add_rewrite' ), 0 );
198 add_filter( 'query_vars', array( $this, 'register_query_var' ) );
199 // Priority 1: a sibling MCP plugin that also claims /.well-known/ gets
200 // to answer first at the default priority 10, and whoever answers
201 // first calls exit(). Running early means the URL is decided by WHOSE
202 // path it is, not by which plugin happened to load last.
203 add_action( 'parse_request', array( $this, 'maybe_handle_pretty_endpoint' ), 1 );
204
205 // Hub redirect-return: after the user approves on the Hub, it sends the
206 // browser back to a plugin admin URL carrying ?xspeed_connected=1 plus
207 // the account email + the SAME signed nonce we minted. We verify our own
208 // nonce and mark this admin attached — no server-to-server callback
209 // needed, so it works for local/firewalled sites too.
210 add_action( 'admin_init', array( $this, 'maybe_handle_hub_return' ) );
211
212 // An attached admin who is DELETED (or removed from the blog) never
213 // runs disconnect(), so the site-level attached mirror would report
214 // hub:true forever. deleted_user fires after both wp_delete_user()
215 // and wpmu_delete_user() drop the user, so a plain recompute is
216 // honest there. remove_user_from_blog is core's only removal action
217 // and fires BEFORE removal, so its handler clears the departing
218 // user's record before recomputing (see Mcp_Hub::handle_user_removed).
219 add_action( 'deleted_user', array( Mcp_Hub::class, 'refresh_site_attached' ) );
220 add_action( 'remove_user_from_blog', array( Mcp_Hub::class, 'handle_user_removed' ) );
221 }
222
223 /**
224 * Handle the browser landing back from the Hub after a connect. Idempotent
225 * and safe to run on every admin page load: it only acts when the return
226 * markers are present and the nonce verifies.
227 */
228 public function maybe_handle_hub_return(): void {
229 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- auth is the signed HMAC nonce below, not a WP nonce; this is a read-only routing check.
230 $nonce = isset( $_GET['xspeed_hub_nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['xspeed_hub_nonce'] ) ) : '';
231 $email = isset( $_GET['xspeed_hub_email'] ) ? sanitize_email( wp_unslash( $_GET['xspeed_hub_email'] ) ) : '';
232
233 /*
234 * Trigger on the signed nonce, not on `xspeed_connected`.
235 *
236 * The Hub bounces the browser back with xspeed_hub_nonce +
237 * xspeed_hub_email, but it does NOT always append xspeed_connected —
238 * that marker only survives when the return_url we handed it carried
239 * one. Gating on it meant a real, correctly-signed return was ignored:
240 * the attach was never recorded, the params were never stripped, and
241 * the card kept showing "Not connected" while the nonce sat in the
242 * address bar. The nonce is the actual proof of a genuine round trip,
243 * so it is what this handler keys on. (FBS-84086)
244 */
245 if ( '' === $nonce && empty( $_GET['xspeed_connected'] ) ) {
246 return;
247 }
248 // phpcs:enable WordPress.Security.NonceVerification.Recommended
249
250 if ( ! current_user_can( 'manage_options' ) ) {
251 return;
252 }
253
254 // Verify OUR own signed nonce (proves the round-trip went through the
255 // Hub with a token we minted), then record the connection.
256 if ( '' !== $nonce ) {
257 $verified = Mcp_Hub::verify_attach_nonce( $nonce );
258 if ( null !== $verified ) {
259 $uid = isset( $verified['user_id'] ) ? (int) $verified['user_id'] : get_current_user_id();
260 Mcp_Hub::mark_attached( $email, $uid ?: null );
261 }
262 }
263
264 // ALWAYS strip the one-time return markers from the URL and redirect to
265 // the clean address. These params are single-use; if they persist in the
266 // browser URL, a later reload re-triggers the "just connected" path and
267 // flashes a stale connected state even after the user has disconnected.
268 $clean = remove_query_arg( array( 'xspeed_connected', 'xspeed_hub_nonce', 'xspeed_hub_email' ) );
269
270 // The setup wizard keeps its current step in component state, so a
271 // redirect remounts it at step 1 — dumping the user back at the START of
272 // onboarding immediately after they finished its LAST step. Carry a
273 // durable hint so the wizard resumes on Connect instead. It's a plain
274 // step marker, not an auth signal (the nonce above did that job), and
275 // it's safe to leave in the URL: re-loading it just re-opens the same
276 // step rather than re-running the connect path. (PM feedback)
277 if ( false !== strpos( (string) $clean, 'page=' . Onboarding::PAGE_SLUG ) ) {
278 $clean = add_query_arg( 'xspeed_step', 'connect', $clean );
279 }
280
281 wp_safe_redirect( $clean );
282 exit;
283 }
284
285 /**
286 * Flush rewrites once when the module first boots so /xspeed/mcp works
287 * without a manual permalink re-save. Cheap: gated on a one-shot flag.
288 */
289 public function activate(): void {
290 $this->add_rewrite();
291 flush_rewrite_rules( false );
292 }
293
294 public function deactivate(): void {
295 flush_rewrite_rules( false );
296 }
297
298 // -- Pretty endpoint: /xspeed/mcp --
299
300 public function add_rewrite(): void {
301 // The stored table is WordPress's routing table AND the only durable
302 // record of which plugin owns which discovery URL, so both the
303 // conditional registration below and the self-heal guard at the
304 // bottom read the SAME snapshot of it. Deciding twice from two reads
305 // is how a guard ends up flushing away a rule it just registered.
306 $stored_rules = get_option( 'rewrite_rules' );
307
308 // Token-in-URL form: /xspeed/mcp/<token> — a single string the user
309 // pastes into their AI client (no separate token field). The bare
310 // /xspeed/mcp still works with a Bearer/header token.
311 add_rewrite_rule(
312 '^xspeed/mcp/([a-f0-9]{64})/?$',
313 'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]',
314 'top'
315 );
316 add_rewrite_rule( '^xspeed/mcp/?$', 'index.php?' . self::QUERY_VAR . '=1', 'top' );
317
318 // Pretty attach-callback endpoint: /xspeed/mcp/attach — the hub POSTs
319 // the signed nonce here to verify + fetch the token. Uses the plugin's
320 // own rewrite (consistent with the MCP URL, survives hosts that block
321 // /wp-json). Placed BEFORE the token rule would never match "attach"
322 // (that rule requires 64 hex chars), so ordering is safe.
323 add_rewrite_rule( '^xspeed/mcp/attach/?$', 'index.php?' . self::ATTACH_QUERY_VAR . '=1', 'top' );
324
325 // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place the
326 // `.well-known` segment BEFORE the resource/issuer path, and both of
327 // our canonical identifiers are the MCP endpoint URL, so our
328 // documents live at:
329 // /.well-known/oauth-protected-resource/xspeed/mcp
330 // /.well-known/oauth-authorization-server/xspeed/mcp
331 //
332 // Matched EXACTLY. A `(?:/.*)?` tail covers our URLs in one rule, but
333 // also matches every OTHER plugin's discovery URL on the same site —
334 // and WordPress matches rewrite rules in table order rather than by
335 // specificity, so a sibling's own exact rule never gets reached. Its
336 // clients then receive OUR metadata, find a resource and issuer that
337 // do not match what they are connecting to, and abort before the
338 // login screen.
339 add_rewrite_rule(
340 '^\\.well-known/oauth-(protected-resource|authorization-server)/xspeed/mcp/?$',
341 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
342 'top'
343 );
344
345 // The bare root form, ONLY while no other plugin claims it. A client
346 // written to the 2025-03-26 MCP spec looks there and nowhere else, so
347 // giving it up unconditionally would break those clients on every
348 // site — including the single-plugin sites where the collision never
349 // happened. When a sibling's rule is present the URL is theirs and we
350 // register nothing, which is the case #266 is about. Current clients
351 // read the protected-resource document first and follow it wherever
352 // it points, so they are unaffected either way. The document served
353 // at root carries the LEGACY
354 // host-only issuer, because that is the identifier a client used to
355 // derive that URL (RFC 8414 §3.3).
356 $root_contested = self::root_discovery_contested( $stored_rules );
357 if ( ! $root_contested ) {
358 add_rewrite_rule(
359 self::ROOT_DISCOVERY_RULE,
360 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
361 'top'
362 );
363 }
364
365 // Browser-facing OAuth consent page — served OUTSIDE REST so cookie
366 // auth (is_user_logged_in) works after the wp-login round-trip.
367 add_rewrite_rule( '^xspeed/authorize/?$', 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', 'top' );
368
369 // Self-heal: flush once if the stored rewrite table disagrees with the
370 // rules we just registered. Checking only the first rule is not
371 // enough — a site flushed under an older build (which had /xspeed/mcp
372 // but not the later /xspeed/authorize + /.well-known rules) keeps that
373 // first rule, so the guard never fires and OAuth discovery 404s
374 // forever. Guard on the full set so any newly-added rule triggers a
375 // re-flush.
376 //
377 // The guard must mirror the registration decisions EXACTLY, or it
378 // never reaches a fixed point:
379 //
380 // - Uncontested root: we register it, so the table must hold it
381 // with OUR target. A flush produces exactly that, and the next
382 // request reads the same table and stays uncontested — our own
383 // target never counts as a sibling's.
384 // - Contested root: we register nothing, so OUR copy must be gone.
385 // A flush regenerates the sibling's rule (they register it every
386 // request) but not ours, so the next request is quiet.
387 //
388 // Ownership is read from the TARGET in both directions. Keying on the
389 // regex alone is what produced a flush on every request forever when
390 // a sibling held that regex: their rule comes back from every flush.
391 //
392 // This runs on `wp_loaded` for every request, so the first request after an
393 // upgrade flushes once and the guard is quiet from then on. It cannot
394 // move to Plugin::maybe_upgrade() — that is admin-only and runs at
395 // plugins_loaded 21, i.e. BEFORE init, so a flush there would write a
396 // table without our rules and this guard would flush a second time.
397 if ( ! is_array( $stored_rules ) ) {
398 return;
399 }
400
401 $stale = false;
402 $retiring = false;
403 foreach ( self::REWRITE_RULES as $rule ) {
404 if ( ! isset( $stored_rules[ $rule ] ) ) {
405 $stale = true;
406 break;
407 }
408 }
409
410 // Our copy of the root rule must be present exactly when we register
411 // it. Present-and-unwanted is the #266 upgrade; absent-and-wanted is
412 // an older table, or a sibling that has since gone away.
413 $root_is_ours = isset( $stored_rules[ self::ROOT_DISCOVERY_RULE ] )
414 && self::is_our_rule_target( $stored_rules[ self::ROOT_DISCOVERY_RULE ] );
415 if ( $root_is_ours === $root_contested ) {
416 $stale = true;
417 $retiring = $root_contested;
418 }
419
420 // Not an identity move — nothing a site owner can act on — so this
421 // one flushes quietly.
422 foreach ( self::RETIRED_REWRITE_RULES as $rule ) {
423 if ( isset( $stored_rules[ $rule ] ) && self::is_our_rule_target( $stored_rules[ $rule ] ) ) {
424 $stale = true;
425 break;
426 }
427 }
428
429 if ( ! $stale ) {
430 return;
431 }
432
433 if ( $retiring ) {
434 // The one moment the identity move is observable to a site owner,
435 // and it happens on a front-end request with no UI attached. Fires
436 // once: after the flush our rule is gone, so the next request
437 // finds nothing to hand over.
438 Activity_Log::record(
439 'mcp_discovery_moved',
440 __( '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' ),
441 Activity_Log::INFO
442 );
443 }
444
445 flush_rewrite_rules( false );
446 }
447
448 /**
449 * Whether another plugin's rewrite rule already routes the ROOT discovery
450 * URLs, making them theirs rather than ours.
451 *
452 * Read from two views of the rewrite table, because neither alone is
453 * complete at `init`:
454 *
455 * - the STORED table, which is what WordPress actually routes with and
456 * the only view that survives the request. If a sibling registered
457 * the same regex after us at the last flush, its target is what is
458 * stored, and that is precisely "the sibling owns this URL now".
459 * - the IN-MEMORY rules registered so far this request, which catches a
460 * sibling that hooks `init` earlier than we do and has therefore not
461 * reached the stored table yet.
462 *
463 * A sibling that registers LATER than us used to be the one case neither
464 * view saw, and it did NOT resolve itself: the guard is what triggers a
465 * flush, so a guard reading an incomplete view simply never fires. That
466 * is why add_rewrite() now runs on `wp_loaded` rather than `init` — by
467 * then every plugin has registered, whatever its load order.
468 *
469 * @param mixed $stored The stored rewrite table, if already read.
470 */
471 public static function root_discovery_contested( $stored = null ): bool {
472 $tables = array();
473 if ( is_array( $stored ) ) {
474 $tables[] = $stored;
475 } elseif ( null === $stored ) {
476 $option = get_option( 'rewrite_rules' );
477 if ( is_array( $option ) ) {
478 $tables[] = $option;
479 }
480 }
481
482 if ( isset( $GLOBALS['wp_rewrite'] ) && is_object( $GLOBALS['wp_rewrite'] ) ) {
483 foreach ( array( 'extra_rules_top', 'extra_rules' ) as $prop ) {
484 if ( isset( $GLOBALS['wp_rewrite']->$prop ) && is_array( $GLOBALS['wp_rewrite']->$prop ) ) {
485 $tables[] = $GLOBALS['wp_rewrite']->$prop;
486 }
487 }
488 }
489
490 foreach ( $tables as $rules ) {
491 foreach ( $rules as $pattern => $target ) {
492 if ( ! self::is_a_wellknown_rule( (string) $pattern ) || self::is_our_rule_target( $target ) ) {
493 continue;
494 }
495 foreach ( array( 'protected-resource', 'authorization-server' ) as $doc ) {
496 $probe = '.well-known/oauth-' . $doc;
497 if ( preg_match( '#' . str_replace( '#', '\\#', (string) $pattern ) . '#', $probe ) ) {
498 return true;
499 }
500 }
501 }
502 }
503
504 return false;
505 }
506
507 /**
508 * Whether a rewrite regex was written FOR a .well-known discovery URL,
509 * as opposed to merely matching one.
510 *
511 * WordPress's own page rule -- `(.?.+?)/?$` => `index.php?pagename=...`
512 * -- is in the stored table of every site using pretty permalinks, and
513 * it matches `.well-known/oauth-protected-resource` exactly as it
514 * matches every other path on the site. Reading that as a sibling's
515 * claim would report root as contested EVERYWHERE: the root document
516 * would be retired on every install, including the single-plugin sites
517 * this change exists to leave alone, and each of them would log a
518 * hand-over that never happened.
519 *
520 * A rule that routes these URLs on purpose spells the segment out, so
521 * that is the signal. Backslashes are stripped first because the regex
522 * carries them as escapes (`^\\.well-known/...`) and a rule is free to
523 * escape the hyphen too.
524 *
525 * @param string $pattern The stored rewrite regex.
526 */
527 private static function is_a_wellknown_rule( string $pattern ): bool {
528 return false !== stripos( str_replace( '\\', '', $pattern ), 'well-known' );
529 }
530
531 /**
532 * Whether a stored rewrite target was written by this module.
533 *
534 * Every rule we register resolves to `index.php?<one of our query
535 * vars>=…`, and no other plugin sets those. Used to tell OUR leftover
536 * copy of a retired rule from a sibling's rule that happens to share the
537 * regex — only the first is ours to flush away.
538 *
539 * @param mixed $target The stored rewrite target.
540 */
541 private static function is_our_rule_target( $target ): bool {
542 if ( ! is_string( $target ) ) {
543 return false;
544 }
545
546 foreach ( array( self::QUERY_VAR, self::TOKEN_QUERY_VAR, self::WELLKNOWN_QUERY_VAR, self::AUTHORIZE_QUERY_VAR, self::ATTACH_QUERY_VAR ) as $var ) {
547 if ( false !== strpos( $target, $var . '=' ) ) {
548 return true;
549 }
550 }
551
552 return false;
553 }
554
555 /**
556 * True when a request for our discovery URL can reach WordPress at all.
557 *
558 * Since maybe_handle_pretty_endpoint() claims the document by REQUEST
559 * PATH, a sibling plugin winning the rewrite match no longer matters —
560 * we answer either way. What still breaks the pretty URL is there being
561 * no rewrite for it in the first place (plain permalinks), because then
562 * nothing routes the path to index.php and parse_request never runs.
563 *
564 * Blind to upstream interception: a host that owns the /.well-known/
565 * prefix (an nginx ACME block, an edge redirect rule) answers before
566 * WordPress loads, and WP cannot see that. Use the
567 * `xspeed_mcp_resource_metadata_url` filter on such hosts.
568 */
569 public static function wellknown_rewrites_active(): bool {
570 $rules = get_option( 'rewrite_rules' );
571 if ( ! is_array( $rules ) || array() === $rules ) {
572 return false;
573 }
574
575 // Any rule that routes our discovery path to index.php will do — ours
576 // or a sibling's — because the path check inside the handler decides
577 // the outcome once the request lands.
578 //
579 // Probe the URL the 401 challenge actually advertises: the
580 // path-suffixed form, which is the canonical identity since #266.
581 // Probing root would answer a different question — whether ANY plugin
582 // routes the contested URL — and on a site where a sibling owns it
583 // that answer says nothing about whether our own document is
584 // reachable.
585 $probe = '.well-known/oauth-protected-resource/' . Mcp_Pairing::SITE_ENDPOINT_PATH;
586 foreach ( $rules as $pattern => $target ) {
587 if ( preg_match( '#' . str_replace( '#', '\\#', $pattern ) . '#', $probe ) ) {
588 return true;
589 }
590 }
591
592 return false;
593 }
594
595 /**
596 * @param string[] $vars Registered query vars.
597 * @return string[]
598 */
599 public function register_query_var( array $vars ): array {
600 $vars[] = self::QUERY_VAR;
601 $vars[] = self::TOKEN_QUERY_VAR;
602 $vars[] = self::WELLKNOWN_QUERY_VAR;
603 $vars[] = self::AUTHORIZE_QUERY_VAR;
604 $vars[] = self::ATTACH_QUERY_VAR;
605 return $vars;
606 }
607
608 /**
609 * Which discovery document the CURRENT request path asks for, if any.
610 *
611 * Claims only URLs that are unambiguously ours, mirroring the rewrite
612 * rules exactly: the RFC 9728 §3.1 / RFC 8414 §3.1 path-suffixed form
613 * naming our own resource and issuer (`/xspeed/mcp`). A suffix belonging
614 * to a sibling plugin is deliberately NOT claimed — answering
615 * `/.well-known/oauth-protected-resource/betterlinks/mcp` with xSpeed
616 * metadata is the same bug that broke this site, just pointed the other
617 * way.
618 *
619 * The bare root form is claimed only while no other plugin's rewrite rule
620 * claims it. Leaving the suffix optional here took the root document from
621 * a sibling even on a build that had stopped registering its own root
622 * rule, so the path check is exact.
623 *
624 * The TABLE is what hands root over -- add_rewrite() stops registering
625 * the rule and flushes it away. This claim only releases it, and only
626 * while a sibling's rule actually owns the URL: once WordPress has
627 * routed the request to OUR query var, no other plugin's handler can see
628 * it, so releasing it would abandon the request to the front page rather
629 * than pass it on. Note that is about the winning rule's TARGET, not its
630 * regex -- a sibling can hold the same pattern. The document served at root carries the LEGACY host-only
631 * issuer, the identifier a client used to derive that URL. (#266)
632 *
633 * @param string $matched_query The query the matched rule resolved to.
634 * @return array{doc:string,issuer:string} Doc name ('' when not ours)
635 * and the identity to stamp on it.
636 */
637 private function wellknown_claim_from_path( string $matched_query = '' ): array {
638 $none = array(
639 'doc' => '',
640 'issuer' => '',
641 );
642
643 $uri = isset( $_SERVER['REQUEST_URI'] )
644 ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) )
645 : '';
646 if ( '' === $uri ) {
647 return $none;
648 }
649
650 $path = (string) wp_parse_url( $uri, PHP_URL_PATH );
651
652 // Sites in a subdirectory carry that prefix on every request.
653 $home = (string) wp_parse_url( home_url(), PHP_URL_PATH );
654 if ( '' !== $home && '/' !== $home && 0 === strpos( $path, $home ) ) {
655 $path = substr( $path, strlen( $home ) );
656 }
657
658 $path = trim( $path, '/' );
659
660 // trim() above already dropped a trailing slash, so `/xspeed/mcp/`
661 // still matches — and so does the root form with one.
662 $ours = '#^\.well-known/oauth-(protected-resource|authorization-server)'
663 . '/' . preg_quote( Mcp_Pairing::SITE_ENDPOINT_PATH, '#' ) . '$#';
664 if ( preg_match( $ours, $path, $m ) ) {
665 return array(
666 'doc' => $m[1],
667 'issuer' => Mcp_OAuth::issuer(),
668 );
669 }
670
671 // Releasing root is only safe when somebody else can pick it up. If
672 // OUR rule is what WordPress matched, nobody can: the sibling's query
673 // var is unset, so its handler never runs, and the request falls
674 // through to the front page -- a 301 to the homepage where dev
675 // returns JSON. Answering with the legacy document is the pre-#266
676 // behaviour. (#266 QA)
677 //
678 // Keyed on the query the matched rule RESOLVED TO -- not on
679 // $wp->query_vars, and not on which regex matched.
680 //
681 // query_vars is wrong because the var is public and WP::parse_request
682 // lets $_GET override anything a rule set, so `?xspeed_mcp_wellknown=1`
683 // would let anyone force our metadata onto a URL a sibling owns.
684 //
685 // matched_rule is wrong because the rewrite table is keyed BY regex:
686 // a sibling that registered this same pattern replaces our entry and
687 // the key still reads as ours, while the target behind it is theirs.
688 // That is a live case here -- is_our_rule_target() exists for it --
689 // and keying on the rule would answer for the sibling, which is the
690 // bug this whole change is about.
691 //
692 // matched_query is built from the winning rule's TARGET (class-wp.php,
693 // before the parse_request action) and $_GET never touches it. If it
694 // sets our query var, our rule genuinely won. (#266 QA)
695 $routed_to_us = 1 === preg_match(
696 '#(?:^|&)' . preg_quote( self::WELLKNOWN_QUERY_VAR, '#' ) . '=#',
697 $matched_query
698 );
699 $root = '#^\.well-known/oauth-(protected-resource|authorization-server)$#';
700 if ( preg_match( $root, $path, $m ) && ( $routed_to_us || ! self::root_discovery_contested() ) ) {
701 return array(
702 'doc' => $m[1],
703 'issuer' => Mcp_OAuth::legacy_issuer(),
704 );
705 }
706
707 return $none;
708 }
709
710 /**
711 * Serve the MCP endpoint on the pretty path. Runs on parse_request so
712 * it fires before the main query, and short-circuits WP entirely.
713 *
714 * @param \WP $wp The WP request object.
715 */
716 public function maybe_handle_pretty_endpoint( $wp ): void {
717 // OAuth discovery documents.
718 //
719 // Read the doc name from the REQUEST PATH, and ONLY from the path.
720 // `add_rewrite_rule( …, 'top' )` only means "top at the moment it
721 // runs", so whichever MCP plugin hooks `init` last ends up first in
722 // the table — an order set by plugin load order, which no plugin
723 // controls. A sibling's catch-all
724 // (`…(protected-resource|authorization-server)(?:/.*)?/?$`) then wins
725 // the match and our query var is never set, even though the URL is
726 // unambiguously ours. Observed live with two different plugins on one
727 // site. parse_request runs AFTER matching, so the path is the one
728 // signal no sibling rule can take away from us.
729 //
730 // The query VAR is deliberately never consulted. It is public, so
731 // $_GET can set it on any URL, and answering from it would put our
732 // metadata on somebody else's address — the whole bug. Which rewrite
733 // RULE matched is a different thing: WordPress decides it, the query
734 // string cannot influence it, and it is only read to tell "a sibling
735 // owns this URL" from "we own it and nobody else can answer".
736 $matched_query = is_object( $wp ) && isset( $wp->matched_query ) ? (string) $wp->matched_query : '';
737 $claim = $this->wellknown_claim_from_path( $matched_query );
738 $doc = $claim['doc'];
739 if ( '' !== $doc ) {
740 $data = 'authorization-server' === $doc
741 ? Mcp_OAuth::authorization_server_metadata( $claim['issuer'] )
742 : Mcp_OAuth::protected_resource_metadata( $claim['issuer'] );
743 status_header( 200 );
744 header( 'Content-Type: application/json; charset=utf-8' );
745 // Public and cacheable, but short: this document IS the server's
746 // identity, and a cached copy outliving an issuer change is the
747 // one failure a client cannot recover from on its own. (#266)
748 header( 'Cache-Control: public, max-age=300' );
749 echo wp_json_encode( $data );
750 exit;
751 }
752
753 // Pretty attach-callback: /xspeed/mcp/attach. The hub POSTs the signed
754 // nonce; we verify it and return this site's URL + token. Auth is the
755 // nonce itself (admin-minted, HMAC-signed), so no credential needed.
756 if ( ! empty( $wp->query_vars[ self::ATTACH_QUERY_VAR ] ) ) {
757 $body = json_decode( (string) file_get_contents( 'php://input' ), true );
758 $nonce = is_array( $body ) && isset( $body['nonce'] ) ? (string) $body['nonce'] : '';
759 $result = Mcp_Hub::verify_attach_nonce( $nonce );
760 header( 'Content-Type: application/json; charset=utf-8' );
761 header( 'Cache-Control: no-store' );
762 if ( null === $result ) {
763 status_header( 403 );
764 echo wp_json_encode( array( 'error' => 'invalid_or_expired_attach_request' ) );
765 } else {
766 status_header( 200 );
767 echo wp_json_encode( $result );
768 }
769 exit;
770 }
771
772 // Browser-facing OAuth consent page (cookie auth applies here).
773 if ( ! empty( $wp->query_vars[ self::AUTHORIZE_QUERY_VAR ] ) ) {
774 $this->handle_authorize_page();
775 return;
776 }
777
778 if ( empty( $wp->query_vars[ self::QUERY_VAR ] ) ) {
779 return;
780 }
781
782 $request = new \WP_REST_Request( 'POST', '/xspeed/v1/mcp' );
783 $request->set_header( 'content-type', 'application/json' );
784 // Carry the auth headers + raw body from the live PHP request.
785 foreach ( array( 'authorization', Mcp_Auth::TOKEN_HEADER ) as $h ) {
786 $val = self::server_header( $h );
787 if ( null !== $val ) {
788 $request->set_header( $h, $val );
789 }
790 }
791 // Token embedded in the URL path (/xspeed/mcp/<token>) — surface it
792 // as the standard token header so Mcp_Server validates it the same
793 // way. A header/Bearer token (if also sent) still takes precedence.
794 $path_token = isset( $wp->query_vars[ self::TOKEN_QUERY_VAR ] )
795 ? (string) $wp->query_vars[ self::TOKEN_QUERY_VAR ]
796 : '';
797 if ( '' !== $path_token && '' === (string) $request->get_header( Mcp_Auth::TOKEN_HEADER ) && '' === (string) $request->get_header( 'authorization' ) ) {
798 $request->set_header( Mcp_Auth::TOKEN_HEADER, $path_token );
799 }
800 $request->set_body( file_get_contents( 'php://input' ) );
801
802 $response = Mcp_Server::handle( $request );
803 $this->emit_json( $response );
804 }
805
806 // -- REST registration --
807
808 public function register_rest(): void {
809 // --- MCP JSON-RPC endpoint (fallback path via wp-json) -----------
810 // permission_callback is __return_true because Mcp_Server does its
811 // own token auth and must reply with a JSON-RPC 401, not a bare WP
812 // permission failure.
813 register_rest_route(
814 self::NS,
815 '/mcp',
816 array(
817 'methods' => 'POST',
818 'callback' => array( $this, 'rest_mcp' ),
819 'permission_callback' => '__return_true',
820 )
821 );
822
823 // --- Public scan signals -----------------------------------------
824 // One tiny unauthenticated JSON body for external audit tools (the
825 // speed scanner on xspeedcache.com): plugin version, whether the MCP
826 // server is connected, and whether the site is attached to xSpeed
827 // Hub. Everything except `hub` is already publicly discoverable —
828 // the cache signature carries the version and /mcp answers 401 when
829 // connected — and `hub` is a bare boolean. No tokens, accounts or
830 // emails leave through this route.
831 register_rest_route(
832 self::NS,
833 '/signals',
834 array(
835 'methods' => 'GET',
836 'callback' => array( $this, 'rest_signals' ),
837 'permission_callback' => '__return_true',
838 )
839 );
840
841 // --- Admin-only management routes (dashboard) --------------------
842 register_rest_route(
843 self::NS,
844 '/mcp/connection',
845 array(
846 'methods' => 'GET',
847 'callback' => array( $this, 'rest_connection' ),
848 'permission_callback' => array( $this, 'admin_permission' ),
849 )
850 );
851 register_rest_route(
852 self::NS,
853 '/mcp/activity',
854 array(
855 'methods' => 'GET',
856 'callback' => array( $this, 'rest_activity' ),
857 'permission_callback' => array( $this, 'admin_permission' ),
858 'args' => array(
859 'limit' => array(
860 'type' => 'integer',
861 'required' => false,
862 'default' => 50,
863 'description' => 'Maximum entries to return (newest first).',
864 ),
865 ),
866 )
867 );
868 register_rest_route(
869 self::NS,
870 '/mcp/activity/clear',
871 array(
872 'methods' => 'POST',
873 'callback' => array( $this, 'rest_activity_clear' ),
874 'permission_callback' => array( $this, 'admin_permission' ),
875 )
876 );
877 register_rest_route(
878 self::NS,
879 '/mcp/connect',
880 array(
881 'methods' => 'POST',
882 'callback' => array( $this, 'rest_connect' ),
883 'permission_callback' => array( $this, 'admin_permission' ),
884 'args' => array(
885 'read_only' => array(
886 'type' => 'boolean',
887 'required' => false,
888 'default' => false,
889 'description' => 'Grant read-only access (no purge/toggle/settings changes).',
890 ),
891 ),
892 )
893 );
894 register_rest_route(
895 self::NS,
896 '/mcp/rotate',
897 array(
898 'methods' => 'POST',
899 'callback' => array( $this, 'rest_rotate' ),
900 'permission_callback' => array( $this, 'admin_permission' ),
901 'args' => array(
902 'read_only' => array(
903 'type' => 'boolean',
904 'required' => false,
905 'description' => 'Optionally set read-only on the new token; omit to keep current scopes.',
906 ),
907 ),
908 )
909 );
910 register_rest_route(
911 self::NS,
912 '/mcp/access',
913 array(
914 'methods' => 'POST',
915 'callback' => array( $this, 'rest_access' ),
916 'permission_callback' => array( $this, 'admin_permission' ),
917 'args' => array(
918 'read_only' => array(
919 'type' => 'boolean',
920 'required' => true,
921 'description' => 'Switch the live connection to read-only (true) or read & write (false), keeping the same token.',
922 ),
923 ),
924 )
925 );
926 register_rest_route(
927 self::NS,
928 '/mcp/disconnect',
929 array(
930 'methods' => 'POST',
931 'callback' => array( $this, 'rest_disconnect' ),
932 'permission_callback' => array( $this, 'admin_permission' ),
933 )
934 );
935
936 // --- xSpeed Hub (multi-site) attach routes ------------------------
937 register_rest_route(
938 self::NS,
939 '/mcp/hub',
940 array(
941 'methods' => 'GET',
942 'callback' => array( $this, 'rest_hub_status' ),
943 'permission_callback' => array( $this, 'admin_permission' ),
944 )
945 );
946 register_rest_route(
947 self::NS,
948 '/mcp/hub/token',
949 array(
950 'methods' => 'POST',
951 'callback' => array( $this, 'rest_hub_token' ),
952 'permission_callback' => array( $this, 'admin_permission' ),
953 )
954 );
955 register_rest_route(
956 self::NS,
957 '/mcp/hub/attached',
958 array(
959 'methods' => 'POST',
960 'callback' => array( $this, 'rest_hub_attached' ),
961 'permission_callback' => array( $this, 'admin_permission' ),
962 'args' => array(
963 'account_email' => array(
964 'type' => 'string',
965 'required' => true,
966 'description' => 'The hub account email this site was attached to.',
967 ),
968 ),
969 )
970 );
971 register_rest_route(
972 self::NS,
973 '/mcp/hub/disconnect',
974 array(
975 'methods' => 'POST',
976 'callback' => array( $this, 'rest_hub_disconnect' ),
977 'permission_callback' => array( $this, 'admin_permission' ),
978 )
979 );
980 // OAuth-attach callback: the hub calls this with the signed nonce the
981 // plugin issued. Auth is the nonce itself (no pre-shared token), so
982 // permission_callback is open — the handler validates the nonce.
983 register_rest_route(
984 self::NS,
985 '/mcp/attach',
986 array(
987 'methods' => 'POST',
988 'callback' => array( $this, 'rest_hub_attach_callback' ),
989 'permission_callback' => '__return_true',
990 'args' => array(
991 'nonce' => array(
992 'type' => 'string',
993 'required' => true,
994 'description' => 'The signed attach nonce the plugin issued.',
995 ),
996 ),
997 )
998 );
999
1000 // --- OAuth 2.1 authorization server (the "paste a URL only" path) -
1001 // Discovery, dynamic client registration, and the token endpoint are
1002 // all public (permission enforced inside): a client must reach them
1003 // BEFORE it holds any credential. The authorize endpoint gates on a
1004 // logged-in admin inside its handler (anonymous → wp-login redirect).
1005 register_rest_route(
1006 self::NS,
1007 '/mcp/oauth/register',
1008 array(
1009 'methods' => 'POST',
1010 'callback' => array( $this, 'rest_oauth_register' ),
1011 'permission_callback' => '__return_true',
1012 )
1013 );
1014 // NOTE: /authorize is deliberately NOT a REST route — it is served as a
1015 // normal front-end page at /xspeed/authorize (see handle_authorize_page)
1016 // so cookie auth works after the wp-login round-trip.
1017 register_rest_route(
1018 self::NS,
1019 '/mcp/oauth/token',
1020 array(
1021 'methods' => 'POST',
1022 'callback' => array( $this, 'rest_oauth_token' ),
1023 'permission_callback' => '__return_true',
1024 )
1025 );
1026
1027 // --- OAuth discovery, REST fallback ------------------------------
1028 // The canonical documents live at /.well-known/… via rewrite rules.
1029 // Many hosts own that prefix for ACME/Let's Encrypt (an nginx
1030 // `location ^~ /.well-known` block, or an edge redirect rule), which
1031 // swallows the request before WordPress ever runs — the pretty URL
1032 // then 404s or redirects to the homepage no matter how the plugin is
1033 // configured, and OAuth discovery dead-ends with no way back.
1034 // Serving the same two documents under /wp-json puts them on a path
1035 // no ACME tooling claims, so discovery still completes there.
1036 register_rest_route(
1037 self::NS,
1038 '/mcp/.well-known/oauth-protected-resource',
1039 array(
1040 'methods' => 'GET',
1041 'callback' => array( $this, 'rest_protected_resource_metadata' ),
1042 'permission_callback' => '__return_true',
1043 )
1044 );
1045 register_rest_route(
1046 self::NS,
1047 '/mcp/.well-known/oauth-authorization-server',
1048 array(
1049 'methods' => 'GET',
1050 'callback' => array( $this, 'rest_authorization_server_metadata' ),
1051 'permission_callback' => '__return_true',
1052 )
1053 );
1054
1055 // --- MCP-token-only tool routes (optional hosted-broker path) ----
1056 $tool_perm = array( Mcp_Auth::class, 'permission' );
1057 register_rest_route(
1058 self::NS,
1059 // [a-z0-9_-]+ — the HYPHEN is the one that matters, not the digit.
1060 // Generated tool names carry their module slug verbatim, and 33 of
1061 // the 92 in the catalog have a hyphenated slug
1062 // (xspeed_cache-404_status, xspeed_migration-pro_apply,
1063 // xspeed_smart-predict_status …). Every one of those returned
1064 // rest_no_route through the broker path. The earlier widening to
1065 // [a-z0-9_]+ un-blocked nothing: the only digit-bearing name is
1066 // cache-404, whose problem was the hyphen. (QA on #158) */
1067 '/mcp/tool/(?P<tool>[a-z0-9_-]+)',
1068 array(
1069 array(
1070 'methods' => 'GET',
1071 'callback' => array( $this, 'rest_tool' ),
1072 'permission_callback' => $tool_perm,
1073 ),
1074 array(
1075 'methods' => 'POST',
1076 'callback' => array( $this, 'rest_tool' ),
1077 'permission_callback' => $tool_perm,
1078 ),
1079 )
1080 );
1081 }
1082
1083 /**
1084 * Capability gate for the admin-only management routes.
1085 *
1086 * @return bool
1087 */
1088 public function admin_permission(): bool {
1089 return current_user_can( 'manage_options' );
1090 }
1091
1092 // -- Handlers ----------------------------------------------------------
1093
1094 /**
1095 * MCP JSON-RPC over the wp-json fallback path.
1096 *
1097 * @param \WP_REST_Request $request Incoming request.
1098 * @return \WP_REST_Response
1099 */
1100 public function rest_mcp( \WP_REST_Request $request ) {
1101 $response = Mcp_Server::handle( $request );
1102 // Advertise the MCP protocol version on the wp-json transport too, so
1103 // both endpoints behave identically to a strict Streamable-HTTP client.
1104 $response->header( 'MCP-Protocol-Version', Mcp_Server::PROTOCOL_VERSION );
1105 return $response;
1106 }
1107
1108 /**
1109 * GET /signals — the public scan-signals body. See the route
1110 * registration for what may (and may not) leave through it.
1111 *
1112 * @return \WP_REST_Response
1113 */
1114 public function rest_signals() {
1115 $signals = array(
1116 'xspeed' => XSPEED_VERSION,
1117 'mcp' => '' !== Mcp_Pairing::site_token(),
1118 'hub' => Mcp_Hub::site_attached(),
1119 );
1120
1121 /**
1122 * Filter the public scan signals.
1123 *
1124 * Lets an add-on append its own public facts (e.g. its version
1125 * under `pro`). Values returned here are served UNAUTHENTICATED —
1126 * never add tokens, accounts, emails, or paths.
1127 *
1128 * @param array<string,mixed> $signals The signals body.
1129 */
1130 $signals = (array) apply_filters( 'xspeed_scan_signals', $signals );
1131
1132 return rest_ensure_response( $signals );
1133 }
1134
1135 /**
1136 * GET /mcp/connection — pairing status for the dashboard.
1137 *
1138 * @param \WP_REST_Request $request Unused.
1139 * @return \WP_REST_Response
1140 */
1141 public function rest_connection( \WP_REST_Request $request ) {
1142 unset( $request );
1143 return rest_ensure_response( Mcp_Pairing::public_status() );
1144 }
1145
1146 /**
1147 * GET /mcp/activity — the audit trail of AI tool calls.
1148 *
1149 * @param \WP_REST_Request $request Carries the optional limit.
1150 * @return \WP_REST_Response|\WP_Error
1151 */
1152 public function rest_activity( \WP_REST_Request $request ) {
1153 $limit = (int) $request->get_param( 'limit' );
1154
1155 return rest_ensure_response(
1156 array(
1157 'entries' => Mcp_Activity_Log::entries( $limit > 0 ? $limit : 50 ),
1158 'summary' => Mcp_Activity_Log::summary(),
1159 )
1160 );
1161 }
1162
1163 /**
1164 * POST /mcp/activity/clear — wipe the audit trail.
1165 *
1166 * @param \WP_REST_Request $request Unused.
1167 * @return \WP_REST_Response|\WP_Error
1168 */
1169 public function rest_activity_clear( \WP_REST_Request $request ) {
1170 unset( $request );
1171 $cleared = Mcp_Activity_Log::clear();
1172
1173 return rest_ensure_response(
1174 array(
1175 'cleared' => $cleared,
1176 'entries' => Mcp_Activity_Log::entries(),
1177 'summary' => Mcp_Activity_Log::summary(),
1178 )
1179 );
1180 }
1181
1182 /**
1183 * POST /mcp/connect — mint a connection token.
1184 *
1185 * @param \WP_REST_Request $request Unused.
1186 * @return \WP_REST_Response|\WP_Error
1187 */
1188 public function rest_connect( \WP_REST_Request $request ) {
1189 $read_only = (bool) $request->get_param( 'read_only' );
1190 $result = Mcp_Pairing::connect( $read_only );
1191 if ( is_wp_error( $result ) ) {
1192 return $result;
1193 }
1194 return rest_ensure_response( $result );
1195 }
1196
1197 /**
1198 * POST /mcp/rotate — mint a fresh token, invalidating the old one.
1199 *
1200 * @param \WP_REST_Request $request Carries optional read_only.
1201 * @return \WP_REST_Response
1202 */
1203 public function rest_rotate( \WP_REST_Request $request ) {
1204 $read_only = null;
1205 if ( null !== $request->get_param( 'read_only' ) ) {
1206 $read_only = (bool) $request->get_param( 'read_only' );
1207 }
1208 return rest_ensure_response( Mcp_Pairing::rotate( $read_only ) );
1209 }
1210
1211 /**
1212 * POST /mcp/access — change the live connection's read-only state WITHOUT
1213 * minting a new token (the paired client keeps working; only its allowed
1214 * tools change). This is what the dashboard's read-only toggle calls.
1215 *
1216 * @param \WP_REST_Request $request Carries the required read_only bool.
1217 * @return \WP_REST_Response|\WP_Error
1218 */
1219 public function rest_access( \WP_REST_Request $request ) {
1220 $read_only = (bool) $request->get_param( 'read_only' );
1221 $result = Mcp_Pairing::set_read_only( $read_only );
1222 if ( is_wp_error( $result ) ) {
1223 return $result;
1224 }
1225 return rest_ensure_response( $result );
1226 }
1227
1228 /**
1229 * POST /mcp/disconnect — revoke the connection token.
1230 *
1231 * @param \WP_REST_Request $request Unused.
1232 * @return \WP_REST_Response
1233 */
1234 public function rest_disconnect( \WP_REST_Request $request ) {
1235 unset( $request );
1236 return rest_ensure_response( Mcp_Pairing::disconnect() );
1237 }
1238
1239 // -- xSpeed Hub (multi-site) handlers ----------------------------------
1240
1241 /**
1242 * GET /mcp/hub — hub-link status + the Method-1 paste-in values.
1243 *
1244 * @param \WP_REST_Request $request Unused.
1245 * @return \WP_REST_Response
1246 */
1247 public function rest_hub_status( \WP_REST_Request $request ) {
1248 // Self-heal from the Hub (source of truth) so the connected badge is
1249 // reliable even if the attach callback never fired. Force a fresh check
1250 // when the panel asks via the X-XSpeed-Reconcile header (e.g. the admin
1251 // returned to the tab after connecting).
1252 $force = '1' === (string) $request->get_header( 'x_xspeed_reconcile' );
1253 Mcp_Hub::reconcile_with_hub( $force );
1254 return rest_ensure_response( Mcp_Hub::public_status() );
1255 }
1256
1257 /**
1258 * POST /mcp/hub/token — ensure a site_token exists and return the
1259 * paste-in values (this site's URL + token) for the hub's Add-site form.
1260 *
1261 * @param \WP_REST_Request $request Unused.
1262 * @return \WP_REST_Response
1263 */
1264 public function rest_hub_token( \WP_REST_Request $request ) {
1265 unset( $request );
1266 return rest_ensure_response( Mcp_Hub::generate_token() );
1267 }
1268
1269 /**
1270 * POST /mcp/hub/attached — record which hub account this site is
1271 * attached to (bookkeeping for the panel's status line).
1272 *
1273 * @param \WP_REST_Request $request Carries account_email.
1274 * @return \WP_REST_Response
1275 */
1276 public function rest_hub_attached( \WP_REST_Request $request ) {
1277 $email = sanitize_email( (string) $request->get_param( 'account_email' ) );
1278 return rest_ensure_response( Mcp_Hub::mark_attached( $email ) );
1279 }
1280
1281 /**
1282 * POST /mcp/hub/disconnect — clear the local hub-link bookkeeping.
1283 *
1284 * @param \WP_REST_Request $request Unused.
1285 * @return \WP_REST_Response
1286 */
1287 public function rest_hub_disconnect( \WP_REST_Request $request ) {
1288 unset( $request );
1289 return rest_ensure_response( Mcp_Hub::disconnect() );
1290 }
1291
1292 /**
1293 * POST /mcp/attach — the OAuth-attach callback. The hub presents the
1294 * signed nonce the plugin issued; on success we return this site's URL +
1295 * token so the hub can record it. Nonce is the auth (admin-minted,
1296 * HMAC-signed, time-bound), so no pre-shared token is required.
1297 *
1298 * @param \WP_REST_Request $request Carries the nonce.
1299 * @return \WP_REST_Response|\WP_Error
1300 */
1301 public function rest_hub_attach_callback( \WP_REST_Request $request ) {
1302 $nonce = (string) $request->get_param( 'nonce' );
1303 $result = Mcp_Hub::verify_attach_nonce( $nonce );
1304 if ( null === $result ) {
1305 return new \WP_Error(
1306 'xspeed_attach_invalid',
1307 __( 'Invalid or expired attach request.', 'xspeed' ),
1308 array( 'status' => 403 )
1309 );
1310 }
1311 // A valid nonce proves this is a real hub-initiated attach, so record it
1312 // now — the hub passes the account email so the panel can show
1313 // "Connected via <email>". The nonce carries the minting admin's user
1314 // id (no WP session exists in this server-to-server call), so the state
1315 // is recorded PER-USER — each admin sees their own connection.
1316 $account_email = sanitize_email( (string) $request->get_param( 'account_email' ) );
1317 $user_id = isset( $result['user_id'] ) ? (int) $result['user_id'] : 0;
1318 Mcp_Hub::mark_attached( $account_email, $user_id ?: null );
1319
1320 // The hub only needs the credential; don't leak the internal user id.
1321 unset( $result['user_id'] );
1322 return rest_ensure_response( $result );
1323 }
1324
1325 // -- OAuth 2.1 handlers ------------------------------------------------
1326
1327 /**
1328 * GET /mcp/.well-known/oauth-protected-resource — RFC 9728 metadata.
1329 *
1330 * Byte-identical to what the /.well-known rewrite serves; both call the
1331 * same builder so the two locations can never drift.
1332 *
1333 * @return \WP_REST_Response
1334 */
1335 public function rest_protected_resource_metadata(): \WP_REST_Response {
1336 return $this->discovery_response( Mcp_OAuth::protected_resource_metadata() );
1337 }
1338
1339 /**
1340 * GET /mcp/.well-known/oauth-authorization-server — RFC 8414 metadata.
1341 *
1342 * @return \WP_REST_Response
1343 */
1344 public function rest_authorization_server_metadata(): \WP_REST_Response {
1345 return $this->discovery_response( Mcp_OAuth::authorization_server_metadata() );
1346 }
1347
1348 /**
1349 * Wrap a discovery document in a public, cacheable REST response.
1350 *
1351 * @param array<string,mixed> $data The metadata document.
1352 * @return \WP_REST_Response
1353 */
1354 private function discovery_response( array $data ): \WP_REST_Response {
1355 $response = new \WP_REST_Response( $data, 200 );
1356 // Same short window as the /.well-known/ emit site, same reason: the
1357 // document carries the server's identity. (#266)
1358 $response->header( 'Cache-Control', 'public, max-age=300' );
1359 return $response;
1360 }
1361
1362 /**
1363 * POST /mcp/oauth/register — RFC 7591 dynamic client registration.
1364 *
1365 * @param \WP_REST_Request $request JSON body with redirect_uris.
1366 * @return \WP_REST_Response|\WP_Error
1367 */
1368 public function rest_oauth_register( \WP_REST_Request $request ) {
1369 $body = $request->get_json_params();
1370 if ( ! is_array( $body ) ) {
1371 $body = array();
1372 }
1373 $result = Mcp_OAuth::register_client( $body );
1374 if ( is_wp_error( $result ) ) {
1375 return $result;
1376 }
1377 return new \WP_REST_Response( $result, 201 );
1378 }
1379
1380 /**
1381 * The browser-facing OAuth authorize page (served at /xspeed/authorize via
1382 * a rewrite, NOT the REST API — see AUTHORIZE_QUERY_VAR). Reads request
1383 * params from the superglobals because this is a normal front-end request
1384 * where cookie auth populates is_user_logged_in().
1385 *
1386 * GET renders the consent screen (requires a logged-in admin; anonymous
1387 * users go to wp-login and return here). POST is the nonce-checked consent
1388 * submission: Approve issues a code and 302s to the client's redirect_uri;
1389 * Deny 302s back with error=access_denied. Always emits its own response
1390 * (HTML page or redirect) and exits.
1391 */
1392 public function handle_authorize_page(): void {
1393 $is_post = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) );
1394 // Params come from GET on the consent link and POST on the form submit.
1395 // Nonce is verified below before any POST value is acted on.
1396 // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing
1397 $source = $is_post ? $_POST : $_GET;
1398 // phpcs:enable
1399 $params = array();
1400 foreach ( array( 'client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'approve', 'deny', '_xspeed_oauth_nonce' ) as $k ) {
1401 $params[ $k ] = isset( $source[ $k ] ) ? sanitize_text_field( wp_unslash( $source[ $k ] ) ) : '';
1402 }
1403
1404 // Validate the OAuth params before touching the session.
1405 $req = Mcp_OAuth::validate_authorize_request( $params );
1406 if ( is_wp_error( $req ) ) {
1407 $data = $req->get_error_data();
1408 $redirectable = is_array( $data ) && ! empty( $data['redirectable'] );
1409 // Only redirect the error back when redirect_uri is verified valid;
1410 // otherwise show a page (never bounce to an unverified URL).
1411 if ( $redirectable && '' !== $params['redirect_uri'] ) {
1412 $this->redirect_error( $params['redirect_uri'], $req->get_error_code(), $req->get_error_message(), $params['state'] );
1413 }
1414 $this->emit_oauth_error_page( $req->get_error_message() );
1415 }
1416
1417 // Require a logged-in admin. Anonymous → wp-login, back to this URL.
1418 if ( ! is_user_logged_in() ) {
1419 $this->redirect_to_login();
1420 }
1421 if ( ! current_user_can( 'manage_options' ) ) {
1422 $this->emit_oauth_error_page(
1423 __( 'You must be an administrator to authorize an AI agent to control this site.', 'xspeed' )
1424 );
1425 }
1426
1427 // POST = consent form submitted.
1428 if ( $is_post ) {
1429 if ( ! wp_verify_nonce( $params['_xspeed_oauth_nonce'], 'xspeed_oauth_consent' ) ) {
1430 $this->emit_oauth_error_page( __( 'Security check failed. Please try connecting again.', 'xspeed' ) );
1431 }
1432 if ( '' === $params['approve'] ) {
1433 $this->redirect_error( $req['redirect_uri'], 'access_denied', 'The user denied the request.', $req['state'] );
1434 }
1435 $code = Mcp_OAuth::issue_code( $req, get_current_user_id() );
1436 $this->redirect_success( $req['redirect_uri'], $code, $req['state'] );
1437 }
1438
1439 // GET = render the consent screen.
1440 $this->emit_consent_screen( $req );
1441 }
1442
1443 /**
1444 * POST /mcp/oauth/token — exchange a code (or refresh token) for tokens.
1445 *
1446 * @param \WP_REST_Request $request Form-encoded or JSON token request.
1447 * @return \WP_REST_Response
1448 */
1449 public function rest_oauth_token( \WP_REST_Request $request ) {
1450 // Token requests are application/x-www-form-urlencoded per OAuth, but
1451 // accept JSON too. get_body_params() covers the form case.
1452 $body = $request->get_body_params();
1453 if ( empty( $body ) ) {
1454 $json = $request->get_json_params();
1455 $body = is_array( $json ) ? $json : array();
1456 }
1457 $body = array_map( 'strval', $body );
1458
1459 $result = Mcp_OAuth::exchange_token( $body );
1460 if ( is_wp_error( $result ) ) {
1461 $data = $result->get_error_data();
1462 $response = new \WP_REST_Response(
1463 array(
1464 'error' => isset( $data['error'] ) ? $data['error'] : 'invalid_request',
1465 'error_description' => isset( $data['error_description'] ) ? $data['error_description'] : $result->get_error_message(),
1466 ),
1467 isset( $data['status'] ) ? (int) $data['status'] : 400
1468 );
1469 $response->header( 'Cache-Control', 'no-store' );
1470 return $response;
1471 }
1472 $response = new \WP_REST_Response( $result, 200 );
1473 $response->header( 'Cache-Control', 'no-store' );
1474 $response->header( 'Pragma', 'no-cache' );
1475 return $response;
1476 }
1477
1478 /**
1479 * Token-authenticated tool route for the hosted broker. Maps a broker
1480 * tool call (e.g. GET /mcp/tool/get_cache_status) onto the shared
1481 * Mcp_Tools catalog, so the broker path and the JSON-RPC path never
1482 * drift. GET params + JSON body both feed the tool's arguments.
1483 */
1484 public function rest_tool( \WP_REST_Request $request ) {
1485 $tool = (string) $request->get_param( 'tool' );
1486 $args = $request->get_json_params();
1487 if ( ! is_array( $args ) ) {
1488 $args = array();
1489 }
1490 // Merge query params (e.g. ?module=minify) so GET tools work too.
1491 foreach ( $request->get_query_params() as $k => $v ) {
1492 if ( 'tool' !== $k && ! array_key_exists( $k, $args ) ) {
1493 $args[ $k ] = $v;
1494 }
1495 }
1496
1497 Mcp_Tools::set_channel( 'broker' );
1498 $result = Mcp_Tools::invoke( $tool, $args );
1499 if ( is_wp_error( $result ) ) {
1500 return $result;
1501 }
1502 return rest_ensure_response( $result );
1503 }
1504
1505 // -- Helpers --
1506
1507 // -- OAuth browser-response helpers ------------------------------------
1508
1509 /** The absolute URL of the current authorize request (for login return). */
1510 private function current_authorize_url(): string {
1511 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- reconstructing the current URL for a login round-trip; escaped at use.
1512 $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
1513 return home_url( $uri );
1514 }
1515
1516 /** Send an anonymous visitor to wp-login, returning to this authorize URL. */
1517 private function redirect_to_login(): void {
1518 wp_safe_redirect( wp_login_url( $this->current_authorize_url() ) );
1519 exit;
1520 }
1521
1522 /** 302 back to the client with the authorization code (+ state). */
1523 private function redirect_success( string $redirect_uri, string $code, string $state ): void {
1524 $args = array( 'code' => $code );
1525 if ( '' !== $state ) {
1526 $args['state'] = $state;
1527 }
1528 // Not wp_safe_redirect: redirect_uri is a client-registered off-site
1529 // callback, already validated against the client's registered set.
1530 wp_redirect( add_query_arg( $args, $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
1531 exit;
1532 }
1533
1534 /** 302 back to the client with an OAuth error (+ state). */
1535 private function redirect_error( string $redirect_uri, string $error, string $description, string $state ): void {
1536 $args = array(
1537 'error' => $error,
1538 'error_description' => $description,
1539 );
1540 if ( '' !== $state ) {
1541 $args['state'] = $state;
1542 }
1543 wp_redirect( add_query_arg( array_map( 'rawurlencode', $args ), $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
1544 exit;
1545 }
1546
1547 /**
1548 * Render the consent screen. Minimal self-contained HTML (no admin
1549 * chrome — this is a client-facing OAuth page). Approve/Deny post back
1550 * to the same authorize URL with a nonce.
1551 *
1552 * @param array<string,string> $req Validated authorize params.
1553 */
1554 private function emit_consent_screen( array $req ): void {
1555 $read_only = Mcp_OAuth::scope_is_read_only( $req['scope'] );
1556 $access = $read_only
1557 ? __( 'Read-only — inspect cache status and settings.', 'xspeed' )
1558 : __( 'Read & write — purge caches, toggle caching, and change settings.', 'xspeed' );
1559 $client = '' !== $req['client_name'] ? $req['client_name'] : __( 'An AI agent', 'xspeed' );
1560 $action_url = Mcp_OAuth::authorize_url();
1561 $nonce = wp_create_nonce( 'xspeed_oauth_consent' );
1562 $user = wp_get_current_user();
1563
1564 // Preserve every OAuth param so the POST re-validates identically.
1565 $hidden = '';
1566 foreach ( array( 'client_id', 'redirect_uri', 'code_challenge', 'scope', 'state' ) as $k ) {
1567 $val = 'scope' === $k ? $req['scope'] : ( $req[ $k ] ?? '' );
1568 $hidden .= sprintf( '<input type="hidden" name="%s" value="%s" />', esc_attr( $k ), esc_attr( (string) $val ) );
1569 }
1570 // code_challenge_method + response_type are re-asserted for validation.
1571 $hidden .= '<input type="hidden" name="code_challenge_method" value="S256" />';
1572 $hidden .= '<input type="hidden" name="response_type" value="code" />';
1573
1574 status_header( 200 );
1575 header( 'Content-Type: text/html; charset=utf-8' );
1576 header( 'Cache-Control: no-store' );
1577
1578 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>';
1579 echo '<style>'
1580 . '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}'
1581 . '.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)}'
1582 . 'h1{font-size:20px;margin:0 0 4px}.sub{color:#94a3b8;font-size:13px;margin:0 0 24px}'
1583 . '.row{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid #334155;font-size:13px}'
1584 . '.row span:first-child{color:#94a3b8}.row span:last-child{font-weight:600;text-align:right;max-width:60%;word-break:break-word}'
1585 . '.actions{display:flex;gap:12px;margin-top:24px}'
1586 . 'button{flex:1;padding:12px;border-radius:10px;border:0;font-size:14px;font-weight:600;cursor:pointer}'
1587 . '.approve{background:#f5cd47;color:#1b2533}.deny{background:transparent;color:#94a3b8;border:1px solid #334155}'
1588 . '</style></head><body><div class="card">';
1589 echo '<h1>' . esc_html__( 'Connect to xSpeed', 'xspeed' ) . '</h1>';
1590 /* translators: %s: AI client name. */
1591 echo '<p class="sub">' . esc_html( sprintf( __( '%s wants to manage the cache on this site.', 'xspeed' ), $client ) ) . '</p>';
1592 echo '<div class="row"><span>' . esc_html__( 'Site', 'xspeed' ) . '</span><span>' . esc_html( wp_parse_url( home_url(), PHP_URL_HOST ) ) . '</span></div>';
1593 echo '<div class="row"><span>' . esc_html__( 'Signed in as', 'xspeed' ) . '</span><span>' . esc_html( $user->user_login ) . '</span></div>';
1594 echo '<div class="row"><span>' . esc_html__( 'Access', 'xspeed' ) . '</span><span>' . esc_html( $access ) . '</span></div>';
1595 echo '<form method="post" action="' . esc_url( $action_url ) . '">';
1596 echo $hidden; // phpcs:ignore WordPress.Security.EscapeOutput -- built from esc_attr() above.
1597 echo '<input type="hidden" name="_xspeed_oauth_nonce" value="' . esc_attr( $nonce ) . '" />';
1598 echo '<div class="actions">';
1599 echo '<button class="deny" name="deny" value="1">' . esc_html__( 'Deny', 'xspeed' ) . '</button>';
1600 echo '<button class="approve" name="approve" value="1">' . esc_html__( 'Approve', 'xspeed' ) . '</button>';
1601 echo '</div></form></div></body></html>';
1602 exit;
1603 }
1604
1605 /** Render a standalone OAuth error page (no redirect). */
1606 private function emit_oauth_error_page( string $message ): void {
1607 status_header( 400 );
1608 header( 'Content-Type: text/html; charset=utf-8' );
1609 header( 'Cache-Control: no-store' );
1610 echo '<!doctype html><html><head><meta charset="utf-8"><title>' . esc_html__( 'Authorization error', 'xspeed' ) . '</title>';
1611 echo '<style>body{font:15px/1.5 -apple-system,sans-serif;background:#0f172a;color:#e2e8f0;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}'
1612 . '.card{background:#1e293b;border:1px solid #334155;border-radius:16px;max-width:440px;padding:32px;text-align:center}</style></head><body>';
1613 echo '<div class="card"><h1>' . esc_html__( 'Could not authorize', 'xspeed' ) . '</h1><p>' . esc_html( $message ) . '</p></div></body></html>';
1614 exit;
1615 }
1616
1617 /** Read an inbound HTTP header from $_SERVER (for the pretty path). */
1618 private static function server_header( string $name ): ?string {
1619 $key = 'HTTP_' . strtoupper( str_replace( '-', '_', $name ) );
1620 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- token compared constant-time downstream; raw header needed verbatim.
1621 return isset( $_SERVER[ $key ] ) ? wp_unslash( $_SERVER[ $key ] ) : null;
1622 }
1623
1624 /** Emit a WP_REST_Response as a JSON HTTP response and stop. */
1625 private function emit_json( \WP_REST_Response $response ): void {
1626 status_header( $response->get_status() );
1627 // MCP Streamable HTTP: advertise the protocol version we speak so a
1628 // strict client can pin it. We answer JSON (a spec-permitted response
1629 // type); we never open an SSE stream, so no session header is needed.
1630 header( 'MCP-Protocol-Version: ' . Mcp_Server::PROTOCOL_VERSION );
1631 // Forward any headers the handler set (notably WWW-Authenticate on a
1632 // 401, which drives the OAuth discovery flow). rest_do_request applies
1633 // these automatically; the pretty-endpoint path must do it by hand.
1634 foreach ( $response->get_headers() as $name => $value ) {
1635 header( $name . ': ' . $value );
1636 }
1637 $data = $response->get_data();
1638 if ( null !== $data ) {
1639 header( 'Content-Type: application/json; charset=utf-8' );
1640 echo wp_json_encode( $data );
1641 }
1642 exit;
1643 }
1644
1645 // -- WP-CLI mirror --
1646
1647 public function cli_commands(): array {
1648 return array(
1649 array(
1650 'name' => 'xspeed mcp status',
1651 'callback' => array( $this, 'cli_status' ),
1652 'shortdesc' => 'Show MCP connection status and the paste-in endpoint URL.',
1653 'synopsis' => array(),
1654 ),
1655 array(
1656 'name' => 'xspeed mcp activity',
1657 'callback' => array( $this, 'cli_activity' ),
1658 'shortdesc' => 'List recent MCP tool calls (the AI audit trail).',
1659 'synopsis' => array(
1660 array(
1661 'name' => 'limit',
1662 'type' => 'assoc',
1663 'optional' => true,
1664 'description' => 'Maximum entries to show (default 20).',
1665 ),
1666 array(
1667 'name' => 'clear',
1668 'type' => 'flag',
1669 'optional' => true,
1670 'description' => 'Wipe the audit trail instead of listing it.',
1671 ),
1672 ),
1673 ),
1674 array(
1675 'name' => 'xspeed mcp connect',
1676 'callback' => array( $this, 'cli_connect' ),
1677 'shortdesc' => 'Generate a connection token for this site\'s MCP endpoint.',
1678 'synopsis' => array(
1679 array(
1680 'name' => 'read-only',
1681 'type' => 'flag',
1682 'optional' => true,
1683 'description' => 'Grant read-only access (no purge/toggle/settings changes).',
1684 ),
1685 ),
1686 ),
1687 array(
1688 'name' => 'xspeed mcp rotate',
1689 'callback' => array( $this, 'cli_rotate' ),
1690 'shortdesc' => 'Mint a fresh MCP token, immediately invalidating the previous one.',
1691 'synopsis' => array(
1692 array(
1693 'name' => 'read-only',
1694 'type' => 'flag',
1695 'optional' => true,
1696 'description' => 'Make the new token read-only.',
1697 ),
1698 ),
1699 ),
1700 array(
1701 'name' => 'xspeed mcp disconnect',
1702 'callback' => array( $this, 'cli_disconnect' ),
1703 'shortdesc' => 'Revoke this site\'s MCP connection token.',
1704 'synopsis' => array(),
1705 ),
1706 );
1707 }
1708
1709 /**
1710 * `wp xspeed mcp status` — print connection status + endpoint URL.
1711 *
1712 * @param array $args Positional args (unused).
1713 * @param array $assoc Associative args (unused).
1714 */
1715 public function cli_status( array $args, array $assoc ): void {
1716 unset( $args, $assoc );
1717 $s = Mcp_Pairing::public_status();
1718 \WP_CLI::log( sprintf( '%-18s %s', 'connected', $s['connected'] ? 'yes' : 'no' ) );
1719 if ( $s['connected'] ) {
1720 \WP_CLI::log( sprintf( '%-18s %s', 'access', $s['read_only'] ? 'read-only' : 'read-write' ) );
1721 \WP_CLI::log( sprintf( '%-18s %s', 'connect_url', $s['connect_url'] ) );
1722 \WP_CLI::log( sprintf( '%-18s %s', 'scopes', implode( ',', $s['scopes'] ) ) );
1723 } else {
1724 \WP_CLI::log( sprintf( '%-18s %s', 'mcp_endpoint', Mcp_Pairing::site_endpoint() ) );
1725 }
1726 }
1727
1728 /**
1729 * `wp xspeed mcp activity` — read (or clear) the AI audit trail.
1730 *
1731 * @param array $args Positional args (unused).
1732 * @param array $assoc --limit=<n>, --clear.
1733 */
1734 public function cli_activity( array $args, array $assoc ): void {
1735 unset( $args );
1736
1737 if ( ! empty( $assoc['clear'] ) ) {
1738 if ( ! Mcp_Activity_Log::clear() ) {
1739 // Reached via MCP run_command — the assistant is asking to
1740 // erase the record of its own calls. Mcp_Activity_Log::clear()
1741 // declines and logs the attempt; say so plainly.
1742 \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.' );
1743 return;
1744 }
1745 \WP_CLI::success( 'MCP activity log cleared.' );
1746 return;
1747 }
1748
1749 $limit = isset( $assoc['limit'] ) ? (int) $assoc['limit'] : 20;
1750 $summary = Mcp_Activity_Log::summary();
1751 $entries = Mcp_Activity_Log::entries( $limit > 0 ? $limit : 20 );
1752
1753 \WP_CLI::log( sprintf( '%-18s %d', 'total_calls', $summary['total'] ) );
1754 \WP_CLI::log( sprintf( '%-18s %d', 'failed', $summary['failed'] ) );
1755 \WP_CLI::log( sprintf( '%-18s %s', 'top_tool', '' === $summary['top_tool'] ? '-' : $summary['top_tool'] ) );
1756
1757 if ( empty( $entries ) ) {
1758 \WP_CLI::log( '' );
1759 \WP_CLI::log( 'No MCP tool calls recorded yet.' );
1760 return;
1761 }
1762
1763 \WP_CLI::log( '' );
1764 foreach ( $entries as $entry ) {
1765 \WP_CLI::log(
1766 sprintf(
1767 '%s %-22s %-5s %-6s %s%s',
1768 gmdate( 'Y-m-d H:i:s', $entry['ts'] ),
1769 $entry['tool'],
1770 $entry['scope'],
1771 $entry['ok'] ? 'ok' : 'FAIL',
1772 $entry['args'],
1773 '' === $entry['error'] ? '' : '' . $entry['error']
1774 )
1775 );
1776 }
1777 }
1778
1779 /**
1780 * `wp xspeed mcp connect` — mint a token and print the paste-in URL.
1781 *
1782 * @param array $args Positional args (unused).
1783 * @param array $assoc Associative args (unused).
1784 */
1785 public function cli_connect( array $args, array $assoc ): void {
1786 unset( $args );
1787 $read_only = ! empty( $assoc['read-only'] );
1788 $result = Mcp_Pairing::connect( $read_only );
1789 if ( is_wp_error( $result ) ) {
1790 \WP_CLI::error( $result->get_error_message() );
1791 return;
1792 }
1793 \WP_CLI::success( 'Connected' . ( Mcp_Pairing::is_read_only() ? ' (read-only).' : '.' ) . ' Paste this single URL into your AI client:' );
1794 \WP_CLI::log( ' ' . Mcp_Pairing::connect_url() );
1795 \WP_CLI::log( '' );
1796 \WP_CLI::log( 'Or, header-based (token stays out of the URL):' );
1797 \WP_CLI::log( ' ' . Mcp_Pairing::config_snippets()['cli'] );
1798 }
1799
1800 /**
1801 * `wp xspeed mcp rotate` — mint a new token, revoking the old one.
1802 *
1803 * @param array $args Positional args (unused).
1804 * @param array $assoc Associative args ({ read-only?:flag }).
1805 */
1806 public function cli_rotate( array $args, array $assoc ): void {
1807 unset( $args );
1808 $read_only = array_key_exists( 'read-only', $assoc ) ? ! empty( $assoc['read-only'] ) : null;
1809 Mcp_Pairing::rotate( $read_only );
1810 \WP_CLI::success( 'Rotated. The previous token is now invalid. New paste-in URL:' );
1811 \WP_CLI::log( ' ' . Mcp_Pairing::connect_url() );
1812 }
1813
1814 /**
1815 * `wp xspeed mcp disconnect` — revoke the connection token.
1816 *
1817 * @param array $args Positional args (unused).
1818 * @param array $assoc Associative args (unused).
1819 */
1820 public function cli_disconnect( array $args, array $assoc ): void {
1821 unset( $args, $assoc );
1822 Mcp_Pairing::disconnect();
1823 \WP_CLI::success( 'Disconnected and revoked the MCP token.' );
1824 }
1825
1826 /**
1827 * MCP is on when a connection token exists -- it has no `enabled`
1828 * setting, so the sidebar counted the AI group as empty on a site with
1829 * a live read-write AI connection. Reads the same
1830 * `Mcp_Pairing::public_status()` the CLI and the panel do, so the count
1831 * cannot disagree with the badge on the panel. (#363)
1832 */
1833 public function is_active(): ?bool {
1834 $status = Mcp_Pairing::public_status();
1835 return ! empty( $status['connected'] );
1836 }
1837
1838 /**
1839 * MCP has no on/off setting -- it is on when a connection exists.
1840 */
1841 public function active_reason(): ?string {
1842 return $this->is_active()
1843 ? __( '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' )
1844 : __( 'No AI assistant is connected. This module counts as on once you connect one.', 'xspeed' );
1845 }
1846 }
1847