PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
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
xspeed / includes / modules / Mcp / McpModule.php

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

2,048 lines 81.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP module — the site side of xSpeed's MCP integration.
4 *
5 * PRIMARY path (no hosted infra): the plugin speaks the MCP protocol
6 * DIRECTLY at this site's own URL. The user pastes their own site's MCP
7 * endpoint + connection token into their AI client:
8 *
9 * https://thissite.com/xspeed/mcp (pretty, via rewrite)
10 * https://thissite.com/wp-json/xspeed/v1/mcp (always-on fallback)
11 *
12 * The MCP JSON-RPC handling lives in Mcp_Server; the tool catalog in
13 * Mcp_Tools. Auth is the per-site connection token (Mcp_Auth /
14 * Mcp_Pairing). See IMPLEMENTATION.md §17.
15 *
16 * OPTIONAL path (hosted broker, api.xspeedcache.com): the same tool
17 * catalog is also exposed as token-authenticated REST routes under
18 * /xspeed/v1/mcp/tool/* so a hosted broker can proxy to it for a single
19 * shared vanity URL. Not required for the product to work.
20 *
21 * Admin-only management routes (manage_options) drive the dashboard
22 * "Connect AI" panel: /mcp/connection, /mcp/connect, /mcp/disconnect.
23 *
24 * These routes register DIRECTLY on rest_api_init (NOT via Rest_Manager,
25 * whose wrap_permission() forces a current_user_can() gate that MCP's
26 * token-only calls can never satisfy).
27 *
28 * Tier: Free. The ONLY gate is possession of the per-site connection
29 * token, which an admin (manage_options) must explicitly mint via
30 * Connect. A fresh install ships with no token → every MCP call is 401
31 * until the admin opts in. Adds ZERO cache logic.
32 *
33 * @package XSpeed
34 */
35
36 declare(strict_types=1);
37
38 namespace XSpeed\Modules\Mcp;
39
40 use XSpeed\Activity_Log;
41 use XSpeed\Module;
42 use XSpeed\Onboarding;
43
44 defined( 'ABSPATH' ) || exit;
45
46 final class McpModule extends Module {
47
48 /**
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
54 public const SLUG = 'mcp';
55 public const TIER = self::TIER_FREE;
56 public const VERSION = '1.0.0';
57
58 /**
59 * Every rewrite rule this module registers, in registration order.
60 *
61 * Single source of truth: add_rewrite() registers these, and the self-heal
62 * guard re-flushes when any is missing from the stored table. They were two
63 * hand-maintained lists before, which is a silent drift risk — a rule
64 * dropped from one and not the other leaves the guard restoring a rule
65 * nothing registers, or never firing for one that is registered.
66 *
67 * @var string[] Rewrite regexes. The query each maps to is built in
68 * add_rewrite(), which also fixes their order.
69 */
70 public const REWRITE_RULES = array(
71 '^xspeed/mcp/([a-f0-9]{64})/?$',
72 '^xspeed/mcp/?$',
73 '^xspeed/mcp/attach/?$',
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.
83 '^\.well-known/oauth-(protected-resource|authorization-server)/xspeed/mcp/?$',
84 '^xspeed/authorize/?$',
85 );
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
124 /** REST namespace shared with Free. Public: Mcp_Server builds the
125 * discovery fallback URL from it. */
126 public const NS = 'xspeed/v1';
127
128 /** Query var flagging a pretty /xspeed/mcp request. */
129 private const QUERY_VAR = 'xspeed_mcp';
130
131 /** Query var carrying the token when embedded in the URL path. */
132 private const TOKEN_QUERY_VAR = 'xspeed_mcp_token';
133
134 /** Query var flagging a /.well-known/ OAuth discovery request. */
135 private const WELLKNOWN_QUERY_VAR = 'xspeed_mcp_wellknown';
136
137 /**
138 * Query var flagging the browser-facing OAuth authorize page. This is
139 * served OUTSIDE the REST API on purpose: a REST route only honors cookie
140 * auth when a REST nonce accompanies it, but a browser arriving from
141 * wp-login carries the cookie with NO nonce — so is_user_logged_in() would
142 * be false there and the consent screen would loop back to login forever.
143 * A normal front-end URL (rewrite + parse_request) sees standard cookie
144 * auth, so the logged-in admin check works.
145 */
146 private const AUTHORIZE_QUERY_VAR = 'xspeed_mcp_authorize';
147
148 /** Front-end path of the browser-facing authorize page. */
149 private const AUTHORIZE_PATH = 'xspeed/authorize';
150
151 /** Query var flagging the pretty /xspeed/mcp/attach callback. */
152 private const ATTACH_QUERY_VAR = 'xspeed_mcp_attach';
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
157 public function ui_metadata(): array {
158 return array(
159 'label' => __( 'MCP Server', 'xspeed' ),
160 'icon' => 'Sparkles',
161 'description' => __( 'Let Claude or another AI assistant clear the cache, check stats and change settings.', 'xspeed' ),
162 'custom_panel' => 'McpPanel',
163 'group' => 'ai-agents',
164 );
165 }
166
167 /**
168 * MCP pairing state lives in xspeed_module_mcp but is managed by
169 * Mcp_Pairing, not the schema engine. Empty schema so the base class
170 * doesn't auto-register generic settings routes.
171 */
172 public function settings_schema(): array {
173 return array();
174 }
175
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 /**
200 * All MCP routes register directly (see class docblock). Returning an
201 * empty array keeps Rest_Manager out of the token-auth path entirely.
202 */
203 public function rest_routes(): array {
204 return array();
205 }
206
207 public function boot(): void {
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' ) );
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
219 // Pretty per-site endpoint: /xspeed/mcp → MCP JSON-RPC handler.
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 );
241 add_filter( 'query_vars', array( $this, 'register_query_var' ) );
242 // Priority 1: a sibling MCP plugin that also claims /.well-known/ gets
243 // to answer first at the default priority 10, and whoever answers
244 // first calls exit(). Running early means the URL is decided by WHOSE
245 // path it is, not by which plugin happened to load last.
246 add_action( 'parse_request', array( $this, 'maybe_handle_pretty_endpoint' ), 1 );
247
248 // Hub redirect-return: after the user approves on the Hub, it sends the
249 // browser back to a plugin admin URL carrying ?xspeed_connected=1 plus
250 // the account email + the SAME signed nonce we minted. We verify our own
251 // nonce and mark this admin attached — no server-to-server callback
252 // needed, so it works for local/firewalled sites too.
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' ) );
258
259 // An attached admin who is DELETED (or removed from the blog) never
260 // runs disconnect(), so the site-level attached mirror would report
261 // hub:true forever. deleted_user fires after both wp_delete_user()
262 // and wpmu_delete_user() drop the user, so a plain recompute is
263 // honest there. remove_user_from_blog is core's only removal action
264 // and fires BEFORE removal, so its handler clears the departing
265 // user's record before recomputing (see Mcp_Hub::handle_user_removed).
266 add_action( 'deleted_user', array( Mcp_Hub::class, 'refresh_site_attached' ) );
267 add_action( 'remove_user_from_blog', array( Mcp_Hub::class, 'handle_user_removed' ) );
268 }
269
270 /**
271 * Handle the browser landing back from the Hub after a connect. Idempotent
272 * and safe to run on every admin page load: it only acts when the return
273 * markers are present and the nonce verifies.
274 */
275 public function maybe_handle_hub_return(): void {
276 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- auth is the signed HMAC nonce below, not a WP nonce; this is a read-only routing check.
277 $nonce = isset( $_GET['xspeed_hub_nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['xspeed_hub_nonce'] ) ) : '';
278 $email = isset( $_GET['xspeed_hub_email'] ) ? sanitize_email( wp_unslash( $_GET['xspeed_hub_email'] ) ) : '';
279
280 /*
281 * Trigger on the signed nonce, not on `xspeed_connected`.
282 *
283 * The Hub bounces the browser back with xspeed_hub_nonce +
284 * xspeed_hub_email, but it does NOT always append xspeed_connected —
285 * that marker only survives when the return_url we handed it carried
286 * one. Gating on it meant a real, correctly-signed return was ignored:
287 * the attach was never recorded, the params were never stripped, and
288 * the card kept showing "Not connected" while the nonce sat in the
289 * address bar. The nonce is the actual proof of a genuine round trip,
290 * so it is what this handler keys on. (FBS-84086)
291 */
292 if ( '' === $nonce && empty( $_GET['xspeed_connected'] ) ) {
293 return;
294 }
295 // phpcs:enable WordPress.Security.NonceVerification.Recommended
296
297 if ( ! current_user_can( 'manage_options' ) ) {
298 return;
299 }
300
301 // Verify OUR own signed nonce (proves the round-trip went through the
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.
305 if ( '' !== $nonce ) {
306 $uid = Mcp_Hub::check_attach_nonce( $nonce );
307 if ( null !== $uid ) {
308 Mcp_Hub::mark_attached( $email, $uid ?: null );
309 }
310 }
311
312 // ALWAYS strip the one-time return markers from the URL and redirect to
313 // the clean address. These params are single-use; if they persist in the
314 // browser URL, a later reload re-triggers the "just connected" path and
315 // flashes a stale connected state even after the user has disconnected.
316 $clean = remove_query_arg( array( 'xspeed_connected', 'xspeed_hub_nonce', 'xspeed_hub_email' ) );
317
318 // The setup wizard keeps its current step in component state, so a
319 // redirect remounts it at step 1 — dumping the user back at the START of
320 // onboarding immediately after they finished its LAST step. Carry a
321 // durable hint so the wizard resumes on Connect instead. It's a plain
322 // step marker, not an auth signal (the nonce above did that job), and
323 // it's safe to leave in the URL: re-loading it just re-opens the same
324 // step rather than re-running the connect path. (PM feedback)
325 if ( false !== strpos( (string) $clean, 'page=' . Onboarding::PAGE_SLUG ) ) {
326 $clean = add_query_arg( 'xspeed_step', 'connect', $clean );
327 }
328
329 wp_safe_redirect( $clean );
330 exit;
331 }
332
333 /**
334 * Flush rewrites once when the module first boots so /xspeed/mcp works
335 * without a manual permalink re-save. Cheap: gated on a one-shot flag.
336 */
337 public function activate(): void {
338 $this->add_rewrite();
339 flush_rewrite_rules( false );
340 }
341
342 public function deactivate(): void {
343 flush_rewrite_rules( false );
344 }
345
346 // -- Pretty endpoint: /xspeed/mcp --
347
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
356 // Token-in-URL form: /xspeed/mcp/<token> — a single string the user
357 // pastes into their AI client (no separate token field). The bare
358 // /xspeed/mcp still works with a Bearer/header token.
359 add_rewrite_rule(
360 '^xspeed/mcp/([a-f0-9]{64})/?$',
361 'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]',
362 'top'
363 );
364 add_rewrite_rule( '^xspeed/mcp/?$', 'index.php?' . self::QUERY_VAR . '=1', 'top' );
365
366 // Pretty attach-callback endpoint: /xspeed/mcp/attach — the hub POSTs
367 // the signed nonce here to verify + fetch the token. Uses the plugin's
368 // own rewrite (consistent with the MCP URL, survives hosts that block
369 // /wp-json). Placed BEFORE the token rule would never match "attach"
370 // (that rule requires 64 hex chars), so ordering is safe.
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' );
375
376 // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place the
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
382 //
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.
390 add_rewrite_rule(
391 '^\\.well-known/oauth-(protected-resource|authorization-server)/xspeed/mcp/?$',
392 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
393 'top'
394 );
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
416 // Browser-facing OAuth consent page — served OUTSIDE REST so cookie
417 // auth (is_user_logged_in) works after the wp-login round-trip.
418 add_rewrite_rule( '^xspeed/authorize/?$', 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', 'top' );
419
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;
537 }
538 }
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;
556 }
557
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 /**
607 * True when a request for our discovery URL can reach WordPress at all.
608 *
609 * Since maybe_handle_pretty_endpoint() claims the document by REQUEST
610 * PATH, a sibling plugin winning the rewrite match no longer matters —
611 * we answer either way. What still breaks the pretty URL is there being
612 * no rewrite for it in the first place (plain permalinks), because then
613 * nothing routes the path to index.php and parse_request never runs.
614 *
615 * Blind to upstream interception: a host that owns the /.well-known/
616 * prefix (an nginx ACME block, an edge redirect rule) answers before
617 * WordPress loads, and WP cannot see that. Use the
618 * `xspeed_mcp_resource_metadata_url` filter on such hosts.
619 */
620 public static function wellknown_rewrites_active(): bool {
621 $rules = get_option( 'rewrite_rules' );
622 if ( ! is_array( $rules ) || array() === $rules ) {
623 return false;
624 }
625
626 // Any rule that routes our discovery path to index.php will do — ours
627 // or a sibling's — because the path check inside the handler decides
628 // the outcome once the request lands.
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;
637 foreach ( $rules as $pattern => $target ) {
638 if ( preg_match( '#' . str_replace( '#', '\\#', $pattern ) . '#', $probe ) ) {
639 return true;
640 }
641 }
642
643 return false;
644 }
645
646 /**
647 * @param string[] $vars Registered query vars.
648 * @return string[]
649 */
650 public function register_query_var( array $vars ): array {
651 $vars[] = self::QUERY_VAR;
652 $vars[] = self::TOKEN_QUERY_VAR;
653 $vars[] = self::WELLKNOWN_QUERY_VAR;
654 $vars[] = self::AUTHORIZE_QUERY_VAR;
655 $vars[] = self::ATTACH_QUERY_VAR;
656 $vars[] = self::CONNECT_INFO_QUERY_VAR;
657 return $vars;
658 }
659
660 /**
661 * Which discovery document the CURRENT request path asks for, if any.
662 *
663 * Claims only URLs that are unambiguously ours, mirroring the rewrite
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
667 * `/.well-known/oauth-protected-resource/betterlinks/mcp` with xSpeed
668 * metadata is the same bug that broke this site, just pointed the other
669 * way.
670 *
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.
688 */
689 private function wellknown_claim_from_path( string $matched_query = '' ): array {
690 $none = array(
691 'doc' => '',
692 'issuer' => '',
693 );
694
695 $uri = isset( $_SERVER['REQUEST_URI'] )
696 ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) )
697 : '';
698 if ( '' === $uri ) {
699 return $none;
700 }
701
702 $path = (string) wp_parse_url( $uri, PHP_URL_PATH );
703
704 // Sites in a subdirectory carry that prefix on every request.
705 $home = (string) wp_parse_url( home_url(), PHP_URL_PATH );
706 if ( '' !== $home && '/' !== $home && 0 === strpos( $path, $home ) ) {
707 $path = substr( $path, strlen( $home ) );
708 }
709
710 $path = trim( $path, '/' );
711
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 }
722
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;
760 }
761
762 /**
763 * Serve the MCP endpoint on the pretty path. Runs on parse_request so
764 * it fires before the main query, and short-circuits WP entirely.
765 *
766 * @param \WP $wp The WP request object.
767 */
768 public function maybe_handle_pretty_endpoint( $wp ): void {
769 // OAuth discovery documents.
770 //
771 // Read the doc name from the REQUEST PATH, and ONLY from the path.
772 // `add_rewrite_rule( …, 'top' )` only means "top at the moment it
773 // runs", so whichever MCP plugin hooks `init` last ends up first in
774 // the table — an order set by plugin load order, which no plugin
775 // controls. A sibling's catch-all
776 // (`…(protected-resource|authorization-server)(?:/.*)?/?$`) then wins
777 // the match and our query var is never set, even though the URL is
778 // unambiguously ours. Observed live with two different plugins on one
779 // site. parse_request runs AFTER matching, so the path is the one
780 // signal no sibling rule can take away from us.
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'];
791 if ( '' !== $doc ) {
792 $data = 'authorization-server' === $doc
793 ? Mcp_OAuth::authorization_server_metadata( $claim['issuer'] )
794 : Mcp_OAuth::protected_resource_metadata( $claim['issuer'] );
795 status_header( 200 );
796 header( 'Content-Type: application/json; charset=utf-8' );
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' );
801 echo wp_json_encode( $data );
802 exit;
803 }
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
814 // Pretty attach-callback: /xspeed/mcp/attach. The hub POSTs the signed
815 // nonce; we verify it and return this site's URL + token. Auth is the
816 // nonce itself (admin-minted, HMAC-signed), so no credential needed.
817 if ( ! empty( $wp->query_vars[ self::ATTACH_QUERY_VAR ] ) ) {
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 }
827 header( 'Content-Type: application/json; charset=utf-8' );
828 header( 'Cache-Control: no-store' );
829 if ( null === $result ) {
830 status_header( 403 );
831 echo wp_json_encode( array( 'error' => 'invalid_or_expired_attach_request' ) );
832 } else {
833 // The Hub needs only the credential, as on the REST route.
834 unset( $result['user_id'] );
835 status_header( 200 );
836 echo wp_json_encode( $result );
837 }
838 exit;
839 }
840
841 // Browser-facing OAuth consent page (cookie auth applies here).
842 if ( ! empty( $wp->query_vars[ self::AUTHORIZE_QUERY_VAR ] ) ) {
843 $this->handle_authorize_page();
844 return;
845 }
846
847 if ( empty( $wp->query_vars[ self::QUERY_VAR ] ) ) {
848 return;
849 }
850
851 $request = new \WP_REST_Request( 'POST', '/xspeed/v1/mcp' );
852 $request->set_header( 'content-type', 'application/json' );
853 // Carry the auth headers + raw body from the live PHP request.
854 foreach ( array( 'authorization', Mcp_Auth::TOKEN_HEADER ) as $h ) {
855 $val = self::server_header( $h );
856 if ( null !== $val ) {
857 $request->set_header( $h, $val );
858 }
859 }
860 // Token embedded in the URL path (/xspeed/mcp/<token>) — surface it
861 // as the standard token header so Mcp_Server validates it the same
862 // way. A header/Bearer token (if also sent) still takes precedence.
863 $path_token = isset( $wp->query_vars[ self::TOKEN_QUERY_VAR ] )
864 ? (string) $wp->query_vars[ self::TOKEN_QUERY_VAR ]
865 : '';
866 if ( '' !== $path_token && '' === (string) $request->get_header( Mcp_Auth::TOKEN_HEADER ) && '' === (string) $request->get_header( 'authorization' ) ) {
867 $request->set_header( Mcp_Auth::TOKEN_HEADER, $path_token );
868 }
869 $request->set_body( file_get_contents( 'php://input' ) );
870
871 $response = Mcp_Server::handle( $request );
872 $this->emit_json( $response );
873 }
874
875 // -- REST registration --
876
877 public function register_rest(): void {
878 // --- MCP JSON-RPC endpoint (fallback path via wp-json) -----------
879 // permission_callback is __return_true because Mcp_Server does its
880 // own token auth and must reply with a JSON-RPC 401, not a bare WP
881 // permission failure.
882 register_rest_route(
883 self::NS,
884 '/mcp',
885 array(
886 'methods' => 'POST',
887 'callback' => array( $this, 'rest_mcp' ),
888 'permission_callback' => '__return_true',
889 )
890 );
891
892 // --- Public scan signals -----------------------------------------
893 // One tiny unauthenticated JSON body for external audit tools (the
894 // speed scanner on xspeedcache.com): plugin version, whether the MCP
895 // server is connected, and whether the site is attached to xSpeed
896 // Hub. Everything except `hub` is already publicly discoverable —
897 // the cache signature carries the version and /mcp answers 401 when
898 // connected — and `hub` is a bare boolean. No tokens, accounts or
899 // emails leave through this route.
900 register_rest_route(
901 self::NS,
902 '/signals',
903 array(
904 'methods' => 'GET',
905 'callback' => array( $this, 'rest_signals' ),
906 'permission_callback' => '__return_true',
907 )
908 );
909
910 // --- Admin-only management routes (dashboard) --------------------
911 register_rest_route(
912 self::NS,
913 '/mcp/connection',
914 array(
915 'methods' => 'GET',
916 'callback' => array( $this, 'rest_connection' ),
917 'permission_callback' => array( $this, 'admin_permission' ),
918 )
919 );
920 register_rest_route(
921 self::NS,
922 '/mcp/activity',
923 array(
924 'methods' => 'GET',
925 'callback' => array( $this, 'rest_activity' ),
926 'permission_callback' => array( $this, 'admin_permission' ),
927 'args' => array(
928 'limit' => array(
929 'type' => 'integer',
930 'required' => false,
931 'default' => 50,
932 'description' => 'Maximum entries to return (newest first).',
933 ),
934 ),
935 )
936 );
937 register_rest_route(
938 self::NS,
939 '/mcp/activity/clear',
940 array(
941 'methods' => 'POST',
942 'callback' => array( $this, 'rest_activity_clear' ),
943 'permission_callback' => array( $this, 'admin_permission' ),
944 )
945 );
946 register_rest_route(
947 self::NS,
948 '/mcp/connect',
949 array(
950 'methods' => 'POST',
951 'callback' => array( $this, 'rest_connect' ),
952 'permission_callback' => array( $this, 'admin_permission' ),
953 'args' => array(
954 'read_only' => array(
955 'type' => 'boolean',
956 'required' => false,
957 'default' => false,
958 'description' => 'Grant read-only access (no purge/toggle/settings changes).',
959 ),
960 ),
961 )
962 );
963 register_rest_route(
964 self::NS,
965 '/mcp/rotate',
966 array(
967 'methods' => 'POST',
968 'callback' => array( $this, 'rest_rotate' ),
969 'permission_callback' => array( $this, 'admin_permission' ),
970 'args' => array(
971 'read_only' => array(
972 'type' => 'boolean',
973 'required' => false,
974 'description' => 'Optionally set read-only on the new token; omit to keep current scopes.',
975 ),
976 ),
977 )
978 );
979 register_rest_route(
980 self::NS,
981 '/mcp/access',
982 array(
983 'methods' => 'POST',
984 'callback' => array( $this, 'rest_access' ),
985 'permission_callback' => array( $this, 'admin_permission' ),
986 'args' => array(
987 'read_only' => array(
988 'type' => 'boolean',
989 'required' => true,
990 'description' => 'Switch the live connection to read-only (true) or read & write (false), keeping the same token.',
991 ),
992 ),
993 )
994 );
995 register_rest_route(
996 self::NS,
997 '/mcp/disconnect',
998 array(
999 'methods' => 'POST',
1000 'callback' => array( $this, 'rest_disconnect' ),
1001 'permission_callback' => array( $this, 'admin_permission' ),
1002 )
1003 );
1004
1005 // --- xSpeed Hub (multi-site) attach routes ------------------------
1006 register_rest_route(
1007 self::NS,
1008 '/mcp/hub',
1009 array(
1010 'methods' => 'GET',
1011 'callback' => array( $this, 'rest_hub_status' ),
1012 'permission_callback' => array( $this, 'admin_permission' ),
1013 )
1014 );
1015 register_rest_route(
1016 self::NS,
1017 '/mcp/hub/token',
1018 array(
1019 'methods' => 'POST',
1020 'callback' => array( $this, 'rest_hub_token' ),
1021 'permission_callback' => array( $this, 'admin_permission' ),
1022 )
1023 );
1024 register_rest_route(
1025 self::NS,
1026 '/mcp/hub/attached',
1027 array(
1028 'methods' => 'POST',
1029 'callback' => array( $this, 'rest_hub_attached' ),
1030 'permission_callback' => array( $this, 'admin_permission' ),
1031 'args' => array(
1032 'account_email' => array(
1033 'type' => 'string',
1034 'required' => true,
1035 'description' => 'The hub account email this site was attached to.',
1036 ),
1037 ),
1038 )
1039 );
1040 register_rest_route(
1041 self::NS,
1042 '/mcp/hub/disconnect',
1043 array(
1044 'methods' => 'POST',
1045 'callback' => array( $this, 'rest_hub_disconnect' ),
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 ),
1054 )
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 );
1067 // OAuth-attach callback: the hub calls this with the signed nonce the
1068 // plugin issued. Auth is the nonce itself (no pre-shared token), so
1069 // permission_callback is open — the handler validates the nonce.
1070 register_rest_route(
1071 self::NS,
1072 '/mcp/attach',
1073 array(
1074 'methods' => 'POST',
1075 'callback' => array( $this, 'rest_hub_attach_callback' ),
1076 'permission_callback' => '__return_true',
1077 'args' => 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(
1081 'type' => 'string',
1082 'required' => false,
1083 'description' => 'The signed attach nonce the plugin issued.',
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 ),
1095 ),
1096 )
1097 );
1098
1099 // --- OAuth 2.1 authorization server (the "paste a URL only" path) -
1100 // Discovery, dynamic client registration, and the token endpoint are
1101 // all public (permission enforced inside): a client must reach them
1102 // BEFORE it holds any credential. The authorize endpoint gates on a
1103 // logged-in admin inside its handler (anonymous → wp-login redirect).
1104 register_rest_route(
1105 self::NS,
1106 '/mcp/oauth/register',
1107 array(
1108 'methods' => 'POST',
1109 'callback' => array( $this, 'rest_oauth_register' ),
1110 'permission_callback' => '__return_true',
1111 )
1112 );
1113 // NOTE: /authorize is deliberately NOT a REST route — it is served as a
1114 // normal front-end page at /xspeed/authorize (see handle_authorize_page)
1115 // so cookie auth works after the wp-login round-trip.
1116 register_rest_route(
1117 self::NS,
1118 '/mcp/oauth/token',
1119 array(
1120 'methods' => 'POST',
1121 'callback' => array( $this, 'rest_oauth_token' ),
1122 'permission_callback' => '__return_true',
1123 )
1124 );
1125
1126 // --- OAuth discovery, REST fallback ------------------------------
1127 // The canonical documents live at /.well-known/… via rewrite rules.
1128 // Many hosts own that prefix for ACME/Let's Encrypt (an nginx
1129 // `location ^~ /.well-known` block, or an edge redirect rule), which
1130 // swallows the request before WordPress ever runs — the pretty URL
1131 // then 404s or redirects to the homepage no matter how the plugin is
1132 // configured, and OAuth discovery dead-ends with no way back.
1133 // Serving the same two documents under /wp-json puts them on a path
1134 // no ACME tooling claims, so discovery still completes there.
1135 register_rest_route(
1136 self::NS,
1137 '/mcp/.well-known/oauth-protected-resource',
1138 array(
1139 'methods' => 'GET',
1140 'callback' => array( $this, 'rest_protected_resource_metadata' ),
1141 'permission_callback' => '__return_true',
1142 )
1143 );
1144 register_rest_route(
1145 self::NS,
1146 '/mcp/.well-known/oauth-authorization-server',
1147 array(
1148 'methods' => 'GET',
1149 'callback' => array( $this, 'rest_authorization_server_metadata' ),
1150 'permission_callback' => '__return_true',
1151 )
1152 );
1153
1154 // --- MCP-token-only tool routes (optional hosted-broker path) ----
1155 $tool_perm = array( Mcp_Auth::class, 'permission' );
1156 register_rest_route(
1157 self::NS,
1158 // [a-z0-9_-]+ — the HYPHEN is the one that matters, not the digit.
1159 // Generated tool names carry their module slug verbatim, and 33 of
1160 // the 92 in the catalog have a hyphenated slug
1161 // (xspeed_cache-404_status, xspeed_migration-pro_apply,
1162 // xspeed_smart-predict_status …). Every one of those returned
1163 // rest_no_route through the broker path. The earlier widening to
1164 // [a-z0-9_]+ un-blocked nothing: the only digit-bearing name is
1165 // cache-404, whose problem was the hyphen. (QA on #158) */
1166 '/mcp/tool/(?P<tool>[a-z0-9_-]+)',
1167 array(
1168 array(
1169 'methods' => 'GET',
1170 'callback' => array( $this, 'rest_tool' ),
1171 'permission_callback' => $tool_perm,
1172 ),
1173 array(
1174 'methods' => 'POST',
1175 'callback' => array( $this, 'rest_tool' ),
1176 'permission_callback' => $tool_perm,
1177 ),
1178 )
1179 );
1180 }
1181
1182 /**
1183 * Capability gate for the admin-only management routes.
1184 *
1185 * @return bool
1186 */
1187 public function admin_permission(): bool {
1188 return current_user_can( 'manage_options' );
1189 }
1190
1191 // -- Handlers ----------------------------------------------------------
1192
1193 /**
1194 * MCP JSON-RPC over the wp-json fallback path.
1195 *
1196 * @param \WP_REST_Request $request Incoming request.
1197 * @return \WP_REST_Response
1198 */
1199 public function rest_mcp( \WP_REST_Request $request ) {
1200 $response = Mcp_Server::handle( $request );
1201 // Advertise the MCP protocol version on the wp-json transport too, so
1202 // both endpoints behave identically to a strict Streamable-HTTP client.
1203 $response->header( 'MCP-Protocol-Version', Mcp_Server::PROTOCOL_VERSION );
1204 return $response;
1205 }
1206
1207 /**
1208 * GET /signals — the public scan-signals body. See the route
1209 * registration for what may (and may not) leave through it.
1210 *
1211 * @return \WP_REST_Response
1212 */
1213 public function rest_signals() {
1214 $signals = array(
1215 'xspeed' => XSPEED_VERSION,
1216 'mcp' => '' !== Mcp_Pairing::site_token(),
1217 'hub' => Mcp_Hub::site_attached(),
1218 );
1219
1220 /**
1221 * Filter the public scan signals.
1222 *
1223 * Lets an add-on append its own public facts (e.g. its version
1224 * under `pro`). Values returned here are served UNAUTHENTICATED —
1225 * never add tokens, accounts, emails, or paths.
1226 *
1227 * @param array<string,mixed> $signals The signals body.
1228 */
1229 $signals = (array) apply_filters( 'xspeed_scan_signals', $signals );
1230
1231 return rest_ensure_response( $signals );
1232 }
1233
1234 /**
1235 * GET /mcp/connection — pairing status for the dashboard.
1236 *
1237 * @param \WP_REST_Request $request Unused.
1238 * @return \WP_REST_Response
1239 */
1240 public function rest_connection( \WP_REST_Request $request ) {
1241 unset( $request );
1242 return rest_ensure_response( Mcp_Pairing::public_status() );
1243 }
1244
1245 /**
1246 * GET /mcp/activity — the audit trail of AI tool calls.
1247 *
1248 * @param \WP_REST_Request $request Carries the optional limit.
1249 * @return \WP_REST_Response|\WP_Error
1250 */
1251 public function rest_activity( \WP_REST_Request $request ) {
1252 $limit = (int) $request->get_param( 'limit' );
1253
1254 return rest_ensure_response(
1255 array(
1256 'entries' => Mcp_Activity_Log::entries( $limit > 0 ? $limit : 50 ),
1257 'summary' => Mcp_Activity_Log::summary(),
1258 )
1259 );
1260 }
1261
1262 /**
1263 * POST /mcp/activity/clear — wipe the audit trail.
1264 *
1265 * @param \WP_REST_Request $request Unused.
1266 * @return \WP_REST_Response|\WP_Error
1267 */
1268 public function rest_activity_clear( \WP_REST_Request $request ) {
1269 unset( $request );
1270 $cleared = Mcp_Activity_Log::clear();
1271
1272 return rest_ensure_response(
1273 array(
1274 'cleared' => $cleared,
1275 'entries' => Mcp_Activity_Log::entries(),
1276 'summary' => Mcp_Activity_Log::summary(),
1277 )
1278 );
1279 }
1280
1281 /**
1282 * POST /mcp/connect — mint a connection token.
1283 *
1284 * @param \WP_REST_Request $request Unused.
1285 * @return \WP_REST_Response|\WP_Error
1286 */
1287 public function rest_connect( \WP_REST_Request $request ) {
1288 $read_only = (bool) $request->get_param( 'read_only' );
1289 $result = Mcp_Pairing::connect( $read_only );
1290 if ( is_wp_error( $result ) ) {
1291 return $result;
1292 }
1293 return rest_ensure_response( $result );
1294 }
1295
1296 /**
1297 * POST /mcp/rotate — mint a fresh token, invalidating the old one.
1298 *
1299 * @param \WP_REST_Request $request Carries optional read_only.
1300 * @return \WP_REST_Response
1301 */
1302 public function rest_rotate( \WP_REST_Request $request ) {
1303 $read_only = null;
1304 if ( null !== $request->get_param( 'read_only' ) ) {
1305 $read_only = (bool) $request->get_param( 'read_only' );
1306 }
1307 return rest_ensure_response( Mcp_Pairing::rotate( $read_only ) );
1308 }
1309
1310 /**
1311 * POST /mcp/access — change the live connection's read-only state WITHOUT
1312 * minting a new token (the paired client keeps working; only its allowed
1313 * tools change). This is what the dashboard's read-only toggle calls.
1314 *
1315 * @param \WP_REST_Request $request Carries the required read_only bool.
1316 * @return \WP_REST_Response|\WP_Error
1317 */
1318 public function rest_access( \WP_REST_Request $request ) {
1319 $read_only = (bool) $request->get_param( 'read_only' );
1320 $result = Mcp_Pairing::set_read_only( $read_only );
1321 if ( is_wp_error( $result ) ) {
1322 return $result;
1323 }
1324 return rest_ensure_response( $result );
1325 }
1326
1327 /**
1328 * POST /mcp/disconnect — revoke the connection token.
1329 *
1330 * @param \WP_REST_Request $request Unused.
1331 * @return \WP_REST_Response
1332 */
1333 public function rest_disconnect( \WP_REST_Request $request ) {
1334 unset( $request );
1335 return rest_ensure_response( Mcp_Pairing::disconnect() );
1336 }
1337
1338 // -- xSpeed Hub (multi-site) handlers ----------------------------------
1339
1340 /**
1341 * GET /mcp/hub — hub-link status + the Method-1 paste-in values.
1342 *
1343 * @param \WP_REST_Request $request Unused.
1344 * @return \WP_REST_Response
1345 */
1346 public function rest_hub_status( \WP_REST_Request $request ) {
1347 // Self-heal from the Hub (source of truth) so the connected badge is
1348 // reliable even if the attach callback never fired. Force a fresh check
1349 // when the panel asks via the X-XSpeed-Reconcile header (e.g. the admin
1350 // returned to the tab after connecting).
1351 $force = '1' === (string) $request->get_header( 'x_xspeed_reconcile' );
1352 Mcp_Hub::reconcile_with_hub( $force );
1353 return rest_ensure_response( Mcp_Hub::public_status() );
1354 }
1355
1356 /**
1357 * POST /mcp/hub/token — ensure a site_token exists and return the
1358 * paste-in values (this site's URL + token) for the hub's Add-site form.
1359 *
1360 * @param \WP_REST_Request $request Unused.
1361 * @return \WP_REST_Response
1362 */
1363 public function rest_hub_token( \WP_REST_Request $request ) {
1364 unset( $request );
1365 return rest_ensure_response( Mcp_Hub::generate_token() );
1366 }
1367
1368 /**
1369 * POST /mcp/hub/attached — record which hub account this site is
1370 * attached to (bookkeeping for the panel's status line).
1371 *
1372 * @param \WP_REST_Request $request Carries account_email.
1373 * @return \WP_REST_Response
1374 */
1375 public function rest_hub_attached( \WP_REST_Request $request ) {
1376 $email = sanitize_email( (string) $request->get_param( 'account_email' ) );
1377 return rest_ensure_response( Mcp_Hub::mark_attached( $email ) );
1378 }
1379
1380 /**
1381 * POST /mcp/hub/disconnect — tell the hub to drop this admin's link and
1382 * clear the local bookkeeping.
1383 *
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
1390 */
1391 public function rest_hub_disconnect( \WP_REST_Request $request ) {
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 );
1413 }
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
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 /**
1437 * POST /mcp/attach — the OAuth-attach callback. The hub presents the
1438 * signed nonce the plugin issued; on success we return this site's URL +
1439 * token so the hub can record it. Nonce is the auth (admin-minted,
1440 * HMAC-signed, time-bound), so no pre-shared token is required.
1441 *
1442 * @param \WP_REST_Request $request Carries the nonce.
1443 * @return \WP_REST_Response|\WP_Error
1444 */
1445 public function rest_hub_attach_callback( \WP_REST_Request $request ) {
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 );
1451 if ( null === $result ) {
1452 return new \WP_Error(
1453 'xspeed_attach_invalid',
1454 __( 'Invalid or expired attach request.', 'xspeed' ),
1455 array( 'status' => 403 )
1456 );
1457 }
1458 // A valid nonce proves this is a real hub-initiated attach, so record it
1459 // now — the hub passes the account email so the panel can show
1460 // "Connected via <email>". The nonce carries the minting admin's user
1461 // id (no WP session exists in this server-to-server call), so the state
1462 // is recorded PER-USER — each admin sees their own connection.
1463 $account_email = sanitize_email( (string) $request->get_param( 'account_email' ) );
1464 $user_id = isset( $result['user_id'] ) ? (int) $result['user_id'] : 0;
1465 Mcp_Hub::mark_attached( $account_email, $user_id ?: null );
1466
1467 // The hub only needs the credential; don't leak the internal user id.
1468 unset( $result['user_id'] );
1469 return rest_ensure_response( $result );
1470 }
1471
1472 // -- OAuth 2.1 handlers ------------------------------------------------
1473
1474 /**
1475 * GET /mcp/.well-known/oauth-protected-resource — RFC 9728 metadata.
1476 *
1477 * Byte-identical to what the /.well-known rewrite serves; both call the
1478 * same builder so the two locations can never drift.
1479 *
1480 * @return \WP_REST_Response
1481 */
1482 public function rest_protected_resource_metadata(): \WP_REST_Response {
1483 return $this->discovery_response( Mcp_OAuth::protected_resource_metadata() );
1484 }
1485
1486 /**
1487 * GET /mcp/.well-known/oauth-authorization-server — RFC 8414 metadata.
1488 *
1489 * @return \WP_REST_Response
1490 */
1491 public function rest_authorization_server_metadata(): \WP_REST_Response {
1492 return $this->discovery_response( Mcp_OAuth::authorization_server_metadata() );
1493 }
1494
1495 /**
1496 * Wrap a discovery document in a public, cacheable REST response.
1497 *
1498 * @param array<string,mixed> $data The metadata document.
1499 * @return \WP_REST_Response
1500 */
1501 private function discovery_response( array $data ): \WP_REST_Response {
1502 $response = new \WP_REST_Response( $data, 200 );
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' );
1506 return $response;
1507 }
1508
1509 /**
1510 * POST /mcp/oauth/register — RFC 7591 dynamic client registration.
1511 *
1512 * @param \WP_REST_Request $request JSON body with redirect_uris.
1513 * @return \WP_REST_Response|\WP_Error
1514 */
1515 public function rest_oauth_register( \WP_REST_Request $request ) {
1516 $body = $request->get_json_params();
1517 if ( ! is_array( $body ) ) {
1518 $body = array();
1519 }
1520 $result = Mcp_OAuth::register_client( $body );
1521 if ( is_wp_error( $result ) ) {
1522 return $result;
1523 }
1524 return new \WP_REST_Response( $result, 201 );
1525 }
1526
1527 /**
1528 * The browser-facing OAuth authorize page (served at /xspeed/authorize via
1529 * a rewrite, NOT the REST API — see AUTHORIZE_QUERY_VAR). Reads request
1530 * params from the superglobals because this is a normal front-end request
1531 * where cookie auth populates is_user_logged_in().
1532 *
1533 * GET renders the consent screen (requires a logged-in admin; anonymous
1534 * users go to wp-login and return here). POST is the nonce-checked consent
1535 * submission: Approve issues a code and 302s to the client's redirect_uri;
1536 * Deny 302s back with error=access_denied. Always emits its own response
1537 * (HTML page or redirect) and exits.
1538 */
1539 public function handle_authorize_page(): void {
1540 $is_post = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) );
1541 // Params come from GET on the consent link and POST on the form submit.
1542 // Nonce is verified below before any POST value is acted on.
1543 // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing
1544 $source = $is_post ? $_POST : $_GET;
1545 // phpcs:enable
1546 $params = array();
1547 foreach ( array( 'client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'approve', 'deny', '_xspeed_oauth_nonce' ) as $k ) {
1548 $params[ $k ] = isset( $source[ $k ] ) ? sanitize_text_field( wp_unslash( $source[ $k ] ) ) : '';
1549 }
1550
1551 // Validate the OAuth params before touching the session.
1552 $req = Mcp_OAuth::validate_authorize_request( $params );
1553 if ( is_wp_error( $req ) ) {
1554 $data = $req->get_error_data();
1555 $redirectable = is_array( $data ) && ! empty( $data['redirectable'] );
1556 // Only redirect the error back when redirect_uri is verified valid;
1557 // otherwise show a page (never bounce to an unverified URL).
1558 if ( $redirectable && '' !== $params['redirect_uri'] ) {
1559 $this->redirect_error( $params['redirect_uri'], $req->get_error_code(), $req->get_error_message(), $params['state'] );
1560 }
1561 $this->emit_oauth_error_page( $req->get_error_message() );
1562 }
1563
1564 // Require a logged-in admin. Anonymous → wp-login, back to this URL.
1565 if ( ! is_user_logged_in() ) {
1566 $this->redirect_to_login();
1567 }
1568 if ( ! current_user_can( 'manage_options' ) ) {
1569 $this->emit_oauth_error_page(
1570 __( 'You must be an administrator to authorize an AI agent to control this site.', 'xspeed' )
1571 );
1572 }
1573
1574 // POST = consent form submitted.
1575 if ( $is_post ) {
1576 if ( ! wp_verify_nonce( $params['_xspeed_oauth_nonce'], 'xspeed_oauth_consent' ) ) {
1577 $this->emit_oauth_error_page( __( 'Security check failed. Please try connecting again.', 'xspeed' ) );
1578 }
1579 if ( '' === $params['approve'] ) {
1580 $this->redirect_error( $req['redirect_uri'], 'access_denied', 'The user denied the request.', $req['state'] );
1581 }
1582 $code = Mcp_OAuth::issue_code( $req, get_current_user_id() );
1583 $this->redirect_success( $req['redirect_uri'], $code, $req['state'] );
1584 }
1585
1586 // GET = render the consent screen.
1587 $this->emit_consent_screen( $req );
1588 }
1589
1590 /**
1591 * POST /mcp/oauth/token — exchange a code (or refresh token) for tokens.
1592 *
1593 * @param \WP_REST_Request $request Form-encoded or JSON token request.
1594 * @return \WP_REST_Response
1595 */
1596 public function rest_oauth_token( \WP_REST_Request $request ) {
1597 // Token requests are application/x-www-form-urlencoded per OAuth, but
1598 // accept JSON too. get_body_params() covers the form case.
1599 $body = $request->get_body_params();
1600 if ( empty( $body ) ) {
1601 $json = $request->get_json_params();
1602 $body = is_array( $json ) ? $json : array();
1603 }
1604 $body = array_map( 'strval', $body );
1605
1606 $result = Mcp_OAuth::exchange_token( $body );
1607 if ( is_wp_error( $result ) ) {
1608 $data = $result->get_error_data();
1609 $response = new \WP_REST_Response(
1610 array(
1611 'error' => isset( $data['error'] ) ? $data['error'] : 'invalid_request',
1612 'error_description' => isset( $data['error_description'] ) ? $data['error_description'] : $result->get_error_message(),
1613 ),
1614 isset( $data['status'] ) ? (int) $data['status'] : 400
1615 );
1616 $response->header( 'Cache-Control', 'no-store' );
1617 return $response;
1618 }
1619 $response = new \WP_REST_Response( $result, 200 );
1620 $response->header( 'Cache-Control', 'no-store' );
1621 $response->header( 'Pragma', 'no-cache' );
1622 return $response;
1623 }
1624
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 /**
1679 * Token-authenticated tool route for the hosted broker. Maps a broker
1680 * tool call (e.g. GET /mcp/tool/get_cache_status) onto the shared
1681 * Mcp_Tools catalog, so the broker path and the JSON-RPC path never
1682 * drift. GET params + JSON body both feed the tool's arguments.
1683 */
1684 public function rest_tool( \WP_REST_Request $request ) {
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 }
1696 $args = $request->get_json_params();
1697 if ( ! is_array( $args ) ) {
1698 $args = array();
1699 }
1700 // Merge query params (e.g. ?module=minify) so GET tools work too.
1701 foreach ( $request->get_query_params() as $k => $v ) {
1702 if ( 'tool' !== $k && ! array_key_exists( $k, $args ) ) {
1703 $args[ $k ] = $v;
1704 }
1705 }
1706
1707 Mcp_Tools::set_channel( 'broker' );
1708 $result = Mcp_Tools::invoke( $tool, $args );
1709 if ( is_wp_error( $result ) ) {
1710 return $result;
1711 }
1712 return rest_ensure_response( $result );
1713 }
1714
1715 // -- Helpers --
1716
1717 // -- OAuth browser-response helpers ------------------------------------
1718
1719 /** The absolute URL of the current authorize request (for login return). */
1720 private function current_authorize_url(): string {
1721 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- reconstructing the current URL for a login round-trip; escaped at use.
1722 $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
1723 return home_url( $uri );
1724 }
1725
1726 /** Send an anonymous visitor to wp-login, returning to this authorize URL. */
1727 private function redirect_to_login(): void {
1728 wp_safe_redirect( wp_login_url( $this->current_authorize_url() ) );
1729 exit;
1730 }
1731
1732 /** 302 back to the client with the authorization code (+ state). */
1733 private function redirect_success( string $redirect_uri, string $code, string $state ): void {
1734 $args = array( 'code' => $code );
1735 if ( '' !== $state ) {
1736 $args['state'] = $state;
1737 }
1738 // Not wp_safe_redirect: redirect_uri is a client-registered off-site
1739 // callback, already validated against the client's registered set.
1740 wp_redirect( add_query_arg( $args, $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
1741 exit;
1742 }
1743
1744 /** 302 back to the client with an OAuth error (+ state). */
1745 private function redirect_error( string $redirect_uri, string $error, string $description, string $state ): void {
1746 $args = array(
1747 'error' => $error,
1748 'error_description' => $description,
1749 );
1750 if ( '' !== $state ) {
1751 $args['state'] = $state;
1752 }
1753 wp_redirect( add_query_arg( array_map( 'rawurlencode', $args ), $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
1754 exit;
1755 }
1756
1757 /**
1758 * Render the consent screen. Minimal self-contained HTML (no admin
1759 * chrome — this is a client-facing OAuth page). Approve/Deny post back
1760 * to the same authorize URL with a nonce.
1761 *
1762 * @param array<string,string> $req Validated authorize params.
1763 */
1764 private function emit_consent_screen( array $req ): void {
1765 $read_only = Mcp_OAuth::scope_is_read_only( $req['scope'] );
1766 $access = $read_only
1767 ? __( 'Read-only — inspect cache status and settings.', 'xspeed' )
1768 : __( 'Read & write — purge caches, toggle caching, and change settings.', 'xspeed' );
1769 $client = '' !== $req['client_name'] ? $req['client_name'] : __( 'An AI agent', 'xspeed' );
1770 $action_url = Mcp_OAuth::authorize_url();
1771 $nonce = wp_create_nonce( 'xspeed_oauth_consent' );
1772 $user = wp_get_current_user();
1773
1774 // Preserve every OAuth param so the POST re-validates identically.
1775 $hidden = '';
1776 foreach ( array( 'client_id', 'redirect_uri', 'code_challenge', 'scope', 'state' ) as $k ) {
1777 $val = 'scope' === $k ? $req['scope'] : ( $req[ $k ] ?? '' );
1778 $hidden .= sprintf( '<input type="hidden" name="%s" value="%s" />', esc_attr( $k ), esc_attr( (string) $val ) );
1779 }
1780 // code_challenge_method + response_type are re-asserted for validation.
1781 $hidden .= '<input type="hidden" name="code_challenge_method" value="S256" />';
1782 $hidden .= '<input type="hidden" name="response_type" value="code" />';
1783
1784 status_header( 200 );
1785 header( 'Content-Type: text/html; charset=utf-8' );
1786 header( 'Cache-Control: no-store' );
1787
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>';
1789 echo '<style>' . self::CONSENT_CSS . '</style></head><body><div class="card">'; // phpcs:ignore WordPress.Security.EscapeOutput -- static stylesheet constant.
1790 echo '<h1>' . esc_html__( 'Connect to xSpeed', 'xspeed' ) . '</h1>';
1791 /* translators: %s: AI client name. */
1792 echo '<p class="sub">' . esc_html( sprintf( __( '%s wants to manage the cache on this site.', 'xspeed' ), $client ) ) . '</p>';
1793 echo '<div class="row"><span>' . esc_html__( 'Site', 'xspeed' ) . '</span><span>' . esc_html( wp_parse_url( home_url(), PHP_URL_HOST ) ) . '</span></div>';
1794 echo '<div class="row"><span>' . esc_html__( 'Signed in as', 'xspeed' ) . '</span><span>' . esc_html( $user->user_login ) . '</span></div>';
1795 echo '<div class="row"><span>' . esc_html__( 'Access', 'xspeed' ) . '</span><span>' . esc_html( $access ) . '</span></div>';
1796 echo '<form method="post" action="' . esc_url( $action_url ) . '">';
1797 echo $hidden; // phpcs:ignore WordPress.Security.EscapeOutput -- built from esc_attr() above.
1798 echo '<input type="hidden" name="_xspeed_oauth_nonce" value="' . esc_attr( $nonce ) . '" />';
1799 echo '<div class="actions">';
1800 echo '<button class="deny" name="deny" value="1">' . esc_html__( 'Deny', 'xspeed' ) . '</button>';
1801 echo '<button class="approve" name="approve" value="1">' . esc_html__( 'Approve', 'xspeed' ) . '</button>';
1802 echo '</div></form></div></body></html>';
1803 exit;
1804 }
1805
1806 /** Render a standalone OAuth error page (no redirect). */
1807 private function emit_oauth_error_page( string $message ): void {
1808 status_header( 400 );
1809 header( 'Content-Type: text/html; charset=utf-8' );
1810 header( 'Cache-Control: no-store' );
1811 echo '<!doctype html><html><head><meta charset="utf-8"><title>' . esc_html__( 'Authorization error', 'xspeed' ) . '</title>';
1812 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}'
1813 . '.card{background:#1e293b;border:1px solid #334155;border-radius:16px;max-width:440px;padding:32px;text-align:center}</style></head><body>';
1814 echo '<div class="card"><h1>' . esc_html__( 'Could not authorize', 'xspeed' ) . '</h1><p>' . esc_html( $message ) . '</p></div></body></html>';
1815 exit;
1816 }
1817
1818 /** Read an inbound HTTP header from $_SERVER (for the pretty path). */
1819 private static function server_header( string $name ): ?string {
1820 $key = 'HTTP_' . strtoupper( str_replace( '-', '_', $name ) );
1821 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- token compared constant-time downstream; raw header needed verbatim.
1822 return isset( $_SERVER[ $key ] ) ? wp_unslash( $_SERVER[ $key ] ) : null;
1823 }
1824
1825 /** Emit a WP_REST_Response as a JSON HTTP response and stop. */
1826 private function emit_json( \WP_REST_Response $response ): void {
1827 status_header( $response->get_status() );
1828 // MCP Streamable HTTP: advertise the protocol version we speak so a
1829 // strict client can pin it. We answer JSON (a spec-permitted response
1830 // type); we never open an SSE stream, so no session header is needed.
1831 header( 'MCP-Protocol-Version: ' . Mcp_Server::PROTOCOL_VERSION );
1832 // Forward any headers the handler set (notably WWW-Authenticate on a
1833 // 401, which drives the OAuth discovery flow). rest_do_request applies
1834 // these automatically; the pretty-endpoint path must do it by hand.
1835 foreach ( $response->get_headers() as $name => $value ) {
1836 header( $name . ': ' . $value );
1837 }
1838 $data = $response->get_data();
1839 if ( null !== $data ) {
1840 header( 'Content-Type: application/json; charset=utf-8' );
1841 echo wp_json_encode( $data );
1842 }
1843 exit;
1844 }
1845
1846 // -- WP-CLI mirror --
1847
1848 public function cli_commands(): array {
1849 return array(
1850 array(
1851 'name' => 'xspeed mcp status',
1852 'callback' => array( $this, 'cli_status' ),
1853 'shortdesc' => 'Show MCP connection status and the paste-in endpoint URL.',
1854 'synopsis' => array(),
1855 ),
1856 array(
1857 'name' => 'xspeed mcp activity',
1858 'callback' => array( $this, 'cli_activity' ),
1859 'shortdesc' => 'List recent MCP tool calls (the AI audit trail).',
1860 'synopsis' => array(
1861 array(
1862 'name' => 'limit',
1863 'type' => 'assoc',
1864 'optional' => true,
1865 'description' => 'Maximum entries to show (default 20).',
1866 ),
1867 array(
1868 'name' => 'clear',
1869 'type' => 'flag',
1870 'optional' => true,
1871 'description' => 'Wipe the audit trail instead of listing it.',
1872 ),
1873 ),
1874 ),
1875 array(
1876 'name' => 'xspeed mcp connect',
1877 'callback' => array( $this, 'cli_connect' ),
1878 'shortdesc' => 'Generate a connection token for this site\'s MCP endpoint.',
1879 'synopsis' => array(
1880 array(
1881 'name' => 'read-only',
1882 'type' => 'flag',
1883 'optional' => true,
1884 'description' => 'Grant read-only access (no purge/toggle/settings changes).',
1885 ),
1886 ),
1887 ),
1888 array(
1889 'name' => 'xspeed mcp rotate',
1890 'callback' => array( $this, 'cli_rotate' ),
1891 'shortdesc' => 'Mint a fresh MCP token, immediately invalidating the previous one.',
1892 'synopsis' => array(
1893 array(
1894 'name' => 'read-only',
1895 'type' => 'flag',
1896 'optional' => true,
1897 'description' => 'Make the new token read-only.',
1898 ),
1899 ),
1900 ),
1901 array(
1902 'name' => 'xspeed mcp disconnect',
1903 'callback' => array( $this, 'cli_disconnect' ),
1904 'shortdesc' => 'Revoke this site\'s MCP connection token.',
1905 'synopsis' => array(),
1906 ),
1907 );
1908 }
1909
1910 /**
1911 * `wp xspeed mcp status` — print connection status + endpoint URL.
1912 *
1913 * @param array $args Positional args (unused).
1914 * @param array $assoc Associative args (unused).
1915 */
1916 public function cli_status( array $args, array $assoc ): void {
1917 unset( $args, $assoc );
1918 $s = Mcp_Pairing::public_status();
1919 \WP_CLI::log( sprintf( '%-18s %s', 'connected', $s['connected'] ? 'yes' : 'no' ) );
1920 if ( $s['connected'] ) {
1921 \WP_CLI::log( sprintf( '%-18s %s', 'access', $s['read_only'] ? 'read-only' : 'read-write' ) );
1922 \WP_CLI::log( sprintf( '%-18s %s', 'connect_url', $s['connect_url'] ) );
1923 \WP_CLI::log( sprintf( '%-18s %s', 'scopes', implode( ',', $s['scopes'] ) ) );
1924 } else {
1925 \WP_CLI::log( sprintf( '%-18s %s', 'mcp_endpoint', Mcp_Pairing::site_endpoint() ) );
1926 }
1927 }
1928
1929 /**
1930 * `wp xspeed mcp activity` — read (or clear) the AI audit trail.
1931 *
1932 * @param array $args Positional args (unused).
1933 * @param array $assoc --limit=<n>, --clear.
1934 */
1935 public function cli_activity( array $args, array $assoc ): void {
1936 unset( $args );
1937
1938 if ( ! empty( $assoc['clear'] ) ) {
1939 if ( ! Mcp_Activity_Log::clear() ) {
1940 // Reached via MCP run_command — the assistant is asking to
1941 // erase the record of its own calls. Mcp_Activity_Log::clear()
1942 // declines and logs the attempt; say so plainly.
1943 \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.' );
1944 return;
1945 }
1946 \WP_CLI::success( 'MCP activity log cleared.' );
1947 return;
1948 }
1949
1950 $limit = isset( $assoc['limit'] ) ? (int) $assoc['limit'] : 20;
1951 $summary = Mcp_Activity_Log::summary();
1952 $entries = Mcp_Activity_Log::entries( $limit > 0 ? $limit : 20 );
1953
1954 \WP_CLI::log( sprintf( '%-18s %d', 'total_calls', $summary['total'] ) );
1955 \WP_CLI::log( sprintf( '%-18s %d', 'failed', $summary['failed'] ) );
1956 \WP_CLI::log( sprintf( '%-18s %s', 'top_tool', '' === $summary['top_tool'] ? '-' : $summary['top_tool'] ) );
1957
1958 if ( empty( $entries ) ) {
1959 \WP_CLI::log( '' );
1960 \WP_CLI::log( 'No MCP tool calls recorded yet.' );
1961 return;
1962 }
1963
1964 \WP_CLI::log( '' );
1965 foreach ( $entries as $entry ) {
1966 \WP_CLI::log(
1967 sprintf(
1968 '%s %-22s %-5s %-6s %s%s',
1969 gmdate( 'Y-m-d H:i:s', $entry['ts'] ),
1970 $entry['tool'],
1971 $entry['scope'],
1972 $entry['ok'] ? 'ok' : 'FAIL',
1973 $entry['args'],
1974 '' === $entry['error'] ? '' : ' — ' . $entry['error']
1975 )
1976 );
1977 }
1978 }
1979
1980 /**
1981 * `wp xspeed mcp connect` — mint a token and print the paste-in URL.
1982 *
1983 * @param array $args Positional args (unused).
1984 * @param array $assoc Associative args (unused).
1985 */
1986 public function cli_connect( array $args, array $assoc ): void {
1987 unset( $args );
1988 $read_only = ! empty( $assoc['read-only'] );
1989 $result = Mcp_Pairing::connect( $read_only );
1990 if ( is_wp_error( $result ) ) {
1991 \WP_CLI::error( $result->get_error_message() );
1992 return;
1993 }
1994 \WP_CLI::success( 'Connected' . ( Mcp_Pairing::is_read_only() ? ' (read-only).' : '.' ) . ' Paste this single URL into your AI client:' );
1995 \WP_CLI::log( ' ' . Mcp_Pairing::connect_url() );
1996 \WP_CLI::log( '' );
1997 \WP_CLI::log( 'Or, header-based (token stays out of the URL):' );
1998 \WP_CLI::log( ' ' . Mcp_Pairing::config_snippets()['cli'] );
1999 }
2000
2001 /**
2002 * `wp xspeed mcp rotate` — mint a new token, revoking the old one.
2003 *
2004 * @param array $args Positional args (unused).
2005 * @param array $assoc Associative args ({ read-only?:flag }).
2006 */
2007 public function cli_rotate( array $args, array $assoc ): void {
2008 unset( $args );
2009 $read_only = array_key_exists( 'read-only', $assoc ) ? ! empty( $assoc['read-only'] ) : null;
2010 Mcp_Pairing::rotate( $read_only );
2011 \WP_CLI::success( 'Rotated. The previous token is now invalid. New paste-in URL:' );
2012 \WP_CLI::log( ' ' . Mcp_Pairing::connect_url() );
2013 }
2014
2015 /**
2016 * `wp xspeed mcp disconnect` — revoke the connection token.
2017 *
2018 * @param array $args Positional args (unused).
2019 * @param array $assoc Associative args (unused).
2020 */
2021 public function cli_disconnect( array $args, array $assoc ): void {
2022 unset( $args, $assoc );
2023 Mcp_Pairing::disconnect();
2024 \WP_CLI::success( 'Disconnected and revoked the MCP token.' );
2025 }
2026
2027 /**
2028 * MCP is on when a connection token exists -- it has no `enabled`
2029 * setting, so the sidebar counted the AI group as empty on a site with
2030 * a live read-write AI connection. Reads the same
2031 * `Mcp_Pairing::public_status()` the CLI and the panel do, so the count
2032 * cannot disagree with the badge on the panel. (#363)
2033 */
2034 public function is_active(): ?bool {
2035 $status = Mcp_Pairing::public_status();
2036 return ! empty( $status['connected'] );
2037 }
2038
2039 /**
2040 * MCP has no on/off setting -- it is on when a connection exists.
2041 */
2042 public function active_reason(): ?string {
2043 return $this->is_active()
2044 ? __( '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' )
2045 : __( 'No AI assistant is connected. This module counts as on once you connect one.', 'xspeed' );
2046 }
2047 }
2048