PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.30.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.30.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 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 1.30.0, at includes/mcp/class-mcp-self-test.php

678 lines 25.5 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 private const CLIENT_USER_AGENTS = [
58 'python-requests/2.32.3',
59 'node-fetch/3.3.2',
60 ];
61
62 /**
63 * Run the round-trip self-test.
64 *
65 * @return array<string, mixed>
66 */
67 public static function run(): array {
68 $endpoint = Mcp_Pairing::site_endpoint();
69 $fallback = Mcp_Pairing::site_endpoint_fallback();
70
71 $result = [
72 'ok' => false,
73 'stage' => '',
74 'message' => '',
75 'endpoint' => $endpoint,
76 'endpoint_rest' => $fallback,
77 'mcp_enabled' => Mcp_Manager::is_enabled(),
78 'connected' => Mcp_Pairing::is_connected(),
79 'http_status' => null,
80 'redirected' => false,
81 'authenticated' => false,
82 'tools_count' => null,
83 'checks' => [],
84 // url => decoded metadata (or the raw body when it isn't JSON).
85 'discovery_documents' => [],
86 ];
87
88 if ( ! $result['mcp_enabled'] ) {
89 $result['stage'] = 'disabled';
90 $result['message'] = __( 'MCP access is turned off, so the endpoint refuses every request. Enable MCP access above and try again.', 'thinkrank' );
91 return $result;
92 }
93
94 if ( ! $result['connected'] ) {
95 $result['stage'] = 'not_connected';
96 $result['message'] = __( 'No connection token exists yet. Click Connect to mint one, then run the test again.', 'thinkrank' );
97 return $result;
98 }
99
100 $token = Mcp_Pairing::site_token();
101
102 $pretty = self::probe_jsonrpc( $endpoint, $token );
103 $rest = self::probe_jsonrpc( $fallback, $token );
104
105 // Back-compat top-level fields describe the primary (pretty) endpoint,
106 // falling back to the REST route when the pretty URL never answered.
107 $primary = 'unreachable' === $pretty['stage'] ? $rest : $pretty;
108 $result['http_status'] = $primary['status'];
109 $result['redirected'] = 'redirect' === $primary['stage'];
110 $result['authenticated'] = $primary['authenticated'];
111 $result['tools_count'] = $primary['tools'];
112
113 $result['checks'][] = self::check( 'endpoint', __( 'Connection URL', 'thinkrank' ), $pretty['stage'], $pretty['detail'] );
114 $result['checks'][] = self::check( 'fallback', __( 'REST fallback URL', 'thinkrank' ), $rest['stage'], $rest['detail'] );
115
116 $discovery = self::probe_discovery();
117 // The documents themselves, so support can read what the site actually
118 // serves instead of asking the customer for screenshots.
119 $result['discovery_documents'] = $discovery['documents'];
120 $result['checks'][] = self::check( 'discovery', __( 'OAuth discovery', 'thinkrank' ), $discovery['stage'], $discovery['detail'] );
121
122 $challenge = self::probe_challenge( $endpoint, $fallback );
123 $result['checks'][] = self::check( 'challenge', __( 'OAuth challenge', 'thinkrank' ), $challenge['stage'], $challenge['detail'] );
124
125 // Only reported when it could actually run — claiming a pass we did
126 // not measure is the failure mode this whole test exists to avoid.
127 $user_agent = self::probe_user_agent( $endpoint );
128 if ( null !== $user_agent ) {
129 $result['checks'][] = self::check( 'user_agent', __( 'Client access', 'thinkrank' ), $user_agent['stage'], $user_agent['detail'] );
130 }
131
132 // Locked-out clients. The loopback below can pass while a REMOTE client
133 // is walled off by the failed-auth limiter — the exact state a connector
134 // still holding a rotated-away token produces. Reported only when the
135 // count is knowable (null under a persistent object cache).
136 $lockouts = Mcp_Rate_Limiter::active_lockouts();
137 $result['locked_clients'] = $lockouts;
138 if ( null !== $lockouts && $lockouts > 0 ) {
139 $result['checks'][] = self::check(
140 'lockouts',
141 __( 'Client lockouts', 'thinkrank' ),
142 'locked_clients',
143 sprintf(
144 /* translators: %d: number of currently locked-out clients. */
145 _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.',
148 $lockouts,
149 'thinkrank'
150 ),
151 $lockouts
152 )
153 );
154 }
155
156 // The pretty URL failing while the fallback works is its own finding:
157 // the site is usable, but only via the REST URL.
158 if ( 'ok' !== $pretty['stage'] && 'ok' === $rest['stage'] ) {
159 $result['stage'] = 'rewrite';
160 $result['message'] = sprintf(
161 /* translators: 1: pretty MCP endpoint URL, 2: REST fallback URL. */
162 __( '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' ),
163 $endpoint,
164 $fallback
165 );
166 return $result;
167 }
168
169 // Otherwise report the first failing check in order.
170 foreach ( [ $pretty, $rest, $discovery, $challenge, $user_agent ] as $check ) {
171 if ( null === $check ) {
172 continue;
173 }
174 if ( 'ok' !== $check['stage'] ) {
175 $result['stage'] = $check['stage'];
176 $result['message'] = $check['detail'];
177 return $result;
178 }
179 }
180
181 $result['ok'] = true;
182 $result['stage'] = 'ok';
183 $result['message'] = sprintf(
184 /* translators: %d: number of MCP tools returned. */
185 _n(
186 'Connection healthy: the endpoint authenticated, offered OAuth, and returned %d tool.',
187 'Connection healthy: the endpoint authenticated, offered OAuth, and returned %d tools.',
188 (int) $result['tools_count'],
189 'thinkrank'
190 ),
191 (int) $result['tools_count']
192 );
193 return $result;
194 }
195
196 // -- Probes ------------------------------------------------------------
197
198 /**
199 * One authenticated JSON-RPC `tools/list` round trip.
200 *
201 * @param string $url Endpoint to call.
202 * @param string $token Connection token.
203 * @return array{stage:string,status:?int,tools:?int,authenticated:bool,detail:string}
204 */
205 private static function probe_jsonrpc( string $url, string $token ): array {
206 $response = wp_remote_post(
207 $url,
208 [
209 'timeout' => 10,
210 // Don't follow redirects: a 301/302 here IS the finding (the
211 // classic http<->https scheme bounce), so surface it verbatim.
212 'redirection' => 0,
213 'headers' => [
214 'Authorization' => 'Bearer ' . $token,
215 'Content-Type' => 'application/json',
216 'Accept' => 'application/json',
217 ],
218 'body' => wp_json_encode(
219 [
220 'jsonrpc' => '2.0',
221 'id' => 1,
222 'method' => 'tools/list',
223 ]
224 ),
225 ]
226 );
227
228 $out = [
229 'stage' => 'ok',
230 'status' => null,
231 'tools' => null,
232 'authenticated' => false,
233 'detail' => '',
234 ];
235
236 if ( is_wp_error( $response ) ) {
237 $err = $response->get_error_message();
238 $is_tls = false !== stripos( $err, 'ssl' ) || false !== stripos( $err, 'certificate' );
239 $out['stage'] = $is_tls ? 'tls' : 'unreachable';
240 $out['detail'] = $is_tls
241 /* translators: 1: endpoint URL, 2: underlying transport error. */
242 ? 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 )
243 /* translators: 1: endpoint URL, 2: underlying transport error. */
244 : sprintf( __( '%1$s could not be reached: %2$s.', 'thinkrank' ), $url, $err );
245 return $out;
246 }
247
248 $status = (int) wp_remote_retrieve_response_code( $response );
249 $out['status'] = $status;
250
251 if ( in_array( $status, [ 301, 302, 307, 308 ], true ) ) {
252 $location = (string) wp_remote_retrieve_header( $response, 'location' );
253 $out['stage'] = 'redirect';
254 $out['detail'] = $location
255 /* translators: 1: endpoint URL, 2: redirect target URL. */
256 ? 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 )
257 /* translators: %s: endpoint URL. */
258 : sprintf( __( '%s redirected instead of answering, which usually means the site address and WordPress address schemes disagree.', 'thinkrank' ), $url );
259 return $out;
260 }
261
262 if ( 404 === $status ) {
263 $out['stage'] = 'rewrite';
264 $out['detail'] = sprintf(
265 /* translators: %s: endpoint URL. */
266 __( '%s returned 404 — WordPress does not know this URL. Re-save Settings → Permalinks to rebuild the rewrite rules.', 'thinkrank' ),
267 $url
268 );
269 return $out;
270 }
271
272 if ( 401 === $status || 403 === $status ) {
273 $out['stage'] = 'auth';
274 $out['detail'] = sprintf(
275 /* translators: %s: endpoint URL. */
276 __( '%s rejected the connection token (authentication failed). Rotate the token and reconnect your AI client.', 'thinkrank' ),
277 $url
278 );
279 return $out;
280 }
281
282 if ( 429 === $status ) {
283 $out['stage'] = 'auth';
284 $out['detail'] = sprintf(
285 /* translators: %s: endpoint URL. */
286 __( '%s is rate-limiting this server after repeated failed tokens. Wait for the lockout to lapse, then rotate the token and reconnect.', 'thinkrank' ),
287 $url
288 );
289 return $out;
290 }
291
292 $out['authenticated'] = true;
293 $body = json_decode( (string) wp_remote_retrieve_body( $response ), true );
294 $tools = ( is_array( $body ) && isset( $body['result']['tools'] ) && is_array( $body['result']['tools'] ) )
295 ? $body['result']['tools']
296 : null;
297
298 // An EMPTY tools array counts as a failure, not a pass: that is exactly
299 // the shape of #241 — a connection an AI client reports as healthy
300 // while it has nothing to call. The abilities snapshot goes into the
301 // detail so support can tell "no ThinkRank abilities registered"
302 // (foreign Abilities API copy owns the registry) from "the runtime is
303 // missing entirely".
304 if ( 200 !== $status || null === $tools || [] === $tools ) {
305 $out['stage'] = 'no_tools';
306 $out['tools'] = is_array( $tools ) ? count( $tools ) : 0;
307 $out['detail'] = sprintf(
308 /* 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' ),
310 $url,
311 Abilities_Registrar::summary()
312 );
313 return $out;
314 }
315
316 $out['tools'] = count( $tools );
317 $out['detail'] = sprintf(
318 /* translators: 1: endpoint URL, 2: number of tools. */
319 __( '%1$s authenticated and returned %2$d tools.', 'thinkrank' ),
320 $url,
321 count( $tools )
322 );
323 return $out;
324 }
325
326 /**
327 * Fetch both OAuth discovery documents and confirm they are served and
328 * well-formed. An OAuth client reads these before it holds any credential,
329 * so a 404 here is invisible to every other check.
330 *
331 * @return array{stage:string,detail:string}
332 */
333 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 = [
339 home_url( '/.well-known/oauth-protected-resource/' . Mcp_Pairing::SITE_ENDPOINT_PATH ) => [
340 'key' => 'resource',
341 'expected' => Mcp_Pairing::site_endpoint(),
342 ],
343 home_url( '/.well-known/oauth-authorization-server/' . Mcp_Pairing::SITE_ENDPOINT_PATH ) => [
344 'key' => 'issuer',
345 'expected' => Mcp_OAuth::issuer(),
346 ],
347 ];
348
349 $documents = [];
350
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 ];
370 }
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
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 ];
387 }
388
389 $advertised = (string) $body[ $spec['key'] ];
390 if ( $advertised !== $spec['expected'] ) {
391 return [
392 'stage' => 'discovery',
393 '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 ),
402 ];
403 }
404 }
405
406 // Both documents agree with us — but they agree on whatever home_url()
407 // says, so a site whose stored URL is http:// while it actually serves
408 // https:// is self-consistently wrong. Clients connect over https and
409 // then reject the http identifier.
410 $scheme_issue = self::probe_scheme();
411 if ( null !== $scheme_issue ) {
412 $scheme_issue['documents'] = $documents;
413 return $scheme_issue;
414 }
415
416 return [
417 'stage' => 'ok',
418 'documents' => $documents,
419 'detail' => __( 'Both OAuth discovery documents are served and advertise this site\'s MCP endpoint exactly.', 'thinkrank' ),
420 ];
421 }
422
423 /**
424 * Catch the reverse-proxy scheme trap: WordPress stores an http:// home
425 * URL, so every advertised OAuth identifier is http://, while the site is
426 * really served over https://. Everything is internally consistent, so no
427 * comparison against our own values can see it — the only tell is that the
428 * https:// variant of the endpoint answers too.
429 *
430 * @return array{stage:string,detail:string}|null Null when nothing is wrong.
431 */
432 private static function probe_scheme(): ?array {
433 $endpoint = Mcp_Pairing::site_endpoint();
434 if ( 'https' === wp_parse_url( $endpoint, PHP_URL_SCHEME ) ) {
435 return null;
436 }
437
438 $secure = set_url_scheme( $endpoint, 'https' );
439 if ( null === self::probe_status( $secure, null ) ) {
440 // No HTTPS at all. A plain-HTTP site is its own (reported) problem,
441 // not the proxy misconfiguration this check is for.
442 return null;
443 }
444
445 return [
446 'stage' => 'discovery',
447 'detail' => sprintf(
448 /* 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' ),
450 $endpoint,
451 $secure
452 ),
453 ];
454 }
455
456 /**
457 * Confirm an unauthenticated call answers 401 WITH the RFC 9728
458 * WWW-Authenticate challenge. A client that connects by URL alone has
459 * nothing else to discover OAuth from — a bare 401, or any other status,
460 * reads to it as "this server does not implement OAuth".
461 *
462 * @param string $endpoint Pretty endpoint URL.
463 * @param string $fallback REST fallback URL.
464 * @return array{stage:string,detail:string}
465 */
466 private static function probe_challenge( string $endpoint, string $fallback ): array {
467 $answered = false;
468
469 foreach ( [ $endpoint, $fallback ] as $url ) {
470 $response = wp_remote_post(
471 $url,
472 [
473 'timeout' => 10,
474 'redirection' => 0,
475 'headers' => [
476 'Content-Type' => 'application/json',
477 'Accept' => 'application/json',
478 ],
479 'body' => wp_json_encode(
480 [
481 'jsonrpc' => '2.0',
482 'id' => 1,
483 'method' => 'initialize',
484 'params' => [],
485 ]
486 ),
487 ]
488 );
489 if ( is_wp_error( $response ) ) {
490 continue; // Reachability is the other checks' job.
491 }
492 $answered = true;
493
494 $status = (int) wp_remote_retrieve_response_code( $response );
495 $challenge = (string) wp_remote_retrieve_header( $response, 'www-authenticate' );
496
497 if ( 401 !== $status ) {
498 return [
499 'stage' => 'challenge',
500 'detail' => sprintf(
501 /* translators: 1: endpoint URL, 2: HTTP status code. */
502 __( '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' ),
503 $url,
504 $status
505 ),
506 ];
507 }
508 if ( '' === $challenge ) {
509 return [
510 'stage' => 'challenge',
511 'detail' => sprintf(
512 /* 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' ),
514 $url
515 ),
516 ];
517 }
518
519 // The challenge is only useful if the URL inside it resolves —
520 // that URL is the client's entire entry point into the flow.
521 if ( ! preg_match( '/resource_metadata="([^"]+)"/i', $challenge, $m ) ) {
522 return [
523 'stage' => 'challenge',
524 'detail' => sprintf(
525 /* translators: 1: endpoint URL, 2: the WWW-Authenticate header value received. */
526 __( '%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' ),
527 $url,
528 $challenge
529 ),
530 ];
531 }
532
533 $metadata_url = $m[1];
534 $metadata = wp_remote_get(
535 $metadata_url,
536 [
537 'timeout' => 10,
538 'redirection' => 2, // A host-level redirect to the real doc is fine.
539 ]
540 );
541 $reachable = ! is_wp_error( $metadata )
542 && 200 === (int) wp_remote_retrieve_response_code( $metadata )
543 && is_array( json_decode( (string) wp_remote_retrieve_body( $metadata ), true ) );
544
545 if ( ! $reachable ) {
546 return [
547 'stage' => 'challenge',
548 'detail' => sprintf(
549 /* 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' ),
551 $metadata_url,
552 $url
553 ),
554 ];
555 }
556 }
557
558 // Neither URL answered at all. Reporting `ok` here would be the exact
559 // false pass this test exists to prevent — an unreachable endpoint is
560 // not a passing challenge. The other checks name the reachability
561 // failure, so this one only has to refuse to claim success.
562 if ( ! $answered ) {
563 return [
564 'stage' => 'challenge',
565 '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' ),
566 ];
567 }
568
569 return [
570 'stage' => 'ok',
571 'detail' => __( 'Unauthenticated calls answer with the OAuth challenge, so URL-only clients can authenticate.', 'thinkrank' ),
572 ];
573 }
574
575 /**
576 * Detect a host that answers WordPress but refuses AI clients by
577 * User-Agent. Replays the unauthenticated probe under the UAs a real MCP
578 * backend sends and compares against the baseline; a 403/406/503 that the
579 * baseline did not get is a bot filter, not a plugin problem.
580 *
581 * Blind spot worth stating plainly: this runs from the server's own IP,
582 * which host firewalls usually trust, so it catches UA filtering but NOT
583 * an IP-range block of the AI vendor. A green result here does not prove
584 * an external client can connect.
585 *
586 * @param string $endpoint Pretty endpoint URL.
587 * @return array{stage:string,detail:string}|null Null when it could not run.
588 */
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 }
594
595 foreach ( self::CLIENT_USER_AGENTS as $agent ) {
596 $status = self::probe_status( $endpoint, $agent );
597 if ( null === $status || $status === $baseline ) {
598 continue;
599 }
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;
604 }
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 }
616
617 return [
618 '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' ),
620 ];
621 }
622
623 // -- Helpers -----------------------------------------------------------
624
625 /**
626 * Status code of one unauthenticated probe, or null if it never answered.
627 *
628 * @param string $url Endpoint to call.
629 * @param string|null $agent User-Agent to send, or null for WordPress's own.
630 * @return int|null
631 */
632 private static function probe_status( string $url, ?string $agent ): ?int {
633 $args = [
634 'timeout' => 10,
635 'redirection' => 0,
636 'headers' => [
637 'Content-Type' => 'application/json',
638 'Accept' => 'application/json',
639 ],
640 'body' => wp_json_encode(
641 [
642 'jsonrpc' => '2.0',
643 'id' => 1,
644 'method' => 'initialize',
645 'params' => [],
646 ]
647 ),
648 ];
649 if ( null !== $agent ) {
650 $args['user-agent'] = $agent;
651 }
652
653 $response = wp_remote_post( $url, $args );
654 if ( is_wp_error( $response ) ) {
655 return null;
656 }
657 return (int) wp_remote_retrieve_response_code( $response );
658 }
659
660 /**
661 * Shape one check for the UI list.
662 *
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}
668 */
669 private static function check( string $id, string $label, string $stage, string $detail ): array {
670 return [
671 'id' => $id,
672 'label' => $label,
673 'ok' => 'ok' === $stage,
674 'detail' => $detail,
675 ];
676 }
677 }
678