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 / MCPHealth.php

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

412 lines 12.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Side-effect-free MCP health report.
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 use WPDeveloper\BetterDocs\Abilities\ProState;
17 use WPDeveloper\BetterDocs\Abilities\Runtime;
18
19 /**
20 * One read-only picture of everything an MCP connection depends on.
21 *
22 * The report answers, in a single request, the questions support otherwise asks
23 * for over three rounds of email: which plugin versions, who owns the Abilities
24 * API, how many BetterDocs abilities registered, whether the site's own URLs
25 * agree with each other, whether this user actually holds the capabilities the
26 * tools gate on, and whether the database schema is current.
27 *
28 * Two properties are load-bearing and both are pinned by tests:
29 *
30 * 1. **Side-effect free.** No outbound HTTP, no writes, no state. Anything that
31 * exercises the round trip belongs to {@see MCPSelfTest}, which is a
32 * separate route precisely so this one stays safe to call from anywhere,
33 * including a broken site.
34 * 2. **No secret material, at any depth.** Not the pairing token, not an OAuth
35 * access or refresh token, not a hash of one. `MCPPairing::public_status()`
36 * deliberately carries the connection token for the admin UI — this report
37 * must not reuse it, and reads the individual accessors instead.
38 *
39 * It is admin-gated but **not** gated by `enable_mcp` (ADR-013): a diagnostic
40 * you can only read once the thing already works is not a diagnostic.
41 *
42 * @since 4.9.0
43 */
44 final class MCPHealth {
45
46 /**
47 * Every capability BetterDocs defines, in `Core\Roles` order.
48 *
49 * Held as a constant rather than read from `Roles::defaults_capabilities()`
50 * because that list is filtered (`betterdocs_default_caps`): a site that
51 * filtered a capability away would quietly shrink `missing` and hide the
52 * very gap this report exists to show. The constant is the specification;
53 * the administrator bucket is expected to equal it.
54 *
55 * @since 4.9.0
56 */
57 const REQUIRED_CAPABILITIES = [
58 'edit_docs',
59 'edit_others_docs',
60 'edit_private_docs',
61 'edit_published_docs',
62 'read_private_docs',
63 'publish_docs',
64 'delete_docs',
65 'delete_private_docs',
66 'delete_published_docs',
67 'delete_others_docs',
68 'manage_doc_terms',
69 'edit_doc_terms',
70 'delete_doc_terms',
71 'manage_knowledge_base_terms',
72 'edit_knowledge_base_terms',
73 'delete_knowledge_base_terms',
74 'edit_docs_settings',
75 'read_docs_analytics',
76 'read_faq_builder'
77 ];
78
79 /**
80 * Build the report.
81 *
82 * @since 4.9.0
83 *
84 * @param int|null $user_id Whose capabilities to report on. Null (the
85 * default) means the current user.
86 * @return array
87 */
88 public function report( $user_id = null ) {
89 $user_id = null === $user_id ? get_current_user_id() : (int) $user_id;
90
91 return [
92 'plugin' => $this->plugin_info(),
93 'mcp' => $this->mcp_info(),
94 'abilities' => $this->abilities_info(),
95 'runtime' => $this->runtime_info(),
96 'capabilities' => $this->capabilities_info( $user_id ),
97 'user' => $this->user_info( $user_id ),
98 'urls' => self::analyze_urls( home_url(), site_url(), rest_url() ),
99 'database' => $this->database_info()
100 ];
101 }
102
103 /**
104 * Free and Pro versions, and the full Pro state.
105 *
106 * `ProState::get( false )` — the knowledge-base feature flag is deliberately
107 * *not* asked about, so a site with Multiple Knowledge Base switched off
108 * reports `pro_unlicensed` or `ok` rather than the tool-facing
109 * `pro_active_setting_off` (ADR-034). This is a report, not a refusal.
110 *
111 * @since 4.9.0
112 *
113 * @return array
114 */
115 private function plugin_info() {
116 return [
117 'free_version' => defined( 'BETTERDOCS_VERSION' ) ? BETTERDOCS_VERSION : null,
118 'pro' => ProState::get( false )
119 ];
120 }
121
122 /**
123 * The switch, the endpoints, the pairing record and the OAuth grants.
124 *
125 * @since 4.9.0
126 *
127 * @return array
128 */
129 private function mcp_info() {
130 return [
131 'enabled' => MCPManager::is_enabled(),
132 'endpoint' => MCPPairing::site_endpoint(),
133 'endpoint_rest' => MCPPairing::site_endpoint_fallback(),
134 'authorize_url' => MCPOAuth::authorize_url(),
135 'discovery' => [
136 'well_known' => [
137 'protected_resource' => home_url( '/.well-known/oauth-protected-resource/' . MCPPairing::SITE_ENDPOINT_PATH ),
138 'authorization_server' => home_url( '/.well-known/oauth-authorization-server/' . MCPPairing::SITE_ENDPOINT_PATH )
139 ],
140 'rest_alias' => [
141 'protected_resource' => MCPOAuth::resource_metadata_url(),
142 'authorization_server' => rest_url( MCPManager::NS . '/mcp/oauth/authorization-server' )
143 ]
144 ],
145 // Built field by field from the individual accessors. `public_status()`
146 // carries the connection token for the admin UI and must never be
147 // spread into this report.
148 'pairing' => $this->pairing_info(),
149 'oauth_apps' => count( MCPOAuth::connected_apps() ),
150 // Null under an external object cache, where the transients this
151 // counts are not in the options table.
152 'lockouts' => MCPRateLimiter::active_lockouts()
153 ];
154 }
155
156 /**
157 * The pairing record with every credential field left out.
158 *
159 * @since 4.9.0
160 *
161 * @return array
162 */
163 private function pairing_info() {
164 $state = MCPPairing::state();
165
166 return [
167 'connected' => MCPPairing::is_connected(),
168 'connected_at' => isset( $state['connected_at'] ) ? (int) $state['connected_at'] : 0,
169 'last_used' => isset( $state['last_used'] ) ? (int) $state['last_used'] : 0,
170 'read_only' => MCPPairing::is_read_only(),
171 // The list `read_only` is derived from, so a scope nobody expected
172 // is visible rather than collapsed into a boolean.
173 'scopes' => isset( $state['scopes'] ) && is_array( $state['scopes'] ) ? $state['scopes'] : [],
174 'user_id' => MCPPairing::user_id()
175 ];
176 }
177
178 /**
179 * Registry diagnostics plus the BetterDocs ability names.
180 *
181 * Only our two prefixes are listed: another plugin's abilities are none of
182 * this report's business, and a support transcript should not enumerate
183 * them.
184 *
185 * @since 4.9.0
186 *
187 * @return array
188 */
189 private function abilities_info() {
190 $info = AbilitiesRegistrar::diagnostics();
191
192 $names = [];
193
194 if ( function_exists( 'wp_get_abilities' ) ) {
195 foreach ( wp_get_abilities() as $ability ) {
196 if ( ! is_object( $ability ) || ! method_exists( $ability, 'get_name' ) ) {
197 continue;
198 }
199
200 $name = (string) $ability->get_name();
201
202 foreach ( MCPTools::ABILITY_PREFIXES as $prefix ) {
203 if ( 0 === strpos( $name, $prefix ) ) {
204 $names[] = $name;
205
206 break;
207 }
208 }
209 }
210
211 sort( $names );
212 }
213
214 $info['names'] = $names;
215 $info['summary'] = AbilitiesRegistrar::summary();
216
217 return $info;
218 }
219
220 /**
221 * Who owns the Abilities API, and what else the runtime brought with it.
222 *
223 * The provider version comes from `Runtime::owner()`, never from
224 * `WP_ABILITIES_API_VERSION`: that constant is defined by whichever
225 * `bootstrap.php` loaded first, so on a core-owned site it still reports the
226 * bundled 0.4.0. When core owns the API the
227 * meaningful version is WordPress' own.
228 *
229 * @since 4.9.0
230 *
231 * @return array
232 */
233 private function runtime_info() {
234 $owner = Runtime::owner();
235
236 if ( 'core' === $owner['source'] ) {
237 $owner['version'] = get_bloginfo( 'version' );
238 }
239
240 return [
241 'abilities_api' => $owner,
242 'parsedown_version' => self::bundled_version( 'erusev/parsedown' ),
243 'jetpack_autoloader' => self::bundled_version( 'automattic/jetpack-autoloader' ),
244 // Without libsodium `Core\SecretAtRest` stores the pairing token
245 // in the clear (it degrades to a passthrough rather than failing),
246 // so this is a real finding, not trivia.
247 'sodium' => function_exists( 'sodium_crypto_secretbox' )
248 ];
249 }
250
251 /**
252 * A package version from the bundled runtime's own `installed.json`.
253 *
254 * @since 4.9.0
255 *
256 * @param string $package Composer package name.
257 * @return string|null Null when the runtime is missing or the package is not in it.
258 */
259 private static function bundled_version( $package ) {
260 $file = dirname( __DIR__, 2 ) . '/dependencies/vendor/composer/installed.json';
261
262 if ( ! is_readable( $file ) ) {
263 return null;
264 }
265
266 $data = json_decode( (string) file_get_contents( $file ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading a bundled file from disk; WP_Filesystem is not initialised on a REST request.
267
268 if ( ! is_array( $data ) || ! isset( $data['packages'] ) || ! is_array( $data['packages'] ) ) {
269 return null;
270 }
271
272 foreach ( $data['packages'] as $entry ) {
273 if ( isset( $entry['name'], $entry['version'] ) && $package === $entry['name'] ) {
274 return (string) $entry['version'];
275 }
276 }
277
278 return null;
279 }
280
281 /**
282 * What this user holds of what BetterDocs asks for.
283 *
284 * @since 4.9.0
285 *
286 * @param int $user_id User to test.
287 * @return array
288 */
289 private function capabilities_info( $user_id ) {
290 $held = [];
291 $missing = [];
292
293 $user = $user_id > 0 ? get_user_by( 'id', $user_id ) : null;
294
295 foreach ( self::REQUIRED_CAPABILITIES as $capability ) {
296 $can = $user ? user_can( $user, $capability ) : false;
297
298 if ( $can ) {
299 $held[] = $capability;
300 } else {
301 $missing[] = $capability;
302 }
303 }
304
305 return [
306 'user_id' => $user_id,
307 'required' => self::REQUIRED_CAPABILITIES,
308 'held' => $held,
309 'missing' => $missing
310 ];
311 }
312
313 /**
314 * Who the report is about.
315 *
316 * @since 4.9.0
317 *
318 * @param int $user_id User to describe.
319 * @return array
320 */
321 private function user_info( $user_id ) {
322 $user = $user_id > 0 ? get_user_by( 'id', $user_id ) : null;
323
324 if ( ! $user ) {
325 return [
326 'id' => $user_id,
327 'login' => '',
328 'display_name' => '',
329 'roles' => [],
330 'exists' => false
331 ];
332 }
333
334 return [
335 'id' => (int) $user->ID,
336 'login' => isset( $user->user_login ) ? (string) $user->user_login : '',
337 'display_name' => isset( $user->display_name ) ? (string) $user->display_name : '',
338 'roles' => isset( $user->roles ) && is_array( $user->roles ) ? array_values( $user->roles ) : [],
339 'exists' => true
340 ];
341 }
342
343 /**
344 * Whether this site's own URLs agree with each other.
345 *
346 * A `home`/`siteurl` scheme disagreement, or a REST URL on a different
347 * scheme from `home`, is the classic cause of an MCP client's requests
348 * bouncing between http and https and of a discovery document advertising
349 * an identifier no client will match.
350 *
351 * Pure function of its arguments, so a test can drive it directly.
352 *
353 * @since 4.9.0
354 *
355 * @param string $home_url The `home` option.
356 * @param string $site_url The `siteurl` option.
357 * @param string $rest_url The REST base.
358 * @return array
359 */
360 public static function analyze_urls( $home_url, $site_url, $rest_url ) {
361 $home = strtolower( (string) wp_parse_url( (string) $home_url, PHP_URL_SCHEME ) );
362 $site = strtolower( (string) wp_parse_url( (string) $site_url, PHP_URL_SCHEME ) );
363 $rest = strtolower( (string) wp_parse_url( (string) $rest_url, PHP_URL_SCHEME ) );
364
365 return [
366 'home' => (string) $home_url,
367 'siteurl' => (string) $site_url,
368 'rest_url' => (string) $rest_url,
369 'home_scheme' => $home,
370 'siteurl_scheme' => $site,
371 'rest_scheme' => $rest,
372 'is_https' => 'https' === $home,
373 'home_siteurl_match' => '' !== $home && $home === $site,
374 'rest_matches_home' => '' !== $rest && $rest === $home
375 ];
376 }
377
378 /**
379 * Schema currency for Free and, when present, Pro.
380 *
381 * A doc or FAQ tool can fail long after the endpoint itself answers, if the
382 * tables the plugin expects are a version behind.
383 *
384 * @since 4.9.0
385 *
386 * @return array
387 */
388 private function database_info() {
389 $installed = (string) get_option( 'betterdocs_db_version', '' );
390 $expected = defined( 'BETTERDOCS_DB_VERSION' ) ? (string) BETTERDOCS_DB_VERSION : '';
391
392 $info = [
393 'installed_version' => '' !== $installed ? $installed : null,
394 'expected_version' => '' !== $expected ? $expected : null,
395 'up_to_date' => '' !== $installed && '' !== $expected && $installed === $expected
396 ];
397
398 if ( defined( 'BETTERDOCS_PRO_DB_VERSION' ) ) {
399 $pro_installed = (string) get_option( 'betterdocs_pro_db_version', '' );
400 $pro_expected = (string) BETTERDOCS_PRO_DB_VERSION;
401
402 $info['pro'] = [
403 'installed_version' => '' !== $pro_installed ? $pro_installed : null,
404 'expected_version' => '' !== $pro_expected ? $pro_expected : null,
405 'up_to_date' => '' !== $pro_installed && '' !== $pro_expected && $pro_installed === $pro_expected
406 ];
407 }
408
409 return $info;
410 }
411 }
412