PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / trunk
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution vtrunk
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 1.7.2 All 33 releases
fluent-booking / app / Modules / MCP / MCPInit.php

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

369 lines 13.0 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\PermissionGate;
6 use FluentBooking\App\Modules\MCP\Tools\ContextTools;
7
8 defined('ABSPATH') || exit;
9
10 /**
11 * Bootstrap for FluentBooking's Model Context Protocol integration.
12 *
13 * Wires the WordPress Abilities API (core 6.9+) to the WP MCP Adapter, which is
14 * provided by FluentToolkit (which bundles it) or the standalone mcp-adapter
15 * plugin — whichever is present. FluentBooking bundles nothing; it consumes
16 * whatever is loaded, and surfaces an admin notice rather than failing silently
17 * when neither is.
18 *
19 * The whole surface is gated behind PermissionGate::isEnabled() (default off).
20 * An operator turns it on in Settings and connects with an application password.
21 * Even when on, the endpoint sits behind WP authentication, a FluentBooking
22 * permission (transport gate), and per-ability permission checks.
23 *
24 * Called once from app/Hooks/actions.php.
25 */
26 class MCPInit
27 {
28 const SERVER_ID = 'fluent-booking';
29
30 /**
31 * Bootstrap entry point.
32 *
33 * Toolkit discovery and the settings card register UNCONDITIONALLY: the
34 * Toolkit needs to list FluentBooking on its MCP page while the feature is
35 * still off, or the operator has no way to find the switch. Both are
36 * add_filter calls that no-op unless something applies them, so the cost
37 * when nothing does is nil.
38 *
39 * The server itself is instantiated only when enabled, so a site that never
40 * turns MCP on pays nothing beyond one autoloaded option read.
41 */
42 public static function boot()
43 {
44 self::registerWithToolkit();
45 self::registerSettingsMenu();
46
47 if (PermissionGate::isEnabled()) {
48 (new self())->init();
49 }
50 }
51
52 public function init()
53 {
54 // Abilities API hooks — fire only on WP 6.9+ (or with the Abilities API
55 // feature plugin active).
56 add_action('wp_abilities_api_categories_init', [$this, 'registerCategory']);
57 add_action('wp_abilities_api_init', [$this, 'registerAbilities']);
58
59 add_action('admin_init', [$this, 'registerPrivacyPolicyContent']);
60
61 // Server registration — fires only when an adapter is loaded.
62 add_action('mcp_adapter_init', [$this, 'registerCustomServer']);
63
64 // Keep get-booking-context honest: drop its cache whenever something it
65 // reports changes, so an operator's edit is visible to the agent on the
66 // next call instead of up to CACHE_TTL later. Static callback so a site
67 // can remove_action it.
68 $invalidate = [ContextTools::class, 'invalidateCache'];
69
70 foreach ([
71 'fluent_booking/after_create_calendar',
72 'fluent_booking/after_update_calendar',
73 'fluent_booking/after_delete_calendar',
74 'fluent_booking/after_create_calendar_slot',
75 'fluent_booking/after_create_event',
76 'fluent_booking/after_update_event_details',
77 'fluent_booking/after_delete_calendar_event',
78 ] as $hook) {
79 add_action($hook, $invalidate);
80 }
81
82 // Warn the operator if they enabled MCP but no adapter is installed.
83 add_action('admin_notices', [$this, 'maybeShowAdapterNotice']);
84 }
85
86 public function registerCategory()
87 {
88 wp_register_ability_category(AbilitiesRegistrar::CATEGORY, [
89 'label' => __('FluentBooking', 'fluent-booking'),
90 'description' => __('Scheduling abilities for FluentBooking — bookings, availability, event types and reports.', 'fluent-booking'),
91 ]);
92 }
93
94 public function registerAbilities()
95 {
96 AbilitiesRegistrar::register();
97
98 /**
99 * Fires after FluentBooking registers its core MCP abilities.
100 * FluentBooking Pro hooks this to register its own abilities (payments)
101 * under the same `fluent-booking/` namespace and on the same server.
102 *
103 * @since 2.3.0
104 */
105 do_action('fluent_booking/mcp_loaded');
106 }
107
108 /**
109 * Register the dedicated FluentBooking MCP server. The endpoint defaults to
110 * /wp-json/fluent-booking/mcp — a sibling of the admin REST namespace
111 * (fluent-booking/v2) but deliberately outside it, so it is not caught by
112 * that policy stack.
113 *
114 * @param object $adapter the \WP\MCP\Core\McpAdapter instance
115 */
116 public function registerCustomServer($adapter)
117 {
118 if (!$adapter || !is_object($adapter) || !method_exists($adapter, 'create_server')) {
119 return;
120 }
121
122 $abilityNames = AbilitiesRegistrar::getToolNames();
123
124 /**
125 * Filters the ability names exposed by the FluentBooking MCP server.
126 * Pro and extensions push their ability names here so they land on the
127 * same server as the free ones.
128 *
129 * @since 2.3.0
130 *
131 * @param array $abilityNames fully-qualified ability names
132 */
133 $abilityNames = apply_filters('fluent_booking/mcp_ability_names', $abilityNames);
134 $abilityNames = array_values(array_unique(array_filter((array) $abilityNames)));
135
136 // Prompts are a separate argument to create_server(). Listed as tools
137 // they would show up in tools/list carrying a body of instructions —
138 // both wrong and, at a few hundred tokens each, expensive.
139 $promptNames = AbilitiesRegistrar::getPromptNames();
140
141 /**
142 * Filters the prompt ability names exposed by the server.
143 *
144 * @since 2.3.0
145 *
146 * @param array $promptNames fully-qualified ability names
147 */
148 $promptNames = apply_filters('fluent_booking/mcp_prompt_names', $promptNames);
149 $promptNames = array_values(array_unique(array_filter((array) $promptNames)));
150
151 $namespace = self::serverNamespace();
152 $route = self::serverRoute();
153
154 $adapter->create_server(
155 self::SERVER_ID,
156 $namespace,
157 $route,
158 __('FluentBooking MCP Server', 'fluent-booking'),
159 __('AI agent tools for FluentBooking bookings, availability, event types and reports.', 'fluent-booking'),
160 defined('FLUENT_BOOKING_VERSION') ? FLUENT_BOOKING_VERSION : '1.0.0',
161 ['\WP\MCP\Transport\HttpTransport'],
162 '\WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler',
163 '\WP\MCP\Infrastructure\Observability\NullMcpObservabilityHandler',
164 $abilityNames,
165 [],
166 $promptNames,
167 [PermissionGate::class, 'transport']
168 );
169 }
170
171 /**
172 * Announce FluentBooking to FluentToolkit's MCP page.
173 *
174 * The Toolkit discovers products through `fluent_kit/mcp_products` and
175 * toggles them through `fluent_kit/mcp_toggle_handlers`. Without these,
176 * FluentBooking's server would be fully functional yet never appear in the
177 * Toolkit's list, which reads to an operator as "not supported".
178 *
179 * Runs even while MCP is off so the card is reachable to switch on.
180 */
181 public static function registerWithToolkit()
182 {
183 // Static callbacks rather than closures so a site can remove_filter
184 // them — a closure registered here would be unreachable forever.
185 add_filter('fluent_kit/mcp_products', [self::class, 'addToolkitProduct']);
186 add_filter('fluent_kit/mcp_toggle_handlers', [self::class, 'addToolkitToggleHandler']);
187 }
188
189 /**
190 * @param array $products
191 * @return array
192 */
193 public static function addToolkitProduct($products)
194 {
195 if (!is_array($products)) {
196 $products = [];
197 }
198
199 $products[] = [
200 'slug' => self::SERVER_ID,
201 'name' => __('FluentBooking', 'fluent-booking'),
202 'mcp_enabled' => PermissionGate::isEnabled(),
203 'tools_count' => self::toolsCount(),
204 'endpoint_url' => self::getEndpointUrl(),
205 'status' => self::toolkitStatus(),
206 ];
207
208 return $products;
209 }
210
211 /**
212 * @param array $handlers
213 * @return array
214 */
215 public static function addToolkitToggleHandler($handlers)
216 {
217 if (!is_array($handlers)) {
218 $handlers = [];
219 }
220
221 $handlers[self::SERVER_ID] = [
222 'get_enabled' => [PermissionGate::class, 'isEnabled'],
223 'set_enabled' => [PermissionGate::class, 'setEnabled'],
224 ];
225
226 return $handlers;
227 }
228
229 /**
230 * Add the MCP entry to FluentBooking → Settings.
231 *
232 * Registered even while the feature is off — it is the only place an
233 * operator can turn it on, so hiding it when disabled would make the switch
234 * unreachable. Priority 30 keeps it after the existing settings entries
235 * rather than in the middle of them.
236 */
237 public static function registerSettingsMenu()
238 {
239 add_filter('fluent_booking/settings_menu_items', [self::class, 'addSettingsMenuItem'], 30);
240 }
241
242 /**
243 * @param array $items
244 * @return array
245 */
246 public static function addSettingsMenuItem($items)
247 {
248 if (!is_array($items)) {
249 $items = [];
250 }
251
252 $items['mcp'] = [
253 'title' => __('MCP for AI Agents', 'fluent-booking'),
254 'disable' => false,
255 'el_icon' => 'MagicStick',
256 'component_type' => 'StandAloneComponent',
257 'class' => 'mcp_settings',
258 'route' => [
259 'name' => 'mcpSettings',
260 ],
261 ];
262
263 return $items;
264 }
265
266 /**
267 * How many abilities the server currently exposes, Pro's included. Reflects
268 * the operator's toolset selection, because that is the number that governs
269 * how much of every request's context window this server occupies.
270 *
271 * @return int
272 */
273 public static function toolsCount()
274 {
275 // Tools only: prompts do not occupy the tool list, which is the number
276 // this count exists to report.
277 $names = AbilitiesRegistrar::getToolNames();
278
279 $names = apply_filters('fluent_booking/mcp_ability_names', $names);
280
281 return is_array($names) ? count(array_unique($names)) : 0;
282 }
283
284 /**
285 * Status key the Toolkit renders on the FluentBooking card.
286 *
287 * @return string
288 */
289 public static function toolkitStatus()
290 {
291 if (!self::adapterAvailable()) {
292 return 'adapter_required';
293 }
294
295 return PermissionGate::isEnabled() ? 'ready' : 'disabled';
296 }
297
298 /**
299 * Stable endpoint URL for the settings UI and connection-snippet generator.
300 *
301 * @return string
302 */
303 public static function getEndpointUrl()
304 {
305 return get_rest_url(null, trailingslashit(self::serverNamespace()) . self::serverRoute());
306 }
307
308 /**
309 * True when both an MCP adapter and the Abilities API are available.
310 *
311 * @return bool
312 */
313 public static function adapterAvailable()
314 {
315 return defined('WP_MCP_VERSION')
316 && class_exists('\WP\MCP\Core\McpAdapter')
317 && function_exists('wp_register_ability');
318 }
319
320 /**
321 * Suggest privacy-policy wording while MCP is on.
322 *
323 * Enabling this makes whichever model provider the connected client uses a
324 * recipient of attendee data the moment a read tool is called — the site
325 * owner is the controller and has to disclose that. WordPress has a place
326 * for exactly this text; not using it left the transfer undisclosed
327 * everywhere except the settings screen.
328 */
329 public function registerPrivacyPolicyContent()
330 {
331 if (!function_exists('wp_add_privacy_policy_content')) {
332 return;
333 }
334
335 $content = '<p>' . __('This site can expose booking data to AI assistants over the Model Context Protocol. While it is enabled, a connected client authenticates as one WordPress user and can read attendee names, email addresses, phone numbers and booking form answers, and can create, reschedule and cancel bookings, within that account\'s permissions.', 'fluent-booking') . '</p>';
336
337 $content .= '<p>' . __('Data a client reads leaves this site. The provider of the AI assistant is therefore a recipient of that data, and you should name them here. Access is granted per WordPress application password and is revoked by deleting it.', 'fluent-booking') . '</p>';
338
339 wp_add_privacy_policy_content(__('FluentBooking — MCP for AI Agents', 'fluent-booking'), wp_kses_post($content));
340 }
341
342 public function maybeShowAdapterNotice()
343 {
344 if (self::adapterAvailable() || !current_user_can('manage_options')) {
345 return;
346 }
347
348 echo '<div class="notice notice-warning"><p>';
349 echo esc_html__('FluentBooking MCP is enabled but no MCP adapter was found. Install Fluent Toolkit (recommended) or the MCP Adapter plugin, on WordPress 6.9 or newer.', 'fluent-booking');
350 echo '</p></div>';
351 }
352
353 /**
354 * @return string
355 */
356 private static function serverNamespace()
357 {
358 return 'fluent-booking';
359 }
360
361 /**
362 * @return string
363 */
364 private static function serverRoute()
365 {
366 return 'mcp';
367 }
368 }
369