PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.0
2.14.0 2.13.0 2.12.0 2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 All 55 releases
← All changes | includes/mcp/class-mcp-self-test.php +331 -94 1.28.0 → 2.14.0 View file →
@@ -52,15 +52,52 @@
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',
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',
59 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,
60 79 ];
61 80
62 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 + /**
93 + * Where an affected site owner is sent for the workaround list. The plugin
94 + * cannot fix an edge block, so the failing check hands over the diagnostic
95 + * and the host-side options instead.
96 + */
97 + private const HOSTING_DOC_URL = 'https://thinkrank.ai/docs/mcp/hosting-compatibility/';
98 +
99 + /**
63 100 * Run the round-trip self-test.
64 101 *
65 102 * @return array<string, mixed>
66 103 */
@@ -96,8 +133,18 @@
96 133 $result['message'] = __( 'No connection token exists yet. Click Connect to mint one, then run the test again.', 'thinkrank' );
97 134 return $result;
98 135 }
99 136
137 + if ( Mcp_Pairing::state()['token_sealed'] ) {
138 + // A token exists and still authenticates the clients holding it,
139 + // but this site can no longer decrypt it, so there is nothing to
140 + // present. Probing with '' would report an authentication failure
141 + // and point support at entirely the wrong thing.
142 + $result['stage'] = 'token_sealed';
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' );
144 + return $result;
145 + }
146 +
100 147 $token = Mcp_Pairing::site_token();
101 148
102 149 $pretty = self::probe_jsonrpc( $endpoint, $token );
103 150 $rest = self::probe_jsonrpc( $fallback, $token );
@@ -123,11 +170,11 @@
123 170 $result['checks'][] = self::check( 'challenge', __( 'OAuth challenge', 'thinkrank' ), $challenge['stage'], $challenge['detail'] );
124 171
125 172 // Only reported when it could actually run — claiming a pass we did
126 173 // not measure is the failure mode this whole test exists to avoid.
127 - $user_agent = self::probe_user_agent( $endpoint );
174 + $user_agent = self::probe_user_agent( $endpoint, $fallback );
128 175 if ( null !== $user_agent ) {
129 - $result['checks'][] = self::check( 'user_agent', __( 'Client access', 'thinkrank' ), $user_agent['stage'], $user_agent['detail'] );
176 + $result['checks'][] = self::check( 'user_agent', __( 'Client access', 'thinkrank' ), $user_agent['stage'], $user_agent['detail'], $user_agent['doc_url'] ?? '' );
130 177 }
131 178
132 179 // Locked-out clients. The loopback below can pass while a REMOTE client
133 180 // is walled off by the failed-auth limiter — the exact state a connector
@@ -142,10 +189,10 @@
142 189 'locked_clients',
143 190 sprintf(
144 191 /* translators: %d: number of currently locked-out clients. */
145 192 _n(
146 - '%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.',
147 - '%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.',
148 195 $lockouts,
149 196 'thinkrank'
150 197 ),
151 198 $lockouts
@@ -262,9 +309,9 @@
262 309 if ( 404 === $status ) {
263 310 $out['stage'] = 'rewrite';
264 311 $out['detail'] = sprintf(
265 312 /* translators: %s: endpoint URL. */
266 - __( '%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' ),
267 314 $url
268 315 );
269 316 return $out;
270 317 }
@@ -305,9 +352,9 @@
305 352 $out['stage'] = 'no_tools';
306 353 $out['tools'] = is_array( $tools ) ? count( $tools ) : 0;
307 354 $out['detail'] = sprintf(
308 355 /* translators: 1: endpoint URL, 2: abilities-registry diagnostic summary. */
309 - __( '%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' ),
310 357 $url,
311 358 Abilities_Registrar::summary()
312 359 );
313 360 return $out;
@@ -330,13 +377,81 @@
330 377 *
331 378 * @return array{stage:string,detail:string}
332 379 */
333 380 private static function probe_discovery(): array {
334 - // Each document must carry an identifier EXACTLY equal to the one we
335 - // compute locally. A mere "the key exists" check passes on another
336 - // plugin's metadata served from the same /.well-known/ path, which is
337 - // the hijack case the rewrite rules already warn about.
338 - $docs = [
381 + $documents = [];
382 +
383 + // --- Published files vs. the identity this site has NOW -----------
384 + // The static /.well-known/ documents embed absolute home_url()-derived
385 + // identifiers, and the whole reason they exist is that the host serves
386 + // them before WordPress. After a domain change, an http->https switch
387 + // or a staging clone, the stale copy therefore wins over the correct
388 + // dynamic route and the site advertises an issuer it no longer owns,
389 + // which a spec-compliant client must refuse (#486).
390 + //
391 + // Checked on disk, ahead of the HTTP probes below, because loopback
392 + // does not always take the path an external client does — a site can
393 + // serve a stale document to the internet while our own request never
394 + // sees it, and every probe below then passes.
395 + $stale = Mcp_Static_Discovery::stale_document();
396 + if ( null !== $stale ) {
397 + Mcp_Static_Discovery::refresh();
398 + $still_stale = Mcp_Static_Discovery::stale_document();
399 +
400 + if ( null !== $still_stale ) {
401 + return [
402 + 'stage' => 'stale_static_discovery',
403 + 'documents' => $documents,
404 + 'detail' => sprintf(
405 + /* translators: 1: file path relative to the site root, 2: identifier name, 3: value found in the file, 4: value it should carry. */
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' ),
407 + $still_stale['file'],
408 + $still_stale['key'],
409 + '' === $still_stale['found'] ? __( 'nothing', 'thinkrank' ) : $still_stale['found'],
410 + $still_stale['expected']
411 + ),
412 + ];
413 + }
414 + }
415 +
416 + // --- The documents clients are POINTED at (must work) -------------
417 + // The 401 challenge advertises the REST-served resource metadata, and
418 + // spec-compliant clients derive the OIDC-suffix form of the AS
419 + // metadata from our path-based issuer. Neither lives under the site
420 + // root's /.well-known/ directory, so both survive hosts that
421 + // intercept that directory at the proxy edge (SiteGround). Each must
422 + // carry an identifier EXACTLY equal to the one we compute locally — a
423 + // mere "the key exists" check passes on another plugin's metadata,
424 + // which is the hijack case the rewrite rules already warn about.
425 + $primary = [
426 + Mcp_OAuth::resource_metadata_url() => [
427 + 'key' => 'resource',
428 + 'expected' => Mcp_Pairing::site_endpoint(),
429 + ],
430 + rest_url( 'thinkrank/v1/mcp/oauth/authorization-server' ) => [
431 + 'key' => 'issuer',
432 + 'expected' => Mcp_OAuth::issuer(),
433 + ],
434 + Mcp_OAuth::issuer() . '/.well-known/openid-configuration' => [
435 + 'key' => 'issuer',
436 + 'expected' => Mcp_OAuth::issuer(),
437 + ],
438 + ];
439 +
440 + foreach ( $primary as $url => $spec ) {
441 + $issue = self::probe_document( $url, $spec, $documents );
442 + if ( null !== $issue ) {
443 + return $issue;
444 + }
445 + }
446 +
447 + // --- The spec-derived /.well-known/ forms (should work) -----------
448 + // A client that ignores the challenge pointer derives these itself
449 + // (RFC 9728 / RFC 8414 path-insert). Some hosts resolve the root
450 + // /.well-known/ directory at their proxy as physical files, 404ing
451 + // before WordPress runs — measurably different from broken rewrites,
452 + // and fixable by publishing the documents AS physical files.
453 + $derived = [
339 454 home_url( '/.well-known/oauth-protected-resource/' . Mcp_Pairing::SITE_ENDPOINT_PATH ) => [
340 455 'key' => 'resource',
341 456 'expected' => Mcp_Pairing::site_endpoint(),
342 457 ],
@@ -345,66 +460,65 @@
345 460 'expected' => Mcp_OAuth::issuer(),
346 461 ],
347 462 ];
348 463
349 - $documents = [];
464 + $root_issue = null;
465 + foreach ( $derived as $url => $spec ) {
466 + $root_issue = self::probe_document( $url, $spec, $documents );
467 + if ( null !== $root_issue ) {
468 + break;
469 + }
470 + }
350 471
351 - foreach ( $docs as $url => $spec ) {
352 - $response = wp_remote_get(
353 - $url,
354 - [
355 - 'timeout' => 10,
356 - 'redirection' => 0,
357 - ]
358 - );
359 - if ( is_wp_error( $response ) ) {
360 - return [
361 - 'stage' => 'discovery',
362 - 'documents' => $documents,
363 - 'detail' => sprintf(
364 - /* translators: 1: discovery document URL, 2: transport error. */
365 - __( 'The OAuth discovery document %1$s could not be fetched: %2$s. Clients that connect by URL alone cannot authenticate without it.', 'thinkrank' ),
366 - $url,
367 - $response->get_error_message()
368 - ),
369 - ];
472 + if ( null !== $root_issue ) {
473 + // A document that answers with SOMEONE ELSE'S identity is a plugin
474 + // conflict poisoning derive-only clients — that stays a hard fail.
475 + // Only the intercepted/unreachable shapes are softened below.
476 + if ( 'mismatch' === ( $root_issue['kind'] ?? '' ) ) {
477 + return $root_issue;
370 478 }
371 - $status = (int) wp_remote_retrieve_response_code( $response );
372 - $raw = (string) wp_remote_retrieve_body( $response );
373 - $body = json_decode( $raw, true );
374 - $documents[ $url ] = is_array( $body ) ? $body : $raw;
375 479
376 - if ( 200 !== $status || ! is_array( $body ) || ! isset( $body[ $spec['key'] ] ) ) {
377 - return [
378 - 'stage' => 'discovery',
379 - 'documents' => $documents,
380 - 'detail' => sprintf(
381 - /* translators: 1: discovery document URL, 2: HTTP status code. */
382 - __( 'The OAuth discovery document %1$s returned %2$d instead of valid metadata. Re-save Settings → Permalinks; if it persists, another plugin may be claiming the /.well-known/ URLs.', 'thinkrank' ),
383 - $url,
384 - $status
385 - ),
386 - ];
480 + // The host's own trick becomes the fix: if the proxy insists on
481 + // serving /.well-known/ as physical files, give it physical files.
482 + /**
483 + * Filter whether the self-test may publish static /.well-known/
484 + * discovery files when the dynamic route is unreachable.
485 + *
486 + * @since 1.32.0
487 + *
488 + * @param bool $allowed Defaults to whether the install can host them.
489 + */
490 + $may_publish = apply_filters( 'thinkrank_mcp_static_discovery_publish', Mcp_Static_Discovery::applicable() );
491 +
492 + $healed = false;
493 + if ( $may_publish && Mcp_Static_Discovery::publish() ) {
494 + $healed = true;
495 + foreach ( $derived as $url => $spec ) {
496 + if ( null !== self::probe_document( $url, $spec, $documents ) ) {
497 + $healed = false;
498 + break;
499 + }
500 + }
387 501 }
388 502
389 - $advertised = (string) $body[ $spec['key'] ];
390 - if ( $advertised !== $spec['expected'] ) {
503 + if ( ! $healed ) {
504 + // Not fatal on its own any more: the challenge points clients
505 + // at the REST document (verified above), so the flow survives.
506 + // Say what is degraded instead of failing the whole check.
507 + $scheme_issue = self::probe_scheme();
508 + if ( null !== $scheme_issue ) {
509 + $scheme_issue['documents'] = $documents;
510 + return $scheme_issue;
511 + }
391 512 return [
392 - 'stage' => 'discovery',
513 + 'stage' => 'ok',
393 514 'documents' => $documents,
394 - 'detail' => sprintf(
395 - /* translators: 1: metadata field name, 2: value found in the document, 3: value it should be, 4: discovery document URL. */
396 - __( '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' ),
397 - $spec['key'],
398 - $advertised,
399 - $spec['expected'],
400 - $url
401 - ),
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' ),
402 516 ];
403 517 }
404 518 }
405 519
406 - // Both documents agree with us — but they agree on whatever home_url()
520 + // Documents agree with us — but they agree on whatever home_url()
407 521 // says, so a site whose stored URL is http:// while it actually serves
408 522 // https:// is self-consistently wrong. Clients connect over https and
409 523 // then reject the http identifier.
410 524 $scheme_issue = self::probe_scheme();
@@ -415,13 +529,83 @@
415 529
416 530 return [
417 531 'stage' => 'ok',
418 532 'documents' => $documents,
419 - 'detail' => __( 'Both OAuth discovery documents are served and advertise this site\'s MCP endpoint exactly.', 'thinkrank' ),
533 + 'detail' => __( 'All OAuth discovery documents are served and advertise this site\'s MCP endpoint exactly.', 'thinkrank' ),
420 534 ];
421 535 }
422 536
423 537 /**
538 + * Fetch and validate one discovery document. Appends what was actually
539 + * served to $documents either way, so support can read the site's real
540 + * responses instead of asking the customer for screenshots.
541 + *
542 + * @param string $url Document URL.
543 + * @param array{key:string,expected:string} $spec Identity field + required value.
544 + * @param array<string,mixed> $documents Accumulator (by reference).
545 + * @return array{stage:string,documents:array<string,mixed>,detail:string}|null Null when the document is valid.
546 + */
547 + private static function probe_document( string $url, array $spec, array &$documents ): ?array {
548 + $response = wp_remote_get(
549 + $url,
550 + [
551 + 'timeout' => 10,
552 + 'redirection' => 0,
553 + ]
554 + );
555 + if ( is_wp_error( $response ) ) {
556 + return [
557 + 'stage' => 'discovery',
558 + 'kind' => 'unreachable',
559 + 'documents' => $documents,
560 + 'detail' => sprintf(
561 + /* translators: 1: discovery document URL, 2: transport error. */
562 + __( 'The OAuth discovery document %1$s could not be fetched: %2$s. Clients that connect by URL alone cannot authenticate without it.', 'thinkrank' ),
563 + $url,
564 + $response->get_error_message()
565 + ),
566 + ];
567 + }
568 + $status = (int) wp_remote_retrieve_response_code( $response );
569 + $raw = (string) wp_remote_retrieve_body( $response );
570 + $body = json_decode( $raw, true );
571 + $documents[ $url ] = is_array( $body ) ? $body : $raw;
572 +
573 + if ( 200 !== $status || ! is_array( $body ) || ! isset( $body[ $spec['key'] ] ) ) {
574 + return [
575 + 'stage' => 'discovery',
576 + 'kind' => 'invalid',
577 + 'documents' => $documents,
578 + 'detail' => sprintf(
579 + /* translators: 1: discovery document URL, 2: HTTP status code. */
580 + __( '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' ),
581 + $url,
582 + $status
583 + ),
584 + ];
585 + }
586 +
587 + $advertised = (string) $body[ $spec['key'] ];
588 + if ( $advertised !== $spec['expected'] ) {
589 + return [
590 + 'stage' => 'discovery',
591 + 'kind' => 'mismatch',
592 + 'documents' => $documents,
593 + 'detail' => sprintf(
594 + /* translators: 1: metadata field name, 2: value found in the document, 3: value it should be, 4: discovery document URL. */
595 + __( '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' ),
596 + $spec['key'],
597 + $advertised,
598 + $spec['expected'],
599 + $url
600 + ),
601 + ];
602 + }
603 +
604 + return null;
605 + }
606 +
607 + /**
424 608 * Catch the reverse-proxy scheme trap: WordPress stores an http:// home
425 609 * URL, so every advertised OAuth identifier is http://, while the site is
426 610 * really served over https://. Everything is internally consistent, so no
427 611 * comparison against our own values can see it — the only tell is that the
@@ -445,9 +629,9 @@
445 629 return [
446 630 'stage' => 'discovery',
447 631 'detail' => sprintf(
448 632 /* translators: 1: http endpoint URL advertised, 2: https endpoint URL that also answers. */
449 - __( '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' ),
450 634 $endpoint,
451 635 $secure
452 636 ),
453 637 ];
@@ -509,9 +693,9 @@
509 693 return [
510 694 'stage' => 'challenge',
511 695 'detail' => sprintf(
512 696 /* translators: %s: endpoint URL. */
513 - __( '%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' ),
514 698 $url
515 699 ),
516 700 ];
517 701 }
@@ -546,9 +730,9 @@
546 730 return [
547 731 'stage' => 'challenge',
548 732 'detail' => sprintf(
549 733 /* translators: 1: resource_metadata URL from the challenge header, 2: endpoint URL. */
550 - __( '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. On a subdirectory install the spec-derived URL sits at the domain root, which WordPress cannot serve — a root redirect to this URL is needed.', 'thinkrank' ),
734 + __( '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' ),
551 735 $metadata_url,
552 736 $url
553 737 ),
554 738 ];
@@ -582,52 +766,97 @@
582 766 * which host firewalls usually trust, so it catches UA filtering but NOT
583 767 * an IP-range block of the AI vendor. A green result here does not prove
584 768 * an external client can connect.
585 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 + *
586 775 * @param string $endpoint Pretty endpoint URL.
776 + * @param string $fallback REST endpoint URL.
587 777 * @return array{stage:string,detail:string}|null Null when it could not run.
588 778 */
589 - private static function probe_user_agent( string $endpoint ): ?array {
590 - $baseline = self::probe_status( $endpoint, null );
591 - if ( null === $baseline ) {
592 - return null; // Endpoint unreachable — the other checks own that.
593 - }
779 + private static function probe_user_agent( string $endpoint, string $fallback ): ?array {
780 + $probed = 0;
594 781
595 - foreach ( self::CLIENT_USER_AGENTS as $agent ) {
596 - $status = self::probe_status( $endpoint, $agent );
597 - if ( null === $status || $status === $baseline ) {
598 - 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.
599 786 }
600 - // A different status is only damning when it is a refusal. An MCP
601 - // answer (401 challenge / 200 / 202) under any UA is fine.
602 - if ( in_array( $status, [ 200, 202, 401 ], true ) ) {
603 - 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 + ];
604 812 }
605 - return [
606 - 'stage' => 'ua_filter',
607 - 'detail' => sprintf(
608 - /* translators: 1: user agent string, 2: HTTP status returned for it, 3: HTTP status returned for WordPress's own user agent. */
609 - __( 'The endpoint answered %3$d for WordPress but %2$d for an AI client\'s User-Agent (%1$s). A security plugin, firewall or "block bad bots" rule is refusing non-browser clients — exempt the MCP and /.well-known/ paths, or no AI client will ever reach this site.', 'thinkrank' ),
610 - $agent,
611 - $status,
612 - $baseline
613 - ),
614 - ];
615 813 }
616 814
815 + if ( 0 === $probed ) {
816 + return null; // Neither URL answered at all.
817 + }
818 +
617 819 return [
618 820 'stage' => 'ok',
619 - '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' ),
620 822 ];
621 823 }
622 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 +
623 844 // -- Helpers -----------------------------------------------------------
624 845
625 846 /**
626 847 * Status code of one unauthenticated probe, or null if it never answered.
627 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 + *
628 856 * @param string $url Endpoint to call.
629 - * @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.
630 859 * @return int|null
631 860 */
632 861 private static function probe_status( string $url, ?string $agent ): ?int {
633 862 $args = [
@@ -659,19 +888,27 @@
659 888
660 889 /**
661 890 * Shape one check for the UI list.
662 891 *
663 - * @param string $id Check id.
664 - * @param string $label Human label.
665 - * @param string $stage Resulting stage ('ok' when it passed).
666 - * @param string $detail Explanatory line.
667 - * @return array{id:string,label:string,ok:bool,detail:string}
892 + * @param string $id Check id.
893 + * @param string $label Human label.
894 + * @param string $stage Resulting stage ('ok' when it passed).
895 + * @param string $detail Explanatory line.
896 + * @param string $doc_url Optional docs page for a failure the user has to
897 + * fix outside WordPress. Omitted when empty.
898 + * @return array{id:string,label:string,ok:bool,detail:string,doc_url?:string}
668 899 */
669 - private static function check( string $id, string $label, string $stage, string $detail ): array {
670 - return [
900 + private static function check( string $id, string $label, string $stage, string $detail, string $doc_url = '' ): array {
901 + $check = [
671 902 'id' => $id,
672 903 'label' => $label,
673 904 'ok' => 'ok' === $stage,
674 905 'detail' => $detail,
675 906 ];
907 +
908 + if ( '' !== $doc_url ) {
909 + $check['doc_url'] = $doc_url;
910 + }
911 +
912 + return $check;
676 913 }
677 914 }