PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.4
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 1.1.5 1.1.6 1.1.7 All 30 releases
xspeed / includes / modules / Mcp / Mcp_Pairing.php

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

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