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
xspeed / includes / modules / Mcp / McpModule.php

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

1,922 lines 75.9 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 assistant clear the cache, check stats and change settings.', 'xspeed' ),
152 'custom_panel' => 'McpPanel',
153 'group' => 'ai-agents',
154 );
155 }
156
157 /**
158 * MCP pairing state lives in xspeed_module_mcp but is managed by
159 * Mcp_Pairing, not the schema engine. Empty schema so the base class
160 * doesn't auto-register generic settings routes.
161 */
162 public function settings_schema(): array {
163 return array();
164 }
165
166 /**
167 * All MCP routes register directly (see class docblock). Returning an
168 * empty array keeps Rest_Manager out of the token-auth path entirely.
169 */
170 public function rest_routes(): array {
171 return array();
172 }
173
174 public function boot(): void {
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' ) );
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
186 // Pretty per-site endpoint: /xspeed/mcp → MCP JSON-RPC handler.
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 );
208 add_filter( 'query_vars', array( $this, 'register_query_var' ) );
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' ) );
231 }
232
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 /**
296 * Flush rewrites once when the module first boots so /xspeed/mcp works
297 * without a manual permalink re-save. Cheap: gated on a one-shot flag.
298 */
299 public function activate(): void {
300 $this->add_rewrite();
301 flush_rewrite_rules( false );
302 }
303
304 public function deactivate(): void {
305 flush_rewrite_rules( false );
306 }
307
308 // -- Pretty endpoint: /xspeed/mcp --
309
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
318 // Token-in-URL form: /xspeed/mcp/<token> — a single string the user
319 // pastes into their AI client (no separate token field). The bare
320 // /xspeed/mcp still works with a Bearer/header token.
321 add_rewrite_rule(
322 '^xspeed/mcp/([a-f0-9]{64})/?$',
323 'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]',
324 'top'
325 );
326 add_rewrite_rule( '^xspeed/mcp/?$', 'index.php?' . self::QUERY_VAR . '=1', 'top' );
327
328 // Pretty attach-callback endpoint: /xspeed/mcp/attach — the hub POSTs
329 // the signed nonce here to verify + fetch the token. Uses the plugin's
330 // own rewrite (consistent with the MCP URL, survives hosts that block
331 // /wp-json). Placed BEFORE the token rule would never match "attach"
332 // (that rule requires 64 hex chars), so ordering is safe.
333 add_rewrite_rule( '^xspeed/mcp/attach/?$', 'index.php?' . self::ATTACH_QUERY_VAR . '=1', 'top' );
334
335 // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place the
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.
349 add_rewrite_rule(
350 '^\\.well-known/oauth-(protected-resource|authorization-server)/xspeed/mcp/?$',
351 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
352 'top'
353 );
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
375 // Browser-facing OAuth consent page — served OUTSIDE REST so cookie
376 // auth (is_user_logged_in) works after the wp-login round-trip.
377 add_rewrite_rule( '^xspeed/authorize/?$', 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', 'top' );
378
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 {
580 $rules = get_option( 'rewrite_rules' );
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;
599 }
600 }
601
602 return false;
603 }
604
605 /**
606 * @param string[] $vars Registered query vars.
607 * @return string[]
608 */
609 public function register_query_var( array $vars ): array {
610 $vars[] = self::QUERY_VAR;
611 $vars[] = self::TOKEN_QUERY_VAR;
612 $vars[] = self::WELLKNOWN_QUERY_VAR;
613 $vars[] = self::AUTHORIZE_QUERY_VAR;
614 $vars[] = self::ATTACH_QUERY_VAR;
615 return $vars;
616 }
617
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 /**
721 * Serve the MCP endpoint on the pretty path. Runs on parse_request so
722 * it fires before the main query, and short-circuits WP entirely.
723 *
724 * @param \WP $wp The WP request object.
725 */
726 public function maybe_handle_pretty_endpoint( $wp ): void {
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 ) {
750 $data = 'authorization-server' === $doc
751 ? Mcp_OAuth::authorization_server_metadata( $claim['issuer'] )
752 : Mcp_OAuth::protected_resource_metadata( $claim['issuer'] );
753 status_header( 200 );
754 header( 'Content-Type: application/json; charset=utf-8' );
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' );
759 echo wp_json_encode( $data );
760 exit;
761 }
762
763 // Pretty attach-callback: /xspeed/mcp/attach. The hub POSTs the signed
764 // nonce; we verify it and return this site's URL + token. Auth is the
765 // nonce itself (admin-minted, HMAC-signed), so no credential needed.
766 if ( ! empty( $wp->query_vars[ self::ATTACH_QUERY_VAR ] ) ) {
767 $body = json_decode( (string) file_get_contents( 'php://input' ), true );
768 $nonce = is_array( $body ) && isset( $body['nonce'] ) ? (string) $body['nonce'] : '';
769 $result = Mcp_Hub::verify_attach_nonce( $nonce );
770 header( 'Content-Type: application/json; charset=utf-8' );
771 header( 'Cache-Control: no-store' );
772 if ( null === $result ) {
773 status_header( 403 );
774 echo wp_json_encode( array( 'error' => 'invalid_or_expired_attach_request' ) );
775 } else {
776 // The Hub needs only the credential, as on the REST route.
777 unset( $result['user_id'] );
778 status_header( 200 );
779 echo wp_json_encode( $result );
780 }
781 exit;
782 }
783
784 // Browser-facing OAuth consent page (cookie auth applies here).
785 if ( ! empty( $wp->query_vars[ self::AUTHORIZE_QUERY_VAR ] ) ) {
786 $this->handle_authorize_page();
787 return;
788 }
789
790 if ( empty( $wp->query_vars[ self::QUERY_VAR ] ) ) {
791 return;
792 }
793
794 $request = new \WP_REST_Request( 'POST', '/xspeed/v1/mcp' );
795 $request->set_header( 'content-type', 'application/json' );
796 // Carry the auth headers + raw body from the live PHP request.
797 foreach ( array( 'authorization', Mcp_Auth::TOKEN_HEADER ) as $h ) {
798 $val = self::server_header( $h );
799 if ( null !== $val ) {
800 $request->set_header( $h, $val );
801 }
802 }
803 // Token embedded in the URL path (/xspeed/mcp/<token>) — surface it
804 // as the standard token header so Mcp_Server validates it the same
805 // way. A header/Bearer token (if also sent) still takes precedence.
806 $path_token = isset( $wp->query_vars[ self::TOKEN_QUERY_VAR ] )
807 ? (string) $wp->query_vars[ self::TOKEN_QUERY_VAR ]
808 : '';
809 if ( '' !== $path_token && '' === (string) $request->get_header( Mcp_Auth::TOKEN_HEADER ) && '' === (string) $request->get_header( 'authorization' ) ) {
810 $request->set_header( Mcp_Auth::TOKEN_HEADER, $path_token );
811 }
812 $request->set_body( file_get_contents( 'php://input' ) );
813
814 $response = Mcp_Server::handle( $request );
815 $this->emit_json( $response );
816 }
817
818 // -- REST registration --
819
820 public function register_rest(): void {
821 // --- MCP JSON-RPC endpoint (fallback path via wp-json) -----------
822 // permission_callback is __return_true because Mcp_Server does its
823 // own token auth and must reply with a JSON-RPC 401, not a bare WP
824 // permission failure.
825 register_rest_route(
826 self::NS,
827 '/mcp',
828 array(
829 'methods' => 'POST',
830 'callback' => array( $this, 'rest_mcp' ),
831 'permission_callback' => '__return_true',
832 )
833 );
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
853 // --- Admin-only management routes (dashboard) --------------------
854 register_rest_route(
855 self::NS,
856 '/mcp/connection',
857 array(
858 'methods' => 'GET',
859 'callback' => array( $this, 'rest_connection' ),
860 'permission_callback' => array( $this, 'admin_permission' ),
861 )
862 );
863 register_rest_route(
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,
891 '/mcp/connect',
892 array(
893 'methods' => 'POST',
894 'callback' => array( $this, 'rest_connect' ),
895 'permission_callback' => array( $this, 'admin_permission' ),
896 'args' => array(
897 'read_only' => array(
898 'type' => 'boolean',
899 'required' => false,
900 'default' => false,
901 'description' => 'Grant read-only access (no purge/toggle/settings changes).',
902 ),
903 ),
904 )
905 );
906 register_rest_route(
907 self::NS,
908 '/mcp/rotate',
909 array(
910 'methods' => 'POST',
911 'callback' => array( $this, 'rest_rotate' ),
912 'permission_callback' => array( $this, 'admin_permission' ),
913 'args' => array(
914 'read_only' => array(
915 'type' => 'boolean',
916 'required' => false,
917 'description' => 'Optionally set read-only on the new token; omit to keep current scopes.',
918 ),
919 ),
920 )
921 );
922 register_rest_route(
923 self::NS,
924 '/mcp/access',
925 array(
926 'methods' => 'POST',
927 'callback' => array( $this, 'rest_access' ),
928 'permission_callback' => array( $this, 'admin_permission' ),
929 'args' => array(
930 'read_only' => array(
931 'type' => 'boolean',
932 'required' => true,
933 'description' => 'Switch the live connection to read-only (true) or read & write (false), keeping the same token.',
934 ),
935 ),
936 )
937 );
938 register_rest_route(
939 self::NS,
940 '/mcp/disconnect',
941 array(
942 'methods' => 'POST',
943 'callback' => array( $this, 'rest_disconnect' ),
944 'permission_callback' => array( $this, 'admin_permission' ),
945 )
946 );
947
948 // --- xSpeed Hub (multi-site) attach routes ------------------------
949 register_rest_route(
950 self::NS,
951 '/mcp/hub',
952 array(
953 'methods' => 'GET',
954 'callback' => array( $this, 'rest_hub_status' ),
955 'permission_callback' => array( $this, 'admin_permission' ),
956 )
957 );
958 register_rest_route(
959 self::NS,
960 '/mcp/hub/token',
961 array(
962 'methods' => 'POST',
963 'callback' => array( $this, 'rest_hub_token' ),
964 'permission_callback' => array( $this, 'admin_permission' ),
965 )
966 );
967 register_rest_route(
968 self::NS,
969 '/mcp/hub/attached',
970 array(
971 'methods' => 'POST',
972 'callback' => array( $this, 'rest_hub_attached' ),
973 'permission_callback' => array( $this, 'admin_permission' ),
974 'args' => array(
975 'account_email' => array(
976 'type' => 'string',
977 'required' => true,
978 'description' => 'The hub account email this site was attached to.',
979 ),
980 ),
981 )
982 );
983 register_rest_route(
984 self::NS,
985 '/mcp/hub/disconnect',
986 array(
987 'methods' => 'POST',
988 'callback' => array( $this, 'rest_hub_disconnect' ),
989 'permission_callback' => array( $this, 'admin_permission' ),
990 )
991 );
992 // OAuth-attach callback: the hub calls this with the signed nonce the
993 // plugin issued. Auth is the nonce itself (no pre-shared token), so
994 // permission_callback is open — the handler validates the nonce.
995 register_rest_route(
996 self::NS,
997 '/mcp/attach',
998 array(
999 'methods' => 'POST',
1000 'callback' => array( $this, 'rest_hub_attach_callback' ),
1001 'permission_callback' => '__return_true',
1002 'args' => array(
1003 'nonce' => array(
1004 'type' => 'string',
1005 'required' => true,
1006 'description' => 'The signed attach nonce the plugin issued.',
1007 ),
1008 ),
1009 )
1010 );
1011
1012 // --- OAuth 2.1 authorization server (the "paste a URL only" path) -
1013 // Discovery, dynamic client registration, and the token endpoint are
1014 // all public (permission enforced inside): a client must reach them
1015 // BEFORE it holds any credential. The authorize endpoint gates on a
1016 // logged-in admin inside its handler (anonymous → wp-login redirect).
1017 register_rest_route(
1018 self::NS,
1019 '/mcp/oauth/register',
1020 array(
1021 'methods' => 'POST',
1022 'callback' => array( $this, 'rest_oauth_register' ),
1023 'permission_callback' => '__return_true',
1024 )
1025 );
1026 // NOTE: /authorize is deliberately NOT a REST route — it is served as a
1027 // normal front-end page at /xspeed/authorize (see handle_authorize_page)
1028 // so cookie auth works after the wp-login round-trip.
1029 register_rest_route(
1030 self::NS,
1031 '/mcp/oauth/token',
1032 array(
1033 'methods' => 'POST',
1034 'callback' => array( $this, 'rest_oauth_token' ),
1035 'permission_callback' => '__return_true',
1036 )
1037 );
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
1067 // --- MCP-token-only tool routes (optional hosted-broker path) ----
1068 $tool_perm = array( Mcp_Auth::class, 'permission' );
1069 register_rest_route(
1070 self::NS,
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_-]+)',
1080 array(
1081 array(
1082 'methods' => 'GET',
1083 'callback' => array( $this, 'rest_tool' ),
1084 'permission_callback' => $tool_perm,
1085 ),
1086 array(
1087 'methods' => 'POST',
1088 'callback' => array( $this, 'rest_tool' ),
1089 'permission_callback' => $tool_perm,
1090 ),
1091 )
1092 );
1093 }
1094
1095 /**
1096 * Capability gate for the admin-only management routes.
1097 *
1098 * @return bool
1099 */
1100 public function admin_permission(): bool {
1101 return current_user_can( 'manage_options' );
1102 }
1103
1104 // -- Handlers ----------------------------------------------------------
1105
1106 /**
1107 * MCP JSON-RPC over the wp-json fallback path.
1108 *
1109 * @param \WP_REST_Request $request Incoming request.
1110 * @return \WP_REST_Response
1111 */
1112 public function rest_mcp( \WP_REST_Request $request ) {
1113 $response = Mcp_Server::handle( $request );
1114 // Advertise the MCP protocol version on the wp-json transport too, so
1115 // both endpoints behave identically to a strict Streamable-HTTP client.
1116 $response->header( 'MCP-Protocol-Version', Mcp_Server::PROTOCOL_VERSION );
1117 return $response;
1118 }
1119
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 /**
1148 * GET /mcp/connection — pairing status for the dashboard.
1149 *
1150 * @param \WP_REST_Request $request Unused.
1151 * @return \WP_REST_Response
1152 */
1153 public function rest_connection( \WP_REST_Request $request ) {
1154 unset( $request );
1155 return rest_ensure_response( Mcp_Pairing::public_status() );
1156 }
1157
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 /**
1195 * POST /mcp/connect — mint a connection token.
1196 *
1197 * @param \WP_REST_Request $request Unused.
1198 * @return \WP_REST_Response|\WP_Error
1199 */
1200 public function rest_connect( \WP_REST_Request $request ) {
1201 $read_only = (bool) $request->get_param( 'read_only' );
1202 $result = Mcp_Pairing::connect( $read_only );
1203 if ( is_wp_error( $result ) ) {
1204 return $result;
1205 }
1206 return rest_ensure_response( $result );
1207 }
1208
1209 /**
1210 * POST /mcp/rotate — mint a fresh token, invalidating the old one.
1211 *
1212 * @param \WP_REST_Request $request Carries optional read_only.
1213 * @return \WP_REST_Response
1214 */
1215 public function rest_rotate( \WP_REST_Request $request ) {
1216 $read_only = null;
1217 if ( null !== $request->get_param( 'read_only' ) ) {
1218 $read_only = (bool) $request->get_param( 'read_only' );
1219 }
1220 return rest_ensure_response( Mcp_Pairing::rotate( $read_only ) );
1221 }
1222
1223 /**
1224 * POST /mcp/access — change the live connection's read-only state WITHOUT
1225 * minting a new token (the paired client keeps working; only its allowed
1226 * tools change). This is what the dashboard's read-only toggle calls.
1227 *
1228 * @param \WP_REST_Request $request Carries the required read_only bool.
1229 * @return \WP_REST_Response|\WP_Error
1230 */
1231 public function rest_access( \WP_REST_Request $request ) {
1232 $read_only = (bool) $request->get_param( 'read_only' );
1233 $result = Mcp_Pairing::set_read_only( $read_only );
1234 if ( is_wp_error( $result ) ) {
1235 return $result;
1236 }
1237 return rest_ensure_response( $result );
1238 }
1239
1240 /**
1241 * POST /mcp/disconnect — revoke the connection token.
1242 *
1243 * @param \WP_REST_Request $request Unused.
1244 * @return \WP_REST_Response
1245 */
1246 public function rest_disconnect( \WP_REST_Request $request ) {
1247 unset( $request );
1248 return rest_ensure_response( Mcp_Pairing::disconnect() );
1249 }
1250
1251 // -- xSpeed Hub (multi-site) handlers ----------------------------------
1252
1253 /**
1254 * GET /mcp/hub — hub-link status + the Method-1 paste-in values.
1255 *
1256 * @param \WP_REST_Request $request Unused.
1257 * @return \WP_REST_Response
1258 */
1259 public function rest_hub_status( \WP_REST_Request $request ) {
1260 // Self-heal from the Hub (source of truth) so the connected badge is
1261 // reliable even if the attach callback never fired. Force a fresh check
1262 // when the panel asks via the X-XSpeed-Reconcile header (e.g. the admin
1263 // returned to the tab after connecting).
1264 $force = '1' === (string) $request->get_header( 'x_xspeed_reconcile' );
1265 Mcp_Hub::reconcile_with_hub( $force );
1266 return rest_ensure_response( Mcp_Hub::public_status() );
1267 }
1268
1269 /**
1270 * POST /mcp/hub/token — ensure a site_token exists and return the
1271 * paste-in values (this site's URL + token) for the hub's Add-site form.
1272 *
1273 * @param \WP_REST_Request $request Unused.
1274 * @return \WP_REST_Response
1275 */
1276 public function rest_hub_token( \WP_REST_Request $request ) {
1277 unset( $request );
1278 return rest_ensure_response( Mcp_Hub::generate_token() );
1279 }
1280
1281 /**
1282 * POST /mcp/hub/attached — record which hub account this site is
1283 * attached to (bookkeeping for the panel's status line).
1284 *
1285 * @param \WP_REST_Request $request Carries account_email.
1286 * @return \WP_REST_Response
1287 */
1288 public function rest_hub_attached( \WP_REST_Request $request ) {
1289 $email = sanitize_email( (string) $request->get_param( 'account_email' ) );
1290 return rest_ensure_response( Mcp_Hub::mark_attached( $email ) );
1291 }
1292
1293 /**
1294 * POST /mcp/hub/disconnect — clear the local hub-link bookkeeping.
1295 *
1296 * @param \WP_REST_Request $request Unused.
1297 * @return \WP_REST_Response
1298 */
1299 public function rest_hub_disconnect( \WP_REST_Request $request ) {
1300 unset( $request );
1301 return rest_ensure_response( Mcp_Hub::disconnect() );
1302 }
1303
1304 /**
1305 * POST /mcp/attach — the OAuth-attach callback. The hub presents the
1306 * signed nonce the plugin issued; on success we return this site's URL +
1307 * token so the hub can record it. Nonce is the auth (admin-minted,
1308 * HMAC-signed, time-bound), so no pre-shared token is required.
1309 *
1310 * @param \WP_REST_Request $request Carries the nonce.
1311 * @return \WP_REST_Response|\WP_Error
1312 */
1313 public function rest_hub_attach_callback( \WP_REST_Request $request ) {
1314 $nonce = (string) $request->get_param( 'nonce' );
1315 $result = Mcp_Hub::verify_attach_nonce( $nonce );
1316 if ( null === $result ) {
1317 return new \WP_Error(
1318 'xspeed_attach_invalid',
1319 __( 'Invalid or expired attach request.', 'xspeed' ),
1320 array( 'status' => 403 )
1321 );
1322 }
1323 // A valid nonce proves this is a real hub-initiated attach, so record it
1324 // now — the hub passes the account email so the panel can show
1325 // "Connected via <email>". The nonce carries the minting admin's user
1326 // id (no WP session exists in this server-to-server call), so the state
1327 // is recorded PER-USER — each admin sees their own connection.
1328 $account_email = sanitize_email( (string) $request->get_param( 'account_email' ) );
1329 $user_id = isset( $result['user_id'] ) ? (int) $result['user_id'] : 0;
1330 Mcp_Hub::mark_attached( $account_email, $user_id ?: null );
1331
1332 // The hub only needs the credential; don't leak the internal user id.
1333 unset( $result['user_id'] );
1334 return rest_ensure_response( $result );
1335 }
1336
1337 // -- OAuth 2.1 handlers ------------------------------------------------
1338
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 /**
1375 * POST /mcp/oauth/register — RFC 7591 dynamic client registration.
1376 *
1377 * @param \WP_REST_Request $request JSON body with redirect_uris.
1378 * @return \WP_REST_Response|\WP_Error
1379 */
1380 public function rest_oauth_register( \WP_REST_Request $request ) {
1381 $body = $request->get_json_params();
1382 if ( ! is_array( $body ) ) {
1383 $body = array();
1384 }
1385 $result = Mcp_OAuth::register_client( $body );
1386 if ( is_wp_error( $result ) ) {
1387 return $result;
1388 }
1389 return new \WP_REST_Response( $result, 201 );
1390 }
1391
1392 /**
1393 * The browser-facing OAuth authorize page (served at /xspeed/authorize via
1394 * a rewrite, NOT the REST API — see AUTHORIZE_QUERY_VAR). Reads request
1395 * params from the superglobals because this is a normal front-end request
1396 * where cookie auth populates is_user_logged_in().
1397 *
1398 * GET renders the consent screen (requires a logged-in admin; anonymous
1399 * users go to wp-login and return here). POST is the nonce-checked consent
1400 * submission: Approve issues a code and 302s to the client's redirect_uri;
1401 * Deny 302s back with error=access_denied. Always emits its own response
1402 * (HTML page or redirect) and exits.
1403 */
1404 public function handle_authorize_page(): void {
1405 $is_post = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) );
1406 // Params come from GET on the consent link and POST on the form submit.
1407 // Nonce is verified below before any POST value is acted on.
1408 // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing
1409 $source = $is_post ? $_POST : $_GET;
1410 // phpcs:enable
1411 $params = array();
1412 foreach ( array( 'client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'approve', 'deny', '_xspeed_oauth_nonce' ) as $k ) {
1413 $params[ $k ] = isset( $source[ $k ] ) ? sanitize_text_field( wp_unslash( $source[ $k ] ) ) : '';
1414 }
1415
1416 // Validate the OAuth params before touching the session.
1417 $req = Mcp_OAuth::validate_authorize_request( $params );
1418 if ( is_wp_error( $req ) ) {
1419 $data = $req->get_error_data();
1420 $redirectable = is_array( $data ) && ! empty( $data['redirectable'] );
1421 // Only redirect the error back when redirect_uri is verified valid;
1422 // otherwise show a page (never bounce to an unverified URL).
1423 if ( $redirectable && '' !== $params['redirect_uri'] ) {
1424 $this->redirect_error( $params['redirect_uri'], $req->get_error_code(), $req->get_error_message(), $params['state'] );
1425 }
1426 $this->emit_oauth_error_page( $req->get_error_message() );
1427 }
1428
1429 // Require a logged-in admin. Anonymous → wp-login, back to this URL.
1430 if ( ! is_user_logged_in() ) {
1431 $this->redirect_to_login();
1432 }
1433 if ( ! current_user_can( 'manage_options' ) ) {
1434 $this->emit_oauth_error_page(
1435 __( 'You must be an administrator to authorize an AI agent to control this site.', 'xspeed' )
1436 );
1437 }
1438
1439 // POST = consent form submitted.
1440 if ( $is_post ) {
1441 if ( ! wp_verify_nonce( $params['_xspeed_oauth_nonce'], 'xspeed_oauth_consent' ) ) {
1442 $this->emit_oauth_error_page( __( 'Security check failed. Please try connecting again.', 'xspeed' ) );
1443 }
1444 if ( '' === $params['approve'] ) {
1445 $this->redirect_error( $req['redirect_uri'], 'access_denied', 'The user denied the request.', $req['state'] );
1446 }
1447 $code = Mcp_OAuth::issue_code( $req, get_current_user_id() );
1448 $this->redirect_success( $req['redirect_uri'], $code, $req['state'] );
1449 }
1450
1451 // GET = render the consent screen.
1452 $this->emit_consent_screen( $req );
1453 }
1454
1455 /**
1456 * POST /mcp/oauth/token — exchange a code (or refresh token) for tokens.
1457 *
1458 * @param \WP_REST_Request $request Form-encoded or JSON token request.
1459 * @return \WP_REST_Response
1460 */
1461 public function rest_oauth_token( \WP_REST_Request $request ) {
1462 // Token requests are application/x-www-form-urlencoded per OAuth, but
1463 // accept JSON too. get_body_params() covers the form case.
1464 $body = $request->get_body_params();
1465 if ( empty( $body ) ) {
1466 $json = $request->get_json_params();
1467 $body = is_array( $json ) ? $json : array();
1468 }
1469 $body = array_map( 'strval', $body );
1470
1471 $result = Mcp_OAuth::exchange_token( $body );
1472 if ( is_wp_error( $result ) ) {
1473 $data = $result->get_error_data();
1474 $response = new \WP_REST_Response(
1475 array(
1476 'error' => isset( $data['error'] ) ? $data['error'] : 'invalid_request',
1477 'error_description' => isset( $data['error_description'] ) ? $data['error_description'] : $result->get_error_message(),
1478 ),
1479 isset( $data['status'] ) ? (int) $data['status'] : 400
1480 );
1481 $response->header( 'Cache-Control', 'no-store' );
1482 return $response;
1483 }
1484 $response = new \WP_REST_Response( $result, 200 );
1485 $response->header( 'Cache-Control', 'no-store' );
1486 $response->header( 'Pragma', 'no-cache' );
1487 return $response;
1488 }
1489
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 /**
1544 * Token-authenticated tool route for the hosted broker. Maps a broker
1545 * tool call (e.g. GET /mcp/tool/get_cache_status) onto the shared
1546 * Mcp_Tools catalog, so the broker path and the JSON-RPC path never
1547 * drift. GET params + JSON body both feed the tool's arguments.
1548 */
1549 public function rest_tool( \WP_REST_Request $request ) {
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 }
1561 $args = $request->get_json_params();
1562 if ( ! is_array( $args ) ) {
1563 $args = array();
1564 }
1565 // Merge query params (e.g. ?module=minify) so GET tools work too.
1566 foreach ( $request->get_query_params() as $k => $v ) {
1567 if ( 'tool' !== $k && ! array_key_exists( $k, $args ) ) {
1568 $args[ $k ] = $v;
1569 }
1570 }
1571
1572 Mcp_Tools::set_channel( 'broker' );
1573 $result = Mcp_Tools::invoke( $tool, $args );
1574 if ( is_wp_error( $result ) ) {
1575 return $result;
1576 }
1577 return rest_ensure_response( $result );
1578 }
1579
1580 // -- Helpers --
1581
1582 // -- OAuth browser-response helpers ------------------------------------
1583
1584 /** The absolute URL of the current authorize request (for login return). */
1585 private function current_authorize_url(): string {
1586 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- reconstructing the current URL for a login round-trip; escaped at use.
1587 $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
1588 return home_url( $uri );
1589 }
1590
1591 /** Send an anonymous visitor to wp-login, returning to this authorize URL. */
1592 private function redirect_to_login(): void {
1593 wp_safe_redirect( wp_login_url( $this->current_authorize_url() ) );
1594 exit;
1595 }
1596
1597 /** 302 back to the client with the authorization code (+ state). */
1598 private function redirect_success( string $redirect_uri, string $code, string $state ): void {
1599 $args = array( 'code' => $code );
1600 if ( '' !== $state ) {
1601 $args['state'] = $state;
1602 }
1603 // Not wp_safe_redirect: redirect_uri is a client-registered off-site
1604 // callback, already validated against the client's registered set.
1605 wp_redirect( add_query_arg( $args, $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
1606 exit;
1607 }
1608
1609 /** 302 back to the client with an OAuth error (+ state). */
1610 private function redirect_error( string $redirect_uri, string $error, string $description, string $state ): void {
1611 $args = array(
1612 'error' => $error,
1613 'error_description' => $description,
1614 );
1615 if ( '' !== $state ) {
1616 $args['state'] = $state;
1617 }
1618 wp_redirect( add_query_arg( array_map( 'rawurlencode', $args ), $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
1619 exit;
1620 }
1621
1622 /**
1623 * Render the consent screen. Minimal self-contained HTML (no admin
1624 * chrome — this is a client-facing OAuth page). Approve/Deny post back
1625 * to the same authorize URL with a nonce.
1626 *
1627 * @param array<string,string> $req Validated authorize params.
1628 */
1629 private function emit_consent_screen( array $req ): void {
1630 $read_only = Mcp_OAuth::scope_is_read_only( $req['scope'] );
1631 $access = $read_only
1632 ? __( 'Read-only — inspect cache status and settings.', 'xspeed' )
1633 : __( 'Read & write — purge caches, toggle caching, and change settings.', 'xspeed' );
1634 $client = '' !== $req['client_name'] ? $req['client_name'] : __( 'An AI agent', 'xspeed' );
1635 $action_url = Mcp_OAuth::authorize_url();
1636 $nonce = wp_create_nonce( 'xspeed_oauth_consent' );
1637 $user = wp_get_current_user();
1638
1639 // Preserve every OAuth param so the POST re-validates identically.
1640 $hidden = '';
1641 foreach ( array( 'client_id', 'redirect_uri', 'code_challenge', 'scope', 'state' ) as $k ) {
1642 $val = 'scope' === $k ? $req['scope'] : ( $req[ $k ] ?? '' );
1643 $hidden .= sprintf( '<input type="hidden" name="%s" value="%s" />', esc_attr( $k ), esc_attr( (string) $val ) );
1644 }
1645 // code_challenge_method + response_type are re-asserted for validation.
1646 $hidden .= '<input type="hidden" name="code_challenge_method" value="S256" />';
1647 $hidden .= '<input type="hidden" name="response_type" value="code" />';
1648
1649 status_header( 200 );
1650 header( 'Content-Type: text/html; charset=utf-8' );
1651 header( 'Cache-Control: no-store' );
1652
1653 echo '<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' . esc_html__( 'Authorize AI access', 'xspeed' ) . '</title>';
1654 echo '<style>'
1655 . 'body{font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e2e8f0;margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center}'
1656 . '.card{background:#1e293b;border:1px solid #334155;border-radius:16px;max-width:440px;padding:32px;box-shadow:0 10px 40px rgba(0,0,0,.4)}'
1657 . 'h1{font-size:20px;margin:0 0 4px}.sub{color:#94a3b8;font-size:13px;margin:0 0 24px}'
1658 . '.row{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid #334155;font-size:13px}'
1659 . '.row span:first-child{color:#94a3b8}.row span:last-child{font-weight:600;text-align:right;max-width:60%;word-break:break-word}'
1660 . '.actions{display:flex;gap:12px;margin-top:24px}'
1661 . 'button{flex:1;padding:12px;border-radius:10px;border:0;font-size:14px;font-weight:600;cursor:pointer}'
1662 . '.approve{background:#f5cd47;color:#1b2533}.deny{background:transparent;color:#94a3b8;border:1px solid #334155}'
1663 . '</style></head><body><div class="card">';
1664 echo '<h1>' . esc_html__( 'Connect to xSpeed', 'xspeed' ) . '</h1>';
1665 /* translators: %s: AI client name. */
1666 echo '<p class="sub">' . esc_html( sprintf( __( '%s wants to manage the cache on this site.', 'xspeed' ), $client ) ) . '</p>';
1667 echo '<div class="row"><span>' . esc_html__( 'Site', 'xspeed' ) . '</span><span>' . esc_html( wp_parse_url( home_url(), PHP_URL_HOST ) ) . '</span></div>';
1668 echo '<div class="row"><span>' . esc_html__( 'Signed in as', 'xspeed' ) . '</span><span>' . esc_html( $user->user_login ) . '</span></div>';
1669 echo '<div class="row"><span>' . esc_html__( 'Access', 'xspeed' ) . '</span><span>' . esc_html( $access ) . '</span></div>';
1670 echo '<form method="post" action="' . esc_url( $action_url ) . '">';
1671 echo $hidden; // phpcs:ignore WordPress.Security.EscapeOutput -- built from esc_attr() above.
1672 echo '<input type="hidden" name="_xspeed_oauth_nonce" value="' . esc_attr( $nonce ) . '" />';
1673 echo '<div class="actions">';
1674 echo '<button class="deny" name="deny" value="1">' . esc_html__( 'Deny', 'xspeed' ) . '</button>';
1675 echo '<button class="approve" name="approve" value="1">' . esc_html__( 'Approve', 'xspeed' ) . '</button>';
1676 echo '</div></form></div></body></html>';
1677 exit;
1678 }
1679
1680 /** Render a standalone OAuth error page (no redirect). */
1681 private function emit_oauth_error_page( string $message ): void {
1682 status_header( 400 );
1683 header( 'Content-Type: text/html; charset=utf-8' );
1684 header( 'Cache-Control: no-store' );
1685 echo '<!doctype html><html><head><meta charset="utf-8"><title>' . esc_html__( 'Authorization error', 'xspeed' ) . '</title>';
1686 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}'
1687 . '.card{background:#1e293b;border:1px solid #334155;border-radius:16px;max-width:440px;padding:32px;text-align:center}</style></head><body>';
1688 echo '<div class="card"><h1>' . esc_html__( 'Could not authorize', 'xspeed' ) . '</h1><p>' . esc_html( $message ) . '</p></div></body></html>';
1689 exit;
1690 }
1691
1692 /** Read an inbound HTTP header from $_SERVER (for the pretty path). */
1693 private static function server_header( string $name ): ?string {
1694 $key = 'HTTP_' . strtoupper( str_replace( '-', '_', $name ) );
1695 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- token compared constant-time downstream; raw header needed verbatim.
1696 return isset( $_SERVER[ $key ] ) ? wp_unslash( $_SERVER[ $key ] ) : null;
1697 }
1698
1699 /** Emit a WP_REST_Response as a JSON HTTP response and stop. */
1700 private function emit_json( \WP_REST_Response $response ): void {
1701 status_header( $response->get_status() );
1702 // MCP Streamable HTTP: advertise the protocol version we speak so a
1703 // strict client can pin it. We answer JSON (a spec-permitted response
1704 // type); we never open an SSE stream, so no session header is needed.
1705 header( 'MCP-Protocol-Version: ' . Mcp_Server::PROTOCOL_VERSION );
1706 // Forward any headers the handler set (notably WWW-Authenticate on a
1707 // 401, which drives the OAuth discovery flow). rest_do_request applies
1708 // these automatically; the pretty-endpoint path must do it by hand.
1709 foreach ( $response->get_headers() as $name => $value ) {
1710 header( $name . ': ' . $value );
1711 }
1712 $data = $response->get_data();
1713 if ( null !== $data ) {
1714 header( 'Content-Type: application/json; charset=utf-8' );
1715 echo wp_json_encode( $data );
1716 }
1717 exit;
1718 }
1719
1720 // -- WP-CLI mirror --
1721
1722 public function cli_commands(): array {
1723 return array(
1724 array(
1725 'name' => 'xspeed mcp status',
1726 'callback' => array( $this, 'cli_status' ),
1727 'shortdesc' => 'Show MCP connection status and the paste-in endpoint URL.',
1728 'synopsis' => array(),
1729 ),
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(
1750 'name' => 'xspeed mcp connect',
1751 'callback' => array( $this, 'cli_connect' ),
1752 'shortdesc' => 'Generate a connection token for this site\'s MCP endpoint.',
1753 'synopsis' => array(
1754 array(
1755 'name' => 'read-only',
1756 'type' => 'flag',
1757 'optional' => true,
1758 'description' => 'Grant read-only access (no purge/toggle/settings changes).',
1759 ),
1760 ),
1761 ),
1762 array(
1763 'name' => 'xspeed mcp rotate',
1764 'callback' => array( $this, 'cli_rotate' ),
1765 'shortdesc' => 'Mint a fresh MCP token, immediately invalidating the previous one.',
1766 'synopsis' => array(
1767 array(
1768 'name' => 'read-only',
1769 'type' => 'flag',
1770 'optional' => true,
1771 'description' => 'Make the new token read-only.',
1772 ),
1773 ),
1774 ),
1775 array(
1776 'name' => 'xspeed mcp disconnect',
1777 'callback' => array( $this, 'cli_disconnect' ),
1778 'shortdesc' => 'Revoke this site\'s MCP connection token.',
1779 'synopsis' => array(),
1780 ),
1781 );
1782 }
1783
1784 /**
1785 * `wp xspeed mcp status` — print connection status + endpoint URL.
1786 *
1787 * @param array $args Positional args (unused).
1788 * @param array $assoc Associative args (unused).
1789 */
1790 public function cli_status( array $args, array $assoc ): void {
1791 unset( $args, $assoc );
1792 $s = Mcp_Pairing::public_status();
1793 \WP_CLI::log( sprintf( '%-18s %s', 'connected', $s['connected'] ? 'yes' : 'no' ) );
1794 if ( $s['connected'] ) {
1795 \WP_CLI::log( sprintf( '%-18s %s', 'access', $s['read_only'] ? 'read-only' : 'read-write' ) );
1796 \WP_CLI::log( sprintf( '%-18s %s', 'connect_url', $s['connect_url'] ) );
1797 \WP_CLI::log( sprintf( '%-18s %s', 'scopes', implode( ',', $s['scopes'] ) ) );
1798 } else {
1799 \WP_CLI::log( sprintf( '%-18s %s', 'mcp_endpoint', Mcp_Pairing::site_endpoint() ) );
1800 }
1801 }
1802
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 /**
1855 * `wp xspeed mcp connect` — mint a token and print the paste-in URL.
1856 *
1857 * @param array $args Positional args (unused).
1858 * @param array $assoc Associative args (unused).
1859 */
1860 public function cli_connect( array $args, array $assoc ): void {
1861 unset( $args );
1862 $read_only = ! empty( $assoc['read-only'] );
1863 $result = Mcp_Pairing::connect( $read_only );
1864 if ( is_wp_error( $result ) ) {
1865 \WP_CLI::error( $result->get_error_message() );
1866 return;
1867 }
1868 \WP_CLI::success( 'Connected' . ( Mcp_Pairing::is_read_only() ? ' (read-only).' : '.' ) . ' Paste this single URL into your AI client:' );
1869 \WP_CLI::log( ' ' . Mcp_Pairing::connect_url() );
1870 \WP_CLI::log( '' );
1871 \WP_CLI::log( 'Or, header-based (token stays out of the URL):' );
1872 \WP_CLI::log( ' ' . Mcp_Pairing::config_snippets()['cli'] );
1873 }
1874
1875 /**
1876 * `wp xspeed mcp rotate` — mint a new token, revoking the old one.
1877 *
1878 * @param array $args Positional args (unused).
1879 * @param array $assoc Associative args ({ read-only?:flag }).
1880 */
1881 public function cli_rotate( array $args, array $assoc ): void {
1882 unset( $args );
1883 $read_only = array_key_exists( 'read-only', $assoc ) ? ! empty( $assoc['read-only'] ) : null;
1884 Mcp_Pairing::rotate( $read_only );
1885 \WP_CLI::success( 'Rotated. The previous token is now invalid. New paste-in URL:' );
1886 \WP_CLI::log( ' ' . Mcp_Pairing::connect_url() );
1887 }
1888
1889 /**
1890 * `wp xspeed mcp disconnect` — revoke the connection token.
1891 *
1892 * @param array $args Positional args (unused).
1893 * @param array $assoc Associative args (unused).
1894 */
1895 public function cli_disconnect( array $args, array $assoc ): void {
1896 unset( $args, $assoc );
1897 Mcp_Pairing::disconnect();
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' );
1920 }
1921 }
1922