432 lines
19 KiB
PHP
432 lines
19 KiB
PHP
<?php
|
|
|
|
namespace FluentCrm\App\Http\Controllers;
|
|
|
|
use FluentCrm\App\Modules\MCP\AbilitiesRegistrar;
|
|
use FluentCrm\App\Modules\MCP\MCPInit;
|
|
use FluentCrm\Framework\Http\Request\Request;
|
|
|
|
/**
|
|
* Settings → MCP admin endpoints (MCP_PLAN.md § 13).
|
|
*
|
|
* Surfaces:
|
|
* - status: adapter detected? CRM count? enabled toggle?
|
|
* - install-adapter: one-click install of the WP MCP Adapter plugin
|
|
* - toggle: enable/disable the entire MCP module without uninstalling
|
|
* - config-snippet: pre-filled JSON for Claude Desktop / Claude Code /
|
|
* Cursor / generic clients
|
|
*/
|
|
class MCPSettingsController extends Controller
|
|
{
|
|
const ADAPTER_PLUGIN_FILE = 'mcp-adapter/mcp-adapter.php';
|
|
const TOOLKIT_PLUGIN_FILE = 'fluent-toolkit/fluent-toolkit.php';
|
|
|
|
/**
|
|
* Status block — what the Settings page lights up with on load.
|
|
*/
|
|
public function status()
|
|
{
|
|
if (!function_exists('is_plugin_active')) {
|
|
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
|
}
|
|
|
|
$adapterInstalled = $this->isAdapterPresent();
|
|
$toolkitInstalled = $this->isToolkitPresent();
|
|
$adapterRuntimeAvailable = $this->isAdapterRuntimeAvailable();
|
|
$standaloneActive = is_plugin_active(self::ADAPTER_PLUGIN_FILE) && $adapterRuntimeAvailable;
|
|
$toolkitActive = $this->isToolkitLoaded();
|
|
$toolkitAdapterActive = $toolkitActive && $this->isToolkitAdapterAvailable();
|
|
$adapterActive = $standaloneActive || $toolkitAdapterActive;
|
|
$adapterProvider = $standaloneActive ? 'plugin' : ($toolkitAdapterActive ? 'toolkit' : '');
|
|
$abilitiesAvailable = function_exists('wp_register_ability');
|
|
$canAutoInstall = (bool) apply_filters('fluent_toolkit/can_auto_install', false);
|
|
|
|
$toolsCount = $abilitiesAvailable ? $this->countAbilities() : 0;
|
|
|
|
$currentUser = wp_get_current_user();
|
|
|
|
// Detect a local dev environment heuristically. Self-signed/local
|
|
// certs trip Node's TLS validation in the npx proxy that Claude
|
|
// Desktop uses; if we know the user is on dev, we pre-bake the
|
|
// workaround into the generated snippet. The Vue page also exposes
|
|
// a manual toggle for edge cases.
|
|
$isLocalDev = self::detectLocalDevEnvironment();
|
|
|
|
return [
|
|
'adapter_installed' => $adapterInstalled || $toolkitInstalled,
|
|
'adapter_active' => $adapterActive,
|
|
'adapter_provider' => $adapterProvider,
|
|
'standalone_adapter_installed' => $adapterInstalled,
|
|
'toolkit_installed' => $toolkitInstalled,
|
|
'toolkit_active' => $toolkitActive,
|
|
'toolkit_adapter_available' => $toolkitAdapterActive,
|
|
'adapter_runtime_available' => $adapterRuntimeAvailable,
|
|
'adapter_version' => $this->detectAdapterVersion(),
|
|
'toolkit_version' => $this->detectToolkitVersion(),
|
|
'abilities_api_loaded' => $abilitiesAvailable,
|
|
'endpoint_url' => MCPInit::getEndpointUrl(),
|
|
'tools_count' => $toolsCount,
|
|
'mcp_enabled' => fluentcrm_get_option('mcp_enabled', 'yes') === 'yes',
|
|
'pro_active' => defined('FLUENTCAMPAIGN'),
|
|
'app_passwords_url' => admin_url('profile.php#application-passwords-section'),
|
|
'plugins_url' => admin_url('plugins.php'),
|
|
'can_auto_install_adapter' => $canAutoInstall,
|
|
'toolkit_download_url' => 'https://github.com/WPManageNinja/fluent-toolkit',
|
|
'current_user_login' => $currentUser ? $currentUser->user_login : '',
|
|
'is_local_dev' => $isLocalDev,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Toggle the kill-switch. Stored as a FluentCRM option so the lazy-register
|
|
* guard in app/Hooks/actions.php picks it up on the next request.
|
|
*/
|
|
public function toggle(Request $request)
|
|
{
|
|
$value = $request->get('mcp_enabled');
|
|
$enabled = is_string($value) ? ($value === 'yes' || $value === 'true' || $value === '1') : (bool) $value;
|
|
|
|
fluentcrm_update_option('mcp_enabled', $enabled ? 'yes' : 'no');
|
|
|
|
return [
|
|
'ok' => true,
|
|
'mcp_enabled' => $enabled,
|
|
'message' => $enabled
|
|
? __('MCP tools enabled. New requests will see the FluentCRM abilities.', 'fluent-crm')
|
|
: __('MCP tools disabled. The adapter will no longer report FluentCRM abilities.', 'fluent-crm'),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* One-click adapter install. Free can only explain the missing dependency;
|
|
* Pro may opt in to the FluentHub background installer via hooks.
|
|
*/
|
|
public function installAdapter()
|
|
{
|
|
if (!current_user_can('install_plugins')) {
|
|
return $this->sendError([
|
|
'message' => __('Sorry! you do not have permission to install plugins', 'fluent-crm'),
|
|
]);
|
|
}
|
|
|
|
$canAutoInstall = (bool) apply_filters('fluent_toolkit/can_auto_install', false);
|
|
if (!$canAutoInstall) {
|
|
return $this->sendError([
|
|
'message' => __('Please install FluentHub from GitHub, then reload this page to connect FluentCRM with AI agents.', 'fluent-crm'),
|
|
'toolkit_download_url' => 'https://github.com/WPManageNinja/fluent-toolkit',
|
|
]);
|
|
}
|
|
|
|
do_action('fluent_toolkit/do_auto_install');
|
|
|
|
wp_clean_plugins_cache();
|
|
|
|
if (!function_exists('is_plugin_active')) {
|
|
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
|
}
|
|
|
|
$toolkitInstalled = $this->isToolkitPresent();
|
|
$toolkitActive = $this->isToolkitLoaded();
|
|
$adapterRuntimeAvailable = $this->isAdapterRuntimeAvailable();
|
|
$toolkitAdapterAvailable = $toolkitActive && $this->isToolkitAdapterAvailable();
|
|
$isInstalled = $this->isAdapterPresent() || $toolkitInstalled;
|
|
$isActive = (is_plugin_active(self::ADAPTER_PLUGIN_FILE) && $adapterRuntimeAvailable) || $toolkitAdapterAvailable;
|
|
|
|
if ($isInstalled && $isActive) {
|
|
$message = __('FluentHub installed and activated. Reload the page to register FluentCRM MCP tools.', 'fluent-crm');
|
|
} elseif ($toolkitInstalled && $toolkitActive) {
|
|
$message = __('FluentHub is installed and active, but this version does not include the bundled MCP adapter yet. Please update FluentHub when the MCP-ready build is available, then reload this page.', 'fluent-crm');
|
|
} elseif ($toolkitInstalled) {
|
|
$message = __('FluentHub is installed but could not be activated automatically. Please activate FluentHub from the Plugins page, then reload this page.', 'fluent-crm');
|
|
} else {
|
|
$message = __('Could not install FluentHub automatically. Please install FluentHub manually, then reload this page.', 'fluent-crm');
|
|
}
|
|
|
|
return [
|
|
'is_installed' => $isInstalled,
|
|
'adapter_active' => $isActive,
|
|
'toolkit_active' => $toolkitActive,
|
|
'toolkit_adapter_available' => $toolkitAdapterAvailable,
|
|
'message' => $message,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Generate a copy-paste config snippet for the requested client.
|
|
*
|
|
* Every client uses WordPress Application Passwords — built into WP 5.6+,
|
|
* no extra plugin needed. Direct HTTP clients (Claude Code, Cursor,
|
|
* generic) carry credentials via Basic Auth header; the
|
|
* @automattic/mcp-wordpress-remote stdio bridge that Claude Desktop uses
|
|
* accepts the username/password directly via WP_API_USERNAME and
|
|
* WP_API_PASSWORD env vars and handles encoding itself.
|
|
*
|
|
* Placeholders used here are stable strings the Vue page substitutes via
|
|
* regex when the user fills the credentials inputs.
|
|
*/
|
|
public function getConfigSnippet(Request $request)
|
|
{
|
|
$client = sanitize_key((string) $request->get('client', 'claude-code'));
|
|
$endpoint = MCPInit::getEndpointUrl();
|
|
// Optional override from the Settings UI checkbox. When the user
|
|
// explicitly says "I'm on local dev" we add the TLS-bypass env var to
|
|
// Claude Desktop's snippet; when they say "no" we omit it even if
|
|
// auto-detection thinks otherwise.
|
|
$forceLocalDev = $request->get('local_dev');
|
|
if ($forceLocalDev === 'yes' || $forceLocalDev === '1' || $forceLocalDev === 'true') {
|
|
$isLocalDev = true;
|
|
} elseif ($forceLocalDev === 'no' || $forceLocalDev === '0' || $forceLocalDev === 'false') {
|
|
$isLocalDev = false;
|
|
} else {
|
|
$isLocalDev = self::detectLocalDevEnvironment();
|
|
}
|
|
|
|
// The Vue page replaces these tokens with the user's real values.
|
|
// Keep them stable + distinct so the regex stays simple.
|
|
$basicPlaceholder = '<base64(your-username:application-password)>';
|
|
$usernamePlaceholder = '<your-username>';
|
|
$passwordPlaceholder = '<your-application-password>';
|
|
|
|
$appPasswordsUrl = admin_url('profile.php#application-passwords-section');
|
|
|
|
switch ($client) {
|
|
case 'codex':
|
|
$snippet = sprintf(
|
|
"Settings → Connect to a custom MCP\n\nName: fluent-crm\nTransport: Streamable HTTP ← click this tab first\n\nURL: %s\n\nHeader:\n Key: Authorization\n Value: Basic %s\n\nClick Save.",
|
|
$endpoint,
|
|
$basicPlaceholder
|
|
);
|
|
$instructions = sprintf(
|
|
/* translators: %s: link to WP user profile application passwords section */
|
|
__('Open OpenAI Codex → Settings → Connect to a custom MCP. Click the "Streamable HTTP" tab. Generate a WordPress Application Password from %s, then paste username + app password into the inputs above — the Value field will auto-fill with the encoded Basic auth string.', 'fluent-crm'),
|
|
$appPasswordsUrl
|
|
);
|
|
break;
|
|
|
|
case 'cursor':
|
|
$snippet = wp_json_encode([
|
|
'mcpServers' => [
|
|
'fluent-crm' => [
|
|
'url' => $endpoint,
|
|
'type' => 'http',
|
|
'headers' => [
|
|
'Authorization' => 'Basic ' . $basicPlaceholder,
|
|
],
|
|
],
|
|
],
|
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
|
$instructions = __('Cursor speaks HTTP MCP natively. Fill in your username and application password above and the snippet will be ready to paste into Cursor → Settings → MCP. Restart Cursor afterwards.', 'fluent-crm');
|
|
break;
|
|
|
|
case 'generic':
|
|
$snippet = sprintf(
|
|
"URL: %s\nAuth: Authorization: Basic %s\n\n# Quick test (curl handles the base64 for you)\ncurl -s -u '%s:%s' \\\n -X POST %s \\\n -H 'Content-Type: application/json' \\\n -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}'",
|
|
$endpoint,
|
|
$basicPlaceholder,
|
|
$usernamePlaceholder,
|
|
$passwordPlaceholder,
|
|
$endpoint
|
|
);
|
|
$instructions = __('Use the URL + Basic Auth header with any HTTP MCP client. The endpoint speaks the standard MCP protocol — initialize, tools/list, tools/call — over JSON-RPC.', 'fluent-crm');
|
|
break;
|
|
|
|
case 'claude-desktop':
|
|
// Claude Desktop cannot speak HTTP MCP directly yet — it
|
|
// routes through @automattic/mcp-wordpress-remote, which
|
|
// accepts WP_API_USERNAME / WP_API_PASSWORD plain (proxy
|
|
// does the encoding). No JWT plugin needed.
|
|
$env = [
|
|
'WP_API_URL' => $endpoint,
|
|
'WP_API_USERNAME' => $usernamePlaceholder,
|
|
'WP_API_PASSWORD' => $passwordPlaceholder,
|
|
'OAUTH_ENABLED' => 'false',
|
|
];
|
|
|
|
if ($isLocalDev) {
|
|
// Self-signed certs trip Node's bundled CA store. Trust
|
|
// the connection wholesale for local dev — the proxy
|
|
// only ever talks to one URL the user explicitly chose,
|
|
// so the practical risk is bounded.
|
|
$env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0';
|
|
}
|
|
|
|
$snippet = wp_json_encode([
|
|
'mcpServers' => [
|
|
'fluent-crm' => [
|
|
'command' => 'npx',
|
|
'args' => ['-y', '@automattic/mcp-wordpress-remote@latest'],
|
|
'env' => $env,
|
|
],
|
|
],
|
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
|
|
|
$localDevNote = $isLocalDev
|
|
? ' ' . __('Local dev mode is on, so NODE_TLS_REJECT_UNAUTHORIZED is included — the npx proxy needs it to talk to self-signed Valet/MAMP/Local SSL.', 'fluent-crm')
|
|
: '';
|
|
$instructions = __('Paste into ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\\Claude\\claude_desktop_config.json (Windows), then restart Claude Desktop.', 'fluent-crm') . $localDevNote;
|
|
break;
|
|
|
|
case 'claude-code':
|
|
default:
|
|
$snippet = sprintf(
|
|
"claude mcp add \\\n --transport http \\\n fluent-crm %s \\\n --header \"Authorization: Basic %s\"",
|
|
$endpoint,
|
|
$basicPlaceholder
|
|
);
|
|
$instructions = __('Fill in your username and application password above, then paste the command into your terminal. Run `claude` and the FluentCRM tools will appear under MCP servers.', 'fluent-crm');
|
|
$client = 'claude-code';
|
|
break;
|
|
}
|
|
|
|
return [
|
|
'client' => $client,
|
|
'snippet' => $snippet,
|
|
'instructions' => $instructions,
|
|
'endpoint' => $endpoint,
|
|
'app_passwords_url' => $appPasswordsUrl,
|
|
'is_local_dev' => $isLocalDev,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Heuristic check for "we're running on a local development install."
|
|
*
|
|
* Tested in order:
|
|
* 1. Hostname ends in a dev TLD (.test, .lab, .local, .localhost)
|
|
* 2. Hostname is literally `localhost`
|
|
* 3. Host resolves to a private/loopback IP range
|
|
*
|
|
* Filterable via `fluent_crm/mcp_is_local_dev` so operators can override
|
|
* detection on edge cases (a public-facing site on `.local`, an internal
|
|
* tool that needs the dev-mode behavior anyway, etc.).
|
|
*
|
|
* @return bool
|
|
*/
|
|
private static function detectLocalDevEnvironment()
|
|
{
|
|
$host = wp_parse_url(home_url(), PHP_URL_HOST);
|
|
$host = strtolower((string) $host);
|
|
|
|
$isDev = false;
|
|
|
|
$devTlds = ['.test', '.lab', '.local', '.localhost', '.docker', '.dev'];
|
|
foreach ($devTlds as $tld) {
|
|
$len = strlen($tld);
|
|
if ($len > 0 && substr($host, -$len) === $tld) {
|
|
$isDev = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$isDev && ($host === 'localhost' || $host === '127.0.0.1' || $host === '::1')) {
|
|
$isDev = true;
|
|
}
|
|
|
|
if (!$isDev && filter_var($host, FILTER_VALIDATE_IP)) {
|
|
// Private IP ranges per RFC 1918.
|
|
$isPrivate = !filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
|
|
if ($isPrivate) {
|
|
$isDev = true;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Override the local-dev detection. Useful when the heuristic gets
|
|
* it wrong (e.g. a public site on a `.local` mDNS hostname).
|
|
*
|
|
* @since 2.10.0
|
|
*
|
|
* @param bool $isDev Whether the install looks like local dev.
|
|
* @param string $host The detected hostname.
|
|
*/
|
|
return (bool) apply_filters('fluent_crm/mcp_is_local_dev', $isDev, $host);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// helpers
|
|
// ---------------------------------------------------------------------
|
|
|
|
private function isAdapterPresent()
|
|
{
|
|
return $this->isPluginPresent(self::ADAPTER_PLUGIN_FILE);
|
|
}
|
|
|
|
private function isToolkitPresent()
|
|
{
|
|
return $this->isToolkitLoaded() || $this->isPluginPresent(self::TOOLKIT_PLUGIN_FILE);
|
|
}
|
|
|
|
private function detectAdapterVersion()
|
|
{
|
|
return $this->detectPluginVersion(self::ADAPTER_PLUGIN_FILE);
|
|
}
|
|
|
|
private function detectToolkitVersion()
|
|
{
|
|
if ($this->isToolkitLoaded()) {
|
|
return (string) FLUENT_TOOLKIT_VERSION;
|
|
}
|
|
|
|
return $this->detectPluginVersion(self::TOOLKIT_PLUGIN_FILE);
|
|
}
|
|
|
|
private function isToolkitLoaded()
|
|
{
|
|
return defined('FLUENT_TOOLKIT_VERSION');
|
|
}
|
|
|
|
private function isPluginPresent($pluginFile)
|
|
{
|
|
if (!function_exists('get_plugins')) {
|
|
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
|
}
|
|
$plugins = get_plugins();
|
|
return isset($plugins[$pluginFile]);
|
|
}
|
|
|
|
private function detectPluginVersion($pluginFile)
|
|
{
|
|
if (!function_exists('get_plugins')) {
|
|
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
|
}
|
|
$plugins = get_plugins();
|
|
if (!isset($plugins[$pluginFile])) {
|
|
return null;
|
|
}
|
|
return $plugins[$pluginFile]['Version'] ?? null;
|
|
}
|
|
|
|
private function isToolkitAdapterAvailable()
|
|
{
|
|
if (!$this->isToolkitLoaded()) {
|
|
return false;
|
|
}
|
|
|
|
if (class_exists('\FluentToolkit\Mcp\AdapterBootstrap') && method_exists('\FluentToolkit\Mcp\AdapterBootstrap', 'available')) {
|
|
return (bool) \FluentToolkit\Mcp\AdapterBootstrap::available();
|
|
}
|
|
|
|
return $this->isAdapterRuntimeAvailable();
|
|
}
|
|
|
|
private function isAdapterRuntimeAvailable()
|
|
{
|
|
return defined('WP_MCP_VERSION')
|
|
&& class_exists('\WP\MCP\Core\McpAdapter')
|
|
&& function_exists('wp_register_ability');
|
|
}
|
|
|
|
private function countAbilities()
|
|
{
|
|
$count = count(AbilitiesRegistrar::getDefinitions());
|
|
|
|
// Pro tools are pushed onto the names list via the
|
|
// `fluent_crm/mcp_ability_names` filter in MCPInit.
|
|
$names = apply_filters('fluent_crm/mcp_ability_names', array_keys(AbilitiesRegistrar::getDefinitions()));
|
|
if (is_array($names)) {
|
|
$count = count(array_unique($names));
|
|
}
|
|
return $count;
|
|
}
|
|
}
|