| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentSupport\App\Modules\MCP; |
| 4 |
|
| 5 |
use FluentSupport\App\Modules\MCP\Support\PermissionGate; |
| 6 |
use FluentSupport\App\Modules\MCP\Tools\ManagementTools; |
| 7 |
|
| 8 |
/** |
| 9 |
* Bootstrap for Fluent Support's Model Context Protocol (MCP) integration. |
| 10 |
* |
| 11 |
* Wires the WordPress Abilities API (core 6.9+) + the WP MCP Adapter (provided |
| 12 |
* by FluentHub or the standalone mcp-adapter plugin). Fluent Support bundles |
| 13 |
* nothing; it consumes whatever is loaded. If neither is available an admin |
| 14 |
* notice is shown when MCP is enabled. |
| 15 |
* |
| 16 |
* The whole surface is gated behind the `_mcp_settings` option (default off). |
| 17 |
* Even when on, the endpoint stays behind WP auth + a Fluent Support agent |
| 18 |
* permission (transport gate) + per-ability permission_callback checks. |
| 19 |
* |
| 20 |
* Entry point: MCPInit::boot(), called from app/Hooks/actions.php. |
| 21 |
*/ |
| 22 |
class MCPInit |
| 23 |
{ |
| 24 |
const SERVER_ID = 'fluent-support'; |
| 25 |
|
| 26 |
/** |
| 27 |
* Bootstrap entry point, called once from app/Hooks/actions.php. |
| 28 |
* |
| 29 |
* Toolkit discovery runs UNCONDITIONALLY so FluentHub can list Fluent |
| 30 |
* Support on its MCP page even while disabled. The actual server |
| 31 |
* registration only happens when MCP is enabled — zero overhead by default. |
| 32 |
*/ |
| 33 |
public static function boot() |
| 34 |
{ |
| 35 |
// Toolkit discovery runs unconditionally — FluentHub needs this to list |
| 36 |
// Fluent Support on its MCP page even on WP < 6.9 or when MCP is off. |
| 37 |
self::registerWithToolkit(); |
| 38 |
|
| 39 |
// Server init requires both the feature flag and the WP 6.9 Abilities API. |
| 40 |
if (PermissionGate::isEnabled() && function_exists('wp_register_ability')) { |
| 41 |
(new self())->init(); |
| 42 |
} |
| 43 |
} |
| 44 |
|
| 45 |
public function init() |
| 46 |
{ |
| 47 |
// Abilities API hooks (fire only on WP 6.9+). |
| 48 |
add_action('wp_abilities_api_categories_init', [$this, 'registerCategory']); |
| 49 |
add_action('wp_abilities_api_init', [$this, 'registerAbilities']); |
| 50 |
|
| 51 |
// Dedicated server registration (fires only when an adapter is loaded). |
| 52 |
add_action('mcp_adapter_init', [$this, 'registerCustomServer']); |
| 53 |
|
| 54 |
// Warn the operator if MCP is on but no adapter is installed. |
| 55 |
add_action('admin_notices', [$this, 'maybeShowAdapterNotice']); |
| 56 |
|
| 57 |
// Keep get-support-context fresh: invalidate its cache when anything it reports changes. |
| 58 |
$invalidate = [ManagementTools::class, 'invalidateSupportContextCache']; |
| 59 |
foreach ([ |
| 60 |
'fluent_support/ticket_created', |
| 61 |
'fluent_support/ticket_closed', |
| 62 |
'fluent_support/agent_assigned_to_ticket', |
| 63 |
'fluent_support/mailbox_saved', |
| 64 |
'fluent_support/agent_created', |
| 65 |
] as $hook) { |
| 66 |
add_action($hook, $invalidate); |
| 67 |
} |
| 68 |
} |
| 69 |
|
| 70 |
public function registerCategory() |
| 71 |
{ |
| 72 |
wp_register_ability_category(self::SERVER_ID, [ |
| 73 |
'label' => __('Fluent Support', 'fluent-support'), |
| 74 |
'description' => __('Helpdesk ticket management abilities for Fluent Support.', 'fluent-support'), |
| 75 |
]); |
| 76 |
} |
| 77 |
|
| 78 |
public function registerAbilities() |
| 79 |
{ |
| 80 |
AbilitiesRegistrar::register(); |
| 81 |
|
| 82 |
/** |
| 83 |
* Fires after Fluent Support registers its core MCP abilities. Pro |
| 84 |
* hooks this to register additional abilities under the same namespace. |
| 85 |
* |
| 86 |
* @since 1.0.0 |
| 87 |
*/ |
| 88 |
do_action('fluent_support/mcp_loaded'); |
| 89 |
} |
| 90 |
|
| 91 |
/** |
| 92 |
* Register the dedicated Fluent Support MCP server. |
| 93 |
* Endpoint: /wp-json/fluent-support/mcp |
| 94 |
* |
| 95 |
* @param object $adapter The \WP\MCP\Core\McpAdapter instance. |
| 96 |
*/ |
| 97 |
public function registerCustomServer($adapter) |
| 98 |
{ |
| 99 |
if (!$adapter || !is_object($adapter) || !method_exists($adapter, 'create_server')) { |
| 100 |
return; |
| 101 |
} |
| 102 |
|
| 103 |
$abilityNames = array_keys(AbilitiesRegistrar::getDefinitions()); |
| 104 |
|
| 105 |
/** |
| 106 |
* Filter the ability names exposed by the Fluent Support MCP server. |
| 107 |
* Pro and extensions push their ability names here. |
| 108 |
* |
| 109 |
* @since 1.0.0 |
| 110 |
* |
| 111 |
* @param array $abilityNames Fully-qualified ability names. |
| 112 |
*/ |
| 113 |
$abilityNames = apply_filters('fluent_support/mcp_ability_names', $abilityNames); |
| 114 |
$abilityNames = array_values(array_unique(array_filter( |
| 115 |
(array) $abilityNames, |
| 116 |
fn($n) => is_string($n) && preg_match('/^[a-z0-9\-\/]+$/', $n) |
| 117 |
))); |
| 118 |
|
| 119 |
$namespace = sanitize_key(apply_filters('fluent_support/mcp_server_namespace', self::SERVER_ID)) ?: self::SERVER_ID; |
| 120 |
$route = sanitize_key(apply_filters('fluent_support/mcp_server_route', 'mcp')) ?: 'mcp'; |
| 121 |
|
| 122 |
$adapter->create_server( |
| 123 |
self::SERVER_ID, |
| 124 |
$namespace, |
| 125 |
$route, |
| 126 |
__('Fluent Support MCP Server', 'fluent-support'), |
| 127 |
__('AI agent tools for helpdesk ticket management.', 'fluent-support'), |
| 128 |
defined('FLUENT_SUPPORT_VERSION') ? FLUENT_SUPPORT_VERSION : '1.0.0', |
| 129 |
['\WP\MCP\Transport\HttpTransport'], |
| 130 |
'\WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler', |
| 131 |
'\WP\MCP\Infrastructure\Observability\NullMcpObservabilityHandler', |
| 132 |
$abilityNames, |
| 133 |
[], |
| 134 |
[], |
| 135 |
[PermissionGate::class, 'transport'] |
| 136 |
); |
| 137 |
} |
| 138 |
|
| 139 |
/** |
| 140 |
* Announce Fluent Support to FluentHub's MCP page via the toolkit filters. |
| 141 |
* Runs UNCONDITIONALLY (even when MCP is off) so the operator can find |
| 142 |
* the card and toggle it on; the toggle handler maps that switch onto our |
| 143 |
* _mcp_settings option. Both filters are cheap no-ops unless the Toolkit |
| 144 |
* applies them, so there's no cost when FluentHub is absent. |
| 145 |
*/ |
| 146 |
public static function registerWithToolkit() |
| 147 |
{ |
| 148 |
add_filter('fluent_kit/mcp_products', function ($products) { |
| 149 |
if (!is_array($products)) { |
| 150 |
$products = []; |
| 151 |
} |
| 152 |
|
| 153 |
$products[] = [ |
| 154 |
'slug' => self::SERVER_ID, |
| 155 |
'name' => __('Fluent Support', 'fluent-support'), |
| 156 |
'mcp_enabled' => PermissionGate::isEnabled(), |
| 157 |
'tools_count' => self::toolsCount(), |
| 158 |
'endpoint_url' => self::getEndpointUrl(), |
| 159 |
'status' => self::toolkitStatus(), |
| 160 |
]; |
| 161 |
|
| 162 |
return $products; |
| 163 |
}); |
| 164 |
|
| 165 |
add_filter('fluent_kit/mcp_toggle_handlers', function ($handlers) { |
| 166 |
if (!is_array($handlers)) { |
| 167 |
$handlers = []; |
| 168 |
} |
| 169 |
|
| 170 |
$handlers[self::SERVER_ID] = [ |
| 171 |
'get_enabled' => [PermissionGate::class, 'isEnabled'], |
| 172 |
'set_enabled' => function ($enabled) { |
| 173 |
return PermissionGate::setEnabled($enabled); |
| 174 |
}, |
| 175 |
]; |
| 176 |
|
| 177 |
return $handlers; |
| 178 |
}); |
| 179 |
} |
| 180 |
|
| 181 |
/** Count of abilities the server exposes, including any pushed by Pro. */ |
| 182 |
public static function toolsCount() |
| 183 |
{ |
| 184 |
$names = array_keys(AbilitiesRegistrar::getDefinitions()); |
| 185 |
$names = apply_filters('fluent_support/mcp_ability_names', $names); |
| 186 |
|
| 187 |
return is_array($names) ? count(array_unique($names)) : 0; |
| 188 |
} |
| 189 |
|
| 190 |
/** Status key the Toolkit renders on the Fluent Support card. */ |
| 191 |
public static function toolkitStatus() |
| 192 |
{ |
| 193 |
if (!self::adapterAvailable()) { |
| 194 |
return 'adapter_required'; |
| 195 |
} |
| 196 |
|
| 197 |
return PermissionGate::isEnabled() ? 'ready' : 'disabled'; |
| 198 |
} |
| 199 |
|
| 200 |
/** Stable endpoint URL shown in connection snippets. */ |
| 201 |
public static function getEndpointUrl() |
| 202 |
{ |
| 203 |
$namespace = sanitize_key(apply_filters('fluent_support/mcp_server_namespace', self::SERVER_ID)) ?: self::SERVER_ID; |
| 204 |
$route = sanitize_key(apply_filters('fluent_support/mcp_server_route', 'mcp')) ?: 'mcp'; |
| 205 |
|
| 206 |
return get_rest_url(null, trailingslashit($namespace) . $route); |
| 207 |
} |
| 208 |
|
| 209 |
/** True when a WP MCP adapter + the Abilities API are both present. */ |
| 210 |
public static function adapterAvailable() |
| 211 |
{ |
| 212 |
return defined('WP_MCP_VERSION') |
| 213 |
&& class_exists('\WP\MCP\Core\McpAdapter') |
| 214 |
&& function_exists('wp_register_ability'); |
| 215 |
} |
| 216 |
|
| 217 |
public function maybeShowAdapterNotice() |
| 218 |
{ |
| 219 |
if (self::adapterAvailable() || !current_user_can('manage_options')) { |
| 220 |
return; |
| 221 |
} |
| 222 |
|
| 223 |
echo '<div class="notice notice-warning"><p>'; |
| 224 |
echo esc_html__( |
| 225 |
'Fluent Support MCP is enabled but no MCP adapter was found. Install FluentHub (recommended) or the MCP Adapter plugin on WordPress 6.9+.', |
| 226 |
'fluent-support' |
| 227 |
); |
| 228 |
echo '</p></div>'; |
| 229 |
} |
| 230 |
} |
| 231 |
|