PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.1
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.1
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 3.5.2 All 199 releases
betterdocs / includes / Mcp / MCPTools.php

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

617 lines 18.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP tool catalog, built from the Abilities API registry.
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\AbilityBase;
17 use WPDeveloper\BetterDocs\Abilities\AbilityError;
18 use WPDeveloper\BetterDocs\Abilities\ProState;
19
20 /**
21 * Turns the abilities registry into the MCP tool surface, and back again.
22 *
23 * There is no hand-written tool catalog: every registered `betterdocs/*` and
24 * `betterdocs-pro/*` ability is one MCP tool, so the two can never drift. What
25 * this class adds on top of the registry is the naming, the scope model and the
26 * error vocabulary.
27 *
28 * **Naming.** MCP tool names cannot contain `/`, so the ability prefix is
29 * stripped — and `bd-` is put back in its place. That prefix is not decoration:
30 * an agent may have several MCP servers connected at once, and another plugin's
31 * catalog can carry its own `create-term` or `get-settings`. `bd-` makes every
32 * name in this catalog unambiguous, and the reverse lookup puts the ability
33 * namespace back.
34 *
35 * **The classifier.** A read-only credential may only invoke read tools, and a
36 * tool is a read when its bare name starts with `get-` or `list-`. The `bd-`
37 * prefix has to come off *before* that test — a classifier that asks whether
38 * `bd-get-status` starts with `get-` calls every tool in the catalog a write,
39 * silently emptying a read-only connection's tool list.
40 *
41 * **The errors.** `invoke()` runs the ability through the Abilities API's own
42 * `execute()` (ADR-031) so MCP calls are validated exactly like every other
43 * client's, then maps the runtime's deliberately generic codes back onto the
44 * typed vocabulary an agent can branch on.
45 *
46 * @since 4.9.0
47 */
48 final class MCPTools {
49
50 /**
51 * Ability-name prefixes exposed as MCP tools. Free owns `betterdocs/`; Pro
52 * registers under `betterdocs-pro/` through the
53 * `betterdocs_register_abilities` filter.
54 *
55 * @since 4.9.0
56 */
57 const ABILITY_PREFIXES = [ 'betterdocs/', 'betterdocs-pro/' ];
58
59 /**
60 * Prefix every BetterDocs MCP tool name carries.
61 *
62 * @since 4.9.0
63 */
64 const TOOL_PREFIX = 'bd-';
65
66 /**
67 * Bare-name prefixes that mark a tool as read-only.
68 *
69 * @since 4.9.0
70 */
71 const READ_PREFIXES = [ 'get-', 'list-' ];
72
73 /**
74 * Per-request read-only override. Null means "ask the pairing token".
75 *
76 * Set by `MCPServer` when an OAuth access token, which carries its own
77 * scopes, authorised the request.
78 *
79 * @since 4.9.0
80 *
81 * @var bool|null
82 */
83 private static $read_only_override = null;
84
85 /**
86 * Records the active credential's read-only state for this request.
87 *
88 * @since 4.9.0
89 *
90 * @param bool|null $read_only True/false to force it; null to fall back to
91 * the pairing token's own scope.
92 * @return void
93 */
94 public static function set_read_only_override( $read_only ) {
95 self::$read_only_override = null === $read_only ? null : (bool) $read_only;
96 }
97
98 /**
99 * Whether the active MCP credential may only read.
100 *
101 * Falls back to the pairing token's scope. The call is `class_exists()`-guarded,
102 * so with no override and no pairing class the answer is false, which is the
103 * same answer a full-access pairing gives.
104 *
105 * @since 4.9.0
106 *
107 * @return bool
108 */
109 public static function is_read_only() {
110 if ( null !== self::$read_only_override ) {
111 return self::$read_only_override;
112 }
113
114 if ( class_exists( __NAMESPACE__ . '\\MCPPairing' ) && method_exists( __NAMESPACE__ . '\\MCPPairing', 'is_read_only' ) ) {
115 return (bool) MCPPairing::is_read_only();
116 }
117
118 return false;
119 }
120
121 /**
122 * The MCP tool name for an ability, or '' when the ability is not ours.
123 *
124 * @since 4.9.0
125 *
126 * @param string $ability_name Full ability name, e.g. `betterdocs/create-doc`.
127 * @return string Tool name (`bd-create-doc`), or '' to be skipped.
128 */
129 public static function tool_name( $ability_name ) {
130 $bare = self::strip_prefix( (string) $ability_name );
131
132 if ( null === $bare || '' === $bare ) {
133 return '';
134 }
135
136 return self::TOOL_PREFIX . $bare;
137 }
138
139 /**
140 * The registered ability behind a tool name, or null.
141 *
142 * Free and Pro bare names never collide, so the prefixes are tried in order
143 * and the first registered hit wins. A name without the `bd-` prefix is not
144 * one of our tools at all — that is what stops a bare `get-settings` (which
145 * could be any plugin's) from resolving here.
146 *
147 * The probe is `wp_has_ability()`, never `wp_get_ability()`: a miss on the
148 * latter calls `_doing_it_wrong()` (`WP_Abilities_Registry::get_registered()`,
149 * in core and in the bundled copy alike), so trying `betterdocs/` before
150 * `betterdocs-pro/` would write a notice into `debug.log` on **every** call to
151 * a Pro tool. Measured on the rig before this was changed.
152 *
153 * @since 4.9.0
154 *
155 * @param string $tool Tool name.
156 * @return string|null Ability name, or null when nothing matches.
157 */
158 public static function ability_name( $tool ) {
159 $tool = (string) $tool;
160
161 if ( 0 !== strpos( $tool, self::TOOL_PREFIX ) ) {
162 return null;
163 }
164
165 $bare = substr( $tool, strlen( self::TOOL_PREFIX ) );
166
167 if ( '' === $bare || ! function_exists( 'wp_has_ability' ) ) {
168 return null;
169 }
170
171 foreach ( self::ABILITY_PREFIXES as $prefix ) {
172 if ( wp_has_ability( $prefix . $bare ) ) {
173 return $prefix . $bare;
174 }
175 }
176
177 return null;
178 }
179
180 /**
181 * Whether a tool changes state.
182 *
183 * The `bd-` prefix comes off first; see the class docblock for why that
184 * ordering is the whole point. A name that is not one of ours is treated as
185 * a write, so an unrecognised tool can never be waved through a read-only
186 * credential.
187 *
188 * @since 4.9.0
189 *
190 * @param string $tool Tool name.
191 * @return bool
192 */
193 public static function is_write_tool( $tool ) {
194 $tool = (string) $tool;
195
196 if ( 0 !== strpos( $tool, self::TOOL_PREFIX ) ) {
197 return true;
198 }
199
200 $bare = substr( $tool, strlen( self::TOOL_PREFIX ) );
201
202 foreach ( self::READ_PREFIXES as $prefix ) {
203 if ( 0 === strpos( $bare, $prefix ) ) {
204 return false;
205 }
206 }
207
208 return true;
209 }
210
211 /**
212 * The catalog, in MCP `tools/list` shape.
213 *
214 * Scoped to what the credential may actually invoke: a read-only connection
215 * is shown only read tools, because listing a tool `invoke()` would refuse
216 * makes an agent plan around an action it cannot take.
217 *
218 * The Pro state is probed **once** for the whole list and handed to every
219 * ability's `describe()`, so a catalog of 28 tools costs one probe and every
220 * description agrees about the site.
221 *
222 * @since 4.9.0
223 *
224 * @param array|null $pro_state Optional pre-probed state, for callers that
225 * already have one (and for tests).
226 * @return array[] One entry per tool: name, title, description, inputSchema,
227 * annotations, and `_meta.requires_pro` — the spec's extension
228 * point on `Tool`, because a compliant client drops unknown
229 * top-level keys (ADR-053).
230 */
231 public static function list( $pro_state = null ) {
232 $state = is_array( $pro_state ) ? $pro_state : ProState::get();
233 $read_only = self::is_read_only();
234 $out = [];
235
236 foreach ( self::abilities() as $ability ) {
237 $ability_name = (string) $ability->get_name();
238 $name = self::tool_name( $ability_name );
239
240 if ( '' === $name ) {
241 continue;
242 }
243
244 if ( $read_only && self::is_write_tool( $name ) ) {
245 continue;
246 }
247
248 $instance = AbilitiesRegistrar::instance( $ability_name );
249 $requires_pro = $instance ? $instance->requires_pro() : (bool) self::meta( $ability, 'requires_pro', false );
250 $title = $instance ? $instance->get_label() : $ability_name;
251
252 $out[] = [
253 'name' => $name,
254 'title' => $requires_pro ? sprintf(
255 /* translators: %s: tool label. */
256 __( '%s (Pro)', 'betterdocs' ),
257 $title
258 ) : $title,
259 'description' => $instance ? $instance->describe( $state ) : (string) $ability->get_description(),
260 'inputSchema' => self::input_schema( $ability ),
261 'annotations' => self::annotations( $ability, $instance ),
262 '_meta' => [ 'requires_pro' => $requires_pro ]
263 ];
264 }
265
266 // Stable order, so a client diffing two `tools/list` responses sees only
267 // real changes and not registry ordering.
268 usort(
269 $out,
270 static function ( $a, $b ) {
271 return strcmp( $a['name'], $b['name'] );
272 }
273 );
274
275 return $out;
276 }
277
278 /**
279 * Run one tool.
280 *
281 * Goes through `WP_Ability::execute()` rather than straight to our own
282 * callback (ADR-031): that is where input normalisation, JSON Schema
283 * validation, the permission check, output validation and the runtime's
284 * filters live, and an MCP call must be validated exactly like a call from
285 * any other Abilities client. What comes back is then translated —
286 * deliberately generic runtime codes in, typed BetterDocs errors out.
287 *
288 * @since 4.9.0
289 *
290 * @param string $tool Tool name.
291 * @param array $args Decoded arguments.
292 * @return mixed|\WP_Error Plain data, or a typed error.
293 */
294 public static function invoke( $tool, array $args ) {
295 $tool = (string) $tool;
296
297 AbilitiesRegistrar::ensure_registered();
298
299 $ability_name = self::ability_name( $tool );
300
301 if ( null === $ability_name ) {
302 return AbilityError::unknown_tool( $tool );
303 }
304
305 if ( self::is_write_tool( $tool ) && self::is_read_only() ) {
306 return AbilityError::read_only( $tool );
307 }
308
309 // A known hit, so `wp_get_ability()` is quiet here — see `ability_name()`.
310 $ability = function_exists( 'wp_get_ability' ) ? wp_get_ability( $ability_name ) : null;
311
312 if ( ! $ability ) {
313 return AbilityError::unknown_tool( $tool );
314 }
315
316 $result = $ability->execute( $args );
317
318 if ( ! is_wp_error( $result ) ) {
319 return $result;
320 }
321
322 return self::map_runtime_error( $result, $tool, $ability_name );
323 }
324
325 /**
326 * Translates an Abilities API error into the typed vocabulary.
327 *
328 * The runtime answers a refused call with a deliberately generic
329 * `ability_invalid_permissions` — it will not say which capability was
330 * missing, on the reasoning that whoever was refused should not learn what
331 * they lack. An agent, though, is acting for a person who *can* be told, and
332 * a refusal it cannot explain is a dead end. We know the capability, because
333 * the ability instance declared it, so the typed error is rebuilt here.
334 *
335 * Anything that already carries `data['error']` is one of ours and passes
336 * through untouched. Everything left over is a bug on our side rather than
337 * the caller's, so it becomes `upstream_error` and is logged under
338 * `WP_DEBUG`.
339 *
340 * Public because it is the seam the unit tests exercise: the mapping has to be
341 * verifiable without provoking the runtime into producing each code.
342 *
343 * @since 4.9.0
344 *
345 * @param \WP_Error $error What the runtime returned.
346 * @param string $tool Tool name, for the message.
347 * @param string $ability_name Ability name, to find the declared capability.
348 * @return \WP_Error
349 */
350 public static function map_runtime_error( \WP_Error $error, $tool, $ability_name ) {
351 $data = $error->get_error_data();
352
353 if ( is_array( $data ) && isset( $data['error'] ) ) {
354 return $error;
355 }
356
357 $code = (string) $error->get_error_code();
358 $message = (string) $error->get_error_message();
359
360 if ( 'ability_invalid_permissions' === $code ) {
361 $instance = AbilitiesRegistrar::instance( $ability_name );
362 $capability = $instance ? $instance->get_capability() : '';
363
364 return AbilityError::capability_missing(
365 $capability,
366 sprintf(
367 /* translators: %s: MCP tool name. */
368 __( 'run the "%s" tool', 'betterdocs' ),
369 $tool
370 )
371 );
372 }
373
374 if ( 'ability_invalid_input' === $code || 'ability_missing_input_schema' === $code ) {
375 return AbilityError::invalid_input( self::input_field( $error ), $message );
376 }
377
378 // `ability_invalid_output`, `ability_callback_exception`,
379 // `ability_invalid_execute_callback`, `ability_invalid_permission_callback`
380 // and anything a controller returned untyped: all our side of the line.
381 self::log( sprintf( 'tool %s failed with %s: %s', $tool, $code, $message ) );
382
383 return AbilityError::upstream(
384 $message,
385 [
386 'ability' => $ability_name,
387 'runtime_code' => $code
388 ]
389 );
390 }
391
392 /**
393 * Every registered ability that belongs to BetterDocs.
394 *
395 * @since 4.9.0
396 *
397 * @return object[] `WP_Ability` instances.
398 */
399 private static function abilities() {
400 if ( ! function_exists( 'wp_get_abilities' ) ) {
401 return [];
402 }
403
404 // Our abilities reach the registry through `wp_abilities_api_init`,
405 // which fires once, from whichever copy of the API owns the global
406 // functions. If a foreign copy owns them and fired before our hook was
407 // attached, the catalog would be empty while auth and discovery all
408 // reported success. This replays once where a replay can work, and is a
409 // no-op otherwise (ADR-032).
410 AbilitiesRegistrar::ensure_registered();
411
412 $out = [];
413
414 foreach ( wp_get_abilities() as $ability ) {
415 if ( ! is_object( $ability ) || ! method_exists( $ability, 'get_name' ) ) {
416 continue;
417 }
418
419 if ( null !== self::strip_prefix( (string) $ability->get_name() ) ) {
420 $out[] = $ability;
421 }
422 }
423
424 return $out;
425 }
426
427 /**
428 * The ability's input schema, in the shape MCP clients validate against.
429 *
430 * @since 4.9.0
431 *
432 * @param object $ability Registered ability.
433 * @return array
434 */
435 private static function input_schema( $ability ) {
436 $schema = method_exists( $ability, 'get_input_schema' ) ? $ability->get_input_schema() : [];
437
438 if ( ! is_array( $schema ) || empty( $schema ) ) {
439 return [
440 'type' => 'object',
441 'properties' => (object) []
442 ];
443 }
444
445 $schema = self::normalize_schema( $schema );
446
447 if ( ! isset( $schema['type'] ) ) {
448 $schema['type'] = 'object';
449 }
450
451 if ( ! isset( $schema['properties'] ) ) {
452 $schema['properties'] = (object) [];
453 }
454
455 return $schema;
456 }
457
458 /**
459 * MCP annotations for one tool.
460 *
461 * The Abilities API and MCP spell the same hints differently — `readonly`
462 * against `readOnlyHint`, and so on — so the ability keeps the API's
463 * spelling and the translation happens here, at the boundary. `priority` is
464 * an Abilities-side ordering hint with no MCP counterpart and is not sent.
465 *
466 * @since 4.9.0
467 *
468 * @param object $ability Registered ability.
469 * @param AbilityBase|null $instance Our own instance, when we have it.
470 * @return array
471 */
472 private static function annotations( $ability, $instance ) {
473 $source = $instance ? $instance->get_annotations() : self::meta( $ability, 'annotations', [] );
474
475 if ( ! is_array( $source ) ) {
476 $source = [];
477 }
478
479 return [
480 'readOnlyHint' => ! empty( $source['readonly'] ),
481 'destructiveHint' => ! empty( $source['destructive'] ),
482 'idempotentHint' => ! empty( $source['idempotent'] ),
483 'openWorldHint' => ! empty( $source['openWorldHint'] )
484 ];
485 }
486
487 /**
488 * One `meta` value off a registered ability, whichever accessor the runtime
489 * copy provides.
490 *
491 * @since 4.9.0
492 *
493 * @param object $ability Registered ability.
494 * @param string $key Meta key.
495 * @param mixed $fallback Value when the key is absent.
496 * @return mixed
497 */
498 private static function meta( $ability, $key, $fallback = null ) {
499 if ( method_exists( $ability, 'get_meta_item' ) ) {
500 $value = $ability->get_meta_item( $key );
501
502 return null === $value ? $fallback : $value;
503 }
504
505 if ( method_exists( $ability, 'get_meta' ) ) {
506 $meta = $ability->get_meta();
507
508 return isset( $meta[ $key ] ) ? $meta[ $key ] : $fallback;
509 }
510
511 return $fallback;
512 }
513
514 /**
515 * JSON Schema fix-ups for MCP clients.
516 *
517 * An empty PHP `properties` array encodes as `[]`, but the schema spec — and
518 * every MCP SDK validator — needs an object, `{}`. Recurses first, so a
519 * nested field-less object is fixed too.
520 *
521 * @since 4.9.0
522 *
523 * @param array $schema JSON Schema node.
524 * @return array
525 */
526 private static function normalize_schema( array $schema ) {
527 foreach ( $schema as $key => $value ) {
528 if ( is_array( $value ) ) {
529 $schema[ $key ] = self::normalize_schema( $value );
530 }
531 }
532
533 if ( isset( $schema['properties'] ) && [] === $schema['properties'] ) {
534 $schema['properties'] = (object) [];
535 }
536
537 // Same trap one key over: an all-optional ability declares `default => []`
538 // so the runtime can fill a missing input (ADR-030), and that encodes as
539 // `[]` against a `type: object` schema — which a client that applies
540 // defaults before validating would then reject as its own schema's fault.
541 // Only an object-typed node is touched; an array field's empty default is
542 // already right.
543 if ( isset( $schema['type'], $schema['default'] ) && 'object' === $schema['type'] && [] === $schema['default'] ) {
544 $schema['default'] = (object) [];
545 }
546
547 return $schema;
548 }
549
550 /**
551 * Which input field the runtime rejected.
552 *
553 * The runtime does not say directly: core rebuilds the schema validator's
554 * error as a bare `ability_invalid_input` with no data, keeping only the
555 * reason in the message ("… Reason: input[status] is not one of …"). The
556 * data is checked first anyway, in case a filter or a future version
557 * supplies it.
558 *
559 * @since 4.9.0
560 *
561 * @param \WP_Error $error Runtime error.
562 * @return string Field name, or '' when it cannot be told.
563 */
564 private static function input_field( \WP_Error $error ) {
565 $data = $error->get_error_data();
566
567 if ( is_array( $data ) ) {
568 foreach ( [ 'param', 'field' ] as $key ) {
569 if ( ! empty( $data[ $key ] ) && is_string( $data[ $key ] ) ) {
570 return $data[ $key ];
571 }
572 }
573 }
574
575 if ( preg_match( '/input\[([^\]]+)\]/', (string) $error->get_error_message(), $matches ) ) {
576 return $matches[1];
577 }
578
579 return '';
580 }
581
582 /**
583 * Strips a BetterDocs ability prefix.
584 *
585 * @since 4.9.0
586 *
587 * @param string $ability_name Full ability name.
588 * @return string|null Bare name, or null when the ability is not ours.
589 */
590 private static function strip_prefix( $ability_name ) {
591 foreach ( self::ABILITY_PREFIXES as $prefix ) {
592 if ( 0 === strpos( $ability_name, $prefix ) ) {
593 return substr( $ability_name, strlen( $prefix ) );
594 }
595 }
596
597 return null;
598 }
599
600 /**
601 * WP_DEBUG-only diagnostic.
602 *
603 * @since 4.9.0
604 *
605 * @param string $message What happened.
606 * @return void
607 */
608 private static function log( $message ) {
609 if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
610 return;
611 }
612
613 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-gated diagnostic.
614 error_log( '[BD-MCP] ' . $message );
615 }
616 }
617