PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.1
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 1.1.0 1.10.0 All 48 releases
thinkrank / includes / mcp / class-mcp-self-test.php

class-mcp-self-test.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.0.1, at includes/mcp/class-mcp-self-test.php

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