| @@ -58,11 +58,54 @@ | ||
| 58 | 58 | |
| 59 | 59 | /** |
| 60 | 60 | * The PRIMARY endpoint the user pastes into their AI client — this |
| 61 | 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. | |
| 62 | 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 | + | |
| 63 | 106 | public static function site_endpoint(): string { |
| 64 | - return home_url( '/' . self::SITE_ENDPOINT_PATH ); | |
| 107 | + return untrailingslashit( home_url( '/' ) ) . '/' . self::SITE_ENDPOINT_PATH; | |
| 65 | 108 | } |
| 66 | 109 | |
| 67 | 110 | /** |
| 68 | 111 | * Always-on fallback endpoint via the REST namespace, for hosts where |
| @@ -68,9 +111,9 @@ | ||
| 68 | 111 | * Always-on fallback endpoint via the REST namespace, for hosts where |
| 69 | 112 | * the pretty rewrite can't be served (e.g. plain permalinks). |
| 70 | 113 | */ |
| 71 | 114 | public static function site_endpoint_fallback(): string { |
| 72 | - return rest_url( 'xspeed/v1/mcp' ); | |
| 115 | + return self::absolute( rest_url( 'xspeed/v1/mcp' ) ); | |
| 73 | 116 | } |
| 74 | 117 | |
| 75 | 118 | /** |
| 76 | 119 | * The SINGLE URL the user pastes into their AI client — the pretty |