| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentForm\App\Modules\MCP; |
| 4 |
|
| 5 |
defined('ABSPATH') || exit; |
| 6 |
|
| 7 |
use FluentForm\App\Modules\MCP\Support\PaymentDataProvider; |
| 8 |
use FluentForm\App\Modules\MCP\Support\PermissionGate; |
| 9 |
use FluentForm\App\Modules\MCP\Tools\ContextTools; |
| 10 |
|
| 11 |
/** |
| 12 |
* Bootstrap for FluentForm's Model Context Protocol (MCP) integration. |
| 13 |
* |
| 14 |
* Wires the WordPress Abilities API (core 6.9+) + the WP MCP Adapter, which is |
| 15 |
* provided by FluentHub / Fluent Toolkit (bundled) or the standalone |
| 16 |
* mcp-adapter plugin — whichever is present. FluentForm bundles nothing; it |
| 17 |
* consumes whatever's loaded and surfaces an admin notice instead of failing |
| 18 |
* silently when nothing is. |
| 19 |
* |
| 20 |
* The whole surface is gated behind the enable option (default off): a site |
| 21 |
* owner turns it on in FluentForm → Settings → MCP and connects with an |
| 22 |
* application password. Even when on, the endpoint stays behind WP auth + a |
| 23 |
* FluentForm role (transport gate) + per-ability permission checks. |
| 24 |
* |
| 25 |
* Booted from boot/app.php. |
| 26 |
*/ |
| 27 |
class MCPInit |
| 28 |
{ |
| 29 |
const SERVER_ID = 'fluentform'; |
| 30 |
|
| 31 |
/** |
| 32 |
* Bootstrap entry point, called on every request from boot/app.php. |
| 33 |
* |
| 34 |
* Toolkit discovery runs unconditionally so FluentHub can list FluentForm |
| 35 |
* even while disabled. When MCP is off the front-end cost is two add_filter |
| 36 |
* calls plus one read of an autoloaded option — not literally zero, but |
| 37 |
* nothing that touches the database beyond that. The legacy-key prune adds a |
| 38 |
* second option read, and is confined to admin requests for that reason. |
| 39 |
*/ |
| 40 |
public static function boot() |
| 41 |
{ |
| 42 |
self::registerWithToolkit(); |
| 43 |
|
| 44 |
if (is_admin()) { |
| 45 |
PermissionGate::pruneLegacyKeys(); |
| 46 |
} |
| 47 |
|
| 48 |
if (PermissionGate::isEnabled()) { |
| 49 |
(new self())->init(); |
| 50 |
} |
| 51 |
} |
| 52 |
|
| 53 |
public function init() |
| 54 |
{ |
| 55 |
add_action('wp_abilities_api_categories_init', [$this, 'registerCategory']); |
| 56 |
add_action('wp_abilities_api_init', [$this, 'registerAbilities']); |
| 57 |
|
| 58 |
add_action('mcp_adapter_init', [$this, 'registerCustomServer']); |
| 59 |
|
| 60 |
PaymentDataProvider::register(); |
| 61 |
|
| 62 |
$invalidate = [ContextTools::class, 'invalidateCache']; |
| 63 |
foreach ([ |
| 64 |
'fluentform/inserted_new_form', |
| 65 |
'fluentform/form_duplicated', |
| 66 |
// after_ only: bumping the cache version twice per deletion costs an |
| 67 |
// extra option write and buys nothing, since the bump is not |
| 68 |
// order-sensitive. |
| 69 |
'fluentform/after_form_deleted', |
| 70 |
// Permissions changed — drop the cached context so it can't briefly |
| 71 |
// advertise stale capabilities/visible-forms after a change. The |
| 72 |
// role-level hook covers the Role Manager; the per-user hook covers |
| 73 |
// an individual manager's assignment, which fires no role event. |
| 74 |
'fluentform/after_permission_set_assignment', |
| 75 |
'fluentform/after_user_permissions_attached', |
| 76 |
] as $hook) { |
| 77 |
add_action($hook, $invalidate); |
| 78 |
} |
| 79 |
|
| 80 |
add_action('admin_notices', [$this, 'maybeShowAdapterNotice']); |
| 81 |
} |
| 82 |
|
| 83 |
public function registerCategory() |
| 84 |
{ |
| 85 |
wp_register_ability_category('fluentform', [ |
| 86 |
'label' => __('FluentForm', 'fluentform'), |
| 87 |
'description' => __('Form abilities for FluentForm — forms, entries, analytics, and integrations.', 'fluentform'), |
| 88 |
]); |
| 89 |
} |
| 90 |
|
| 91 |
public function registerAbilities() |
| 92 |
{ |
| 93 |
AbilitiesRegistrar::register(); |
| 94 |
|
| 95 |
/** |
| 96 |
* Fires after FluentForm registers its core MCP abilities. Pro and |
| 97 |
* extensions hook this to register their own abilities (payments, |
| 98 |
* advanced reports) under the same `fluentform/` namespace. |
| 99 |
* |
| 100 |
* @since 6.2.5 |
| 101 |
*/ |
| 102 |
do_action('fluentform/mcp_loaded'); |
| 103 |
} |
| 104 |
|
| 105 |
/** |
| 106 |
* Register the dedicated FluentForm MCP server. Endpoint defaults to |
| 107 |
* /wp-json/fluentform/mcp. |
| 108 |
* |
| 109 |
* @param object $adapter The \WP\MCP\Core\McpAdapter instance. |
| 110 |
*/ |
| 111 |
public function registerCustomServer($adapter) |
| 112 |
{ |
| 113 |
if (!$adapter || !is_object($adapter) || !method_exists($adapter, 'create_server')) { |
| 114 |
return; |
| 115 |
} |
| 116 |
|
| 117 |
$abilityNames = array_keys(AbilitiesRegistrar::getDefinitions()); |
| 118 |
|
| 119 |
/** |
| 120 |
* Filter the ability names exposed by the FluentForm MCP server. Pro and |
| 121 |
* extensions push their ability names here. |
| 122 |
* |
| 123 |
* @since 6.2.5 |
| 124 |
* |
| 125 |
* @param array $abilityNames Fully-qualified ability names. |
| 126 |
*/ |
| 127 |
$abilityNames = apply_filters('fluentform/mcp_ability_names', $abilityNames); |
| 128 |
$abilityNames = array_values(array_unique(array_filter((array) $abilityNames))); |
| 129 |
|
| 130 |
$namespace = apply_filters('fluentform/mcp_server_namespace', 'fluentform'); |
| 131 |
$route = apply_filters('fluentform/mcp_server_route', 'mcp'); |
| 132 |
|
| 133 |
$adapter->create_server( |
| 134 |
self::SERVER_ID, |
| 135 |
$namespace, |
| 136 |
$route, |
| 137 |
__('FluentForm MCP Server', 'fluentform'), |
| 138 |
__('AI agent tools for FluentForm forms, entries, analytics, and integrations.', 'fluentform'), |
| 139 |
defined('FLUENTFORM_VERSION') ? FLUENTFORM_VERSION : '1.0.0', |
| 140 |
['\WP\MCP\Transport\HttpTransport'], |
| 141 |
'\WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler', |
| 142 |
'\WP\MCP\Infrastructure\Observability\NullMcpObservabilityHandler', |
| 143 |
$abilityNames, |
| 144 |
[], |
| 145 |
[], |
| 146 |
[PermissionGate::class, 'transport'] |
| 147 |
); |
| 148 |
} |
| 149 |
|
| 150 |
/** |
| 151 |
* Announce FluentForm to FluentHub's MCP page. FluentHub discovers products |
| 152 |
* through these filters; without them the server is fully functional yet |
| 153 |
* never appears in the Toolkit's list. Runs unconditionally (even when MCP is |
| 154 |
* off) so the operator can flip it on from the Toolkit. Both filters are |
| 155 |
* cheap no-ops unless the Toolkit applies them. |
| 156 |
*/ |
| 157 |
public static function registerWithToolkit() |
| 158 |
{ |
| 159 |
add_filter('fluent_kit/mcp_products', function ($products) { |
| 160 |
if (!is_array($products)) { |
| 161 |
$products = []; |
| 162 |
} |
| 163 |
|
| 164 |
$products[] = [ |
| 165 |
'slug' => self::SERVER_ID, |
| 166 |
'name' => __('FluentForm', 'fluentform'), |
| 167 |
'mcp_enabled' => PermissionGate::isEnabled(), |
| 168 |
'tools_count' => self::toolsCount(), |
| 169 |
'endpoint_url' => self::getEndpointUrl(), |
| 170 |
'status' => self::toolkitStatus(), |
| 171 |
]; |
| 172 |
|
| 173 |
return $products; |
| 174 |
}); |
| 175 |
|
| 176 |
add_filter('fluent_kit/mcp_toggle_handlers', function ($handlers) { |
| 177 |
if (!is_array($handlers)) { |
| 178 |
$handlers = []; |
| 179 |
} |
| 180 |
|
| 181 |
$handlers[self::SERVER_ID] = [ |
| 182 |
'get_enabled' => [PermissionGate::class, 'isEnabled'], |
| 183 |
'set_enabled' => function ($enabled) { |
| 184 |
return PermissionGate::setEnabled($enabled); |
| 185 |
}, |
| 186 |
]; |
| 187 |
|
| 188 |
return $handlers; |
| 189 |
}); |
| 190 |
} |
| 191 |
|
| 192 |
public static function toolsCount() |
| 193 |
{ |
| 194 |
$names = array_keys(AbilitiesRegistrar::getDefinitions()); |
| 195 |
$names = apply_filters('fluentform/mcp_ability_names', $names); |
| 196 |
|
| 197 |
return is_array($names) ? count(array_unique($names)) : 0; |
| 198 |
} |
| 199 |
|
| 200 |
public static function toolkitStatus() |
| 201 |
{ |
| 202 |
if (!self::adapterAvailable()) { |
| 203 |
return 'adapter_required'; |
| 204 |
} |
| 205 |
|
| 206 |
return PermissionGate::isEnabled() ? 'ready' : 'disabled'; |
| 207 |
} |
| 208 |
|
| 209 |
public static function getEndpointUrl() |
| 210 |
{ |
| 211 |
$namespace = apply_filters('fluentform/mcp_server_namespace', 'fluentform'); |
| 212 |
$route = apply_filters('fluentform/mcp_server_route', 'mcp'); |
| 213 |
|
| 214 |
return get_rest_url(null, trailingslashit($namespace) . $route); |
| 215 |
} |
| 216 |
|
| 217 |
/** True when an MCP adapter + the Abilities API are both available. */ |
| 218 |
public static function adapterAvailable() |
| 219 |
{ |
| 220 |
return defined('WP_MCP_VERSION') |
| 221 |
&& class_exists('\WP\MCP\Core\McpAdapter') |
| 222 |
&& function_exists('wp_register_ability'); |
| 223 |
} |
| 224 |
|
| 225 |
/** |
| 226 |
* Warn that MCP is on but no adapter is installed, so the endpoint is silently |
| 227 |
* dead. Scoped to FluentForm admin screens — the settings card carries the |
| 228 |
* richer prompt; this is only a cross-page reminder inside the plugin, never a |
| 229 |
* global dashboard nag. |
| 230 |
*/ |
| 231 |
public function maybeShowAdapterNotice() |
| 232 |
{ |
| 233 |
if (self::adapterAvailable() || !current_user_can('manage_options')) { |
| 234 |
return; |
| 235 |
} |
| 236 |
|
| 237 |
$page = isset($_GET['page']) ? sanitize_text_field(wp_unslash($_GET['page'])) : ''; |
| 238 |
if (0 !== strpos($page, 'fluent_forms')) { |
| 239 |
return; |
| 240 |
} |
| 241 |
|
| 242 |
// The MCP settings card renders its own richer adapter alert (with an |
| 243 |
// install/activate action), so skip this notice on the settings screen to |
| 244 |
// avoid double-reporting the same state. |
| 245 |
if ('fluent_forms_settings' === $page) { |
| 246 |
return; |
| 247 |
} |
| 248 |
|
| 249 |
echo '<div class="notice notice-warning"><p>'; |
| 250 |
echo esc_html__('FluentForm MCP is enabled but no MCP adapter was found. Install FluentHub (recommended) or the MCP Adapter plugin, on WordPress 6.9+.', 'fluentform'); |
| 251 |
echo '</p></div>'; |
| 252 |
} |
| 253 |
} |
| 254 |
|