| 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 |
|