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
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.14.0, at includes/mcp/class-mcp-self-test.php

915 lines 36.1 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 // 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',
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,
79 ];
80
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 /**
100 * Run the round-trip self-test.
101 *
102 * @return array<string, mixed>
103 */
104 public static function run(): array {
105 $endpoint = Mcp_Pairing::site_endpoint();
106 $fallback = Mcp_Pairing::site_endpoint_fallback();
107
108 $result = [
109 'ok' => false,
110 'stage' => '',
111 'message' => '',
112 'endpoint' => $endpoint,
113 'endpoint_rest' => $fallback,
114 'mcp_enabled' => Mcp_Manager::is_enabled(),
115 'connected' => Mcp_Pairing::is_connected(),
116 'http_status' => null,
117 'redirected' => false,
118 'authenticated' => false,
119 'tools_count' => null,
120 'checks' => [],
121 // url => decoded metadata (or the raw body when it isn't JSON).
122 'discovery_documents' => [],
123 ];
124
125 if ( ! $result['mcp_enabled'] ) {
126 $result['stage'] = 'disabled';
127 $result['message'] = __( 'MCP access is turned off, so the endpoint refuses every request. Enable MCP access above and try again.', 'thinkrank' );
128 return $result;
129 }
130
131 if ( ! $result['connected'] ) {
132 $result['stage'] = 'not_connected';
133 $result['message'] = __( 'No connection token exists yet. Click Connect to mint one, then run the test again.', 'thinkrank' );
134 return $result;
135 }
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
147 $token = Mcp_Pairing::site_token();
148
149 $pretty = self::probe_jsonrpc( $endpoint, $token );
150 $rest = self::probe_jsonrpc( $fallback, $token );
151
152 // Back-compat top-level fields describe the primary (pretty) endpoint,
153 // falling back to the REST route when the pretty URL never answered.
154 $primary = 'unreachable' === $pretty['stage'] ? $rest : $pretty;
155 $result['http_status'] = $primary['status'];
156 $result['redirected'] = 'redirect' === $primary['stage'];
157 $result['authenticated'] = $primary['authenticated'];
158 $result['tools_count'] = $primary['tools'];
159
160 $result['checks'][] = self::check( 'endpoint', __( 'Connection URL', 'thinkrank' ), $pretty['stage'], $pretty['detail'] );
161 $result['checks'][] = self::check( 'fallback', __( 'REST fallback URL', 'thinkrank' ), $rest['stage'], $rest['detail'] );
162
163 $discovery = self::probe_discovery();
164 // The documents themselves, so support can read what the site actually
165 // serves instead of asking the customer for screenshots.
166 $result['discovery_documents'] = $discovery['documents'];
167 $result['checks'][] = self::check( 'discovery', __( 'OAuth discovery', 'thinkrank' ), $discovery['stage'], $discovery['detail'] );
168
169 $challenge = self::probe_challenge( $endpoint, $fallback );
170 $result['checks'][] = self::check( 'challenge', __( 'OAuth challenge', 'thinkrank' ), $challenge['stage'], $challenge['detail'] );
171
172 // Only reported when it could actually run — claiming a pass we did
173 // not measure is the failure mode this whole test exists to avoid.
174 $user_agent = self::probe_user_agent( $endpoint, $fallback );
175 if ( null !== $user_agent ) {
176 $result['checks'][] = self::check( 'user_agent', __( 'Client access', 'thinkrank' ), $user_agent['stage'], $user_agent['detail'], $user_agent['doc_url'] ?? '' );
177 }
178
179 // Locked-out clients. The loopback below can pass while a REMOTE client
180 // is walled off by the failed-auth limiter — the exact state a connector
181 // still holding a rotated-away token produces. Reported only when the
182 // count is knowable (null under a persistent object cache).
183 $lockouts = Mcp_Rate_Limiter::active_lockouts();
184 $result['locked_clients'] = $lockouts;
185 if ( null !== $lockouts && $lockouts > 0 ) {
186 $result['checks'][] = self::check(
187 'lockouts',
188 __( 'Client lockouts', 'thinkrank' ),
189 'locked_clients',
190 sprintf(
191 /* translators: %d: number of currently locked-out clients. */
192 _n(
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.',
195 $lockouts,
196 'thinkrank'
197 ),
198 $lockouts
199 )
200 );
201 }
202
203 // The pretty URL failing while the fallback works is its own finding:
204 // the site is usable, but only via the REST URL.
205 if ( 'ok' !== $pretty['stage'] && 'ok' === $rest['stage'] ) {
206 $result['stage'] = 'rewrite';
207 $result['message'] = sprintf(
208 /* translators: 1: pretty MCP endpoint URL, 2: REST fallback URL. */
209 __( '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' ),
210 $endpoint,
211 $fallback
212 );
213 return $result;
214 }
215
216 // Otherwise report the first failing check in order.
217 foreach ( [ $pretty, $rest, $discovery, $challenge, $user_agent ] as $check ) {
218 if ( null === $check ) {
219 continue;
220 }
221 if ( 'ok' !== $check['stage'] ) {
222 $result['stage'] = $check['stage'];
223 $result['message'] = $check['detail'];
224 return $result;
225 }
226 }
227
228 $result['ok'] = true;
229 $result['stage'] = 'ok';
230 $result['message'] = sprintf(
231 /* translators: %d: number of MCP tools returned. */
232 _n(
233 'Connection healthy: the endpoint authenticated, offered OAuth, and returned %d tool.',
234 'Connection healthy: the endpoint authenticated, offered OAuth, and returned %d tools.',
235 (int) $result['tools_count'],
236 'thinkrank'
237 ),
238 (int) $result['tools_count']
239 );
240 return $result;
241 }
242
243 // -- Probes ------------------------------------------------------------
244
245 /**
246 * One authenticated JSON-RPC `tools/list` round trip.
247 *
248 * @param string $url Endpoint to call.
249 * @param string $token Connection token.
250 * @return array{stage:string,status:?int,tools:?int,authenticated:bool,detail:string}
251 */
252 private static function probe_jsonrpc( string $url, string $token ): array {
253 $response = wp_remote_post(
254 $url,
255 [
256 'timeout' => 10,
257 // Don't follow redirects: a 301/302 here IS the finding (the
258 // classic http<->https scheme bounce), so surface it verbatim.
259 'redirection' => 0,
260 'headers' => [
261 'Authorization' => 'Bearer ' . $token,
262 'Content-Type' => 'application/json',
263 'Accept' => 'application/json',
264 ],
265 'body' => wp_json_encode(
266 [
267 'jsonrpc' => '2.0',
268 'id' => 1,
269 'method' => 'tools/list',
270 ]
271 ),
272 ]
273 );
274
275 $out = [
276 'stage' => 'ok',
277 'status' => null,
278 'tools' => null,
279 'authenticated' => false,
280 'detail' => '',
281 ];
282
283 if ( is_wp_error( $response ) ) {
284 $err = $response->get_error_message();
285 $is_tls = false !== stripos( $err, 'ssl' ) || false !== stripos( $err, 'certificate' );
286 $out['stage'] = $is_tls ? 'tls' : 'unreachable';
287 $out['detail'] = $is_tls
288 /* translators: 1: endpoint URL, 2: underlying transport error. */
289 ? 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 )
290 /* translators: 1: endpoint URL, 2: underlying transport error. */
291 : sprintf( __( '%1$s could not be reached: %2$s.', 'thinkrank' ), $url, $err );
292 return $out;
293 }
294
295 $status = (int) wp_remote_retrieve_response_code( $response );
296 $out['status'] = $status;
297
298 if ( in_array( $status, [ 301, 302, 307, 308 ], true ) ) {
299 $location = (string) wp_remote_retrieve_header( $response, 'location' );
300 $out['stage'] = 'redirect';
301 $out['detail'] = $location
302 /* translators: 1: endpoint URL, 2: redirect target URL. */
303 ? 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 )
304 /* translators: %s: endpoint URL. */
305 : sprintf( __( '%s redirected instead of answering, which usually means the site address and WordPress address schemes disagree.', 'thinkrank' ), $url );
306 return $out;
307 }
308
309 if ( 404 === $status ) {
310 $out['stage'] = 'rewrite';
311 $out['detail'] = sprintf(
312 /* translators: %s: endpoint URL. */
313 __( '%s returned 404. WordPress does not know this URL. Re-save Settings → Permalinks to rebuild the rewrite rules.', 'thinkrank' ),
314 $url
315 );
316 return $out;
317 }
318
319 if ( 401 === $status || 403 === $status ) {
320 $out['stage'] = 'auth';
321 $out['detail'] = sprintf(
322 /* translators: %s: endpoint URL. */
323 __( '%s rejected the connection token (authentication failed). Rotate the token and reconnect your AI client.', 'thinkrank' ),
324 $url
325 );
326 return $out;
327 }
328
329 if ( 429 === $status ) {
330 $out['stage'] = 'auth';
331 $out['detail'] = sprintf(
332 /* translators: %s: endpoint URL. */
333 __( '%s is rate-limiting this server after repeated failed tokens. Wait for the lockout to lapse, then rotate the token and reconnect.', 'thinkrank' ),
334 $url
335 );
336 return $out;
337 }
338
339 $out['authenticated'] = true;
340 $body = json_decode( (string) wp_remote_retrieve_body( $response ), true );
341 $tools = ( is_array( $body ) && isset( $body['result']['tools'] ) && is_array( $body['result']['tools'] ) )
342 ? $body['result']['tools']
343 : null;
344
345 // An EMPTY tools array counts as a failure, not a pass: that is exactly
346 // the shape of #241 — a connection an AI client reports as healthy
347 // while it has nothing to call. The abilities snapshot goes into the
348 // detail so support can tell "no ThinkRank abilities registered"
349 // (foreign Abilities API copy owns the registry) from "the runtime is
350 // missing entirely".
351 if ( 200 !== $status || null === $tools || [] === $tools ) {
352 $out['stage'] = 'no_tools';
353 $out['tools'] = is_array( $tools ) ? count( $tools ) : 0;
354 $out['detail'] = sprintf(
355 /* translators: 1: endpoint URL, 2: abilities-registry diagnostic summary. */
356 __( '%1$s answered but returned no tool catalog. Confirm the MCP runtime is built and abilities are registered. Diagnostics: %2$s', 'thinkrank' ),
357 $url,
358 Abilities_Registrar::summary()
359 );
360 return $out;
361 }
362
363 $out['tools'] = count( $tools );
364 $out['detail'] = sprintf(
365 /* translators: 1: endpoint URL, 2: number of tools. */
366 __( '%1$s authenticated and returned %2$d tools.', 'thinkrank' ),
367 $url,
368 count( $tools )
369 );
370 return $out;
371 }
372
373 /**
374 * Fetch both OAuth discovery documents and confirm they are served and
375 * well-formed. An OAuth client reads these before it holds any credential,
376 * so a 404 here is invisible to every other check.
377 *
378 * @return array{stage:string,detail:string}
379 */
380 private static function probe_discovery(): array {
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 = [
454 home_url( '/.well-known/oauth-protected-resource/' . Mcp_Pairing::SITE_ENDPOINT_PATH ) => [
455 'key' => 'resource',
456 'expected' => Mcp_Pairing::site_endpoint(),
457 ],
458 home_url( '/.well-known/oauth-authorization-server/' . Mcp_Pairing::SITE_ENDPOINT_PATH ) => [
459 'key' => 'issuer',
460 'expected' => Mcp_OAuth::issuer(),
461 ],
462 ];
463
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 }
471
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;
478 }
479
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 }
501 }
502
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 }
512 return [
513 'stage' => 'ok',
514 'documents' => $documents,
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' ),
516 ];
517 }
518 }
519
520 // Documents agree with us — but they agree on whatever home_url()
521 // says, so a site whose stored URL is http:// while it actually serves
522 // https:// is self-consistently wrong. Clients connect over https and
523 // then reject the http identifier.
524 $scheme_issue = self::probe_scheme();
525 if ( null !== $scheme_issue ) {
526 $scheme_issue['documents'] = $documents;
527 return $scheme_issue;
528 }
529
530 return [
531 'stage' => 'ok',
532 'documents' => $documents,
533 'detail' => __( 'All OAuth discovery documents are served and advertise this site\'s MCP endpoint exactly.', 'thinkrank' ),
534 ];
535 }
536
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 /**
608 * Catch the reverse-proxy scheme trap: WordPress stores an http:// home
609 * URL, so every advertised OAuth identifier is http://, while the site is
610 * really served over https://. Everything is internally consistent, so no
611 * comparison against our own values can see it — the only tell is that the
612 * https:// variant of the endpoint answers too.
613 *
614 * @return array{stage:string,detail:string}|null Null when nothing is wrong.
615 */
616 private static function probe_scheme(): ?array {
617 $endpoint = Mcp_Pairing::site_endpoint();
618 if ( 'https' === wp_parse_url( $endpoint, PHP_URL_SCHEME ) ) {
619 return null;
620 }
621
622 $secure = set_url_scheme( $endpoint, 'https' );
623 if ( null === self::probe_status( $secure, null ) ) {
624 // No HTTPS at all. A plain-HTTP site is its own (reported) problem,
625 // not the proxy misconfiguration this check is for.
626 return null;
627 }
628
629 return [
630 'stage' => 'discovery',
631 'detail' => sprintf(
632 /* translators: 1: http endpoint URL advertised, 2: https endpoint URL that also answers. */
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' ),
634 $endpoint,
635 $secure
636 ),
637 ];
638 }
639
640 /**
641 * Confirm an unauthenticated call answers 401 WITH the RFC 9728
642 * WWW-Authenticate challenge. A client that connects by URL alone has
643 * nothing else to discover OAuth from — a bare 401, or any other status,
644 * reads to it as "this server does not implement OAuth".
645 *
646 * @param string $endpoint Pretty endpoint URL.
647 * @param string $fallback REST fallback URL.
648 * @return array{stage:string,detail:string}
649 */
650 private static function probe_challenge( string $endpoint, string $fallback ): array {
651 $answered = false;
652
653 foreach ( [ $endpoint, $fallback ] as $url ) {
654 $response = wp_remote_post(
655 $url,
656 [
657 'timeout' => 10,
658 'redirection' => 0,
659 'headers' => [
660 'Content-Type' => 'application/json',
661 'Accept' => 'application/json',
662 ],
663 'body' => wp_json_encode(
664 [
665 'jsonrpc' => '2.0',
666 'id' => 1,
667 'method' => 'initialize',
668 'params' => [],
669 ]
670 ),
671 ]
672 );
673 if ( is_wp_error( $response ) ) {
674 continue; // Reachability is the other checks' job.
675 }
676 $answered = true;
677
678 $status = (int) wp_remote_retrieve_response_code( $response );
679 $challenge = (string) wp_remote_retrieve_header( $response, 'www-authenticate' );
680
681 if ( 401 !== $status ) {
682 return [
683 'stage' => 'challenge',
684 'detail' => sprintf(
685 /* translators: 1: endpoint URL, 2: HTTP status code. */
686 __( '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' ),
687 $url,
688 $status
689 ),
690 ];
691 }
692 if ( '' === $challenge ) {
693 return [
694 'stage' => 'challenge',
695 'detail' => sprintf(
696 /* translators: %s: endpoint URL. */
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' ),
698 $url
699 ),
700 ];
701 }
702
703 // The challenge is only useful if the URL inside it resolves —
704 // that URL is the client's entire entry point into the flow.
705 if ( ! preg_match( '/resource_metadata="([^"]+)"/i', $challenge, $m ) ) {
706 return [
707 'stage' => 'challenge',
708 'detail' => sprintf(
709 /* translators: 1: endpoint URL, 2: the WWW-Authenticate header value received. */
710 __( '%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' ),
711 $url,
712 $challenge
713 ),
714 ];
715 }
716
717 $metadata_url = $m[1];
718 $metadata = wp_remote_get(
719 $metadata_url,
720 [
721 'timeout' => 10,
722 'redirection' => 2, // A host-level redirect to the real doc is fine.
723 ]
724 );
725 $reachable = ! is_wp_error( $metadata )
726 && 200 === (int) wp_remote_retrieve_response_code( $metadata )
727 && is_array( json_decode( (string) wp_remote_retrieve_body( $metadata ), true ) );
728
729 if ( ! $reachable ) {
730 return [
731 'stage' => 'challenge',
732 'detail' => sprintf(
733 /* translators: 1: resource_metadata URL from the challenge header, 2: endpoint URL. */
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' ),
735 $metadata_url,
736 $url
737 ),
738 ];
739 }
740 }
741
742 // Neither URL answered at all. Reporting `ok` here would be the exact
743 // false pass this test exists to prevent — an unreachable endpoint is
744 // not a passing challenge. The other checks name the reachability
745 // failure, so this one only has to refuse to claim success.
746 if ( ! $answered ) {
747 return [
748 'stage' => 'challenge',
749 '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' ),
750 ];
751 }
752
753 return [
754 'stage' => 'ok',
755 'detail' => __( 'Unauthenticated calls answer with the OAuth challenge, so URL-only clients can authenticate.', 'thinkrank' ),
756 ];
757 }
758
759 /**
760 * Detect a host that answers WordPress but refuses AI clients by
761 * User-Agent. Replays the unauthenticated probe under the UAs a real MCP
762 * backend sends and compares against the baseline; a 403/406/503 that the
763 * baseline did not get is a bot filter, not a plugin problem.
764 *
765 * Blind spot worth stating plainly: this runs from the server's own IP,
766 * which host firewalls usually trust, so it catches UA filtering but NOT
767 * an IP-range block of the AI vendor. A green result here does not prove
768 * an external client can connect.
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 *
775 * @param string $endpoint Pretty endpoint URL.
776 * @param string $fallback REST endpoint URL.
777 * @return array{stage:string,detail:string}|null Null when it could not run.
778 */
779 private static function probe_user_agent( string $endpoint, string $fallback ): ?array {
780 $probed = 0;
781
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.
786 }
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 ];
812 }
813 }
814
815 if ( 0 === $probed ) {
816 return null; // Neither URL answered at all.
817 }
818
819 return [
820 'stage' => 'ok',
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' ),
822 ];
823 }
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
844 // -- Helpers -----------------------------------------------------------
845
846 /**
847 * Status code of one unauthenticated probe, or null if it never answered.
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 *
856 * @param string $url Endpoint to call.
857 * @param string|null $agent User-Agent to send, null for WordPress's own,
858 * or '' to send no User-Agent header.
859 * @return int|null
860 */
861 private static function probe_status( string $url, ?string $agent ): ?int {
862 $args = [
863 'timeout' => 10,
864 'redirection' => 0,
865 'headers' => [
866 'Content-Type' => 'application/json',
867 'Accept' => 'application/json',
868 ],
869 'body' => wp_json_encode(
870 [
871 'jsonrpc' => '2.0',
872 'id' => 1,
873 'method' => 'initialize',
874 'params' => [],
875 ]
876 ),
877 ];
878 if ( null !== $agent ) {
879 $args['user-agent'] = $agent;
880 }
881
882 $response = wp_remote_post( $url, $args );
883 if ( is_wp_error( $response ) ) {
884 return null;
885 }
886 return (int) wp_remote_retrieve_response_code( $response );
887 }
888
889 /**
890 * Shape one check for the UI list.
891 *
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}
899 */
900 private static function check( string $id, string $label, string $stage, string $detail, string $doc_url = '' ): array {
901 $check = [
902 'id' => $id,
903 'label' => $label,
904 'ok' => 'ok' === $stage,
905 'detail' => $detail,
906 ];
907
908 if ( '' !== $doc_url ) {
909 $check['doc_url'] = $doc_url;
910 }
911
912 return $check;
913 }
914 }
915