> */ const MCP_CLIENTS = array( 'vibe-ai/vibe-ai.php' => array( 'slug' => 'wpvibe', 'label' => 'WPVibe', ), 'wpvibe-ai-mcp/wpvibe-ai-mcp.php' => array( 'slug' => 'wpvibe-mcp', 'label' => 'WPVibe MCP', ), 'mcp-adapter/mcp-adapter.php' => array( 'slug' => 'mcp-adapter', 'label' => 'WordPress MCP Adapter', ), 'wordpress-mcp/wordpress-mcp.php' => array( 'slug' => 'wordpress-mcp', 'label' => 'WordPress MCP', ), ); /** * The MCP clients active on this site. * * Loads the plugin API itself rather than guarding on * `function_exists( 'is_plugin_active' )` alone. Both callers can run * outside wp-admin — one in a cron check-in, one wherever the System * Info filter is applied — and a bare guard there reports "no client * installed" on a site running WPVibe. A false clean is the worst of the * three possible answers, because nothing about it looks wrong. * * @since 1.8.13 * * @return array> Basename => entry, active only. */ public static function get_active_mcp_clients() { if ( ! function_exists( 'is_plugin_active' ) && is_readable( ABSPATH . 'wp-admin/includes/plugin.php' ) ) { include_once ABSPATH . 'wp-admin/includes/plugin.php'; } if ( ! function_exists( 'is_plugin_active' ) ) { return array(); } $active = array(); foreach ( self::MCP_CLIENTS as $basename => $entry ) { if ( is_plugin_active( $basename ) ) { $active[ $basename ] = $entry; } } return $active; } /** * Abilities Pro provides and Lite does not. * * These are NOT registered - an assistant's capability list holds only * what it can use, and an ability that exists solely to fail reads as a * broken plugin. This list exists for intercept_gated_ability() below, * which serves a different client: one holding an ability list cached * from a Pro site, or a human pasting a Pro tutorial's slug into an * assistant running against this Lite site. * * @since 1.8.13 * @var string[] */ const GATED_ABILITIES = array( 'charitable/get-campaign-donation-form', 'charitable/update-campaign-donation-form', 'charitable/add-campaign-donation-form-field', 'charitable/get-donor', 'charitable/get-donor-donations', 'charitable/list-donors', ); /** * Domain registrars. * * Held on the instance deliberately: the registered execute callbacks bind * array( $registrar, 'execute_*' ), so the registrar objects must outlive * register_abilities() or those callbacks point at collected objects. * * @since 1.8.13 * @var Charitable_Abilities_Registrar[] */ private $registrars = array(); /** * Returns and/or creates the single instance of this class. * * @since 1.8.13 * * @return Charitable_Abilities */ public static function get_instance() { if ( is_null( self::$instance ) ) { self::$instance = new self(); } return self::$instance; } /** * Register the `charitable` ability category. * * @since 1.8.13 * * @return void */ public function register_category() { if ( ! function_exists( 'wp_register_ability_category' ) ) { return; } /** * Filters whether Charitable registers its abilities at all. * * A one-line mitigation for a site where another plugin's REST * hardening interacts badly with wp-abilities/v1, so support never * has to say "deactivate Charitable". * * @since 1.8.13 * * @param bool $enabled Defaults to true. */ if ( ! apply_filters( 'charitable_abilities_enabled', true ) ) { return; } wp_register_ability_category( 'charitable', array( 'label' => __( 'Charitable', 'charitable' ), /* * Differs from Pro's on purpose. Core exposes category label * and description over wp-abilities/v1, so a client * enumerating categories - a call that costs nothing and * needs no ability call - learns the edition boundary * before ever trying a gated slug. * * @since 1.8.13 */ 'description' => __( 'Read and manage fundraising campaigns, donations and reports in Charitable. Donor management and donation form editing require Charitable Pro.', 'charitable' ), ) ); } /** * Instantiate every registrar and register its abilities. * * `wp_abilities_api_init` fires exactly once in a normal WordPress * request (WP_Abilities_Registry::get_instance() guards that with * its own private static instance), so the check below never runs * more than once there. It matters when the hook fires more than * once in the SAME process, which the Charitable and WP core test * suites both do deliberately — to test kill-switch behaviour, or to * register an additional test-only ability on a hook that already * fired. A naive second run would ask WP_Abilities_Registry to * register every charitable/* ability a second time, and core * refuses each one with an "already registered" _doing_it_wrong(). * * Checking the registry's OWN current content, not a static "have I * ever run" flag: a flag cannot tell "the hook fired again on top of * an unreset registry" (skip — every charitable/* ability is already * there) apart from "the hook fired again because something reset * the registry itself, e.g. via reflection on * WP_Abilities_Registry::$instance" (register — the registry * genuinely has nothing in it now, including no Charitable * abilities). A flag answers the same stale "yes" to both. * * ORDER MATTERS: this guard runs AFTER the charitable_abilities_enabled * kill-switch check below, not before it. test-abilities-contract.php's * test_the_kill_switch_prevents_registration() re-fires this hook with * the kill switch forced off and asserts the ability count does not * change. If this guard ran first, it would return before the kill * switch filter is ever consulted — the test would still pass (nothing * changes either way) but for the wrong reason, and it would no longer * be able to catch a real regression in the kill-switch check itself. * * @since 1.8.13 * * @return void */ public function register_abilities() { if ( ! function_exists( 'wp_register_ability' ) ) { return; } /** * Filters whether Charitable registers its abilities at all. * * A one-line mitigation for a site where another plugin's REST * hardening interacts badly with wp-abilities/v1, so support never * has to say "deactivate Charitable". * * @since 1.8.13 * * @param bool $enabled Defaults to true. */ if ( ! apply_filters( 'charitable_abilities_enabled', true ) ) { return; } foreach ( wp_get_abilities() as $registered_ability ) { if ( 0 === strpos( $registered_ability->get_name(), 'charitable/' ) ) { return; } } /* * Four registrars, not Pro's five. Donor abilities are Pro-gated in * Lite: Lite's Charitable_Donor lacks six of the eleven methods they * call, and Lite's Donors admin page is an education page rather than * donor management. See the 1.8.13 spec, section 3.2. */ $this->registrars = array(); /* * class_exists() guarded, unlike Pro. Pro ships all five registrars in * one release, so this array can never reference a class that is not * yet on disk. This port is landing across several commits instead * (this class first, then the registrars themselves), so for the * window between this commit and the last of those, the classes below * genuinely do not exist yet — and `new` on a name with no class and * no autoloader match is a fatal `Error`, not a WP_Error, thrown * straight out of this wp_abilities_api_init callback with nothing * upstream to catch it. That would fatal every front-end, REST and * WP-CLI request on any WordPress 6.9+ site running this exact * commit. The guard costs nothing once every registrar exists: four * classes that are always loaded are always instantiated, in the same * order, and this array ends up identical to an unguarded literal. * * @since 1.8.13 */ foreach ( array( 'Charitable_Diagnostic_Abilities', 'Charitable_Campaign_Abilities', 'Charitable_Donation_Abilities', 'Charitable_Report_Abilities', ) as $registrar_class ) { if ( class_exists( $registrar_class ) ) { $this->registrars[] = new $registrar_class(); } } foreach ( $this->registrars as $registrar ) { $registrar->register(); } } /** * Add the `abilities` log type so ability activity is filterable in Tools > Logs. * * @since 1.8.13 * * @param array $types Registered log types. * @return array */ public function register_log_type( $types ) { $types['abilities'] = __( 'Abilities', 'charitable' ); return $types; } /** * Add the two abilities log sources so Tools > Logs can label and filter them. * * Charitable_Abilities_Registrar::log_write_refused() writes * `source => 'write-gate'` and log_denial() writes * `source => 'permission'` — the discriminator the AI Activity panel * reads to tell a refusal from a denial. Charitable_Log::get_sources() * is an allowlist and Charitable_Log_List_Table::column_source() falls * back to the RAW KEY for anything missing from it, so without this * registration the Source column reads the hyphenated machine slug * `write-gate` while neighbouring abilities rows read "Core". Worse, the * Source filter `