| @@ -52,8 +52,15 @@ | ||
| 52 | 52 | * none of them is a browser, which is exactly what "block bad bots" rules |
| 53 | 53 | * key on. A site that answers WordPress's own UA but 403s these is |
| 54 | 54 | * unreachable for every AI client while looking perfectly healthy from |
| 55 | 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. | |
| 56 | 63 | */ |
| 57 | 64 | private const CLIENT_USER_AGENTS = [ |
| 58 | 65 | 'python-requests/2.32.3', |
| 59 | 66 | 'node-fetch/3.3.2', |
| @@ -59,8 +66,15 @@ | ||
| 59 | 66 | 'node-fetch/3.3.2', |
| 60 | 67 | ]; |
| 61 | 68 | |
| 62 | 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 | + /** | |
| 63 | 77 | * Run the round-trip self-test. |
| 64 | 78 | * |
| 65 | 79 | * @return array<string, mixed> |
| 66 | 80 | */ |
| @@ -96,8 +110,18 @@ | ||
| 96 | 110 | $result['message'] = __( 'No connection token exists yet. Click Connect to mint one, then run the test again.', 'thinkrank' ); |
| 97 | 111 | return $result; |
| 98 | 112 | } |
| 99 | 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 | + | |
| 100 | 124 | $token = Mcp_Pairing::site_token(); |
| 101 | 125 | |
| 102 | 126 | $pretty = self::probe_jsonrpc( $endpoint, $token ); |
| 103 | 127 | $rest = self::probe_jsonrpc( $fallback, $token ); |
| @@ -125,9 +149,9 @@ | ||
| 125 | 149 | // Only reported when it could actually run — claiming a pass we did |
| 126 | 150 | // not measure is the failure mode this whole test exists to avoid. |
| 127 | 151 | $user_agent = self::probe_user_agent( $endpoint ); |
| 128 | 152 | if ( null !== $user_agent ) { |
| 129 | - $result['checks'][] = self::check( 'user_agent', __( 'Client access', 'thinkrank' ), $user_agent['stage'], $user_agent['detail'] ); | |
| 153 | + $result['checks'][] = self::check( 'user_agent', __( 'Client access', 'thinkrank' ), $user_agent['stage'], $user_agent['detail'], $user_agent['doc_url'] ?? '' ); | |
| 130 | 154 | } |
| 131 | 155 | |
| 132 | 156 | // Locked-out clients. The loopback below can pass while a REMOTE client |
| 133 | 157 | // is walled off by the failed-auth limiter — the exact state a connector |
| @@ -332,8 +356,41 @@ | ||
| 332 | 356 | */ |
| 333 | 357 | private static function probe_discovery(): array { |
| 334 | 358 | $documents = []; |
| 335 | 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 | + | |
| 336 | 393 | // --- The documents clients are POINTED at (must work) ------------- |
| 337 | 394 | // The 401 challenge advertises the REST-served resource metadata, and |
| 338 | 395 | // spec-compliant clients derive the OIDC-suffix form of the AS |
| 339 | 396 | // metadata from our path-based issuer. Neither lives under the site |
| @@ -706,10 +763,11 @@ | ||
| 706 | 763 | if ( in_array( $status, [ 200, 202, 401 ], true ) ) { |
| 707 | 764 | continue; |
| 708 | 765 | } |
| 709 | 766 | return [ |
| 710 | - 'stage' => 'ua_filter', | |
| 711 | - 'detail' => sprintf( | |
| 767 | + 'stage' => 'ua_filter', | |
| 768 | + 'doc_url' => self::HOSTING_DOC_URL, | |
| 769 | + 'detail' => sprintf( | |
| 712 | 770 | /* translators: 1: user agent string, 2: HTTP status returned for it, 3: HTTP status returned for WordPress's own user agent. */ |
| 713 | 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' ), |
| 714 | 772 | $agent, |
| 715 | 773 | $status, |
| @@ -763,19 +821,27 @@ | ||
| 763 | 821 | |
| 764 | 822 | /** |
| 765 | 823 | * Shape one check for the UI list. |
| 766 | 824 | * |
| 767 | - * @param string $id Check id. | |
| 768 | - * @param string $label Human label. | |
| 769 | - * @param string $stage Resulting stage ('ok' when it passed). | |
| 770 | - * @param string $detail Explanatory line. | |
| 771 | - * @return array{id:string,label:string,ok:bool,detail:string} | |
| 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} | |
| 772 | 832 | */ |
| 773 | - private static function check( string $id, string $label, string $stage, string $detail ): array { | |
| 774 | - return [ | |
| 833 | + private static function check( string $id, string $label, string $stage, string $detail, string $doc_url = '' ): array { | |
| 834 | + $check = [ | |
| 775 | 835 | 'id' => $id, |
| 776 | 836 | 'label' => $label, |
| 777 | 837 | 'ok' => 'ok' === $stage, |
| 778 | 838 | 'detail' => $detail, |
| 779 | 839 | ]; |
| 840 | + | |
| 841 | + if ( '' !== $doc_url ) { | |
| 842 | + $check['doc_url'] = $doc_url; | |
| 843 | + } | |
| 844 | + | |
| 845 | + return $check; | |
| 780 | 846 | } |
| 781 | 847 | } |