PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 All 200 releases
betterdocs / includes / Mcp / MCPSelfTest.php

MCPSelfTest.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.2, at includes/Mcp/MCPSelfTest.php

918 lines 32.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 — the loopback ladder.
4 *
5 * @package BetterDocs
6 * @since 4.9.0
7 */
8
9 namespace WPDeveloper\BetterDocs\Mcp;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit; // Exit if accessed directly.
13 }
14
15 use WPDeveloper\BetterDocs\Abilities\AbilitiesRegistrar;
16
17 /**
18 * Exercises the MCP round trip the way an external client would, and reports
19 * *where* it broke.
20 *
21 * {@see MCPHealth} says what this site is; this says whether it answers. It
22 * makes real loopback HTTP requests, so it is a `POST` route and a deliberate
23 * action, never something a page load triggers.
24 *
25 * Five surfaces, each one a real client depends on:
26 *
27 * 1. `endpoint` — the pretty URL a user pastes (`/betterdocs/mcp`), with the
28 * pairing token. It depends on rewrite rules, so it is the
29 * check that fails on plain permalinks or an unflushed table.
30 * 2. `fallback` — the always-on `/wp-json/betterdocs/v1/mcp` route. Working
31 * here while the pretty URL fails is what separates "MCP is
32 * down" from "only the pretty URL is".
33 * 3. `discovery` — the RFC 9728 / RFC 8414 metadata, in both the `/.well-known/`
34 * form and the REST aliases.
35 * 4. `challenge` — an **unauthenticated** call, which must answer 401 with a
36 * `WWW-Authenticate` header. It is the only thing an
37 * OAuth-only client has to go on: no challenge reads to it as
38 * "this server does not implement OAuth", however healthy
39 * everything else is.
40 * 5. `user_agent` — the same probe under the User-Agents real MCP backends
41 * send, to catch a "block bad bots" rule that answers
42 * WordPress and refuses every AI client.
43 *
44 * The staged result names the first failing step:
45 * `disabled` → `not_connected` → `unreachable` → `tls` → `redirect` → `auth` →
46 * `no_tools` → `rewrite` → `discovery` → `challenge` → `ok`.
47 *
48 * @since 4.9.0
49 */
50 final class MCPSelfTest {
51
52 /**
53 * How long each loopback request may take, in seconds.
54 *
55 * @since 4.9.0
56 */
57 const TIMEOUT = 10;
58
59 /**
60 * User-Agents to replay the challenge probe with.
61 *
62 * The shapes real MCP backends send. None of them is a browser, which is
63 * exactly what a "block bad bots" rule keys on: a site that answers
64 * WordPress' own User-Agent but refuses these is unreachable for every AI
65 * client while looking perfectly healthy from the inside.
66 *
67 * @since 4.9.0
68 */
69 const CLIENT_USER_AGENTS = [
70 'python-requests/2.32.3',
71 'node-fetch/3.3.2'
72 ];
73
74 /**
75 * Run the round trip.
76 *
77 * @since 4.9.0
78 *
79 * @return array
80 */
81 public function run() {
82 $endpoint = MCPPairing::site_endpoint();
83 $fallback = MCPPairing::site_endpoint_fallback();
84
85 $result = [
86 'ok' => false,
87 'stage' => '',
88 'message' => '',
89 'endpoint' => $endpoint,
90 'endpoint_rest' => $fallback,
91 'mcp_enabled' => MCPManager::is_enabled(),
92 'connected' => MCPPairing::is_connected(),
93 'http_status' => null,
94 'redirected' => false,
95 'authenticated' => false,
96 'tools_count' => null,
97 'checks' => [],
98 // Findings that are worth showing but do not fail the test.
99 'caveats' => [],
100 'locked_clients' => null,
101 // url => decoded metadata, or the raw body when it is not JSON.
102 'discovery_documents' => []
103 ];
104
105 if ( ! $result['mcp_enabled'] ) {
106 $result['stage'] = 'disabled';
107 $result['message'] = __( 'MCP access is turned off, so the endpoint refuses every request. Switch MCP on above and run the test again.', 'betterdocs' );
108
109 return $result;
110 }
111
112 if ( ! $result['connected'] ) {
113 $result['stage'] = 'not_connected';
114 $result['message'] = __( 'No connection token exists yet. Click Connect to mint one, then run the test again.', 'betterdocs' );
115
116 return $result;
117 }
118
119 $token = MCPPairing::site_token();
120
121 if ( '' === $token ) {
122 $result['stage'] = 'not_connected';
123 $result['message'] = __( 'This site is paired, but the stored copy of the connection token cannot be decrypted — usually because the WordPress security salts changed. Existing clients still authenticate; rotate the token and reconnect them to be able to test it.', 'betterdocs' );
124
125 return $result;
126 }
127
128 $pretty = $this->probe_jsonrpc( $endpoint, $token );
129 $rest = $this->probe_jsonrpc( $fallback, $token );
130
131 // The top-level fields describe the primary (pretty) endpoint, falling
132 // back to the REST route when the pretty URL never answered at all.
133 $primary = 'unreachable' === $pretty['stage'] ? $rest : $pretty;
134 $result['http_status'] = $primary['status'];
135 $result['redirected'] = 'redirect' === $primary['stage'];
136 $result['authenticated'] = $primary['authenticated'];
137 $result['tools_count'] = $primary['tools'];
138
139 $result['checks'][] = self::check( 'endpoint', __( 'Connection URL', 'betterdocs' ), $pretty['stage'], $pretty['detail'] );
140 $result['checks'][] = self::check( 'fallback', __( 'REST fallback URL', 'betterdocs' ), $rest['stage'], $rest['detail'] );
141
142 $discovery = $this->probe_discovery();
143 $result['discovery_documents'] = $discovery['documents'];
144 $result['checks'][] = self::check( 'discovery', __( 'OAuth discovery', 'betterdocs' ), $discovery['stage'], $discovery['detail'] );
145
146 if ( ! empty( $discovery['caveat'] ) ) {
147 $result['caveats'][] = $discovery['caveat'];
148 }
149
150 $challenge = $this->probe_challenge( $endpoint, $fallback );
151 $result['checks'][] = self::check( 'challenge', __( 'OAuth challenge', 'betterdocs' ), $challenge['stage'], $challenge['detail'] );
152
153 // Only reported when it could actually run: claiming a pass that was
154 // never measured is the failure mode this whole test exists to avoid.
155 $user_agent = $this->probe_user_agent( $endpoint );
156
157 if ( null !== $user_agent ) {
158 $result['checks'][] = self::check( 'user_agent', __( 'Client access', 'betterdocs' ), $user_agent['stage'], $user_agent['detail'] );
159 }
160
161 $this->add_lockout_check( $result );
162
163 // The pretty URL failing while the fallback works is its own finding:
164 // the site is usable, but only through the REST URL.
165 if ( 'ok' !== $pretty['stage'] && 'ok' === $rest['stage'] ) {
166 $result['stage'] = 'rewrite';
167 $result['message'] = sprintf(
168 /* translators: 1: pretty MCP endpoint URL, 2: REST fallback URL. */
169 __( 'The connection URL %1$s did not answer correctly, 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.', 'betterdocs' ),
170 $endpoint,
171 $fallback
172 );
173
174 return $result;
175 }
176
177 // `$user_agent` is deliberately not in this list. It is the one check that
178 // does not measure this server: it probes what a *generic* HTTP client
179 // sees, and a host that refuses those may still answer the AI client's own
180 // User-Agent — which cannot be known from here. Letting that proxy signal
181 // overrule four checks that completed a real round trip reported working
182 // sites as broken (ADR-067). It is still reported, as its own warning row.
183 foreach ( [ $pretty, $rest, $discovery, $challenge ] as $check ) {
184 if ( null === $check || 'ok' === $check['stage'] ) {
185 continue;
186 }
187
188 $result['stage'] = $check['stage'];
189 $result['message'] = $check['detail'];
190
191 return $result;
192 }
193
194 $result['ok'] = true;
195 $result['stage'] = 'ok';
196 $result['message'] = sprintf(
197 /* translators: %d: number of MCP tools returned. */
198 _n(
199 'Connection healthy: the endpoint authenticated, offered OAuth, and returned %d tool.',
200 'Connection healthy: the endpoint authenticated, offered OAuth, and returned %d tools.',
201 (int) $result['tools_count'],
202 'betterdocs'
203 ),
204 (int) $result['tools_count']
205 );
206
207 return $result;
208 }
209
210 // -- Probes ---------------------------------------------------------------
211
212 /**
213 * One authenticated JSON-RPC `tools/list` round trip.
214 *
215 * @since 4.9.0
216 *
217 * @param string $url Endpoint to call.
218 * @param string $token Pairing token.
219 * @return array `{ stage, status, tools, authenticated, detail }`
220 */
221 private function probe_jsonrpc( $url, $token ) {
222 $response = wp_remote_post(
223 $url,
224 [
225 'timeout' => self::TIMEOUT,
226 // Do not follow redirects: a 301/302 here *is* the finding —
227 // the classic http↔https scheme bounce — so surface it verbatim.
228 'redirection' => 0,
229 'local' => true,
230 'sslverify' => self::verify_certificate( $url ),
231 'headers' => [
232 'Authorization' => 'Bearer ' . $token,
233 'Content-Type' => 'application/json',
234 'Accept' => 'application/json'
235 ],
236 'body' => wp_json_encode(
237 [
238 'jsonrpc' => '2.0',
239 'id' => 1,
240 'method' => 'tools/list'
241 ]
242 )
243 ]
244 );
245
246 $out = [
247 'stage' => 'ok',
248 'status' => null,
249 'tools' => null,
250 'authenticated' => false,
251 'detail' => ''
252 ];
253
254 if ( is_wp_error( $response ) ) {
255 $error = $response->get_error_message();
256
257 if ( self::is_tls_error( $error ) ) {
258 $out['stage'] = 'tls';
259 $out['detail'] = sprintf(
260 /* translators: 1: endpoint URL, 2: the transport error PHP reported. */
261 __( 'PHP could not verify this site\'s TLS certificate (common on local sites with self-signed certificates). %1$s reported: %2$s. Add `add_filter( \'https_local_ssl_verify\', \'__return_false\' );` in a mu-plugin for local testing.', 'betterdocs' ),
262 $url,
263 $error
264 );
265
266 return $out;
267 }
268
269 $out['stage'] = 'unreachable';
270 $out['detail'] = sprintf(
271 /* translators: 1: endpoint URL, 2: the transport error PHP reported. */
272 __( '%1$s could not be reached: %2$s.', 'betterdocs' ),
273 $url,
274 $error
275 );
276
277 return $out;
278 }
279
280 $status = (int) wp_remote_retrieve_response_code( $response );
281 $out['status'] = $status;
282
283 if ( in_array( $status, [ 301, 302, 307, 308 ], true ) ) {
284 $location = (string) wp_remote_retrieve_header( $response, 'location' );
285
286 $out['stage'] = 'redirect';
287 $out['detail'] = '' !== $location
288 ? sprintf(
289 /* translators: 1: endpoint URL, 2: the URL it redirected to. */
290 __( '%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.', 'betterdocs' ),
291 $url,
292 $location
293 )
294 : sprintf(
295 /* translators: %s: endpoint URL. */
296 __( '%s redirected instead of answering, which usually means the Site Address and WordPress Address schemes disagree.', 'betterdocs' ),
297 $url
298 );
299
300 return $out;
301 }
302
303 if ( 404 === $status ) {
304 $out['stage'] = 'rewrite';
305 $out['detail'] = sprintf(
306 /* translators: %s: endpoint URL. */
307 __( '%s returned 404 — WordPress does not know this URL. Re-save Settings → Permalinks to rebuild the rewrite rules.', 'betterdocs' ),
308 $url
309 );
310
311 return $out;
312 }
313
314 if ( 401 === $status || 403 === $status ) {
315 $out['stage'] = 'auth';
316 $out['detail'] = sprintf(
317 /* translators: %s: endpoint URL. */
318 __( '%s rejected the connection token (authentication failed). Rotate the token and reconnect your AI client.', 'betterdocs' ),
319 $url
320 );
321
322 return $out;
323 }
324
325 if ( 429 === $status ) {
326 $out['stage'] = 'auth';
327 $out['detail'] = sprintf(
328 /* translators: %s: endpoint URL. */
329 __( '%s is rate-limiting this server after repeated failed tokens. Wait for the lockout to lapse, then rotate the token and reconnect.', 'betterdocs' ),
330 $url
331 );
332
333 return $out;
334 }
335
336 $body = (string) wp_remote_retrieve_body( $response );
337 $content_type = strtolower( (string) wp_remote_retrieve_header( $response, 'content-type' ) );
338 $decoded = json_decode( $body, true );
339
340 // Under plain permalinks the pretty path does not 404 — WordPress
341 // serves the FRONT PAGE at it, with a cheerful 200 and `text/html`.
342 // Status alone therefore cannot tell a working endpoint from a
343 // completely absent one, which is why the content type and the body
344 // shape are checked before anything else (Batch 2 review).
345 if ( ! is_array( $decoded ) ) {
346 $out['stage'] = 'rewrite';
347 $out['detail'] = sprintf(
348 /* translators: 1: endpoint URL, 2: HTTP status code, 3: the Content-Type header received. */
349 __( '%1$s answered %2$d with %3$s instead of JSON — WordPress is serving an ordinary page at this URL, not the MCP endpoint. Under plain permalinks the pretty URL silently returns the front page; re-save Settings → Permalinks, or give your AI client the REST fallback URL.', 'betterdocs' ),
350 $url,
351 $status,
352 '' !== $content_type ? $content_type : __( 'no content type', 'betterdocs' )
353 );
354
355 return $out;
356 }
357
358 if ( ! isset( $decoded['jsonrpc'] ) ) {
359 $out['stage'] = 'rewrite';
360 $out['detail'] = sprintf(
361 /* translators: 1: endpoint URL, 2: HTTP status code. */
362 __( '%1$s answered %2$d with JSON that is not a JSON-RPC response, so something other than the MCP endpoint is serving this URL. Re-save Settings → Permalinks, or use the REST fallback URL.', 'betterdocs' ),
363 $url,
364 $status
365 );
366
367 return $out;
368 }
369
370 $out['authenticated'] = true;
371
372 $tools = isset( $decoded['result']['tools'] ) && is_array( $decoded['result']['tools'] )
373 ? $decoded['result']['tools']
374 : null;
375
376 // An EMPTY tool list is a failure, not a pass: that is precisely the
377 // shape of a connection an AI client calls healthy while having nothing
378 // to call. The registry snapshot goes in the detail, so support can
379 // tell "no BetterDocs abilities registered" from "the runtime is
380 // missing entirely".
381 if ( 200 !== $status || null === $tools || [] === $tools ) {
382 $out['stage'] = 'no_tools';
383 $out['tools'] = is_array( $tools ) ? count( $tools ) : 0;
384 $out['detail'] = sprintf(
385 /* translators: 1: endpoint URL, 2: abilities-registry diagnostic summary. */
386 __( '%1$s answered but returned no tool catalog. Confirm the MCP runtime shipped with this build and that abilities registered. Diagnostics — %2$s', 'betterdocs' ),
387 $url,
388 AbilitiesRegistrar::summary()
389 );
390
391 return $out;
392 }
393
394 $out['tools'] = count( $tools );
395 $out['detail'] = sprintf(
396 /* translators: 1: endpoint URL, 2: number of tools returned. */
397 __( '%1$s authenticated and returned %2$d tools.', 'betterdocs' ),
398 $url,
399 count( $tools )
400 );
401
402 return $out;
403 }
404
405 /**
406 * Fetch the OAuth discovery documents and confirm they identify this site.
407 *
408 * Both shapes are probed: the `/.well-known/` pair a spec-compliant client
409 * derives from the issuer, and the REST aliases the 401 challenge points at
410 * (ADR-014). A host that intercepts `/.well-known/` — an nginx `location`
411 * block for ACME is the usual culprit — breaks the first pair while the
412 * aliases keep working, and that is a **caveat, not a failure**: every
413 * client we know of reaches the metadata through the challenge.
414 *
415 * @since 4.9.0
416 *
417 * @return array `{ stage, detail, documents, caveat }`
418 */
419 private function probe_discovery() {
420 $path = MCPPairing::SITE_ENDPOINT_PATH;
421
422 $well_known = [
423 home_url( '/.well-known/oauth-protected-resource/' . $path ) => 'resource',
424 home_url( '/.well-known/oauth-authorization-server/' . $path ) => 'issuer'
425 ];
426
427 $aliases = [
428 MCPOAuth::resource_metadata_url() => 'resource',
429 rest_url( MCPManager::NS . '/mcp/oauth/authorization-server' ) => 'issuer'
430 ];
431
432 $expected = [
433 'resource' => MCPOAuth::resource(),
434 'issuer' => MCPOAuth::issuer()
435 ];
436
437 $documents = [];
438
439 $well_known_result = $this->fetch_metadata( $well_known, $expected, $documents );
440 $alias_result = $this->fetch_metadata( $aliases, $expected, $documents );
441
442 // The aliases are the pair the challenge points at, so they are the
443 // ones that must work.
444 if ( null !== $alias_result ) {
445 return [
446 'stage' => 'discovery',
447 'detail' => $alias_result,
448 'documents' => $documents,
449 'caveat' => ''
450 ];
451 }
452
453 if ( null !== $well_known_result ) {
454 return [
455 'stage' => 'ok',
456 'detail' => __( 'The REST discovery aliases are served and identify this site exactly. The /.well-known/ documents are not — see the caveat below.', 'betterdocs' ),
457 'documents' => $documents,
458 'caveat' => sprintf(
459 /* translators: %s: the reason the /.well-known/ document could not be read. */
460 __( 'This site does not serve the /.well-known/ OAuth documents, usually because the web server handles that path itself before WordPress sees it. Every client we have tested finds the metadata through the 401 challenge instead, which points at the REST alias, so this is not fatal — but a client that only tries /.well-known/ will not connect. Details: %s', 'betterdocs' ),
461 $well_known_result
462 )
463 ];
464 }
465
466 // Everything is served and self-consistent — but self-consistent with
467 // whatever `home_url()` says, so a site stored as http:// while really
468 // served over https:// is consistently wrong.
469 $scheme_issue = $this->probe_scheme();
470
471 if ( null !== $scheme_issue ) {
472 return [
473 'stage' => 'discovery',
474 'detail' => $scheme_issue,
475 'documents' => $documents,
476 'caveat' => ''
477 ];
478 }
479
480 return [
481 'stage' => 'ok',
482 'detail' => __( 'All four OAuth discovery documents are served and advertise this site\'s MCP endpoint exactly.', 'betterdocs' ),
483 'documents' => $documents,
484 'caveat' => ''
485 ];
486 }
487
488 /**
489 * Fetch a set of metadata documents and check the identifier in each.
490 *
491 * A "the key exists" check is not enough: it passes on another plugin's
492 * metadata served from the same `/.well-known/` path, which is exactly the
493 * hijack the per-path rewrite rules exist to avoid. The identifier has to
494 * equal the one computed here, character for character.
495 *
496 * @since 4.9.0
497 *
498 * @param array $urls `url => identifier key`.
499 * @param array $expected `identifier key => expected value`.
500 * @param array $documents Collected documents, by reference.
501 * @return string|null The failure detail, or null when all of them passed.
502 */
503 private function fetch_metadata( array $urls, array $expected, array &$documents ) {
504 foreach ( $urls as $url => $key ) {
505 $response = wp_remote_get(
506 $url,
507 [
508 'timeout' => self::TIMEOUT,
509 'redirection' => 0,
510 'local' => true,
511 'sslverify' => self::verify_certificate( $url )
512 ]
513 );
514
515 if ( is_wp_error( $response ) ) {
516 return sprintf(
517 /* translators: 1: discovery document URL, 2: transport error. */
518 __( 'The OAuth discovery document %1$s could not be fetched: %2$s. Clients that connect by URL alone cannot authenticate without it.', 'betterdocs' ),
519 $url,
520 $response->get_error_message()
521 );
522 }
523
524 $status = (int) wp_remote_retrieve_response_code( $response );
525 $raw = (string) wp_remote_retrieve_body( $response );
526 $body = json_decode( $raw, true );
527
528 $documents[ $url ] = is_array( $body ) ? $body : $raw;
529
530 if ( 200 !== $status || ! is_array( $body ) || ! isset( $body[ $key ] ) ) {
531 return sprintf(
532 /* translators: 1: discovery document URL, 2: HTTP status code. */
533 __( 'The OAuth discovery document %1$s returned %2$d instead of valid metadata. Re-save Settings → Permalinks; if it persists, the web server or another plugin is claiming that URL.', 'betterdocs' ),
534 $url,
535 $status
536 );
537 }
538
539 $advertised = (string) $body[ $key ];
540
541 if ( $advertised !== $expected[ $key ] ) {
542 return sprintf(
543 /* translators: 1: metadata field name, 2: value found in the document, 3: the value it should carry, 4: discovery document URL. */
544 __( '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 something else is serving this URL.', 'betterdocs' ),
545 $key,
546 $advertised,
547 $expected[ $key ],
548 $url
549 );
550 }
551 }
552
553 return null;
554 }
555
556 /**
557 * Catch the reverse-proxy scheme trap.
558 *
559 * WordPress stores an `http://` home URL, so every advertised OAuth
560 * identifier is `http://`, while the site is really served over `https://`.
561 * Everything is internally consistent, so no comparison against our own
562 * values can see it — the only tell is that the `https://` variant of the
563 * endpoint answers too.
564 *
565 * @since 4.9.0
566 *
567 * @return string|null The detail, or null when nothing is wrong.
568 */
569 private function probe_scheme() {
570 $endpoint = MCPPairing::site_endpoint();
571
572 if ( 'https' === wp_parse_url( $endpoint, PHP_URL_SCHEME ) ) {
573 return null;
574 }
575
576 $secure = set_url_scheme( $endpoint, 'https' );
577
578 if ( null === $this->probe_status( $secure, null ) ) {
579 // No HTTPS at all. A plain-HTTP site is its own (reported) problem,
580 // not the proxy misconfiguration this check is for.
581 return null;
582 }
583
584 return sprintf(
585 /* translators: 1: the http:// endpoint the documents advertise, 2: the https:// endpoint that also answers. */
586 __( '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.', 'betterdocs' ),
587 $endpoint,
588 $secure
589 );
590 }
591
592 /**
593 * Confirm an unauthenticated call answers 401 *with* the challenge.
594 *
595 * A client that connects by URL alone has nothing else to discover OAuth
596 * from: a bare 401, or any other status, reads to it as "this server does
597 * not implement OAuth".
598 *
599 * @since 4.9.0
600 *
601 * @param string $endpoint Pretty endpoint URL.
602 * @param string $fallback REST fallback URL.
603 * @return array `{ stage, detail }`
604 */
605 private function probe_challenge( $endpoint, $fallback ) {
606 $answered = false;
607
608 foreach ( [ $endpoint, $fallback ] as $url ) {
609 $response = $this->unauthenticated_probe( $url, null );
610
611 if ( is_wp_error( $response ) ) {
612 continue; // Reachability is another check's job.
613 }
614
615 $answered = true;
616
617 $status = (int) wp_remote_retrieve_response_code( $response );
618 $challenge = (string) wp_remote_retrieve_header( $response, 'www-authenticate' );
619
620 if ( 401 !== $status ) {
621 return [
622 'stage' => 'challenge',
623 'detail' => sprintf(
624 /* translators: 1: endpoint URL, 2: HTTP status code. */
625 __( '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.', 'betterdocs' ),
626 $url,
627 $status
628 )
629 ];
630 }
631
632 if ( '' === $challenge ) {
633 return [
634 'stage' => 'challenge',
635 'detail' => sprintf(
636 /* translators: %s: endpoint URL. */
637 __( '%s answered 401 but sent no WWW-Authenticate header — a security plugin or proxy is most likely stripping it. Clients that connect by URL alone will report that this server does not implement OAuth.', 'betterdocs' ),
638 $url
639 )
640 ];
641 }
642
643 // The challenge is only useful if the URL inside it resolves: that
644 // URL is the client's entire entry point into the flow.
645 if ( ! preg_match( '/resource_metadata="([^"]+)"/i', $challenge, $matches ) ) {
646 return [
647 'stage' => 'challenge',
648 'detail' => sprintf(
649 /* translators: 1: endpoint URL, 2: the WWW-Authenticate header value received. */
650 __( '%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.', 'betterdocs' ),
651 $url,
652 $challenge
653 )
654 ];
655 }
656
657 $metadata_url = $matches[1];
658 $metadata = wp_remote_get(
659 $metadata_url,
660 [
661 'timeout' => self::TIMEOUT,
662 'redirection' => 2, // A host-level redirect to the real document is fine.
663 'local' => true,
664 'sslverify' => self::verify_certificate( $metadata_url )
665 ]
666 );
667
668 $reachable = ! is_wp_error( $metadata )
669 && 200 === (int) wp_remote_retrieve_response_code( $metadata )
670 && is_array( json_decode( (string) wp_remote_retrieve_body( $metadata ), true ) );
671
672 if ( ! $reachable ) {
673 return [
674 'stage' => 'challenge',
675 'detail' => sprintf(
676 /* translators: 1: the resource_metadata URL from the challenge header, 2: endpoint URL. */
677 __( 'The challenge from %2$s points at %1$s, but that URL does not return OAuth metadata. It is the first thing a client fetches, so the connection fails there.', 'betterdocs' ),
678 $metadata_url,
679 $url
680 )
681 ];
682 }
683 }
684
685 // Neither URL answered at all. Reporting `ok` here would be the exact
686 // false pass this test exists to prevent — an unreachable endpoint is
687 // not a passing challenge. The other checks name the reachability
688 // failure; this one only has to refuse to claim success.
689 if ( ! $answered ) {
690 return [
691 'stage' => 'challenge',
692 'detail' => __( 'The OAuth challenge could not be checked because the endpoint did not answer. Fix the connection error above and run the test again.', 'betterdocs' )
693 ];
694 }
695
696 return [
697 'stage' => 'ok',
698 'detail' => __( 'Unauthenticated calls answer with the OAuth challenge, so clients that have only the URL can authenticate.', 'betterdocs' )
699 ];
700 }
701
702 /**
703 * Detect a host that answers WordPress but refuses generic clients by User-Agent.
704 *
705 * Two blind spots, both worth stating plainly. This runs from the server's own
706 * IP, which host firewalls usually trust, so it catches User-Agent filtering
707 * but **not** an IP-range block of the AI vendor: a green result does not prove
708 * an external client can connect. And the User-Agents below are representative,
709 * not the ones any particular vendor sends, so a red result does not prove one
710 * cannot — which is why this never sets the overall verdict (ADR-067).
711 *
712 * @since 4.9.0
713 *
714 * @param string $endpoint Pretty endpoint URL.
715 * @return array|null `{ stage, detail }`, or null when it could not run.
716 */
717 private function probe_user_agent( $endpoint ) {
718 $baseline = $this->probe_status( $endpoint, null );
719
720 if ( null === $baseline ) {
721 return null; // Endpoint unreachable — another check owns that.
722 }
723
724 foreach ( self::CLIENT_USER_AGENTS as $agent ) {
725 $status = $this->probe_status( $endpoint, $agent );
726
727 if ( null === $status || $status === $baseline ) {
728 continue;
729 }
730
731 // A different status is only damning when it is a refusal. An MCP
732 // answer — a 401 challenge, a 200, a 202 — is fine under any UA.
733 if ( in_array( $status, [ 200, 202, 401 ], true ) ) {
734 continue;
735 }
736
737 return [
738 'stage' => 'ua_filter',
739 'detail' => sprintf(
740 /* translators: 1: the User-Agent string tried, 2: the HTTP status it received, 3: the HTTP status WordPress' own User-Agent received. */
741 __( 'The endpoint answered %3$d for WordPress but %2$d for a generic HTTP client\'s User-Agent (%1$s). A security plugin, firewall or "block bad bots" rule is refusing non-browser clients. Whether that affects your assistant depends on the User-Agent it sends, which this test cannot see — connect it and check that tools load. If they do not, exempt the MCP and /.well-known/ paths.', 'betterdocs' ),
742 $agent,
743 $status,
744 $baseline
745 )
746 ];
747 }
748
749 return [
750 'stage' => 'ok',
751 'detail' => __( 'The endpoint answers generic HTTP clients the same way it answers WordPress, so no bot filter is refusing them. This cannot see an IP-level block of the AI vendor.', 'betterdocs' )
752 ];
753 }
754
755 /**
756 * Add the rate-limiter finding to the result.
757 *
758 * The loopback above can pass while a *remote* client is walled off by the
759 * failed-authentication limiter — the state a connector still holding a
760 * rotated-away token produces. It matters more than it looks: a lockout
761 * refuses **valid** credentials from that IP too, so behind a reverse proxy,
762 * where every client shares one `REMOTE_ADDR`, one stale connector locks
763 * out everybody (Batch 2 review).
764 *
765 * @since 4.9.0
766 *
767 * @param array $result The result being built, by reference.
768 * @return void
769 */
770 private function add_lockout_check( array &$result ) {
771 $lockouts = MCPRateLimiter::active_lockouts();
772
773 $result['locked_clients'] = $lockouts;
774
775 if ( null === $lockouts || $lockouts < 1 ) {
776 return;
777 }
778
779 $detail = sprintf(
780 /* translators: 1: number of locked-out clients, 2: the filter name that overrides client identification. */
781 _n(
782 '%1$d client address is locked out after repeated failed authentications — usually a connector still holding a rotated-away token. While a lockout holds, that address is refused even when it presents a valid token, so behind a reverse proxy (where every client shares one address) one stale connector can wall off everyone; the `%2$s` filter is how you teach the limiter to read the real client address. Lockouts clear within 15 minutes of the retries stopping.',
783 '%1$d client addresses are locked out after repeated failed authentications — usually connectors still holding rotated-away tokens. While a lockout holds, that address is refused even when it presents a valid token, so behind a reverse proxy (where every client shares one address) one stale connector can wall off everyone; the `%2$s` filter is how you teach the limiter to read the real client address. Lockouts clear within 15 minutes of the retries stopping.',
784 $lockouts,
785 'betterdocs'
786 ),
787 $lockouts,
788 'betterdocs_mcp_client_ip'
789 );
790
791 $result['checks'][] = self::check( 'lockouts', __( 'Client lockouts', 'betterdocs' ), 'locked_clients', $detail );
792
793 // A lockout is a warning, not a broken connection: the loopback just
794 // proved the endpoint works. It must not overwrite a genuine ladder
795 // failure, so it is recorded as a caveat and the stage is left alone.
796 $result['caveats'][] = $detail;
797 }
798
799 // -- Helpers --------------------------------------------------------------
800
801 /**
802 * One unauthenticated `initialize` call.
803 *
804 * @since 4.9.0
805 *
806 * @param string $url Endpoint to call.
807 * @param string|null $agent User-Agent to send, or null for WordPress' own.
808 * @return array|\WP_Error
809 */
810 private function unauthenticated_probe( $url, $agent ) {
811 $args = [
812 'timeout' => self::TIMEOUT,
813 'redirection' => 0,
814 'local' => true,
815 'sslverify' => self::verify_certificate( $url ),
816 'headers' => [
817 'Content-Type' => 'application/json',
818 'Accept' => 'application/json'
819 ],
820 'body' => wp_json_encode(
821 [
822 'jsonrpc' => '2.0',
823 'id' => 1,
824 'method' => 'initialize',
825 'params' => []
826 ]
827 )
828 ];
829
830 if ( null !== $agent ) {
831 $args['user-agent'] = $agent;
832 }
833
834 return wp_remote_post( $url, $args );
835 }
836
837 /**
838 * Status code of one unauthenticated probe, or null if it never answered.
839 *
840 * @since 4.9.0
841 *
842 * @param string $url Endpoint to call.
843 * @param string|null $agent User-Agent to send, or null for WordPress' own.
844 * @return int|null
845 */
846 private function probe_status( $url, $agent ) {
847 $response = $this->unauthenticated_probe( $url, $agent );
848
849 if ( is_wp_error( $response ) ) {
850 return null;
851 }
852
853 return (int) wp_remote_retrieve_response_code( $response );
854 }
855
856 /**
857 * Whether to verify the certificate on one loopback request.
858 *
859 * Since WordPress 5.9 the HTTP API runs on the Requests 2 transport, which
860 * applies `https_ssl_verify` and **never** `https_local_ssl_verify` — that
861 * filter survives only in the legacy cURL/streams transports nothing uses
862 * any more. Core's own loopback callers therefore apply it themselves and
863 * pass the answer as `sslverify` (`cron.php`, `WP_Site_Health`,
864 * `wp-admin/includes/file.php`); measured on WordPress 7.1, and this does
865 * the same, so the advice in the `tls` message actually works.
866 *
867 * The default is `true`, not core's `false`: skipping verification would
868 * make this test report a healthy connection on a site whose certificate no
869 * real client will accept, which is the exact false pass it exists to
870 * prevent. Relaxing it stays a deliberate, filtered choice.
871 *
872 * @since 4.9.0
873 *
874 * @param string $url URL about to be requested.
875 * @return bool|string
876 */
877 private static function verify_certificate( $url ) {
878 /** This filter is documented in wp-includes/class-wp-http-streams.php */
879 return apply_filters( 'https_local_ssl_verify', true, $url );
880 }
881
882 /**
883 * Whether a transport error is a certificate problem.
884 *
885 * @since 4.9.0
886 *
887 * @param string $message Transport error message.
888 * @return bool
889 */
890 private static function is_tls_error( $message ) {
891 $message = (string) $message;
892
893 return false !== stripos( $message, 'ssl' )
894 || false !== stripos( $message, 'certificate' )
895 || false !== stripos( $message, 'tls' );
896 }
897
898 /**
899 * Shape one check for the UI list.
900 *
901 * @since 4.9.0
902 *
903 * @param string $id Check id.
904 * @param string $label Human-readable label.
905 * @param string $stage Resulting stage (`ok` when it passed).
906 * @param string $detail Explanatory line.
907 * @return array
908 */
909 private static function check( $id, $label, $stage, $detail ) {
910 return [
911 'id' => $id,
912 'label' => $label,
913 'ok' => 'ok' === $stage,
914 'detail' => $detail
915 ];
916 }
917 }
918