| @@ -62,12 +62,35 @@ | ||
| 62 | 62 | * deliberate. See #379. |
| 63 | 63 | */ |
| 64 | 64 | private const CLIENT_USER_AGENTS = [ |
| 65 | 65 | 'python-requests/2.32.3', |
| 66 | + // aiohttp's default, `Python/{major}.{minor} aiohttp/{version}`. A | |
| 67 | + // large share of real MCP backends send this shape, and it is the one | |
| 68 | + // an nginx "block bad bots" rule matches first because it leads with | |
| 69 | + // `Python/`. Its absence here is half of #884: a customer's host was | |
| 70 | + // refusing it while this check reported green. | |
| 71 | + 'Python/3.11 aiohttp/3.9.5', | |
| 66 | 72 | 'node-fetch/3.3.2', |
| 73 | + // No User-Agent header at all, which is what a Cloudflare managed | |
| 74 | + // challenge refuses. probe_status() sends this as an absent header | |
| 75 | + // rather than an empty one: measured, `'user-agent' => ''` makes | |
| 76 | + // WP_Http omit the header instead of falling back to WordPress's own | |
| 77 | + // agent, so this probe tests what it says it tests. | |
| 78 | + self::NO_USER_AGENT, | |
| 67 | 79 | ]; |
| 68 | 80 | |
| 69 | 81 | /** |
| 82 | + * Stands for "send no User-Agent header" in CLIENT_USER_AGENTS. | |
| 83 | + * | |
| 84 | + * Distinct from the `null` probe_status() takes for WordPress's own agent: | |
| 85 | + * that one is the baseline every probe is compared against. | |
| 86 | + * | |
| 87 | + * @since 2.14.0 | |
| 88 | + * @var string | |
| 89 | + */ | |
| 90 | + private const NO_USER_AGENT = ''; | |
| 91 | + | |
| 92 | + /** | |
| 70 | 93 | * Where an affected site owner is sent for the workaround list. The plugin |
| 71 | 94 | * cannot fix an edge block, so the failing check hands over the diagnostic |
| 72 | 95 | * and the host-side options instead. |
| 73 | 96 | */ |
| @@ -116,9 +139,9 @@ | ||
| 116 | 139 | // but this site can no longer decrypt it, so there is nothing to |
| 117 | 140 | // present. Probing with '' would report an authentication failure |
| 118 | 141 | // and point support at entirely the wrong thing. |
| 119 | 142 | $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' ); | |
| 143 | + $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 | 144 | return $result; |
| 122 | 145 | } |
| 123 | 146 | |
| 124 | 147 | $token = Mcp_Pairing::site_token(); |
| @@ -147,9 +170,9 @@ | ||
| 147 | 170 | $result['checks'][] = self::check( 'challenge', __( 'OAuth challenge', 'thinkrank' ), $challenge['stage'], $challenge['detail'] ); |
| 148 | 171 | |
| 149 | 172 | // Only reported when it could actually run — claiming a pass we did |
| 150 | 173 | // not measure is the failure mode this whole test exists to avoid. |
| 151 | - $user_agent = self::probe_user_agent( $endpoint ); | |
| 174 | + $user_agent = self::probe_user_agent( $endpoint, $fallback ); | |
| 152 | 175 | if ( null !== $user_agent ) { |
| 153 | 176 | $result['checks'][] = self::check( 'user_agent', __( 'Client access', 'thinkrank' ), $user_agent['stage'], $user_agent['detail'], $user_agent['doc_url'] ?? '' ); |
| 154 | 177 | } |
| 155 | 178 | |
| @@ -166,10 +189,10 @@ | ||
| 166 | 189 | 'locked_clients', |
| 167 | 190 | sprintf( |
| 168 | 191 | /* translators: %d: number of currently locked-out clients. */ |
| 169 | 192 | _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.', | |
| 193 | + '%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.', | |
| 194 | + '%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 | 195 | $lockouts, |
| 173 | 196 | 'thinkrank' |
| 174 | 197 | ), |
| 175 | 198 | $lockouts |
| @@ -286,9 +309,9 @@ | ||
| 286 | 309 | if ( 404 === $status ) { |
| 287 | 310 | $out['stage'] = 'rewrite'; |
| 288 | 311 | $out['detail'] = sprintf( |
| 289 | 312 | /* translators: %s: endpoint URL. */ |
| 290 | - __( '%s returned 404 — WordPress does not know this URL. Re-save Settings → Permalinks to rebuild the rewrite rules.', 'thinkrank' ), | |
| 313 | + __( '%s returned 404. WordPress does not know this URL. Re-save Settings → Permalinks to rebuild the rewrite rules.', 'thinkrank' ), | |
| 291 | 314 | $url |
| 292 | 315 | ); |
| 293 | 316 | return $out; |
| 294 | 317 | } |
| @@ -329,9 +352,9 @@ | ||
| 329 | 352 | $out['stage'] = 'no_tools'; |
| 330 | 353 | $out['tools'] = is_array( $tools ) ? count( $tools ) : 0; |
| 331 | 354 | $out['detail'] = sprintf( |
| 332 | 355 | /* 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' ), | |
| 356 | + __( '%1$s answered but returned no tool catalog. Confirm the MCP runtime is built and abilities are registered. Diagnostics: %2$s', 'thinkrank' ), | |
| 334 | 357 | $url, |
| 335 | 358 | Abilities_Registrar::summary() |
| 336 | 359 | ); |
| 337 | 360 | return $out; |
| @@ -379,9 +402,9 @@ | ||
| 379 | 402 | 'stage' => 'stale_static_discovery', |
| 380 | 403 | 'documents' => $documents, |
| 381 | 404 | 'detail' => sprintf( |
| 382 | 405 | /* 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' ), | |
| 406 | + __( '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 | 407 | $still_stale['file'], |
| 385 | 408 | $still_stale['key'], |
| 386 | 409 | '' === $still_stale['found'] ? __( 'nothing', 'thinkrank' ) : $still_stale['found'], |
| 387 | 410 | $still_stale['expected'] |
| @@ -488,9 +511,9 @@ | ||
| 488 | 511 | } |
| 489 | 512 | return [ |
| 490 | 513 | 'stage' => 'ok', |
| 491 | 514 | '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' ), | |
| 515 | + '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, such as ChatGPT and 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 | 516 | ]; |
| 494 | 517 | } |
| 495 | 518 | } |
| 496 | 519 | |
| @@ -606,9 +629,9 @@ | ||
| 606 | 629 | return [ |
| 607 | 630 | 'stage' => 'discovery', |
| 608 | 631 | 'detail' => sprintf( |
| 609 | 632 | /* 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' ), | |
| 633 | + __( '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 | 634 | $endpoint, |
| 612 | 635 | $secure |
| 613 | 636 | ), |
| 614 | 637 | ]; |
| @@ -670,9 +693,9 @@ | ||
| 670 | 693 | return [ |
| 671 | 694 | 'stage' => 'challenge', |
| 672 | 695 | 'detail' => sprintf( |
| 673 | 696 | /* 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' ), | |
| 697 | + __( '%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 | 698 | $url |
| 676 | 699 | ), |
| 677 | 700 | ]; |
| 678 | 701 | } |
| @@ -743,53 +766,97 @@ | ||
| 743 | 766 | * which host firewalls usually trust, so it catches UA filtering but NOT |
| 744 | 767 | * an IP-range block of the AI vendor. A green result here does not prove |
| 745 | 768 | * an external client can connect. |
| 746 | 769 | * |
| 770 | + * Both URLs are probed, not just the pretty one. A rule scoped to a path | |
| 771 | + * blocks one and not the other, and reporting "REST fallback URL ✓" for a | |
| 772 | + * path that was only ever reached under WordPress's own agent is the pass | |
| 773 | + * this test was written to avoid (#884). | |
| 774 | + * | |
| 747 | 775 | * @param string $endpoint Pretty endpoint URL. |
| 776 | + * @param string $fallback REST endpoint URL. | |
| 748 | 777 | * @return array{stage:string,detail:string}|null Null when it could not run. |
| 749 | 778 | */ |
| 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 | - } | |
| 779 | + private static function probe_user_agent( string $endpoint, string $fallback ): ?array { | |
| 780 | + $probed = 0; | |
| 755 | 781 | |
| 756 | - foreach ( self::CLIENT_USER_AGENTS as $agent ) { | |
| 757 | - $status = self::probe_status( $endpoint, $agent ); | |
| 758 | - if ( null === $status || $status === $baseline ) { | |
| 759 | - continue; | |
| 782 | + foreach ( [ $endpoint, $fallback ] as $url ) { | |
| 783 | + $baseline = self::probe_status( $url, null ); | |
| 784 | + if ( null === $baseline ) { | |
| 785 | + continue; // This URL is unreachable — the other checks own that. | |
| 760 | 786 | } |
| 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; | |
| 787 | + | |
| 788 | + ++$probed; | |
| 789 | + | |
| 790 | + foreach ( self::CLIENT_USER_AGENTS as $agent ) { | |
| 791 | + $status = self::probe_status( $url, $agent ); | |
| 792 | + if ( null === $status || $status === $baseline ) { | |
| 793 | + continue; | |
| 794 | + } | |
| 795 | + // A different status is only damning when it is a refusal. An | |
| 796 | + // MCP answer (401 challenge / 200 / 202) under any UA is fine. | |
| 797 | + if ( in_array( $status, [ 200, 202, 401 ], true ) ) { | |
| 798 | + continue; | |
| 799 | + } | |
| 800 | + return [ | |
| 801 | + 'stage' => 'ua_filter', | |
| 802 | + 'doc_url' => self::HOSTING_DOC_URL, | |
| 803 | + 'detail' => sprintf( | |
| 804 | + /* translators: 1: endpoint URL that refused the probe, 2: user agent description, 3: HTTP status returned for it, 4: HTTP status returned for WordPress's own user agent. */ | |
| 805 | + __( 'Ask the host to exempt %1$s from its bot filtering, along with the /.well-known/ documents. That URL answered %4$d for WordPress and %3$d for %2$s, so a security plugin, firewall or host-level "block bad bots" rule is refusing AI clients on it. Allowlisting the User-Agents ThinkRank probes with is not enough on its own: a real client sends whichever agent its own backend uses, and the next one will be refused in the same way.', 'thinkrank' ), | |
| 806 | + $url, | |
| 807 | + self::describe_agent( $agent ), | |
| 808 | + $status, | |
| 809 | + $baseline | |
| 810 | + ), | |
| 811 | + ]; | |
| 765 | 812 | } |
| 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 | 813 | } |
| 778 | 814 | |
| 815 | + if ( 0 === $probed ) { | |
| 816 | + return null; // Neither URL answered at all. | |
| 817 | + } | |
| 818 | + | |
| 779 | 819 | return [ |
| 780 | 820 | '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' ), | |
| 821 | + 'detail' => __( 'Both the connection URL and the REST fallback answer AI-client User-Agents, including a request with no User-Agent at all, the same way they answer WordPress. No bot filter is blocking them. This cannot see an IP-level block of the AI vendor.', 'thinkrank' ), | |
| 782 | 822 | ]; |
| 783 | 823 | } |
| 784 | 824 | |
| 825 | + /** | |
| 826 | + * How one probed User-Agent reads in the failure message. | |
| 827 | + * | |
| 828 | + * The empty agent is a request with no header at all, so printing it as a | |
| 829 | + * quoted empty string would read as though nothing was tested. | |
| 830 | + * | |
| 831 | + * @since 2.14.0 | |
| 832 | + * @param string $agent Probed agent, or NO_USER_AGENT. | |
| 833 | + * @return string | |
| 834 | + */ | |
| 835 | + private static function describe_agent( string $agent ): string { | |
| 836 | + if ( self::NO_USER_AGENT === $agent ) { | |
| 837 | + return __( 'a request with no User-Agent header', 'thinkrank' ); | |
| 838 | + } | |
| 839 | + | |
| 840 | + /* translators: %s: user agent string an AI client sends. */ | |
| 841 | + return sprintf( __( 'an AI client\'s User-Agent (%s)', 'thinkrank' ), $agent ); | |
| 842 | + } | |
| 843 | + | |
| 785 | 844 | // -- Helpers ----------------------------------------------------------- |
| 786 | 845 | |
| 787 | 846 | /** |
| 788 | 847 | * Status code of one unauthenticated probe, or null if it never answered. |
| 789 | 848 | * |
| 849 | + * Three agent values, all distinct: `null` sends WordPress's own agent and | |
| 850 | + * is the baseline; a string sends that agent; and the empty string sends no | |
| 851 | + * User-Agent header at all. The last one was measured rather than assumed, | |
| 852 | + * because a fallback to WordPress's agent there would mean the empty-UA | |
| 853 | + * probe silently tested nothing while reporting a pass (#884). Against this | |
| 854 | + * site, `'user-agent' => ''` arrived with HTTP_USER_AGENT unset. | |
| 855 | + * | |
| 790 | 856 | * @param string $url Endpoint to call. |
| 791 | - * @param string|null $agent User-Agent to send, or null for WordPress's own. | |
| 857 | + * @param string|null $agent User-Agent to send, null for WordPress's own, | |
| 858 | + * or '' to send no User-Agent header. | |
| 792 | 859 | * @return int|null |
| 793 | 860 | */ |
| 794 | 861 | private static function probe_status( string $url, ?string $agent ): ?int { |
| 795 | 862 | $args = [ |