PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.0.8
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.0.8
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / modules / Mcp / McpModule.php

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

867 lines 32.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP module — the site side of xSpeed's MCP integration.
4 *
5 * PRIMARY path (no hosted infra): the plugin speaks the MCP protocol
6 * DIRECTLY at this site's own URL. The user pastes their own site's MCP
7 * endpoint + connection token into their AI client:
8 *
9 * https://thissite.com/xspeed/mcp (pretty, via rewrite)
10 * https://thissite.com/wp-json/xspeed/v1/mcp (always-on fallback)
11 *
12 * The MCP JSON-RPC handling lives in Mcp_Server; the tool catalog in
13 * Mcp_Tools. Auth is the per-site connection token (Mcp_Auth /
14 * Mcp_Pairing). See IMPLEMENTATION.md §17.
15 *
16 * OPTIONAL path (hosted broker, api.xspeedcache.com): the same tool
17 * catalog is also exposed as token-authenticated REST routes under
18 * /xspeed/v1/mcp/tool/* so a hosted broker can proxy to it for a single
19 * shared vanity URL. Not required for the product to work.
20 *
21 * Admin-only management routes (manage_options) drive the dashboard
22 * "Connect AI" panel: /mcp/connection, /mcp/connect, /mcp/disconnect.
23 *
24 * These routes register DIRECTLY on rest_api_init (NOT via Rest_Manager,
25 * whose wrap_permission() forces a current_user_can() gate that MCP's
26 * token-only calls can never satisfy).
27 *
28 * Tier: Free. The ONLY gate is possession of the per-site connection
29 * token, which an admin (manage_options) must explicitly mint via
30 * Connect. A fresh install ships with no token → every MCP call is 401
31 * until the admin opts in. Adds ZERO cache logic.
32 *
33 * @package XSpeed
34 */
35
36 declare(strict_types=1);
37
38 namespace XSpeed\Modules\Mcp;
39
40 use XSpeed\Module;
41
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 public function ui_metadata(): array {
77 return array(
78 'label' => 'MCP Server',
79 'icon' => 'Sparkles',
80 'description' => 'Control this site\'s cache from Claude and other AI agents.',
81 'custom_panel' => 'McpPanel',
82 );
83 }
84
85 /**
86 * MCP pairing state lives in xspeed_module_mcp but is managed by
87 * Mcp_Pairing, not the schema engine. Empty schema so the base class
88 * doesn't auto-register generic settings routes.
89 */
90 public function settings_schema(): array {
91 return array();
92 }
93
94 /**
95 * All MCP routes register directly (see class docblock). Returning an
96 * empty array keeps Rest_Manager out of the token-auth path entirely.
97 */
98 public function rest_routes(): array {
99 return array();
100 }
101
102 public function boot(): void {
103 add_action( 'rest_api_init', array( $this, 'register_rest' ) );
104
105 // Pretty per-site endpoint: /xspeed/mcp → MCP JSON-RPC handler.
106 add_action( 'init', array( $this, 'add_rewrite' ) );
107 add_filter( 'query_vars', array( $this, 'register_query_var' ) );
108 add_action( 'parse_request', array( $this, 'maybe_handle_pretty_endpoint' ) );
109 }
110
111 /**
112 * Flush rewrites once when the module first boots so /xspeed/mcp works
113 * without a manual permalink re-save. Cheap: gated on a one-shot flag.
114 */
115 public function activate(): void {
116 $this->add_rewrite();
117 flush_rewrite_rules( false );
118 }
119
120 public function deactivate(): void {
121 flush_rewrite_rules( false );
122 }
123
124 // -- Pretty endpoint: /xspeed/mcp --
125
126 public function add_rewrite(): void {
127 // Token-in-URL form: /xspeed/mcp/<token> — a single string the user
128 // pastes into their AI client (no separate token field). The bare
129 // /xspeed/mcp still works with a Bearer/header token.
130 add_rewrite_rule(
131 '^xspeed/mcp/([a-f0-9]{64})/?$',
132 'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]',
133 'top'
134 );
135 add_rewrite_rule( '^xspeed/mcp/?$', 'index.php?' . self::QUERY_VAR . '=1', 'top' );
136
137 // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place the
138 // `.well-known` segment BEFORE the resource path, so a resource at
139 // /xspeed/mcp is discovered at BOTH:
140 // /.well-known/oauth-protected-resource (root form)
141 // /.well-known/oauth-protected-resource/xspeed/mcp (path-suffixed)
142 // Real clients (e.g. Claude Desktop) request the path-suffixed form;
143 // serving only the root form 404s them and the connection aborts. The
144 // optional `(?:/.*)?` tail matches both without caring about the exact
145 // resource path (we only serve one resource).
146 add_rewrite_rule(
147 '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$',
148 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
149 'top'
150 );
151
152 // Browser-facing OAuth consent page — served OUTSIDE REST so cookie
153 // auth (is_user_logged_in) works after the wp-login round-trip.
154 add_rewrite_rule( '^xspeed/authorize/?$', 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', 'top' );
155
156 // Self-heal: flush once if ANY of our rules is missing from the stored
157 // rewrite table. Checking only the first rule is not enough — a site
158 // flushed under an older build (which had /xspeed/mcp but not the
159 // later /xspeed/authorize + /.well-known rules) keeps that first rule,
160 // so the guard never fires and OAuth discovery 404s forever. Guard on
161 // the full set so any newly-added rule triggers a re-flush.
162 $expected = array(
163 '^xspeed/mcp/([a-f0-9]{64})/?$',
164 '^xspeed/mcp/?$',
165 '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$',
166 '^xspeed/authorize/?$',
167 );
168 $rules = get_option( 'rewrite_rules' );
169 if ( is_array( $rules ) ) {
170 foreach ( $expected as $rule ) {
171 if ( ! isset( $rules[ $rule ] ) ) {
172 flush_rewrite_rules( false );
173 break;
174 }
175 }
176 }
177 }
178
179 /**
180 * @param string[] $vars Registered query vars.
181 * @return string[]
182 */
183 public function register_query_var( array $vars ): array {
184 $vars[] = self::QUERY_VAR;
185 $vars[] = self::TOKEN_QUERY_VAR;
186 $vars[] = self::WELLKNOWN_QUERY_VAR;
187 $vars[] = self::AUTHORIZE_QUERY_VAR;
188 return $vars;
189 }
190
191 /**
192 * Serve the MCP endpoint on the pretty path. Runs on parse_request so
193 * it fires before the main query, and short-circuits WP entirely.
194 *
195 * @param \WP $wp The WP request object.
196 */
197 public function maybe_handle_pretty_endpoint( $wp ): void {
198 // OAuth discovery documents (served at the site root).
199 if ( ! empty( $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] ) ) {
200 $doc = (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ];
201 $data = 'authorization-server' === $doc
202 ? Mcp_OAuth::authorization_server_metadata()
203 : Mcp_OAuth::protected_resource_metadata();
204 status_header( 200 );
205 header( 'Content-Type: application/json; charset=utf-8' );
206 // Discovery metadata is public + cacheable.
207 header( 'Cache-Control: public, max-age=3600' );
208 echo wp_json_encode( $data );
209 exit;
210 }
211
212 // Browser-facing OAuth consent page (cookie auth applies here).
213 if ( ! empty( $wp->query_vars[ self::AUTHORIZE_QUERY_VAR ] ) ) {
214 $this->handle_authorize_page();
215 return;
216 }
217
218 if ( empty( $wp->query_vars[ self::QUERY_VAR ] ) ) {
219 return;
220 }
221
222 $request = new \WP_REST_Request( 'POST', '/xspeed/v1/mcp' );
223 $request->set_header( 'content-type', 'application/json' );
224 // Carry the auth headers + raw body from the live PHP request.
225 foreach ( array( 'authorization', Mcp_Auth::TOKEN_HEADER ) as $h ) {
226 $val = self::server_header( $h );
227 if ( null !== $val ) {
228 $request->set_header( $h, $val );
229 }
230 }
231 // Token embedded in the URL path (/xspeed/mcp/<token>) — surface it
232 // as the standard token header so Mcp_Server validates it the same
233 // way. A header/Bearer token (if also sent) still takes precedence.
234 $path_token = isset( $wp->query_vars[ self::TOKEN_QUERY_VAR ] )
235 ? (string) $wp->query_vars[ self::TOKEN_QUERY_VAR ]
236 : '';
237 if ( '' !== $path_token && '' === (string) $request->get_header( Mcp_Auth::TOKEN_HEADER ) && '' === (string) $request->get_header( 'authorization' ) ) {
238 $request->set_header( Mcp_Auth::TOKEN_HEADER, $path_token );
239 }
240 $request->set_body( file_get_contents( 'php://input' ) );
241
242 $response = Mcp_Server::handle( $request );
243 $this->emit_json( $response );
244 }
245
246 // -- REST registration --
247
248 public function register_rest(): void {
249 // --- MCP JSON-RPC endpoint (fallback path via wp-json) -----------
250 // permission_callback is __return_true because Mcp_Server does its
251 // own token auth and must reply with a JSON-RPC 401, not a bare WP
252 // permission failure.
253 register_rest_route(
254 self::NS,
255 '/mcp',
256 array(
257 'methods' => 'POST',
258 'callback' => array( $this, 'rest_mcp' ),
259 'permission_callback' => '__return_true',
260 )
261 );
262
263 // --- Admin-only management routes (dashboard) --------------------
264 register_rest_route(
265 self::NS,
266 '/mcp/connection',
267 array(
268 'methods' => 'GET',
269 'callback' => array( $this, 'rest_connection' ),
270 'permission_callback' => array( $this, 'admin_permission' ),
271 )
272 );
273 register_rest_route(
274 self::NS,
275 '/mcp/connect',
276 array(
277 'methods' => 'POST',
278 'callback' => array( $this, 'rest_connect' ),
279 'permission_callback' => array( $this, 'admin_permission' ),
280 'args' => array(
281 'read_only' => array(
282 'type' => 'boolean',
283 'required' => false,
284 'default' => false,
285 'description' => 'Grant read-only access (no purge/toggle/settings changes).',
286 ),
287 ),
288 )
289 );
290 register_rest_route(
291 self::NS,
292 '/mcp/rotate',
293 array(
294 'methods' => 'POST',
295 'callback' => array( $this, 'rest_rotate' ),
296 'permission_callback' => array( $this, 'admin_permission' ),
297 'args' => array(
298 'read_only' => array(
299 'type' => 'boolean',
300 'required' => false,
301 'description' => 'Optionally set read-only on the new token; omit to keep current scopes.',
302 ),
303 ),
304 )
305 );
306 register_rest_route(
307 self::NS,
308 '/mcp/access',
309 array(
310 'methods' => 'POST',
311 'callback' => array( $this, 'rest_access' ),
312 'permission_callback' => array( $this, 'admin_permission' ),
313 'args' => array(
314 'read_only' => array(
315 'type' => 'boolean',
316 'required' => true,
317 'description' => 'Switch the live connection to read-only (true) or read & write (false), keeping the same token.',
318 ),
319 ),
320 )
321 );
322 register_rest_route(
323 self::NS,
324 '/mcp/disconnect',
325 array(
326 'methods' => 'POST',
327 'callback' => array( $this, 'rest_disconnect' ),
328 'permission_callback' => array( $this, 'admin_permission' ),
329 )
330 );
331
332 // --- OAuth 2.1 authorization server (the "paste a URL only" path) -
333 // Discovery, dynamic client registration, and the token endpoint are
334 // all public (permission enforced inside): a client must reach them
335 // BEFORE it holds any credential. The authorize endpoint gates on a
336 // logged-in admin inside its handler (anonymous → wp-login redirect).
337 register_rest_route(
338 self::NS,
339 '/mcp/oauth/register',
340 array(
341 'methods' => 'POST',
342 'callback' => array( $this, 'rest_oauth_register' ),
343 'permission_callback' => '__return_true',
344 )
345 );
346 // NOTE: /authorize is deliberately NOT a REST route — it is served as a
347 // normal front-end page at /xspeed/authorize (see handle_authorize_page)
348 // so cookie auth works after the wp-login round-trip.
349 register_rest_route(
350 self::NS,
351 '/mcp/oauth/token',
352 array(
353 'methods' => 'POST',
354 'callback' => array( $this, 'rest_oauth_token' ),
355 'permission_callback' => '__return_true',
356 )
357 );
358
359 // --- MCP-token-only tool routes (optional hosted-broker path) ----
360 $tool_perm = array( Mcp_Auth::class, 'permission' );
361 register_rest_route(
362 self::NS,
363 '/mcp/tool/(?P<tool>[a-z_]+)',
364 array(
365 array(
366 'methods' => 'GET',
367 'callback' => array( $this, 'rest_tool' ),
368 'permission_callback' => $tool_perm,
369 ),
370 array(
371 'methods' => 'POST',
372 'callback' => array( $this, 'rest_tool' ),
373 'permission_callback' => $tool_perm,
374 ),
375 )
376 );
377 }
378
379 /**
380 * Capability gate for the admin-only management routes.
381 *
382 * @return bool
383 */
384 public function admin_permission(): bool {
385 return current_user_can( 'manage_options' );
386 }
387
388 // -- Handlers ----------------------------------------------------------
389
390 /**
391 * MCP JSON-RPC over the wp-json fallback path.
392 *
393 * @param \WP_REST_Request $request Incoming request.
394 * @return \WP_REST_Response
395 */
396 public function rest_mcp( \WP_REST_Request $request ) {
397 $response = Mcp_Server::handle( $request );
398 // Advertise the MCP protocol version on the wp-json transport too, so
399 // both endpoints behave identically to a strict Streamable-HTTP client.
400 $response->header( 'MCP-Protocol-Version', Mcp_Server::PROTOCOL_VERSION );
401 return $response;
402 }
403
404 /**
405 * GET /mcp/connection — pairing status for the dashboard.
406 *
407 * @param \WP_REST_Request $request Unused.
408 * @return \WP_REST_Response
409 */
410 public function rest_connection( \WP_REST_Request $request ) {
411 unset( $request );
412 return rest_ensure_response( Mcp_Pairing::public_status() );
413 }
414
415 /**
416 * POST /mcp/connect — mint a connection token.
417 *
418 * @param \WP_REST_Request $request Unused.
419 * @return \WP_REST_Response|\WP_Error
420 */
421 public function rest_connect( \WP_REST_Request $request ) {
422 $read_only = (bool) $request->get_param( 'read_only' );
423 $result = Mcp_Pairing::connect( $read_only );
424 if ( is_wp_error( $result ) ) {
425 return $result;
426 }
427 return rest_ensure_response( $result );
428 }
429
430 /**
431 * POST /mcp/rotate — mint a fresh token, invalidating the old one.
432 *
433 * @param \WP_REST_Request $request Carries optional read_only.
434 * @return \WP_REST_Response
435 */
436 public function rest_rotate( \WP_REST_Request $request ) {
437 $read_only = null;
438 if ( null !== $request->get_param( 'read_only' ) ) {
439 $read_only = (bool) $request->get_param( 'read_only' );
440 }
441 return rest_ensure_response( Mcp_Pairing::rotate( $read_only ) );
442 }
443
444 /**
445 * POST /mcp/access — change the live connection's read-only state WITHOUT
446 * minting a new token (the paired client keeps working; only its allowed
447 * tools change). This is what the dashboard's read-only toggle calls.
448 *
449 * @param \WP_REST_Request $request Carries the required read_only bool.
450 * @return \WP_REST_Response|\WP_Error
451 */
452 public function rest_access( \WP_REST_Request $request ) {
453 $read_only = (bool) $request->get_param( 'read_only' );
454 $result = Mcp_Pairing::set_read_only( $read_only );
455 if ( is_wp_error( $result ) ) {
456 return $result;
457 }
458 return rest_ensure_response( $result );
459 }
460
461 /**
462 * POST /mcp/disconnect — revoke the connection token.
463 *
464 * @param \WP_REST_Request $request Unused.
465 * @return \WP_REST_Response
466 */
467 public function rest_disconnect( \WP_REST_Request $request ) {
468 unset( $request );
469 return rest_ensure_response( Mcp_Pairing::disconnect() );
470 }
471
472 // -- OAuth 2.1 handlers ------------------------------------------------
473
474 /**
475 * POST /mcp/oauth/register — RFC 7591 dynamic client registration.
476 *
477 * @param \WP_REST_Request $request JSON body with redirect_uris.
478 * @return \WP_REST_Response|\WP_Error
479 */
480 public function rest_oauth_register( \WP_REST_Request $request ) {
481 $body = $request->get_json_params();
482 if ( ! is_array( $body ) ) {
483 $body = array();
484 }
485 $result = Mcp_OAuth::register_client( $body );
486 if ( is_wp_error( $result ) ) {
487 return $result;
488 }
489 return new \WP_REST_Response( $result, 201 );
490 }
491
492 /**
493 * The browser-facing OAuth authorize page (served at /xspeed/authorize via
494 * a rewrite, NOT the REST API — see AUTHORIZE_QUERY_VAR). Reads request
495 * params from the superglobals because this is a normal front-end request
496 * where cookie auth populates is_user_logged_in().
497 *
498 * GET renders the consent screen (requires a logged-in admin; anonymous
499 * users go to wp-login and return here). POST is the nonce-checked consent
500 * submission: Approve issues a code and 302s to the client's redirect_uri;
501 * Deny 302s back with error=access_denied. Always emits its own response
502 * (HTML page or redirect) and exits.
503 */
504 public function handle_authorize_page(): void {
505 $is_post = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) );
506 // Params come from GET on the consent link and POST on the form submit.
507 // Nonce is verified below before any POST value is acted on.
508 // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing
509 $source = $is_post ? $_POST : $_GET;
510 // phpcs:enable
511 $params = array();
512 foreach ( array( 'client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'approve', 'deny', '_xspeed_oauth_nonce' ) as $k ) {
513 $params[ $k ] = isset( $source[ $k ] ) ? sanitize_text_field( wp_unslash( $source[ $k ] ) ) : '';
514 }
515
516 // Validate the OAuth params before touching the session.
517 $req = Mcp_OAuth::validate_authorize_request( $params );
518 if ( is_wp_error( $req ) ) {
519 $data = $req->get_error_data();
520 $redirectable = is_array( $data ) && ! empty( $data['redirectable'] );
521 // Only redirect the error back when redirect_uri is verified valid;
522 // otherwise show a page (never bounce to an unverified URL).
523 if ( $redirectable && '' !== $params['redirect_uri'] ) {
524 $this->redirect_error( $params['redirect_uri'], $req->get_error_code(), $req->get_error_message(), $params['state'] );
525 }
526 $this->emit_oauth_error_page( $req->get_error_message() );
527 }
528
529 // Require a logged-in admin. Anonymous → wp-login, back to this URL.
530 if ( ! is_user_logged_in() ) {
531 $this->redirect_to_login();
532 }
533 if ( ! current_user_can( 'manage_options' ) ) {
534 $this->emit_oauth_error_page(
535 __( 'You must be an administrator to authorize an AI agent to control this site.', 'xspeed' )
536 );
537 }
538
539 // POST = consent form submitted.
540 if ( $is_post ) {
541 if ( ! wp_verify_nonce( $params['_xspeed_oauth_nonce'], 'xspeed_oauth_consent' ) ) {
542 $this->emit_oauth_error_page( __( 'Security check failed. Please try connecting again.', 'xspeed' ) );
543 }
544 if ( '' === $params['approve'] ) {
545 $this->redirect_error( $req['redirect_uri'], 'access_denied', 'The user denied the request.', $req['state'] );
546 }
547 $code = Mcp_OAuth::issue_code( $req, get_current_user_id() );
548 $this->redirect_success( $req['redirect_uri'], $code, $req['state'] );
549 }
550
551 // GET = render the consent screen.
552 $this->emit_consent_screen( $req );
553 }
554
555 /**
556 * POST /mcp/oauth/token — exchange a code (or refresh token) for tokens.
557 *
558 * @param \WP_REST_Request $request Form-encoded or JSON token request.
559 * @return \WP_REST_Response
560 */
561 public function rest_oauth_token( \WP_REST_Request $request ) {
562 // Token requests are application/x-www-form-urlencoded per OAuth, but
563 // accept JSON too. get_body_params() covers the form case.
564 $body = $request->get_body_params();
565 if ( empty( $body ) ) {
566 $json = $request->get_json_params();
567 $body = is_array( $json ) ? $json : array();
568 }
569 $body = array_map( 'strval', $body );
570
571 $result = Mcp_OAuth::exchange_token( $body );
572 if ( is_wp_error( $result ) ) {
573 $data = $result->get_error_data();
574 $response = new \WP_REST_Response(
575 array(
576 'error' => isset( $data['error'] ) ? $data['error'] : 'invalid_request',
577 'error_description' => isset( $data['error_description'] ) ? $data['error_description'] : $result->get_error_message(),
578 ),
579 isset( $data['status'] ) ? (int) $data['status'] : 400
580 );
581 $response->header( 'Cache-Control', 'no-store' );
582 return $response;
583 }
584 $response = new \WP_REST_Response( $result, 200 );
585 $response->header( 'Cache-Control', 'no-store' );
586 $response->header( 'Pragma', 'no-cache' );
587 return $response;
588 }
589
590 /**
591 * Token-authenticated tool route for the hosted broker. Maps a broker
592 * tool call (e.g. GET /mcp/tool/get_cache_status) onto the shared
593 * Mcp_Tools catalog, so the broker path and the JSON-RPC path never
594 * drift. GET params + JSON body both feed the tool's arguments.
595 */
596 public function rest_tool( \WP_REST_Request $request ) {
597 $tool = (string) $request->get_param( 'tool' );
598 $args = $request->get_json_params();
599 if ( ! is_array( $args ) ) {
600 $args = array();
601 }
602 // Merge query params (e.g. ?module=minify) so GET tools work too.
603 foreach ( $request->get_query_params() as $k => $v ) {
604 if ( 'tool' !== $k && ! array_key_exists( $k, $args ) ) {
605 $args[ $k ] = $v;
606 }
607 }
608
609 $result = Mcp_Tools::invoke( $tool, $args );
610 if ( is_wp_error( $result ) ) {
611 return $result;
612 }
613 return rest_ensure_response( $result );
614 }
615
616 // -- Helpers --
617
618 // -- OAuth browser-response helpers ------------------------------------
619
620 /** The absolute URL of the current authorize request (for login return). */
621 private function current_authorize_url(): string {
622 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- reconstructing the current URL for a login round-trip; escaped at use.
623 $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
624 return home_url( $uri );
625 }
626
627 /** Send an anonymous visitor to wp-login, returning to this authorize URL. */
628 private function redirect_to_login(): void {
629 wp_safe_redirect( wp_login_url( $this->current_authorize_url() ) );
630 exit;
631 }
632
633 /** 302 back to the client with the authorization code (+ state). */
634 private function redirect_success( string $redirect_uri, string $code, string $state ): void {
635 $args = array( 'code' => $code );
636 if ( '' !== $state ) {
637 $args['state'] = $state;
638 }
639 // Not wp_safe_redirect: redirect_uri is a client-registered off-site
640 // callback, already validated against the client's registered set.
641 wp_redirect( add_query_arg( $args, $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
642 exit;
643 }
644
645 /** 302 back to the client with an OAuth error (+ state). */
646 private function redirect_error( string $redirect_uri, string $error, string $description, string $state ): void {
647 $args = array(
648 'error' => $error,
649 'error_description' => $description,
650 );
651 if ( '' !== $state ) {
652 $args['state'] = $state;
653 }
654 wp_redirect( add_query_arg( array_map( 'rawurlencode', $args ), $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
655 exit;
656 }
657
658 /**
659 * Render the consent screen. Minimal self-contained HTML (no admin
660 * chrome — this is a client-facing OAuth page). Approve/Deny post back
661 * to the same authorize URL with a nonce.
662 *
663 * @param array<string,string> $req Validated authorize params.
664 */
665 private function emit_consent_screen( array $req ): void {
666 $read_only = Mcp_OAuth::scope_is_read_only( $req['scope'] );
667 $access = $read_only
668 ? __( 'Read-only — inspect cache status and settings.', 'xspeed' )
669 : __( 'Read & write — purge caches, toggle caching, and change settings.', 'xspeed' );
670 $client = '' !== $req['client_name'] ? $req['client_name'] : __( 'An AI agent', 'xspeed' );
671 $action_url = Mcp_OAuth::authorize_url();
672 $nonce = wp_create_nonce( 'xspeed_oauth_consent' );
673 $user = wp_get_current_user();
674
675 // Preserve every OAuth param so the POST re-validates identically.
676 $hidden = '';
677 foreach ( array( 'client_id', 'redirect_uri', 'code_challenge', 'scope', 'state' ) as $k ) {
678 $val = 'scope' === $k ? $req['scope'] : ( $req[ $k ] ?? '' );
679 $hidden .= sprintf( '<input type="hidden" name="%s" value="%s" />', esc_attr( $k ), esc_attr( (string) $val ) );
680 }
681 // code_challenge_method + response_type are re-asserted for validation.
682 $hidden .= '<input type="hidden" name="code_challenge_method" value="S256" />';
683 $hidden .= '<input type="hidden" name="response_type" value="code" />';
684
685 status_header( 200 );
686 header( 'Content-Type: text/html; charset=utf-8' );
687 header( 'Cache-Control: no-store' );
688
689 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>';
690 echo '<style>'
691 . '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}'
692 . '.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)}'
693 . 'h1{font-size:20px;margin:0 0 4px}.sub{color:#94a3b8;font-size:13px;margin:0 0 24px}'
694 . '.row{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid #334155;font-size:13px}'
695 . '.row span:first-child{color:#94a3b8}.row span:last-child{font-weight:600;text-align:right;max-width:60%;word-break:break-word}'
696 . '.actions{display:flex;gap:12px;margin-top:24px}'
697 . 'button{flex:1;padding:12px;border-radius:10px;border:0;font-size:14px;font-weight:600;cursor:pointer}'
698 . '.approve{background:#f5cd47;color:#1b2533}.deny{background:transparent;color:#94a3b8;border:1px solid #334155}'
699 . '</style></head><body><div class="card">';
700 echo '<h1>' . esc_html__( 'Connect to xSpeed', 'xspeed' ) . '</h1>';
701 /* translators: %s: AI client name. */
702 echo '<p class="sub">' . esc_html( sprintf( __( '%s wants to manage the cache on this site.', 'xspeed' ), $client ) ) . '</p>';
703 echo '<div class="row"><span>' . esc_html__( 'Site', 'xspeed' ) . '</span><span>' . esc_html( wp_parse_url( home_url(), PHP_URL_HOST ) ) . '</span></div>';
704 echo '<div class="row"><span>' . esc_html__( 'Signed in as', 'xspeed' ) . '</span><span>' . esc_html( $user->user_login ) . '</span></div>';
705 echo '<div class="row"><span>' . esc_html__( 'Access', 'xspeed' ) . '</span><span>' . esc_html( $access ) . '</span></div>';
706 echo '<form method="post" action="' . esc_url( $action_url ) . '">';
707 echo $hidden; // phpcs:ignore WordPress.Security.EscapeOutput -- built from esc_attr() above.
708 echo '<input type="hidden" name="_xspeed_oauth_nonce" value="' . esc_attr( $nonce ) . '" />';
709 echo '<div class="actions">';
710 echo '<button class="deny" name="deny" value="1">' . esc_html__( 'Deny', 'xspeed' ) . '</button>';
711 echo '<button class="approve" name="approve" value="1">' . esc_html__( 'Approve', 'xspeed' ) . '</button>';
712 echo '</div></form></div></body></html>';
713 exit;
714 }
715
716 /** Render a standalone OAuth error page (no redirect). */
717 private function emit_oauth_error_page( string $message ): void {
718 status_header( 400 );
719 header( 'Content-Type: text/html; charset=utf-8' );
720 header( 'Cache-Control: no-store' );
721 echo '<!doctype html><html><head><meta charset="utf-8"><title>' . esc_html__( 'Authorization error', 'xspeed' ) . '</title>';
722 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}'
723 . '.card{background:#1e293b;border:1px solid #334155;border-radius:16px;max-width:440px;padding:32px;text-align:center}</style></head><body>';
724 echo '<div class="card"><h1>' . esc_html__( 'Could not authorize', 'xspeed' ) . '</h1><p>' . esc_html( $message ) . '</p></div></body></html>';
725 exit;
726 }
727
728 /** Read an inbound HTTP header from $_SERVER (for the pretty path). */
729 private static function server_header( string $name ): ?string {
730 $key = 'HTTP_' . strtoupper( str_replace( '-', '_', $name ) );
731 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- token compared constant-time downstream; raw header needed verbatim.
732 return isset( $_SERVER[ $key ] ) ? wp_unslash( $_SERVER[ $key ] ) : null;
733 }
734
735 /** Emit a WP_REST_Response as a JSON HTTP response and stop. */
736 private function emit_json( \WP_REST_Response $response ): void {
737 status_header( $response->get_status() );
738 // MCP Streamable HTTP: advertise the protocol version we speak so a
739 // strict client can pin it. We answer JSON (a spec-permitted response
740 // type); we never open an SSE stream, so no session header is needed.
741 header( 'MCP-Protocol-Version: ' . Mcp_Server::PROTOCOL_VERSION );
742 // Forward any headers the handler set (notably WWW-Authenticate on a
743 // 401, which drives the OAuth discovery flow). rest_do_request applies
744 // these automatically; the pretty-endpoint path must do it by hand.
745 foreach ( $response->get_headers() as $name => $value ) {
746 header( $name . ': ' . $value );
747 }
748 $data = $response->get_data();
749 if ( null !== $data ) {
750 header( 'Content-Type: application/json; charset=utf-8' );
751 echo wp_json_encode( $data );
752 }
753 exit;
754 }
755
756 // -- WP-CLI mirror --
757
758 public function cli_commands(): array {
759 return array(
760 array(
761 'name' => 'xspeed mcp status',
762 'callback' => array( $this, 'cli_status' ),
763 'shortdesc' => 'Show MCP connection status and the paste-in endpoint URL.',
764 'synopsis' => array(),
765 ),
766 array(
767 'name' => 'xspeed mcp connect',
768 'callback' => array( $this, 'cli_connect' ),
769 'shortdesc' => 'Generate a connection token for this site\'s MCP endpoint.',
770 'synopsis' => array(
771 array(
772 'name' => 'read-only',
773 'type' => 'flag',
774 'optional' => true,
775 'description' => 'Grant read-only access (no purge/toggle/settings changes).',
776 ),
777 ),
778 ),
779 array(
780 'name' => 'xspeed mcp rotate',
781 'callback' => array( $this, 'cli_rotate' ),
782 'shortdesc' => 'Mint a fresh MCP token, immediately invalidating the previous one.',
783 'synopsis' => array(
784 array(
785 'name' => 'read-only',
786 'type' => 'flag',
787 'optional' => true,
788 'description' => 'Make the new token read-only.',
789 ),
790 ),
791 ),
792 array(
793 'name' => 'xspeed mcp disconnect',
794 'callback' => array( $this, 'cli_disconnect' ),
795 'shortdesc' => 'Revoke this site\'s MCP connection token.',
796 'synopsis' => array(),
797 ),
798 );
799 }
800
801 /**
802 * `wp xspeed mcp status` — print connection status + endpoint URL.
803 *
804 * @param array $args Positional args (unused).
805 * @param array $assoc Associative args (unused).
806 */
807 public function cli_status( array $args, array $assoc ): void {
808 unset( $args, $assoc );
809 $s = Mcp_Pairing::public_status();
810 \WP_CLI::log( sprintf( '%-18s %s', 'connected', $s['connected'] ? 'yes' : 'no' ) );
811 if ( $s['connected'] ) {
812 \WP_CLI::log( sprintf( '%-18s %s', 'access', $s['read_only'] ? 'read-only' : 'read-write' ) );
813 \WP_CLI::log( sprintf( '%-18s %s', 'connect_url', $s['connect_url'] ) );
814 \WP_CLI::log( sprintf( '%-18s %s', 'scopes', implode( ',', $s['scopes'] ) ) );
815 } else {
816 \WP_CLI::log( sprintf( '%-18s %s', 'mcp_endpoint', Mcp_Pairing::site_endpoint() ) );
817 }
818 }
819
820 /**
821 * `wp xspeed mcp connect` — mint a token and print the paste-in URL.
822 *
823 * @param array $args Positional args (unused).
824 * @param array $assoc Associative args (unused).
825 */
826 public function cli_connect( array $args, array $assoc ): void {
827 unset( $args );
828 $read_only = ! empty( $assoc['read-only'] );
829 $result = Mcp_Pairing::connect( $read_only );
830 if ( is_wp_error( $result ) ) {
831 \WP_CLI::error( $result->get_error_message() );
832 return;
833 }
834 \WP_CLI::success( 'Connected' . ( Mcp_Pairing::is_read_only() ? ' (read-only).' : '.' ) . ' Paste this single URL into your AI client:' );
835 \WP_CLI::log( ' ' . Mcp_Pairing::connect_url() );
836 \WP_CLI::log( '' );
837 \WP_CLI::log( 'Or, header-based (token stays out of the URL):' );
838 \WP_CLI::log( ' ' . Mcp_Pairing::config_snippets()['cli'] );
839 }
840
841 /**
842 * `wp xspeed mcp rotate` — mint a new token, revoking the old one.
843 *
844 * @param array $args Positional args (unused).
845 * @param array $assoc Associative args ({ read-only?:flag }).
846 */
847 public function cli_rotate( array $args, array $assoc ): void {
848 unset( $args );
849 $read_only = array_key_exists( 'read-only', $assoc ) ? ! empty( $assoc['read-only'] ) : null;
850 Mcp_Pairing::rotate( $read_only );
851 \WP_CLI::success( 'Rotated. The previous token is now invalid. New paste-in URL:' );
852 \WP_CLI::log( ' ' . Mcp_Pairing::connect_url() );
853 }
854
855 /**
856 * `wp xspeed mcp disconnect` — revoke the connection token.
857 *
858 * @param array $args Positional args (unused).
859 * @param array $assoc Associative args (unused).
860 */
861 public function cli_disconnect( array $args, array $assoc ): void {
862 unset( $args, $assoc );
863 Mcp_Pairing::disconnect();
864 \WP_CLI::success( 'Disconnected and revoked the MCP token.' );
865 }
866 }
867