PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / 2.4.0
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution v2.4.0
2.5.0 2.4.0 2.3.0 2.2.5 2.2.0 2.1.2 2.1.1 trunk 1.10.0 1.10.01 1.10.02 1.5.0 1.5.01 1.5.02 1.5.1 1.5.10 1.5.20 1.5.21 1.5.22 1.5.23 1.5.24 1.5.25 1.6.0 1.7.0 1.7.1 All 34 releases
fluent-booking / app / Modules / MCP / AbilitiesRegistrar.php

AbilitiesRegistrar.php in Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution 2.4.0, at app/Modules/MCP/AbilitiesRegistrar.php

435 lines 16.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentBooking\App\Modules\MCP;
4
5 use FluentBooking\App\Modules\MCP\Support\MCPHelper;
6 use FluentBooking\App\Modules\MCP\Support\PermissionGate;
7 use FluentBooking\App\Modules\MCP\Prompts\BookingPrompts;
8 use FluentBooking\App\Modules\MCP\Tools\BookingTools;
9 use FluentBooking\App\Modules\MCP\Tools\BookingWriteTools;
10 use FluentBooking\App\Modules\MCP\Tools\ContextTools;
11 use FluentBooking\App\Modules\MCP\Tools\EventTypeTools;
12 use FluentBooking\App\Modules\MCP\Tools\ReportTools;
13 use FluentBooking\App\Modules\MCP\Tools\SchedulingTools;
14 use FluentBooking\App\Modules\MCP\Tools\SlotTools;
15 use FluentBooking\Framework\Support\Arr;
16
17 defined('ABSPATH') || exit;
18
19 /**
20 * Single source of truth for every FluentBooking MCP ability.
21 *
22 * Each tool class owns its own `definitions()` slice, so a tool's schema lives
23 * next to the code that answers it. This class merges those slices, filters
24 * them by the toolsets the operator has enabled, wraps every execute_callback,
25 * and registers the survivors with the WordPress Abilities API.
26 *
27 * Pro tools are NOT listed here — FluentBooking Pro pushes its abilities via
28 * the `fluent_booking/mcp_loaded` action and the
29 * `fluent_booking/mcp_ability_names` filter, registering into this same
30 * namespace and server.
31 */
32 class AbilitiesRegistrar
33 {
34 const CATEGORY = 'fluent-booking';
35
36 /**
37 * Measured against this plugin's own definitions. It is a label on a
38 * toggle, not an invoice.
39 */
40 const BYTES_PER_TOKEN = 3.5;
41
42 /**
43 * Tool classes per toolset. A class listed under a toolset is registered
44 * only when that toolset is on, which is the whole point: an operator who
45 * never asks an agent to edit event types should not pay for those schemas
46 * in every request's context window.
47 *
48 * @return array toolset slug => tool class names
49 */
50 private static function toolClasses()
51 {
52 $classes = [
53 PermissionGate::TOOLSET_CORE => [
54 ContextTools::class,
55 BookingTools::class,
56 BookingWriteTools::class,
57 SlotTools::class,
58 EventTypeTools::class,
59 ReportTools::class,
60 BookingPrompts::class,
61 ],
62 PermissionGate::TOOLSET_SCHEDULING => [
63 SchedulingTools::class,
64 ],
65 // Pro fills this in through the filter below.
66 PermissionGate::TOOLSET_PAYMENTS => [],
67 ];
68
69 /**
70 * The tool classes each toolset exposes, keyed by toolset.
71 *
72 * This is the extension point add-ons register through — Pro's payment
73 * tools arrive here. Adding a class to a toolset means it inherits that
74 * toolset's on/off switch and its context budget automatically, which
75 * is why the hook is on the class map rather than on the finished
76 * definitions.
77 *
78 * Every class listed must expose a static `definitions()` returning
79 * ability-name => definition, in the shape documented in
80 * docs/mcp-server-spec.md §8.
81 *
82 * @since 2.2.6
83 *
84 * @param array $classes toolset key => array of class names.
85 */
86 return (array) apply_filters('fluent_booking/mcp_tool_classes', $classes);
87 }
88
89 /**
90 * Every definition the enabled toolsets expose.
91 *
92 * @param array|null $toolsets defaults to the operator's saved selection
93 * @return array ability name => definition
94 */
95 public static function getDefinitions($toolsets = null)
96 {
97 if ($toolsets === null) {
98 $toolsets = PermissionGate::enabledToolsets();
99 }
100
101 $defs = [];
102
103 foreach (self::toolClasses() as $toolset => $classes) {
104 if (!in_array($toolset, (array) $toolsets, true)) {
105 continue;
106 }
107
108 foreach ($classes as $class) {
109 if (class_exists($class) && method_exists($class, 'definitions')) {
110 $defs = array_merge($defs, (array) $class::definitions());
111 }
112 }
113 }
114
115 return $defs;
116 }
117
118 /**
119 * The ability names that are prompts rather than tools.
120 *
121 * The adapter takes tools and prompts as separate arguments to
122 * create_server(), and a prompt listed as a tool would appear in
123 * `tools/list` with a body that reads as instructions — which is both
124 * wrong and expensive.
125 *
126 * @param array|null $toolsets
127 * @return array
128 */
129 public static function getPromptNames($toolsets = null)
130 {
131 $names = [];
132
133 foreach (self::getDefinitions($toolsets) as $name => $definition) {
134 if (!empty($definition['is_prompt'])) {
135 $names[] = $name;
136 }
137 }
138
139 return $names;
140 }
141
142 /**
143 * The ability names that are tools.
144 *
145 * @param array|null $toolsets
146 * @return array
147 */
148 public static function getToolNames($toolsets = null)
149 {
150 $names = [];
151
152 foreach (self::getDefinitions($toolsets) as $name => $definition) {
153 if (empty($definition['is_prompt'])) {
154 $names[] = $name;
155 }
156 }
157
158 return $names;
159 }
160
161 /**
162 * Register every enabled definition as a WP ability.
163 */
164 public static function register()
165 {
166 foreach (self::getDefinitions() as $name => $definition) {
167 try {
168 // wp_register_ability() returns null on every validation
169 // failure rather than throwing: WP_Abilities_Registry::register()
170 // catches its own InvalidArgumentException, calls
171 // _doing_it_wrong() and returns. So the return value is the ONLY
172 // signal that a definition was rejected — ignore it and a tool
173 // goes missing from tools/list with nothing recorded anywhere.
174 $registered = self::registerAbility($name, $definition);
175
176 if (!$registered) {
177 self::reportRegistrationFailure($name, 'wp_register_ability() rejected the definition; see the _doing_it_wrong notice for the reason.');
178 }
179 } catch (\Throwable $e) {
180 // Belt and braces for the paths core does NOT guard: a TypeError
181 // raised while building $args, or a future core version that
182 // lets an exception escape. Registration runs on
183 // wp_abilities_api_init, which the adapter fires lazily from
184 // INSIDE our own create_server() call, so an uncaught throw here
185 // would not just drop this one ability — it aborts every later
186 // callback on that action, other plugins' abilities included,
187 // and takes the FluentBooking MCP server down with it. One
188 // malformed definition must never cost the whole surface.
189 self::reportRegistrationFailure($name, $e);
190 }
191 }
192 }
193
194 /**
195 * Record that one ability did not register, without taking the rest down.
196 *
197 * @param string $name
198 * @param \Throwable|string $reason
199 */
200 private static function reportRegistrationFailure($name, $reason)
201 {
202 if (defined('FLUENT_BOOKING_DEBUG') && FLUENT_BOOKING_DEBUG) {
203 // No booking data or tokens here — just the ability name and the
204 // failure site.
205 $detail = $reason instanceof \Throwable
206 ? get_class($reason) . ': ' . $reason->getMessage() . ' at ' . basename($reason->getFile()) . ':' . $reason->getLine()
207 : (string) $reason;
208
209 error_log('FluentBooking MCP ability registration failed: ' . $name . ' - ' . $detail); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
210 }
211
212 /**
213 * Fires when a single MCP ability fails to register. The remaining
214 * abilities still register; this lets a site alert on the gap rather
215 * than discover it through a missing tool.
216 *
217 * @since 2.3.0
218 *
219 * @param string $name the ability name that failed
220 * @param \Throwable|string $reason the exception, or a description of
221 * why core rejected the definition
222 */
223 do_action('fluent_booking/mcp_ability_registration_failed', $name, $reason);
224 }
225
226 /**
227 * Register one definition with the Abilities API. Kept separate from
228 * register() so that method's try/catch stays a thin skip-and-continue shell.
229 *
230 * @param string $name
231 * @param array $definition
232 * @return object|null the registered WP_Ability, or null when core refused
233 */
234 private static function registerAbility($name, $definition)
235 {
236 // Cast before array_keys(): a no-argument tool declares `properties` as
237 // an stdClass so the schema serialises as {} rather than [], and
238 // array_keys() rejects an object with a TypeError on PHP 8.
239 $properties = Arr::get($definition, 'input_schema.properties', []);
240
241 $declaredParams = $properties ? array_keys((array) $properties) : [];
242
243 $args = [
244 'label' => Arr::get($definition, 'label'),
245 'description' => Arr::get($definition, 'description'),
246 'category' => self::CATEGORY,
247 'execute_callback' => self::wrapExecuteCallback($name, Arr::get($definition, 'execute_callback'), $declaredParams),
248 'permission_callback' => Arr::get($definition, 'permission_callback'),
249 'meta' => [
250 'show_in_rest' => true,
251 'mcp' => array_merge(
252 ['public' => true],
253 !empty($definition['is_prompt']) ? ['type' => 'prompt'] : []
254 ),
255 ],
256 ];
257
258 if (!empty($definition['input_schema'])) {
259 $args['input_schema'] = $definition['input_schema'];
260 }
261
262 if (!empty($definition['output_schema'])) {
263 $args['output_schema'] = $definition['output_schema'];
264 }
265
266 if (!empty($definition['annotations'])) {
267 $mapped = self::mapAnnotations($definition['annotations']);
268 if (!empty($mapped)) {
269 $args['meta']['annotations'] = $mapped;
270 }
271 }
272
273 return wp_register_ability($name, $args);
274 }
275
276 /**
277 * Emit a tool's behaviour hints under BOTH vocabularies.
278 *
279 * There are two, and which one is read depends on who is reading:
280 *
281 * - WordPress core owns `meta.annotations` and defines it in snake_case —
282 * `readonly`, `destructive`, `idempotent` (see WP_Ability::
283 * $default_annotations). Core merges its own nulls over whatever is
284 * passed, so an ability that supplies only camelCase ends up recorded
285 * with `destructive => null`: not destructive, as far as core and
286 * anything reading core is concerned.
287 * - The MCP wire format names them `readOnlyHint` / `destructiveHint` /
288 * `idempotentHint` / `openWorldHint`, and an adapter that forwards
289 * meta.annotations verbatim needs those spellings to reach the client.
290 *
291 * Emitting one spelling and hoping is how every destructive tool on this
292 * server silently loses its confirmation prompt. Emitting both costs a few
293 * bytes per tool and is correct under either reader, so that is what this
294 * does. Unknown keys are still dropped rather than passed through as noise.
295 *
296 * Public so scripts/check-mcp-budget.php can measure the annotations a
297 * client actually receives. Measuring the pre-mapping shape under-reports
298 * every tool by the size of the second vocabulary.
299 *
300 * @param array $annotations
301 * @return array
302 */
303 /**
304 * The wire size of one definition as a client receives it in tools/list.
305 *
306 * The MAPPED annotations, not the declared ones: tools declare `readonly`
307 * and this class emits both that and `readOnlyHint`, so measuring the
308 * declared shape under-reports every tool.
309 *
310 * @param string $name
311 * @param array $definition
312 *
313 * @return int
314 */
315 public static function wireBytes($name, $definition)
316 {
317 return strlen((string) wp_json_encode([
318 'name' => $name,
319 'description' => isset($definition['description']) ? $definition['description'] : '',
320 'inputSchema' => isset($definition['input_schema']) ? $definition['input_schema'] : [],
321 'annotations' => self::mapAnnotations(
322 isset($definition['annotations']) ? $definition['annotations'] : []
323 ),
324 ]));
325 }
326
327 /**
328 * @param int $bytes
329 * @return int
330 */
331 public static function wireTokens($bytes)
332 {
333 return (int) round($bytes / self::BYTES_PER_TOKEN);
334 }
335
336 public static function mapAnnotations($annotations)
337 {
338 $map = [
339 'readonly' => 'readOnlyHint',
340 'destructive' => 'destructiveHint',
341 'idempotent' => 'idempotentHint',
342 'open_world' => 'openWorldHint',
343 ];
344
345 $out = [];
346
347 foreach ((array) $annotations as $key => $value) {
348 if ($key === 'title') {
349 $out['title'] = (string) $value;
350 continue;
351 }
352
353 if (isset($map[$key])) {
354 $out[$key] = (bool) $value; // core's vocabulary
355 $out[$map[$key]] = (bool) $value; // the MCP wire vocabulary
356 }
357 }
358
359 // A read-only tool cannot be destructive. destructiveHint defaults to
360 // TRUE when absent per the MCP spec, so state it explicitly for read
361 // tools — otherwise a client gating on destructiveHint would prompt for
362 // confirmation before every report.
363 if (!empty($out['readOnlyHint']) && !isset($out['destructiveHint'])) {
364 $out['destructive'] = false;
365 $out['destructiveHint'] = false;
366 }
367
368 return $out;
369 }
370
371 /**
372 * Wrap a tool callback so it (a) rejects input parameters the tool does not
373 * declare and (b) converts an unhandled exception into a structured error.
374 *
375 * The rejection matters more than it looks. `input_schema` sets no
376 * `additionalProperties`, so an undeclared key would otherwise pass
377 * validation and be silently dropped — and the agent would receive a full,
378 * plausible-looking result that is NOT filtered the way it asked. That is
379 * the worst failure mode available: a wrong number reads as a right one,
380 * whereas an error is recoverable. Sibling tools also name overlapping
381 * concepts differently, so a carried-over parameter name is a realistic slip
382 * rather than a rare typo. The error names the accepted parameters so the
383 * agent can self-correct in one step — richer than the schema validator's
384 * message, which is why this lives here rather than in the schema.
385 *
386 * @param string $toolName
387 * @param callable $callback
388 * @param array $declaredParams
389 * @return \Closure
390 */
391 private static function wrapExecuteCallback($toolName, $callback, $declaredParams)
392 {
393 return function ($params = []) use ($toolName, $callback, $declaredParams) {
394 if (!is_array($params)) {
395 $params = [];
396 }
397
398 $unknown = array_diff(array_keys($params), $declaredParams);
399
400 if ($unknown) {
401 return MCPHelper::error(
402 'unknown_parameter',
403 sprintf(
404 /* translators: 1: tool name, 2: rejected parameter names, 3: accepted parameter names */
405 __('%1$s does not accept: %2$s. Accepted parameters: %3$s.', 'fluent-booking'),
406 $toolName,
407 implode(', ', $unknown),
408 $declaredParams ? implode(', ', $declaredParams) : __('none', 'fluent-booking')
409 ),
410 ['accepted_parameters' => $declaredParams]
411 );
412 }
413
414 try {
415 return call_user_func($callback, $params);
416 } catch (\Throwable $e) {
417 if (defined('FLUENT_BOOKING_DEBUG') && FLUENT_BOOKING_DEBUG) {
418 error_log('FluentBooking MCP tool failed: ' . $toolName . ' - ' . get_class($e) . ': ' . $e->getMessage() . ' at ' . basename($e->getFile()) . ':' . $e->getLine()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
419 }
420
421 // Deliberately not surfacing $e->getMessage(): it can carry SQL
422 // fragments or file paths, and the agent cannot act on either.
423 return MCPHelper::error(
424 'tool_failed',
425 sprintf(
426 /* translators: %s: tool name */
427 __('%s could not complete. The site logged the details.', 'fluent-booking'),
428 $toolName
429 )
430 );
431 }
432 };
433 }
434 }
435