*/ public static function paths(): array { return [ '.well-known/agents.json' => 'enable_agents_json', '.well-known/agent-card.json' => 'enable_agent_card', '.well-known/agent.json' => 'enable_agent_card', '.well-known/mcp.json' => 'enable_mcp_json', ]; } /** * Last known state of one endpoint. * * @param string $path Path below the site root. * @param array $settings Plugin settings. * @param bool $may_measure Allow a fresh measurement when nothing is cached. */ public static function state( string $path, array $settings, bool $may_measure = false ): string { $status = self::get_status( $settings, $may_measure ); return $status['paths'][ $path ]['state'] ?? self::UNKNOWN; } /** * Whether an endpoint should count as working for the readiness score. * * UNKNOWN counts as working on purpose. It means the loopback request could * not be made at all, which plenty of hosts block, and it says nothing about * the endpoint. Marking a site down on the strength of a check that never * ran would be the same mistake as trusting the file on disk, in reverse. */ public static function is_working( string $path, array $settings, bool $may_measure = false ): bool { return self::BROKEN !== self::state( $path, $settings, $may_measure ); } /** * Read the cached measurement, optionally taking a fresh one. * * @param bool $may_measure Only admin and cron contexts pass true. The public * REST route must not be able to trigger four * outgoing requests per call. * * @return array{checked_at: int, paths: array} */ public static function get_status( array $settings, bool $may_measure = false ): array { $cached = get_transient( self::TRANSIENT ); if ( is_array( $cached ) && isset( $cached['paths'] ) ) { return $cached; } if ( ! $may_measure ) { return [ 'checked_at' => 0, 'paths' => [] ]; } return self::run( $settings ); } /** * Measure every enabled endpoint and cache the result. * * @return array{checked_at: int, paths: array} */ public static function run( array $settings ): array { $empty = [ 'checked_at' => 0, 'paths' => [] ]; // Not a lock. It stops a burst of admin requests on a cold cache from // each starting their own round of loopback calls; one of them wins and // the rest read the result a moment later. if ( get_transient( self::RUNNING ) ) { return $empty; } set_transient( self::RUNNING, 1, MINUTE_IN_SECONDS ); $result = [ 'checked_at' => time(), 'paths' => [] ]; foreach ( self::paths() as $path => $setting_key ) { if ( empty( $settings[ $setting_key ] ) ) { continue; } $measured = self::request( $path ); $result['paths'][ $path ] = $measured; // The first endpoint that cannot be reached at all ends the round. // A host that blocks loopback requests blocks all of them, and // waiting out the timeout three more times would cost the admin // twenty seconds to learn nothing. if ( self::UNKNOWN === $measured['state'] ) { break; } } set_transient( self::TRANSIENT, $result, self::TTL ); delete_transient( self::RUNNING ); return $result; } /** * Throw away the cached measurement so the next admin view takes a new one. */ public static function invalidate(): void { delete_transient( self::TRANSIENT ); } /** * Ask one URL and classify the answer. * * @return array{state: string, code: int} */ private static function request( string $path ): array { $response = wp_remote_get( home_url( '/' . ltrim( $path, '/' ) ), [ 'timeout' => self::TIMEOUT, // The site may well redirect www to apex or http to https, and // following that is the point: the answer a crawler gets is the // one at the end of the chain. 'redirection' => 3, // A site talking to itself routinely presents a certificate its // own PHP does not trust, on staging domains and behind // terminating proxies especially. WordPress core makes its own // loopback checks the same way. 'sslverify' => false, 'headers' => [ 'Accept' => 'application/json' ], ] ); if ( is_wp_error( $response ) ) { return [ 'state' => self::UNKNOWN, 'code' => 0 ]; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 !== $code ) { return [ 'state' => self::BROKEN, 'code' => $code ]; } // 200 is not enough. A server that hands /.well-known/ to a static // handler answers 200 with an HTML page on some setups, and a caching // layer can serve anything at all. The document has to parse. $body = wp_remote_retrieve_body( $response ); if ( '' === trim( (string) $body ) || null === json_decode( $body ) ) { return [ 'state' => self::BROKEN, 'code' => $code ]; } return [ 'state' => self::OK, 'code' => $code ]; } }