PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.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 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
← All changes | includes/mcp/class-mcp-self-test.php +235 -65 1.28.0 → 2.9.0 View file →
@@ -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
@@ -330,13 +354,81 @@
330 354 *
331 355 * @return array{stage:string,detail:string}
332 356 */
333 357 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 = [
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 = [
339 431 home_url( '/.well-known/oauth-protected-resource/' . Mcp_Pairing::SITE_ENDPOINT_PATH ) => [
340 432 'key' => 'resource',
341 433 'expected' => Mcp_Pairing::site_endpoint(),
342 434 ],
@@ -345,66 +437,65 @@
345 437 'expected' => Mcp_OAuth::issuer(),
346 438 ],
347 439 ];
348 440
349 - $documents = [];
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 + }
350 448
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 - ];
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;
370 455 }
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 456
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 - ];
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 + }
387 478 }
388 479
389 - $advertised = (string) $body[ $spec['key'] ];
390 - if ( $advertised !== $spec['expected'] ) {
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 + }
391 489 return [
392 - 'stage' => 'discovery',
490 + 'stage' => 'ok',
393 491 '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 - ),
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' ),
402 493 ];
403 494 }
404 495 }
405 496
406 - // Both documents agree with us — but they agree on whatever home_url()
497 + // Documents agree with us — but they agree on whatever home_url()
407 498 // says, so a site whose stored URL is http:// while it actually serves
408 499 // https:// is self-consistently wrong. Clients connect over https and
409 500 // then reject the http identifier.
410 501 $scheme_issue = self::probe_scheme();
@@ -415,13 +506,83 @@
415 506
416 507 return [
417 508 'stage' => 'ok',
418 509 'documents' => $documents,
419 - 'detail' => __( 'Both OAuth discovery documents are served and advertise this site\'s MCP endpoint exactly.', 'thinkrank' ),
510 + 'detail' => __( 'All OAuth discovery documents are served and advertise this site\'s MCP endpoint exactly.', 'thinkrank' ),
420 511 ];
421 512 }
422 513
423 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 + /**
424 585 * Catch the reverse-proxy scheme trap: WordPress stores an http:// home
425 586 * URL, so every advertised OAuth identifier is http://, while the site is
426 587 * really served over https://. Everything is internally consistent, so no
427 588 * comparison against our own values can see it — the only tell is that the
@@ -546,9 +707,9 @@
546 707 return [
547 708 'stage' => 'challenge',
548 709 'detail' => sprintf(
549 710 /* 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' ),
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' ),
551 712 $metadata_url,
552 713 $url
553 714 ),
554 715 ];
@@ -602,12 +763,13 @@
602 763 if ( in_array( $status, [ 200, 202, 401 ], true ) ) {
603 764 continue;
604 765 }
605 766 return [
606 - 'stage' => 'ua_filter',
607 - 'detail' => sprintf(
767 + 'stage' => 'ua_filter',
768 + 'doc_url' => self::HOSTING_DOC_URL,
769 + 'detail' => sprintf(
608 770 /* 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' ),
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' ),
610 772 $agent,
611 773 $status,
612 774 $baseline
613 775 ),
@@ -659,19 +821,27 @@
659 821
660 822 /**
661 823 * Shape one check for the UI list.
662 824 *
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}
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}
668 832 */
669 - private static function check( string $id, string $label, string $stage, string $detail ): array {
670 - return [
833 + private static function check( string $id, string $label, string $stage, string $detail, string $doc_url = '' ): array {
834 + $check = [
671 835 'id' => $id,
672 836 'label' => $label,
673 837 'ok' => 'ok' === $stage,
674 838 'detail' => $detail,
675 839 ];
840 +
841 + if ( '' !== $doc_url ) {
842 + $check['doc_url'] = $doc_url;
843 + }
844 +
845 + return $check;
676 846 }
677 847 }