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

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