Security > WAF > Custom rules > Create rule. Choose \"Edit expression\" and paste:\n(starts_with(http.request.uri.path, \"/wp-json/handymcp/\") or starts_with(http.request.uri.path, \"/.well-known/oauth\") or starts_with(http.request.uri.path, \"/handymcp/oauth/\") or http.request.uri.path in {\"/register\" \"/token\" \"/authorize\"})\nAction: Skip. Tick every option: All remaining custom rules, All rate limiting rules, All managed rules, All Super Bot Fight Mode rules, Browser Integrity Check, Security Level. Under \"Place at\" choose First. Save."; } /** * Cloudflare steps that rules cannot skip. */ public static function cloudflare_switches(): string { return "These are not fixed by a Skip rule, so check each one in Cloudflare:\n- Security > Analytics > Events: open the newest blocked request. \"Service\" and \"Rule ID\" name what did it (Rule ID iuam = Under Attack mode, Service Security level = a Security Level setting or rule).\n- Rules > Configuration Rules and Rules > Page Rules: a rule that sets Security Level (for example to I'm Under Attack for visitors from other countries) wins over the Skip rule. Add to its expression:\n and not starts_with(http.request.uri.path, \"/wp-json/handymcp/\") and not starts_with(http.request.uri.path, \"/.well-known/oauth\") and not starts_with(http.request.uri.path, \"/handymcp/oauth/\") and not http.request.uri.path in {\"/register\" \"/token\" \"/authorize\" \"/wp-cron.php\"}\n- Overview > Quick Actions: Under Attack Mode must be off.\n- Security > Settings > Bot traffic: Bot Fight Mode must be off. On the Free plan it ignores Skip rules.\n- Security > Settings > Configure AI bot policies: Agent must be Allow (Claude-User and ChatGPT-User)."; } /** * Troubleshooting page section (handymcp.com/docs/troubleshooting/#...) for a blocker. * * @param string $who Blocker id from classify(). */ public static function doc( string $who ): string { if ( 0 === strpos( $who, 'cloudflare' ) ) { return 'cloudflare-blocks-the-connection'; } if ( in_array( $who, array( 'wp_rest_blocked', 'wp_no_route' ), true ) ) { return 'a-plugin-turns-off-the-rest-api'; } return 'sucuri-wordfence-or-the-host-firewall-blocks-the-endpoint'; } /** * Is the site behind Cloudflare, judging by a response or the current request? * * @param array|\WP_Error|null $r Response. */ public static function is_cloudflare( $r = null ): bool { if ( ! empty( $_SERVER['HTTP_CF_RAY'] ) || ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) { return true; } if ( is_array( $r ) ) { return '' !== (string) wp_remote_retrieve_header( $r, 'cf-ray' ) || 'cloudflare' === strtolower( (string) wp_remote_retrieve_header( $r, 'server' ) ); } return false; } /** * Identify what answered a blocked request. * * @param array $r wp_remote_* response. * @param string $what Which address was tested, for the message. * @return array{who: string, detail: string, fix: string}|null Null when it looks like WordPress answered normally. */ public static function classify( array $r, string $what ): ?array { $code = (int) wp_remote_retrieve_response_code( $r ); $body = (string) wp_remote_retrieve_body( $r ); $low = strtolower( substr( $body, 0, 20000 ) ); $server = strtolower( (string) wp_remote_retrieve_header( $r, 'server' ) ); $h = static function ( string $name ) use ( $r ): string { return strtolower( (string) wp_remote_retrieve_header( $r, $name ) ); }; $cf = '' !== $h( 'cf-ray' ) || 'cloudflare' === $server; // Cloudflare Zero Trust (Access) sends every request to its own sign-in page. if ( in_array( $code, array( 301, 302, 303, 307, 308, 401, 403 ), true ) && ( false !== strpos( $h( 'location' ), 'cloudflareaccess.com' ) || false !== strpos( $low, 'cloudflareaccess.com' ) ) ) { return array( 'who' => 'cloudflare_access', 'detail' => "Cloudflare Zero Trust (Access) sent $what to its own sign-in page (HTTP $code). AI apps cannot sign in there.", 'fix' => 'Cloudflare Zero Trust > Access > Applications > Add an application > Self-hosted. Use this domain with the path wp-json/' . HANDYMCP_REST_NS . ' and add a policy with the action Bypass and the rule Everyone. A more specific path wins, so the rest of the site stays protected. Do the same for the path .well-known if your Access application covers the whole domain. Then run this check again.' ); } // Cloudflare. if ( $cf ) { $fix = self::cloudflare_rule() . "\n\n" . self::cloudflare_switches(); if ( 'challenge' === $h( 'cf-mitigated' ) || false !== strpos( $low, 'just a moment' ) || false !== strpos( $low, '_cf_chl_opt' ) || false !== strpos( $low, 'challenge-platform' ) || false !== strpos( $low, 'cf-browser-verification' ) ) { return array( 'who' => 'cloudflare_challenge', 'detail' => "Cloudflare answered $what with a browser challenge (\"Just a moment...\", HTTP $code). AI apps cannot solve it. This comes from Under Attack mode (also when a Configuration Rule or Page Rule turns it on, for example for other countries), Bot Fight Mode, Security Level High, or a WAF rule with the action Managed or JS Challenge.", 'fix' => $fix ); } if ( preg_match( '/error(?:\s| )*code:?\s*(10\d\d)/', $low, $m ) || preg_match( '/cf-error-code[^0-9]{0,40}(10\d\d)/', $low, $m ) ) { $e = (int) $m[1]; $names = array( 1006 => 'an IP Access rule bans the server address', 1007 => 'an IP Access rule bans the server address', 1008 => 'an IP Access rule bans the server address', 1009 => 'a country block (IP Access rule or WAF rule on country)', 1010 => 'Browser Integrity Check (it blocks requests without a normal browser signature, which every AI app sends)', 1012 => 'access denied by the site owner settings', 1015 => 'a rate limiting rule', 1020 => 'a WAF custom rule or firewall rule', ); $why = $names[ $e ] ?? 'a Cloudflare security setting'; $tip = 1010 === $e ? "Cloudflare > Security > Settings: turn off Browser Integrity Check, or add the Skip rule below with Browser Integrity Check ticked.\n" : ( 1015 === $e ? "Cloudflare > Security > WAF > Rate limiting rules: exclude the paths below or raise the limit.\n" : '' ); return array( 'who' => 'cloudflare_' . $e, 'detail' => "Cloudflare blocked $what with error $e: $why.", 'fix' => $tip . $fix ); } if ( false !== strpos( $low, 'sorry, you have been blocked' ) || false !== strpos( $low, 'attention required' ) ) { return array( 'who' => 'cloudflare_waf', 'detail' => "Cloudflare's firewall blocked $what (\"Sorry, you have been blocked\"). A managed rule (Cloudflare or OWASP rule set) or a custom rule matched it.", 'fix' => $fix ); } if ( in_array( $code, array( 403, 429, 503 ), true ) && ( '' === trim( $body ) || false === strpos( $low, 'wordpress' ) ) && ! self::wp_json( $body ) ) { return array( 'who' => 'cloudflare', 'detail' => "Cloudflare answered $what with HTTP $code before your server did. This is usually Bot Fight Mode, AI bot blocking or a WAF rule.", 'fix' => $fix ); } } // Other firewalls in front of WordPress. if ( '' !== $h( 'x-sucuri-id' ) || '' !== $h( 'x-sucuri-block' ) || false !== strpos( $low, 'sucuri website firewall' ) ) { return array( 'who' => 'sucuri', 'detail' => "The Sucuri Website Firewall blocked $what (HTTP $code).", 'fix' => "Sucuri dashboard > Firewall (WAF) > Access Control > Allowlist URL Paths: add each of these, then clear the Sucuri cache:\n" . self::PATHS . "\nAlso in Sucuri > Security: turn off \"Block non-browser user agents\" / anti-bot challenge for these paths." ); } if ( false !== strpos( $low, 'wordfence' ) || false !== strpos( $low, 'wfwaf' ) || false !== strpos( $low, 'your access to this site has been limited' ) ) { return array( 'who' => 'wordfence', 'detail' => "Wordfence blocked $what (HTTP $code).", 'fix' => "Wordfence > Tools > Live Traffic: find the blocked request to /wp-json/handymcp/ and click \"Add Param to Firewall Allowlist\".\nOr Wordfence > Firewall > All Firewall Options > Allowlisted URLs: add /wp-json/handymcp/v1/mcp (and the other paths below).\nIf the reason is rate limiting: Wordfence > Firewall > Rate Limiting: set \"If a crawler's page views exceed\" to Unlimited.\nPaths:\n" . self::PATHS ); } if ( false !== strpos( $low, 'imunify360' ) || false !== strpos( $low, 'imunify' ) || false !== strpos( $low, 'bot-protection' ) ) { return array( 'who' => 'imunify360', 'detail' => "Imunify360 (your host's firewall) blocked $what (HTTP $code).", 'fix' => "cPanel or Plesk > Imunify360 > Firewall: add the AI app as allowed, or ask your host to disable Imunify360 bot protection (WebShield / Anti-bot) for these paths:\n" . self::PATHS ); } if ( 406 === $code || false !== strpos( $low, 'mod_security' ) || false !== strpos( $low, 'modsecurity' ) || false !== strpos( $low, 'not acceptable!' ) ) { return array( 'who' => 'modsecurity', 'detail' => "ModSecurity on your server blocked $what (HTTP $code).", 'fix' => "cPanel > Security > ModSecurity: turn it off for this domain, or ask your host to whitelist the rule that matched for these paths:\n" . self::PATHS ); } if ( self::is_sg_captcha( $r ) ) { return array( 'who' => 'siteground', 'detail' => "SiteGround's Anti-Bot AI system answered $what with a captcha (HTTP $code). AI apps cannot solve it.", 'fix' => "There is no switch for this in Site Tools. Open a ticket with SiteGround support and paste the text from the SiteGround panel on the Connection screen. It asks them to exclude these paths from Anti-Bot AI and captcha checks for non-browser clients:\n" . self::PATHS ); } if ( false !== strpos( $low, 'ninjafirewall' ) ) { return array( 'who' => 'ninjafirewall', 'detail' => "NinjaFirewall blocked $what.", 'fix' => 'NinjaFirewall > Firewall Log: find the request and its rule ID, then NinjaFirewall > Rules Editor: disable that rule. Also NinjaFirewall > Firewall Policies > WordPress REST API: allow it.' ); } if ( false !== strpos( $low, 'litespeed' ) && in_array( $code, array( 403, 429 ), true ) ) { return array( 'who' => 'litespeed', 'detail' => "The LiteSpeed web server refused $what (HTTP $code). Its reCAPTCHA / anti-DDoS or a connection limit is active.", 'fix' => 'Ask your host to turn off LiteSpeed reCAPTCHA (bot protection) for these paths, or to raise the per-IP limits:' . "\n" . self::PATHS ); } // A WordPress JSON error: a plugin or code on the site refused it. $wp = self::wp_json( $body ); if ( $wp && 'rest_no_route' === $wp ) { return array( 'who' => 'wp_no_route', 'detail' => "WordPress says the route does not exist for $what (rest_no_route). Something removes HandyMCP's REST routes.", 'fix' => 'A plugin that limits REST API routes (Disable REST API, Perfmatters, Hide My WP, WP Hide, Clearfy) hides it. Allow the handymcp/v1 namespace in that plugin.' ); } if ( $wp && in_array( $wp, array( 'rest_disabled', 'rest_cannot_access', 'rest_forbidden', 'rest_login_required', 'rest_not_logged_in', 'rest_api_disabled', 'rest_unauthorized', 'rest_access_forbidden' ), true ) ) { return array( 'who' => 'wp_rest_blocked', 'detail' => "A plugin on this site refuses REST API requests from apps that are not logged in ($wp). AI apps are never logged in with a cookie.", 'fix' => 'A security or REST API plugin does this. See the "Security plugin settings" check below, or deactivate security plugins one at a time and run the check again to find it.' ); } // Plain web server page. if ( in_array( $code, array( 403, 406, 429, 503 ), true ) && ! $wp ) { $by = false !== strpos( $server, 'nginx' ) ? 'nginx' : ( false !== strpos( $server, 'apache' ) ? 'Apache' : ( '' !== $server ? $server : 'the web server' ) ); return array( 'who' => 'server', 'detail' => "$by refused $what with HTTP $code before WordPress ran. A host firewall, an .htaccess or nginx rule, or a security plugin firewall that runs before WordPress did it.", 'fix' => 'Ask your host whether their firewall blocks these paths or non-browser requests, and to allow them:' . "\n" . self::PATHS . ( self::prepend_waf() ? "\n" . self::prepend_waf() . ' runs before WordPress on this server: check its firewall log first.' : '' ) ); } return null; } /** * REST error code in a WordPress JSON body, or ''. * * @param string $body Body. */ private static function wp_json( string $body ): string { $j = json_decode( $body, true ); return is_array( $j ) && isset( $j['code'] ) && is_string( $j['code'] ) ? $j['code'] : ''; } /** * A security plugin firewall loaded before WordPress (auto_prepend_file). */ public static function prepend_waf(): string { $f = strtolower( (string) ini_get( 'auto_prepend_file' ) ); if ( '' === $f ) { return ''; } foreach ( array( 'wordfence' => 'The Wordfence firewall (Extended Protection)', 'ninjafirewall' => 'NinjaFirewall', 'malcare' => 'MalCare firewall', 'aios' => 'All-In-One Security firewall', 'bbq' => 'BBQ Firewall', 'shield' => 'Shield Security', 'sucuri' => 'Sucuri', 'imunify' => 'Imunify360', 'patchstack' => 'Patchstack' ) as $needle => $name ) { if ( false !== strpos( $f, $needle ) ) { return $name; } } return 'A firewall loaded with auto_prepend_file (' . basename( $f ) . ')'; } /** * Active security plugins and settings known to block AI apps, with the exact place to change them. * * @return array problem is '' when no blocking setting was found. */ public static function security_plugins(): array { $out = array(); if ( defined( 'AIO_WP_SECURITY_VERSION' ) ) { $c = (array) get_option( 'aio_wp_security_configs', array() ); $problem = array(); if ( ! empty( $c['aiowps_disallow_unauthorized_rest_requests'] ) ) { $problem[] = '"Disallow unauthorized REST requests" is on'; } if ( ! empty( $c['aiowps_enable_6g_firewall'] ) || ! empty( $c['aiowps_enable_5g_firewall'] ) ) { $problem[] = 'the 5G/6G firewall rules are on (they can block JSON requests)'; } $out[] = array( 'name' => 'All-In-One Security', 'problem' => implode( '; ', $problem ), 'fix' => 'WP Security > Miscellaneous > WP REST API: turn off "Disallow unauthorized REST requests", or add handymcp to the allowed REST namespaces. If the 6G firewall is on: WP Security > Firewall > 6G firewall rules: allow the paths, or turn it off and test again.' ); } if ( class_exists( 'ITSEC_Core', false ) ) { $s = (array) get_option( 'itsec-storage', array() ); $rest = (string) ( $s['wordpress-tweaks']['rest_api'] ?? '' ); $out[] = array( 'name' => 'Solid Security (iThemes Security)', 'problem' => 'restrict-access' === $rest ? 'REST API access is set to "Restricted Access"' : '', 'fix' => 'Security > Settings > Advanced > WordPress Tweaks > REST API: choose "Default Access". If Solid\'s firewall or "Ban users" is on, check Security > Logs for blocked requests to /wp-json/handymcp/.' ); } if ( defined( 'WORDFENCE_VERSION' ) ) { $problem = ''; if ( class_exists( 'wfConfig', false ) && is_callable( array( 'wfConfig', 'get' ) ) ) { $crawl = (string) call_user_func( array( 'wfConfig', 'get' ), 'maxRequestsCrawlers' ); $act = (string) call_user_func( array( 'wfConfig', 'get' ), 'maxRequestsCrawlers_action' ); // An AI app makes a few dozen requests a minute at most: only a low limit is a problem. if ( call_user_func( array( 'wfConfig', 'get' ), 'firewallEnabled' ) && is_numeric( $crawl ) && (int) $crawl < 120 && 'block' === strtolower( $act ) ) { $problem = 'Rate Limiting blocks crawlers after ' . $crawl . ' requests a minute, and AI app servers count as crawlers'; } } $out[] = array( 'name' => 'Wordfence', 'problem' => $problem, 'fix' => "Wordfence > Firewall > Rate Limiting: set \"If a crawler's page views exceed\" to Unlimited or Throttle. Wordfence > Tools > Live Traffic shows any blocked request to /wp-json/handymcp/; use \"Add Param to Firewall Allowlist\" on it." ); } $perf = get_option( 'perfmatters_options' ); if ( is_array( $perf ) && ! empty( $perf['disable_rest_api'] ) ) { $out[] = array( 'name' => 'Perfmatters', 'problem' => '"Disable REST API" is set to ' . (string) $perf['disable_rest_api'], 'fix' => 'Perfmatters > General > Disable REST API: choose Default (Enabled).' ); } if ( defined( 'CERBER_VER' ) ) { $h = (array) get_option( 'cerber-hardening', array() ); $out[] = array( 'name' => 'WP Cerber', 'problem' => ! empty( $h['norest'] ) ? 'access to the REST API is restricted' : '', 'fix' => 'Cerber > Dashboard > Hardening > "Disable WordPress REST API": add handymcp to the allowed namespaces, or turn the option off.' ); } if ( defined( 'HMWP_VERSION' ) ) { $out[] = array( 'name' => 'Hide My WP Ghost', 'problem' => '', 'fix' => 'Hide My WP > Change Paths: if "Change WP REST API path" or "Disable REST API access" is on, AI apps cannot find the endpoint. Turn them off, or use the server URL shown on the Connection screen after the change.' ); } if ( defined( 'NFW_ENGINE_VERSION' ) ) { $out[] = array( 'name' => 'NinjaFirewall', 'problem' => '', 'fix' => 'NinjaFirewall > Firewall Policies > WordPress REST API: keep it allowed. NinjaFirewall > Firewall Log shows blocked requests.' ); } if ( defined( 'SUCURISCAN_VERSION' ) ) { $out[] = array( 'name' => 'Sucuri Security', 'problem' => '', 'fix' => 'If you use the Sucuri Website Firewall: Firewall (WAF) > Access Control > Allowlist URL Paths: add /wp-json/handymcp/ and /.well-known/oauth.' ); } foreach ( array( 'DEFENDER_VERSION' => 'Defender', 'WP_DEFENDER_VERSION' => 'Defender', 'BBQ_VERSION' => 'BBQ Firewall', 'SECUPRESS_VERSION' => 'SecuPress', 'rsssl_version' => 'Really Simple Security', 'JETPACK_PROTECT_VERSION' => 'Jetpack Protect' ) as $const => $name ) { if ( defined( $const ) ) { $out[] = array( 'name' => $name, 'problem' => '', 'fix' => 'If its firewall is on, check its log for blocked requests to /wp-json/handymcp/ and allow that path.' ); } } if ( class_exists( 'ICWP_WPSF_Shield_Security', false ) || defined( 'ICWP_WPSF_FULL_PATH' ) ) { $out[] = array( 'name' => 'Shield Security', 'problem' => '', 'fix' => 'Shield > Config > Bot Protection: AI app servers can be marked as bots. Add the AI app to the allowed list, or set the bot actions to Log only. Check Shield > Activity Log.' ); } if ( ! function_exists( 'is_plugin_active' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } foreach ( array( 'disable-json-api/disable-json-api.php' => 'Disable REST API', 'disable-wp-rest-api/disable-wp-rest-api.php' => 'Disable WP REST API', 'clearfy/clearfy.php' => 'Clearfy', 'wp-hide-security-enhancer/wp-hide.php' => 'WP Hide & Security Enhancer' ) as $file => $name ) { if ( is_plugin_active( $file ) ) { $out[] = array( 'name' => $name, 'problem' => 'it can switch off the REST API for apps that are not logged in', 'fix' => "Open $name settings and allow the handymcp/v1 REST namespace (or deactivate it and run the check again)." ); } } return $out; } /** * Did SiteGround's Anti-Bot AI answer with a captcha (sg-captcha header, or a * page that refreshes to /.well-known/sgcaptcha/)? * * @param array|\WP_Error|null $r Response. */ public static function is_sg_captcha( $r ): bool { if ( ! is_array( $r ) ) { return false; } $code = (int) wp_remote_retrieve_response_code( $r ); $low = strtolower( substr( (string) wp_remote_retrieve_body( $r ), 0, 20000 ) ); $cap = strtolower( (string) wp_remote_retrieve_header( $r, 'sg-captcha' ) ); return '' !== $cap || false !== strpos( $low, 'sgcaptcha' ) || ( 202 === $code && false !== strpos( strtolower( (string) wp_remote_retrieve_header( $r, 'server' ) ), 'nginx' ) && '' === trim( $low ) ); } /** * Is this site hosted on SiteGround? Looks at response headers (x-proxy-cache, * x-sg-*, sg-captcha) and the SiteGround plugins. * * @param array $responses wp_remote_* responses from the check. */ public static function is_siteground( array $responses = array() ): bool { foreach ( $responses as $r ) { if ( ! is_array( $r ) ) { continue; } if ( self::is_sg_captcha( $r ) || '' !== (string) wp_remote_retrieve_header( $r, 'x-proxy-cache' ) ) { return true; } $headers = wp_remote_retrieve_headers( $r ); $list = is_object( $headers ) && method_exists( $headers, 'getAll' ) ? $headers->getAll() : (array) $headers; foreach ( array_keys( $list ) as $name ) { if ( 0 === strpos( strtolower( (string) $name ), 'x-sg-' ) ) { return true; } } } if ( ! function_exists( 'is_plugin_active' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } return is_plugin_active( 'sg-cachepress/sg-cachepress.php' ) || is_plugin_active( 'sg-security/sg-security.php' ) || defined( 'SG_SECURITY_VERSION' ) || class_exists( 'SiteGround_Optimizer\Loader\Loader', false ); } /** * Support ticket text for SiteGround, ready to paste. */ public static function siteground_ticket(): string { $host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); return "Hello,\n\nPlease exclude these paths on " . $host . " from Anti-Bot AI / captcha checks for non-browser clients:\n/wp-json/handymcp/v1/*\n/.well-known/oauth-authorization-server\n/.well-known/oauth-protected-resource\n/handymcp/oauth/authorize\n/register\n/token\n/authorize\n\nAI apps (Claude, ChatGPT) connect to my site through the HandyMCP plugin and are blocked with sg-captcha (HTTP 202) or HTTP 403. They are server-side apps, so they cannot solve the captcha.\n\nAlso, if possible, please let requests to /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource reach WordPress instead of being answered by the server with a 404.\n\nThank you."; } /** * Paths to exclude from SiteGround's dynamic cache (SG Optimizer). */ public static function siteground_cache(): string { return "Speed Optimizer (SG Optimizer) > Caching > Dynamic Caching > Exclude URLs: add\n/wp-json/handymcp/*\n/.well-known/*\n/handymcp/oauth/*\n/register\n/token\n/authorize\nThen purge the cache (Speed Optimizer > Caching > Purge, or Site Tools > Speed > Caching > Flush Cache)."; } /** * Cache header values that show a response came from a cache, or ''. * * @param array $r Response. */ public static function cache_hit( array $r ): string { $hits = array(); foreach ( array( 'x-litespeed-cache', 'x-proxy-cache', 'cf-cache-status', 'x-cache', 'x-sg-cache', 'x-cache-status', 'x-wp-rocket-cache' ) as $name ) { $v = strtolower( implode( ', ', (array) wp_remote_retrieve_header( $r, $name ) ) ); if ( '' !== $v && preg_match( '/\bhit\b/', $v ) ) { $hits[] = $name . ': ' . $v; } } return implode( '; ', $hits ); } /** * Steps to exclude the MCP paths from page caching, for the cache plugins and CDN in use. * * @param string $hit Cache header values from cache_hit(). */ public static function cache_fix( string $hit = '' ): string { if ( ! function_exists( 'is_plugin_active' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $paths = '/wp-json/handymcp/, /.well-known/, /handymcp/oauth/, /register, /token, /authorize'; $steps = array( 'litespeed' => array( is_plugin_active( 'litespeed-cache/litespeed-cache.php' ) || false !== strpos( $hit, 'litespeed' ), 'LiteSpeed Cache > Cache > Excludes > Do Not Cache URIs: add ' . $paths . ' (one per line), save, then Purge All.' ), 'sg' => array( is_plugin_active( 'sg-cachepress/sg-cachepress.php' ) || false !== strpos( $hit, 'x-proxy-cache' ) || false !== strpos( $hit, 'x-sg-' ), self::siteground_cache() ), 'rocket' => array( is_plugin_active( 'wp-rocket/wp-rocket.php' ) || false !== strpos( $hit, 'rocket' ), 'WP Rocket > Advanced Rules > Never Cache URL(s): add /wp-json/handymcp/(.*), /.well-known/(.*) and /handymcp/oauth/(.*), save, then Clear cache.' ), 'w3tc' => array( is_plugin_active( 'w3-total-cache/w3-total-cache.php' ), 'W3 Total Cache > Page Cache > Advanced > Never cache the following pages: add wp-json/handymcp/*, .well-known/* and handymcp/oauth/*, save, then Empty All Caches.' ), 'cf' => array( false !== strpos( $hit, 'cf-cache-status' ) || self::is_cloudflare(), 'Cloudflare > Caching > Cache Rules: create a rule for URI Path starts with /wp-json/handymcp/ or /.well-known/oauth or /handymcp/oauth/, Cache eligibility: Bypass cache. Then Caching > Configuration > Purge Everything.' ), ); $out = array(); foreach ( $steps as $step ) { if ( $step[0] ) { $out[] = $step[1]; } } if ( ! $out ) { $out[] = 'Exclude ' . $paths . ' from page caching in your cache plugin, your host cache and your CDN, then clear every cache.'; } return implode( "\n", $out ); } /** * Plain steps to serve the two discovery files as static files in the site root. * The plugin never writes them itself. */ public static function wellknown_steps(): string { return "1. In your hosting file manager (or over SFTP), open the folder that holds wp-config.php (often public_html) and create a folder named .well-known if it is not there yet.\n2. In it, create a file named oauth-authorization-server (no extension) and paste the first JSON below.\n3. Create a file named oauth-protected-resource (no extension) and paste the second JSON below.\n4. Make the server send them as JSON:\n Apache or LiteSpeed: create .well-known/.htaccess with:\n \n ForceType application/json\n Header set Access-Control-Allow-Origin \"*\"\n \n nginx: add to the server block:\n location ^~ /.well-known/oauth- { default_type application/json; add_header Access-Control-Allow-Origin *; }\n SiteGround: nginx answers /.well-known/ itself and may send the files as text/plain. Most AI apps still read them. If one does not, ask SiteGround support to serve these two files as application/json.\n5. Open both addresses in your browser to check they show the JSON, then run the connection check again.\nIf you later change the site address or turn automatic app registration off, update the files (or delete them)."; } /** * Fix text when a firewall blocks requests that contain code-like text. * * @param string $who Blocker id from classify(). */ public static function code_waf_fix( string $who ): string { $path = '/wp-json/' . HANDYMCP_REST_NS . '/mcp'; $all = array( 'wordfence' => "Wordfence: Firewall > All Firewall Options > set the Web Application Firewall to Learning Mode for a week while you use the AI app, or Tools > Live Traffic > find the blocked request and click \"Add Param to Firewall Allowlist\". You can also add $path under Allowlisted URLs.", 'imunify360' => "Imunify360 / ModSecurity: ask your host to whitelist the rule that matched for $path (they find the rule ID in the ModSecurity log).", 'modsecurity' => "ModSecurity: ask your host to whitelist the rule that matched for $path, or turn ModSecurity off for this domain in cPanel > Security > ModSecurity.", 'cloudflare' => "Cloudflare: Security > WAF > Custom rules > Create rule: URI Path starts with $path, action Skip, tick \"All managed rules\" and \"All remaining custom rules\".", 'sucuri' => "Sucuri: Firewall (WAF) > Access Control > Allowlist URL Paths: add $path.", 'host' => "Hostinger, SiteGround and other hosts with their own firewall: open a support ticket and ask them to allow requests with HTML and code in the body for $path.", ); if ( 0 === strpos( $who, 'cloudflare' ) ) { $who = 'cloudflare'; } if ( isset( $all[ $who ] ) ) { return $all[ $who ] . "\n\n" . __( 'Other firewalls:', 'handymcp' ) . "\n" . implode( "\n", array_diff_key( $all, array( $who => 1 ) ) ); } return implode( "\n", $all ); } }