PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / 2.5.0
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution v2.5.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.5.0, at app/Modules/MCP/AbilitiesRegistrar.php

374 lines 13.2 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 * Registers every FluentBooking MCP ability.
21 *
22 * Each tool class owns its `definitions()`. This class merges them, keeps the
23 * enabled toolsets, wraps each execute_callback and registers the result with
24 * the Abilities API. Pro adds its own via `fluent_booking/mcp_loaded` and
25 * `fluent_booking/mcp_ability_names`.
26 */
27 class AbilitiesRegistrar
28 {
29 const CATEGORY = 'fluent-booking';
30
31 /**
32 * Measured against this plugin's definitions. A rough label, not exact.
33 */
34 const BYTES_PER_TOKEN = 3.5;
35
36 /**
37 * Tool classes per toolset. A class registers only when its toolset is on,
38 * so unused schemas don't cost context.
39 *
40 * @return array toolset slug => tool class names
41 */
42 private static function toolClasses()
43 {
44 $classes = [
45 PermissionGate::TOOLSET_CORE => [
46 ContextTools::class,
47 BookingTools::class,
48 BookingWriteTools::class,
49 SlotTools::class,
50 EventTypeTools::class,
51 ReportTools::class,
52 BookingPrompts::class,
53 ],
54 PermissionGate::TOOLSET_SCHEDULING => [
55 SchedulingTools::class,
56 ],
57 // Pro fills this in through the filter below.
58 PermissionGate::TOOLSET_PAYMENTS => [],
59 ];
60
61 /**
62 * The tool classes each toolset exposes, keyed by toolset.
63 *
64 * Add-ons (e.g. Pro's payment tools) register here so they inherit the
65 * toolset's on/off switch. Each class needs a static `definitions()`
66 * returning ability-name => definition (docs/mcp-server-spec.md §8).
67 *
68 * @since 2.2.6
69 *
70 * @param array $classes toolset key => array of class names.
71 */
72 return (array) apply_filters('fluent_booking/mcp_tool_classes', $classes);
73 }
74
75 /**
76 * Every definition the enabled toolsets expose.
77 *
78 * @param array|null $toolsets defaults to the operator's saved selection
79 * @return array ability name => definition
80 */
81 public static function getDefinitions($toolsets = null)
82 {
83 if ($toolsets === null) {
84 $toolsets = PermissionGate::enabledToolsets();
85 }
86
87 $defs = [];
88
89 foreach (self::toolClasses() as $toolset => $classes) {
90 if (!in_array($toolset, (array) $toolsets, true)) {
91 continue;
92 }
93
94 foreach ($classes as $class) {
95 if (class_exists($class) && method_exists($class, 'definitions')) {
96 $defs = array_merge($defs, (array) $class::definitions());
97 }
98 }
99 }
100
101 return $defs;
102 }
103
104 /**
105 * The ability names that are prompts rather than tools. create_server()
106 * takes them separately; a prompt passed as a tool would show in tools/list.
107 *
108 * @param array|null $toolsets
109 * @return array
110 */
111 public static function getPromptNames($toolsets = null)
112 {
113 $names = [];
114
115 foreach (self::getDefinitions($toolsets) as $name => $definition) {
116 if (!empty($definition['is_prompt'])) {
117 $names[] = $name;
118 }
119 }
120
121 return $names;
122 }
123
124 /**
125 * The ability names that are tools.
126 *
127 * @param array|null $toolsets
128 * @return array
129 */
130 public static function getToolNames($toolsets = null)
131 {
132 $names = [];
133
134 foreach (self::getDefinitions($toolsets) as $name => $definition) {
135 if (empty($definition['is_prompt'])) {
136 $names[] = $name;
137 }
138 }
139
140 return $names;
141 }
142
143 /**
144 * Register every enabled definition as a WP ability.
145 */
146 public static function register()
147 {
148 foreach (self::getDefinitions() as $name => $definition) {
149 try {
150 // Core catches its own validation errors and returns null, so
151 // the return value is the only sign a definition was rejected.
152 $registered = self::registerAbility($name, $definition);
153
154 if (!$registered) {
155 self::reportRegistrationFailure($name, 'wp_register_ability() rejected the definition; see the _doing_it_wrong notice for the reason.');
156 }
157 } catch (\Throwable $e) {
158 // For what core doesn't catch, e.g. a TypeError building $args.
159 // This runs on wp_abilities_api_init inside create_server(), so
160 // an uncaught throw would abort every later ability, other
161 // plugins' included, and the whole server.
162 self::reportRegistrationFailure($name, $e);
163 }
164 }
165 }
166
167 /**
168 * Record that one ability did not register, without taking the rest down.
169 *
170 * @param string $name
171 * @param \Throwable|string $reason
172 */
173 private static function reportRegistrationFailure($name, $reason)
174 {
175 if (defined('FLUENT_BOOKING_DEBUG') && FLUENT_BOOKING_DEBUG) {
176 // Ability name and failure site only, no booking data or tokens.
177 $detail = $reason instanceof \Throwable
178 ? get_class($reason) . ': ' . $reason->getMessage() . ' at ' . basename($reason->getFile()) . ':' . $reason->getLine()
179 : (string) $reason;
180
181 error_log('FluentBooking MCP ability registration failed: ' . $name . ' - ' . $detail); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
182 }
183
184 /**
185 * Fires when a single MCP ability fails to register. The rest still
186 * register; this lets a site alert on the missing tool.
187 *
188 * @since 2.3.0
189 *
190 * @param string $name the ability name that failed
191 * @param \Throwable|string $reason the exception, or a description of
192 * why core rejected the definition
193 */
194 do_action('fluent_booking/mcp_ability_registration_failed', $name, $reason);
195 }
196
197 /**
198 * Register one definition with the Abilities API.
199 *
200 * @param string $name
201 * @param array $definition
202 * @return object|null the registered WP_Ability, or null when core refused
203 */
204 private static function registerAbility($name, $definition)
205 {
206 // No-argument tools declare `properties` as stdClass (to encode as {}),
207 // and array_keys() throws on an object in PHP 8.
208 $properties = Arr::get($definition, 'input_schema.properties', []);
209
210 $declaredParams = $properties ? array_keys((array) $properties) : [];
211
212 $args = [
213 'label' => Arr::get($definition, 'label'),
214 'description' => Arr::get($definition, 'description'),
215 'category' => self::CATEGORY,
216 'execute_callback' => self::wrapExecuteCallback($name, Arr::get($definition, 'execute_callback'), $declaredParams),
217 'permission_callback' => Arr::get($definition, 'permission_callback'),
218 'meta' => [
219 'show_in_rest' => true,
220 'mcp' => array_merge(
221 ['public' => true],
222 !empty($definition['is_prompt']) ? ['type' => 'prompt'] : []
223 ),
224 ],
225 ];
226
227 if (!empty($definition['input_schema'])) {
228 $args['input_schema'] = $definition['input_schema'];
229 }
230
231 if (!empty($definition['output_schema'])) {
232 $args['output_schema'] = $definition['output_schema'];
233 }
234
235 if (!empty($definition['annotations'])) {
236 $mapped = self::mapAnnotations($definition['annotations']);
237 if (!empty($mapped)) {
238 $args['meta']['annotations'] = $mapped;
239 }
240 }
241
242 return wp_register_ability($name, $args);
243 }
244
245 /**
246 * The wire size of one definition as a client receives it in tools/list,
247 * measured with the mapped annotations, which carry both vocabularies.
248 *
249 * @param string $name
250 * @param array $definition
251 *
252 * @return int
253 */
254 public static function wireBytes($name, $definition)
255 {
256 return strlen((string) wp_json_encode([
257 'name' => $name,
258 'description' => isset($definition['description']) ? $definition['description'] : '',
259 'inputSchema' => isset($definition['input_schema']) ? $definition['input_schema'] : [],
260 'annotations' => self::mapAnnotations(
261 isset($definition['annotations']) ? $definition['annotations'] : []
262 ),
263 ]));
264 }
265
266 /**
267 * @param int $bytes
268 * @return int
269 */
270 public static function wireTokens($bytes)
271 {
272 return (int) round($bytes / self::BYTES_PER_TOKEN);
273 }
274
275 /**
276 * Emit a tool's behaviour hints in both vocabularies.
277 *
278 * Core reads snake_case (`readonly`, `destructive`, ...) and merges its own
279 * nulls over anything else, so camelCase alone reads as not destructive.
280 * MCP clients read `readOnlyHint`, `destructiveHint`, ... Unknown keys are dropped.
281 *
282 * @param array $annotations
283 * @return array
284 */
285 public static function mapAnnotations($annotations)
286 {
287 $map = [
288 'readonly' => 'readOnlyHint',
289 'destructive' => 'destructiveHint',
290 'idempotent' => 'idempotentHint',
291 'open_world' => 'openWorldHint',
292 ];
293
294 $out = [];
295
296 foreach ((array) $annotations as $key => $value) {
297 if ($key === 'title') {
298 $out['title'] = (string) $value;
299 continue;
300 }
301
302 if (isset($map[$key])) {
303 $out[$key] = (bool) $value; // core's vocabulary
304 $out[$map[$key]] = (bool) $value; // the MCP wire vocabulary
305 }
306 }
307
308 // MCP defaults destructiveHint to true when absent, so say false for
309 // read-only tools or clients would ask to confirm every report.
310 if (!empty($out['readOnlyHint']) && !isset($out['destructiveHint'])) {
311 $out['destructive'] = false;
312 $out['destructiveHint'] = false;
313 }
314
315 return $out;
316 }
317
318 /**
319 * Wrap a tool callback to reject undeclared parameters and turn an
320 * unhandled exception into a structured error.
321 *
322 * The schema sets no additionalProperties, so an unknown key would be
323 * dropped and the agent would get an unfiltered result that looks right.
324 * The error lists the accepted parameters so the agent can correct itself.
325 *
326 * @param string $toolName
327 * @param callable $callback
328 * @param array $declaredParams
329 * @return \Closure
330 */
331 private static function wrapExecuteCallback($toolName, $callback, $declaredParams)
332 {
333 return function ($params = []) use ($toolName, $callback, $declaredParams) {
334 if (!is_array($params)) {
335 $params = [];
336 }
337
338 $unknown = array_diff(array_keys($params), $declaredParams);
339
340 if ($unknown) {
341 return MCPHelper::error(
342 'unknown_parameter',
343 sprintf(
344 /* translators: 1: tool name, 2: rejected parameter names, 3: accepted parameter names */
345 __('%1$s does not accept: %2$s. Accepted parameters: %3$s.', 'fluent-booking'),
346 $toolName,
347 implode(', ', $unknown),
348 $declaredParams ? implode(', ', $declaredParams) : __('none', 'fluent-booking')
349 ),
350 ['accepted_parameters' => $declaredParams]
351 );
352 }
353
354 try {
355 return call_user_func($callback, $params);
356 } catch (\Throwable $e) {
357 if (defined('FLUENT_BOOKING_DEBUG') && FLUENT_BOOKING_DEBUG) {
358 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
359 }
360
361 // Not $e->getMessage(): it can carry SQL or file paths.
362 return MCPHelper::error(
363 'tool_failed',
364 sprintf(
365 /* translators: %s: tool name */
366 __('%s could not complete. The site logged the details.', 'fluent-booking'),
367 $toolName
368 )
369 );
370 }
371 };
372 }
373 }
374