| @@ -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 | |