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

1,259 lines 47.3 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 // [a-z0-9_-]+ — the HYPHEN is the one that matters, not the digit.
555 // Generated tool names carry their module slug verbatim, and 33 of
556 // the 92 in the catalog have a hyphenated slug
557 // (xspeed_cache-404_status, xspeed_migration-pro_apply,
558 // xspeed_smart-predict_status …). Every one of those returned
559 // rest_no_route through the broker path. The earlier widening to
560 // [a-z0-9_]+ un-blocked nothing: the only digit-bearing name is
561 // cache-404, whose problem was the hyphen. (QA on #158) */
562 '/mcp/tool/(?P<tool>[a-z0-9_-]+)',
563 array(
564 array(
565 'methods' => 'GET',
566 'callback' => array( $this, 'rest_tool' ),
567 'permission_callback' => $tool_perm,
568 ),
569 array(
570 'methods' => 'POST',
571 'callback' => array( $this, 'rest_tool' ),
572 'permission_callback' => $tool_perm,
573 ),
574 )
575 );
576 }
577
578 /**
579 * Capability gate for the admin-only management routes.
580 *
581 * @return bool
582 */
583 public function admin_permission(): bool {
584 return current_user_can( 'manage_options' );
585 }
586
587 // -- Handlers ----------------------------------------------------------
588
589 /**
590 * MCP JSON-RPC over the wp-json fallback path.
591 *
592 * @param \WP_REST_Request $request Incoming request.
593 * @return \WP_REST_Response
594 */
595 public function rest_mcp( \WP_REST_Request $request ) {
596 $response = Mcp_Server::handle( $request );
597 // Advertise the MCP protocol version on the wp-json transport too, so
598 // both endpoints behave identically to a strict Streamable-HTTP client.
599 $response->header( 'MCP-Protocol-Version', Mcp_Server::PROTOCOL_VERSION );
600 return $response;
601 }
602
603 /**
604 * GET /mcp/connection — pairing status for the dashboard.
605 *
606 * @param \WP_REST_Request $request Unused.
607 * @return \WP_REST_Response
608 */
609 public function rest_connection( \WP_REST_Request $request ) {
610 unset( $request );
611 return rest_ensure_response( Mcp_Pairing::public_status() );
612 }
613
614 /**
615 * GET /mcp/activity — the audit trail of AI tool calls.
616 *
617 * @param \WP_REST_Request $request Carries the optional limit.
618 * @return \WP_REST_Response|\WP_Error
619 */
620 public function rest_activity( \WP_REST_Request $request ) {
621 $limit = (int) $request->get_param( 'limit' );
622
623 return rest_ensure_response(
624 array(
625 'entries' => Mcp_Activity_Log::entries( $limit > 0 ? $limit : 50 ),
626 'summary' => Mcp_Activity_Log::summary(),
627 )
628 );
629 }
630
631 /**
632 * POST /mcp/activity/clear — wipe the audit trail.
633 *
634 * @param \WP_REST_Request $request Unused.
635 * @return \WP_REST_Response|\WP_Error
636 */
637 public function rest_activity_clear( \WP_REST_Request $request ) {
638 unset( $request );
639 $cleared = Mcp_Activity_Log::clear();
640
641 return rest_ensure_response(
642 array(
643 'cleared' => $cleared,
644 'entries' => Mcp_Activity_Log::entries(),
645 'summary' => Mcp_Activity_Log::summary(),
646 )
647 );
648 }
649
650 /**
651 * POST /mcp/connect — mint a connection token.
652 *
653 * @param \WP_REST_Request $request Unused.
654 * @return \WP_REST_Response|\WP_Error
655 */
656 public function rest_connect( \WP_REST_Request $request ) {
657 $read_only = (bool) $request->get_param( 'read_only' );
658 $result = Mcp_Pairing::connect( $read_only );
659 if ( is_wp_error( $result ) ) {
660 return $result;
661 }
662 return rest_ensure_response( $result );
663 }
664
665 /**
666 * POST /mcp/rotate — mint a fresh token, invalidating the old one.
667 *
668 * @param \WP_REST_Request $request Carries optional read_only.
669 * @return \WP_REST_Response
670 */
671 public function rest_rotate( \WP_REST_Request $request ) {
672 $read_only = null;
673 if ( null !== $request->get_param( 'read_only' ) ) {
674 $read_only = (bool) $request->get_param( 'read_only' );
675 }
676 return rest_ensure_response( Mcp_Pairing::rotate( $read_only ) );
677 }
678
679 /**
680 * POST /mcp/access — change the live connection's read-only state WITHOUT
681 * minting a new token (the paired client keeps working; only its allowed
682 * tools change). This is what the dashboard's read-only toggle calls.
683 *
684 * @param \WP_REST_Request $request Carries the required read_only bool.
685 * @return \WP_REST_Response|\WP_Error
686 */
687 public function rest_access( \WP_REST_Request $request ) {
688 $read_only = (bool) $request->get_param( 'read_only' );
689 $result = Mcp_Pairing::set_read_only( $read_only );
690 if ( is_wp_error( $result ) ) {
691 return $result;
692 }
693 return rest_ensure_response( $result );
694 }
695
696 /**
697 * POST /mcp/disconnect — revoke the connection token.
698 *
699 * @param \WP_REST_Request $request Unused.
700 * @return \WP_REST_Response
701 */
702 public function rest_disconnect( \WP_REST_Request $request ) {
703 unset( $request );
704 return rest_ensure_response( Mcp_Pairing::disconnect() );
705 }
706
707 // -- xSpeed Hub (multi-site) handlers ----------------------------------
708
709 /**
710 * GET /mcp/hub — hub-link status + the Method-1 paste-in values.
711 *
712 * @param \WP_REST_Request $request Unused.
713 * @return \WP_REST_Response
714 */
715 public function rest_hub_status( \WP_REST_Request $request ) {
716 // Self-heal from the Hub (source of truth) so the connected badge is
717 // reliable even if the attach callback never fired. Force a fresh check
718 // when the panel asks via the X-XSpeed-Reconcile header (e.g. the admin
719 // returned to the tab after connecting).
720 $force = '1' === (string) $request->get_header( 'x_xspeed_reconcile' );
721 Mcp_Hub::reconcile_with_hub( $force );
722 return rest_ensure_response( Mcp_Hub::public_status() );
723 }
724
725 /**
726 * POST /mcp/hub/token — ensure a site_token exists and return the
727 * paste-in values (this site's URL + token) for the hub's Add-site form.
728 *
729 * @param \WP_REST_Request $request Unused.
730 * @return \WP_REST_Response
731 */
732 public function rest_hub_token( \WP_REST_Request $request ) {
733 unset( $request );
734 return rest_ensure_response( Mcp_Hub::generate_token() );
735 }
736
737 /**
738 * POST /mcp/hub/attached — record which hub account this site is
739 * attached to (bookkeeping for the panel's status line).
740 *
741 * @param \WP_REST_Request $request Carries account_email.
742 * @return \WP_REST_Response
743 */
744 public function rest_hub_attached( \WP_REST_Request $request ) {
745 $email = sanitize_email( (string) $request->get_param( 'account_email' ) );
746 return rest_ensure_response( Mcp_Hub::mark_attached( $email ) );
747 }
748
749 /**
750 * POST /mcp/hub/disconnect — clear the local hub-link bookkeeping.
751 *
752 * @param \WP_REST_Request $request Unused.
753 * @return \WP_REST_Response
754 */
755 public function rest_hub_disconnect( \WP_REST_Request $request ) {
756 unset( $request );
757 return rest_ensure_response( Mcp_Hub::disconnect() );
758 }
759
760 /**
761 * POST /mcp/attach — the OAuth-attach callback. The hub presents the
762 * signed nonce the plugin issued; on success we return this site's URL +
763 * token so the hub can record it. Nonce is the auth (admin-minted,
764 * HMAC-signed, time-bound), so no pre-shared token is required.
765 *
766 * @param \WP_REST_Request $request Carries the nonce.
767 * @return \WP_REST_Response|\WP_Error
768 */
769 public function rest_hub_attach_callback( \WP_REST_Request $request ) {
770 $nonce = (string) $request->get_param( 'nonce' );
771 $result = Mcp_Hub::verify_attach_nonce( $nonce );
772 if ( null === $result ) {
773 return new \WP_Error(
774 'xspeed_attach_invalid',
775 __( 'Invalid or expired attach request.', 'xspeed' ),
776 array( 'status' => 403 )
777 );
778 }
779 // A valid nonce proves this is a real hub-initiated attach, so record it
780 // now — the hub passes the account email so the panel can show
781 // "Connected via <email>". The nonce carries the minting admin's user
782 // id (no WP session exists in this server-to-server call), so the state
783 // is recorded PER-USER — each admin sees their own connection.
784 $account_email = sanitize_email( (string) $request->get_param( 'account_email' ) );
785 $user_id = isset( $result['user_id'] ) ? (int) $result['user_id'] : 0;
786 Mcp_Hub::mark_attached( $account_email, $user_id ?: null );
787
788 // The hub only needs the credential; don't leak the internal user id.
789 unset( $result['user_id'] );
790 return rest_ensure_response( $result );
791 }
792
793 // -- OAuth 2.1 handlers ------------------------------------------------
794
795 /**
796 * POST /mcp/oauth/register — RFC 7591 dynamic client registration.
797 *
798 * @param \WP_REST_Request $request JSON body with redirect_uris.
799 * @return \WP_REST_Response|\WP_Error
800 */
801 public function rest_oauth_register( \WP_REST_Request $request ) {
802 $body = $request->get_json_params();
803 if ( ! is_array( $body ) ) {
804 $body = array();
805 }
806 $result = Mcp_OAuth::register_client( $body );
807 if ( is_wp_error( $result ) ) {
808 return $result;
809 }
810 return new \WP_REST_Response( $result, 201 );
811 }
812
813 /**
814 * The browser-facing OAuth authorize page (served at /xspeed/authorize via
815 * a rewrite, NOT the REST API — see AUTHORIZE_QUERY_VAR). Reads request
816 * params from the superglobals because this is a normal front-end request
817 * where cookie auth populates is_user_logged_in().
818 *
819 * GET renders the consent screen (requires a logged-in admin; anonymous
820 * users go to wp-login and return here). POST is the nonce-checked consent
821 * submission: Approve issues a code and 302s to the client's redirect_uri;
822 * Deny 302s back with error=access_denied. Always emits its own response
823 * (HTML page or redirect) and exits.
824 */
825 public function handle_authorize_page(): void {
826 $is_post = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) );
827 // Params come from GET on the consent link and POST on the form submit.
828 // Nonce is verified below before any POST value is acted on.
829 // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing
830 $source = $is_post ? $_POST : $_GET;
831 // phpcs:enable
832 $params = array();
833 foreach ( array( 'client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'approve', 'deny', '_xspeed_oauth_nonce' ) as $k ) {
834 $params[ $k ] = isset( $source[ $k ] ) ? sanitize_text_field( wp_unslash( $source[ $k ] ) ) : '';
835 }
836
837 // Validate the OAuth params before touching the session.
838 $req = Mcp_OAuth::validate_authorize_request( $params );
839 if ( is_wp_error( $req ) ) {
840 $data = $req->get_error_data();
841 $redirectable = is_array( $data ) && ! empty( $data['redirectable'] );
842 // Only redirect the error back when redirect_uri is verified valid;
843 // otherwise show a page (never bounce to an unverified URL).
844 if ( $redirectable && '' !== $params['redirect_uri'] ) {
845 $this->redirect_error( $params['redirect_uri'], $req->get_error_code(), $req->get_error_message(), $params['state'] );
846 }
847 $this->emit_oauth_error_page( $req->get_error_message() );
848 }
849
850 // Require a logged-in admin. Anonymous → wp-login, back to this URL.
851 if ( ! is_user_logged_in() ) {
852 $this->redirect_to_login();
853 }
854 if ( ! current_user_can( 'manage_options' ) ) {
855 $this->emit_oauth_error_page(
856 __( 'You must be an administrator to authorize an AI agent to control this site.', 'xspeed' )
857 );
858 }
859
860 // POST = consent form submitted.
861 if ( $is_post ) {
862 if ( ! wp_verify_nonce( $params['_xspeed_oauth_nonce'], 'xspeed_oauth_consent' ) ) {
863 $this->emit_oauth_error_page( __( 'Security check failed. Please try connecting again.', 'xspeed' ) );
864 }
865 if ( '' === $params['approve'] ) {
866 $this->redirect_error( $req['redirect_uri'], 'access_denied', 'The user denied the request.', $req['state'] );
867 }
868 $code = Mcp_OAuth::issue_code( $req, get_current_user_id() );
869 $this->redirect_success( $req['redirect_uri'], $code, $req['state'] );
870 }
871
872 // GET = render the consent screen.
873 $this->emit_consent_screen( $req );
874 }
875
876 /**
877 * POST /mcp/oauth/token — exchange a code (or refresh token) for tokens.
878 *
879 * @param \WP_REST_Request $request Form-encoded or JSON token request.
880 * @return \WP_REST_Response
881 */
882 public function rest_oauth_token( \WP_REST_Request $request ) {
883 // Token requests are application/x-www-form-urlencoded per OAuth, but
884 // accept JSON too. get_body_params() covers the form case.
885 $body = $request->get_body_params();
886 if ( empty( $body ) ) {
887 $json = $request->get_json_params();
888 $body = is_array( $json ) ? $json : array();
889 }
890 $body = array_map( 'strval', $body );
891
892 $result = Mcp_OAuth::exchange_token( $body );
893 if ( is_wp_error( $result ) ) {
894 $data = $result->get_error_data();
895 $response = new \WP_REST_Response(
896 array(
897 'error' => isset( $data['error'] ) ? $data['error'] : 'invalid_request',
898 'error_description' => isset( $data['error_description'] ) ? $data['error_description'] : $result->get_error_message(),
899 ),
900 isset( $data['status'] ) ? (int) $data['status'] : 400
901 );
902 $response->header( 'Cache-Control', 'no-store' );
903 return $response;
904 }
905 $response = new \WP_REST_Response( $result, 200 );
906 $response->header( 'Cache-Control', 'no-store' );
907 $response->header( 'Pragma', 'no-cache' );
908 return $response;
909 }
910
911 /**
912 * Token-authenticated tool route for the hosted broker. Maps a broker
913 * tool call (e.g. GET /mcp/tool/get_cache_status) onto the shared
914 * Mcp_Tools catalog, so the broker path and the JSON-RPC path never
915 * drift. GET params + JSON body both feed the tool's arguments.
916 */
917 public function rest_tool( \WP_REST_Request $request ) {
918 $tool = (string) $request->get_param( 'tool' );
919 $args = $request->get_json_params();
920 if ( ! is_array( $args ) ) {
921 $args = array();
922 }
923 // Merge query params (e.g. ?module=minify) so GET tools work too.
924 foreach ( $request->get_query_params() as $k => $v ) {
925 if ( 'tool' !== $k && ! array_key_exists( $k, $args ) ) {
926 $args[ $k ] = $v;
927 }
928 }
929
930 Mcp_Tools::set_channel( 'broker' );
931 $result = Mcp_Tools::invoke( $tool, $args );
932 if ( is_wp_error( $result ) ) {
933 return $result;
934 }
935 return rest_ensure_response( $result );
936 }
937
938 // -- Helpers --
939
940 // -- OAuth browser-response helpers ------------------------------------
941
942 /** The absolute URL of the current authorize request (for login return). */
943 private function current_authorize_url(): string {
944 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- reconstructing the current URL for a login round-trip; escaped at use.
945 $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
946 return home_url( $uri );
947 }
948
949 /** Send an anonymous visitor to wp-login, returning to this authorize URL. */
950 private function redirect_to_login(): void {
951 wp_safe_redirect( wp_login_url( $this->current_authorize_url() ) );
952 exit;
953 }
954
955 /** 302 back to the client with the authorization code (+ state). */
956 private function redirect_success( string $redirect_uri, string $code, string $state ): void {
957 $args = array( 'code' => $code );
958 if ( '' !== $state ) {
959 $args['state'] = $state;
960 }
961 // Not wp_safe_redirect: redirect_uri is a client-registered off-site
962 // callback, already validated against the client's registered set.
963 wp_redirect( add_query_arg( $args, $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
964 exit;
965 }
966
967 /** 302 back to the client with an OAuth error (+ state). */
968 private function redirect_error( string $redirect_uri, string $error, string $description, string $state ): void {
969 $args = array(
970 'error' => $error,
971 'error_description' => $description,
972 );
973 if ( '' !== $state ) {
974 $args['state'] = $state;
975 }
976 wp_redirect( add_query_arg( array_map( 'rawurlencode', $args ), $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
977 exit;
978 }
979
980 /**
981 * Render the consent screen. Minimal self-contained HTML (no admin
982 * chrome — this is a client-facing OAuth page). Approve/Deny post back
983 * to the same authorize URL with a nonce.
984 *
985 * @param array<string,string> $req Validated authorize params.
986 */
987 private function emit_consent_screen( array $req ): void {
988 $read_only = Mcp_OAuth::scope_is_read_only( $req['scope'] );
989 $access = $read_only
990 ? __( 'Read-only — inspect cache status and settings.', 'xspeed' )
991 : __( 'Read & write — purge caches, toggle caching, and change settings.', 'xspeed' );
992 $client = '' !== $req['client_name'] ? $req['client_name'] : __( 'An AI agent', 'xspeed' );
993 $action_url = Mcp_OAuth::authorize_url();
994 $nonce = wp_create_nonce( 'xspeed_oauth_consent' );
995 $user = wp_get_current_user();
996
997 // Preserve every OAuth param so the POST re-validates identically.
998 $hidden = '';
999 foreach ( array( 'client_id', 'redirect_uri', 'code_challenge', 'scope', 'state' ) as $k ) {
1000 $val = 'scope' === $k ? $req['scope'] : ( $req[ $k ] ?? '' );
1001 $hidden .= sprintf( '<input type="hidden" name="%s" value="%s" />', esc_attr( $k ), esc_attr( (string) $val ) );
1002 }
1003 // code_challenge_method + response_type are re-asserted for validation.
1004 $hidden .= '<input type="hidden" name="code_challenge_method" value="S256" />';
1005 $hidden .= '<input type="hidden" name="response_type" value="code" />';
1006
1007 status_header( 200 );
1008 header( 'Content-Type: text/html; charset=utf-8' );
1009 header( 'Cache-Control: no-store' );
1010
1011 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>';
1012 echo '<style>'
1013 . '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}'
1014 . '.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)}'
1015 . 'h1{font-size:20px;margin:0 0 4px}.sub{color:#94a3b8;font-size:13px;margin:0 0 24px}'
1016 . '.row{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid #334155;font-size:13px}'
1017 . '.row span:first-child{color:#94a3b8}.row span:last-child{font-weight:600;text-align:right;max-width:60%;word-break:break-word}'
1018 . '.actions{display:flex;gap:12px;margin-top:24px}'
1019 . 'button{flex:1;padding:12px;border-radius:10px;border:0;font-size:14px;font-weight:600;cursor:pointer}'
1020 . '.approve{background:#f5cd47;color:#1b2533}.deny{background:transparent;color:#94a3b8;border:1px solid #334155}'
1021 . '</style></head><body><div class="card">';
1022 echo '<h1>' . esc_html__( 'Connect to xSpeed', 'xspeed' ) . '</h1>';
1023 /* translators: %s: AI client name. */
1024 echo '<p class="sub">' . esc_html( sprintf( __( '%s wants to manage the cache on this site.', 'xspeed' ), $client ) ) . '</p>';
1025 echo '<div class="row"><span>' . esc_html__( 'Site', 'xspeed' ) . '</span><span>' . esc_html( wp_parse_url( home_url(), PHP_URL_HOST ) ) . '</span></div>';
1026 echo '<div class="row"><span>' . esc_html__( 'Signed in as', 'xspeed' ) . '</span><span>' . esc_html( $user->user_login ) . '</span></div>';
1027 echo '<div class="row"><span>' . esc_html__( 'Access', 'xspeed' ) . '</span><span>' . esc_html( $access ) . '</span></div>';
1028 echo '<form method="post" action="' . esc_url( $action_url ) . '">';
1029 echo $hidden; // phpcs:ignore WordPress.Security.EscapeOutput -- built from esc_attr() above.
1030 echo '<input type="hidden" name="_xspeed_oauth_nonce" value="' . esc_attr( $nonce ) . '" />';
1031 echo '<div class="actions">';
1032 echo '<button class="deny" name="deny" value="1">' . esc_html__( 'Deny', 'xspeed' ) . '</button>';
1033 echo '<button class="approve" name="approve" value="1">' . esc_html__( 'Approve', 'xspeed' ) . '</button>';
1034 echo '</div></form></div></body></html>';
1035 exit;
1036 }
1037
1038 /** Render a standalone OAuth error page (no redirect). */
1039 private function emit_oauth_error_page( string $message ): void {
1040 status_header( 400 );
1041 header( 'Content-Type: text/html; charset=utf-8' );
1042 header( 'Cache-Control: no-store' );
1043 echo '<!doctype html><html><head><meta charset="utf-8"><title>' . esc_html__( 'Authorization error', 'xspeed' ) . '</title>';
1044 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}'
1045 . '.card{background:#1e293b;border:1px solid #334155;border-radius:16px;max-width:440px;padding:32px;text-align:center}</style></head><body>';
1046 echo '<div class="card"><h1>' . esc_html__( 'Could not authorize', 'xspeed' ) . '</h1><p>' . esc_html( $message ) . '</p></div></body></html>';
1047 exit;
1048 }
1049
1050 /** Read an inbound HTTP header from $_SERVER (for the pretty path). */
1051 private static function server_header( string $name ): ?string {
1052 $key = 'HTTP_' . strtoupper( str_replace( '-', '_', $name ) );
1053 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- token compared constant-time downstream; raw header needed verbatim.
1054 return isset( $_SERVER[ $key ] ) ? wp_unslash( $_SERVER[ $key ] ) : null;
1055 }
1056
1057 /** Emit a WP_REST_Response as a JSON HTTP response and stop. */
1058 private function emit_json( \WP_REST_Response $response ): void {
1059 status_header( $response->get_status() );
1060 // MCP Streamable HTTP: advertise the protocol version we speak so a
1061 // strict client can pin it. We answer JSON (a spec-permitted response
1062 // type); we never open an SSE stream, so no session header is needed.
1063 header( 'MCP-Protocol-Version: ' . Mcp_Server::PROTOCOL_VERSION );
1064 // Forward any headers the handler set (notably WWW-Authenticate on a
1065 // 401, which drives the OAuth discovery flow). rest_do_request applies
1066 // these automatically; the pretty-endpoint path must do it by hand.
1067 foreach ( $response->get_headers() as $name => $value ) {
1068 header( $name . ': ' . $value );
1069 }
1070 $data = $response->get_data();
1071 if ( null !== $data ) {
1072 header( 'Content-Type: application/json; charset=utf-8' );
1073 echo wp_json_encode( $data );
1074 }
1075 exit;
1076 }
1077
1078 // -- WP-CLI mirror --
1079
1080 public function cli_commands(): array {
1081 return array(
1082 array(
1083 'name' => 'xspeed mcp status',
1084 'callback' => array( $this, 'cli_status' ),
1085 'shortdesc' => 'Show MCP connection status and the paste-in endpoint URL.',
1086 'synopsis' => array(),
1087 ),
1088 array(
1089 'name' => 'xspeed mcp activity',
1090 'callback' => array( $this, 'cli_activity' ),
1091 'shortdesc' => 'List recent MCP tool calls (the AI audit trail).',
1092 'synopsis' => array(
1093 array(
1094 'name' => 'limit',
1095 'type' => 'assoc',
1096 'optional' => true,
1097 'description' => 'Maximum entries to show (default 20).',
1098 ),
1099 array(
1100 'name' => 'clear',
1101 'type' => 'flag',
1102 'optional' => true,
1103 'description' => 'Wipe the audit trail instead of listing it.',
1104 ),
1105 ),
1106 ),
1107 array(
1108 'name' => 'xspeed mcp connect',
1109 'callback' => array( $this, 'cli_connect' ),
1110 'shortdesc' => 'Generate a connection token for this site\'s MCP endpoint.',
1111 'synopsis' => array(
1112 array(
1113 'name' => 'read-only',
1114 'type' => 'flag',
1115 'optional' => true,
1116 'description' => 'Grant read-only access (no purge/toggle/settings changes).',
1117 ),
1118 ),
1119 ),
1120 array(
1121 'name' => 'xspeed mcp rotate',
1122 'callback' => array( $this, 'cli_rotate' ),
1123 'shortdesc' => 'Mint a fresh MCP token, immediately invalidating the previous one.',
1124 'synopsis' => array(
1125 array(
1126 'name' => 'read-only',
1127 'type' => 'flag',
1128 'optional' => true,
1129 'description' => 'Make the new token read-only.',
1130 ),
1131 ),
1132 ),
1133 array(
1134 'name' => 'xspeed mcp disconnect',
1135 'callback' => array( $this, 'cli_disconnect' ),
1136 'shortdesc' => 'Revoke this site\'s MCP connection token.',
1137 'synopsis' => array(),
1138 ),
1139 );
1140 }
1141
1142 /**
1143 * `wp xspeed mcp status` — print connection status + endpoint URL.
1144 *
1145 * @param array $args Positional args (unused).
1146 * @param array $assoc Associative args (unused).
1147 */
1148 public function cli_status( array $args, array $assoc ): void {
1149 unset( $args, $assoc );
1150 $s = Mcp_Pairing::public_status();
1151 \WP_CLI::log( sprintf( '%-18s %s', 'connected', $s['connected'] ? 'yes' : 'no' ) );
1152 if ( $s['connected'] ) {
1153 \WP_CLI::log( sprintf( '%-18s %s', 'access', $s['read_only'] ? 'read-only' : 'read-write' ) );
1154 \WP_CLI::log( sprintf( '%-18s %s', 'connect_url', $s['connect_url'] ) );
1155 \WP_CLI::log( sprintf( '%-18s %s', 'scopes', implode( ',', $s['scopes'] ) ) );
1156 } else {
1157 \WP_CLI::log( sprintf( '%-18s %s', 'mcp_endpoint', Mcp_Pairing::site_endpoint() ) );
1158 }
1159 }
1160
1161 /**
1162 * `wp xspeed mcp activity` — read (or clear) the AI audit trail.
1163 *
1164 * @param array $args Positional args (unused).
1165 * @param array $assoc --limit=<n>, --clear.
1166 */
1167 public function cli_activity( array $args, array $assoc ): void {
1168 unset( $args );
1169
1170 if ( ! empty( $assoc['clear'] ) ) {
1171 if ( ! Mcp_Activity_Log::clear() ) {
1172 // Reached via MCP run_command — the assistant is asking to
1173 // erase the record of its own calls. Mcp_Activity_Log::clear()
1174 // declines and logs the attempt; say so plainly.
1175 \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.' );
1176 return;
1177 }
1178 \WP_CLI::success( 'MCP activity log cleared.' );
1179 return;
1180 }
1181
1182 $limit = isset( $assoc['limit'] ) ? (int) $assoc['limit'] : 20;
1183 $summary = Mcp_Activity_Log::summary();
1184 $entries = Mcp_Activity_Log::entries( $limit > 0 ? $limit : 20 );
1185
1186 \WP_CLI::log( sprintf( '%-18s %d', 'total_calls', $summary['total'] ) );
1187 \WP_CLI::log( sprintf( '%-18s %d', 'failed', $summary['failed'] ) );
1188 \WP_CLI::log( sprintf( '%-18s %s', 'top_tool', '' === $summary['top_tool'] ? '-' : $summary['top_tool'] ) );
1189
1190 if ( empty( $entries ) ) {
1191 \WP_CLI::log( '' );
1192 \WP_CLI::log( 'No MCP tool calls recorded yet.' );
1193 return;
1194 }
1195
1196 \WP_CLI::log( '' );
1197 foreach ( $entries as $entry ) {
1198 \WP_CLI::log(
1199 sprintf(
1200 '%s %-22s %-5s %-6s %s%s',
1201 gmdate( 'Y-m-d H:i:s', $entry['ts'] ),
1202 $entry['tool'],
1203 $entry['scope'],
1204 $entry['ok'] ? 'ok' : 'FAIL',
1205 $entry['args'],
1206 '' === $entry['error'] ? '' : '' . $entry['error']
1207 )
1208 );
1209 }
1210 }
1211
1212 /**
1213 * `wp xspeed mcp connect` — mint a token and print the paste-in URL.
1214 *
1215 * @param array $args Positional args (unused).
1216 * @param array $assoc Associative args (unused).
1217 */
1218 public function cli_connect( array $args, array $assoc ): void {
1219 unset( $args );
1220 $read_only = ! empty( $assoc['read-only'] );
1221 $result = Mcp_Pairing::connect( $read_only );
1222 if ( is_wp_error( $result ) ) {
1223 \WP_CLI::error( $result->get_error_message() );
1224 return;
1225 }
1226 \WP_CLI::success( 'Connected' . ( Mcp_Pairing::is_read_only() ? ' (read-only).' : '.' ) . ' Paste this single URL into your AI client:' );
1227 \WP_CLI::log( ' ' . Mcp_Pairing::connect_url() );
1228 \WP_CLI::log( '' );
1229 \WP_CLI::log( 'Or, header-based (token stays out of the URL):' );
1230 \WP_CLI::log( ' ' . Mcp_Pairing::config_snippets()['cli'] );
1231 }
1232
1233 /**
1234 * `wp xspeed mcp rotate` — mint a new token, revoking the old one.
1235 *
1236 * @param array $args Positional args (unused).
1237 * @param array $assoc Associative args ({ read-only?:flag }).
1238 */
1239 public function cli_rotate( array $args, array $assoc ): void {
1240 unset( $args );
1241 $read_only = array_key_exists( 'read-only', $assoc ) ? ! empty( $assoc['read-only'] ) : null;
1242 Mcp_Pairing::rotate( $read_only );
1243 \WP_CLI::success( 'Rotated. The previous token is now invalid. New paste-in URL:' );
1244 \WP_CLI::log( ' ' . Mcp_Pairing::connect_url() );
1245 }
1246
1247 /**
1248 * `wp xspeed mcp disconnect` — revoke the connection token.
1249 *
1250 * @param array $args Positional args (unused).
1251 * @param array $assoc Associative args (unused).
1252 */
1253 public function cli_disconnect( array $args, array $assoc ): void {
1254 unset( $args, $assoc );
1255 Mcp_Pairing::disconnect();
1256 \WP_CLI::success( 'Disconnected and revoked the MCP token.' );
1257 }
1258 }
1259