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
← All changes | includes/modules/Mcp/Mcp_Pairing.php +68 -2 1.3.2 → 1.3.7 View file →
@@ -52,8 +52,15 @@
52 52
53 53 /** Path segment of the pretty per-site endpoint. */
54 54 public const SITE_ENDPOINT_PATH = 'xspeed/mcp';
55 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 +
56 63 /** Default scopes granted on connect. */
57 64 private const DEFAULT_SCOPES = array( 'read', 'write' );
58 65
59 66 /**
@@ -58,11 +65,54 @@
58 65
59 66 /**
60 67 * The PRIMARY endpoint the user pastes into their AI client — this
61 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.
62 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 +
63 113 public static function site_endpoint(): string {
64 - return home_url( '/' . self::SITE_ENDPOINT_PATH );
114 + return untrailingslashit( home_url( '/' ) ) . '/' . self::SITE_ENDPOINT_PATH;
65 115 }
66 116
67 117 /**
68 118 * Always-on fallback endpoint via the REST namespace, for hosts where
@@ -68,9 +118,9 @@
68 118 * Always-on fallback endpoint via the REST namespace, for hosts where
69 119 * the pretty rewrite can't be served (e.g. plain permalinks).
70 120 */
71 121 public static function site_endpoint_fallback(): string {
72 - return rest_url( 'xspeed/v1/mcp' );
122 + return self::absolute( rest_url( 'xspeed/v1/mcp' ) );
73 123 }
74 124
75 125 /**
76 126 * The SINGLE URL the user pastes into their AI client — the pretty
@@ -176,8 +226,16 @@
176 226 return array();
177 227 }
178 228 $out = array();
179 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 + }
180 238 $out[] = array(
181 239 'name' => (string) $name,
182 240 'description' => isset( $spec['description'] ) ? (string) $spec['description'] : '',
183 241 'write' => ! empty( $spec['write'] ),
@@ -354,8 +412,13 @@
354 412 ),
355 413 false
356 414 );
357 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 +
358 421 return self::public_status();
359 422 }
360 423
361 424 /**
@@ -385,8 +448,11 @@
385 448 'scopes' => $scopes,
386 449 ),
387 450 false
388 451 );
452 +
453 + /** This action is documented in Mcp_Pairing::TOKEN_CHANGED_ACTION. */
454 + do_action( self::TOKEN_CHANGED_ACTION, $token );
389 455
390 456 return self::public_status();
391 457 }
392 458