| 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 the MCP integration. |
| 12 |
* |
| 13 |
* Wires the Abilities API (WP 6.9+) to the MCP Adapter, supplied by |
| 14 |
* FluentToolkit or the standalone mcp-adapter plugin. Shows an admin notice |
| 15 |
* when neither is loaded. |
| 16 |
* |
| 17 |
* Off by default (PermissionGate::isEnabled()). When on, the endpoint needs WP |
| 18 |
* auth, the transport permission gate and per-ability permission checks. |
| 19 |
*/ |
| 20 |
class MCPInit |
| 21 |
{ |
| 22 |
const SERVER_ID = 'fluent-booking'; |
| 23 |
|
| 24 |
/** |
| 25 |
* Toolkit discovery and the settings entry always register, since they are |
| 26 |
* where the operator finds the switch. The server starts only when enabled. |
| 27 |
*/ |
| 28 |
public static function boot() |
| 29 |
{ |
| 30 |
self::registerWithToolkit(); |
| 31 |
self::registerSettingsMenu(); |
| 32 |
|
| 33 |
if (PermissionGate::isEnabled()) { |
| 34 |
(new self())->init(); |
| 35 |
} |
| 36 |
} |
| 37 |
|
| 38 |
public function init() |
| 39 |
{ |
| 40 |
// Fire only on WP 6.9+ or with the Abilities API plugin. |
| 41 |
add_action('wp_abilities_api_categories_init', [$this, 'registerCategory']); |
| 42 |
add_action('wp_abilities_api_init', [$this, 'registerAbilities']); |
| 43 |
|
| 44 |
add_action('admin_init', [$this, 'registerPrivacyPolicyContent']); |
| 45 |
|
| 46 |
// Fires only when an adapter is loaded. |
| 47 |
add_action('mcp_adapter_init', [$this, 'registerCustomServer']); |
| 48 |
|
| 49 |
// Drop get-booking-context's cache when anything it reports changes. |
| 50 |
// Static callback so a site can remove_action it. |
| 51 |
$invalidate = [ContextTools::class, 'invalidateCache']; |
| 52 |
|
| 53 |
foreach ([ |
| 54 |
'fluent_booking/after_create_calendar', |
| 55 |
'fluent_booking/after_update_calendar', |
| 56 |
'fluent_booking/after_delete_calendar', |
| 57 |
'fluent_booking/after_create_calendar_slot', |
| 58 |
'fluent_booking/after_create_event', |
| 59 |
'fluent_booking/after_update_event_details', |
| 60 |
'fluent_booking/after_delete_calendar_event', |
| 61 |
] as $hook) { |
| 62 |
add_action($hook, $invalidate); |
| 63 |
} |
| 64 |
|
| 65 |
add_action('admin_notices', [$this, 'maybeShowAdapterNotice']); |
| 66 |
} |
| 67 |
|
| 68 |
public function registerCategory() |
| 69 |
{ |
| 70 |
wp_register_ability_category(AbilitiesRegistrar::CATEGORY, [ |
| 71 |
'label' => __('FluentBooking', 'fluent-booking'), |
| 72 |
'description' => __('Scheduling abilities for FluentBooking — bookings, availability, event types and reports.', 'fluent-booking'), |
| 73 |
]); |
| 74 |
} |
| 75 |
|
| 76 |
public function registerAbilities() |
| 77 |
{ |
| 78 |
AbilitiesRegistrar::register(); |
| 79 |
|
| 80 |
/** |
| 81 |
* Fires after FluentBooking registers its core MCP abilities. Pro |
| 82 |
* registers its own here, under the same namespace and server. |
| 83 |
* |
| 84 |
* @since 2.3.0 |
| 85 |
*/ |
| 86 |
do_action('fluent_booking/mcp_loaded'); |
| 87 |
} |
| 88 |
|
| 89 |
/** |
| 90 |
* Register the FluentBooking MCP server at /wp-json/fluent-booking/mcp, |
| 91 |
* outside fluent-booking/v2 so the admin policy stack doesn't apply. |
| 92 |
* |
| 93 |
* @param object $adapter the \WP\MCP\Core\McpAdapter instance |
| 94 |
*/ |
| 95 |
public function registerCustomServer($adapter) |
| 96 |
{ |
| 97 |
if (!$adapter || !is_object($adapter) || !method_exists($adapter, 'create_server')) { |
| 98 |
return; |
| 99 |
} |
| 100 |
|
| 101 |
$abilityNames = AbilitiesRegistrar::getToolNames(); |
| 102 |
|
| 103 |
/** |
| 104 |
* Filters the ability names exposed by the FluentBooking MCP server. |
| 105 |
* Pro and extensions add theirs here to share the server. |
| 106 |
* |
| 107 |
* @since 2.3.0 |
| 108 |
* |
| 109 |
* @param array $abilityNames fully-qualified ability names |
| 110 |
*/ |
| 111 |
$abilityNames = apply_filters('fluent_booking/mcp_ability_names', $abilityNames); |
| 112 |
$abilityNames = array_values(array_unique(array_filter((array) $abilityNames))); |
| 113 |
|
| 114 |
// Prompts go in their own argument, not in tools/list. |
| 115 |
$promptNames = AbilitiesRegistrar::getPromptNames(); |
| 116 |
|
| 117 |
/** |
| 118 |
* Filters the prompt ability names exposed by the server. |
| 119 |
* |
| 120 |
* @since 2.3.0 |
| 121 |
* |
| 122 |
* @param array $promptNames fully-qualified ability names |
| 123 |
*/ |
| 124 |
$promptNames = apply_filters('fluent_booking/mcp_prompt_names', $promptNames); |
| 125 |
$promptNames = array_values(array_unique(array_filter((array) $promptNames))); |
| 126 |
|
| 127 |
$namespace = self::serverNamespace(); |
| 128 |
$route = self::serverRoute(); |
| 129 |
|
| 130 |
$adapter->create_server( |
| 131 |
self::SERVER_ID, |
| 132 |
$namespace, |
| 133 |
$route, |
| 134 |
__('FluentBooking MCP Server', 'fluent-booking'), |
| 135 |
__('AI agent tools for FluentBooking bookings, availability, event types and reports.', 'fluent-booking'), |
| 136 |
defined('FLUENT_BOOKING_VERSION') ? FLUENT_BOOKING_VERSION : '1.0.0', |
| 137 |
['\WP\MCP\Transport\HttpTransport'], |
| 138 |
'\WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler', |
| 139 |
'\WP\MCP\Infrastructure\Observability\NullMcpObservabilityHandler', |
| 140 |
$abilityNames, |
| 141 |
[], |
| 142 |
$promptNames, |
| 143 |
[PermissionGate::class, 'transport'] |
| 144 |
); |
| 145 |
} |
| 146 |
|
| 147 |
/** |
| 148 |
* List FluentBooking on FluentToolkit's MCP page, even while MCP is off, |
| 149 |
* so the operator can switch it on there. |
| 150 |
*/ |
| 151 |
public static function registerWithToolkit() |
| 152 |
{ |
| 153 |
// Static callbacks, not closures, so a site can remove_filter them. |
| 154 |
add_filter('fluent_kit/mcp_products', [self::class, 'addToolkitProduct']); |
| 155 |
add_filter('fluent_kit/mcp_toggle_handlers', [self::class, 'addToolkitToggleHandler']); |
| 156 |
} |
| 157 |
|
| 158 |
/** |
| 159 |
* @param array $products |
| 160 |
* @return array |
| 161 |
*/ |
| 162 |
public static function addToolkitProduct($products) |
| 163 |
{ |
| 164 |
if (!is_array($products)) { |
| 165 |
$products = []; |
| 166 |
} |
| 167 |
|
| 168 |
$products[] = [ |
| 169 |
'slug' => self::SERVER_ID, |
| 170 |
'name' => __('FluentBooking', 'fluent-booking'), |
| 171 |
'mcp_enabled' => PermissionGate::isEnabled(), |
| 172 |
'tools_count' => self::toolsCount(), |
| 173 |
'endpoint_url' => self::getEndpointUrl(), |
| 174 |
'status' => self::toolkitStatus(), |
| 175 |
]; |
| 176 |
|
| 177 |
return $products; |
| 178 |
} |
| 179 |
|
| 180 |
/** |
| 181 |
* @param array $handlers |
| 182 |
* @return array |
| 183 |
*/ |
| 184 |
public static function addToolkitToggleHandler($handlers) |
| 185 |
{ |
| 186 |
if (!is_array($handlers)) { |
| 187 |
$handlers = []; |
| 188 |
} |
| 189 |
|
| 190 |
$handlers[self::SERVER_ID] = [ |
| 191 |
'get_enabled' => [PermissionGate::class, 'isEnabled'], |
| 192 |
'set_enabled' => [PermissionGate::class, 'setEnabled'], |
| 193 |
]; |
| 194 |
|
| 195 |
return $handlers; |
| 196 |
} |
| 197 |
|
| 198 |
/** |
| 199 |
* Add the MCP entry to Settings. Registered while off too, since it holds |
| 200 |
* the switch. Priority 30 puts it after the existing entries. |
| 201 |
*/ |
| 202 |
public static function registerSettingsMenu() |
| 203 |
{ |
| 204 |
add_filter('fluent_booking/settings_menu_items', [self::class, 'addSettingsMenuItem'], 30); |
| 205 |
} |
| 206 |
|
| 207 |
/** |
| 208 |
* @param array $items |
| 209 |
* @return array |
| 210 |
*/ |
| 211 |
public static function addSettingsMenuItem($items) |
| 212 |
{ |
| 213 |
if (!is_array($items)) { |
| 214 |
$items = []; |
| 215 |
} |
| 216 |
|
| 217 |
$items['mcp'] = [ |
| 218 |
'title' => __('MCP for AI Agents', 'fluent-booking'), |
| 219 |
'disable' => false, |
| 220 |
'el_icon' => 'MagicStick', |
| 221 |
'component_type' => 'StandAloneComponent', |
| 222 |
'class' => 'mcp_settings', |
| 223 |
'route' => [ |
| 224 |
'name' => 'mcpSettings', |
| 225 |
], |
| 226 |
]; |
| 227 |
|
| 228 |
return $items; |
| 229 |
} |
| 230 |
|
| 231 |
/** |
| 232 |
* How many tools the server exposes for the enabled toolsets, Pro's |
| 233 |
* included. Prompts don't count; they aren't in the tool list. |
| 234 |
* |
| 235 |
* @return int |
| 236 |
*/ |
| 237 |
public static function toolsCount() |
| 238 |
{ |
| 239 |
$names = AbilitiesRegistrar::getToolNames(); |
| 240 |
|
| 241 |
$names = apply_filters('fluent_booking/mcp_ability_names', $names); |
| 242 |
|
| 243 |
return is_array($names) ? count(array_unique($names)) : 0; |
| 244 |
} |
| 245 |
|
| 246 |
/** |
| 247 |
* Status key the Toolkit renders on the FluentBooking card. |
| 248 |
* |
| 249 |
* @return string |
| 250 |
*/ |
| 251 |
public static function toolkitStatus() |
| 252 |
{ |
| 253 |
if (!self::adapterAvailable()) { |
| 254 |
return 'adapter_required'; |
| 255 |
} |
| 256 |
|
| 257 |
return PermissionGate::isEnabled() ? 'ready' : 'disabled'; |
| 258 |
} |
| 259 |
|
| 260 |
/** |
| 261 |
* Stable endpoint URL for the settings UI and connection-snippet generator. |
| 262 |
* |
| 263 |
* @return string |
| 264 |
*/ |
| 265 |
public static function getEndpointUrl() |
| 266 |
{ |
| 267 |
return get_rest_url(null, trailingslashit(self::serverNamespace()) . self::serverRoute()); |
| 268 |
} |
| 269 |
|
| 270 |
/** |
| 271 |
* True when both an MCP adapter and the Abilities API are available. |
| 272 |
* |
| 273 |
* @return bool |
| 274 |
*/ |
| 275 |
public static function adapterAvailable() |
| 276 |
{ |
| 277 |
return defined('WP_MCP_VERSION') |
| 278 |
&& class_exists('\WP\MCP\Core\McpAdapter') |
| 279 |
&& function_exists('wp_register_ability'); |
| 280 |
} |
| 281 |
|
| 282 |
/** |
| 283 |
* Suggest privacy-policy wording while MCP is on. The connected client's |
| 284 |
* model provider receives attendee data, which the site owner must disclose. |
| 285 |
*/ |
| 286 |
public function registerPrivacyPolicyContent() |
| 287 |
{ |
| 288 |
if (!function_exists('wp_add_privacy_policy_content')) { |
| 289 |
return; |
| 290 |
} |
| 291 |
|
| 292 |
$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>'; |
| 293 |
|
| 294 |
$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>'; |
| 295 |
|
| 296 |
wp_add_privacy_policy_content(__('FluentBooking — MCP for AI Agents', 'fluent-booking'), wp_kses_post($content)); |
| 297 |
} |
| 298 |
|
| 299 |
public function maybeShowAdapterNotice() |
| 300 |
{ |
| 301 |
if (self::adapterAvailable() || !current_user_can('manage_options')) { |
| 302 |
return; |
| 303 |
} |
| 304 |
|
| 305 |
echo '<div class="notice notice-warning"><p>'; |
| 306 |
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'); |
| 307 |
echo '</p></div>'; |
| 308 |
} |
| 309 |
|
| 310 |
/** |
| 311 |
* @return string |
| 312 |
*/ |
| 313 |
private static function serverNamespace() |
| 314 |
{ |
| 315 |
return 'fluent-booking'; |
| 316 |
} |
| 317 |
|
| 318 |
/** |
| 319 |
* @return string |
| 320 |
*/ |
| 321 |
private static function serverRoute() |
| 322 |
{ |
| 323 |
return 'mcp'; |
| 324 |
} |
| 325 |
} |
| 326 |
|