PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 All 33 releases
xspeed / includes / modules / Mcp / Mcp_Pairing.php

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

528 lines 19.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP pairing lifecycle — the site side of the hosted-broker handshake.
4 *
5 * Flow (see IMPLEMENTATION.md §17):
6 * 1. Admin clicks "Connect AI" in the dashboard.
7 * 2. connect() mints a 32-byte `site_token`, POSTs it to the broker's
8 * /pair endpoint together with this site's URL + the Pro license
9 * key. The broker verifies the license against api.wpdeveloper.com,
10 * stores { connection_token → site_url + site_token }, and returns
11 * the `connection_token` the user pastes into their AI client.
12 * 3. The broker thereafter proxies MCP tool calls to this site's
13 * xspeed/v1 REST routes, presenting `site_token` in the
14 * X-XSpeed-MCP-Token header (validated by Mcp_Auth).
15 * 4. disconnect() clears the local token and asks the broker to revoke
16 * the pairing, so a leaked token dies immediately.
17 *
18 * The plugin is the only party that legitimately holds BOTH the license
19 * key and the canonical site URL, so it is the correct place to
20 * initiate pairing — this is a root design, not a workaround.
21 *
22 * State is stored in the `xspeed_module_mcp` option:
23 * {
24 * site_token: string (secret; the broker's credential to us),
25 * connection_token:string (what the user pastes into their AI client),
26 * connected: bool,
27 * connected_at: int (unix ts),
28 * scopes: string[] (e.g. ['read','write'])
29 * }
30 *
31 * @package XSpeed
32 */
33
34 declare(strict_types=1);
35
36 namespace XSpeed\Modules\Mcp;
37
38 defined( 'ABSPATH' ) || exit;
39
40 final class Mcp_Pairing {
41
42 /** Option key holding all MCP pairing state. */
43 public const OPTION = 'xspeed_module_mcp';
44
45 /**
46 * Optional hosted broker base URL, for the single-vanity-URL path.
47 * Overridable via the XSPEED_MCP_BROKER_URL constant (wp-config.php)
48 * and the `xspeed_mcp_broker_url` filter. The broker is NOT required —
49 * the primary path is this site's own endpoint (site_endpoint()).
50 */
51 public const DEFAULT_BROKER = 'https://api.xspeedcache.com';
52
53 /** Path segment of the pretty per-site endpoint. */
54 public const SITE_ENDPOINT_PATH = 'xspeed/mcp';
55
56 /**
57 * Fires after a NEW token replaces the stored one (Rotate, or a Connect
58 * after Disconnect). Mcp_Hub listens and sends the token to the Hub,
59 * which holds a copy and would otherwise go on presenting the old one.
60 */
61 public const TOKEN_CHANGED_ACTION = 'xspeed_mcp_token_changed';
62
63 /** Default scopes granted on connect. */
64 private const DEFAULT_SCOPES = array( 'read', 'write' );
65
66 /**
67 * The PRIMARY endpoint the user pastes into their AI client — this
68 * site's own MCP URL. No hosted infra involved.
69 *
70 * Normalised, because get_home_url() is not: it concatenates the `home`
71 * option with the path verbatim, so a site whose `home` carries a
72 * trailing slash yields `https://site//xspeed/mcp`. That string is the
73 * OAuth resource AND (since #266) the issuer, so every discovery URL a
74 * client derives from it would carry the doubled slash and 404 — and the
75 * connect URL the user pastes would too. Mcp_Hub::site_url_canonical()
76 * defends the same way for the attach nonce.
77 */
78 /**
79 * Collapse the doubled slash a trailing-slash `home` option leaves behind.
80 *
81 * `get_home_url()` appends `'/' . ltrim( $path, '/' )` to the raw option,
82 * so a site stored as `https://example.test/` yields
83 * `https://example.test//xspeed/authorize`, and `rest_url()` inherits the
84 * same doubling through its pretty-permalink branch. The URLs still
85 * resolve, but they are published in discovery documents that clients
86 * compare as strings.
87 *
88 * Only the run immediately after the authority is collapsed. A doubled
89 * slash deeper in a path can be meaningful, and rebuilding REST URLs by
90 * hand instead would lose `index.php/wp-json`, the plain-permalink
91 * `?rest_route=` form, and anything the `rest_url` filter did. (#266 QA)
92 *
93 * A subdirectory install doubles the slash after the subdirectory rather
94 * than after the host (`https://x/blog//wp-json/...`), so the whole path
95 * is collapsed, not just the run behind the authority.
96 *
97 * @param string $url Absolute URL.
98 */
99 public static function absolute( string $url ): string {
100 if ( ! preg_match( '#^([a-z][a-z0-9+.-]*://[^/?\#]+)(.*)$#is', $url, $m ) ) {
101 return $url;
102 }
103 // Only the path is collapsed -- never the query or the fragment,
104 // where a doubled slash can carry meaning (a nested URL in a
105 // redirect_to, say).
106 $rest = $m[2];
107 $split = strcspn( $rest, '?#' );
108 $path = (string) preg_replace( '#/{2,}#', '/', substr( $rest, 0, $split ) );
109
110 return $m[1] . $path . substr( $rest, $split );
111 }
112
113 public static function site_endpoint(): string {
114 return untrailingslashit( home_url( '/' ) ) . '/' . self::SITE_ENDPOINT_PATH;
115 }
116
117 /**
118 * Always-on fallback endpoint via the REST namespace, for hosts where
119 * the pretty rewrite can't be served (e.g. plain permalinks).
120 */
121 public static function site_endpoint_fallback(): string {
122 return self::absolute( rest_url( 'xspeed/v1/mcp' ) );
123 }
124
125 /**
126 * The SINGLE URL the user pastes into their AI client — the pretty
127 * endpoint with the connection token embedded as a path segment. No
128 * separate token field needed. Empty string when not connected.
129 */
130 public static function connect_url(): string {
131 $token = self::site_token();
132 if ( '' === $token ) {
133 return '';
134 }
135 return self::site_endpoint() . '/' . $token;
136 }
137
138 /**
139 * Resolve the optional broker base URL (no trailing slash).
140 */
141 public static function broker_url(): string {
142 $url = defined( 'XSPEED_MCP_BROKER_URL' ) ? (string) \XSPEED_MCP_BROKER_URL : self::DEFAULT_BROKER;
143 /** Filter the MCP broker base URL. */
144 $url = (string) apply_filters( 'xspeed_mcp_broker_url', $url );
145 return untrailingslashit( $url );
146 }
147
148 /**
149 * Current pairing state, defaults merged.
150 *
151 * @return array{site_token:string,connection_token:string,connected:bool,connected_at:int,scopes:string[]}
152 */
153 public static function state(): array {
154 $stored = get_option( self::OPTION, array() );
155 if ( ! is_array( $stored ) ) {
156 $stored = array();
157 }
158 return array(
159 'site_token' => isset( $stored['site_token'] ) ? (string) $stored['site_token'] : '',
160 'connection_token' => isset( $stored['connection_token'] ) ? (string) $stored['connection_token'] : '',
161 'connected' => ! empty( $stored['connected'] ),
162 'connected_at' => isset( $stored['connected_at'] ) ? (int) $stored['connected_at'] : 0,
163 'scopes' => isset( $stored['scopes'] ) && is_array( $stored['scopes'] )
164 ? array_values( array_map( 'strval', $stored['scopes'] ) )
165 : array(),
166 );
167 }
168
169 /** The stored site token (secret). Empty string when not connected. */
170 public static function site_token(): string {
171 return self::state()['site_token'];
172 }
173
174 /** Whether an MCP connection token is currently active for this site. */
175 public static function is_connected(): bool {
176 $state = self::state();
177 return $state['connected'] && '' !== $state['site_token'];
178 }
179
180 /**
181 * Sanitized snapshot for the dashboard panel.
182 *
183 * In the per-site model the token the user pastes IS the site_token
184 * (the plugin validates it directly). We surface it as
185 * `connection_token`. The site's own endpoint is primary; the broker
186 * endpoint is offered only as an optional alternative.
187 *
188 * @return array<string,mixed>
189 */
190 public static function public_status(): array {
191 $state = self::state();
192 return array(
193 'connected' => self::is_connected(),
194 'connection_token' => $state['connection_token'],
195 // The single paste-in URL (token embedded). Convenient fallback.
196 'connect_url' => self::connect_url(),
197 'mcp_endpoint' => self::site_endpoint(),
198 'mcp_endpoint_rest' => self::site_endpoint_fallback(),
199 'broker_endpoint' => self::broker_url() . '/mcp',
200 'connected_at' => $state['connected_at'],
201 'scopes' => $state['scopes'],
202 'read_only' => self::is_read_only(),
203 // Ready-to-paste connection recipes (header-based — token never in
204 // the URL, so it can't leak into server/proxy logs). Empty when
205 // not connected.
206 'config' => self::config_snippets(),
207 // A drop-in instruction the user can paste into their AI client so
208 // it knows what it's connected to and how to behave.
209 'ai_prompt' => self::ai_prompt(),
210 // The tool catalog (name + short description + whether it writes),
211 // so the panel can show the user exactly what the AI can do.
212 'tools' => self::tools_summary(),
213 );
214 }
215
216 /**
217 * Compact tool catalog for the dashboard: each tool's name, a short
218 * description, and whether it mutates state (write). Mirrors the same
219 * catalog the MCP `tools/list` call returns, so the panel can never drift
220 * from what an AI client actually sees.
221 *
222 * @return array<int,array{name:string,description:string,write:bool}>
223 */
224 public static function tools_summary(): array {
225 if ( ! class_exists( __NAMESPACE__ . '\\Mcp_Tools' ) ) {
226 return array();
227 }
228 $out = array();
229 foreach ( Mcp_Tools::catalog() as $name => $spec ) {
230 // A hidden tool is a private broker stage. This summary is the
231 // dashboard's answer to "what can an agent do here", so listing one
232 // would advertise it in the one place a user reads — the second
233 // enumerator of the same catalog, and the one tools/list's own
234 // filter does not cover.
235 if ( ! empty( $spec['hidden'] ) ) {
236 continue;
237 }
238 $out[] = array(
239 'name' => (string) $name,
240 'description' => isset( $spec['description'] ) ? (string) $spec['description'] : '',
241 'write' => ! empty( $spec['write'] ),
242 );
243 }
244 return $out;
245 }
246
247 /**
248 * Ready-to-paste connection recipes for the dashboard. All header-based
249 * (Authorization: Bearer) so the secret stays out of URLs and logs.
250 * Empty strings when not connected.
251 *
252 * @return array{cli:string,json:string}
253 */
254 public static function config_snippets(): array {
255 $token = self::site_token();
256 if ( '' === $token ) {
257 return array(
258 'cli' => '',
259 'json' => '',
260 );
261 }
262 $endpoint = self::site_endpoint();
263 $name = self::server_name();
264
265 // Claude Code one-liner. The CLI requires the positional NAME and URL
266 // BEFORE any flags (`claude mcp add <name> <url> --flags`); putting
267 // --transport first fails with "missing required argument 'name'".
268 $cli = sprintf(
269 'claude mcp add %s %s --transport http --header "Authorization: Bearer %s"',
270 $name,
271 $endpoint,
272 $token
273 );
274
275 // Portable mcpServers JSON block (Claude Desktop / other clients).
276 // `type: http` declares the Streamable-HTTP transport explicitly —
277 // clients that default to stdio otherwise fail to connect.
278 $json = wp_json_encode(
279 array(
280 'mcpServers' => array(
281 $name => array(
282 'type' => 'http',
283 'url' => $endpoint,
284 'headers' => array(
285 'Authorization' => 'Bearer ' . $token,
286 ),
287 ),
288 ),
289 ),
290 JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
291 );
292
293 return array(
294 'cli' => $cli,
295 'json' => is_string( $json ) ? $json : '',
296 );
297 }
298
299 /**
300 * A per-SITE MCP server name so a user can connect MANY sites to the same
301 * AI client without a name collision. `claude mcp add xspeed …` hardcoded
302 * "xspeed" for every site, so the second site failed with "server xspeed
303 * already exists". We derive `xspeed-<label>` from the FIRST label of the
304 * site host (e.g. `xspeedproaudit.emon.info` → `xspeed-xspeedproaudit`) —
305 * short + readable, sanitized to the simple identifier MCP clients accept
306 * (lowercase, digits, single hyphens).
307 *
308 * Overridable via the `xspeed_mcp_server_name` filter for white-label or
309 * multi-connection setups.
310 */
311 public static function server_name(): string {
312 $host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
313 // Drop a leading www. so www.foo.com and foo.com read the same.
314 $host = preg_replace( '/^www\./i', '', $host );
315 // Use just the FIRST label of the host — the distinctive part — so the
316 // name stays short (`xspeed-<label>`) instead of the full dotted host.
317 $label = explode( '.', $host )[0];
318 // Collapse anything that isn't a-z/0-9 into single hyphens.
319 $slug = strtolower( (string) preg_replace( '/[^a-z0-9]+/i', '-', $label ) );
320 $slug = trim( $slug, '-' );
321
322 $name = '' !== $slug ? 'xspeed-' . $slug : 'xspeed';
323
324 /**
325 * Filter the per-site MCP server name shown in the connection recipes.
326 *
327 * @param string $name Default `xspeed-<host>`.
328 * @param string $host The site host the name was derived from.
329 */
330 return (string) apply_filters( 'xspeed_mcp_server_name', $name, $host );
331 }
332
333 /**
334 * A copy-paste instruction the user hands to their AI assistant so IT
335 * sets up the xSpeed MCP connection for them (the assistant walks the
336 * user through adding the server to whatever client they use). Formatted
337 * as multi-line labeled fields — server URL, auth type, API key — not a
338 * paragraph. Empty when not connected.
339 */
340 public static function ai_prompt(): string {
341 if ( ! self::is_connected() ) {
342 return '';
343 }
344 $token = self::site_token();
345 $endpoint = self::site_endpoint();
346 $access = self::is_read_only() ? 'read-only (inspect status & settings only)' : 'read-write (can purge, toggle caching, and change settings)';
347
348 $lines = array(
349 'Add the following remote MCP server to your connections so you can manage the cache on my WordPress site, then use it.',
350 '',
351 'Server name: ' . self::server_name(),
352 'Server URL: ' . $endpoint,
353 'Transport: streamable HTTP',
354 'Authentication: Bearer token (in the Authorization header)',
355 'API key: ' . $token,
356 'Access level: ' . $access,
357 '',
358 'If you use the Claude Code CLI, this is the exact command (name and URL come BEFORE the flags):',
359 ' ' . self::config_snippets()['cli'],
360 '',
361 'Add it now, confirm it is connected by calling its "get_cache_status" tool, and tell me the current cache status.',
362 );
363 $prompt = implode( "\n", $lines );
364
365 /**
366 * Filter the copy-paste AI setup prompt shown in the MCP panel.
367 *
368 * @param string $prompt The default prompt text.
369 * @param bool $read_only Whether the connection is read-only.
370 */
371 return (string) apply_filters( 'xspeed_mcp_ai_prompt', $prompt, self::is_read_only() );
372 }
373
374 /**
375 * Connect — mint a connection token for this site's own MCP endpoint.
376 *
377 * Per-site model: NO hosted broker is involved. The token the user
378 * pastes into their AI client IS the credential the plugin validates
379 * directly (site_token === connection_token). Idempotent: re-connecting
380 * keeps the existing token so a paired client isn't silently broken.
381 *
382 * @param bool $read_only When true, grant only the `read` scope — the AI
383 * can inspect state but every write tool (purge,
384 * toggle, settings changes, cleans) is refused.
385 * Only applied when minting a NEW token; a
386 * re-connect preserves the existing scopes so an
387 * already-paired client's access doesn't silently
388 * change. Use rotate() to change scopes.
389 * @return array<string,mixed> Public status on success.
390 */
391 public static function connect( bool $read_only = false ) {
392 // Reuse an existing token so re-connecting doesn't break a client
393 // that's already paired; otherwise mint a fresh 32-byte secret.
394 $state = self::state();
395 $existing = '' !== $state['site_token'];
396 $token = $existing ? $state['site_token'] : self::mint_token();
397 // Preserve scopes on re-connect; only a fresh token honors read_only.
398 $scopes = $existing && ! empty( $state['scopes'] )
399 ? $state['scopes']
400 : self::scopes_for( $read_only );
401
402 update_option(
403 self::OPTION,
404 array(
405 // site_token (what the plugin checks) and connection_token
406 // (what the user pastes) are the same secret in this model.
407 'site_token' => $token,
408 'connection_token' => $token,
409 'connected' => true,
410 'connected_at' => $existing ? $state['connected_at'] : time(),
411 'scopes' => $scopes,
412 ),
413 false
414 );
415
416 if ( ! $existing ) {
417 /** This action is documented in Mcp_Pairing::TOKEN_CHANGED_ACTION. */
418 do_action( self::TOKEN_CHANGED_ACTION, $token );
419 }
420
421 return self::public_status();
422 }
423
424 /**
425 * Rotate — mint a BRAND-NEW token, invalidating the previous one
426 * immediately (any client still using the old token gets 401 on its next
427 * call). This is the leaked-token remedy: unlike connect(), it never
428 * reuses the existing secret. Optionally also flips the read-only scope.
429 *
430 * @param bool|null $read_only null = keep current scopes; true/false =
431 * set read-only on/off for the new token.
432 * @return array<string,mixed> Public status with the fresh token.
433 */
434 public static function rotate( ?bool $read_only = null ): array {
435 $state = self::state();
436 $scopes = null === $read_only
437 ? ( ! empty( $state['scopes'] ) ? $state['scopes'] : self::DEFAULT_SCOPES )
438 : self::scopes_for( $read_only );
439 $token = self::mint_token();
440
441 update_option(
442 self::OPTION,
443 array(
444 'site_token' => $token,
445 'connection_token' => $token,
446 'connected' => true,
447 'connected_at' => time(),
448 'scopes' => $scopes,
449 ),
450 false
451 );
452
453 /** This action is documented in Mcp_Pairing::TOKEN_CHANGED_ACTION. */
454 do_action( self::TOKEN_CHANGED_ACTION, $token );
455
456 return self::public_status();
457 }
458
459 /**
460 * Change the access level of the CURRENT connection without minting a new
461 * token — the paired client keeps working, only its allowed tools change.
462 * Unlike rotate() (which invalidates the token), this is for flipping
463 * read-only on/off on a live connection from the dashboard. No-op with a
464 * WP_Error if nothing is connected.
465 *
466 * @param bool $read_only true = grant only `read`; false = read & write.
467 * @return array<string,mixed>|\WP_Error Public status, or error if not connected.
468 */
469 public static function set_read_only( bool $read_only ) {
470 $state = self::state();
471 if ( '' === $state['site_token'] ) {
472 return new \WP_Error(
473 'xspeed_mcp_not_connected',
474 __( 'No active MCP connection to change. Connect first.', 'xspeed' ),
475 array( 'status' => 409 )
476 );
477 }
478
479 update_option(
480 self::OPTION,
481 array(
482 'site_token' => $state['site_token'],
483 'connection_token' => $state['connection_token'],
484 'connected' => true,
485 'connected_at' => $state['connected_at'],
486 'scopes' => self::scopes_for( $read_only ),
487 ),
488 false
489 );
490
491 return self::public_status();
492 }
493
494 /** Whether the active connection is limited to read-only tools. */
495 public static function is_read_only(): bool {
496 $scopes = self::state()['scopes'];
497 return ! in_array( 'write', $scopes, true );
498 }
499
500 /** Map a read-only flag to the granted scope list. */
501 private static function scopes_for( bool $read_only ): array {
502 return $read_only ? array( 'read' ) : self::DEFAULT_SCOPES;
503 }
504
505 /**
506 * Disconnect — revoke the connection token. Any AI client using it
507 * immediately loses access on the next call (Mcp_Server/Mcp_Auth deny
508 * once the stored token is gone).
509 *
510 * @return array<string,mixed> Public status after disconnect.
511 */
512 public static function disconnect(): array {
513 delete_option( self::OPTION );
514
515 // Also revoke every OAuth grant (clients, codes, access + refresh
516 // tokens) so "Disconnect" is a single kill switch for ALL MCP access,
517 // not just the pasted pairing token.
518 Mcp_OAuth::revoke_all();
519
520 return self::public_status();
521 }
522
523 /** Mint a 32-byte URL-safe-ish random token (64 hex chars). */
524 private static function mint_token(): string {
525 return bin2hex( random_bytes( 32 ) );
526 }
527 }
528