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 / Http / Controllers / McpController.php

McpController.php in Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution trunk, at app/Http/Controllers/McpController.php

358 lines 13.1 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\Http\Controllers;
4
5 use FluentBooking\App\Modules\MCP\AbilitiesRegistrar;
6 use FluentBooking\App\Modules\MCP\MCPInit;
7 use FluentBooking\App\Modules\MCP\Support\PermissionGate;
8 use FluentBooking\App\Modules\MCP\Tools\ContextTools;
9 use FluentBooking\Framework\Http\Request\Request;
10 use FluentBooking\Framework\Support\Arr;
11
12 /**
13 * Settings surface for the MCP server (FluentBooking → Settings → MCP for AI
14 * Agents).
15 *
16 * Bound to SettingsPolicy, whose verifyRequest() requires `manage_all_data`.
17 * Writes additionally require `manage_options`: enabling MCP exposes booking
18 * and attendee data to any client holding an application password, which is a
19 * site-administration decision rather than a scheduling one. The read endpoint
20 * reports `can_manage` so the UI can disable the controls instead of letting an
21 * operator submit a change that would be refused.
22 */
23 class McpController extends Controller
24 {
25 /**
26 * "FluentHub" in the UI is the fluent-toolkit plugin, which can bundle the
27 * MCP adapter runtime. The standalone WordPress MCP Adapter plugin is the
28 * other accepted provider.
29 */
30 const TOOLKIT_PLUGIN_FILE = 'fluent-toolkit/fluent-toolkit.php';
31
32 const ADAPTER_PLUGIN_FILE = 'mcp-adapter/mcp-adapter.php';
33
34 const TOOLKIT_DOWNLOAD_URL = 'https://github.com/WPManageNinja/fluent-toolkit';
35
36 public function getSettings(Request $request)
37 {
38 return array_merge([
39 'settings' => [
40 'enabled' => PermissionGate::isEnabled(),
41 'toolsets' => PermissionGate::enabledToolsets(),
42 ],
43 'available_toolsets' => self::withCosts(PermissionGate::availableToolsets()),
44 'tools_count' => MCPInit::toolsCount(),
45 'can_manage' => current_user_can('manage_options'),
46 ], self::statusFields());
47 }
48
49 public function updateSettings(Request $request)
50 {
51 if (!current_user_can('manage_options')) {
52 return $this->sendError([
53 'message' => __('Only site administrators can change MCP settings.', 'fluent-booking'),
54 ], 403);
55 }
56
57 $data = $request->get('settings', []);
58
59 if (!is_array($data)) {
60 $data = [];
61 }
62
63 // Absent keys keep their stored value rather than defaulting to off. A
64 // partial POST — a caller sending only `toolsets`, say — would otherwise
65 // read as "enabled: false" and silently switch the server off.
66 //
67 // Arr::isTrue(), never a (bool) cast: jQuery form-encodes the body, so
68 // an off toggle arrives as the STRING "false" and `(bool) "false"` is
69 // true — which makes the master switch one-way.
70 $enabled = array_key_exists('enabled', $data)
71 ? (bool) Arr::isTrue($data, 'enabled')
72 : PermissionGate::isEnabled();
73
74 $toolsets = isset($data['toolsets']) && is_array($data['toolsets'])
75 ? $data['toolsets']
76 : PermissionGate::enabledToolsets();
77
78 // Toolsets first: enabling the server and its tool selection in one
79 // request should never leave a window where the server is live with a
80 // stale toolset list.
81 PermissionGate::setToolsets($toolsets);
82 PermissionGate::setEnabled($enabled);
83
84 // get-booking-context reports the toolset list and names tools only the
85 // enabled toolsets expose, so its cache has to go with a toolset change.
86 ContextTools::invalidateCache();
87
88 return [
89 'message' => __('MCP settings have been updated', 'fluent-booking'),
90 'settings' => [
91 'enabled' => PermissionGate::isEnabled(),
92 'toolsets' => PermissionGate::enabledToolsets(),
93 ],
94 'tools_count' => MCPInit::toolsCount(),
95 ];
96 }
97
98 /**
99 * Install FluentHub (the fluent-toolkit plugin, which carries the MCP
100 * adapter) so the abilities become reachable without a manual upload.
101 *
102 * FluentBooking bundles no installer of its own. The actual download lives
103 * behind `fluent_toolkit/do_auto_install`, which a paid tier registers; the
104 * `fluent_toolkit/can_auto_install` filter reports whether that handler is
105 * present. With nothing registered (free-only), the endpoint returns the
106 * GitHub link so the operator can install it by hand. `install_plugins` is
107 * required either way — installing a plugin is a site-administration action.
108 */
109 public function installAdapter(Request $request)
110 {
111 if (!current_user_can('install_plugins')) {
112 return $this->sendError([
113 'message' => __('Sorry! You do not have permission to install plugins.', 'fluent-booking'),
114 ], 403);
115 }
116
117 $canAutoInstall = (bool) apply_filters('fluent_toolkit/can_auto_install', false);
118
119 if (!$canAutoInstall) {
120 return $this->sendError([
121 'message' => __('Please install FluentHub from GitHub, then reload this page to connect FluentBooking with AI agents.', 'fluent-booking'),
122 'toolkit_download_url' => self::TOOLKIT_DOWNLOAD_URL,
123 ], 422);
124 }
125
126 do_action('fluent_toolkit/do_auto_install');
127
128 wp_clean_plugins_cache();
129
130 $status = self::statusFields();
131
132 if (Arr::get($status, 'adapter_active')) {
133 $message = __('FluentHub is installed and connected. FluentBooking is now reachable by AI agents.', 'fluent-booking');
134 } elseif (Arr::get($status, 'toolkit_active')) {
135 $message = __('FluentHub is installed and active, but its MCP adapter is not available yet. Update FluentHub to the MCP-ready build.', 'fluent-booking');
136 } elseif (Arr::get($status, 'toolkit_installed')) {
137 $message = __('FluentHub was installed but could not be activated automatically. Activate it from the Plugins page.', 'fluent-booking');
138 } else {
139 $message = __('FluentHub could not be installed automatically. Please install it from GitHub.', 'fluent-booking');
140 }
141
142 return array_merge(['message' => $message], $status);
143 }
144
145 /**
146 * Adapter / FluentHub detection shared by the read endpoint and the install
147 * action so both report the same state after a change.
148 *
149 * @return array
150 */
151 private static function statusFields()
152 {
153 $standaloneInstalled = self::isPluginPresent(self::ADAPTER_PLUGIN_FILE);
154 $standaloneActive = self::isPluginActive(self::ADAPTER_PLUGIN_FILE);
155 $toolkitInstalled = self::isToolkitPresent();
156 $toolkitLoaded = self::isToolkitLoaded();
157 $adapterAvailable = MCPInit::adapterAvailable();
158
159 // Which supplier is actually serving the adapter runtime.
160 if ($standaloneActive && $adapterAvailable) {
161 $provider = 'plugin';
162 } elseif ($toolkitLoaded && $adapterAvailable) {
163 $provider = 'toolkit';
164 } else {
165 $provider = '';
166 }
167
168 $currentUser = wp_get_current_user();
169
170 return [
171 'endpoint_url' => MCPInit::getEndpointUrl(),
172 'adapter_available' => $adapterAvailable,
173 'adapter_active' => $adapterAvailable,
174 // Any provider on disk (standalone adapter counts even when inactive).
175 'adapter_installed' => $adapterAvailable || $standaloneInstalled || $toolkitInstalled,
176 'adapter_provider' => $provider,
177 'adapter_version' => self::detectPluginVersion(self::ADAPTER_PLUGIN_FILE),
178 'standalone_adapter_installed' => $standaloneInstalled,
179 'toolkit_installed' => $toolkitInstalled,
180 'toolkit_active' => $toolkitLoaded,
181 'toolkit_version' => self::detectToolkitVersion(),
182 'can_install_adapter' => current_user_can('install_plugins'),
183 'can_auto_install_adapter' => (bool) apply_filters('fluent_toolkit/can_auto_install', false),
184 'toolkit_download_url' => self::TOOLKIT_DOWNLOAD_URL,
185 'app_password_url' => admin_url('profile.php#application-passwords-section'),
186 'plugins_url' => admin_url('plugins.php'),
187 'current_user_login' => $currentUser ? $currentUser->user_login : '',
188 'is_local_dev' => self::detectLocalDevEnvironment(),
189 'pro_active' => defined('FLUENT_BOOKING_PRO_DIR_FILE'),
190 ];
191 }
192
193 /**
194 * FluentHub loaded in this request (its constant is defined) or present on
195 * disk as an installed plugin.
196 *
197 * @return bool
198 */
199 private static function isToolkitPresent()
200 {
201 return self::isToolkitLoaded() || self::isPluginPresent(self::TOOLKIT_PLUGIN_FILE);
202 }
203
204 /**
205 * @return bool
206 */
207 private static function isToolkitLoaded()
208 {
209 return defined('FLUENT_TOOLKIT_VERSION');
210 }
211
212 /**
213 * @return string|null
214 */
215 private static function detectToolkitVersion()
216 {
217 if (self::isToolkitLoaded()) {
218 return (string) FLUENT_TOOLKIT_VERSION;
219 }
220
221 return self::detectPluginVersion(self::TOOLKIT_PLUGIN_FILE);
222 }
223
224 /**
225 * @param string $pluginFile
226 *
227 * @return bool
228 */
229 private static function isPluginPresent($pluginFile)
230 {
231 return isset(self::installedPlugins()[$pluginFile]);
232 }
233
234 /**
235 * @param string $pluginFile
236 *
237 * @return bool
238 */
239 private static function isPluginActive($pluginFile)
240 {
241 if (!function_exists('is_plugin_active')) {
242 require_once ABSPATH . 'wp-admin/includes/plugin.php';
243 }
244
245 return is_plugin_active($pluginFile);
246 }
247
248 /**
249 * @param string $pluginFile
250 *
251 * @return string|null
252 */
253 private static function detectPluginVersion($pluginFile)
254 {
255 $plugins = self::installedPlugins();
256
257 if (!isset($plugins[$pluginFile])) {
258 return null;
259 }
260
261 return isset($plugins[$pluginFile]['Version']) ? $plugins[$pluginFile]['Version'] : null;
262 }
263
264 /**
265 * @return array
266 */
267 private static function installedPlugins()
268 {
269 if (!function_exists('get_plugins')) {
270 require_once ABSPATH . 'wp-admin/includes/plugin.php';
271 }
272
273 return get_plugins();
274 }
275
276 /**
277 * Heuristic for a local development host, used to offer the self-signed-TLS
278 * override in the Claude Desktop connection snippet. Filterable so a real
279 * deployment on an unusual TLD can correct it.
280 *
281 * @return bool
282 */
283 private static function detectLocalDevEnvironment()
284 {
285 $host = strtolower((string) wp_parse_url(home_url(), PHP_URL_HOST));
286 $isDev = false;
287
288 // .dev is intentionally excluded — it is a real public TLD (HSTS-preloaded),
289 // not a local-only suffix, so it must never trip the TLS-bypass hint.
290 $devTlds = ['.test', '.lab', '.local', '.localhost', '.docker'];
291
292 foreach ($devTlds as $tld) {
293 $len = strlen($tld);
294
295 if ($len > 0 && substr($host, -$len) === $tld) {
296 $isDev = true;
297 break;
298 }
299 }
300
301 if (!$isDev && ($host === 'localhost' || $host === '127.0.0.1' || $host === '::1')) {
302 $isDev = true;
303 }
304
305 if (!$isDev && filter_var($host, FILTER_VALIDATE_IP)) {
306 $isPrivate = !filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
307
308 if ($isPrivate) {
309 $isDev = true;
310 }
311 }
312
313 return (bool) apply_filters('fluent_booking/mcp_is_local_dev', $isDev, $host);
314 }
315
316 /**
317 * Attach each toolset's tool count and rough context cost.
318 *
319 * Every tool definition sits in the AI client's context for the whole
320 * session whether it gets called or not, so the operator deciding whether
321 * to switch a toolset on is really deciding how much of their agent's
322 * working memory to spend. That number belongs on the toggle, not in a
323 * design document.
324 *
325 * The measurement itself lives on AbilitiesRegistrar so this and
326 * scripts/check-mcp-budget.php cannot report different figures for the
327 * same toolset.
328 *
329 * @param array $toolsets
330 *
331 * @return array
332 */
333 private static function withCosts($toolsets)
334 {
335 foreach ($toolsets as $key => $meta) {
336 $definitions = AbilitiesRegistrar::getDefinitions([$key]);
337
338 // Prompts are excluded: they are not in tools/list, and their
339 // bodies are fetched only when someone runs them.
340 $definitions = array_filter($definitions, function ($definition) {
341 return empty($definition['is_prompt']);
342 });
343
344 $bytes = 0;
345
346 foreach ($definitions as $name => $definition) {
347 $bytes += AbilitiesRegistrar::wireBytes($name, $definition);
348 }
349
350 $toolsets[$key]['tools_count'] = count($definitions);
351 $toolsets[$key]['approx_tokens'] = AbilitiesRegistrar::wireTokens($bytes);
352 }
353
354 return $toolsets;
355 }
356
357 }
358