PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.1
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / modules / Mcp / McpModule.php

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

1,517 lines 57.8 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\Module;
41 use XSpeed\Onboarding;
42
43 defined( 'ABSPATH' ) || exit;
44
45 final class McpModule extends Module {
46
47 public const SLUG = 'mcp';
48 public const TIER = self::TIER_FREE;
49 public const VERSION = '1.0.0';
50
51 /**
52 * Every rewrite rule this module registers, in registration order.
53 *
54 * Single source of truth: add_rewrite() registers these, and the self-heal
55 * guard re-flushes when any is missing from the stored table. They were two
56 * hand-maintained lists before, which is a silent drift risk — a rule
57 * dropped from one and not the other leaves the guard restoring a rule
58 * nothing registers, or never firing for one that is registered.
59 *
60 * @var string[] Rewrite regexes. The query each maps to is built in
61 * add_rewrite(), which also fixes their order.
62 */
63 public const REWRITE_RULES = array(
64 '^xspeed/mcp/([a-f0-9]{64})/?$',
65 '^xspeed/mcp/?$',
66 '^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.
75 '^\.well-known/oauth-(protected-resource|authorization-server)/xspeed/mcp/?$',
76 '^xspeed/authorize/?$',
77 );
78
79 /** REST namespace shared with Free. Public: Mcp_Server builds the
80 * discovery fallback URL from it. */
81 public const NS = 'xspeed/v1';
82
83 /** Query var flagging a pretty /xspeed/mcp request. */
84 private const QUERY_VAR = 'xspeed_mcp';
85
86 /** Query var carrying the token when embedded in the URL path. */
87 private const TOKEN_QUERY_VAR = 'xspeed_mcp_token';
88
89 /** Query var flagging a /.well-known/ OAuth discovery request. */
90 private const WELLKNOWN_QUERY_VAR = 'xspeed_mcp_wellknown';
91
92 /**
93 * Query var flagging the browser-facing OAuth authorize page. This is
94 * served OUTSIDE the REST API on purpose: a REST route only honors cookie
95 * auth when a REST nonce accompanies it, but a browser arriving from
96 * wp-login carries the cookie with NO nonce — so is_user_logged_in() would
97 * be false there and the consent screen would loop back to login forever.
98 * A normal front-end URL (rewrite + parse_request) sees standard cookie
99 * auth, so the logged-in admin check works.
100 */
101 private const AUTHORIZE_QUERY_VAR = 'xspeed_mcp_authorize';
102
103 /** Front-end path of the browser-facing authorize page. */
104 private const AUTHORIZE_PATH = 'xspeed/authorize';
105
106 /** Query var flagging the pretty /xspeed/mcp/attach callback. */
107 private const ATTACH_QUERY_VAR = 'xspeed_mcp_attach';
108
109 public function ui_metadata(): array {
110 return array(
111 'label' => __( 'MCP Server', 'xspeed' ),
112 'icon' => 'Sparkles',
113 'description' => __( 'Control this site\'s cache from Claude and other AI agents.', 'xspeed' ),
114 'custom_panel' => 'McpPanel',
115 );
116 }
117
118 /**
119 * MCP pairing state lives in xspeed_module_mcp but is managed by
120 * Mcp_Pairing, not the schema engine. Empty schema so the base class
121 * doesn't auto-register generic settings routes.
122 */
123 public function settings_schema(): array {
124 return array();
125 }
126
127 /**
128 * All MCP routes register directly (see class docblock). Returning an
129 * empty array keeps Rest_Manager out of the token-auth path entirely.
130 */
131 public function rest_routes(): array {
132 return array();
133 }
134
135 public function boot(): void {
136 add_action( 'rest_api_init', array( $this, 'register_rest' ) );
137
138 // Pretty per-site endpoint: /xspeed/mcp → MCP JSON-RPC handler.
139 add_action( 'init', array( $this, 'add_rewrite' ) );
140 add_filter( 'query_vars', array( $this, 'register_query_var' ) );
141 // Priority 1: a sibling MCP plugin that also claims /.well-known/ gets
142 // to answer first at the default priority 10, and whoever answers
143 // first calls exit(). Running early means the URL is decided by WHOSE
144 // path it is, not by which plugin happened to load last.
145 add_action( 'parse_request', array( $this, 'maybe_handle_pretty_endpoint' ), 1 );
146
147 // Hub redirect-return: after the user approves on the Hub, it sends the
148 // browser back to a plugin admin URL carrying ?xspeed_connected=1 plus
149 // the account email + the SAME signed nonce we minted. We verify our own
150 // nonce and mark this admin attached — no server-to-server callback
151 // needed, so it works for local/firewalled sites too.
152 add_action( 'admin_init', array( $this, 'maybe_handle_hub_return' ) );
153
154 // An attached admin who is DELETED (or removed from the blog) never
155 // runs disconnect(), so the site-level attached mirror would report
156 // hub:true forever. deleted_user fires after both wp_delete_user()
157 // and wpmu_delete_user() drop the user, so a plain recompute is
158 // honest there. remove_user_from_blog is core's only removal action
159 // and fires BEFORE removal, so its handler clears the departing
160 // user's record before recomputing (see Mcp_Hub::handle_user_removed).
161 add_action( 'deleted_user', array( Mcp_Hub::class, 'refresh_site_attached' ) );
162 add_action( 'remove_user_from_blog', array( Mcp_Hub::class, 'handle_user_removed' ) );
163 }
164
165 /**
166 * Handle the browser landing back from the Hub after a connect. Idempotent
167 * and safe to run on every admin page load: it only acts when the return
168 * markers are present and the nonce verifies.
169 */
170 public function maybe_handle_hub_return(): void {
171 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- auth is the signed HMAC nonce below, not a WP nonce; this is a read-only routing check.
172 $nonce = isset( $_GET['xspeed_hub_nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['xspeed_hub_nonce'] ) ) : '';
173 $email = isset( $_GET['xspeed_hub_email'] ) ? sanitize_email( wp_unslash( $_GET['xspeed_hub_email'] ) ) : '';
174
175 /*
176 * Trigger on the signed nonce, not on `xspeed_connected`.
177 *
178 * The Hub bounces the browser back with xspeed_hub_nonce +
179 * xspeed_hub_email, but it does NOT always append xspeed_connected —
180 * that marker only survives when the return_url we handed it carried
181 * one. Gating on it meant a real, correctly-signed return was ignored:
182 * the attach was never recorded, the params were never stripped, and
183 * the card kept showing "Not connected" while the nonce sat in the
184 * address bar. The nonce is the actual proof of a genuine round trip,
185 * so it is what this handler keys on. (FBS-84086)
186 */
187 if ( '' === $nonce && empty( $_GET['xspeed_connected'] ) ) {
188 return;
189 }
190 // phpcs:enable WordPress.Security.NonceVerification.Recommended
191
192 if ( ! current_user_can( 'manage_options' ) ) {
193 return;
194 }
195
196 // Verify OUR own signed nonce (proves the round-trip went through the
197 // Hub with a token we minted), then record the connection.
198 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();
202 Mcp_Hub::mark_attached( $email, $uid ?: null );
203 }
204 }
205
206 // ALWAYS strip the one-time return markers from the URL and redirect to
207 // the clean address. These params are single-use; if they persist in the
208 // browser URL, a later reload re-triggers the "just connected" path and
209 // flashes a stale connected state even after the user has disconnected.
210 $clean = remove_query_arg( array( 'xspeed_connected', 'xspeed_hub_nonce', 'xspeed_hub_email' ) );
211
212 // The setup wizard keeps its current step in component state, so a
213 // redirect remounts it at step 1 — dumping the user back at the START of
214 // onboarding immediately after they finished its LAST step. Carry a
215 // durable hint so the wizard resumes on Connect instead. It's a plain
216 // step marker, not an auth signal (the nonce above did that job), and
217 // it's safe to leave in the URL: re-loading it just re-opens the same
218 // step rather than re-running the connect path. (PM feedback)
219 if ( false !== strpos( (string) $clean, 'page=' . Onboarding::PAGE_SLUG ) ) {
220 $clean = add_query_arg( 'xspeed_step', 'connect', $clean );
221 }
222
223 wp_safe_redirect( $clean );
224 exit;
225 }
226
227 /**
228 * Flush rewrites once when the module first boots so /xspeed/mcp works
229 * without a manual permalink re-save. Cheap: gated on a one-shot flag.
230 */
231 public function activate(): void {
232 $this->add_rewrite();
233 flush_rewrite_rules( false );
234 }
235
236 public function deactivate(): void {
237 flush_rewrite_rules( false );
238 }
239
240 // -- Pretty endpoint: /xspeed/mcp --
241
242 public function add_rewrite(): void {
243 // Token-in-URL form: /xspeed/mcp/<token> — a single string the user
244 // pastes into their AI client (no separate token field). The bare
245 // /xspeed/mcp still works with a Bearer/header token.
246 add_rewrite_rule(
247 '^xspeed/mcp/([a-f0-9]{64})/?$',
248 'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]',
249 'top'
250 );
251 add_rewrite_rule( '^xspeed/mcp/?$', 'index.php?' . self::QUERY_VAR . '=1', 'top' );
252
253 // Pretty attach-callback endpoint: /xspeed/mcp/attach — the hub POSTs
254 // the signed nonce here to verify + fetch the token. Uses the plugin's
255 // own rewrite (consistent with the MCP URL, survives hosts that block
256 // /wp-json). Placed BEFORE the token rule would never match "attach"
257 // (that rule requires 64 hex chars), so ordering is safe.
258 add_rewrite_rule( '^xspeed/mcp/attach/?$', 'index.php?' . self::ATTACH_QUERY_VAR . '=1', 'top' );
259
260 // 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.
267 //
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.
275 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 '^\\.well-known/oauth-(protected-resource|authorization-server)/xspeed/mcp/?$',
282 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
283 'top'
284 );
285
286 // Browser-facing OAuth consent page — served OUTSIDE REST so cookie
287 // auth (is_user_logged_in) works after the wp-login round-trip.
288 add_rewrite_rule( '^xspeed/authorize/?$', 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', 'top' );
289
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;
302 }
303 }
304 }
305 }
306
307 /**
308 * True when a request for our discovery URL can reach WordPress at all.
309 *
310 * Since maybe_handle_pretty_endpoint() claims the document by REQUEST
311 * PATH, a sibling plugin winning the rewrite match no longer matters —
312 * we answer either way. What still breaks the pretty URL is there being
313 * no rewrite for it in the first place (plain permalinks), because then
314 * nothing routes the path to index.php and parse_request never runs.
315 *
316 * Blind to upstream interception: a host that owns the /.well-known/
317 * prefix (an nginx ACME block, an edge redirect rule) answers before
318 * WordPress loads, and WP cannot see that. Use the
319 * `xspeed_mcp_resource_metadata_url` filter on such hosts.
320 */
321 public static function wellknown_rewrites_active(): bool {
322 $rules = get_option( 'rewrite_rules' );
323 if ( ! is_array( $rules ) || array() === $rules ) {
324 return false;
325 }
326
327 // Any rule that routes our discovery path to index.php will do — ours
328 // or a sibling's — because the path check inside the handler decides
329 // the outcome once the request lands.
330 $probe = '.well-known/oauth-protected-resource';
331 foreach ( $rules as $pattern => $target ) {
332 if ( preg_match( '#' . str_replace( '#', '\\#', $pattern ) . '#', $probe ) ) {
333 return true;
334 }
335 }
336
337 return false;
338 }
339
340 /**
341 * @param string[] $vars Registered query vars.
342 * @return string[]
343 */
344 public function register_query_var( array $vars ): array {
345 $vars[] = self::QUERY_VAR;
346 $vars[] = self::TOKEN_QUERY_VAR;
347 $vars[] = self::WELLKNOWN_QUERY_VAR;
348 $vars[] = self::AUTHORIZE_QUERY_VAR;
349 $vars[] = self::ATTACH_QUERY_VAR;
350 return $vars;
351 }
352
353 /**
354 * Which discovery document the CURRENT request path asks for, if any.
355 *
356 * 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
360 * `/.well-known/oauth-protected-resource/betterlinks/mcp` with xSpeed
361 * metadata is the same bug that broke this site, just pointed the other
362 * way.
363 *
364 * @return string 'protected-resource', 'authorization-server', or ''.
365 */
366 private function wellknown_doc_from_path(): string {
367 $uri = isset( $_SERVER['REQUEST_URI'] )
368 ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) )
369 : '';
370 if ( '' === $uri ) {
371 return '';
372 }
373
374 $path = (string) wp_parse_url( $uri, PHP_URL_PATH );
375
376 // Sites in a subdirectory carry that prefix on every request.
377 $home = (string) wp_parse_url( home_url(), PHP_URL_PATH );
378 if ( '' !== $home && '/' !== $home && 0 === strpos( $path, $home ) ) {
379 $path = substr( $path, strlen( $home ) );
380 }
381
382 $path = trim( $path, '/' );
383
384 $pattern = '#^\.well-known/oauth-(protected-resource|authorization-server)'
385 . '(?:/xspeed/mcp)?$#';
386
387 return preg_match( $pattern, $path, $m ) ? $m[1] : '';
388 }
389
390 /**
391 * Serve the MCP endpoint on the pretty path. Runs on parse_request so
392 * it fires before the main query, and short-circuits WP entirely.
393 *
394 * @param \WP $wp The WP request object.
395 */
396 public function maybe_handle_pretty_endpoint( $wp ): void {
397 // OAuth discovery documents (served at the site root).
398 //
399 // Read the doc name from the REQUEST PATH, not just our query var.
400 // `add_rewrite_rule( …, 'top' )` only means "top at the moment it
401 // runs", so whichever MCP plugin hooks `init` last ends up first in
402 // the table — an order set by plugin load order, which no plugin
403 // controls. A sibling's catch-all
404 // (`…(protected-resource|authorization-server)(?:/.*)?/?$`) then wins
405 // the match and our query var is never set, even though the URL is
406 // unambiguously ours. Observed live with two different plugins on one
407 // site. parse_request runs AFTER matching, so the path is the one
408 // 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 }
413 if ( '' !== $doc ) {
414 $data = 'authorization-server' === $doc
415 ? Mcp_OAuth::authorization_server_metadata()
416 : Mcp_OAuth::protected_resource_metadata();
417 status_header( 200 );
418 header( 'Content-Type: application/json; charset=utf-8' );
419 // Discovery metadata is public + cacheable.
420 header( 'Cache-Control: public, max-age=3600' );
421 echo wp_json_encode( $data );
422 exit;
423 }
424
425 // Pretty attach-callback: /xspeed/mcp/attach. The hub POSTs the signed
426 // nonce; we verify it and return this site's URL + token. Auth is the
427 // nonce itself (admin-minted, HMAC-signed), so no credential needed.
428 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 );
432 header( 'Content-Type: application/json; charset=utf-8' );
433 header( 'Cache-Control: no-store' );
434 if ( null === $result ) {
435 status_header( 403 );
436 echo wp_json_encode( array( 'error' => 'invalid_or_expired_attach_request' ) );
437 } else {
438 status_header( 200 );
439 echo wp_json_encode( $result );
440 }
441 exit;
442 }
443
444 // Browser-facing OAuth consent page (cookie auth applies here).
445 if ( ! empty( $wp->query_vars[ self::AUTHORIZE_QUERY_VAR ] ) ) {
446 $this->handle_authorize_page();
447 return;
448 }
449
450 if ( empty( $wp->query_vars[ self::QUERY_VAR ] ) ) {
451 return;
452 }
453
454 $request = new \WP_REST_Request( 'POST', '/xspeed/v1/mcp' );
455 $request->set_header( 'content-type', 'application/json' );
456 // Carry the auth headers + raw body from the live PHP request.
457 foreach ( array( 'authorization', Mcp_Auth::TOKEN_HEADER ) as $h ) {
458 $val = self::server_header( $h );
459 if ( null !== $val ) {
460 $request->set_header( $h, $val );
461 }
462 }
463 // Token embedded in the URL path (/xspeed/mcp/<token>) — surface it
464 // as the standard token header so Mcp_Server validates it the same
465 // way. A header/Bearer token (if also sent) still takes precedence.
466 $path_token = isset( $wp->query_vars[ self::TOKEN_QUERY_VAR ] )
467 ? (string) $wp->query_vars[ self::TOKEN_QUERY_VAR ]
468 : '';
469 if ( '' !== $path_token && '' === (string) $request->get_header( Mcp_Auth::TOKEN_HEADER ) && '' === (string) $request->get_header( 'authorization' ) ) {
470 $request->set_header( Mcp_Auth::TOKEN_HEADER, $path_token );
471 }
472 $request->set_body( file_get_contents( 'php://input' ) );
473
474 $response = Mcp_Server::handle( $request );
475 $this->emit_json( $response );
476 }
477
478 // -- REST registration --
479
480 public function register_rest(): void {
481 // --- MCP JSON-RPC endpoint (fallback path via wp-json) -----------
482 // permission_callback is __return_true because Mcp_Server does its
483 // own token auth and must reply with a JSON-RPC 401, not a bare WP
484 // permission failure.
485 register_rest_route(
486 self::NS,
487 '/mcp',
488 array(
489 'methods' => 'POST',
490 'callback' => array( $this, 'rest_mcp' ),
491 'permission_callback' => '__return_true',
492 )
493 );
494
495 // --- Public scan signals -----------------------------------------
496 // One tiny unauthenticated JSON body for external audit tools (the
497 // speed scanner on xspeedcache.com): plugin version, whether the MCP
498 // server is connected, and whether the site is attached to xSpeed
499 // Hub. Everything except `hub` is already publicly discoverable —
500 // the cache signature carries the version and /mcp answers 401 when
501 // connected — and `hub` is a bare boolean. No tokens, accounts or
502 // emails leave through this route.
503 register_rest_route(
504 self::NS,
505 '/signals',
506 array(
507 'methods' => 'GET',
508 'callback' => array( $this, 'rest_signals' ),
509 'permission_callback' => '__return_true',
510 )
511 );
512
513 // --- Admin-only management routes (dashboard) --------------------
514 register_rest_route(
515 self::NS,
516 '/mcp/connection',
517 array(
518 'methods' => 'GET',
519 'callback' => array( $this, 'rest_connection' ),
520 'permission_callback' => array( $this, 'admin_permission' ),
521 )
522 );
523 register_rest_route(
524 self::NS,
525 '/mcp/activity',
526 array(
527 'methods' => 'GET',
528 'callback' => array( $this, 'rest_activity' ),
529 'permission_callback' => array( $this, 'admin_permission' ),
530 'args' => array(
531 'limit' => array(
532 'type' => 'integer',
533 'required' => false,
534 'default' => 50,
535 'description' => 'Maximum entries to return (newest first).',
536 ),
537 ),
538 )
539 );
540 register_rest_route(
541 self::NS,
542 '/mcp/activity/clear',
543 array(
544 'methods' => 'POST',
545 'callback' => array( $this, 'rest_activity_clear' ),
546 'permission_callback' => array( $this, 'admin_permission' ),
547 )
548 );
549 register_rest_route(
550 self::NS,
551 '/mcp/connect',
552 array(
553 'methods' => 'POST',
554 'callback' => array( $this, 'rest_connect' ),
555 'permission_callback' => array( $this, 'admin_permission' ),
556 'args' => array(
557 'read_only' => array(
558 'type' => 'boolean',
559 'required' => false,
560 'default' => false,
561 'description' => 'Grant read-only access (no purge/toggle/settings changes).',
562 ),
563 ),
564 )
565 );
566 register_rest_route(
567 self::NS,
568 '/mcp/rotate',
569 array(
570 'methods' => 'POST',
571 'callback' => array( $this, 'rest_rotate' ),
572 'permission_callback' => array( $this, 'admin_permission' ),
573 'args' => array(
574 'read_only' => array(
575 'type' => 'boolean',
576 'required' => false,
577 'description' => 'Optionally set read-only on the new token; omit to keep current scopes.',
578 ),
579 ),
580 )
581 );
582 register_rest_route(
583 self::NS,
584 '/mcp/access',
585 array(
586 'methods' => 'POST',
587 'callback' => array( $this, 'rest_access' ),
588 'permission_callback' => array( $this, 'admin_permission' ),
589 'args' => array(
590 'read_only' => array(
591 'type' => 'boolean',
592 'required' => true,
593 'description' => 'Switch the live connection to read-only (true) or read & write (false), keeping the same token.',
594 ),
595 ),
596 )
597 );
598 register_rest_route(
599 self::NS,
600 '/mcp/disconnect',
601 array(
602 'methods' => 'POST',
603 'callback' => array( $this, 'rest_disconnect' ),
604 'permission_callback' => array( $this, 'admin_permission' ),
605 )
606 );
607
608 // --- xSpeed Hub (multi-site) attach routes ------------------------
609 register_rest_route(
610 self::NS,
611 '/mcp/hub',
612 array(
613 'methods' => 'GET',
614 'callback' => array( $this, 'rest_hub_status' ),
615 'permission_callback' => array( $this, 'admin_permission' ),
616 )
617 );
618 register_rest_route(
619 self::NS,
620 '/mcp/hub/token',
621 array(
622 'methods' => 'POST',
623 'callback' => array( $this, 'rest_hub_token' ),
624 'permission_callback' => array( $this, 'admin_permission' ),
625 )
626 );
627 register_rest_route(
628 self::NS,
629 '/mcp/hub/attached',
630 array(
631 'methods' => 'POST',
632 'callback' => array( $this, 'rest_hub_attached' ),
633 'permission_callback' => array( $this, 'admin_permission' ),
634 'args' => array(
635 'account_email' => array(
636 'type' => 'string',
637 'required' => true,
638 'description' => 'The hub account email this site was attached to.',
639 ),
640 ),
641 )
642 );
643 register_rest_route(
644 self::NS,
645 '/mcp/hub/disconnect',
646 array(
647 'methods' => 'POST',
648 'callback' => array( $this, 'rest_hub_disconnect' ),
649 'permission_callback' => array( $this, 'admin_permission' ),
650 )
651 );
652 // OAuth-attach callback: the hub calls this with the signed nonce the
653 // plugin issued. Auth is the nonce itself (no pre-shared token), so
654 // permission_callback is open — the handler validates the nonce.
655 register_rest_route(
656 self::NS,
657 '/mcp/attach',
658 array(
659 'methods' => 'POST',
660 'callback' => array( $this, 'rest_hub_attach_callback' ),
661 'permission_callback' => '__return_true',
662 'args' => array(
663 'nonce' => array(
664 'type' => 'string',
665 'required' => true,
666 'description' => 'The signed attach nonce the plugin issued.',
667 ),
668 ),
669 )
670 );
671
672 // --- OAuth 2.1 authorization server (the "paste a URL only" path) -
673 // Discovery, dynamic client registration, and the token endpoint are
674 // all public (permission enforced inside): a client must reach them
675 // BEFORE it holds any credential. The authorize endpoint gates on a
676 // logged-in admin inside its handler (anonymous → wp-login redirect).
677 register_rest_route(
678 self::NS,
679 '/mcp/oauth/register',
680 array(
681 'methods' => 'POST',
682 'callback' => array( $this, 'rest_oauth_register' ),
683 'permission_callback' => '__return_true',
684 )
685 );
686 // NOTE: /authorize is deliberately NOT a REST route — it is served as a
687 // normal front-end page at /xspeed/authorize (see handle_authorize_page)
688 // so cookie auth works after the wp-login round-trip.
689 register_rest_route(
690 self::NS,
691 '/mcp/oauth/token',
692 array(
693 'methods' => 'POST',
694 'callback' => array( $this, 'rest_oauth_token' ),
695 'permission_callback' => '__return_true',
696 )
697 );
698
699 // --- OAuth discovery, REST fallback ------------------------------
700 // The canonical documents live at /.well-known/… via rewrite rules.
701 // Many hosts own that prefix for ACME/Let's Encrypt (an nginx
702 // `location ^~ /.well-known` block, or an edge redirect rule), which
703 // swallows the request before WordPress ever runs — the pretty URL
704 // then 404s or redirects to the homepage no matter how the plugin is
705 // configured, and OAuth discovery dead-ends with no way back.
706 // Serving the same two documents under /wp-json puts them on a path
707 // no ACME tooling claims, so discovery still completes there.
708 register_rest_route(
709 self::NS,
710 '/mcp/.well-known/oauth-protected-resource',
711 array(
712 'methods' => 'GET',
713 'callback' => array( $this, 'rest_protected_resource_metadata' ),
714 'permission_callback' => '__return_true',
715 )
716 );
717 register_rest_route(
718 self::NS,
719 '/mcp/.well-known/oauth-authorization-server',
720 array(
721 'methods' => 'GET',
722 'callback' => array( $this, 'rest_authorization_server_metadata' ),
723 'permission_callback' => '__return_true',
724 )
725 );
726
727 // --- MCP-token-only tool routes (optional hosted-broker path) ----
728 $tool_perm = array( Mcp_Auth::class, 'permission' );
729 register_rest_route(
730 self::NS,
731 // [a-z0-9_-]+ — the HYPHEN is the one that matters, not the digit.
732 // Generated tool names carry their module slug verbatim, and 33 of
733 // the 92 in the catalog have a hyphenated slug
734 // (xspeed_cache-404_status, xspeed_migration-pro_apply,
735 // xspeed_smart-predict_status …). Every one of those returned
736 // rest_no_route through the broker path. The earlier widening to
737 // [a-z0-9_]+ un-blocked nothing: the only digit-bearing name is
738 // cache-404, whose problem was the hyphen. (QA on #158) */
739 '/mcp/tool/(?P<tool>[a-z0-9_-]+)',
740 array(
741 array(
742 'methods' => 'GET',
743 'callback' => array( $this, 'rest_tool' ),
744 'permission_callback' => $tool_perm,
745 ),
746 array(
747 'methods' => 'POST',
748 'callback' => array( $this, 'rest_tool' ),
749 'permission_callback' => $tool_perm,
750 ),
751 )
752 );
753 }
754
755 /**
756 * Capability gate for the admin-only management routes.
757 *
758 * @return bool
759 */
760 public function admin_permission(): bool {
761 return current_user_can( 'manage_options' );
762 }
763
764 // -- Handlers ----------------------------------------------------------
765
766 /**
767 * MCP JSON-RPC over the wp-json fallback path.
768 *
769 * @param \WP_REST_Request $request Incoming request.
770 * @return \WP_REST_Response
771 */
772 public function rest_mcp( \WP_REST_Request $request ) {
773 $response = Mcp_Server::handle( $request );
774 // Advertise the MCP protocol version on the wp-json transport too, so
775 // both endpoints behave identically to a strict Streamable-HTTP client.
776 $response->header( 'MCP-Protocol-Version', Mcp_Server::PROTOCOL_VERSION );
777 return $response;
778 }
779
780 /**
781 * GET /signals — the public scan-signals body. See the route
782 * registration for what may (and may not) leave through it.
783 *
784 * @return \WP_REST_Response
785 */
786 public function rest_signals() {
787 $signals = array(
788 'xspeed' => XSPEED_VERSION,
789 'mcp' => '' !== Mcp_Pairing::site_token(),
790 'hub' => Mcp_Hub::site_attached(),
791 );
792
793 /**
794 * Filter the public scan signals.
795 *
796 * Lets an add-on append its own public facts (e.g. its version
797 * under `pro`). Values returned here are served UNAUTHENTICATED —
798 * never add tokens, accounts, emails, or paths.
799 *
800 * @param array<string,mixed> $signals The signals body.
801 */
802 $signals = (array) apply_filters( 'xspeed_scan_signals', $signals );
803
804 return rest_ensure_response( $signals );
805 }
806
807 /**
808 * GET /mcp/connection — pairing status for the dashboard.
809 *
810 * @param \WP_REST_Request $request Unused.
811 * @return \WP_REST_Response
812 */
813 public function rest_connection( \WP_REST_Request $request ) {
814 unset( $request );
815 return rest_ensure_response( Mcp_Pairing::public_status() );
816 }
817
818 /**
819 * GET /mcp/activity — the audit trail of AI tool calls.
820 *
821 * @param \WP_REST_Request $request Carries the optional limit.
822 * @return \WP_REST_Response|\WP_Error
823 */
824 public function rest_activity( \WP_REST_Request $request ) {
825 $limit = (int) $request->get_param( 'limit' );
826
827 return rest_ensure_response(
828 array(
829 'entries' => Mcp_Activity_Log::entries( $limit > 0 ? $limit : 50 ),
830 'summary' => Mcp_Activity_Log::summary(),
831 )
832 );
833 }
834
835 /**
836 * POST /mcp/activity/clear — wipe the audit trail.
837 *
838 * @param \WP_REST_Request $request Unused.
839 * @return \WP_REST_Response|\WP_Error
840 */
841 public function rest_activity_clear( \WP_REST_Request $request ) {
842 unset( $request );
843 $cleared = Mcp_Activity_Log::clear();
844
845 return rest_ensure_response(
846 array(
847 'cleared' => $cleared,
848 'entries' => Mcp_Activity_Log::entries(),
849 'summary' => Mcp_Activity_Log::summary(),
850 )
851 );
852 }
853
854 /**
855 * POST /mcp/connect — mint a connection token.
856 *
857 * @param \WP_REST_Request $request Unused.
858 * @return \WP_REST_Response|\WP_Error
859 */
860 public function rest_connect( \WP_REST_Request $request ) {
861 $read_only = (bool) $request->get_param( 'read_only' );
862 $result = Mcp_Pairing::connect( $read_only );
863 if ( is_wp_error( $result ) ) {
864 return $result;
865 }
866 return rest_ensure_response( $result );
867 }
868
869 /**
870 * POST /mcp/rotate — mint a fresh token, invalidating the old one.
871 *
872 * @param \WP_REST_Request $request Carries optional read_only.
873 * @return \WP_REST_Response
874 */
875 public function rest_rotate( \WP_REST_Request $request ) {
876 $read_only = null;
877 if ( null !== $request->get_param( 'read_only' ) ) {
878 $read_only = (bool) $request->get_param( 'read_only' );
879 }
880 return rest_ensure_response( Mcp_Pairing::rotate( $read_only ) );
881 }
882
883 /**
884 * POST /mcp/access — change the live connection's read-only state WITHOUT
885 * minting a new token (the paired client keeps working; only its allowed
886 * tools change). This is what the dashboard's read-only toggle calls.
887 *
888 * @param \WP_REST_Request $request Carries the required read_only bool.
889 * @return \WP_REST_Response|\WP_Error
890 */
891 public function rest_access( \WP_REST_Request $request ) {
892 $read_only = (bool) $request->get_param( 'read_only' );
893 $result = Mcp_Pairing::set_read_only( $read_only );
894 if ( is_wp_error( $result ) ) {
895 return $result;
896 }
897 return rest_ensure_response( $result );
898 }
899
900 /**
901 * POST /mcp/disconnect — revoke the connection token.
902 *
903 * @param \WP_REST_Request $request Unused.
904 * @return \WP_REST_Response
905 */
906 public function rest_disconnect( \WP_REST_Request $request ) {
907 unset( $request );
908 return rest_ensure_response( Mcp_Pairing::disconnect() );
909 }
910
911 // -- xSpeed Hub (multi-site) handlers ----------------------------------
912
913 /**
914 * GET /mcp/hub — hub-link status + the Method-1 paste-in values.
915 *
916 * @param \WP_REST_Request $request Unused.
917 * @return \WP_REST_Response
918 */
919 public function rest_hub_status( \WP_REST_Request $request ) {
920 // Self-heal from the Hub (source of truth) so the connected badge is
921 // reliable even if the attach callback never fired. Force a fresh check
922 // when the panel asks via the X-XSpeed-Reconcile header (e.g. the admin
923 // returned to the tab after connecting).
924 $force = '1' === (string) $request->get_header( 'x_xspeed_reconcile' );
925 Mcp_Hub::reconcile_with_hub( $force );
926 return rest_ensure_response( Mcp_Hub::public_status() );
927 }
928
929 /**
930 * POST /mcp/hub/token — ensure a site_token exists and return the
931 * paste-in values (this site's URL + token) for the hub's Add-site form.
932 *
933 * @param \WP_REST_Request $request Unused.
934 * @return \WP_REST_Response
935 */
936 public function rest_hub_token( \WP_REST_Request $request ) {
937 unset( $request );
938 return rest_ensure_response( Mcp_Hub::generate_token() );
939 }
940
941 /**
942 * POST /mcp/hub/attached — record which hub account this site is
943 * attached to (bookkeeping for the panel's status line).
944 *
945 * @param \WP_REST_Request $request Carries account_email.
946 * @return \WP_REST_Response
947 */
948 public function rest_hub_attached( \WP_REST_Request $request ) {
949 $email = sanitize_email( (string) $request->get_param( 'account_email' ) );
950 return rest_ensure_response( Mcp_Hub::mark_attached( $email ) );
951 }
952
953 /**
954 * POST /mcp/hub/disconnect — clear the local hub-link bookkeeping.
955 *
956 * @param \WP_REST_Request $request Unused.
957 * @return \WP_REST_Response
958 */
959 public function rest_hub_disconnect( \WP_REST_Request $request ) {
960 unset( $request );
961 return rest_ensure_response( Mcp_Hub::disconnect() );
962 }
963
964 /**
965 * POST /mcp/attach — the OAuth-attach callback. The hub presents the
966 * signed nonce the plugin issued; on success we return this site's URL +
967 * token so the hub can record it. Nonce is the auth (admin-minted,
968 * HMAC-signed, time-bound), so no pre-shared token is required.
969 *
970 * @param \WP_REST_Request $request Carries the nonce.
971 * @return \WP_REST_Response|\WP_Error
972 */
973 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 );
976 if ( null === $result ) {
977 return new \WP_Error(
978 'xspeed_attach_invalid',
979 __( 'Invalid or expired attach request.', 'xspeed' ),
980 array( 'status' => 403 )
981 );
982 }
983 // A valid nonce proves this is a real hub-initiated attach, so record it
984 // now — the hub passes the account email so the panel can show
985 // "Connected via <email>". The nonce carries the minting admin's user
986 // id (no WP session exists in this server-to-server call), so the state
987 // is recorded PER-USER — each admin sees their own connection.
988 $account_email = sanitize_email( (string) $request->get_param( 'account_email' ) );
989 $user_id = isset( $result['user_id'] ) ? (int) $result['user_id'] : 0;
990 Mcp_Hub::mark_attached( $account_email, $user_id ?: null );
991
992 // The hub only needs the credential; don't leak the internal user id.
993 unset( $result['user_id'] );
994 return rest_ensure_response( $result );
995 }
996
997 // -- OAuth 2.1 handlers ------------------------------------------------
998
999 /**
1000 * GET /mcp/.well-known/oauth-protected-resource — RFC 9728 metadata.
1001 *
1002 * Byte-identical to what the /.well-known rewrite serves; both call the
1003 * same builder so the two locations can never drift.
1004 *
1005 * @return \WP_REST_Response
1006 */
1007 public function rest_protected_resource_metadata(): \WP_REST_Response {
1008 return $this->discovery_response( Mcp_OAuth::protected_resource_metadata() );
1009 }
1010
1011 /**
1012 * GET /mcp/.well-known/oauth-authorization-server — RFC 8414 metadata.
1013 *
1014 * @return \WP_REST_Response
1015 */
1016 public function rest_authorization_server_metadata(): \WP_REST_Response {
1017 return $this->discovery_response( Mcp_OAuth::authorization_server_metadata() );
1018 }
1019
1020 /**
1021 * Wrap a discovery document in a public, cacheable REST response.
1022 *
1023 * @param array<string,mixed> $data The metadata document.
1024 * @return \WP_REST_Response
1025 */
1026 private function discovery_response( array $data ): \WP_REST_Response {
1027 $response = new \WP_REST_Response( $data, 200 );
1028 $response->header( 'Cache-Control', 'public, max-age=3600' );
1029 return $response;
1030 }
1031
1032 /**
1033 * POST /mcp/oauth/register — RFC 7591 dynamic client registration.
1034 *
1035 * @param \WP_REST_Request $request JSON body with redirect_uris.
1036 * @return \WP_REST_Response|\WP_Error
1037 */
1038 public function rest_oauth_register( \WP_REST_Request $request ) {
1039 $body = $request->get_json_params();
1040 if ( ! is_array( $body ) ) {
1041 $body = array();
1042 }
1043 $result = Mcp_OAuth::register_client( $body );
1044 if ( is_wp_error( $result ) ) {
1045 return $result;
1046 }
1047 return new \WP_REST_Response( $result, 201 );
1048 }
1049
1050 /**
1051 * The browser-facing OAuth authorize page (served at /xspeed/authorize via
1052 * a rewrite, NOT the REST API — see AUTHORIZE_QUERY_VAR). Reads request
1053 * params from the superglobals because this is a normal front-end request
1054 * where cookie auth populates is_user_logged_in().
1055 *
1056 * GET renders the consent screen (requires a logged-in admin; anonymous
1057 * users go to wp-login and return here). POST is the nonce-checked consent
1058 * submission: Approve issues a code and 302s to the client's redirect_uri;
1059 * Deny 302s back with error=access_denied. Always emits its own response
1060 * (HTML page or redirect) and exits.
1061 */
1062 public function handle_authorize_page(): void {
1063 $is_post = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) );
1064 // Params come from GET on the consent link and POST on the form submit.
1065 // Nonce is verified below before any POST value is acted on.
1066 // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing
1067 $source = $is_post ? $_POST : $_GET;
1068 // phpcs:enable
1069 $params = array();
1070 foreach ( array( 'client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'approve', 'deny', '_xspeed_oauth_nonce' ) as $k ) {
1071 $params[ $k ] = isset( $source[ $k ] ) ? sanitize_text_field( wp_unslash( $source[ $k ] ) ) : '';
1072 }
1073
1074 // Validate the OAuth params before touching the session.
1075 $req = Mcp_OAuth::validate_authorize_request( $params );
1076 if ( is_wp_error( $req ) ) {
1077 $data = $req->get_error_data();
1078 $redirectable = is_array( $data ) && ! empty( $data['redirectable'] );
1079 // Only redirect the error back when redirect_uri is verified valid;
1080 // otherwise show a page (never bounce to an unverified URL).
1081 if ( $redirectable && '' !== $params['redirect_uri'] ) {
1082 $this->redirect_error( $params['redirect_uri'], $req->get_error_code(), $req->get_error_message(), $params['state'] );
1083 }
1084 $this->emit_oauth_error_page( $req->get_error_message() );
1085 }
1086
1087 // Require a logged-in admin. Anonymous → wp-login, back to this URL.
1088 if ( ! is_user_logged_in() ) {
1089 $this->redirect_to_login();
1090 }
1091 if ( ! current_user_can( 'manage_options' ) ) {
1092 $this->emit_oauth_error_page(
1093 __( 'You must be an administrator to authorize an AI agent to control this site.', 'xspeed' )
1094 );
1095 }
1096
1097 // POST = consent form submitted.
1098 if ( $is_post ) {
1099 if ( ! wp_verify_nonce( $params['_xspeed_oauth_nonce'], 'xspeed_oauth_consent' ) ) {
1100 $this->emit_oauth_error_page( __( 'Security check failed. Please try connecting again.', 'xspeed' ) );
1101 }
1102 if ( '' === $params['approve'] ) {
1103 $this->redirect_error( $req['redirect_uri'], 'access_denied', 'The user denied the request.', $req['state'] );
1104 }
1105 $code = Mcp_OAuth::issue_code( $req, get_current_user_id() );
1106 $this->redirect_success( $req['redirect_uri'], $code, $req['state'] );
1107 }
1108
1109 // GET = render the consent screen.
1110 $this->emit_consent_screen( $req );
1111 }
1112
1113 /**
1114 * POST /mcp/oauth/token — exchange a code (or refresh token) for tokens.
1115 *
1116 * @param \WP_REST_Request $request Form-encoded or JSON token request.
1117 * @return \WP_REST_Response
1118 */
1119 public function rest_oauth_token( \WP_REST_Request $request ) {
1120 // Token requests are application/x-www-form-urlencoded per OAuth, but
1121 // accept JSON too. get_body_params() covers the form case.
1122 $body = $request->get_body_params();
1123 if ( empty( $body ) ) {
1124 $json = $request->get_json_params();
1125 $body = is_array( $json ) ? $json : array();
1126 }
1127 $body = array_map( 'strval', $body );
1128
1129 $result = Mcp_OAuth::exchange_token( $body );
1130 if ( is_wp_error( $result ) ) {
1131 $data = $result->get_error_data();
1132 $response = new \WP_REST_Response(
1133 array(
1134 'error' => isset( $data['error'] ) ? $data['error'] : 'invalid_request',
1135 'error_description' => isset( $data['error_description'] ) ? $data['error_description'] : $result->get_error_message(),
1136 ),
1137 isset( $data['status'] ) ? (int) $data['status'] : 400
1138 );
1139 $response->header( 'Cache-Control', 'no-store' );
1140 return $response;
1141 }
1142 $response = new \WP_REST_Response( $result, 200 );
1143 $response->header( 'Cache-Control', 'no-store' );
1144 $response->header( 'Pragma', 'no-cache' );
1145 return $response;
1146 }
1147
1148 /**
1149 * Token-authenticated tool route for the hosted broker. Maps a broker
1150 * tool call (e.g. GET /mcp/tool/get_cache_status) onto the shared
1151 * Mcp_Tools catalog, so the broker path and the JSON-RPC path never
1152 * drift. GET params + JSON body both feed the tool's arguments.
1153 */
1154 public function rest_tool( \WP_REST_Request $request ) {
1155 $tool = (string) $request->get_param( 'tool' );
1156 $args = $request->get_json_params();
1157 if ( ! is_array( $args ) ) {
1158 $args = array();
1159 }
1160 // Merge query params (e.g. ?module=minify) so GET tools work too.
1161 foreach ( $request->get_query_params() as $k => $v ) {
1162 if ( 'tool' !== $k && ! array_key_exists( $k, $args ) ) {
1163 $args[ $k ] = $v;
1164 }
1165 }
1166
1167 Mcp_Tools::set_channel( 'broker' );
1168 $result = Mcp_Tools::invoke( $tool, $args );
1169 if ( is_wp_error( $result ) ) {
1170 return $result;
1171 }
1172 return rest_ensure_response( $result );
1173 }
1174
1175 // -- Helpers --
1176
1177 // -- OAuth browser-response helpers ------------------------------------
1178
1179 /** The absolute URL of the current authorize request (for login return). */
1180 private function current_authorize_url(): string {
1181 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- reconstructing the current URL for a login round-trip; escaped at use.
1182 $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
1183 return home_url( $uri );
1184 }
1185
1186 /** Send an anonymous visitor to wp-login, returning to this authorize URL. */
1187 private function redirect_to_login(): void {
1188 wp_safe_redirect( wp_login_url( $this->current_authorize_url() ) );
1189 exit;
1190 }
1191
1192 /** 302 back to the client with the authorization code (+ state). */
1193 private function redirect_success( string $redirect_uri, string $code, string $state ): void {
1194 $args = array( 'code' => $code );
1195 if ( '' !== $state ) {
1196 $args['state'] = $state;
1197 }
1198 // Not wp_safe_redirect: redirect_uri is a client-registered off-site
1199 // callback, already validated against the client's registered set.
1200 wp_redirect( add_query_arg( $args, $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
1201 exit;
1202 }
1203
1204 /** 302 back to the client with an OAuth error (+ state). */
1205 private function redirect_error( string $redirect_uri, string $error, string $description, string $state ): void {
1206 $args = array(
1207 'error' => $error,
1208 'error_description' => $description,
1209 );
1210 if ( '' !== $state ) {
1211 $args['state'] = $state;
1212 }
1213 wp_redirect( add_query_arg( array_map( 'rawurlencode', $args ), $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
1214 exit;
1215 }
1216
1217 /**
1218 * Render the consent screen. Minimal self-contained HTML (no admin
1219 * chrome — this is a client-facing OAuth page). Approve/Deny post back
1220 * to the same authorize URL with a nonce.
1221 *
1222 * @param array<string,string> $req Validated authorize params.
1223 */
1224 private function emit_consent_screen( array $req ): void {
1225 $read_only = Mcp_OAuth::scope_is_read_only( $req['scope'] );
1226 $access = $read_only
1227 ? __( 'Read-only — inspect cache status and settings.', 'xspeed' )
1228 : __( 'Read & write — purge caches, toggle caching, and change settings.', 'xspeed' );
1229 $client = '' !== $req['client_name'] ? $req['client_name'] : __( 'An AI agent', 'xspeed' );
1230 $action_url = Mcp_OAuth::authorize_url();
1231 $nonce = wp_create_nonce( 'xspeed_oauth_consent' );
1232 $user = wp_get_current_user();
1233
1234 // Preserve every OAuth param so the POST re-validates identically.
1235 $hidden = '';
1236 foreach ( array( 'client_id', 'redirect_uri', 'code_challenge', 'scope', 'state' ) as $k ) {
1237 $val = 'scope' === $k ? $req['scope'] : ( $req[ $k ] ?? '' );
1238 $hidden .= sprintf( '<input type="hidden" name="%s" value="%s" />', esc_attr( $k ), esc_attr( (string) $val ) );
1239 }
1240 // code_challenge_method + response_type are re-asserted for validation.
1241 $hidden .= '<input type="hidden" name="code_challenge_method" value="S256" />';
1242 $hidden .= '<input type="hidden" name="response_type" value="code" />';
1243
1244 status_header( 200 );
1245 header( 'Content-Type: text/html; charset=utf-8' );
1246 header( 'Cache-Control: no-store' );
1247
1248 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">';
1259 echo '<h1>' . esc_html__( 'Connect to xSpeed', 'xspeed' ) . '</h1>';
1260 /* translators: %s: AI client name. */
1261 echo '<p class="sub">' . esc_html( sprintf( __( '%s wants to manage the cache on this site.', 'xspeed' ), $client ) ) . '</p>';
1262 echo '<div class="row"><span>' . esc_html__( 'Site', 'xspeed' ) . '</span><span>' . esc_html( wp_parse_url( home_url(), PHP_URL_HOST ) ) . '</span></div>';
1263 echo '<div class="row"><span>' . esc_html__( 'Signed in as', 'xspeed' ) . '</span><span>' . esc_html( $user->user_login ) . '</span></div>';
1264 echo '<div class="row"><span>' . esc_html__( 'Access', 'xspeed' ) . '</span><span>' . esc_html( $access ) . '</span></div>';
1265 echo '<form method="post" action="' . esc_url( $action_url ) . '">';
1266 echo $hidden; // phpcs:ignore WordPress.Security.EscapeOutput -- built from esc_attr() above.
1267 echo '<input type="hidden" name="_xspeed_oauth_nonce" value="' . esc_attr( $nonce ) . '" />';
1268 echo '<div class="actions">';
1269 echo '<button class="deny" name="deny" value="1">' . esc_html__( 'Deny', 'xspeed' ) . '</button>';
1270 echo '<button class="approve" name="approve" value="1">' . esc_html__( 'Approve', 'xspeed' ) . '</button>';
1271 echo '</div></form></div></body></html>';
1272 exit;
1273 }
1274
1275 /** Render a standalone OAuth error page (no redirect). */
1276 private function emit_oauth_error_page( string $message ): void {
1277 status_header( 400 );
1278 header( 'Content-Type: text/html; charset=utf-8' );
1279 header( 'Cache-Control: no-store' );
1280 echo '<!doctype html><html><head><meta charset="utf-8"><title>' . esc_html__( 'Authorization error', 'xspeed' ) . '</title>';
1281 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}'
1282 . '.card{background:#1e293b;border:1px solid #334155;border-radius:16px;max-width:440px;padding:32px;text-align:center}</style></head><body>';
1283 echo '<div class="card"><h1>' . esc_html__( 'Could not authorize', 'xspeed' ) . '</h1><p>' . esc_html( $message ) . '</p></div></body></html>';
1284 exit;
1285 }
1286
1287 /** Read an inbound HTTP header from $_SERVER (for the pretty path). */
1288 private static function server_header( string $name ): ?string {
1289 $key = 'HTTP_' . strtoupper( str_replace( '-', '_', $name ) );
1290 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- token compared constant-time downstream; raw header needed verbatim.
1291 return isset( $_SERVER[ $key ] ) ? wp_unslash( $_SERVER[ $key ] ) : null;
1292 }
1293
1294 /** Emit a WP_REST_Response as a JSON HTTP response and stop. */
1295 private function emit_json( \WP_REST_Response $response ): void {
1296 status_header( $response->get_status() );
1297 // MCP Streamable HTTP: advertise the protocol version we speak so a
1298 // strict client can pin it. We answer JSON (a spec-permitted response
1299 // type); we never open an SSE stream, so no session header is needed.
1300 header( 'MCP-Protocol-Version: ' . Mcp_Server::PROTOCOL_VERSION );
1301 // Forward any headers the handler set (notably WWW-Authenticate on a
1302 // 401, which drives the OAuth discovery flow). rest_do_request applies
1303 // these automatically; the pretty-endpoint path must do it by hand.
1304 foreach ( $response->get_headers() as $name => $value ) {
1305 header( $name . ': ' . $value );
1306 }
1307 $data = $response->get_data();
1308 if ( null !== $data ) {
1309 header( 'Content-Type: application/json; charset=utf-8' );
1310 echo wp_json_encode( $data );
1311 }
1312 exit;
1313 }
1314
1315 // -- WP-CLI mirror --
1316
1317 public function cli_commands(): array {
1318 return array(
1319 array(
1320 'name' => 'xspeed mcp status',
1321 'callback' => array( $this, 'cli_status' ),
1322 'shortdesc' => 'Show MCP connection status and the paste-in endpoint URL.',
1323 'synopsis' => array(),
1324 ),
1325 array(
1326 'name' => 'xspeed mcp activity',
1327 'callback' => array( $this, 'cli_activity' ),
1328 'shortdesc' => 'List recent MCP tool calls (the AI audit trail).',
1329 'synopsis' => array(
1330 array(
1331 'name' => 'limit',
1332 'type' => 'assoc',
1333 'optional' => true,
1334 'description' => 'Maximum entries to show (default 20).',
1335 ),
1336 array(
1337 'name' => 'clear',
1338 'type' => 'flag',
1339 'optional' => true,
1340 'description' => 'Wipe the audit trail instead of listing it.',
1341 ),
1342 ),
1343 ),
1344 array(
1345 'name' => 'xspeed mcp connect',
1346 'callback' => array( $this, 'cli_connect' ),
1347 'shortdesc' => 'Generate a connection token for this site\'s MCP endpoint.',
1348 'synopsis' => array(
1349 array(
1350 'name' => 'read-only',
1351 'type' => 'flag',
1352 'optional' => true,
1353 'description' => 'Grant read-only access (no purge/toggle/settings changes).',
1354 ),
1355 ),
1356 ),
1357 array(
1358 'name' => 'xspeed mcp rotate',
1359 'callback' => array( $this, 'cli_rotate' ),
1360 'shortdesc' => 'Mint a fresh MCP token, immediately invalidating the previous one.',
1361 'synopsis' => array(
1362 array(
1363 'name' => 'read-only',
1364 'type' => 'flag',
1365 'optional' => true,
1366 'description' => 'Make the new token read-only.',
1367 ),
1368 ),
1369 ),
1370 array(
1371 'name' => 'xspeed mcp disconnect',
1372 'callback' => array( $this, 'cli_disconnect' ),
1373 'shortdesc' => 'Revoke this site\'s MCP connection token.',
1374 'synopsis' => array(),
1375 ),
1376 );
1377 }
1378
1379 /**
1380 * `wp xspeed mcp status` — print connection status + endpoint URL.
1381 *
1382 * @param array $args Positional args (unused).
1383 * @param array $assoc Associative args (unused).
1384 */
1385 public function cli_status( array $args, array $assoc ): void {
1386 unset( $args, $assoc );
1387 $s = Mcp_Pairing::public_status();
1388 \WP_CLI::log( sprintf( '%-18s %s', 'connected', $s['connected'] ? 'yes' : 'no' ) );
1389 if ( $s['connected'] ) {
1390 \WP_CLI::log( sprintf( '%-18s %s', 'access', $s['read_only'] ? 'read-only' : 'read-write' ) );
1391 \WP_CLI::log( sprintf( '%-18s %s', 'connect_url', $s['connect_url'] ) );
1392 \WP_CLI::log( sprintf( '%-18s %s', 'scopes', implode( ',', $s['scopes'] ) ) );
1393 } else {
1394 \WP_CLI::log( sprintf( '%-18s %s', 'mcp_endpoint', Mcp_Pairing::site_endpoint() ) );
1395 }
1396 }
1397
1398 /**
1399 * `wp xspeed mcp activity` — read (or clear) the AI audit trail.
1400 *
1401 * @param array $args Positional args (unused).
1402 * @param array $assoc --limit=<n>, --clear.
1403 */
1404 public function cli_activity( array $args, array $assoc ): void {
1405 unset( $args );
1406
1407 if ( ! empty( $assoc['clear'] ) ) {
1408 if ( ! Mcp_Activity_Log::clear() ) {
1409 // Reached via MCP run_command — the assistant is asking to
1410 // erase the record of its own calls. Mcp_Activity_Log::clear()
1411 // declines and logs the attempt; say so plainly.
1412 \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.' );
1413 return;
1414 }
1415 \WP_CLI::success( 'MCP activity log cleared.' );
1416 return;
1417 }
1418
1419 $limit = isset( $assoc['limit'] ) ? (int) $assoc['limit'] : 20;
1420 $summary = Mcp_Activity_Log::summary();
1421 $entries = Mcp_Activity_Log::entries( $limit > 0 ? $limit : 20 );
1422
1423 \WP_CLI::log( sprintf( '%-18s %d', 'total_calls', $summary['total'] ) );
1424 \WP_CLI::log( sprintf( '%-18s %d', 'failed', $summary['failed'] ) );
1425 \WP_CLI::log( sprintf( '%-18s %s', 'top_tool', '' === $summary['top_tool'] ? '-' : $summary['top_tool'] ) );
1426
1427 if ( empty( $entries ) ) {
1428 \WP_CLI::log( '' );
1429 \WP_CLI::log( 'No MCP tool calls recorded yet.' );
1430 return;
1431 }
1432
1433 \WP_CLI::log( '' );
1434 foreach ( $entries as $entry ) {
1435 \WP_CLI::log(
1436 sprintf(
1437 '%s %-22s %-5s %-6s %s%s',
1438 gmdate( 'Y-m-d H:i:s', $entry['ts'] ),
1439 $entry['tool'],
1440 $entry['scope'],
1441 $entry['ok'] ? 'ok' : 'FAIL',
1442 $entry['args'],
1443 '' === $entry['error'] ? '' : '' . $entry['error']
1444 )
1445 );
1446 }
1447 }
1448
1449 /**
1450 * `wp xspeed mcp connect` — mint a token and print the paste-in URL.
1451 *
1452 * @param array $args Positional args (unused).
1453 * @param array $assoc Associative args (unused).
1454 */
1455 public function cli_connect( array $args, array $assoc ): void {
1456 unset( $args );
1457 $read_only = ! empty( $assoc['read-only'] );
1458 $result = Mcp_Pairing::connect( $read_only );
1459 if ( is_wp_error( $result ) ) {
1460 \WP_CLI::error( $result->get_error_message() );
1461 return;
1462 }
1463 \WP_CLI::success( 'Connected' . ( Mcp_Pairing::is_read_only() ? ' (read-only).' : '.' ) . ' Paste this single URL into your AI client:' );
1464 \WP_CLI::log( ' ' . Mcp_Pairing::connect_url() );
1465 \WP_CLI::log( '' );
1466 \WP_CLI::log( 'Or, header-based (token stays out of the URL):' );
1467 \WP_CLI::log( ' ' . Mcp_Pairing::config_snippets()['cli'] );
1468 }
1469
1470 /**
1471 * `wp xspeed mcp rotate` — mint a new token, revoking the old one.
1472 *
1473 * @param array $args Positional args (unused).
1474 * @param array $assoc Associative args ({ read-only?:flag }).
1475 */
1476 public function cli_rotate( array $args, array $assoc ): void {
1477 unset( $args );
1478 $read_only = array_key_exists( 'read-only', $assoc ) ? ! empty( $assoc['read-only'] ) : null;
1479 Mcp_Pairing::rotate( $read_only );
1480 \WP_CLI::success( 'Rotated. The previous token is now invalid. New paste-in URL:' );
1481 \WP_CLI::log( ' ' . Mcp_Pairing::connect_url() );
1482 }
1483
1484 /**
1485 * `wp xspeed mcp disconnect` — revoke the connection token.
1486 *
1487 * @param array $args Positional args (unused).
1488 * @param array $assoc Associative args (unused).
1489 */
1490 public function cli_disconnect( array $args, array $assoc ): void {
1491 unset( $args, $assoc );
1492 Mcp_Pairing::disconnect();
1493 \WP_CLI::success( 'Disconnected and revoked the MCP token.' );
1494 }
1495
1496 /**
1497 * MCP is on when a connection token exists -- it has no `enabled`
1498 * setting, so the sidebar counted the AI group as empty on a site with
1499 * a live read-write AI connection. Reads the same
1500 * `Mcp_Pairing::public_status()` the CLI and the panel do, so the count
1501 * cannot disagree with the badge on the panel. (#363)
1502 */
1503 public function is_active(): ?bool {
1504 $status = Mcp_Pairing::public_status();
1505 return ! empty( $status['connected'] );
1506 }
1507
1508 /**
1509 * MCP has no on/off setting -- it is on when a connection exists.
1510 */
1511 public function active_reason(): ?string {
1512 return $this->is_active()
1513 ? __( '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' )
1514 : __( 'No AI assistant is connected. This module counts as on once you connect one.', 'xspeed' );
1515 }
1516 }
1517