| 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 |
|