PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 All 35 releases
← All changes | includes/modules/Mcp/McpModule.php +617 -86 1.3.1 → 1.4.0 View file →
@@ -36,8 +36,9 @@
36 36 declare(strict_types=1);
37 37
38 38 namespace XSpeed\Modules\Mcp;
39 39
40 +use XSpeed\Activity_Log;
40 41 use XSpeed\Module;
41 42 use XSpeed\Onboarding;
42 43
43 44 defined( 'ABSPATH' ) || exit;
@@ -43,8 +44,14 @@
43 44 defined( 'ABSPATH' ) || exit;
44 45
45 46 final class McpModule extends Module {
46 47
48 + /**
49 + * The stylesheet of the standalone consent pages: the OAuth authorize
50 + * screen here and the Hub connect screen (Mcp_Hub_Connect).
51 + */
52 + public const CONSENT_CSS = 'body{font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e2e8f0;margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center}.card{background:#1e293b;border:1px solid #334155;border-radius:16px;max-width:440px;padding:32px;box-shadow:0 10px 40px rgba(0,0,0,.4)}h1{font-size:20px;margin:0 0 4px}.sub{color:#94a3b8;font-size:13px;margin:0 0 24px}.row{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid #334155;font-size:13px}.row span:first-child{color:#94a3b8}.row span:last-child{font-weight:600;text-align:right;max-width:60%;word-break:break-word}.actions{display:flex;gap:12px;margin-top:24px}button{flex:1;padding:12px;border-radius:10px;border:0;font-size:14px;font-weight:600;cursor:pointer}.approve{background:#f5cd47;color:#1b2533}.deny{background:transparent;color:#94a3b8;border:1px solid #334155}';
53 +
47 54 public const SLUG = 'mcp';
48 55 public const TIER = self::TIER_FREE;
49 56 public const VERSION = '1.0.0';
50 57
@@ -63,20 +70,58 @@
63 70 public const REWRITE_RULES = array(
64 71 '^xspeed/mcp/([a-f0-9]{64})/?$',
65 72 '^xspeed/mcp/?$',
66 73 '^xspeed/mcp/attach/?$',
67 - // OAuth discovery, root form. RFC 9728 §3.1 / RFC 8414 §3.1 put the
68 - // `.well-known` segment BEFORE the resource path.
69 - '^\.well-known/oauth-(protected-resource|authorization-server)/?$',
70 - // OAuth discovery, path-suffixed form. Real clients (Claude Desktop
71 - // among them) request THIS one; serving only the root form 404s them.
72 - // It names our own resource path explicitly: a catch-all tail here
73 - // also matched other MCP plugins' discovery URLs on the same site and
74 - // answered them with our metadata, which broke their connectors.
74 + '^xspeed/mcp/connect-info/?$',
75 + // OAuth discovery. RFC 9728 §3.1 / RFC 8414 §3.1 put the
76 + // `.well-known` segment BEFORE the resource/issuer path, and both of
77 + // our identifiers are /xspeed/mcp — so this pair of URLs, and only
78 + // this pair, is ours. It names our own path explicitly: a catch-all
79 + // tail here also matched other MCP plugins' discovery URLs on the
80 + // same site and answered them with our metadata, which broke their
81 + // connectors. The bare root form is registered conditionally and so
82 + // lives apart, in ROOT_DISCOVERY_RULE.
75 83 '^\.well-known/oauth-(protected-resource|authorization-server)/xspeed/mcp/?$',
76 84 '^xspeed/authorize/?$',
77 85 );
78 86
87 + /**
88 + * The root-form discovery rule — registered CONDITIONALLY, which is why
89 + * it is not in REWRITE_RULES.
90 + *
91 + * It is the address a client built to the 2025-03-26 MCP spec looks at,
92 + * and the only address it looks at; current clients read the
93 + * protected-resource document first and follow it to the path form. So
94 + * dropping it outright would cut off older clients on every site,
95 + * including the single-plugin sites where the collision #266 exists to
96 + * fix never happened. We answer it while it is uncontested and stand
97 + * down the moment another plugin's rule claims it —
98 + * root_discovery_contested().
99 + */
100 + public const ROOT_DISCOVERY_RULE = '^\.well-known/oauth-(protected-resource|authorization-server)/?$';
101 +
102 + /**
103 + * Rules earlier builds registered that we never register again under any
104 + * condition. The self-heal guard flushes once when it finds OUR copy of
105 + * one still in the stored table.
106 + *
107 + * The catch-all below shipped in an intermediate build and matched every
108 + * path-suffixed discovery URL on the site, including other MCP plugins'
109 + * (#264). Nothing brings it back, so its removal is unconditional —
110 + * unlike ROOT_DISCOVERY_RULE, which is a rule we still register when the
111 + * root URL is uncontested and therefore cannot live in this list.
112 + *
113 + * Ownership is read from the rule's TARGET, never from the regex alone:
114 + * a sibling may register the same regex for its own document, its rule
115 + * comes back from every flush, and a guard that treated that as stale
116 + * would flush on every request forever.
117 + *
118 + * @var string[]
119 + */
120 + public const RETIRED_REWRITE_RULES = array(
121 + '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$',
122 + );
123 +
79 124 /** REST namespace shared with Free. Public: Mcp_Server builds the
80 125 * discovery fallback URL from it. */
81 126 public const NS = 'xspeed/v1';
82 127
@@ -105,14 +150,18 @@
105 150
106 151 /** Query var flagging the pretty /xspeed/mcp/attach callback. */
107 152 private const ATTACH_QUERY_VAR = 'xspeed_mcp_attach';
108 153
154 + /** Query var flagging the pretty /xspeed/mcp/connect-info document (xspeed-hub#307). */
155 + private const CONNECT_INFO_QUERY_VAR = 'xspeed_mcp_connect_info';
156 +
109 157 public function ui_metadata(): array {
110 158 return array(
111 159 'label' => __( 'MCP Server', 'xspeed' ),
112 160 'icon' => 'Sparkles',
113 - 'description' => __( 'Control this site\'s cache from Claude and other AI agents.', 'xspeed' ),
161 + 'description' => __( 'Let Claude or another AI assistant clear the cache, check stats and change settings.', 'xspeed' ),
114 162 'custom_panel' => 'McpPanel',
163 + 'group' => 'ai-agents',
115 164 );
116 165 }
117 166
118 167 /**
@@ -124,8 +173,31 @@
124 173 return array();
125 174 }
126 175
127 176 /**
177 + * The pairing state, declared so a settings write cannot delete it.
178 + *
179 + * `Mcp_Pairing` keeps its credentials in this module's settings option,
180 + * and the schema above is empty — so `Settings_Manager::get()` strips
181 + * every one of these keys, and an `update()` for this slug then writes
182 + * that stripped array back. The connection token, the scopes and the
183 + * connected flag all disappear in a single save, and the only visible
184 + * result is a site that has silently lost MCP access while the Hub still
185 + * lists it as attached.
186 + *
187 + * Nothing offers a settings form for this module, which is why it went
188 + * unnoticed, but `update()` takes its slug from data on three paths that
189 + * do not: a recommendation action, an optimize plan, and the MCP
190 + * `update_settings` tool. `preserved_keys()` is the mechanism for exactly
191 + * this — out-of-schema keys a schema-driven save must carry through.
192 + *
193 + * @return string[]
194 + */
195 + public function preserved_keys(): array {
196 + return array( 'site_token', 'connection_token', 'connected', 'connected_at', 'scopes' );
197 + }
198 +
199 + /**
128 200 * All MCP routes register directly (see class docblock). Returning an
129 201 * empty array keeps Rest_Manager out of the token-auth path entirely.
130 202 */
131 203 public function rest_routes(): array {
@@ -133,11 +205,40 @@
133 205 }
134 206
135 207 public function boot(): void {
136 208 add_action( 'rest_api_init', array( $this, 'register_rest' ) );
209 + add_action( Mcp_Pairing::TOKEN_CHANGED_ACTION, array( Mcp_Hub::class, 'on_token_changed' ) );
137 210
211 + // The only place the body cap can run before WordPress decodes it.
212 + // WP_REST_Server::dispatch() fires rest_pre_dispatch, and only after
213 + // that calls has_valid_params() — which json_decode()s the whole body
214 + // for any application/json request, ahead of the permission callback
215 + // and the handler. A cap inside a handler is therefore a second line,
216 + // not the bound it reads like.
217 + add_filter( 'rest_pre_dispatch', array( $this, 'cap_request_body' ), 10, 3 );
218 +
138 219 // Pretty per-site endpoint: /xspeed/mcp → MCP JSON-RPC handler.
139 - add_action( 'init', array( $this, 'add_rewrite' ) );
220 + // `wp_loaded`, not `init`: add_rewrite() decides whether to claim the
221 + // root discovery URL by looking at the rewrite table, and on `init` that
222 + // view is incomplete -- a sibling MCP plugin hooked at the same priority
223 + // but loaded after us has not registered yet. Root then looks
224 + // uncontested, we register our rule, and the self-heal guard concludes
225 + // nothing is stale, so it never flushes. That is a fixed point: the
226 + // table never converges, and because our rule is the one WordPress
227 + // matches, the sibling never sees the request either.
228 + //
229 + // By `wp_loaded` every init callback on every request type has run, so
230 + // the contested check sees the sibling and the guard flushes once.
231 + // WP_Rewrite::flush_rules() already defers itself to `wp_loaded`, so
232 + // nothing is lost by deciding here, and did_action('wp_loaded') is
233 + // truthy inside this callback, so the flush lands in time for
234 + // parse_request in the same request. (#266 QA)
235 + // Priority 0: still after every `init` callback, but ahead of the
236 + // widely copied `add_action( 'wp_loaded', 'flush_rewrite_rules' )`
237 + // snippet. If such a plugin flushed first it would write a table
238 + // without our rules, our guard would find them missing and flush
239 + // again -- two flushes and two option writes on every request.
240 + add_action( 'wp_loaded', array( $this, 'add_rewrite' ), 0 );
140 241 add_filter( 'query_vars', array( $this, 'register_query_var' ) );
141 242 // Priority 1: a sibling MCP plugin that also claims /.well-known/ gets
142 243 // to answer first at the default priority 10, and whoever answers
143 244 // first calls exit(). Running early means the URL is decided by WHOSE
@@ -149,8 +250,12 @@
149 250 // the account email + the SAME signed nonce we minted. We verify our own
150 251 // nonce and mark this admin attached — no server-to-server callback
151 252 // needed, so it works for local/firewalled sites too.
152 253 add_action( 'admin_init', array( $this, 'maybe_handle_hub_return' ) );
254 + // The Hub connect consent page (xspeed-hub#307). Priority 1: it emits a
255 + // standalone page and exits before anything else on admin_init runs.
256 + add_action( 'admin_init', array( Mcp_Hub_Connect::class, 'maybe_handle_page' ), 1 );
257 + add_action( 'admin_menu', array( Mcp_Hub_Connect::class, 'register_page' ) );
153 258
154 259 // An attached admin who is DELETED (or removed from the blog) never
155 260 // runs disconnect(), so the site-level attached mirror would report
156 261 // hub:true forever. deleted_user fires after both wp_delete_user()
@@ -194,12 +299,13 @@
194 299 }
195 300
196 301 // Verify OUR own signed nonce (proves the round-trip went through the
197 302 // Hub with a token we minted), then record the connection.
303 + // Signature only: the Hub has already spent this nonce on the attach
304 + // callback, and recording the link needs no credential.
198 305 if ( '' !== $nonce ) {
199 - $verified = Mcp_Hub::verify_attach_nonce( $nonce );
200 - if ( null !== $verified ) {
201 - $uid = isset( $verified['user_id'] ) ? (int) $verified['user_id'] : get_current_user_id();
306 + $uid = Mcp_Hub::check_attach_nonce( $nonce );
307 + if ( null !== $uid ) {
202 308 Mcp_Hub::mark_attached( $email, $uid ?: null );
203 309 }
204 310 }
205 311
@@ -239,8 +345,15 @@
239 345
240 346 // -- Pretty endpoint: /xspeed/mcp --
241 347
242 348 public function add_rewrite(): void {
349 + // The stored table is WordPress's routing table AND the only durable
350 + // record of which plugin owns which discovery URL, so both the
351 + // conditional registration below and the self-heal guard at the
352 + // bottom read the SAME snapshot of it. Deciding twice from two reads
353 + // is how a guard ends up flushing away a rule it just registered.
354 + $stored_rules = get_option( 'rewrite_rules' );
355 +
243 356 // Token-in-URL form: /xspeed/mcp/<token> — a single string the user
244 357 // pastes into their AI client (no separate token field). The bare
245 358 // /xspeed/mcp still works with a Bearer/header token.
246 359 add_rewrite_rule(
@@ -255,57 +368,243 @@
255 368 // own rewrite (consistent with the MCP URL, survives hosts that block
256 369 // /wp-json). Placed BEFORE the token rule would never match "attach"
257 370 // (that rule requires 64 hex chars), so ordering is safe.
258 371 add_rewrite_rule( '^xspeed/mcp/attach/?$', 'index.php?' . self::ATTACH_QUERY_VAR . '=1', 'top' );
372 + // The Hub connect document, outside /wp-json for the same reason: a
373 + // site whose security plugin refuses anonymous REST still answers it.
374 + add_rewrite_rule( '^xspeed/mcp/connect-info/?$', 'index.php?' . self::CONNECT_INFO_QUERY_VAR . '=1', 'top' );
259 375
260 376 // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place the
261 - // `.well-known` segment BEFORE the resource path, so our resource at
262 - // /xspeed/mcp is discovered at BOTH:
263 - // /.well-known/oauth-protected-resource (root form)
264 - // /.well-known/oauth-protected-resource/xspeed/mcp (path-suffixed)
265 - // Real clients (Claude Desktop among them) request the path-suffixed
266 - // form; serving only the root form 404s them and the connection aborts.
377 + // `.well-known` segment BEFORE the resource/issuer path, and both of
378 + // our canonical identifiers are the MCP endpoint URL, so our
379 + // documents live at:
380 + // /.well-known/oauth-protected-resource/xspeed/mcp
381 + // /.well-known/oauth-authorization-server/xspeed/mcp
267 382 //
268 - // Both are matched EXACTLY. A `(?:/.*)?` tail covers the same two URLs
269 - // in one rule, but also matches every OTHER plugin's discovery URL on
270 - // the same site — and WordPress matches rewrite rules in table order
271 - // rather than by specificity, so a sibling's own exact rule never gets
272 - // reached. Its clients then receive OUR metadata, find a resource and
273 - // issuer that do not match what they are connecting to, and abort
274 - // before the login screen.
383 + // Matched EXACTLY. A `(?:/.*)?` tail covers our URLs in one rule, but
384 + // also matches every OTHER plugin's discovery URL on the same site —
385 + // and WordPress matches rewrite rules in table order rather than by
386 + // specificity, so a sibling's own exact rule never gets reached. Its
387 + // clients then receive OUR metadata, find a resource and issuer that
388 + // do not match what they are connecting to, and abort before the
389 + // login screen.
275 390 add_rewrite_rule(
276 - '^\\.well-known/oauth-(protected-resource|authorization-server)/?$',
277 - 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
278 - 'top'
279 - );
280 - add_rewrite_rule(
281 391 '^\\.well-known/oauth-(protected-resource|authorization-server)/xspeed/mcp/?$',
282 392 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
283 393 'top'
284 394 );
285 395
396 + // The bare root form, ONLY while no other plugin claims it. A client
397 + // written to the 2025-03-26 MCP spec looks there and nowhere else, so
398 + // giving it up unconditionally would break those clients on every
399 + // site — including the single-plugin sites where the collision never
400 + // happened. When a sibling's rule is present the URL is theirs and we
401 + // register nothing, which is the case #266 is about. Current clients
402 + // read the protected-resource document first and follow it wherever
403 + // it points, so they are unaffected either way. The document served
404 + // at root carries the LEGACY
405 + // host-only issuer, because that is the identifier a client used to
406 + // derive that URL (RFC 8414 §3.3).
407 + $root_contested = self::root_discovery_contested( $stored_rules );
408 + if ( ! $root_contested ) {
409 + add_rewrite_rule(
410 + self::ROOT_DISCOVERY_RULE,
411 + 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
412 + 'top'
413 + );
414 + }
415 +
286 416 // Browser-facing OAuth consent page — served OUTSIDE REST so cookie
287 417 // auth (is_user_logged_in) works after the wp-login round-trip.
288 418 add_rewrite_rule( '^xspeed/authorize/?$', 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', 'top' );
289 419
290 - // Self-heal: flush once if ANY of our rules is missing from the stored
291 - // rewrite table. Checking only the first rule is not enough — a site
292 - // flushed under an older build (which had /xspeed/mcp but not the
293 - // later /xspeed/authorize + /.well-known rules) keeps that first rule,
294 - // so the guard never fires and OAuth discovery 404s forever. Guard on
295 - // the full set so any newly-added rule triggers a re-flush.
296 - $rules = get_option( 'rewrite_rules' );
297 - if ( is_array( $rules ) ) {
298 - foreach ( self::REWRITE_RULES as $rule ) {
299 - if ( ! isset( $rules[ $rule ] ) ) {
300 - flush_rewrite_rules( false );
301 - break;
420 + // Self-heal: flush once if the stored rewrite table disagrees with the
421 + // rules we just registered. Checking only the first rule is not
422 + // enough — a site flushed under an older build (which had /xspeed/mcp
423 + // but not the later /xspeed/authorize + /.well-known rules) keeps that
424 + // first rule, so the guard never fires and OAuth discovery 404s
425 + // forever. Guard on the full set so any newly-added rule triggers a
426 + // re-flush.
427 + //
428 + // The guard must mirror the registration decisions EXACTLY, or it
429 + // never reaches a fixed point:
430 + //
431 + // - Uncontested root: we register it, so the table must hold it
432 + // with OUR target. A flush produces exactly that, and the next
433 + // request reads the same table and stays uncontested — our own
434 + // target never counts as a sibling's.
435 + // - Contested root: we register nothing, so OUR copy must be gone.
436 + // A flush regenerates the sibling's rule (they register it every
437 + // request) but not ours, so the next request is quiet.
438 + //
439 + // Ownership is read from the TARGET in both directions. Keying on the
440 + // regex alone is what produced a flush on every request forever when
441 + // a sibling held that regex: their rule comes back from every flush.
442 + //
443 + // This runs on `wp_loaded` for every request, so the first request after an
444 + // upgrade flushes once and the guard is quiet from then on. It cannot
445 + // move to Plugin::maybe_upgrade() — that is admin-only and runs at
446 + // plugins_loaded 21, i.e. BEFORE init, so a flush there would write a
447 + // table without our rules and this guard would flush a second time.
448 + if ( ! is_array( $stored_rules ) ) {
449 + return;
450 + }
451 +
452 + $stale = false;
453 + $retiring = false;
454 + foreach ( self::REWRITE_RULES as $rule ) {
455 + if ( ! isset( $stored_rules[ $rule ] ) ) {
456 + $stale = true;
457 + break;
458 + }
459 + }
460 +
461 + // Our copy of the root rule must be present exactly when we register
462 + // it. Present-and-unwanted is the #266 upgrade; absent-and-wanted is
463 + // an older table, or a sibling that has since gone away.
464 + $root_is_ours = isset( $stored_rules[ self::ROOT_DISCOVERY_RULE ] )
465 + && self::is_our_rule_target( $stored_rules[ self::ROOT_DISCOVERY_RULE ] );
466 + if ( $root_is_ours === $root_contested ) {
467 + $stale = true;
468 + $retiring = $root_contested;
469 + }
470 +
471 + // Not an identity move — nothing a site owner can act on — so this
472 + // one flushes quietly.
473 + foreach ( self::RETIRED_REWRITE_RULES as $rule ) {
474 + if ( isset( $stored_rules[ $rule ] ) && self::is_our_rule_target( $stored_rules[ $rule ] ) ) {
475 + $stale = true;
476 + break;
477 + }
478 + }
479 +
480 + if ( ! $stale ) {
481 + return;
482 + }
483 +
484 + if ( $retiring ) {
485 + // The one moment the identity move is observable to a site owner,
486 + // and it happens on a front-end request with no UI attached. Fires
487 + // once: after the flush our rule is gone, so the next request
488 + // finds nothing to hand over.
489 + Activity_Log::record(
490 + 'mcp_discovery_moved',
491 + __( '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' ),
492 + Activity_Log::INFO
493 + );
494 + }
495 +
496 + flush_rewrite_rules( false );
497 + }
498 +
499 + /**
500 + * Whether another plugin's rewrite rule already routes the ROOT discovery
501 + * URLs, making them theirs rather than ours.
502 + *
503 + * Read from two views of the rewrite table, because neither alone is
504 + * complete at `init`:
505 + *
506 + * - the STORED table, which is what WordPress actually routes with and
507 + * the only view that survives the request. If a sibling registered
508 + * the same regex after us at the last flush, its target is what is
509 + * stored, and that is precisely "the sibling owns this URL now".
510 + * - the IN-MEMORY rules registered so far this request, which catches a
511 + * sibling that hooks `init` earlier than we do and has therefore not
512 + * reached the stored table yet.
513 + *
514 + * A sibling that registers LATER than us used to be the one case neither
515 + * view saw, and it did NOT resolve itself: the guard is what triggers a
516 + * flush, so a guard reading an incomplete view simply never fires. That
517 + * is why add_rewrite() now runs on `wp_loaded` rather than `init` — by
518 + * then every plugin has registered, whatever its load order.
519 + *
520 + * @param mixed $stored The stored rewrite table, if already read.
521 + */
522 + public static function root_discovery_contested( $stored = null ): bool {
523 + $tables = array();
524 + if ( is_array( $stored ) ) {
525 + $tables[] = $stored;
526 + } elseif ( null === $stored ) {
527 + $option = get_option( 'rewrite_rules' );
528 + if ( is_array( $option ) ) {
529 + $tables[] = $option;
530 + }
531 + }
532 +
533 + if ( isset( $GLOBALS['wp_rewrite'] ) && is_object( $GLOBALS['wp_rewrite'] ) ) {
534 + foreach ( array( 'extra_rules_top', 'extra_rules' ) as $prop ) {
535 + if ( isset( $GLOBALS['wp_rewrite']->$prop ) && is_array( $GLOBALS['wp_rewrite']->$prop ) ) {
536 + $tables[] = $GLOBALS['wp_rewrite']->$prop;
302 537 }
303 538 }
304 539 }
540 +
541 + foreach ( $tables as $rules ) {
542 + foreach ( $rules as $pattern => $target ) {
543 + if ( ! self::is_a_wellknown_rule( (string) $pattern ) || self::is_our_rule_target( $target ) ) {
544 + continue;
545 + }
546 + foreach ( array( 'protected-resource', 'authorization-server' ) as $doc ) {
547 + $probe = '.well-known/oauth-' . $doc;
548 + if ( preg_match( '#' . str_replace( '#', '\\#', (string) $pattern ) . '#', $probe ) ) {
549 + return true;
550 + }
551 + }
552 + }
553 + }
554 +
555 + return false;
305 556 }
306 557
307 558 /**
559 + * Whether a rewrite regex was written FOR a .well-known discovery URL,
560 + * as opposed to merely matching one.
561 + *
562 + * WordPress's own page rule -- `(.?.+?)/?$` => `index.php?pagename=...`
563 + * -- is in the stored table of every site using pretty permalinks, and
564 + * it matches `.well-known/oauth-protected-resource` exactly as it
565 + * matches every other path on the site. Reading that as a sibling's
566 + * claim would report root as contested EVERYWHERE: the root document
567 + * would be retired on every install, including the single-plugin sites
568 + * this change exists to leave alone, and each of them would log a
569 + * hand-over that never happened.
570 + *
571 + * A rule that routes these URLs on purpose spells the segment out, so
572 + * that is the signal. Backslashes are stripped first because the regex
573 + * carries them as escapes (`^\\.well-known/...`) and a rule is free to
574 + * escape the hyphen too.
575 + *
576 + * @param string $pattern The stored rewrite regex.
577 + */
578 + private static function is_a_wellknown_rule( string $pattern ): bool {
579 + return false !== stripos( str_replace( '\\', '', $pattern ), 'well-known' );
580 + }
581 +
582 + /**
583 + * Whether a stored rewrite target was written by this module.
584 + *
585 + * Every rule we register resolves to `index.php?<one of our query
586 + * vars>=…`, and no other plugin sets those. Used to tell OUR leftover
587 + * copy of a retired rule from a sibling's rule that happens to share the
588 + * regex — only the first is ours to flush away.
589 + *
590 + * @param mixed $target The stored rewrite target.
591 + */
592 + private static function is_our_rule_target( $target ): bool {
593 + if ( ! is_string( $target ) ) {
594 + return false;
595 + }
596 +
597 + foreach ( array( self::QUERY_VAR, self::TOKEN_QUERY_VAR, self::WELLKNOWN_QUERY_VAR, self::AUTHORIZE_QUERY_VAR, self::ATTACH_QUERY_VAR, self::CONNECT_INFO_QUERY_VAR ) as $var ) {
598 + if ( false !== strpos( $target, $var . '=' ) ) {
599 + return true;
600 + }
601 + }
602 +
603 + return false;
604 + }
605 +
606 + /**
308 607 * True when a request for our discovery URL can reach WordPress at all.
309 608 *
310 609 * Since maybe_handle_pretty_endpoint() claims the document by REQUEST
311 610 * PATH, a sibling plugin winning the rewrite match no longer matters —
@@ -326,9 +625,16 @@
326 625
327 626 // Any rule that routes our discovery path to index.php will do — ours
328 627 // or a sibling's — because the path check inside the handler decides
329 628 // the outcome once the request lands.
330 - $probe = '.well-known/oauth-protected-resource';
629 + //
630 + // Probe the URL the 401 challenge actually advertises: the
631 + // path-suffixed form, which is the canonical identity since #266.
632 + // Probing root would answer a different question — whether ANY plugin
633 + // routes the contested URL — and on a site where a sibling owns it
634 + // that answer says nothing about whether our own document is
635 + // reachable.
636 + $probe = '.well-known/oauth-protected-resource/' . Mcp_Pairing::SITE_ENDPOINT_PATH;
331 637 foreach ( $rules as $pattern => $target ) {
332 638 if ( preg_match( '#' . str_replace( '#', '\\#', $pattern ) . '#', $probe ) ) {
333 639 return true;
334 640 }
@@ -346,8 +652,9 @@
346 652 $vars[] = self::TOKEN_QUERY_VAR;
347 653 $vars[] = self::WELLKNOWN_QUERY_VAR;
348 654 $vars[] = self::AUTHORIZE_QUERY_VAR;
349 655 $vars[] = self::ATTACH_QUERY_VAR;
656 + $vars[] = self::CONNECT_INFO_QUERY_VAR;
350 657 return $vars;
351 658 }
352 659
353 660 /**
@@ -353,23 +660,44 @@
353 660 /**
354 661 * Which discovery document the CURRENT request path asks for, if any.
355 662 *
356 663 * Claims only URLs that are unambiguously ours, mirroring the rewrite
357 - * rules exactly: the bare root form, and the RFC 9728 §3.1 path-suffixed
358 - * form naming our own resource (`/xspeed/mcp`). A suffix belonging to a
359 - * sibling plugin is deliberately NOT claimed — answering
664 + * rules exactly: the RFC 9728 §3.1 / RFC 8414 §3.1 path-suffixed form
665 + * naming our own resource and issuer (`/xspeed/mcp`). A suffix belonging
666 + * to a sibling plugin is deliberately NOT claimed — answering
360 667 * `/.well-known/oauth-protected-resource/betterlinks/mcp` with xSpeed
361 668 * metadata is the same bug that broke this site, just pointed the other
362 669 * way.
363 670 *
364 - * @return string 'protected-resource', 'authorization-server', or ''.
671 + * The bare root form is claimed only while no other plugin's rewrite rule
672 + * claims it. Leaving the suffix optional here took the root document from
673 + * a sibling even on a build that had stopped registering its own root
674 + * rule, so the path check is exact.
675 + *
676 + * The TABLE is what hands root over -- add_rewrite() stops registering
677 + * the rule and flushes it away. This claim only releases it, and only
678 + * while a sibling's rule actually owns the URL: once WordPress has
679 + * routed the request to OUR query var, no other plugin's handler can see
680 + * it, so releasing it would abandon the request to the front page rather
681 + * than pass it on. Note that is about the winning rule's TARGET, not its
682 + * regex -- a sibling can hold the same pattern. The document served at root carries the LEGACY host-only
683 + * issuer, the identifier a client used to derive that URL. (#266)
684 + *
685 + * @param string $matched_query The query the matched rule resolved to.
686 + * @return array{doc:string,issuer:string} Doc name ('' when not ours)
687 + * and the identity to stamp on it.
365 688 */
366 - private function wellknown_doc_from_path(): string {
689 + private function wellknown_claim_from_path( string $matched_query = '' ): array {
690 + $none = array(
691 + 'doc' => '',
692 + 'issuer' => '',
693 + );
694 +
367 695 $uri = isset( $_SERVER['REQUEST_URI'] )
368 696 ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) )
369 697 : '';
370 698 if ( '' === $uri ) {
371 - return '';
699 + return $none;
372 700 }
373 701
374 702 $path = (string) wp_parse_url( $uri, PHP_URL_PATH );
375 703
@@ -380,12 +708,56 @@
380 708 }
381 709
382 710 $path = trim( $path, '/' );
383 711
384 - $pattern = '#^\.well-known/oauth-(protected-resource|authorization-server)'
385 - . '(?:/xspeed/mcp)?$#';
712 + // trim() above already dropped a trailing slash, so `/xspeed/mcp/`
713 + // still matches — and so does the root form with one.
714 + $ours = '#^\.well-known/oauth-(protected-resource|authorization-server)'
715 + . '/' . preg_quote( Mcp_Pairing::SITE_ENDPOINT_PATH, '#' ) . '$#';
716 + if ( preg_match( $ours, $path, $m ) ) {
717 + return array(
718 + 'doc' => $m[1],
719 + 'issuer' => Mcp_OAuth::issuer(),
720 + );
721 + }
386 722
387 - return preg_match( $pattern, $path, $m ) ? $m[1] : '';
723 + // Releasing root is only safe when somebody else can pick it up. If
724 + // OUR rule is what WordPress matched, nobody can: the sibling's query
725 + // var is unset, so its handler never runs, and the request falls
726 + // through to the front page -- a 301 to the homepage where dev
727 + // returns JSON. Answering with the legacy document is the pre-#266
728 + // behaviour. (#266 QA)
729 + //
730 + // Keyed on the query the matched rule RESOLVED TO -- not on
731 + // $wp->query_vars, and not on which regex matched.
732 + //
733 + // query_vars is wrong because the var is public and WP::parse_request
734 + // lets $_GET override anything a rule set, so `?xspeed_mcp_wellknown=1`
735 + // would let anyone force our metadata onto a URL a sibling owns.
736 + //
737 + // matched_rule is wrong because the rewrite table is keyed BY regex:
738 + // a sibling that registered this same pattern replaces our entry and
739 + // the key still reads as ours, while the target behind it is theirs.
740 + // That is a live case here -- is_our_rule_target() exists for it --
741 + // and keying on the rule would answer for the sibling, which is the
742 + // bug this whole change is about.
743 + //
744 + // matched_query is built from the winning rule's TARGET (class-wp.php,
745 + // before the parse_request action) and $_GET never touches it. If it
746 + // sets our query var, our rule genuinely won. (#266 QA)
747 + $routed_to_us = 1 === preg_match(
748 + '#(?:^|&)' . preg_quote( self::WELLKNOWN_QUERY_VAR, '#' ) . '=#',
749 + $matched_query
750 + );
751 + $root = '#^\.well-known/oauth-(protected-resource|authorization-server)$#';
752 + if ( preg_match( $root, $path, $m ) && ( $routed_to_us || ! self::root_discovery_contested() ) ) {
753 + return array(
754 + 'doc' => $m[1],
755 + 'issuer' => Mcp_OAuth::legacy_issuer(),
756 + );
757 + }
758 +
759 + return $none;
388 760 }
389 761
390 762 /**
391 763 * Serve the MCP endpoint on the pretty path. Runs on parse_request so
@@ -393,11 +765,11 @@
393 765 *
394 766 * @param \WP $wp The WP request object.
395 767 */
396 768 public function maybe_handle_pretty_endpoint( $wp ): void {
397 - // OAuth discovery documents (served at the site root).
769 + // OAuth discovery documents.
398 770 //
399 - // Read the doc name from the REQUEST PATH, not just our query var.
771 + // Read the doc name from the REQUEST PATH, and ONLY from the path.
400 772 // `add_rewrite_rule( …, 'top' )` only means "top at the moment it
401 773 // runs", so whichever MCP plugin hooks `init` last ends up first in
402 774 // the table — an order set by plugin load order, which no plugin
403 775 // controls. A sibling's catch-all
@@ -405,31 +777,54 @@
405 777 // the match and our query var is never set, even though the URL is
406 778 // unambiguously ours. Observed live with two different plugins on one
407 779 // site. parse_request runs AFTER matching, so the path is the one
408 780 // signal no sibling rule can take away from us.
409 - $doc = $this->wellknown_doc_from_path();
410 - if ( '' === $doc && ! empty( $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] ) ) {
411 - $doc = (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ];
412 - }
781 + //
782 + // The query VAR is deliberately never consulted. It is public, so
783 + // $_GET can set it on any URL, and answering from it would put our
784 + // metadata on somebody else's address — the whole bug. Which rewrite
785 + // RULE matched is a different thing: WordPress decides it, the query
786 + // string cannot influence it, and it is only read to tell "a sibling
787 + // owns this URL" from "we own it and nobody else can answer".
788 + $matched_query = is_object( $wp ) && isset( $wp->matched_query ) ? (string) $wp->matched_query : '';
789 + $claim = $this->wellknown_claim_from_path( $matched_query );
790 + $doc = $claim['doc'];
413 791 if ( '' !== $doc ) {
414 792 $data = 'authorization-server' === $doc
415 - ? Mcp_OAuth::authorization_server_metadata()
416 - : Mcp_OAuth::protected_resource_metadata();
793 + ? Mcp_OAuth::authorization_server_metadata( $claim['issuer'] )
794 + : Mcp_OAuth::protected_resource_metadata( $claim['issuer'] );
417 795 status_header( 200 );
418 796 header( 'Content-Type: application/json; charset=utf-8' );
419 - // Discovery metadata is public + cacheable.
420 - header( 'Cache-Control: public, max-age=3600' );
797 + // Public and cacheable, but short: this document IS the server's
798 + // identity, and a cached copy outliving an issuer change is the
799 + // one failure a client cannot recover from on its own. (#266)
800 + header( 'Cache-Control: public, max-age=300' );
421 801 echo wp_json_encode( $data );
422 802 exit;
423 803 }
424 804
805 + // Pretty Hub connect document: /xspeed/mcp/connect-info (xspeed-hub#307).
806 + if ( ! empty( $wp->query_vars[ self::CONNECT_INFO_QUERY_VAR ] ) ) {
807 + header( 'Content-Type: application/json; charset=utf-8' );
808 + header( 'Cache-Control: no-store' );
809 + status_header( 200 );
810 + echo wp_json_encode( Mcp_Hub_Connect::connect_info() );
811 + exit;
812 + }
813 +
425 814 // Pretty attach-callback: /xspeed/mcp/attach. The hub POSTs the signed
426 815 // nonce; we verify it and return this site's URL + token. Auth is the
427 816 // nonce itself (admin-minted, HMAC-signed), so no credential needed.
428 817 if ( ! empty( $wp->query_vars[ self::ATTACH_QUERY_VAR ] ) ) {
429 - $body = json_decode( (string) file_get_contents( 'php://input' ), true );
430 - $nonce = is_array( $body ) && isset( $body['nonce'] ) ? (string) $body['nonce'] : '';
431 - $result = Mcp_Hub::verify_attach_nonce( $nonce );
818 + $body = json_decode( (string) file_get_contents( 'php://input' ), true );
819 + $body = is_array( $body ) ? $body : array();
820 + $field = static fn( string $k ): string => isset( $body[ $k ] ) && is_string( $body[ $k ] ) ? $body[ $k ] : '';
821 + $result = self::attach_result( $field( 'nonce' ), $field( 'code' ), $field( 'code_verifier' ) );
822 + // A Hub-started connect lands the browser on the Hub, not here, so
823 + // this call is the only place the per-user link can be recorded.
824 + if ( null !== $result && '' !== $field( 'code' ) ) {
825 + Mcp_Hub::mark_attached( sanitize_email( $field( 'account_email' ) ), (int) $result['user_id'] ?: null );
826 + }
432 827 header( 'Content-Type: application/json; charset=utf-8' );
433 828 header( 'Cache-Control: no-store' );
434 829 if ( null === $result ) {
435 830 status_header( 403 );
@@ -434,8 +829,10 @@
434 829 if ( null === $result ) {
435 830 status_header( 403 );
436 831 echo wp_json_encode( array( 'error' => 'invalid_or_expired_attach_request' ) );
437 832 } else {
833 + // The Hub needs only the credential, as on the REST route.
834 + unset( $result['user_id'] );
438 835 status_header( 200 );
439 836 echo wp_json_encode( $result );
440 837 }
441 838 exit;
@@ -646,10 +1043,28 @@
646 1043 array(
647 1044 'methods' => 'POST',
648 1045 'callback' => array( $this, 'rest_hub_disconnect' ),
649 1046 'permission_callback' => array( $this, 'admin_permission' ),
1047 + 'args' => array(
1048 + 'acknowledge' => array(
1049 + 'type' => 'boolean',
1050 + 'default' => false,
1051 + 'description' => 'Continue even though Mcp_Hub::disconnect_blockers() reported a reason not to. The caller has shown those reasons to a human.',
1052 + ),
1053 + ),
650 1054 )
651 1055 );
1056 + // Hub connect discovery (xspeed-hub#307): public, so the Hub can ask
1057 + // before sending anyone to the consent page. Names no secret.
1058 + register_rest_route(
1059 + self::NS,
1060 + '/hub/connect-info',
1061 + array(
1062 + 'methods' => 'GET',
1063 + 'callback' => array( $this, 'rest_hub_connect_info' ),
1064 + 'permission_callback' => '__return_true',
1065 + )
1066 + );
652 1067 // OAuth-attach callback: the hub calls this with the signed nonce the
653 1068 // plugin issued. Auth is the nonce itself (no pre-shared token), so
654 1069 // permission_callback is open — the handler validates the nonce.
655 1070 register_rest_route(
@@ -659,13 +1074,25 @@
659 1074 'methods' => 'POST',
660 1075 'callback' => array( $this, 'rest_hub_attach_callback' ),
661 1076 'permission_callback' => '__return_true',
662 1077 'args' => array(
663 - 'nonce' => array(
1078 + // One of: the signed nonce (plugin-started attach), or a
1079 + // code plus its PKCE verifier (Hub-started connect, #307).
1080 + 'nonce' => array(
664 1081 'type' => 'string',
665 - 'required' => true,
1082 + 'required' => false,
666 1083 'description' => 'The signed attach nonce the plugin issued.',
667 1084 ),
1085 + 'code' => array(
1086 + 'type' => 'string',
1087 + 'required' => false,
1088 + 'description' => 'The single-use code from the Hub connect consent page.',
1089 + ),
1090 + 'code_verifier' => array(
1091 + 'type' => 'string',
1092 + 'required' => false,
1093 + 'description' => 'The PKCE verifier for that code.',
1094 + ),
668 1095 ),
669 1096 )
670 1097 );
671 1098
@@ -950,19 +1377,64 @@
950 1377 return rest_ensure_response( Mcp_Hub::mark_attached( $email ) );
951 1378 }
952 1379
953 1380 /**
954 - * POST /mcp/hub/disconnect — clear the local hub-link bookkeeping.
1381 + * POST /mcp/hub/disconnect — tell the hub to drop this admin's link and
1382 + * clear the local bookkeeping.
955 1383 *
956 - * @param \WP_REST_Request $request Unused.
957 - * @return \WP_REST_Response
1384 + * Answers 409 while something reports a reason not to disconnect and the
1385 + * request did not send `acknowledge`. The blockers travel in the error
1386 + * data so a non-UI client gets the same reason a human would read.
1387 + *
1388 + * @param \WP_REST_Request $request Carries the optional `acknowledge` flag.
1389 + * @return \WP_REST_Response|\WP_Error
958 1390 */
959 1391 public function rest_hub_disconnect( \WP_REST_Request $request ) {
960 - unset( $request );
961 - return rest_ensure_response( Mcp_Hub::disconnect() );
1392 + $result = Mcp_Hub::disconnect( (bool) $request->get_param( 'acknowledge' ) );
1393 +
1394 + if ( ! empty( $result['blocked'] ) ) {
1395 + $blockers = isset( $result['blockers'] ) && is_array( $result['blockers'] )
1396 + ? $result['blockers']
1397 + : array();
1398 + $reasons = trim( implode( ' ', array_column( $blockers, 'message' ) ) );
1399 +
1400 + return new \WP_Error(
1401 + 'xspeed_hub_disconnect_blocked',
1402 + '' !== $reasons
1403 + ? $reasons
1404 + : __( 'Disconnecting is blocked while this site depends on the hub connection.', 'xspeed' ),
1405 + array(
1406 + 'status' => 409,
1407 + 'blockers' => $blockers,
1408 + )
1409 + );
1410 + }
1411 +
1412 + return rest_ensure_response( $result );
962 1413 }
963 1414
1415 + /** GET /hub/connect-info — whether this site supports connecting from the Hub. */
1416 + public function rest_hub_connect_info(): \WP_REST_Response {
1417 + return rest_ensure_response( Mcp_Hub_Connect::connect_info() );
1418 + }
1419 +
964 1420 /**
1421 + * Check an attach callback: a nonce (plugin-started) or a code with its
1422 + * PKCE verifier (Hub-started, xspeed-hub#307). Null when neither holds.
1423 + *
1424 + * @return array{site_url:string,site_token:string,user_id:int}|null
1425 + */
1426 + private static function attach_result( string $nonce, string $code, string $verifier ): ?array {
1427 + if ( '' !== $nonce ) {
1428 + return Mcp_Hub::verify_attach_nonce( $nonce );
1429 + }
1430 + if ( '' !== $code ) {
1431 + return Mcp_Hub_Connect::redeem_code( $code, $verifier );
1432 + }
1433 + return null;
1434 + }
1435 +
1436 + /**
965 1437 * POST /mcp/attach — the OAuth-attach callback. The hub presents the
966 1438 * signed nonce the plugin issued; on success we return this site's URL +
967 1439 * token so the hub can record it. Nonce is the auth (admin-minted,
968 1440 * HMAC-signed, time-bound), so no pre-shared token is required.
@@ -970,10 +1442,13 @@
970 1442 * @param \WP_REST_Request $request Carries the nonce.
971 1443 * @return \WP_REST_Response|\WP_Error
972 1444 */
973 1445 public function rest_hub_attach_callback( \WP_REST_Request $request ) {
974 - $nonce = (string) $request->get_param( 'nonce' );
975 - $result = Mcp_Hub::verify_attach_nonce( $nonce );
1446 + $result = self::attach_result(
1447 + (string) $request->get_param( 'nonce' ),
1448 + (string) $request->get_param( 'code' ),
1449 + (string) $request->get_param( 'code_verifier' )
1450 + );
976 1451 if ( null === $result ) {
977 1452 return new \WP_Error(
978 1453 'xspeed_attach_invalid',
979 1454 __( 'Invalid or expired attach request.', 'xspeed' ),
@@ -1024,9 +1499,11 @@
1024 1499 * @return \WP_REST_Response
1025 1500 */
1026 1501 private function discovery_response( array $data ): \WP_REST_Response {
1027 1502 $response = new \WP_REST_Response( $data, 200 );
1028 - $response->header( 'Cache-Control', 'public, max-age=3600' );
1503 + // Same short window as the /.well-known/ emit site, same reason: the
1504 + // document carries the server's identity. (#266)
1505 + $response->header( 'Cache-Control', 'public, max-age=300' );
1029 1506 return $response;
1030 1507 }
1031 1508
1032 1509 /**
@@ -1145,8 +1622,61 @@
1145 1622 return $response;
1146 1623 }
1147 1624
1148 1625 /**
1626 + * Reject an over-sized MCP request body before WordPress decodes it.
1627 + *
1628 + * `rest_pre_dispatch` is the last hook that runs before
1629 + * `WP_REST_Server::dispatch()` calls `has_valid_params()`, and that is
1630 + * what `json_decode()`s the body — before the permission callback, so an
1631 + * unauthenticated caller already pays for the decode. Capping here is the
1632 + * difference between reading a length and parsing megabytes of JSON.
1633 + *
1634 + * Refusing is not enough on its own. Core reads request params again on
1635 + * the way out — `rest_filter_response_fields()` on `rest_post_dispatch`
1636 + * looks up `_fields` — and for an `application/json` request that lookup
1637 + * runs `parse_json_params()` over whatever body is still attached. So a
1638 + * refused body is also emptied, and the 413 goes out with nothing left to
1639 + * decode. QA measured a refused 3 MB body peaking near 90 MB without this.
1640 + *
1641 + * Scoped to the two routes that carry tool payloads, compared
1642 + * case-insensitively: `WP_REST_Server::match_request_to_handler()` matches
1643 + * routes with the `i` flag, so `/XSPEED/v1/MCP` reaches the same handler
1644 + * and has to meet the same cap. Returning null leaves the request alone,
1645 + * which is what this filter does for everything else.
1646 + *
1647 + * @param mixed $result A short-circuit response, if one is set.
1648 + * @param mixed $server Unused; the REST server instance.
1649 + * @param \WP_REST_Request $request Incoming request.
1650 + * @return mixed Null to continue, or a WP_Error to refuse.
1651 + */
1652 + public function cap_request_body( $result, $server = null, $request = null ) {
1653 + if ( null !== $result || ! $request instanceof \WP_REST_Request ) {
1654 + return $result;
1655 + }
1656 +
1657 + $route = strtolower( (string) $request->get_route() );
1658 + $mcp = '/' . self::NS . '/mcp';
1659 + if ( $route !== $mcp && 0 !== strpos( $route, $mcp . '/tool/' ) ) {
1660 + return $result;
1661 + }
1662 +
1663 + if ( strlen( (string) $request->get_body() ) <= Mcp_Tools::MAX_TOOL_BODY_BYTES ) {
1664 + return $result;
1665 + }
1666 +
1667 + // Drop the body before refusing. Nothing downstream needs it, and
1668 + // core's response pipeline would otherwise json_decode() it anyway.
1669 + $request->set_body( '' );
1670 +
1671 + return new \WP_Error(
1672 + 'xspeed_mcp_tool_payload_too_large',
1673 + __( 'The MCP tool payload is too large.', 'xspeed' ),
1674 + array( 'status' => 413 )
1675 + );
1676 + }
1677 +
1678 + /**
1149 1679 * Token-authenticated tool route for the hosted broker. Maps a broker
1150 1680 * tool call (e.g. GET /mcp/tool/get_cache_status) onto the shared
1151 1681 * Mcp_Tools catalog, so the broker path and the JSON-RPC path never
1152 1682 * drift. GET params + JSON body both feed the tool's arguments.
@@ -1152,8 +1682,18 @@
1152 1682 * drift. GET params + JSON body both feed the tool's arguments.
1153 1683 */
1154 1684 public function rest_tool( \WP_REST_Request $request ) {
1155 1685 $tool = (string) $request->get_param( 'tool' );
1686 + // Second line behind cap_request_body(). This one still matters: the
1687 + // pretty front-door path builds its own WP_REST_Request and calls the
1688 + // handler without going through WP_REST_Server::dispatch() at all.
1689 + if ( strlen( $request->get_body() ) > Mcp_Tools::MAX_TOOL_BODY_BYTES ) {
1690 + return new \WP_Error(
1691 + 'xspeed_mcp_tool_payload_too_large',
1692 + __( 'The MCP tool payload is too large.', 'xspeed' ),
1693 + array( 'status' => 413 )
1694 + );
1695 + }
1156 1696 $args = $request->get_json_params();
1157 1697 if ( ! is_array( $args ) ) {
1158 1698 $args = array();
1159 1699 }
@@ -1245,18 +1785,9 @@
1245 1785 header( 'Content-Type: text/html; charset=utf-8' );
1246 1786 header( 'Cache-Control: no-store' );
1247 1787
1248 1788 echo '<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' . esc_html__( 'Authorize AI access', 'xspeed' ) . '</title>';
1249 - echo '<style>'
1250 - . '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}'
1251 - . '.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)}'
1252 - . 'h1{font-size:20px;margin:0 0 4px}.sub{color:#94a3b8;font-size:13px;margin:0 0 24px}'
1253 - . '.row{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid #334155;font-size:13px}'
1254 - . '.row span:first-child{color:#94a3b8}.row span:last-child{font-weight:600;text-align:right;max-width:60%;word-break:break-word}'
1255 - . '.actions{display:flex;gap:12px;margin-top:24px}'
1256 - . 'button{flex:1;padding:12px;border-radius:10px;border:0;font-size:14px;font-weight:600;cursor:pointer}'
1257 - . '.approve{background:#f5cd47;color:#1b2533}.deny{background:transparent;color:#94a3b8;border:1px solid #334155}'
1258 - . '</style></head><body><div class="card">';
1789 + echo '<style>' . self::CONSENT_CSS . '</style></head><body><div class="card">'; // phpcs:ignore WordPress.Security.EscapeOutput -- static stylesheet constant.
1259 1790 echo '<h1>' . esc_html__( 'Connect to xSpeed', 'xspeed' ) . '</h1>';
1260 1791 /* translators: %s: AI client name. */
1261 1792 echo '<p class="sub">' . esc_html( sprintf( __( '%s wants to manage the cache on this site.', 'xspeed' ), $client ) ) . '</p>';
1262 1793 echo '<div class="row"><span>' . esc_html__( 'Site', 'xspeed' ) . '</span><span>' . esc_html( wp_parse_url( home_url(), PHP_URL_HOST ) ) . '</span></div>';