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/Tools/ContextTools.php +50 -111 2.4.0 → 2.5.0 View file →
@@ -14,26 +14,15 @@
14 14
15 15 defined('ABSPATH') || exit;
16 16
17 17 /**
18 - * Discovery — the agent's entry point into a FluentBooking site.
18 + * Discovery: `get-booking-context` is the "call this first" tool. One call,
19 + * no parameters, tells the agent who it is, what it may do, the site's time
20 + * conventions, headline counts and every valid enum, so other schemas don't
21 + * have to restate them.
19 22 *
20 - * `get-booking-context` is the documented "call this first" tool. One call tells
21 - * the agent who it is, what it may do, the site's time conventions, headline
22 - * counts, and every valid enum — so it never has to guess a status string or a
23 - * timezone. It takes no parameters at all: discovery should have zero friction,
24 - * and a no-argument schema is also the cheapest schema there is.
25 - *
26 - * It earns its ~350 tokens of resident context several times over. Without it,
27 - * every other tool's schema would have to restate the status and event-type
28 - * enums inline, and the agent would still guess wrong about week start and
29 - * timezone.
30 - *
31 - * Small reference lists (hosts, calendars, event types) are inlined only while
32 - * they stay small. Past the threshold the payload reports counts and points at
33 - * `list-reference-data` instead — a context tool that grows with the size of
34 - * the site is a context tool that eventually breaks the session it was meant to
35 - * bootstrap.
23 + * Reference lists are inlined only while small; past the limit they become a
24 + * count plus a pointer, so the payload doesn't grow with the site.
36 25 */
37 26 class ContextTools
38 27 {
39 28 const CACHE_TTL = 60;
@@ -42,48 +31,30 @@
42 31
43 32 /**
44 33 * Above this many rows a reference list is summarised rather than inlined.
45 34 *
46 - * Derived from the response budget in docs/mcp-server-spec.md §10 (≤1,200
47 - * tokens for this tool), not picked by feel. Measured against a real site:
48 - * the payload without reference lists is ~1,770 bytes, a calendar row ~55
49 - * and an event-type row ~115. Twelve of each is 12 × 55 + 12 × 115 = 2,040
50 - * bytes, for a worst case of ~3,800 bytes ≈ 1,090 tokens. Raising this
51 - * without re-doing that arithmetic breaks the budget the whole design rests
52 - * on — this tool is called at the start of every session.
35 + * Sized to the ≤1,200 token budget in docs/mcp-server-spec.md §10: base
36 + * payload ~1,770 bytes, plus 12 calendar rows (~55 bytes) and 12 event-type
37 + * rows (~115 bytes) is ~3,800 bytes ≈ 1,090 tokens. Redo the sum before raising it.
53 38 */
54 39 const INLINE_LIST_LIMIT = 12;
55 40
56 41 /**
57 - * Booking statuses the `status` column genuinely holds that
58 - * Booking::getBookingStatus()'s label map does NOT list.
42 + * Statuses the `status` column holds that Booking::getBookingStatus()'s
43 + * label map doesn't list. A value missing from the input_schema enum makes
44 + * those bookings unreachable, since the call is rejected outright.
59 45 *
60 - * An enum is wrong in two directions and only one of them is loud. Listing a
61 - * value the column can never hold gives the agent a filter that silently
62 - * returns zero rows. OMITTING a value the column does hold is worse: those
63 - * bookings become unreachable, and because the value is absent from the
64 - * input_schema enum the call is rejected outright, so the agent cannot even
65 - * discover that the rows exist.
46 + * - reserved: written at checkout for payment-pending bookings.
47 + * - no_show: settable via SchedulesController::patchBooking().
48 + * - approved: TimeSlotService::getBookedSlots() treats it as occupying a slot.
66 49 *
67 - * Both of these are written by real code paths:
68 - * - reserved: written during checkout for payment-pending bookings and
69 - * queried by SchedulesController::addCountsForFirstPage().
70 - * - no_show: settable through SchedulesController::patchBooking()'s status
71 - * whitelist and counted by the same method.
72 - * - approved: TimeSlotService::getBookedSlots() treats it as occupying a
73 - * slot, so rows holding it are real enough to remove
74 - * availability — and were previously unreachable by any filter.
75 - *
76 - * Keep this list in step with the writers, not with the label map.
50 + * Keep this in step with the writers, not with the label map.
77 51 */
78 52 const PERSISTED_ONLY_STATUSES = ['reserved', 'no_show', 'approved'];
79 53
80 54 /**
81 - * Statuses Booking::getBookingStatus() has a label for. That method keeps
82 - * its map private, so the list is mirrored here rather than read out of it —
83 - * and the mirror is deliberate: adding a status there without adding it here
84 - * only costs the agent a filter, whereas reflecting into a private array
85 - * would break silently on any refactor.
55 + * Statuses Booking::getBookingStatus() has a label for. Its map is private,
56 + * so it's mirrored here rather than read by reflection.
86 57 */
87 58 const LABELLED_STATUSES = ['scheduled', 'rescheduled', 'completed', 'pending', 'cancelled', 'rejected'];
88 59
89 60 /**
@@ -96,17 +67,12 @@
96 67 return array_values(array_unique(array_merge(self::LABELLED_STATUSES, self::PERSISTED_ONLY_STATUSES)));
97 68 }
98 69
99 70 /**
100 - * The computed period buckets list-bookings accepts.
71 + * The computed period buckets list-bookings accepts: the helper's list
72 + * (so filter-added buckets show up), plus the two that
73 + * Booking::scopeApplyComputedStatus() honours but the admin dropdown omits.
101 74 *
102 - * Read from the canonical helper so a bucket added by a filter shows up
103 - * automatically, then unioned with the two that
104 - * Booking::scopeApplyComputedStatus() honours but the admin's filter
105 - * dropdown never renders. A period the scope supports but the enum omits is
106 - * a filter the agent cannot reach; one the enum lists but the scope ignores
107 - * silently returns the unfiltered set. Both directions matter.
108 - *
109 75 * @return array
110 76 */
111 77 public static function bookingPeriods()
112 78 {
@@ -122,15 +88,14 @@
122 88 * @return array
123 89 */
124 90 public static function eventTypes()
125 91 {
126 - return ['single', 'group', 'round_robin', 'collective', 'single_event', 'group_event'];
92 + return CalendarSlot::getEventTypes();
127 93 }
128 94
129 95 /**
130 - * Every enum the agent is told to trust. Shared with the tool schemas, so a
131 - * value the agent is offered in an input_schema and a value this payload
132 - * advertises can never disagree.
96 + * Every enum the agent is told to trust. Shared with the tool schemas so
97 + * the two can't disagree.
133 98 *
134 99 * @return array
135 100 */
136 101 public static function enums()
@@ -144,10 +109,8 @@
144 109 ];
145 110 }
146 111
147 112 /**
148 - * The tool definitions this class owns.
149 - *
150 113 * @return array
151 114 */
152 115 public static function definitions()
153 116 {
@@ -156,9 +119,9 @@
156 119 'label' => __('Get booking context', 'fluent-booking'),
157 120 'description' => __('Call this first. Returns who you are, what you may do, the site timezone and current time, valid enum values for every filter, headline counts, and small reference lists of hosts, calendars and event types.', 'fluent-booking'),
158 121 'input_schema' => [
159 122 'type' => 'object',
160 - // stdClass, not [] — an empty PHP array serialises as a JSON
123 + // stdClass, not []: an empty array serialises as a JSON
161 124 // array and clients reject `"properties": []`.
162 125 'properties' => new \stdClass(),
163 126 ],
164 127 'annotations' => [
@@ -172,15 +135,12 @@
172 135 ];
173 136 }
174 137
175 138 /**
176 - * Build (or serve from cache) the context payload.
139 + * Build (or serve from cache) the context payload. Cached per user, since
140 + * it holds the caller's permissions and permission-scoped counts.
177 141 *
178 - * Cached per user, never globally: the payload states the caller's
179 - * permission set and permission-scoped counts, so a shared cache entry would
180 - * hand one host another host's view of the site.
181 - *
182 - * @param array $params unused; the tool takes none
142 + * @param array $params unused
183 143 * @return array
184 144 */
185 145 public static function getContext($params = [])
186 146 {
@@ -212,29 +172,24 @@
212 172 return $payload;
213 173 }
214 174
215 175 /**
216 - * Invalidate every user's cached context.
217 - *
218 - * Bumping a shared version counter rather than deleting a key: the cache is
219 - * per-user, and these hooks fire as whoever made the edit, so deleting
220 - * "the" key only ever cleared the editor's own copy and left every other
221 - * operator reading stale reference lists until the TTL expired. The version
222 - * is part of the key, so one write retires all of them at once.
176 + * Invalidate every user's cached context. The cache is per user and the
177 + * hooks fire as the editor, so bump a version in the key rather than
178 + * deleting one user's entry.
223 179 */
224 180 public static function invalidateCache()
225 181 {
226 182 $option = self::CACHE_PREFIX . 'version';
227 183
228 - // Not autoloaded. Only MCP requests read it, and autoloading meant
229 - // every calendar write flushed the site's alloptions cache.
184 + // Not autoloaded: only MCP reads it, and autoloading would flush
185 + // alloptions on every calendar write.
230 186 update_option($option, (int) get_option($option, 0) + 1, false);
231 187 }
232 188
233 189 /**
234 - * Per-user, per-site, per-version cache key. `get_current_blog_id()` is
235 - * included because an object-cache backend fronting transients is not
236 - * guaranteed to isolate keys per site on multisite.
190 + * Per-user, per-site, per-version cache key. The blog id is included
191 + * because an object cache isn't guaranteed to isolate keys per site.
237 192 *
238 193 * @return string
239 194 */
240 195 private static function cacheKey()
@@ -244,11 +199,10 @@
244 199 return self::CACHE_PREFIX . get_current_blog_id() . '_' . get_current_user_id() . '_' . $version;
245 200 }
246 201
247 202 /**
248 - * Who the agent is acting as, and what that account may do. The permission
249 - * keys are echoed verbatim so an agent that hits a permission_denied can
250 - * tell the user exactly which grant is missing.
203 + * Who the agent is acting as. Permission keys are echoed verbatim so a
204 + * permission_denied can be traced to the missing grant.
251 205 *
252 206 * @param string $timezone
253 207 * @return array
254 208 */
@@ -269,11 +223,10 @@
269 223 ];
270 224 }
271 225
272 226 /**
273 - * Site conventions the agent would otherwise guess wrong: the zone, the
274 - * current instant in both UTC and local form, which day the week starts on,
275 - * and the clock format the operator reads.
227 + * Site conventions the agent would otherwise guess: timezone, current time,
228 + * week start and clock format.
276 229 *
277 230 * @param string $timezone
278 231 * @param array $settings
279 232 * @return array
@@ -297,11 +250,9 @@
297 250 );
298 251 }
299 252
300 253 /**
301 - * Headline counts, scoped exactly the way the list tools scope their
302 - * queries. A count built on a wider query than the list it describes is a
303 - * disclosure bug, so both go through the same host filter.
254 + * Headline counts, scoped exactly like the list tools' queries.
304 255 *
305 256 * @return array
306 257 */
307 258 private static function counts()
@@ -311,14 +262,10 @@
311 262
312 263 $bookingQuery = Booking::query();
313 264
314 265 if (!$seesAll) {
315 - // whereHostAccess(), matching BookingTools::buildQuery() and
316 - // BookingReportService::scoped(). A bare host_user_id filter is
317 - // NARROWER: it misses every booking the caller is a secondary host
318 - // on, which is most of a round-robin or collective host's work. The
319 - // context payload would report three upcoming bookings and
320 - // list-bookings would then return eleven.
266 + // Matches BookingTools::buildQuery(). A bare host_user_id filter
267 + // would miss bookings where the caller is a secondary host.
321 268 $bookingQuery->whereHostAccess($userId);
322 269 }
323 270
324 271 $upcoming = (clone $bookingQuery)
@@ -336,12 +283,8 @@
336 283 ];
337 284 }
338 285
339 286 /**
340 - * Reference lists, inlined only while they are small enough to be free. Past
341 - * INLINE_LIST_LIMIT the entry becomes a count plus a pointer, so the context
342 - * payload stays flat as a site grows.
343 - *
344 287 * @return array
345 288 */
346 289 private static function referenceLists()
347 290 {
@@ -347,10 +290,10 @@
347 290 {
348 291 return [
349 292 'calendars' => self::inlineList(
350 293 self::visibleCalendarQuery(),
351 - // list-reference-data is in the `scheduling` toolset: on a
352 - // core-only site nothing lists calendars, so point at nothing.
294 + // list-reference-data is in the `scheduling` toolset; on a
295 + // core-only site there's nothing to point at.
353 296 PermissionGate::isToolsetEnabled(PermissionGate::TOOLSET_SCHEDULING)
354 297 ? 'fluent-booking/list-reference-data'
355 298 : '',
356 299 function ($calendar) {
@@ -363,10 +306,9 @@
363 306 }
364 307 ),
365 308 'event_types' => self::inlineList(
366 309 self::visibleEventTypeQuery(),
367 - // NOT list-reference-data: it has no `event_types` kind, so
368 - // that pointer fails validation. get-event-types is in `core`.
310 + // Not list-reference-data: it has no `event_types` kind.
369 311 'fluent-booking/get-event-types',
370 312 function ($slot) {
371 313 return [
372 314 'id' => (int) $slot->id,
@@ -381,9 +323,9 @@
381 323 ];
382 324 }
383 325
384 326 /**
385 - * Inline a list, or summarise it when it is too long to be free.
327 + * Inline a list, or summarise it past INLINE_LIST_LIMIT.
386 328 *
387 329 * @param object $query a model query, already permission-scoped
388 330 * @param string $getWith the tool that returns this list in full; '' when
389 331 * no enabled toolset exposes one
@@ -422,11 +364,10 @@
422 364 ];
423 365 }
424 366
425 367 /**
426 - * Calendars this caller may read, through the module's one visibility
427 - * helper — so the context payload, `get-event-types` and
428 - * `list-reference-data` cannot disagree about what exists.
368 + * Calendars this caller may read, via the shared visibility helper so
369 + * this and the list tools agree.
429 370 *
430 371 * @return object
431 372 */
432 373 private static function visibleCalendarQuery()
@@ -444,12 +385,10 @@
444 385 return PermissionGate::scopeToReadableCalendars(CalendarSlot::query(), 'calendar_id');
445 386 }
446 387
447 388 /**
448 - * FluentBooking's nouns against the ones an agent is most likely to arrive
449 - * with. An agent that has read Cal.com's docs will ask for an "event type"
450 - * and a "schedule"; telling it the mapping once here is cheaper than every
451 - * tool description explaining itself.
389 + * FluentBooking's nouns mapped to the ones an agent likely arrives with
390 + * (e.g. Cal.com's "event type"), stated once instead of in every tool.
452 391 *
453 392 * @return array
454 393 */
455 394 private static function terminology()