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
← All changes | app/Modules/MCP/AbilitiesRegistrar.php +45 -106 2.4.0 → 2.5.0 View file →
@@ -16,19 +16,14 @@
16 16
17 17 defined('ABSPATH') || exit;
18 18
19 19 /**
20 - * Single source of truth for every FluentBooking MCP ability.
20 + * Registers every FluentBooking MCP ability.
21 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.
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`.
31 26 */
32 27 class AbilitiesRegistrar
33 28 {
34 29 const CATEGORY = 'fluent-booking';
@@ -33,18 +28,15 @@
33 28 {
34 29 const CATEGORY = 'fluent-booking';
35 30
36 31 /**
37 - * Measured against this plugin's own definitions. It is a label on a
38 - * toggle, not an invoice.
32 + * Measured against this plugin's definitions. A rough label, not exact.
39 33 */
40 34 const BYTES_PER_TOKEN = 3.5;
41 35
42 36 /**
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.
37 + * Tool classes per toolset. A class registers only when its toolset is on,
38 + * so unused schemas don't cost context.
47 39 *
48 40 * @return array toolset slug => tool class names
49 41 */
50 42 private static function toolClasses()
@@ -68,18 +60,12 @@
68 60
69 61 /**
70 62 * The tool classes each toolset exposes, keyed by toolset.
71 63 *
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.
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).
77 67 *
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 68 * @since 2.2.6
83 69 *
84 70 * @param array $classes toolset key => array of class names.
85 71 */
@@ -115,15 +101,11 @@
115 101 return $defs;
116 102 }
117 103
118 104 /**
119 - * The ability names that are prompts rather than tools.
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.
120 107 *
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 108 * @param array|null $toolsets
127 109 * @return array
128 110 */
129 111 public static function getPromptNames($toolsets = null)
@@ -164,14 +146,10 @@
164 146 public static function register()
165 147 {
166 148 foreach (self::getDefinitions() as $name => $definition) {
167 149 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.
150 + // Core catches its own validation errors and returns null, so
151 + // the return value is the only sign a definition was rejected.
174 152 $registered = self::registerAbility($name, $definition);
175 153
176 154 if (!$registered) {
177 155 self::reportRegistrationFailure($name, 'wp_register_ability() rejected the definition; see the _doing_it_wrong notice for the reason.');
@@ -176,17 +154,12 @@
176 154 if (!$registered) {
177 155 self::reportRegistrationFailure($name, 'wp_register_ability() rejected the definition; see the _doing_it_wrong notice for the reason.');
178 156 }
179 157 } 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.
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.
189 162 self::reportRegistrationFailure($name, $e);
190 163 }
191 164 }
192 165 }
@@ -199,10 +172,9 @@
199 172 */
200 173 private static function reportRegistrationFailure($name, $reason)
201 174 {
202 175 if (defined('FLUENT_BOOKING_DEBUG') && FLUENT_BOOKING_DEBUG) {
203 - // No booking data or tokens here — just the ability name and the
204 - // failure site.
176 + // Ability name and failure site only, no booking data or tokens.
205 177 $detail = $reason instanceof \Throwable
206 178 ? get_class($reason) . ': ' . $reason->getMessage() . ' at ' . basename($reason->getFile()) . ':' . $reason->getLine()
207 179 : (string) $reason;
208 180
@@ -209,11 +181,10 @@
209 181 error_log('FluentBooking MCP ability registration failed: ' . $name . ' - ' . $detail); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
210 182 }
211 183
212 184 /**
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.
185 + * Fires when a single MCP ability fails to register. The rest still
186 + * register; this lets a site alert on the missing tool.
216 187 *
217 188 * @since 2.3.0
218 189 *
219 190 * @param string $name the ability name that failed
@@ -223,10 +194,9 @@
223 194 do_action('fluent_booking/mcp_ability_registration_failed', $name, $reason);
224 195 }
225 196
226 197 /**
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.
198 + * Register one definition with the Abilities API.
229 199 *
230 200 * @param string $name
231 201 * @param array $definition
232 202 * @return object|null the registered WP_Ability, or null when core refused
@@ -232,11 +202,10 @@
232 202 * @return object|null the registered WP_Ability, or null when core refused
233 203 */
234 204 private static function registerAbility($name, $definition)
235 205 {
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.
206 + // No-argument tools declare `properties` as stdClass (to encode as {}),
207 + // and array_keys() throws on an object in PHP 8.
239 208 $properties = Arr::get($definition, 'input_schema.properties', []);
240 209
241 210 $declaredParams = $properties ? array_keys((array) $properties) : [];
242 211
@@ -273,41 +242,11 @@
273 242 return wp_register_ability($name, $args);
274 243 }
275 244
276 245 /**
277 - * Emit a tool's behaviour hints under BOTH vocabularies.
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.
278 248 *
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 249 * @param string $name
311 250 * @param array $definition
312 251 *
313 252 * @return int
@@ -332,8 +271,18 @@
332 271 {
333 272 return (int) round($bytes / self::BYTES_PER_TOKEN);
334 273 }
335 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 + */
336 285 public static function mapAnnotations($annotations)
337 286 {
338 287 $map = [
339 288 'readonly' => 'readOnlyHint',
@@ -355,12 +304,10 @@
355 304 $out[$map[$key]] = (bool) $value; // the MCP wire vocabulary
356 305 }
357 306 }
358 307
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.
308 + // MCP defaults destructiveHint to true when absent, so say false for
309 + // read-only tools or clients would ask to confirm every report.
363 310 if (!empty($out['readOnlyHint']) && !isset($out['destructiveHint'])) {
364 311 $out['destructive'] = false;
365 312 $out['destructiveHint'] = false;
366 313 }
@@ -368,21 +315,14 @@
368 315 return $out;
369 316 }
370 317
371 318 /**
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.
319 + * Wrap a tool callback to reject undeclared parameters and turn an
320 + * unhandled exception into a structured error.
374 321 *
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.
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.
385 325 *
386 326 * @param string $toolName
387 327 * @param callable $callback
388 328 * @param array $declaredParams
@@ -417,10 +357,9 @@
417 357 if (defined('FLUENT_BOOKING_DEBUG') && FLUENT_BOOKING_DEBUG) {
418 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
419 359 }
420 360
421 - // Deliberately not surfacing $e->getMessage(): it can carry SQL
422 - // fragments or file paths, and the agent cannot act on either.
361 + // Not $e->getMessage(): it can carry SQL or file paths.
423 362 return MCPHelper::error(
424 363 'tool_failed',
425 364 sprintf(
426 365 /* translators: %s: tool name */