PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.0
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 1.2.0 All 28 releases
xspeed / includes / modules / Mcp / McpModule.php

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

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