| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentCart\App\Modules\MCP; |
| 4 |
|
| 5 |
use FluentCart\App\Modules\MCP\Support\CrmContact; |
| 6 |
use FluentCart\App\Modules\MCP\Support\PermissionGate; |
| 7 |
use FluentCart\App\Modules\MCP\Tools\ContextTools; |
| 8 |
|
| 9 |
/** |
| 10 |
* Bootstrap for FluentCart's Model Context Protocol (MCP) integration. |
| 11 |
* |
| 12 |
* Wires the WordPress Abilities API (core 6.9+) + the WP MCP Adapter, which is |
| 13 |
* provided by FluentHub (bundled) or the standalone mcp-adapter plugin — |
| 14 |
* whichever is present. FluentCart bundles nothing; it consumes whatever's |
| 15 |
* loaded. If neither is available we surface an admin notice rather than fail |
| 16 |
* silently. |
| 17 |
* |
| 18 |
* The whole surface is gated behind the `mcp_enabled` option (default off) — a |
| 19 |
* store owner turns it on in Settings → MCP and creates an application |
| 20 |
* password. Even when on, the endpoint stays behind WP auth + a FluentCart |
| 21 |
* role (transport gate) + per-ability permission checks. |
| 22 |
* |
| 23 |
* Instantiated from app/Hooks/actions.php under a `function_exists` + |
| 24 |
* `PermissionGate::isEnabled()` guard. |
| 25 |
*/ |
| 26 |
class MCPInit |
| 27 |
{ |
| 28 |
const SERVER_ID = 'fluent-cart'; |
| 29 |
|
| 30 |
/** |
| 31 |
* Bootstrap entry point, called once from app/Hooks/actions.php. |
| 32 |
* |
| 33 |
* MCP ships OFF; it's enabled in Settings → Features & addon → MCP. The |
| 34 |
* server itself is instantiated only when enabled, so there is zero overhead |
| 35 |
* by default. The Abilities-API / MCP-Adapter hooks inside init() fire only |
| 36 |
* when those systems are present (WP 6.9+ and FluentHub / mcp-adapter). |
| 37 |
* |
| 38 |
* Toolkit discovery and the settings card are registered UNCONDITIONALLY: |
| 39 |
* FluentHub needs to list FluentCart on its MCP page even while disabled |
| 40 |
* so the operator can toggle it on, and the card must be reachable to flip |
| 41 |
* the switch. Both are lightweight add_filter calls — no-ops unless applied. |
| 42 |
*/ |
| 43 |
public static function boot() |
| 44 |
{ |
| 45 |
self::registerWithToolkit(); |
| 46 |
self::registerModuleSettings(); |
| 47 |
|
| 48 |
if (PermissionGate::isEnabled()) { |
| 49 |
(new self())->init(); |
| 50 |
} |
| 51 |
} |
| 52 |
|
| 53 |
public function init() |
| 54 |
{ |
| 55 |
// Abilities API hooks (fire only on WP 6.9+). |
| 56 |
add_action('wp_abilities_api_categories_init', [$this, 'registerCategory']); |
| 57 |
add_action('wp_abilities_api_init', [$this, 'registerAbilities']); |
| 58 |
|
| 59 |
// Server registration (fires only when an adapter is loaded). |
| 60 |
add_action('mcp_adapter_init', [$this, 'registerCustomServer']); |
| 61 |
|
| 62 |
// Keep get-store-context fresh: invalidate its cache when anything it |
| 63 |
// reports changes. One array payload per hook (coding-rule friendly). |
| 64 |
$invalidate = [ContextTools::class, 'invalidateCache']; |
| 65 |
foreach ([ |
| 66 |
'fluent_cart/coupon_created', |
| 67 |
'fluent_cart/coupon_updated', |
| 68 |
'fluent_cart/label_created', |
| 69 |
'fluent_cart/label_updated', |
| 70 |
'fluent_cart/store_settings_saved', |
| 71 |
'fluent_cart/payment_settings_saved', |
| 72 |
] as $hook) { |
| 73 |
add_action($hook, $invalidate); |
| 74 |
} |
| 75 |
|
| 76 |
// FluentCRM contact context on get-customer / get-order, mirroring the |
| 77 |
// contact widget FluentCRM already renders on those admin screens. |
| 78 |
// No-op unless FluentCRM is active. |
| 79 |
CrmContact::register(); |
| 80 |
|
| 81 |
// Warn the operator if they enabled MCP but no adapter is installed. |
| 82 |
add_action('admin_notices', [$this, 'maybeShowAdapterNotice']); |
| 83 |
} |
| 84 |
|
| 85 |
public function registerCategory() |
| 86 |
{ |
| 87 |
wp_register_ability_category('fluent-cart', [ |
| 88 |
'label' => __('FluentCart', 'fluent-cart'), |
| 89 |
'description' => __('Commerce abilities for FluentCart — orders, customers, products, subscriptions, coupons, and reports.', 'fluent-cart'), |
| 90 |
]); |
| 91 |
} |
| 92 |
|
| 93 |
public function registerAbilities() |
| 94 |
{ |
| 95 |
AbilitiesRegistrar::register(); |
| 96 |
|
| 97 |
/** |
| 98 |
* Fires after FluentCart registers its core MCP abilities. FluentCart |
| 99 |
* Pro hooks this to register its own abilities (licenses, advanced |
| 100 |
* inventory) under the same `fluent-cart/` namespace. |
| 101 |
* |
| 102 |
* @since 1.0.0 |
| 103 |
*/ |
| 104 |
do_action('fluent_cart/mcp_loaded'); |
| 105 |
} |
| 106 |
|
| 107 |
/** |
| 108 |
* Register the dedicated FluentCart MCP server. Endpoint defaults to |
| 109 |
* /wp-json/fluent-cart/mcp (sibling to, but distinct from, the admin REST |
| 110 |
* namespace so it doesn't get caught by that policy stack). |
| 111 |
* |
| 112 |
* @param object $adapter The \WP\MCP\Core\McpAdapter instance. |
| 113 |
*/ |
| 114 |
public function registerCustomServer($adapter) |
| 115 |
{ |
| 116 |
if (!$adapter || !is_object($adapter) || !method_exists($adapter, 'create_server')) { |
| 117 |
return; |
| 118 |
} |
| 119 |
|
| 120 |
$abilityNames = array_keys(AbilitiesRegistrar::getDefinitions()); |
| 121 |
|
| 122 |
/** |
| 123 |
* Filter the ability names exposed by the FluentCart MCP server. Pro |
| 124 |
* and extensions push their ability names here. |
| 125 |
* |
| 126 |
* @since 1.0.0 |
| 127 |
* |
| 128 |
* @param array $abilityNames Fully-qualified ability names. |
| 129 |
*/ |
| 130 |
$abilityNames = apply_filters('fluent_cart/mcp_ability_names', $abilityNames); |
| 131 |
$abilityNames = array_values(array_unique(array_filter((array) $abilityNames))); |
| 132 |
|
| 133 |
$namespace = apply_filters('fluent_cart/mcp_server_namespace', 'fluent-cart'); |
| 134 |
$route = apply_filters('fluent_cart/mcp_server_route', 'mcp'); |
| 135 |
|
| 136 |
$adapter->create_server( |
| 137 |
self::SERVER_ID, |
| 138 |
$namespace, |
| 139 |
$route, |
| 140 |
__('FluentCart MCP Server', 'fluent-cart'), |
| 141 |
__('AI agent tools for FluentCart orders, customers, products, subscriptions, and reports.', 'fluent-cart'), |
| 142 |
defined('FLUENTCART_VERSION') ? FLUENTCART_VERSION : '1.0.0', |
| 143 |
['\WP\MCP\Transport\HttpTransport'], |
| 144 |
'\WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler', |
| 145 |
'\WP\MCP\Infrastructure\Observability\NullMcpObservabilityHandler', |
| 146 |
$abilityNames, |
| 147 |
[], |
| 148 |
[], |
| 149 |
[PermissionGate::class, 'transport'] |
| 150 |
); |
| 151 |
} |
| 152 |
|
| 153 |
/** |
| 154 |
* Announce FluentCart to FluentHub's MCP page (Settings → MCP). |
| 155 |
* |
| 156 |
* FluentHub hardcodes FluentCRM but discovers every other product |
| 157 |
* through the `fluent_kit/mcp_products` + `fluent_kit/mcp_toggle_handlers` |
| 158 |
* filters. Without these, FluentCart's server is fully functional yet never |
| 159 |
* appears in the Toolkit's list — which is exactly the symptom here. |
| 160 |
* |
| 161 |
* Runs UNCONDITIONALLY (even when MCP is OFF) so the operator can see the |
| 162 |
* card and flip it on from the Toolkit; the toggle handler maps that switch |
| 163 |
* onto our `mcp_enabled` option. Both filters are cheap no-ops unless the |
| 164 |
* Toolkit actually applies them, so there's no cost when it's absent. |
| 165 |
*/ |
| 166 |
public static function registerWithToolkit() |
| 167 |
{ |
| 168 |
add_filter('fluent_kit/mcp_products', function ($products) { |
| 169 |
if (!is_array($products)) { |
| 170 |
$products = []; |
| 171 |
} |
| 172 |
|
| 173 |
$products[] = [ |
| 174 |
'slug' => self::SERVER_ID, |
| 175 |
'name' => __('FluentCart', 'fluent-cart'), |
| 176 |
'mcp_enabled' => PermissionGate::isEnabled(), |
| 177 |
'tools_count' => self::toolsCount(), |
| 178 |
'endpoint_url' => self::getEndpointUrl(), |
| 179 |
'status' => self::toolkitStatus(), |
| 180 |
]; |
| 181 |
|
| 182 |
return $products; |
| 183 |
}); |
| 184 |
|
| 185 |
add_filter('fluent_kit/mcp_toggle_handlers', function ($handlers) { |
| 186 |
if (!is_array($handlers)) { |
| 187 |
$handlers = []; |
| 188 |
} |
| 189 |
|
| 190 |
$handlers[self::SERVER_ID] = [ |
| 191 |
'get_enabled' => [PermissionGate::class, 'isEnabled'], |
| 192 |
'set_enabled' => function ($enabled) { |
| 193 |
return PermissionGate::setEnabled($enabled); |
| 194 |
}, |
| 195 |
]; |
| 196 |
|
| 197 |
return $handlers; |
| 198 |
}); |
| 199 |
} |
| 200 |
|
| 201 |
/** |
| 202 |
* Register the MCP card on Settings → Features & addon. Runs UNCONDITIONALLY |
| 203 |
* (even when MCP is OFF) so the operator can find it and turn it on. The card |
| 204 |
* renders the McpSettings.vue component, which drives the settings/mcp* REST |
| 205 |
* endpoints (instant toggle + connection helpers) rather than the generic |
| 206 |
* module-settings save — though the on/off flag itself lives under the `mcp` |
| 207 |
* key of the shared modules blob (see PermissionGate::isEnabled/setEnabled). |
| 208 |
*/ |
| 209 |
public static function registerModuleSettings() |
| 210 |
{ |
| 211 |
// Priority 100 so MCP is appended AFTER the other modules (which all |
| 212 |
// register at the default priority 10), keeping it at the end of the |
| 213 |
// Features & addon list rather than in the middle. |
| 214 |
add_filter('fluent_cart/module_setting/fields', function ($fields) { |
| 215 |
if (!is_array($fields)) { |
| 216 |
$fields = []; |
| 217 |
} |
| 218 |
$fields['mcp'] = [ |
| 219 |
'title' => __('MCP for AI Agents', 'fluent-cart'), |
| 220 |
'description' => __('Let AI assistants (Claude, Cursor, and other MCP clients) securely read your store and run operator tasks via the Model Context Protocol. Ships off; enable it and connect with an application password.', 'fluent-cart'), |
| 221 |
'type' => 'component', |
| 222 |
'component' => 'McpSettings', |
| 223 |
]; |
| 224 |
return $fields; |
| 225 |
}, 100); |
| 226 |
|
| 227 |
// Default the `mcp.active` flag to off so the shared modules blob always |
| 228 |
// carries a structured value — the McpSettings.vue model stays in sync |
| 229 |
// with it, so the generic "Save Settings" can't clobber the toggle. |
| 230 |
add_filter('fluent_cart/module_setting/default_values', function ($defaults) { |
| 231 |
if (!is_array($defaults)) { |
| 232 |
$defaults = []; |
| 233 |
} |
| 234 |
$defaults['mcp'] = ['active' => 'no']; |
| 235 |
return $defaults; |
| 236 |
}); |
| 237 |
} |
| 238 |
|
| 239 |
/** |
| 240 |
* Count of abilities the server exposes, including any pushed by Pro via the |
| 241 |
* `fluent_cart/mcp_ability_names` filter. Mirrors how the Toolkit counts |
| 242 |
* FluentCRM's tools. |
| 243 |
*/ |
| 244 |
public static function toolsCount() |
| 245 |
{ |
| 246 |
$names = array_keys(AbilitiesRegistrar::getDefinitions()); |
| 247 |
$names = apply_filters('fluent_cart/mcp_ability_names', $names); |
| 248 |
|
| 249 |
return is_array($names) ? count(array_unique($names)) : 0; |
| 250 |
} |
| 251 |
|
| 252 |
/** Status key the Toolkit renders on the FluentCart card. */ |
| 253 |
public static function toolkitStatus() |
| 254 |
{ |
| 255 |
if (!self::adapterAvailable()) { |
| 256 |
return 'adapter_required'; |
| 257 |
} |
| 258 |
|
| 259 |
return PermissionGate::isEnabled() ? 'ready' : 'disabled'; |
| 260 |
} |
| 261 |
|
| 262 |
/** Stable endpoint URL for the Settings UI + connection-snippet generator. */ |
| 263 |
public static function getEndpointUrl() |
| 264 |
{ |
| 265 |
$namespace = apply_filters('fluent_cart/mcp_server_namespace', 'fluent-cart'); |
| 266 |
$route = apply_filters('fluent_cart/mcp_server_route', 'mcp'); |
| 267 |
|
| 268 |
return get_rest_url(null, trailingslashit($namespace) . $route); |
| 269 |
} |
| 270 |
|
| 271 |
/** True when an MCP adapter + the Abilities API are both available. */ |
| 272 |
public static function adapterAvailable() |
| 273 |
{ |
| 274 |
return defined('WP_MCP_VERSION') |
| 275 |
&& class_exists('\WP\MCP\Core\McpAdapter') |
| 276 |
&& function_exists('wp_register_ability'); |
| 277 |
} |
| 278 |
|
| 279 |
public function maybeShowAdapterNotice() |
| 280 |
{ |
| 281 |
if (self::adapterAvailable() || !current_user_can('manage_options')) { |
| 282 |
return; |
| 283 |
} |
| 284 |
|
| 285 |
echo '<div class="notice notice-warning"><p>'; |
| 286 |
echo esc_html__('FluentCart MCP is enabled but no MCP adapter was found. Install FluentHub (recommended) or the MCP Adapter plugin, on WordPress 6.9+.', 'fluent-cart'); |
| 287 |
echo '</p></div>'; |
| 288 |
} |
| 289 |
} |
| 290 |
|