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

1,291 lines 48.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP module — the site side of xSpeed's MCP integration.
4 *
5 * PRIMARY path (no hosted infra): the plugin speaks the MCP protocol
6 * DIRECTLY at this site's own URL. The user pastes their own site's MCP
7 * endpoint + connection token into their AI client:
8 *
9 * https://thissite.com/xspeed/mcp (pretty, via rewrite)
10 * https://thissite.com/wp-json/xspeed/v1/mcp (always-on fallback)
11 *
12 * The MCP JSON-RPC handling lives in Mcp_Server; the tool catalog in
13 * Mcp_Tools. Auth is the per-site connection token (Mcp_Auth /
14 * Mcp_Pairing). See IMPLEMENTATION.md §17.
15 *
16 * OPTIONAL path (hosted broker, api.xspeedcache.com): the same tool
17 * catalog is also exposed as token-authenticated REST routes under
18 * /xspeed/v1/mcp/tool/* so a hosted broker can proxy to it for a single
19 * shared vanity URL. Not required for the product to work.
20 *
21 * Admin-only management routes (manage_options) drive the dashboard
22 * "Connect AI" panel: /mcp/connection, /mcp/connect, /mcp/disconnect.
23 *
24 * These routes register DIRECTLY on rest_api_init (NOT via Rest_Manager,
25 * whose wrap_permission() forces a current_user_can() gate that MCP's
26 * token-only calls can never satisfy).
27 *
28 * Tier: Free. The ONLY gate is possession of the per-site connection
29 * token, which an admin (manage_options) must explicitly mint via
30 * Connect. A fresh install ships with no token → every MCP call is 401
31 * until the admin opts in. Adds ZERO cache logic.
32 *
33 * @package XSpeed
34 */
35
36 declare(strict_types=1);
37
38 namespace XSpeed\Modules\Mcp;
39
40 use XSpeed\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. */
80 private const NS = 'xspeed/v1';
81
82 /** Query var flagging a pretty /xspeed/mcp request. */
83 private const QUERY_VAR = 'xspeed_mcp';
84
85 /** Query var carrying the token when embedded in the URL path. */
86 private const TOKEN_QUERY_VAR = 'xspeed_mcp_token';
87
88 /** Query var flagging a /.well-known/ OAuth discovery request. */
89 private const WELLKNOWN_QUERY_VAR = 'xspeed_mcp_wellknown';
90
91 /**
92 * Query var flagging the browser-facing OAuth authorize page. This is
93 * served OUTSIDE the REST API on purpose: a REST route only honors cookie
94 * auth when a REST nonce accompanies it, but a browser arriving from
95 * wp-login carries the cookie with NO nonce — so is_user_logged_in() would
96 * be false there and the consent screen would loop back to login forever.
97 * A normal front-end URL (rewrite + parse_request) sees standard cookie
98 * auth, so the logged-in admin check works.
99 */
100 private const AUTHORIZE_QUERY_VAR = 'xspeed_mcp_authorize';
101
102 /** Front-end path of the browser-facing authorize page. */
103 private const AUTHORIZE_PATH = 'xspeed/authorize';
104
105 /** Query var flagging the pretty /xspeed/mcp/attach callback. */
106 private const ATTACH_QUERY_VAR = 'xspeed_mcp_attach';
107
108 public function ui_metadata(): array {
109 return array(
110 'label' => 'MCP Server',
111 'icon' => 'Sparkles',
112 'description' => 'Control this site\'s cache from Claude and other AI agents.',
113 'custom_panel' => 'McpPanel',
114 );
115 }
116
117 /**
118 * MCP pairing state lives in xspeed_module_mcp but is managed by
119 * Mcp_Pairing, not the schema engine. Empty schema so the base class
120 * doesn't auto-register generic settings routes.
121 */
122 public function settings_schema(): array {
123 return array();
124 }
125
126 /**
127 * All MCP routes register directly (see class docblock). Returning an
128 * empty array keeps Rest_Manager out of the token-auth path entirely.
129 */
130 public function rest_routes(): array {
131 return array();
132 }
133
134 public function boot(): void {
135 add_action( 'rest_api_init', array( $this, 'register_rest' ) );
136
137 // Pretty per-site endpoint: /xspeed/mcp → MCP JSON-RPC handler.
138 add_action( 'init', array( $this, 'add_rewrite' ) );
139 add_filter( 'query_vars', array( $this, 'register_query_var' ) );
140 add_action( 'parse_request', array( $this, 'maybe_handle_pretty_endpoint' ) );
141
142 // Hub redirect-return: after the user approves on the Hub, it sends the
143 // browser back to a plugin admin URL carrying ?xspeed_connected=1 plus
144 // the account email + the SAME signed nonce we minted. We verify our own
145 // nonce and mark this admin attached — no server-to-server callback
146 // needed, so it works for local/firewalled sites too.
147 add_action( 'admin_init', array( $this, 'maybe_handle_hub_return' ) );
148 }
149
150 /**
151 * Handle the browser landing back from the Hub after a connect. Idempotent
152 * and safe to run on every admin page load: it only acts when the return
153 * markers are present and the nonce verifies.
154 */
155 public function maybe_handle_hub_return(): void {
156 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- auth is the signed HMAC nonce below, not a WP nonce; this is a read-only routing check.
157 $nonce = isset( $_GET['xspeed_hub_nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['xspeed_hub_nonce'] ) ) : '';
158 $email = isset( $_GET['xspeed_hub_email'] ) ? sanitize_email( wp_unslash( $_GET['xspeed_hub_email'] ) ) : '';
159
160 /*
161 * Trigger on the signed nonce, not on `xspeed_connected`.
162 *
163 * The Hub bounces the browser back with xspeed_hub_nonce +
164 * xspeed_hub_email, but it does NOT always append xspeed_connected —
165 * that marker only survives when the return_url we handed it carried
166 * one. Gating on it meant a real, correctly-signed return was ignored:
167 * the attach was never recorded, the params were never stripped, and
168 * the card kept showing "Not connected" while the nonce sat in the
169 * address bar. The nonce is the actual proof of a genuine round trip,
170 * so it is what this handler keys on. (FBS-84086)
171 */
172 if ( '' === $nonce && empty( $_GET['xspeed_connected'] ) ) {
173 return;
174 }
175 // phpcs:enable WordPress.Security.NonceVerification.Recommended
176
177 if ( ! current_user_can( 'manage_options' ) ) {
178 return;
179 }
180
181 // Verify OUR own signed nonce (proves the round-trip went through the
182 // Hub with a token we minted), then record the connection.
183 if ( '' !== $nonce ) {
184 $verified = Mcp_Hub::verify_attach_nonce( $nonce );
185 if ( null !== $verified ) {
186 $uid = isset( $verified['user_id'] ) ? (int) $verified['user_id'] : get_current_user_id();
187 Mcp_Hub::mark_attached( $email, $uid ?: null );
188 }
189 }
190
191 // ALWAYS strip the one-time return markers from the URL and redirect to
192 // the clean address. These params are single-use; if they persist in the
193 // browser URL, a later reload re-triggers the "just connected" path and
194 // flashes a stale connected state even after the user has disconnected.
195 $clean = remove_query_arg( array( 'xspeed_connected', 'xspeed_hub_nonce', 'xspeed_hub_email' ) );
196
197 // The setup wizard keeps its current step in component state, so a
198 // redirect remounts it at step 1 — dumping the user back at the START of
199 // onboarding immediately after they finished its LAST step. Carry a
200 // durable hint so the wizard resumes on Connect instead. It's a plain
201 // step marker, not an auth signal (the nonce above did that job), and
202 // it's safe to leave in the URL: re-loading it just re-opens the same
203 // step rather than re-running the connect path. (PM feedback)
204 if ( false !== strpos( (string) $clean, 'page=' . Onboarding::PAGE_SLUG ) ) {
205 $clean = add_query_arg( 'xspeed_step', 'connect', $clean );
206 }
207
208 wp_safe_redirect( $clean );
209 exit;
210 }
211
212 /**
213 * Flush rewrites once when the module first boots so /xspeed/mcp works
214 * without a manual permalink re-save. Cheap: gated on a one-shot flag.
215 */
216 public function activate(): void {
217 $this->add_rewrite();
218 flush_rewrite_rules( false );
219 }
220
221 public function deactivate(): void {
222 flush_rewrite_rules( false );
223 }
224
225 // -- Pretty endpoint: /xspeed/mcp --
226
227 public function add_rewrite(): void {
228 // Token-in-URL form: /xspeed/mcp/<token> — a single string the user
229 // pastes into their AI client (no separate token field). The bare
230 // /xspeed/mcp still works with a Bearer/header token.
231 add_rewrite_rule(
232 '^xspeed/mcp/([a-f0-9]{64})/?$',
233 'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]',
234 'top'
235 );
236 add_rewrite_rule( '^xspeed/mcp/?$', 'index.php?' . self::QUERY_VAR . '=1', 'top' );
237
238 // Pretty attach-callback endpoint: /xspeed/mcp/attach — the hub POSTs
239 // the signed nonce here to verify + fetch the token. Uses the plugin's
240 // own rewrite (consistent with the MCP URL, survives hosts that block
241 // /wp-json). Placed BEFORE the token rule would never match "attach"
242 // (that rule requires 64 hex chars), so ordering is safe.
243 add_rewrite_rule( '^xspeed/mcp/attach/?$', 'index.php?' . self::ATTACH_QUERY_VAR . '=1', 'top' );
244
245 // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place the
246 // `.well-known` segment BEFORE the resource path, so our resource at
247 // /xspeed/mcp is discovered at BOTH:
248 // /.well-known/oauth-protected-resource (root form)
249 // /.well-known/oauth-protected-resource/xspeed/mcp (path-suffixed)
250 // Real clients (Claude Desktop among them) request the path-suffixed
251 // form; serving only the root form 404s them and the connection aborts.
252 //
253 // Both are matched EXACTLY. A `(?:/.*)?` tail covers the same two URLs
254 // in one rule, but also matches every OTHER plugin's discovery URL on
255 // the same site — and WordPress matches rewrite rules in table order
256 // rather than by specificity, so a sibling's own exact rule never gets
257 // reached. Its clients then receive OUR metadata, find a resource and
258 // issuer that do not match what they are connecting to, and abort
259 // before the login screen.
260 add_rewrite_rule(
261 '^\\.well-known/oauth-(protected-resource|authorization-server)/?$',
262 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
263 'top'
264 );
265 add_rewrite_rule(
266 '^\\.well-known/oauth-(protected-resource|authorization-server)/xspeed/mcp/?$',
267 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
268 'top'
269 );
270
271 // Browser-facing OAuth consent page — served OUTSIDE REST so cookie
272 // auth (is_user_logged_in) works after the wp-login round-trip.
273 add_rewrite_rule( '^xspeed/authorize/?$', 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', 'top' );
274
275 // Self-heal: flush once if ANY of our rules is missing from the stored
276 // rewrite table. Checking only the first rule is not enough — a site
277 // flushed under an older build (which had /xspeed/mcp but not the
278 // later /xspeed/authorize + /.well-known rules) keeps that first rule,
279 // so the guard never fires and OAuth discovery 404s forever. Guard on
280 // the full set so any newly-added rule triggers a re-flush.
281 $rules = get_option( 'rewrite_rules' );
282 if ( is_array( $rules ) ) {
283 foreach ( self::REWRITE_RULES as $rule ) {
284 if ( ! isset( $rules[ $rule ] ) ) {
285 flush_rewrite_rules( false );
286 break;
287 }
288 }
289 }
290 }
291
292 /**
293 * @param string[] $vars Registered query vars.
294 * @return string[]
295 */
296 public function register_query_var( array $vars ): array {
297 $vars[] = self::QUERY_VAR;
298 $vars[] = self::TOKEN_QUERY_VAR;
299 $vars[] = self::WELLKNOWN_QUERY_VAR;
300 $vars[] = self::AUTHORIZE_QUERY_VAR;
301 $vars[] = self::ATTACH_QUERY_VAR;
302 return $vars;
303 }
304
305 /**
306 * Serve the MCP endpoint on the pretty path. Runs on parse_request so
307 * it fires before the main query, and short-circuits WP entirely.
308 *
309 * @param \WP $wp The WP request object.
310 */
311 public function maybe_handle_pretty_endpoint( $wp ): void {
312 // OAuth discovery documents (served at the site root).
313 if ( ! empty( $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] ) ) {
314 $doc = (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ];
315 $data = 'authorization-server' === $doc
316 ? Mcp_OAuth::authorization_server_metadata()
317 : Mcp_OAuth::protected_resource_metadata();
318 status_header( 200 );
319 header( 'Content-Type: application/json; charset=utf-8' );
320 // Discovery metadata is public + cacheable.
321 header( 'Cache-Control: public, max-age=3600' );
322 echo wp_json_encode( $data );
323 exit;
324 }
325
326 // Pretty attach-callback: /xspeed/mcp/attach. The hub POSTs the signed
327 // nonce; we verify it and return this site's URL + token. Auth is the
328 // nonce itself (admin-minted, HMAC-signed), so no credential needed.
329 if ( ! empty( $wp->query_vars[ self::ATTACH_QUERY_VAR ] ) ) {
330 $body = json_decode( (string) file_get_contents( 'php://input' ), true );
331 $nonce = is_array( $body ) && isset( $body['nonce'] ) ? (string) $body['nonce'] : '';
332 $result = Mcp_Hub::verify_attach_nonce( $nonce );
333 header( 'Content-Type: application/json; charset=utf-8' );
334 header( 'Cache-Control: no-store' );
335 if ( null === $result ) {
336 status_header( 403 );
337 echo wp_json_encode( array( 'error' => 'invalid_or_expired_attach_request' ) );
338 } else {
339 status_header( 200 );
340 echo wp_json_encode( $result );
341 }
342 exit;
343 }
344
345 // Browser-facing OAuth consent page (cookie auth applies here).
346 if ( ! empty( $wp->query_vars[ self::AUTHORIZE_QUERY_VAR ] ) ) {
347 $this->handle_authorize_page();
348 return;
349 }
350
351 if ( empty( $wp->query_vars[ self::QUERY_VAR ] ) ) {
352 return;
353 }
354
355 $request = new \WP_REST_Request( 'POST', '/xspeed/v1/mcp' );
356 $request->set_header( 'content-type', 'application/json' );
357 // Carry the auth headers + raw body from the live PHP request.
358 foreach ( array( 'authorization', Mcp_Auth::TOKEN_HEADER ) as $h ) {
359 $val = self::server_header( $h );
360 if ( null !== $val ) {
361 $request->set_header( $h, $val );
362 }
363 }
364 // Token embedded in the URL path (/xspeed/mcp/<token>) — surface it
365 // as the standard token header so Mcp_Server validates it the same
366 // way. A header/Bearer token (if also sent) still takes precedence.
367 $path_token = isset( $wp->query_vars[ self::TOKEN_QUERY_VAR ] )
368 ? (string) $wp->query_vars[ self::TOKEN_QUERY_VAR ]
369 : '';
370 if ( '' !== $path_token && '' === (string) $request->get_header( Mcp_Auth::TOKEN_HEADER ) && '' === (string) $request->get_header( 'authorization' ) ) {
371 $request->set_header( Mcp_Auth::TOKEN_HEADER, $path_token );
372 }
373 $request->set_body( file_get_contents( 'php://input' ) );
374
375 $response = Mcp_Server::handle( $request );
376 $this->emit_json( $response );
377 }
378
379 // -- REST registration --
380
381 public function register_rest(): void {
382 // --- MCP JSON-RPC endpoint (fallback path via wp-json) -----------
383 // permission_callback is __return_true because Mcp_Server does its
384 // own token auth and must reply with a JSON-RPC 401, not a bare WP
385 // permission failure.
386 register_rest_route(
387 self::NS,
388 '/mcp',
389 array(
390 'methods' => 'POST',
391 'callback' => array( $this, 'rest_mcp' ),
392 'permission_callback' => '__return_true',
393 )
394 );
395
396 // --- Admin-only management routes (dashboard) --------------------
397 register_rest_route(
398 self::NS,
399 '/mcp/connection',
400 array(
401 'methods' => 'GET',
402 'callback' => array( $this, 'rest_connection' ),
403 'permission_callback' => array( $this, 'admin_permission' ),
404 )
405 );
406 register_rest_route(
407 self::NS,
408 '/mcp/activity',
409 array(
410 'methods' => 'GET',
411 'callback' => array( $this, 'rest_activity' ),
412 'permission_callback' => array( $this, 'admin_permission' ),
413 'args' => array(
414 'limit' => array(
415 'type' => 'integer',
416 'required' => false,
417 'default' => 50,
418 'description' => 'Maximum entries to return (newest first).',
419 ),
420 ),
421 )
422 );
423 register_rest_route(
424 self::NS,
425 '/mcp/activity/clear',
426 array(
427 'methods' => 'POST',
428 'callback' => array( $this, 'rest_activity_clear' ),
429 'permission_callback' => array( $this, 'admin_permission' ),
430 )
431 );
432 register_rest_route(
433 self::NS,
434 '/mcp/connect',
435 array(
436 'methods' => 'POST',
437 'callback' => array( $this, 'rest_connect' ),
438 'permission_callback' => array( $this, 'admin_permission' ),
439 'args' => array(
440 'read_only' => array(
441 'type' => 'boolean',
442 'required' => false,
443 'default' => false,
444 'description' => 'Grant read-only access (no purge/toggle/settings changes).',
445 ),
446 ),
447 )
448 );
449 register_rest_route(
450 self::NS,
451 '/mcp/rotate',
452 array(
453 'methods' => 'POST',
454 'callback' => array( $this, 'rest_rotate' ),
455 'permission_callback' => array( $this, 'admin_permission' ),
456 'args' => array(
457 'read_only' => array(
458 'type' => 'boolean',
459 'required' => false,
460 'description' => 'Optionally set read-only on the new token; omit to keep current scopes.',
461 ),
462 ),
463 )
464 );
465 register_rest_route(
466 self::NS,
467 '/mcp/access',
468 array(
469 'methods' => 'POST',
470 'callback' => array( $this, 'rest_access' ),
471 'permission_callback' => array( $this, 'admin_permission' ),
472 'args' => array(
473 'read_only' => array(
474 'type' => 'boolean',
475 'required' => true,
476 'description' => 'Switch the live connection to read-only (true) or read & write (false), keeping the same token.',
477 ),
478 ),
479 )
480 );
481 register_rest_route(
482 self::NS,
483 '/mcp/disconnect',
484 array(
485 'methods' => 'POST',
486 'callback' => array( $this, 'rest_disconnect' ),
487 'permission_callback' => array( $this, 'admin_permission' ),
488 )
489 );
490
491 // --- xSpeed Hub (multi-site) attach routes ------------------------
492 register_rest_route(
493 self::NS,
494 '/mcp/hub',
495 array(
496 'methods' => 'GET',
497 'callback' => array( $this, 'rest_hub_status' ),
498 'permission_callback' => array( $this, 'admin_permission' ),
499 )
500 );
501 register_rest_route(
502 self::NS,
503 '/mcp/hub/token',
504 array(
505 'methods' => 'POST',
506 'callback' => array( $this, 'rest_hub_token' ),
507 'permission_callback' => array( $this, 'admin_permission' ),
508 )
509 );
510 register_rest_route(
511 self::NS,
512 '/mcp/hub/attached',
513 array(
514 'methods' => 'POST',
515 'callback' => array( $this, 'rest_hub_attached' ),
516 'permission_callback' => array( $this, 'admin_permission' ),
517 'args' => array(
518 'account_email' => array(
519 'type' => 'string',
520 'required' => true,
521 'description' => 'The hub account email this site was attached to.',
522 ),
523 ),
524 )
525 );
526 register_rest_route(
527 self::NS,
528 '/mcp/hub/disconnect',
529 array(
530 'methods' => 'POST',
531 'callback' => array( $this, 'rest_hub_disconnect' ),
532 'permission_callback' => array( $this, 'admin_permission' ),
533 )
534 );
535 // OAuth-attach callback: the hub calls this with the signed nonce the
536 // plugin issued. Auth is the nonce itself (no pre-shared token), so
537 // permission_callback is open — the handler validates the nonce.
538 register_rest_route(
539 self::NS,
540 '/mcp/attach',
541 array(
542 'methods' => 'POST',
543 'callback' => array( $this, 'rest_hub_attach_callback' ),
544 'permission_callback' => '__return_true',
545 'args' => array(
546 'nonce' => array(
547 'type' => 'string',
548 'required' => true,
549 'description' => 'The signed attach nonce the plugin issued.',
550 ),
551 ),
552 )
553 );
554
555 // --- OAuth 2.1 authorization server (the "paste a URL only" path) -
556 // Discovery, dynamic client registration, and the token endpoint are
557 // all public (permission enforced inside): a client must reach them
558 // BEFORE it holds any credential. The authorize endpoint gates on a
559 // logged-in admin inside its handler (anonymous → wp-login redirect).
560 register_rest_route(
561 self::NS,
562 '/mcp/oauth/register',
563 array(
564 'methods' => 'POST',
565 'callback' => array( $this, 'rest_oauth_register' ),
566 'permission_callback' => '__return_true',
567 )
568 );
569 // NOTE: /authorize is deliberately NOT a REST route — it is served as a
570 // normal front-end page at /xspeed/authorize (see handle_authorize_page)
571 // so cookie auth works after the wp-login round-trip.
572 register_rest_route(
573 self::NS,
574 '/mcp/oauth/token',
575 array(
576 'methods' => 'POST',
577 'callback' => array( $this, 'rest_oauth_token' ),
578 'permission_callback' => '__return_true',
579 )
580 );
581
582 // --- MCP-token-only tool routes (optional hosted-broker path) ----
583 $tool_perm = array( Mcp_Auth::class, 'permission' );
584 register_rest_route(
585 self::NS,
586 // [a-z0-9_-]+ — the HYPHEN is the one that matters, not the digit.
587 // Generated tool names carry their module slug verbatim, and 33 of
588 // the 92 in the catalog have a hyphenated slug
589 // (xspeed_cache-404_status, xspeed_migration-pro_apply,
590 // xspeed_smart-predict_status …). Every one of those returned
591 // rest_no_route through the broker path. The earlier widening to
592 // [a-z0-9_]+ un-blocked nothing: the only digit-bearing name is
593 // cache-404, whose problem was the hyphen. (QA on #158) */
594 '/mcp/tool/(?P<tool>[a-z0-9_-]+)',
595 array(
596 array(
597 'methods' => 'GET',
598 'callback' => array( $this, 'rest_tool' ),
599 'permission_callback' => $tool_perm,
600 ),
601 array(
602 'methods' => 'POST',
603 'callback' => array( $this, 'rest_tool' ),
604 'permission_callback' => $tool_perm,
605 ),
606 )
607 );
608 }
609
610 /**
611 * Capability gate for the admin-only management routes.
612 *
613 * @return bool
614 */
615 public function admin_permission(): bool {
616 return current_user_can( 'manage_options' );
617 }
618
619 // -- Handlers ----------------------------------------------------------
620
621 /**
622 * MCP JSON-RPC over the wp-json fallback path.
623 *
624 * @param \WP_REST_Request $request Incoming request.
625 * @return \WP_REST_Response
626 */
627 public function rest_mcp( \WP_REST_Request $request ) {
628 $response = Mcp_Server::handle( $request );
629 // Advertise the MCP protocol version on the wp-json transport too, so
630 // both endpoints behave identically to a strict Streamable-HTTP client.
631 $response->header( 'MCP-Protocol-Version', Mcp_Server::PROTOCOL_VERSION );
632 return $response;
633 }
634
635 /**
636 * GET /mcp/connection — pairing status for the dashboard.
637 *
638 * @param \WP_REST_Request $request Unused.
639 * @return \WP_REST_Response
640 */
641 public function rest_connection( \WP_REST_Request $request ) {
642 unset( $request );
643 return rest_ensure_response( Mcp_Pairing::public_status() );
644 }
645
646 /**
647 * GET /mcp/activity — the audit trail of AI tool calls.
648 *
649 * @param \WP_REST_Request $request Carries the optional limit.
650 * @return \WP_REST_Response|\WP_Error
651 */
652 public function rest_activity( \WP_REST_Request $request ) {
653 $limit = (int) $request->get_param( 'limit' );
654
655 return rest_ensure_response(
656 array(
657 'entries' => Mcp_Activity_Log::entries( $limit > 0 ? $limit : 50 ),
658 'summary' => Mcp_Activity_Log::summary(),
659 )
660 );
661 }
662
663 /**
664 * POST /mcp/activity/clear — wipe the audit trail.
665 *
666 * @param \WP_REST_Request $request Unused.
667 * @return \WP_REST_Response|\WP_Error
668 */
669 public function rest_activity_clear( \WP_REST_Request $request ) {
670 unset( $request );
671 $cleared = Mcp_Activity_Log::clear();
672
673 return rest_ensure_response(
674 array(
675 'cleared' => $cleared,
676 'entries' => Mcp_Activity_Log::entries(),
677 'summary' => Mcp_Activity_Log::summary(),
678 )
679 );
680 }
681
682 /**
683 * POST /mcp/connect — mint a connection token.
684 *
685 * @param \WP_REST_Request $request Unused.
686 * @return \WP_REST_Response|\WP_Error
687 */
688 public function rest_connect( \WP_REST_Request $request ) {
689 $read_only = (bool) $request->get_param( 'read_only' );
690 $result = Mcp_Pairing::connect( $read_only );
691 if ( is_wp_error( $result ) ) {
692 return $result;
693 }
694 return rest_ensure_response( $result );
695 }
696
697 /**
698 * POST /mcp/rotate — mint a fresh token, invalidating the old one.
699 *
700 * @param \WP_REST_Request $request Carries optional read_only.
701 * @return \WP_REST_Response
702 */
703 public function rest_rotate( \WP_REST_Request $request ) {
704 $read_only = null;
705 if ( null !== $request->get_param( 'read_only' ) ) {
706 $read_only = (bool) $request->get_param( 'read_only' );
707 }
708 return rest_ensure_response( Mcp_Pairing::rotate( $read_only ) );
709 }
710
711 /**
712 * POST /mcp/access — change the live connection's read-only state WITHOUT
713 * minting a new token (the paired client keeps working; only its allowed
714 * tools change). This is what the dashboard's read-only toggle calls.
715 *
716 * @param \WP_REST_Request $request Carries the required read_only bool.
717 * @return \WP_REST_Response|\WP_Error
718 */
719 public function rest_access( \WP_REST_Request $request ) {
720 $read_only = (bool) $request->get_param( 'read_only' );
721 $result = Mcp_Pairing::set_read_only( $read_only );
722 if ( is_wp_error( $result ) ) {
723 return $result;
724 }
725 return rest_ensure_response( $result );
726 }
727
728 /**
729 * POST /mcp/disconnect — revoke the connection token.
730 *
731 * @param \WP_REST_Request $request Unused.
732 * @return \WP_REST_Response
733 */
734 public function rest_disconnect( \WP_REST_Request $request ) {
735 unset( $request );
736 return rest_ensure_response( Mcp_Pairing::disconnect() );
737 }
738
739 // -- xSpeed Hub (multi-site) handlers ----------------------------------
740
741 /**
742 * GET /mcp/hub — hub-link status + the Method-1 paste-in values.
743 *
744 * @param \WP_REST_Request $request Unused.
745 * @return \WP_REST_Response
746 */
747 public function rest_hub_status( \WP_REST_Request $request ) {
748 // Self-heal from the Hub (source of truth) so the connected badge is
749 // reliable even if the attach callback never fired. Force a fresh check
750 // when the panel asks via the X-XSpeed-Reconcile header (e.g. the admin
751 // returned to the tab after connecting).
752 $force = '1' === (string) $request->get_header( 'x_xspeed_reconcile' );
753 Mcp_Hub::reconcile_with_hub( $force );
754 return rest_ensure_response( Mcp_Hub::public_status() );
755 }
756
757 /**
758 * POST /mcp/hub/token — ensure a site_token exists and return the
759 * paste-in values (this site's URL + token) for the hub's Add-site form.
760 *
761 * @param \WP_REST_Request $request Unused.
762 * @return \WP_REST_Response
763 */
764 public function rest_hub_token( \WP_REST_Request $request ) {
765 unset( $request );
766 return rest_ensure_response( Mcp_Hub::generate_token() );
767 }
768
769 /**
770 * POST /mcp/hub/attached — record which hub account this site is
771 * attached to (bookkeeping for the panel's status line).
772 *
773 * @param \WP_REST_Request $request Carries account_email.
774 * @return \WP_REST_Response
775 */
776 public function rest_hub_attached( \WP_REST_Request $request ) {
777 $email = sanitize_email( (string) $request->get_param( 'account_email' ) );
778 return rest_ensure_response( Mcp_Hub::mark_attached( $email ) );
779 }
780
781 /**
782 * POST /mcp/hub/disconnect — clear the local hub-link bookkeeping.
783 *
784 * @param \WP_REST_Request $request Unused.
785 * @return \WP_REST_Response
786 */
787 public function rest_hub_disconnect( \WP_REST_Request $request ) {
788 unset( $request );
789 return rest_ensure_response( Mcp_Hub::disconnect() );
790 }
791
792 /**
793 * POST /mcp/attach — the OAuth-attach callback. The hub presents the
794 * signed nonce the plugin issued; on success we return this site's URL +
795 * token so the hub can record it. Nonce is the auth (admin-minted,
796 * HMAC-signed, time-bound), so no pre-shared token is required.
797 *
798 * @param \WP_REST_Request $request Carries the nonce.
799 * @return \WP_REST_Response|\WP_Error
800 */
801 public function rest_hub_attach_callback( \WP_REST_Request $request ) {
802 $nonce = (string) $request->get_param( 'nonce' );
803 $result = Mcp_Hub::verify_attach_nonce( $nonce );
804 if ( null === $result ) {
805 return new \WP_Error(
806 'xspeed_attach_invalid',
807 __( 'Invalid or expired attach request.', 'xspeed' ),
808 array( 'status' => 403 )
809 );
810 }
811 // A valid nonce proves this is a real hub-initiated attach, so record it
812 // now — the hub passes the account email so the panel can show
813 // "Connected via <email>". The nonce carries the minting admin's user
814 // id (no WP session exists in this server-to-server call), so the state
815 // is recorded PER-USER — each admin sees their own connection.
816 $account_email = sanitize_email( (string) $request->get_param( 'account_email' ) );
817 $user_id = isset( $result['user_id'] ) ? (int) $result['user_id'] : 0;
818 Mcp_Hub::mark_attached( $account_email, $user_id ?: null );
819
820 // The hub only needs the credential; don't leak the internal user id.
821 unset( $result['user_id'] );
822 return rest_ensure_response( $result );
823 }
824
825 // -- OAuth 2.1 handlers ------------------------------------------------
826
827 /**
828 * POST /mcp/oauth/register — RFC 7591 dynamic client registration.
829 *
830 * @param \WP_REST_Request $request JSON body with redirect_uris.
831 * @return \WP_REST_Response|\WP_Error
832 */
833 public function rest_oauth_register( \WP_REST_Request $request ) {
834 $body = $request->get_json_params();
835 if ( ! is_array( $body ) ) {
836 $body = array();
837 }
838 $result = Mcp_OAuth::register_client( $body );
839 if ( is_wp_error( $result ) ) {
840 return $result;
841 }
842 return new \WP_REST_Response( $result, 201 );
843 }
844
845 /**
846 * The browser-facing OAuth authorize page (served at /xspeed/authorize via
847 * a rewrite, NOT the REST API — see AUTHORIZE_QUERY_VAR). Reads request
848 * params from the superglobals because this is a normal front-end request
849 * where cookie auth populates is_user_logged_in().
850 *
851 * GET renders the consent screen (requires a logged-in admin; anonymous
852 * users go to wp-login and return here). POST is the nonce-checked consent
853 * submission: Approve issues a code and 302s to the client's redirect_uri;
854 * Deny 302s back with error=access_denied. Always emits its own response
855 * (HTML page or redirect) and exits.
856 */
857 public function handle_authorize_page(): void {
858 $is_post = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) );
859 // Params come from GET on the consent link and POST on the form submit.
860 // Nonce is verified below before any POST value is acted on.
861 // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing
862 $source = $is_post ? $_POST : $_GET;
863 // phpcs:enable
864 $params = array();
865 foreach ( array( 'client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'approve', 'deny', '_xspeed_oauth_nonce' ) as $k ) {
866 $params[ $k ] = isset( $source[ $k ] ) ? sanitize_text_field( wp_unslash( $source[ $k ] ) ) : '';
867 }
868
869 // Validate the OAuth params before touching the session.
870 $req = Mcp_OAuth::validate_authorize_request( $params );
871 if ( is_wp_error( $req ) ) {
872 $data = $req->get_error_data();
873 $redirectable = is_array( $data ) && ! empty( $data['redirectable'] );
874 // Only redirect the error back when redirect_uri is verified valid;
875 // otherwise show a page (never bounce to an unverified URL).
876 if ( $redirectable && '' !== $params['redirect_uri'] ) {
877 $this->redirect_error( $params['redirect_uri'], $req->get_error_code(), $req->get_error_message(), $params['state'] );
878 }
879 $this->emit_oauth_error_page( $req->get_error_message() );
880 }
881
882 // Require a logged-in admin. Anonymous → wp-login, back to this URL.
883 if ( ! is_user_logged_in() ) {
884 $this->redirect_to_login();
885 }
886 if ( ! current_user_can( 'manage_options' ) ) {
887 $this->emit_oauth_error_page(
888 __( 'You must be an administrator to authorize an AI agent to control this site.', 'xspeed' )
889 );
890 }
891
892 // POST = consent form submitted.
893 if ( $is_post ) {
894 if ( ! wp_verify_nonce( $params['_xspeed_oauth_nonce'], 'xspeed_oauth_consent' ) ) {
895 $this->emit_oauth_error_page( __( 'Security check failed. Please try connecting again.', 'xspeed' ) );
896 }
897 if ( '' === $params['approve'] ) {
898 $this->redirect_error( $req['redirect_uri'], 'access_denied', 'The user denied the request.', $req['state'] );
899 }
900 $code = Mcp_OAuth::issue_code( $req, get_current_user_id() );
901 $this->redirect_success( $req['redirect_uri'], $code, $req['state'] );
902 }
903
904 // GET = render the consent screen.
905 $this->emit_consent_screen( $req );
906 }
907
908 /**
909 * POST /mcp/oauth/token — exchange a code (or refresh token) for tokens.
910 *
911 * @param \WP_REST_Request $request Form-encoded or JSON token request.
912 * @return \WP_REST_Response
913 */
914 public function rest_oauth_token( \WP_REST_Request $request ) {
915 // Token requests are application/x-www-form-urlencoded per OAuth, but
916 // accept JSON too. get_body_params() covers the form case.
917 $body = $request->get_body_params();
918 if ( empty( $body ) ) {
919 $json = $request->get_json_params();
920 $body = is_array( $json ) ? $json : array();
921 }
922 $body = array_map( 'strval', $body );
923
924 $result = Mcp_OAuth::exchange_token( $body );
925 if ( is_wp_error( $result ) ) {
926 $data = $result->get_error_data();
927 $response = new \WP_REST_Response(
928 array(
929 'error' => isset( $data['error'] ) ? $data['error'] : 'invalid_request',
930 'error_description' => isset( $data['error_description'] ) ? $data['error_description'] : $result->get_error_message(),
931 ),
932 isset( $data['status'] ) ? (int) $data['status'] : 400
933 );
934 $response->header( 'Cache-Control', 'no-store' );
935 return $response;
936 }
937 $response = new \WP_REST_Response( $result, 200 );
938 $response->header( 'Cache-Control', 'no-store' );
939 $response->header( 'Pragma', 'no-cache' );
940 return $response;
941 }
942
943 /**
944 * Token-authenticated tool route for the hosted broker. Maps a broker
945 * tool call (e.g. GET /mcp/tool/get_cache_status) onto the shared
946 * Mcp_Tools catalog, so the broker path and the JSON-RPC path never
947 * drift. GET params + JSON body both feed the tool's arguments.
948 */
949 public function rest_tool( \WP_REST_Request $request ) {
950 $tool = (string) $request->get_param( 'tool' );
951 $args = $request->get_json_params();
952 if ( ! is_array( $args ) ) {
953 $args = array();
954 }
955 // Merge query params (e.g. ?module=minify) so GET tools work too.
956 foreach ( $request->get_query_params() as $k => $v ) {
957 if ( 'tool' !== $k && ! array_key_exists( $k, $args ) ) {
958 $args[ $k ] = $v;
959 }
960 }
961
962 Mcp_Tools::set_channel( 'broker' );
963 $result = Mcp_Tools::invoke( $tool, $args );
964 if ( is_wp_error( $result ) ) {
965 return $result;
966 }
967 return rest_ensure_response( $result );
968 }
969
970 // -- Helpers --
971
972 // -- OAuth browser-response helpers ------------------------------------
973
974 /** The absolute URL of the current authorize request (for login return). */
975 private function current_authorize_url(): string {
976 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- reconstructing the current URL for a login round-trip; escaped at use.
977 $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
978 return home_url( $uri );
979 }
980
981 /** Send an anonymous visitor to wp-login, returning to this authorize URL. */
982 private function redirect_to_login(): void {
983 wp_safe_redirect( wp_login_url( $this->current_authorize_url() ) );
984 exit;
985 }
986
987 /** 302 back to the client with the authorization code (+ state). */
988 private function redirect_success( string $redirect_uri, string $code, string $state ): void {
989 $args = array( 'code' => $code );
990 if ( '' !== $state ) {
991 $args['state'] = $state;
992 }
993 // Not wp_safe_redirect: redirect_uri is a client-registered off-site
994 // callback, already validated against the client's registered set.
995 wp_redirect( add_query_arg( $args, $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
996 exit;
997 }
998
999 /** 302 back to the client with an OAuth error (+ state). */
1000 private function redirect_error( string $redirect_uri, string $error, string $description, string $state ): void {
1001 $args = array(
1002 'error' => $error,
1003 'error_description' => $description,
1004 );
1005 if ( '' !== $state ) {
1006 $args['state'] = $state;
1007 }
1008 wp_redirect( add_query_arg( array_map( 'rawurlencode', $args ), $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
1009 exit;
1010 }
1011
1012 /**
1013 * Render the consent screen. Minimal self-contained HTML (no admin
1014 * chrome — this is a client-facing OAuth page). Approve/Deny post back
1015 * to the same authorize URL with a nonce.
1016 *
1017 * @param array<string,string> $req Validated authorize params.
1018 */
1019 private function emit_consent_screen( array $req ): void {
1020 $read_only = Mcp_OAuth::scope_is_read_only( $req['scope'] );
1021 $access = $read_only
1022 ? __( 'Read-only — inspect cache status and settings.', 'xspeed' )
1023 : __( 'Read & write — purge caches, toggle caching, and change settings.', 'xspeed' );
1024 $client = '' !== $req['client_name'] ? $req['client_name'] : __( 'An AI agent', 'xspeed' );
1025 $action_url = Mcp_OAuth::authorize_url();
1026 $nonce = wp_create_nonce( 'xspeed_oauth_consent' );
1027 $user = wp_get_current_user();
1028
1029 // Preserve every OAuth param so the POST re-validates identically.
1030 $hidden = '';
1031 foreach ( array( 'client_id', 'redirect_uri', 'code_challenge', 'scope', 'state' ) as $k ) {
1032 $val = 'scope' === $k ? $req['scope'] : ( $req[ $k ] ?? '' );
1033 $hidden .= sprintf( '<input type="hidden" name="%s" value="%s" />', esc_attr( $k ), esc_attr( (string) $val ) );
1034 }
1035 // code_challenge_method + response_type are re-asserted for validation.
1036 $hidden .= '<input type="hidden" name="code_challenge_method" value="S256" />';
1037 $hidden .= '<input type="hidden" name="response_type" value="code" />';
1038
1039 status_header( 200 );
1040 header( 'Content-Type: text/html; charset=utf-8' );
1041 header( 'Cache-Control: no-store' );
1042
1043 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>';
1044 echo '<style>'
1045 . '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}'
1046 . '.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)}'
1047 . 'h1{font-size:20px;margin:0 0 4px}.sub{color:#94a3b8;font-size:13px;margin:0 0 24px}'
1048 . '.row{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid #334155;font-size:13px}'
1049 . '.row span:first-child{color:#94a3b8}.row span:last-child{font-weight:600;text-align:right;max-width:60%;word-break:break-word}'
1050 . '.actions{display:flex;gap:12px;margin-top:24px}'
1051 . 'button{flex:1;padding:12px;border-radius:10px;border:0;font-size:14px;font-weight:600;cursor:pointer}'
1052 . '.approve{background:#f5cd47;color:#1b2533}.deny{background:transparent;color:#94a3b8;border:1px solid #334155}'
1053 . '</style></head><body><div class="card">';
1054 echo '<h1>' . esc_html__( 'Connect to xSpeed', 'xspeed' ) . '</h1>';
1055 /* translators: %s: AI client name. */
1056 echo '<p class="sub">' . esc_html( sprintf( __( '%s wants to manage the cache on this site.', 'xspeed' ), $client ) ) . '</p>';
1057 echo '<div class="row"><span>' . esc_html__( 'Site', 'xspeed' ) . '</span><span>' . esc_html( wp_parse_url( home_url(), PHP_URL_HOST ) ) . '</span></div>';
1058 echo '<div class="row"><span>' . esc_html__( 'Signed in as', 'xspeed' ) . '</span><span>' . esc_html( $user->user_login ) . '</span></div>';
1059 echo '<div class="row"><span>' . esc_html__( 'Access', 'xspeed' ) . '</span><span>' . esc_html( $access ) . '</span></div>';
1060 echo '<form method="post" action="' . esc_url( $action_url ) . '">';
1061 echo $hidden; // phpcs:ignore WordPress.Security.EscapeOutput -- built from esc_attr() above.
1062 echo '<input type="hidden" name="_xspeed_oauth_nonce" value="' . esc_attr( $nonce ) . '" />';
1063 echo '<div class="actions">';
1064 echo '<button class="deny" name="deny" value="1">' . esc_html__( 'Deny', 'xspeed' ) . '</button>';
1065 echo '<button class="approve" name="approve" value="1">' . esc_html__( 'Approve', 'xspeed' ) . '</button>';
1066 echo '</div></form></div></body></html>';
1067 exit;
1068 }
1069
1070 /** Render a standalone OAuth error page (no redirect). */
1071 private function emit_oauth_error_page( string $message ): void {
1072 status_header( 400 );
1073 header( 'Content-Type: text/html; charset=utf-8' );
1074 header( 'Cache-Control: no-store' );
1075 echo '<!doctype html><html><head><meta charset="utf-8"><title>' . esc_html__( 'Authorization error', 'xspeed' ) . '</title>';
1076 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}'
1077 . '.card{background:#1e293b;border:1px solid #334155;border-radius:16px;max-width:440px;padding:32px;text-align:center}</style></head><body>';
1078 echo '<div class="card"><h1>' . esc_html__( 'Could not authorize', 'xspeed' ) . '</h1><p>' . esc_html( $message ) . '</p></div></body></html>';
1079 exit;
1080 }
1081
1082 /** Read an inbound HTTP header from $_SERVER (for the pretty path). */
1083 private static function server_header( string $name ): ?string {
1084 $key = 'HTTP_' . strtoupper( str_replace( '-', '_', $name ) );
1085 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- token compared constant-time downstream; raw header needed verbatim.
1086 return isset( $_SERVER[ $key ] ) ? wp_unslash( $_SERVER[ $key ] ) : null;
1087 }
1088
1089 /** Emit a WP_REST_Response as a JSON HTTP response and stop. */
1090 private function emit_json( \WP_REST_Response $response ): void {
1091 status_header( $response->get_status() );
1092 // MCP Streamable HTTP: advertise the protocol version we speak so a
1093 // strict client can pin it. We answer JSON (a spec-permitted response
1094 // type); we never open an SSE stream, so no session header is needed.
1095 header( 'MCP-Protocol-Version: ' . Mcp_Server::PROTOCOL_VERSION );
1096 // Forward any headers the handler set (notably WWW-Authenticate on a
1097 // 401, which drives the OAuth discovery flow). rest_do_request applies
1098 // these automatically; the pretty-endpoint path must do it by hand.
1099 foreach ( $response->get_headers() as $name => $value ) {
1100 header( $name . ': ' . $value );
1101 }
1102 $data = $response->get_data();
1103 if ( null !== $data ) {
1104 header( 'Content-Type: application/json; charset=utf-8' );
1105 echo wp_json_encode( $data );
1106 }
1107 exit;
1108 }
1109
1110 // -- WP-CLI mirror --
1111
1112 public function cli_commands(): array {
1113 return array(
1114 array(
1115 'name' => 'xspeed mcp status',
1116 'callback' => array( $this, 'cli_status' ),
1117 'shortdesc' => 'Show MCP connection status and the paste-in endpoint URL.',
1118 'synopsis' => array(),
1119 ),
1120 array(
1121 'name' => 'xspeed mcp activity',
1122 'callback' => array( $this, 'cli_activity' ),
1123 'shortdesc' => 'List recent MCP tool calls (the AI audit trail).',
1124 'synopsis' => array(
1125 array(
1126 'name' => 'limit',
1127 'type' => 'assoc',
1128 'optional' => true,
1129 'description' => 'Maximum entries to show (default 20).',
1130 ),
1131 array(
1132 'name' => 'clear',
1133 'type' => 'flag',
1134 'optional' => true,
1135 'description' => 'Wipe the audit trail instead of listing it.',
1136 ),
1137 ),
1138 ),
1139 array(
1140 'name' => 'xspeed mcp connect',
1141 'callback' => array( $this, 'cli_connect' ),
1142 'shortdesc' => 'Generate a connection token for this site\'s MCP endpoint.',
1143 'synopsis' => array(
1144 array(
1145 'name' => 'read-only',
1146 'type' => 'flag',
1147 'optional' => true,
1148 'description' => 'Grant read-only access (no purge/toggle/settings changes).',
1149 ),
1150 ),
1151 ),
1152 array(
1153 'name' => 'xspeed mcp rotate',
1154 'callback' => array( $this, 'cli_rotate' ),
1155 'shortdesc' => 'Mint a fresh MCP token, immediately invalidating the previous one.',
1156 'synopsis' => array(
1157 array(
1158 'name' => 'read-only',
1159 'type' => 'flag',
1160 'optional' => true,
1161 'description' => 'Make the new token read-only.',
1162 ),
1163 ),
1164 ),
1165 array(
1166 'name' => 'xspeed mcp disconnect',
1167 'callback' => array( $this, 'cli_disconnect' ),
1168 'shortdesc' => 'Revoke this site\'s MCP connection token.',
1169 'synopsis' => array(),
1170 ),
1171 );
1172 }
1173
1174 /**
1175 * `wp xspeed mcp status` — print connection status + endpoint URL.
1176 *
1177 * @param array $args Positional args (unused).
1178 * @param array $assoc Associative args (unused).
1179 */
1180 public function cli_status( array $args, array $assoc ): void {
1181 unset( $args, $assoc );
1182 $s = Mcp_Pairing::public_status();
1183 \WP_CLI::log( sprintf( '%-18s %s', 'connected', $s['connected'] ? 'yes' : 'no' ) );
1184 if ( $s['connected'] ) {
1185 \WP_CLI::log( sprintf( '%-18s %s', 'access', $s['read_only'] ? 'read-only' : 'read-write' ) );
1186 \WP_CLI::log( sprintf( '%-18s %s', 'connect_url', $s['connect_url'] ) );
1187 \WP_CLI::log( sprintf( '%-18s %s', 'scopes', implode( ',', $s['scopes'] ) ) );
1188 } else {
1189 \WP_CLI::log( sprintf( '%-18s %s', 'mcp_endpoint', Mcp_Pairing::site_endpoint() ) );
1190 }
1191 }
1192
1193 /**
1194 * `wp xspeed mcp activity` — read (or clear) the AI audit trail.
1195 *
1196 * @param array $args Positional args (unused).
1197 * @param array $assoc --limit=<n>, --clear.
1198 */
1199 public function cli_activity( array $args, array $assoc ): void {
1200 unset( $args );
1201
1202 if ( ! empty( $assoc['clear'] ) ) {
1203 if ( ! Mcp_Activity_Log::clear() ) {
1204 // Reached via MCP run_command — the assistant is asking to
1205 // erase the record of its own calls. Mcp_Activity_Log::clear()
1206 // declines and logs the attempt; say so plainly.
1207 \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.' );
1208 return;
1209 }
1210 \WP_CLI::success( 'MCP activity log cleared.' );
1211 return;
1212 }
1213
1214 $limit = isset( $assoc['limit'] ) ? (int) $assoc['limit'] : 20;
1215 $summary = Mcp_Activity_Log::summary();
1216 $entries = Mcp_Activity_Log::entries( $limit > 0 ? $limit : 20 );
1217
1218 \WP_CLI::log( sprintf( '%-18s %d', 'total_calls', $summary['total'] ) );
1219 \WP_CLI::log( sprintf( '%-18s %d', 'failed', $summary['failed'] ) );
1220 \WP_CLI::log( sprintf( '%-18s %s', 'top_tool', '' === $summary['top_tool'] ? '-' : $summary['top_tool'] ) );
1221
1222 if ( empty( $entries ) ) {
1223 \WP_CLI::log( '' );
1224 \WP_CLI::log( 'No MCP tool calls recorded yet.' );
1225 return;
1226 }
1227
1228 \WP_CLI::log( '' );
1229 foreach ( $entries as $entry ) {
1230 \WP_CLI::log(
1231 sprintf(
1232 '%s %-22s %-5s %-6s %s%s',
1233 gmdate( 'Y-m-d H:i:s', $entry['ts'] ),
1234 $entry['tool'],
1235 $entry['scope'],
1236 $entry['ok'] ? 'ok' : 'FAIL',
1237 $entry['args'],
1238 '' === $entry['error'] ? '' : '' . $entry['error']
1239 )
1240 );
1241 }
1242 }
1243
1244 /**
1245 * `wp xspeed mcp connect` — mint a token and print the paste-in URL.
1246 *
1247 * @param array $args Positional args (unused).
1248 * @param array $assoc Associative args (unused).
1249 */
1250 public function cli_connect( array $args, array $assoc ): void {
1251 unset( $args );
1252 $read_only = ! empty( $assoc['read-only'] );
1253 $result = Mcp_Pairing::connect( $read_only );
1254 if ( is_wp_error( $result ) ) {
1255 \WP_CLI::error( $result->get_error_message() );
1256 return;
1257 }
1258 \WP_CLI::success( 'Connected' . ( Mcp_Pairing::is_read_only() ? ' (read-only).' : '.' ) . ' Paste this single URL into your AI client:' );
1259 \WP_CLI::log( ' ' . Mcp_Pairing::connect_url() );
1260 \WP_CLI::log( '' );
1261 \WP_CLI::log( 'Or, header-based (token stays out of the URL):' );
1262 \WP_CLI::log( ' ' . Mcp_Pairing::config_snippets()['cli'] );
1263 }
1264
1265 /**
1266 * `wp xspeed mcp rotate` — mint a new token, revoking the old one.
1267 *
1268 * @param array $args Positional args (unused).
1269 * @param array $assoc Associative args ({ read-only?:flag }).
1270 */
1271 public function cli_rotate( array $args, array $assoc ): void {
1272 unset( $args );
1273 $read_only = array_key_exists( 'read-only', $assoc ) ? ! empty( $assoc['read-only'] ) : null;
1274 Mcp_Pairing::rotate( $read_only );
1275 \WP_CLI::success( 'Rotated. The previous token is now invalid. New paste-in URL:' );
1276 \WP_CLI::log( ' ' . Mcp_Pairing::connect_url() );
1277 }
1278
1279 /**
1280 * `wp xspeed mcp disconnect` — revoke the connection token.
1281 *
1282 * @param array $args Positional args (unused).
1283 * @param array $assoc Associative args (unused).
1284 */
1285 public function cli_disconnect( array $args, array $assoc ): void {
1286 unset( $args, $assoc );
1287 Mcp_Pairing::disconnect();
1288 \WP_CLI::success( 'Disconnected and revoked the MCP token.' );
1289 }
1290 }
1291