PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.4
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 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.4, at includes/modules/Mcp/McpModule.php

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