| @@ -8,8 +8,10 @@ | ||
| 8 | 8 | declare(strict_types=1); |
| 9 | 9 | |
| 10 | 10 | namespace ThinkRank\Mcp; |
| 11 | 11 | |
| 12 | +use ThinkRank\Abilities\Abilities_Registrar; | |
| 13 | + | |
| 12 | 14 | if ( ! defined( 'ABSPATH' ) ) { |
| 13 | 15 | exit; // Exit if accessed directly. |
| 14 | 16 | } |
| 15 | 17 | |
| @@ -18,22 +20,68 @@ | ||
| 18 | 20 | * *where* it broke, so an admin can tell a certificate problem from an |
| 19 | 21 | * authentication problem from an ability-discovery problem without leaving the |
| 20 | 22 | * MCP page (see #189). |
| 21 | 23 | * |
| 22 | - * It performs one real loopback `tools/list` call against this site's own MCP | |
| 23 | - * endpoint using the active connection token. The staged result names the first | |
| 24 | - * failing step: `disabled` → `not_connected` → `unreachable` → `tls` → | |
| 25 | - * `redirect` → `auth` → `no_tools` → `ok`. | |
| 24 | + * Four loopback checks, each against a surface a real client actually uses: | |
| 25 | + * | |
| 26 | + * 1. `endpoint` — the pretty URL the user pastes (/thinkrank/mcp), with the | |
| 27 | + * connection token. Depends on rewrite rules, so it fails on | |
| 28 | + * plain permalinks or an unflushed rule table. | |
| 29 | + * 2. `fallback` — the always-on /wp-json/thinkrank/v1/mcp route. Works even | |
| 30 | + * when rewrites do not, which is what separates "the whole | |
| 31 | + * MCP surface is down" from "only the pretty URL is". | |
| 32 | + * 3. `discovery` — the RFC 9728 / RFC 8414 metadata documents. | |
| 33 | + * 4. `challenge` — an UNAUTHENTICATED call, which must answer 401 with a | |
| 34 | + * WWW-Authenticate header. This is the only thing an | |
| 35 | + * OAuth-only client (ChatGPT, claude.ai) has to go on: if | |
| 36 | + * the challenge is missing it reports that the server does | |
| 37 | + * not implement OAuth, no matter how healthy the rest is. | |
| 38 | + * | |
| 39 | + * Checks 3 and 4 exist because a token-pasting client can be perfectly happy | |
| 40 | + * while every OAuth client is refused — the earlier version of this test only | |
| 41 | + * exercised check 2 and so reported `ok` in exactly that situation. | |
| 42 | + * | |
| 43 | + * The staged result names the first failing step: `disabled` → `not_connected` | |
| 44 | + * → `unreachable` → `tls` → `redirect` → `auth` → `no_tools` → `rewrite` → | |
| 45 | + * `discovery` → `challenge` → `ok`. | |
| 26 | 46 | */ |
| 27 | 47 | final class Mcp_Self_Test { |
| 28 | 48 | |
| 29 | 49 | /** |
| 50 | + * User agents to replay the challenge probe with, to detect a host that | |
| 51 | + * filters by User-Agent. These are the shapes real MCP backends send — | |
| 52 | + * none of them is a browser, which is exactly what "block bad bots" rules | |
| 53 | + * key on. A site that answers WordPress's own UA but 403s these is | |
| 54 | + * unreachable for every AI client while looking perfectly healthy from | |
| 55 | + * inside. | |
| 56 | + * | |
| 57 | + * DO NOT replace these with a descriptive agent such as | |
| 58 | + * `ThinkRank-SelfTest/1.0`. An affected host allowlists a named agent and | |
| 59 | + * keeps refusing `python-requests/…`, so the check would go green while | |
| 60 | + * ChatGPT stays blocked — the exact false pass this test exists to catch. | |
| 61 | + * SiteGround support has recommended that change; declining it is | |
| 62 | + * deliberate. See #379. | |
| 63 | + */ | |
| 64 | + private const CLIENT_USER_AGENTS = [ | |
| 65 | + 'python-requests/2.32.3', | |
| 66 | + 'node-fetch/3.3.2', | |
| 67 | + ]; | |
| 68 | + | |
| 69 | + /** | |
| 70 | + * Where an affected site owner is sent for the workaround list. The plugin | |
| 71 | + * cannot fix an edge block, so the failing check hands over the diagnostic | |
| 72 | + * and the host-side options instead. | |
| 73 | + */ | |
| 74 | + private const HOSTING_DOC_URL = 'https://thinkrank.ai/docs/mcp/hosting-compatibility/'; | |
| 75 | + | |
| 76 | + /** | |
| 30 | 77 | * Run the round-trip self-test. |
| 31 | 78 | * |
| 32 | 79 | * @return array<string, mixed> |
| 33 | 80 | */ |
| 34 | 81 | public static function run(): array { |
| 35 | - $endpoint = Mcp_Pairing::site_endpoint_fallback(); | |
| 82 | + $endpoint = Mcp_Pairing::site_endpoint(); | |
| 83 | + $fallback = Mcp_Pairing::site_endpoint_fallback(); | |
| 36 | 84 | |
| 37 | 85 | $result = [ |
| 38 | 86 | 'ok' => false, |
| 39 | 87 | 'stage' => '', |
| @@ -38,8 +86,9 @@ | ||
| 38 | 86 | 'ok' => false, |
| 39 | 87 | 'stage' => '', |
| 40 | 88 | 'message' => '', |
| 41 | 89 | 'endpoint' => $endpoint, |
| 90 | + 'endpoint_rest' => $fallback, | |
| 42 | 91 | 'mcp_enabled' => Mcp_Manager::is_enabled(), |
| 43 | 92 | 'connected' => Mcp_Pairing::is_connected(), |
| 44 | 93 | 'http_status' => null, |
| 45 | 94 | 'redirected' => false, |
| @@ -44,8 +93,11 @@ | ||
| 44 | 93 | 'http_status' => null, |
| 45 | 94 | 'redirected' => false, |
| 46 | 95 | 'authenticated' => false, |
| 47 | 96 | 'tools_count' => null, |
| 97 | + 'checks' => [], | |
| 98 | + // url => decoded metadata (or the raw body when it isn't JSON). | |
| 99 | + 'discovery_documents' => [], | |
| 48 | 100 | ]; |
| 49 | 101 | |
| 50 | 102 | if ( ! $result['mcp_enabled'] ) { |
| 51 | 103 | $result['stage'] = 'disabled'; |
| @@ -58,10 +110,126 @@ | ||
| 58 | 110 | $result['message'] = __( 'No connection token exists yet. Click Connect to mint one, then run the test again.', 'thinkrank' ); |
| 59 | 111 | return $result; |
| 60 | 112 | } |
| 61 | 113 | |
| 114 | + if ( Mcp_Pairing::state()['token_sealed'] ) { | |
| 115 | + // A token exists and still authenticates the clients holding it, | |
| 116 | + // but this site can no longer decrypt it, so there is nothing to | |
| 117 | + // present. Probing with '' would report an authentication failure | |
| 118 | + // and point support at entirely the wrong thing. | |
| 119 | + $result['stage'] = 'token_sealed'; | |
| 120 | + $result['message'] = __( 'A connection token exists but can no longer be read on this site — the security keys in wp-config.php changed after it was minted. Clients already set up with it keep working. Use Reset token to mint one this site can show, then run the test again.', 'thinkrank' ); | |
| 121 | + return $result; | |
| 122 | + } | |
| 123 | + | |
| 124 | + $token = Mcp_Pairing::site_token(); | |
| 125 | + | |
| 126 | + $pretty = self::probe_jsonrpc( $endpoint, $token ); | |
| 127 | + $rest = self::probe_jsonrpc( $fallback, $token ); | |
| 128 | + | |
| 129 | + // Back-compat top-level fields describe the primary (pretty) endpoint, | |
| 130 | + // falling back to the REST route when the pretty URL never answered. | |
| 131 | + $primary = 'unreachable' === $pretty['stage'] ? $rest : $pretty; | |
| 132 | + $result['http_status'] = $primary['status']; | |
| 133 | + $result['redirected'] = 'redirect' === $primary['stage']; | |
| 134 | + $result['authenticated'] = $primary['authenticated']; | |
| 135 | + $result['tools_count'] = $primary['tools']; | |
| 136 | + | |
| 137 | + $result['checks'][] = self::check( 'endpoint', __( 'Connection URL', 'thinkrank' ), $pretty['stage'], $pretty['detail'] ); | |
| 138 | + $result['checks'][] = self::check( 'fallback', __( 'REST fallback URL', 'thinkrank' ), $rest['stage'], $rest['detail'] ); | |
| 139 | + | |
| 140 | + $discovery = self::probe_discovery(); | |
| 141 | + // The documents themselves, so support can read what the site actually | |
| 142 | + // serves instead of asking the customer for screenshots. | |
| 143 | + $result['discovery_documents'] = $discovery['documents']; | |
| 144 | + $result['checks'][] = self::check( 'discovery', __( 'OAuth discovery', 'thinkrank' ), $discovery['stage'], $discovery['detail'] ); | |
| 145 | + | |
| 146 | + $challenge = self::probe_challenge( $endpoint, $fallback ); | |
| 147 | + $result['checks'][] = self::check( 'challenge', __( 'OAuth challenge', 'thinkrank' ), $challenge['stage'], $challenge['detail'] ); | |
| 148 | + | |
| 149 | + // Only reported when it could actually run — claiming a pass we did | |
| 150 | + // not measure is the failure mode this whole test exists to avoid. | |
| 151 | + $user_agent = self::probe_user_agent( $endpoint ); | |
| 152 | + if ( null !== $user_agent ) { | |
| 153 | + $result['checks'][] = self::check( 'user_agent', __( 'Client access', 'thinkrank' ), $user_agent['stage'], $user_agent['detail'], $user_agent['doc_url'] ?? '' ); | |
| 154 | + } | |
| 155 | + | |
| 156 | + // Locked-out clients. The loopback below can pass while a REMOTE client | |
| 157 | + // is walled off by the failed-auth limiter — the exact state a connector | |
| 158 | + // still holding a rotated-away token produces. Reported only when the | |
| 159 | + // count is knowable (null under a persistent object cache). | |
| 160 | + $lockouts = Mcp_Rate_Limiter::active_lockouts(); | |
| 161 | + $result['locked_clients'] = $lockouts; | |
| 162 | + if ( null !== $lockouts && $lockouts > 0 ) { | |
| 163 | + $result['checks'][] = self::check( | |
| 164 | + 'lockouts', | |
| 165 | + __( 'Client lockouts', 'thinkrank' ), | |
| 166 | + 'locked_clients', | |
| 167 | + sprintf( | |
| 168 | + /* translators: %d: number of currently locked-out clients. */ | |
| 169 | + _n( | |
| 170 | + '%d client is currently locked out after repeated failed authentications — typically a connector still holding a rotated-away token. Remove and re-add the connector in the AI client; the lockout clears itself within 15 minutes of the retries stopping.', | |
| 171 | + '%d clients are currently locked out after repeated failed authentications — typically connectors still holding a rotated-away token. Remove and re-add the connector in the AI client; lockouts clear within 15 minutes of the retries stopping.', | |
| 172 | + $lockouts, | |
| 173 | + 'thinkrank' | |
| 174 | + ), | |
| 175 | + $lockouts | |
| 176 | + ) | |
| 177 | + ); | |
| 178 | + } | |
| 179 | + | |
| 180 | + // The pretty URL failing while the fallback works is its own finding: | |
| 181 | + // the site is usable, but only via the REST URL. | |
| 182 | + if ( 'ok' !== $pretty['stage'] && 'ok' === $rest['stage'] ) { | |
| 183 | + $result['stage'] = 'rewrite'; | |
| 184 | + $result['message'] = sprintf( | |
| 185 | + /* translators: 1: pretty MCP endpoint URL, 2: REST fallback URL. */ | |
| 186 | + __( 'The connection URL %1$s did not answer, but the REST fallback %2$s works. Re-save Settings → Permalinks to rebuild the rewrite rules; until then, give your AI client the fallback URL.', 'thinkrank' ), | |
| 187 | + $endpoint, | |
| 188 | + $fallback | |
| 189 | + ); | |
| 190 | + return $result; | |
| 191 | + } | |
| 192 | + | |
| 193 | + // Otherwise report the first failing check in order. | |
| 194 | + foreach ( [ $pretty, $rest, $discovery, $challenge, $user_agent ] as $check ) { | |
| 195 | + if ( null === $check ) { | |
| 196 | + continue; | |
| 197 | + } | |
| 198 | + if ( 'ok' !== $check['stage'] ) { | |
| 199 | + $result['stage'] = $check['stage']; | |
| 200 | + $result['message'] = $check['detail']; | |
| 201 | + return $result; | |
| 202 | + } | |
| 203 | + } | |
| 204 | + | |
| 205 | + $result['ok'] = true; | |
| 206 | + $result['stage'] = 'ok'; | |
| 207 | + $result['message'] = sprintf( | |
| 208 | + /* translators: %d: number of MCP tools returned. */ | |
| 209 | + _n( | |
| 210 | + 'Connection healthy: the endpoint authenticated, offered OAuth, and returned %d tool.', | |
| 211 | + 'Connection healthy: the endpoint authenticated, offered OAuth, and returned %d tools.', | |
| 212 | + (int) $result['tools_count'], | |
| 213 | + 'thinkrank' | |
| 214 | + ), | |
| 215 | + (int) $result['tools_count'] | |
| 216 | + ); | |
| 217 | + return $result; | |
| 218 | + } | |
| 219 | + | |
| 220 | + // -- Probes ------------------------------------------------------------ | |
| 221 | + | |
| 222 | + /** | |
| 223 | + * One authenticated JSON-RPC `tools/list` round trip. | |
| 224 | + * | |
| 225 | + * @param string $url Endpoint to call. | |
| 226 | + * @param string $token Connection token. | |
| 227 | + * @return array{stage:string,status:?int,tools:?int,authenticated:bool,detail:string} | |
| 228 | + */ | |
| 229 | + private static function probe_jsonrpc( string $url, string $token ): array { | |
| 62 | 230 | $response = wp_remote_post( |
| 63 | - $endpoint, | |
| 231 | + $url, | |
| 64 | 232 | [ |
| 65 | 233 | 'timeout' => 10, |
| 66 | 234 | // Don't follow redirects: a 301/302 here IS the finding (the |
| 67 | 235 | // classic http<->https scheme bounce), so surface it verbatim. |
| @@ -66,9 +234,9 @@ | ||
| 66 | 234 | // Don't follow redirects: a 301/302 here IS the finding (the |
| 67 | 235 | // classic http<->https scheme bounce), so surface it verbatim. |
| 68 | 236 | 'redirection' => 0, |
| 69 | 237 | 'headers' => [ |
| 70 | - 'Authorization' => 'Bearer ' . Mcp_Pairing::site_token(), | |
| 238 | + 'Authorization' => 'Bearer ' . $token, | |
| 71 | 239 | 'Content-Type' => 'application/json', |
| 72 | 240 | 'Accept' => 'application/json', |
| 73 | 241 | ], |
| 74 | 242 | 'body' => wp_json_encode( |
| @@ -80,60 +248,600 @@ | ||
| 80 | 248 | ), |
| 81 | 249 | ] |
| 82 | 250 | ); |
| 83 | 251 | |
| 252 | + $out = [ | |
| 253 | + 'stage' => 'ok', | |
| 254 | + 'status' => null, | |
| 255 | + 'tools' => null, | |
| 256 | + 'authenticated' => false, | |
| 257 | + 'detail' => '', | |
| 258 | + ]; | |
| 259 | + | |
| 84 | 260 | if ( is_wp_error( $response ) ) { |
| 85 | - $err = $response->get_error_message(); | |
| 86 | - $is_tls = false !== stripos( $err, 'ssl' ) || false !== stripos( $err, 'certificate' ); | |
| 87 | - $result['stage'] = $is_tls ? 'tls' : 'unreachable'; | |
| 88 | - $result['message'] = $is_tls | |
| 89 | - /* translators: %s: underlying transport error. */ | |
| 90 | - ? sprintf( __( 'The endpoint could not be reached over HTTPS: %s. On local/dev sites this is usually a self-signed certificate the AI client must be told to trust.', 'thinkrank' ), $err ) | |
| 91 | - /* translators: %s: underlying transport error. */ | |
| 92 | - : sprintf( __( 'The endpoint could not be reached: %s.', 'thinkrank' ), $err ); | |
| 93 | - return $result; | |
| 261 | + $err = $response->get_error_message(); | |
| 262 | + $is_tls = false !== stripos( $err, 'ssl' ) || false !== stripos( $err, 'certificate' ); | |
| 263 | + $out['stage'] = $is_tls ? 'tls' : 'unreachable'; | |
| 264 | + $out['detail'] = $is_tls | |
| 265 | + /* translators: 1: endpoint URL, 2: underlying transport error. */ | |
| 266 | + ? sprintf( __( '%1$s could not be reached over HTTPS: %2$s. On local/dev sites this is usually a self-signed certificate the AI client must be told to trust.', 'thinkrank' ), $url, $err ) | |
| 267 | + /* translators: 1: endpoint URL, 2: underlying transport error. */ | |
| 268 | + : sprintf( __( '%1$s could not be reached: %2$s.', 'thinkrank' ), $url, $err ); | |
| 269 | + return $out; | |
| 94 | 270 | } |
| 95 | 271 | |
| 96 | - $status = (int) wp_remote_retrieve_response_code( $response ); | |
| 97 | - $result['http_status'] = $status; | |
| 272 | + $status = (int) wp_remote_retrieve_response_code( $response ); | |
| 273 | + $out['status'] = $status; | |
| 98 | 274 | |
| 99 | 275 | if ( in_array( $status, [ 301, 302, 307, 308 ], true ) ) { |
| 100 | - $location = (string) wp_remote_retrieve_header( $response, 'location' ); | |
| 101 | - $result['redirected'] = true; | |
| 102 | - $result['stage'] = 'redirect'; | |
| 103 | - $result['message'] = $location | |
| 104 | - /* translators: %s: redirect target URL. */ | |
| 105 | - ? sprintf( __( 'The endpoint redirected to %s instead of answering. A redirect between HTTP and HTTPS usually means the site address and WordPress address schemes disagree.', 'thinkrank' ), $location ) | |
| 106 | - : __( 'The endpoint redirected instead of answering, which usually means the site address and WordPress address schemes disagree.', 'thinkrank' ); | |
| 107 | - return $result; | |
| 276 | + $location = (string) wp_remote_retrieve_header( $response, 'location' ); | |
| 277 | + $out['stage'] = 'redirect'; | |
| 278 | + $out['detail'] = $location | |
| 279 | + /* translators: 1: endpoint URL, 2: redirect target URL. */ | |
| 280 | + ? sprintf( __( '%1$s redirected to %2$s instead of answering. A redirect between HTTP and HTTPS usually means the site address and WordPress address schemes disagree.', 'thinkrank' ), $url, $location ) | |
| 281 | + /* translators: %s: endpoint URL. */ | |
| 282 | + : sprintf( __( '%s redirected instead of answering, which usually means the site address and WordPress address schemes disagree.', 'thinkrank' ), $url ); | |
| 283 | + return $out; | |
| 108 | 284 | } |
| 109 | 285 | |
| 286 | + if ( 404 === $status ) { | |
| 287 | + $out['stage'] = 'rewrite'; | |
| 288 | + $out['detail'] = sprintf( | |
| 289 | + /* translators: %s: endpoint URL. */ | |
| 290 | + __( '%s returned 404 — WordPress does not know this URL. Re-save Settings → Permalinks to rebuild the rewrite rules.', 'thinkrank' ), | |
| 291 | + $url | |
| 292 | + ); | |
| 293 | + return $out; | |
| 294 | + } | |
| 295 | + | |
| 110 | 296 | if ( 401 === $status || 403 === $status ) { |
| 111 | - $result['stage'] = 'auth'; | |
| 112 | - $result['message'] = __( 'The endpoint rejected the connection token (authentication failed). Rotate the token and reconnect your AI client.', 'thinkrank' ); | |
| 113 | - return $result; | |
| 297 | + $out['stage'] = 'auth'; | |
| 298 | + $out['detail'] = sprintf( | |
| 299 | + /* translators: %s: endpoint URL. */ | |
| 300 | + __( '%s rejected the connection token (authentication failed). Rotate the token and reconnect your AI client.', 'thinkrank' ), | |
| 301 | + $url | |
| 302 | + ); | |
| 303 | + return $out; | |
| 114 | 304 | } |
| 115 | 305 | |
| 116 | - $result['authenticated'] = true; | |
| 117 | - $body = json_decode( (string) wp_remote_retrieve_body( $response ), true ); | |
| 118 | - $tools = ( is_array( $body ) && isset( $body['result']['tools'] ) && is_array( $body['result']['tools'] ) ) | |
| 306 | + if ( 429 === $status ) { | |
| 307 | + $out['stage'] = 'auth'; | |
| 308 | + $out['detail'] = sprintf( | |
| 309 | + /* translators: %s: endpoint URL. */ | |
| 310 | + __( '%s is rate-limiting this server after repeated failed tokens. Wait for the lockout to lapse, then rotate the token and reconnect.', 'thinkrank' ), | |
| 311 | + $url | |
| 312 | + ); | |
| 313 | + return $out; | |
| 314 | + } | |
| 315 | + | |
| 316 | + $out['authenticated'] = true; | |
| 317 | + $body = json_decode( (string) wp_remote_retrieve_body( $response ), true ); | |
| 318 | + $tools = ( is_array( $body ) && isset( $body['result']['tools'] ) && is_array( $body['result']['tools'] ) ) | |
| 119 | 319 | ? $body['result']['tools'] |
| 120 | 320 | : null; |
| 121 | 321 | |
| 122 | - if ( 200 !== $status || null === $tools ) { | |
| 123 | - $result['stage'] = 'no_tools'; | |
| 124 | - $result['message'] = __( 'The endpoint answered but returned no tool catalog. Confirm the MCP runtime is built and abilities are registered.', 'thinkrank' ); | |
| 125 | - $result['tools_count'] = is_array( $tools ) ? count( $tools ) : 0; | |
| 126 | - return $result; | |
| 322 | + // An EMPTY tools array counts as a failure, not a pass: that is exactly | |
| 323 | + // the shape of #241 — a connection an AI client reports as healthy | |
| 324 | + // while it has nothing to call. The abilities snapshot goes into the | |
| 325 | + // detail so support can tell "no ThinkRank abilities registered" | |
| 326 | + // (foreign Abilities API copy owns the registry) from "the runtime is | |
| 327 | + // missing entirely". | |
| 328 | + if ( 200 !== $status || null === $tools || [] === $tools ) { | |
| 329 | + $out['stage'] = 'no_tools'; | |
| 330 | + $out['tools'] = is_array( $tools ) ? count( $tools ) : 0; | |
| 331 | + $out['detail'] = sprintf( | |
| 332 | + /* translators: 1: endpoint URL, 2: abilities-registry diagnostic summary. */ | |
| 333 | + __( '%1$s answered but returned no tool catalog. Confirm the MCP runtime is built and abilities are registered. Diagnostics — %2$s', 'thinkrank' ), | |
| 334 | + $url, | |
| 335 | + Abilities_Registrar::summary() | |
| 336 | + ); | |
| 337 | + return $out; | |
| 127 | 338 | } |
| 128 | 339 | |
| 129 | - $result['ok'] = true; | |
| 130 | - $result['stage'] = 'ok'; | |
| 131 | - $result['tools_count'] = count( $tools ); | |
| 132 | - $result['message'] = sprintf( | |
| 133 | - /* translators: %d: number of MCP tools returned. */ | |
| 134 | - _n( 'Connection healthy: the endpoint authenticated and returned %d tool.', 'Connection healthy: the endpoint authenticated and returned %d tools.', $result['tools_count'], 'thinkrank' ), | |
| 135 | - $result['tools_count'] | |
| 340 | + $out['tools'] = count( $tools ); | |
| 341 | + $out['detail'] = sprintf( | |
| 342 | + /* translators: 1: endpoint URL, 2: number of tools. */ | |
| 343 | + __( '%1$s authenticated and returned %2$d tools.', 'thinkrank' ), | |
| 344 | + $url, | |
| 345 | + count( $tools ) | |
| 136 | 346 | ); |
| 137 | - return $result; | |
| 347 | + return $out; | |
| 348 | + } | |
| 349 | + | |
| 350 | + /** | |
| 351 | + * Fetch both OAuth discovery documents and confirm they are served and | |
| 352 | + * well-formed. An OAuth client reads these before it holds any credential, | |
| 353 | + * so a 404 here is invisible to every other check. | |
| 354 | + * | |
| 355 | + * @return array{stage:string,detail:string} | |
| 356 | + */ | |
| 357 | + private static function probe_discovery(): array { | |
| 358 | + $documents = []; | |
| 359 | + | |
| 360 | + // --- Published files vs. the identity this site has NOW ----------- | |
| 361 | + // The static /.well-known/ documents embed absolute home_url()-derived | |
| 362 | + // identifiers, and the whole reason they exist is that the host serves | |
| 363 | + // them before WordPress. After a domain change, an http->https switch | |
| 364 | + // or a staging clone, the stale copy therefore wins over the correct | |
| 365 | + // dynamic route and the site advertises an issuer it no longer owns, | |
| 366 | + // which a spec-compliant client must refuse (#486). | |
| 367 | + // | |
| 368 | + // Checked on disk, ahead of the HTTP probes below, because loopback | |
| 369 | + // does not always take the path an external client does — a site can | |
| 370 | + // serve a stale document to the internet while our own request never | |
| 371 | + // sees it, and every probe below then passes. | |
| 372 | + $stale = Mcp_Static_Discovery::stale_document(); | |
| 373 | + if ( null !== $stale ) { | |
| 374 | + Mcp_Static_Discovery::refresh(); | |
| 375 | + $still_stale = Mcp_Static_Discovery::stale_document(); | |
| 376 | + | |
| 377 | + if ( null !== $still_stale ) { | |
| 378 | + return [ | |
| 379 | + 'stage' => 'stale_static_discovery', | |
| 380 | + 'documents' => $documents, | |
| 381 | + 'detail' => sprintf( | |
| 382 | + /* translators: 1: file path relative to the site root, 2: identifier name, 3: value found in the file, 4: value it should carry. */ | |
| 383 | + __( 'The static discovery file %1$s advertises %2$s as %3$s, but this site is %4$s. It was written before the site URL changed, the host serves it ahead of WordPress, and it could not be rewritten or removed — so clients read the old identity and refuse to connect. Delete that file from the site root, or restore write access there and run this test again.', 'thinkrank' ), | |
| 384 | + $still_stale['file'], | |
| 385 | + $still_stale['key'], | |
| 386 | + '' === $still_stale['found'] ? __( 'nothing', 'thinkrank' ) : $still_stale['found'], | |
| 387 | + $still_stale['expected'] | |
| 388 | + ), | |
| 389 | + ]; | |
| 390 | + } | |
| 391 | + } | |
| 392 | + | |
| 393 | + // --- The documents clients are POINTED at (must work) ------------- | |
| 394 | + // The 401 challenge advertises the REST-served resource metadata, and | |
| 395 | + // spec-compliant clients derive the OIDC-suffix form of the AS | |
| 396 | + // metadata from our path-based issuer. Neither lives under the site | |
| 397 | + // root's /.well-known/ directory, so both survive hosts that | |
| 398 | + // intercept that directory at the proxy edge (SiteGround). Each must | |
| 399 | + // carry an identifier EXACTLY equal to the one we compute locally — a | |
| 400 | + // mere "the key exists" check passes on another plugin's metadata, | |
| 401 | + // which is the hijack case the rewrite rules already warn about. | |
| 402 | + $primary = [ | |
| 403 | + Mcp_OAuth::resource_metadata_url() => [ | |
| 404 | + 'key' => 'resource', | |
| 405 | + 'expected' => Mcp_Pairing::site_endpoint(), | |
| 406 | + ], | |
| 407 | + rest_url( 'thinkrank/v1/mcp/oauth/authorization-server' ) => [ | |
| 408 | + 'key' => 'issuer', | |
| 409 | + 'expected' => Mcp_OAuth::issuer(), | |
| 410 | + ], | |
| 411 | + Mcp_OAuth::issuer() . '/.well-known/openid-configuration' => [ | |
| 412 | + 'key' => 'issuer', | |
| 413 | + 'expected' => Mcp_OAuth::issuer(), | |
| 414 | + ], | |
| 415 | + ]; | |
| 416 | + | |
| 417 | + foreach ( $primary as $url => $spec ) { | |
| 418 | + $issue = self::probe_document( $url, $spec, $documents ); | |
| 419 | + if ( null !== $issue ) { | |
| 420 | + return $issue; | |
| 421 | + } | |
| 422 | + } | |
| 423 | + | |
| 424 | + // --- The spec-derived /.well-known/ forms (should work) ----------- | |
| 425 | + // A client that ignores the challenge pointer derives these itself | |
| 426 | + // (RFC 9728 / RFC 8414 path-insert). Some hosts resolve the root | |
| 427 | + // /.well-known/ directory at their proxy as physical files, 404ing | |
| 428 | + // before WordPress runs — measurably different from broken rewrites, | |
| 429 | + // and fixable by publishing the documents AS physical files. | |
| 430 | + $derived = [ | |
| 431 | + home_url( '/.well-known/oauth-protected-resource/' . Mcp_Pairing::SITE_ENDPOINT_PATH ) => [ | |
| 432 | + 'key' => 'resource', | |
| 433 | + 'expected' => Mcp_Pairing::site_endpoint(), | |
| 434 | + ], | |
| 435 | + home_url( '/.well-known/oauth-authorization-server/' . Mcp_Pairing::SITE_ENDPOINT_PATH ) => [ | |
| 436 | + 'key' => 'issuer', | |
| 437 | + 'expected' => Mcp_OAuth::issuer(), | |
| 438 | + ], | |
| 439 | + ]; | |
| 440 | + | |
| 441 | + $root_issue = null; | |
| 442 | + foreach ( $derived as $url => $spec ) { | |
| 443 | + $root_issue = self::probe_document( $url, $spec, $documents ); | |
| 444 | + if ( null !== $root_issue ) { | |
| 445 | + break; | |
| 446 | + } | |
| 447 | + } | |
| 448 | + | |
| 449 | + if ( null !== $root_issue ) { | |
| 450 | + // A document that answers with SOMEONE ELSE'S identity is a plugin | |
| 451 | + // conflict poisoning derive-only clients — that stays a hard fail. | |
| 452 | + // Only the intercepted/unreachable shapes are softened below. | |
| 453 | + if ( 'mismatch' === ( $root_issue['kind'] ?? '' ) ) { | |
| 454 | + return $root_issue; | |
| 455 | + } | |
| 456 | + | |
| 457 | + // The host's own trick becomes the fix: if the proxy insists on | |
| 458 | + // serving /.well-known/ as physical files, give it physical files. | |
| 459 | + /** | |
| 460 | + * Filter whether the self-test may publish static /.well-known/ | |
| 461 | + * discovery files when the dynamic route is unreachable. | |
| 462 | + * | |
| 463 | + * @since 1.32.0 | |
| 464 | + * | |
| 465 | + * @param bool $allowed Defaults to whether the install can host them. | |
| 466 | + */ | |
| 467 | + $may_publish = apply_filters( 'thinkrank_mcp_static_discovery_publish', Mcp_Static_Discovery::applicable() ); | |
| 468 | + | |
| 469 | + $healed = false; | |
| 470 | + if ( $may_publish && Mcp_Static_Discovery::publish() ) { | |
| 471 | + $healed = true; | |
| 472 | + foreach ( $derived as $url => $spec ) { | |
| 473 | + if ( null !== self::probe_document( $url, $spec, $documents ) ) { | |
| 474 | + $healed = false; | |
| 475 | + break; | |
| 476 | + } | |
| 477 | + } | |
| 478 | + } | |
| 479 | + | |
| 480 | + if ( ! $healed ) { | |
| 481 | + // Not fatal on its own any more: the challenge points clients | |
| 482 | + // at the REST document (verified above), so the flow survives. | |
| 483 | + // Say what is degraded instead of failing the whole check. | |
| 484 | + $scheme_issue = self::probe_scheme(); | |
| 485 | + if ( null !== $scheme_issue ) { | |
| 486 | + $scheme_issue['documents'] = $documents; | |
| 487 | + return $scheme_issue; | |
| 488 | + } | |
| 489 | + return [ | |
| 490 | + 'stage' => 'ok', | |
| 491 | + 'documents' => $documents, | |
| 492 | + 'detail' => __( 'The primary discovery documents are served and correct, but the host intercepts the site root\'s /.well-known/ directory before WordPress runs (common on SiteGround shared hosting), and static files could not be published there. Clients that follow the challenge — ChatGPT, Claude — still connect; a client that only derives the root /.well-known/ URL itself may not. If write access to the site root is possible, granting it lets ThinkRank publish static discovery files that fix this completely.', 'thinkrank' ), | |
| 493 | + ]; | |
| 494 | + } | |
| 495 | + } | |
| 496 | + | |
| 497 | + // Documents agree with us — but they agree on whatever home_url() | |
| 498 | + // says, so a site whose stored URL is http:// while it actually serves | |
| 499 | + // https:// is self-consistently wrong. Clients connect over https and | |
| 500 | + // then reject the http identifier. | |
| 501 | + $scheme_issue = self::probe_scheme(); | |
| 502 | + if ( null !== $scheme_issue ) { | |
| 503 | + $scheme_issue['documents'] = $documents; | |
| 504 | + return $scheme_issue; | |
| 505 | + } | |
| 506 | + | |
| 507 | + return [ | |
| 508 | + 'stage' => 'ok', | |
| 509 | + 'documents' => $documents, | |
| 510 | + 'detail' => __( 'All OAuth discovery documents are served and advertise this site\'s MCP endpoint exactly.', 'thinkrank' ), | |
| 511 | + ]; | |
| 512 | + } | |
| 513 | + | |
| 514 | + /** | |
| 515 | + * Fetch and validate one discovery document. Appends what was actually | |
| 516 | + * served to $documents either way, so support can read the site's real | |
| 517 | + * responses instead of asking the customer for screenshots. | |
| 518 | + * | |
| 519 | + * @param string $url Document URL. | |
| 520 | + * @param array{key:string,expected:string} $spec Identity field + required value. | |
| 521 | + * @param array<string,mixed> $documents Accumulator (by reference). | |
| 522 | + * @return array{stage:string,documents:array<string,mixed>,detail:string}|null Null when the document is valid. | |
| 523 | + */ | |
| 524 | + private static function probe_document( string $url, array $spec, array &$documents ): ?array { | |
| 525 | + $response = wp_remote_get( | |
| 526 | + $url, | |
| 527 | + [ | |
| 528 | + 'timeout' => 10, | |
| 529 | + 'redirection' => 0, | |
| 530 | + ] | |
| 531 | + ); | |
| 532 | + if ( is_wp_error( $response ) ) { | |
| 533 | + return [ | |
| 534 | + 'stage' => 'discovery', | |
| 535 | + 'kind' => 'unreachable', | |
| 536 | + 'documents' => $documents, | |
| 537 | + 'detail' => sprintf( | |
| 538 | + /* translators: 1: discovery document URL, 2: transport error. */ | |
| 539 | + __( 'The OAuth discovery document %1$s could not be fetched: %2$s. Clients that connect by URL alone cannot authenticate without it.', 'thinkrank' ), | |
| 540 | + $url, | |
| 541 | + $response->get_error_message() | |
| 542 | + ), | |
| 543 | + ]; | |
| 544 | + } | |
| 545 | + $status = (int) wp_remote_retrieve_response_code( $response ); | |
| 546 | + $raw = (string) wp_remote_retrieve_body( $response ); | |
| 547 | + $body = json_decode( $raw, true ); | |
| 548 | + $documents[ $url ] = is_array( $body ) ? $body : $raw; | |
| 549 | + | |
| 550 | + if ( 200 !== $status || ! is_array( $body ) || ! isset( $body[ $spec['key'] ] ) ) { | |
| 551 | + return [ | |
| 552 | + 'stage' => 'discovery', | |
| 553 | + 'kind' => 'invalid', | |
| 554 | + 'documents' => $documents, | |
| 555 | + 'detail' => sprintf( | |
| 556 | + /* translators: 1: discovery document URL, 2: HTTP status code. */ | |
| 557 | + __( 'The OAuth discovery document %1$s returned %2$d instead of valid metadata. Re-save Settings → Permalinks; if it persists, the host may be intercepting the URL before WordPress runs, or another plugin may be claiming it.', 'thinkrank' ), | |
| 558 | + $url, | |
| 559 | + $status | |
| 560 | + ), | |
| 561 | + ]; | |
| 562 | + } | |
| 563 | + | |
| 564 | + $advertised = (string) $body[ $spec['key'] ]; | |
| 565 | + if ( $advertised !== $spec['expected'] ) { | |
| 566 | + return [ | |
| 567 | + 'stage' => 'discovery', | |
| 568 | + 'kind' => 'mismatch', | |
| 569 | + 'documents' => $documents, | |
| 570 | + 'detail' => sprintf( | |
| 571 | + /* translators: 1: metadata field name, 2: value found in the document, 3: value it should be, 4: discovery document URL. */ | |
| 572 | + __( 'The discovery document %4$s advertises %1$s "%2$s" but this site\'s MCP endpoint is "%3$s". RFC 9728 requires an exact match, so clients reject the metadata and report that the server does not implement OAuth. If the two differ only by scheme, a reverse proxy is terminating TLS without passing X-Forwarded-Proto; otherwise another plugin is serving this URL.', 'thinkrank' ), | |
| 573 | + $spec['key'], | |
| 574 | + $advertised, | |
| 575 | + $spec['expected'], | |
| 576 | + $url | |
| 577 | + ), | |
| 578 | + ]; | |
| 579 | + } | |
| 580 | + | |
| 581 | + return null; | |
| 582 | + } | |
| 583 | + | |
| 584 | + /** | |
| 585 | + * Catch the reverse-proxy scheme trap: WordPress stores an http:// home | |
| 586 | + * URL, so every advertised OAuth identifier is http://, while the site is | |
| 587 | + * really served over https://. Everything is internally consistent, so no | |
| 588 | + * comparison against our own values can see it — the only tell is that the | |
| 589 | + * https:// variant of the endpoint answers too. | |
| 590 | + * | |
| 591 | + * @return array{stage:string,detail:string}|null Null when nothing is wrong. | |
| 592 | + */ | |
| 593 | + private static function probe_scheme(): ?array { | |
| 594 | + $endpoint = Mcp_Pairing::site_endpoint(); | |
| 595 | + if ( 'https' === wp_parse_url( $endpoint, PHP_URL_SCHEME ) ) { | |
| 596 | + return null; | |
| 597 | + } | |
| 598 | + | |
| 599 | + $secure = set_url_scheme( $endpoint, 'https' ); | |
| 600 | + if ( null === self::probe_status( $secure, null ) ) { | |
| 601 | + // No HTTPS at all. A plain-HTTP site is its own (reported) problem, | |
| 602 | + // not the proxy misconfiguration this check is for. | |
| 603 | + return null; | |
| 604 | + } | |
| 605 | + | |
| 606 | + return [ | |
| 607 | + 'stage' => 'discovery', | |
| 608 | + 'detail' => sprintf( | |
| 609 | + /* translators: 1: http endpoint URL advertised, 2: https endpoint URL that also answers. */ | |
| 610 | + __( 'The discovery documents advertise %1$s, but %2$s answers as well — WordPress is storing an http:// site address behind a proxy that terminates TLS. AI clients connect over https and reject the http identifier as a mismatch. Fix the Site Address in Settings → General, or have the proxy send X-Forwarded-Proto.', 'thinkrank' ), | |
| 611 | + $endpoint, | |
| 612 | + $secure | |
| 613 | + ), | |
| 614 | + ]; | |
| 615 | + } | |
| 616 | + | |
| 617 | + /** | |
| 618 | + * Confirm an unauthenticated call answers 401 WITH the RFC 9728 | |
| 619 | + * WWW-Authenticate challenge. A client that connects by URL alone has | |
| 620 | + * nothing else to discover OAuth from — a bare 401, or any other status, | |
| 621 | + * reads to it as "this server does not implement OAuth". | |
| 622 | + * | |
| 623 | + * @param string $endpoint Pretty endpoint URL. | |
| 624 | + * @param string $fallback REST fallback URL. | |
| 625 | + * @return array{stage:string,detail:string} | |
| 626 | + */ | |
| 627 | + private static function probe_challenge( string $endpoint, string $fallback ): array { | |
| 628 | + $answered = false; | |
| 629 | + | |
| 630 | + foreach ( [ $endpoint, $fallback ] as $url ) { | |
| 631 | + $response = wp_remote_post( | |
| 632 | + $url, | |
| 633 | + [ | |
| 634 | + 'timeout' => 10, | |
| 635 | + 'redirection' => 0, | |
| 636 | + 'headers' => [ | |
| 637 | + 'Content-Type' => 'application/json', | |
| 638 | + 'Accept' => 'application/json', | |
| 639 | + ], | |
| 640 | + 'body' => wp_json_encode( | |
| 641 | + [ | |
| 642 | + 'jsonrpc' => '2.0', | |
| 643 | + 'id' => 1, | |
| 644 | + 'method' => 'initialize', | |
| 645 | + 'params' => [], | |
| 646 | + ] | |
| 647 | + ), | |
| 648 | + ] | |
| 649 | + ); | |
| 650 | + if ( is_wp_error( $response ) ) { | |
| 651 | + continue; // Reachability is the other checks' job. | |
| 652 | + } | |
| 653 | + $answered = true; | |
| 654 | + | |
| 655 | + $status = (int) wp_remote_retrieve_response_code( $response ); | |
| 656 | + $challenge = (string) wp_remote_retrieve_header( $response, 'www-authenticate' ); | |
| 657 | + | |
| 658 | + if ( 401 !== $status ) { | |
| 659 | + return [ | |
| 660 | + 'stage' => 'challenge', | |
| 661 | + 'detail' => sprintf( | |
| 662 | + /* translators: 1: endpoint URL, 2: HTTP status code. */ | |
| 663 | + __( 'An unauthenticated call to %1$s answered %2$d instead of 401. Clients that connect by URL alone need the 401 challenge to start the OAuth flow.', 'thinkrank' ), | |
| 664 | + $url, | |
| 665 | + $status | |
| 666 | + ), | |
| 667 | + ]; | |
| 668 | + } | |
| 669 | + if ( '' === $challenge ) { | |
| 670 | + return [ | |
| 671 | + 'stage' => 'challenge', | |
| 672 | + 'detail' => sprintf( | |
| 673 | + /* translators: %s: endpoint URL. */ | |
| 674 | + __( '%s answered 401 but sent no WWW-Authenticate header — a security plugin or proxy is likely stripping it. Clients that connect by URL alone will report that this server does not implement OAuth.', 'thinkrank' ), | |
| 675 | + $url | |
| 676 | + ), | |
| 677 | + ]; | |
| 678 | + } | |
| 679 | + | |
| 680 | + // The challenge is only useful if the URL inside it resolves — | |
| 681 | + // that URL is the client's entire entry point into the flow. | |
| 682 | + if ( ! preg_match( '/resource_metadata="([^"]+)"/i', $challenge, $m ) ) { | |
| 683 | + return [ | |
| 684 | + 'stage' => 'challenge', | |
| 685 | + 'detail' => sprintf( | |
| 686 | + /* translators: 1: endpoint URL, 2: the WWW-Authenticate header value received. */ | |
| 687 | + __( '%1$s sent a WWW-Authenticate header with no resource_metadata URL (%2$s). Clients have nowhere to look up this site\'s OAuth metadata.', 'thinkrank' ), | |
| 688 | + $url, | |
| 689 | + $challenge | |
| 690 | + ), | |
| 691 | + ]; | |
| 692 | + } | |
| 693 | + | |
| 694 | + $metadata_url = $m[1]; | |
| 695 | + $metadata = wp_remote_get( | |
| 696 | + $metadata_url, | |
| 697 | + [ | |
| 698 | + 'timeout' => 10, | |
| 699 | + 'redirection' => 2, // A host-level redirect to the real doc is fine. | |
| 700 | + ] | |
| 701 | + ); | |
| 702 | + $reachable = ! is_wp_error( $metadata ) | |
| 703 | + && 200 === (int) wp_remote_retrieve_response_code( $metadata ) | |
| 704 | + && is_array( json_decode( (string) wp_remote_retrieve_body( $metadata ), true ) ); | |
| 705 | + | |
| 706 | + if ( ! $reachable ) { | |
| 707 | + return [ | |
| 708 | + 'stage' => 'challenge', | |
| 709 | + 'detail' => sprintf( | |
| 710 | + /* translators: 1: resource_metadata URL from the challenge header, 2: endpoint URL. */ | |
| 711 | + __( 'The challenge from %2$s points at %1$s, but that URL does not return OAuth metadata. This is the first thing a client fetches, so the connection fails there. A security plugin or edge rule blocking the REST API for visitors is the usual cause.', 'thinkrank' ), | |
| 712 | + $metadata_url, | |
| 713 | + $url | |
| 714 | + ), | |
| 715 | + ]; | |
| 716 | + } | |
| 717 | + } | |
| 718 | + | |
| 719 | + // Neither URL answered at all. Reporting `ok` here would be the exact | |
| 720 | + // false pass this test exists to prevent — an unreachable endpoint is | |
| 721 | + // not a passing challenge. The other checks name the reachability | |
| 722 | + // failure, so this one only has to refuse to claim success. | |
| 723 | + if ( ! $answered ) { | |
| 724 | + return [ | |
| 725 | + 'stage' => 'challenge', | |
| 726 | + 'detail' => __( 'The OAuth challenge could not be checked because the endpoint did not answer. Fix the connection error above and re-run the test.', 'thinkrank' ), | |
| 727 | + ]; | |
| 728 | + } | |
| 729 | + | |
| 730 | + return [ | |
| 731 | + 'stage' => 'ok', | |
| 732 | + 'detail' => __( 'Unauthenticated calls answer with the OAuth challenge, so URL-only clients can authenticate.', 'thinkrank' ), | |
| 733 | + ]; | |
| 734 | + } | |
| 735 | + | |
| 736 | + /** | |
| 737 | + * Detect a host that answers WordPress but refuses AI clients by | |
| 738 | + * User-Agent. Replays the unauthenticated probe under the UAs a real MCP | |
| 739 | + * backend sends and compares against the baseline; a 403/406/503 that the | |
| 740 | + * baseline did not get is a bot filter, not a plugin problem. | |
| 741 | + * | |
| 742 | + * Blind spot worth stating plainly: this runs from the server's own IP, | |
| 743 | + * which host firewalls usually trust, so it catches UA filtering but NOT | |
| 744 | + * an IP-range block of the AI vendor. A green result here does not prove | |
| 745 | + * an external client can connect. | |
| 746 | + * | |
| 747 | + * @param string $endpoint Pretty endpoint URL. | |
| 748 | + * @return array{stage:string,detail:string}|null Null when it could not run. | |
| 749 | + */ | |
| 750 | + private static function probe_user_agent( string $endpoint ): ?array { | |
| 751 | + $baseline = self::probe_status( $endpoint, null ); | |
| 752 | + if ( null === $baseline ) { | |
| 753 | + return null; // Endpoint unreachable — the other checks own that. | |
| 754 | + } | |
| 755 | + | |
| 756 | + foreach ( self::CLIENT_USER_AGENTS as $agent ) { | |
| 757 | + $status = self::probe_status( $endpoint, $agent ); | |
| 758 | + if ( null === $status || $status === $baseline ) { | |
| 759 | + continue; | |
| 760 | + } | |
| 761 | + // A different status is only damning when it is a refusal. An MCP | |
| 762 | + // answer (401 challenge / 200 / 202) under any UA is fine. | |
| 763 | + if ( in_array( $status, [ 200, 202, 401 ], true ) ) { | |
| 764 | + continue; | |
| 765 | + } | |
| 766 | + return [ | |
| 767 | + 'stage' => 'ua_filter', | |
| 768 | + 'doc_url' => self::HOSTING_DOC_URL, | |
| 769 | + 'detail' => sprintf( | |
| 770 | + /* translators: 1: user agent string, 2: HTTP status returned for it, 3: HTTP status returned for WordPress's own user agent. */ | |
| 771 | + __( 'The endpoint answered %3$d for WordPress but %2$d for an AI client\'s User-Agent (%1$s). ThinkRank deliberately tests with the generic agents real MCP backends send; this refusal means a security plugin, firewall or host-level "block bad bots" rule (SiteGround\'s edge protection does this) will also refuse the real AI client. Ask the host to exempt the MCP and /.well-known/ paths, or allowlist these User-Agents.', 'thinkrank' ), | |
| 772 | + $agent, | |
| 773 | + $status, | |
| 774 | + $baseline | |
| 775 | + ), | |
| 776 | + ]; | |
| 777 | + } | |
| 778 | + | |
| 779 | + return [ | |
| 780 | + 'stage' => 'ok', | |
| 781 | + 'detail' => __( 'The endpoint answers AI-client User-Agents the same way it answers WordPress, so no bot filter is blocking them. This cannot see an IP-level block of the AI vendor.', 'thinkrank' ), | |
| 782 | + ]; | |
| 783 | + } | |
| 784 | + | |
| 785 | + // -- Helpers ----------------------------------------------------------- | |
| 786 | + | |
| 787 | + /** | |
| 788 | + * Status code of one unauthenticated probe, or null if it never answered. | |
| 789 | + * | |
| 790 | + * @param string $url Endpoint to call. | |
| 791 | + * @param string|null $agent User-Agent to send, or null for WordPress's own. | |
| 792 | + * @return int|null | |
| 793 | + */ | |
| 794 | + private static function probe_status( string $url, ?string $agent ): ?int { | |
| 795 | + $args = [ | |
| 796 | + 'timeout' => 10, | |
| 797 | + 'redirection' => 0, | |
| 798 | + 'headers' => [ | |
| 799 | + 'Content-Type' => 'application/json', | |
| 800 | + 'Accept' => 'application/json', | |
| 801 | + ], | |
| 802 | + 'body' => wp_json_encode( | |
| 803 | + [ | |
| 804 | + 'jsonrpc' => '2.0', | |
| 805 | + 'id' => 1, | |
| 806 | + 'method' => 'initialize', | |
| 807 | + 'params' => [], | |
| 808 | + ] | |
| 809 | + ), | |
| 810 | + ]; | |
| 811 | + if ( null !== $agent ) { | |
| 812 | + $args['user-agent'] = $agent; | |
| 813 | + } | |
| 814 | + | |
| 815 | + $response = wp_remote_post( $url, $args ); | |
| 816 | + if ( is_wp_error( $response ) ) { | |
| 817 | + return null; | |
| 818 | + } | |
| 819 | + return (int) wp_remote_retrieve_response_code( $response ); | |
| 820 | + } | |
| 821 | + | |
| 822 | + /** | |
| 823 | + * Shape one check for the UI list. | |
| 824 | + * | |
| 825 | + * @param string $id Check id. | |
| 826 | + * @param string $label Human label. | |
| 827 | + * @param string $stage Resulting stage ('ok' when it passed). | |
| 828 | + * @param string $detail Explanatory line. | |
| 829 | + * @param string $doc_url Optional docs page for a failure the user has to | |
| 830 | + * fix outside WordPress. Omitted when empty. | |
| 831 | + * @return array{id:string,label:string,ok:bool,detail:string,doc_url?:string} | |
| 832 | + */ | |
| 833 | + private static function check( string $id, string $label, string $stage, string $detail, string $doc_url = '' ): array { | |
| 834 | + $check = [ | |
| 835 | + 'id' => $id, | |
| 836 | + 'label' => $label, | |
| 837 | + 'ok' => 'ok' === $stage, | |
| 838 | + 'detail' => $detail, | |
| 839 | + ]; | |
| 840 | + | |
| 841 | + if ( '' !== $doc_url ) { | |
| 842 | + $check['doc_url'] = $doc_url; | |
| 843 | + } | |
| 844 | + | |
| 845 | + return $check; | |
| 138 | 846 | } |
| 139 | 847 | } |