From 445903d1df77f146690696fafefc052879cf8e5c Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 24 Jun 2026 11:53:08 +0800 Subject: [PATCH 1/3] Abilities API: Plugin Settings --- ...class-convertkit-admin-section-general.php | 4 +- includes/class-convertkit-settings.php | 116 ++++++++ ...ss-convertkit-mcp-ability-settings-get.php | 148 ++++++++++ ...convertkit-mcp-ability-settings-update.php | 182 ++++++++++++ .../class-convertkit-mcp-ability-settings.php | 110 +++++++ includes/mcp/class-convertkit-mcp.php | 35 +++ tests/Integration/MCPSettingsGeneralTest.php | 271 ++++++++++++++++++ wp-convertkit.php | 7 +- 8 files changed, 869 insertions(+), 4 deletions(-) create mode 100644 includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings-get.php create mode 100644 includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings-update.php create mode 100644 includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings.php create mode 100644 tests/Integration/MCPSettingsGeneralTest.php diff --git a/admin/section/class-convertkit-admin-section-general.php b/admin/section/class-convertkit-admin-section-general.php index dc10bbb5b..66f3fe59f 100644 --- a/admin/section/class-convertkit-admin-section-general.php +++ b/admin/section/class-convertkit-admin-section-general.php @@ -53,8 +53,8 @@ public function __construct() { $this->settings_key = $this->settings::SETTINGS_NAME; // Define the programmatic name, Title and Tab Text. - $this->name = 'general'; - $this->title = __( 'General Settings', 'convertkit' ); + $this->name = $this->settings->get_name(); + $this->title = $this->settings->get_title(); $this->tab_text = __( 'General', 'convertkit' ); // Define settings sections. diff --git a/includes/class-convertkit-settings.php b/includes/class-convertkit-settings.php index 13c5234b2..3e86915ae 100644 --- a/includes/class-convertkit-settings.php +++ b/includes/class-convertkit-settings.php @@ -568,6 +568,122 @@ public function usage_tracking() { } + /** + * Returns this settings group's programmatic name. + * + * @since 3.4.0 + * + * @return string + */ + public function get_name() { + + return 'general'; + + } + + /** + * Returns the title of this settings group. + * + * @since 3.4.0 + * + * @return string + */ + public function get_title() { + + return __( 'General Settings', 'convertkit' ); + + } + + /** + * Returns the keys in this settings group that hold credentials or other + * sensitive values. + * + * @since 3.4.0 + * + * @return string[] + */ + public function get_secret_keys() { + + return array( + 'access_token', + 'refresh_token', + 'token_expires', + 'api_key', + 'api_secret', + 'recaptcha_secret_key', + ); + + } + + /** + * Returns the JSON Schema describing this settings group, in the shape + * stored by save() / returned by get(), excluding secret keys. + * + * @since 3.4.0 + * + * @return array + */ + public function get_schema() { + + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'non_inline_form' => array( + 'type' => 'array', + 'items' => array( 'type' => 'integer' ), + 'description' => __( 'IDs of non-inline Forms to display site-wide.', 'convertkit' ), + ), + 'non_inline_form_honor_none_setting' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether the site-wide non-inline Form honors a per-Page / per-Post "None" Form setting.', 'convertkit' ), + ), + 'non_inline_form_limit_per_session' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether to limit non-inline Form display to once per session.', 'convertkit' ), + ), + 'recaptcha_site_key' => array( + 'type' => 'string', + 'description' => __( 'Google reCAPTCHA v3 site key.', 'convertkit' ), + ), + 'recaptcha_minimum_score' => array( + 'type' => 'number', + 'minimum' => 0, + 'maximum' => 1, + 'description' => __( 'Minimum Google reCAPTCHA v3 score (0.0 - 1.0) below which a request is treated as spam.', 'convertkit' ), + ), + 'debug' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether debug logging is enabled.', 'convertkit' ), + ), + 'no_scripts' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether the Plugin\'s frontend JavaScript is disabled.', 'convertkit' ), + ), + 'no_css' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether the Plugin\'s frontend CSS is disabled.', 'convertkit' ), + ), + 'no_add_new_button' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether the "Add New" button for Landing Pages / Member Content is hidden in the WordPress Admin.', 'convertkit' ), + ), + 'usage_tracking' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether anonymous usage tracking is enabled.', 'convertkit' ), + ), + ), + ); + + } + /** * The default settings, used when the ConvertKit Plugin Settings haven't been saved * e.g. on a new installation. diff --git a/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings-get.php b/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings-get.php new file mode 100644 index 000000000..ba853a375 --- /dev/null +++ b/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings-get.php @@ -0,0 +1,148 @@ +-get` (e.g. `kit/settings-general-get`). + * + * @package ConvertKit + * @author ConvertKit + */ +class ConvertKit_MCP_Ability_Settings_Get extends ConvertKit_MCP_Ability_Settings { + + /** + * Sets whether the ability is readonly. + * + * @since 3.4.0 + * + * @var bool + */ + private $readonly = true; // @phpstan-ignore-line + + /** + * Sets whether the ability is idempotent. + * + * @since 3.4.0 + * + * @var bool + */ + private $idempotent = true; // @phpstan-ignore-line + + /** + * Returns the operation suffix used in the ability name. + * + * @since 3.4.0 + * + * @return string + */ + protected function get_operation() { + + return 'get'; + + } + + /** + * Returns the ability's human-readable label. + * + * @since 3.4.0 + * + * @return string + */ + public function get_label() { + + return sprintf( + /* translators: %s: Settings Title, e.g. 'General Settings'. */ + __( 'Get Kit Plugin %s', 'convertkit' ), + $this->settings->get_title() + ); + + } + + /** + * Returns the ability's human-readable description. + * + * @since 3.4.0 + * + * @return string + */ + public function get_description() { + + return sprintf( + /* translators: %s: Settings Title, e.g. 'General Settings'. */ + __( 'Returns the current values of the Kit Plugin "%s".', 'convertkit' ), + $this->settings->get_title() + ); + + } + + /** + * Returns the ability's input JSON Schema. + * + * Get takes no input. + * + * @since 3.4.0 + * + * @return array + */ + public function get_input_schema() { + + return array( + 'type' => 'object', + 'properties' => new stdClass(), + ); + + } + + /** + * Returns the ability's output JSON Schema. + * + * @since 3.4.0 + * + * @return array + */ + public function get_output_schema() { + + return $this->get_public_schema(); + + } + + /** + * Executes the ability: returns the current settings, scoped to the keys + * declared in the public schema. + * + * Stored values are sanitised through the property schema before being + * returned, so the response matches the declared types (e.g. a numeric + * setting stored as the string "0.5" is returned as 0.5). + * + * @since 3.4.0 + * + * @param array $input Ability input (unused). + * @return array|WP_Error + */ + public function execute_callback( $input ) { + + $values = $this->settings->get(); + $schema = $this->get_public_schema(); + $result = array(); + + if ( ! isset( $schema['properties'] ) || ! is_array( $schema['properties'] ) ) { + return $result; + } + + foreach ( $schema['properties'] as $key => $property_schema ) { + if ( array_key_exists( $key, $values ) ) { + $result[ $key ] = rest_sanitize_value_from_schema( $values[ $key ], $property_schema, $key ); + } + } + + return $result; + + } + +} diff --git a/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings-update.php b/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings-update.php new file mode 100644 index 000000000..a8e30438a --- /dev/null +++ b/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings-update.php @@ -0,0 +1,182 @@ +-update` (e.g. `kit/settings-general-update`). + * + * @package ConvertKit + * @author ConvertKit + */ +class ConvertKit_MCP_Ability_Settings_Update extends ConvertKit_MCP_Ability_Settings { + + /** + * Sets whether the ability is idempotent. + * + * @since 3.4.0 + * + * @var bool + */ + private $idempotent = true; // @phpstan-ignore-line + + /** + * Returns the operation suffix used in the ability name. + * + * @since 3.4.0 + * + * @return string + */ + protected function get_operation() { + + return 'update'; + + } + + /** + * Returns the ability's human-readable label. + * + * @since 3.4.0 + * + * @return string + */ + public function get_label() { + + return sprintf( + /* translators: %s: Settings Title, e.g. 'General Settings'. */ + __( 'Update Kit Plugin %s', 'convertkit' ), + $this->settings->get_title() + ); + + } + + /** + * Returns the ability's human-readable description. + * + * @since 3.4.0 + * + * @return string + */ + public function get_description() { + + return sprintf( + /* translators: %s: Settings Title, e.g. 'General Settings'. */ + __( 'Updates one or more values in the Kit Plugin "%s". Only keys declared in the input schema can be updated; secret values (API keys, OAuth tokens) cannot be set via this ability.', 'convertkit' ), + $this->settings->get_title() + ); + + } + + /** + * Returns the ability's input JSON Schema. + * + * Mirrors the settings class's get_schema() with secret keys removed, so + * partial updates are possible (no top-level `required`). + * + * @since 3.4.0 + * + * @return array + */ + public function get_input_schema() { + + return $this->get_public_schema(); + + } + + /** + * Returns the ability's output JSON Schema. + * + * Returns the same shape as kit/settings--get so a caller can chain + * update → confirm in one round trip. + * + * @since 3.4.0 + * + * @return array + */ + public function get_output_schema() { + + return $this->get_public_schema(); + + } + + /** + * Executes the ability. + * + * Validates the input, rejecting unknown and secret keys + * and saves via the settings class. + * + * @since 3.4.0 + * + * @param array $input Ability input. + * @return array|WP_Error + */ + public function execute_callback( $input ) { + + // Bail if no input is provided. + if ( ! count( $input ) ) { + return new WP_Error( + 'convertkit_mcp_settings_invalid_input', + __( 'Input must be an object of settings keys and values.', 'convertkit' ) + ); + } + + // Get the public schema, allowed and secret keys. + $schema = $this->get_public_schema(); + $allowed_keys = array_keys( $schema['properties'] ); + $secret_keys = $this->settings->get_secret_keys(); + + // Bail if any secret keys are provided in the input. + $secret_attempts = array_intersect( array_keys( $input ), $secret_keys ); + if ( ! empty( $secret_attempts ) ) { + return new WP_Error( + 'convertkit_mcp_settings_secret_write', + sprintf( + /* translators: %s: Comma-separated list of secret keys. */ + __( 'The following settings cannot be updated via MCP: %s.', 'convertkit' ), + implode( ', ', $secret_attempts ) + ) + ); + } + + // Bail if any unknown keys are provided in the input. + $unknown_attempts = array_diff( array_keys( $input ), $allowed_keys ); + if ( ! empty( $unknown_attempts ) ) { + return new WP_Error( + 'convertkit_mcp_settings_unknown_keys', + sprintf( + /* translators: %s: Comma-separated list of unknown keys. */ + __( 'The following settings keys are not recognised: %s.', 'convertkit' ), + implode( ', ', $unknown_attempts ) + ) + ); + } + + // Validate each provided value against its declared schema. + $validated = array(); + foreach ( $input as $key => $value ) { + $valid = rest_validate_value_from_schema( $value, $schema['properties'][ $key ], $key ); + + // Bail if the value is invalid. + if ( is_wp_error( $valid ) ) { + return $valid; + } + + $validated[ $key ] = rest_sanitize_value_from_schema( $value, $schema['properties'][ $key ], $key ); + } + + // Save via the settings class so its own sanitisation runs. + $this->settings->save( $validated ); + + // Return the post-save state. + $get_ability = new ConvertKit_MCP_Ability_Settings_Get( $this->settings ); + return $get_ability->execute_callback( array() ); + + } + +} diff --git a/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings.php b/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings.php new file mode 100644 index 000000000..ccc25e700 --- /dev/null +++ b/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings.php @@ -0,0 +1,110 @@ +settings = $settings; + + } + + /** + * Returns the operation suffix used in the ability name (e.g. 'get', + * 'update'). Combined with the settings name to produce the full + * `kit/settings--` name. + * + * @since 3.4.0 + * + * @return string + */ + abstract protected function get_operation(); + + /** + * Returns the ability name, derived from the settings name and operation + * (e.g. `kit/settings-general-get`). + * + * @since 3.4.0 + * + * @return string + */ + public function get_name() { + + return 'kit/settings-' . $this->settings->get_name() . '-' . $this->get_operation(); + + } + + /** + * Permission callback for settings abilities. + * + * Plugin settings are restricted to users who can manage options, matching + * the capability that gates the Plugin's own settings screens. + * + * @since 3.4.0 + * + * @param array $input Ability input (unused). + * @return bool|WP_Error + */ + public function permission_callback( $input ) { + + if ( ! current_user_can( 'manage_options' ) ) { + return new WP_Error( + 'convertkit_mcp_cannot_manage_settings', + __( 'You do not have permission to read or update Kit Plugin settings.', 'convertkit' ) + ); + } + + return true; + + } + + /** + * Returns the ability's input and output JSON schemas. + * + * @since 3.4.0 + * + * @return array + */ + protected function get_public_schema() { + + $schema = $this->settings->get_schema(); + $secret = $this->settings->get_secret_keys(); + + if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) { + foreach ( $secret as $key ) { + unset( $schema['properties'][ $key ] ); + } + } + + return $schema; + + } + +} diff --git a/includes/mcp/class-convertkit-mcp.php b/includes/mcp/class-convertkit-mcp.php index 33cec9fe2..5a736a38f 100644 --- a/includes/mcp/class-convertkit-mcp.php +++ b/includes/mcp/class-convertkit-mcp.php @@ -75,11 +75,46 @@ public function __construct() { // so they're added here rather than via a per-class register_abilities(). add_filter( 'convertkit_abilities', array( $this, 'register_resource_abilities' ) ); + // Register settings get / update abilities for each Plugin settings + // These are owned by the Plugin (not by any single feature), + // so they're added here rather than via a per-class register_abilities(). + add_filter( 'convertkit_abilities', array( $this, 'register_settings_abilities' ) ); + // Register the MCP server. add_action( 'mcp_adapter_init', array( $this, 'register_mcp_server' ) ); } + /** + * Appends the settings get / update abilities for each Plugin settings + * group to the convertkit_abilities filter, so they are registered with + * the Abilities API and exposed via the MCP server. + * + * @since 3.4.0 + * + * @param array $abilities Abilities to register. + * @return array + */ + public function register_settings_abilities( $abilities ) { + + // Settings instances to register with MCP. + $groups = array( + new ConvertKit_Settings(), + ); + + // Iterate through settings groups, registering the get and update abilities. + foreach ( $groups as $settings ) { + $get = new ConvertKit_MCP_Ability_Settings_Get( $settings ); + $update = new ConvertKit_MCP_Ability_Settings_Update( $settings ); + + $abilities[ $get->get_name() ] = $get; + $abilities[ $update->get_name() ] = $update; + } + + return $abilities; + + } + /** * Appends the resource-list abilities (Forms, Tags, Landing Pages, * Products) to the convertkit_abilities filter, so they are registered diff --git a/tests/Integration/MCPSettingsGeneralTest.php b/tests/Integration/MCPSettingsGeneralTest.php new file mode 100644 index 000000000..abf84942e --- /dev/null +++ b/tests/Integration/MCPSettingsGeneralTest.php @@ -0,0 +1,271 @@ + \ConvertKit_MCP_Ability_Settings_Get::class, + 'kit/settings-general-update' => \ConvertKit_MCP_Ability_Settings_Update::class, + ); + + // Assert that the abilities are registered and are instances of the expected classes. + foreach ( $expected as $name => $class ) { + $this->assertArrayHasKey($name, $abilities); + $this->assertInstanceOf($class, $abilities[ $name ]); + } + } + + /** + * Test that the permission_callback() rejects a user who cannot manage options. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutManageOptionsCapability() + { + // Become a Subscriber (no manage_options capability). + $subscriber_id = static::factory()->user->create([ 'role' => 'subscriber' ]); + wp_set_current_user($subscriber_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that kit/settings-general-get returns the current settings. + * + * @since 3.4.0 + */ + public function testGetSettings() + { + // Populate settings. + $this->populateSettings(); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/settings-general-get']->execute_callback([]); + + // Confirm secret keys are not returned. + $this->assertArrayNotHasKey('access_token', $result); + $this->assertArrayNotHasKey('refresh_token', $result); + $this->assertArrayNotHasKey('token_expires', $result); + $this->assertArrayNotHasKey('api_key', $result); + $this->assertArrayNotHasKey('api_secret', $result); + $this->assertArrayNotHasKey('recaptcha_secret_key', $result); + + // Confirm expected settings are returned. + $this->assertArrayHasKey('non_inline_form', $result); + $this->assertArrayHasKey('non_inline_form_honor_none_setting', $result); + $this->assertArrayHasKey('non_inline_form_limit_per_session', $result); + $this->assertArrayHasKey('recaptcha_site_key', $result); + $this->assertArrayHasKey('recaptcha_minimum_score', $result); + $this->assertArrayHasKey('debug', $result); + $this->assertEquals('on', $result['debug']); + $this->assertArrayHasKey('no_scripts', $result); + $this->assertArrayHasKey('no_css', $result); + $this->assertArrayHasKey('no_add_new_button', $result); + $this->assertArrayHasKey('usage_tracking', $result); + } + + /** + * Test that kit/settings-general-update updates the settings. + * + * @since 3.4.0 + */ + public function testUpdateSettings() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/settings-general-update']->execute_callback( + [ + 'recaptcha_site_key' => '12345', + 'debug' => '', + 'no_scripts' => 'on', + 'no_css' => 'on', + 'no_add_new_button' => 'on', + 'usage_tracking' => 'on', + ] + ); + + // Confirm secret keys are not returned. + $this->assertArrayNotHasKey('access_token', $result); + $this->assertArrayNotHasKey('refresh_token', $result); + $this->assertArrayNotHasKey('token_expires', $result); + $this->assertArrayNotHasKey('api_key', $result); + $this->assertArrayNotHasKey('api_secret', $result); + $this->assertArrayNotHasKey('recaptcha_secret_key', $result); + + // Confirm expected settings are returned. + $this->assertArrayHasKey('non_inline_form', $result); + $this->assertArrayHasKey('non_inline_form_honor_none_setting', $result); + $this->assertArrayHasKey('non_inline_form_limit_per_session', $result); + $this->assertArrayHasKey('recaptcha_site_key', $result); + $this->assertArrayHasKey('recaptcha_minimum_score', $result); + $this->assertArrayHasKey('debug', $result); + $this->assertArrayHasKey('no_scripts', $result); + $this->assertArrayHasKey('no_css', $result); + $this->assertArrayHasKey('no_add_new_button', $result); + $this->assertArrayHasKey('usage_tracking', $result); + + // Confirm settings are updated. + $this->assertEquals('12345', $result['recaptcha_site_key']); + $this->assertEquals('', $result['debug']); + $this->assertEquals('on', $result['no_scripts']); + $this->assertEquals('on', $result['no_css']); + $this->assertEquals('on', $result['no_add_new_button']); + $this->assertEquals('on', $result['usage_tracking']); + } + + /** + * Test that kit/settings-general-update returns an error if an invalid key is provided. + * + * @since 3.4.0 + */ + public function testUpdateSettingsWithInvalidKeyReturnsError() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/settings-general-update']->execute_callback([ 'invalid_key' => 'invalid_value' ]); + } + + /** + * Test that kit/settings-general-update returns an error if a secret key is provided. + * + * @since 3.4.0 + */ + public function testUpdateSettingsWithSecretKeyReturnsError() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/settings-general-update']->execute_callback([ 'access_token' => 'invalid_value' ]); + } + + /** + * Populate the settings with some sensible values for testing. + * + * @since 3.4.0 + */ + private function populateSettings() + { + update_option( + self::SETTINGS_NAME, + [ + 'access_token' => $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'], + 'refresh_token' => $_ENV['CONVERTKIT_OAUTH_REFRESH_TOKEN'], + 'debug' => 'on', + 'no_scripts' => '', + 'no_css' => '', + 'no_add_new_button' => '', + 'usage_tracking' => '', + 'post_form' => $_ENV['CONVERTKIT_API_FORM_ID'], + 'page_form' => $_ENV['CONVERTKIT_API_FORM_ID'], + 'article_form' => $_ENV['CONVERTKIT_API_FORM_ID'], + 'product_form' => $_ENV['CONVERTKIT_API_FORM_ID'], + 'non_inline_form' => array(), + 'non_inline_form_honor_none_setting' => '', + 'recaptcha_site_key' => '', + 'recaptcha_secret_key' => '', + 'recaptcha_minimum_score' => '', + ] + ); + } +} diff --git a/wp-convertkit.php b/wp-convertkit.php index 4a93caff1..0b9c56fbf 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -9,7 +9,7 @@ * Plugin Name: Kit (formerly ConvertKit) * Plugin URI: https://kit.com/ * Description: Display Kit (formerly ConvertKit) email subscription forms, landing pages, products, broadcasts and more. - * Version: 3.4.0 + * Version: 3.3.4 * Author: Kit * Author URI: https://kit.com/ * Text Domain: convertkit @@ -27,7 +27,7 @@ define( 'CONVERTKIT_PLUGIN_FILE', plugin_basename( __FILE__ ) ); define( 'CONVERTKIT_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); define( 'CONVERTKIT_PLUGIN_PATH', __DIR__ ); -define( 'CONVERTKIT_PLUGIN_VERSION', '3.4.0' ); +define( 'CONVERTKIT_PLUGIN_VERSION', '3.3.4' ); define( 'CONVERTKIT_OAUTH_CLIENT_ID', 'HXZlOCj-K5r0ufuWCtyoyo3f688VmMAYSsKg1eGvw0Y' ); define( 'CONVERTKIT_OAUTH_CLIENT_REDIRECT_URI', 'https://app.kit.com/wordpress/redirect' ); @@ -114,6 +114,9 @@ require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-tags.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-landing-pages.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-products.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings-get.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/settings/class-convertkit-mcp-ability-settings-update.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/plugin-sidebars/class-convertkit-plugin-sidebar.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/plugin-sidebars/class-convertkit-plugin-sidebar-post-settings.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/pre-publish-actions/class-convertkit-pre-publish-action.php'; From 338ac441774cafc24ca07593028011cdf98f74ce Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 24 Jun 2026 12:13:34 +0800 Subject: [PATCH 2/3] Abilities API: Plugin Settings: Broadcasts --- ...ss-convertkit-admin-section-broadcasts.php | 4 +- .../class-convertkit-settings-broadcasts.php | 102 ++++++++ includes/mcp/class-convertkit-mcp.php | 1 + .../Integration/MCPSettingsBroadcastsTest.php | 233 ++++++++++++++++++ 4 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 tests/Integration/MCPSettingsBroadcastsTest.php diff --git a/admin/section/class-convertkit-admin-section-broadcasts.php b/admin/section/class-convertkit-admin-section-broadcasts.php index 387c0f7bd..b3e57cbbb 100644 --- a/admin/section/class-convertkit-admin-section-broadcasts.php +++ b/admin/section/class-convertkit-admin-section-broadcasts.php @@ -28,8 +28,8 @@ public function __construct() { $this->settings_key = $this->settings::SETTINGS_NAME; // Define the programmatic name, Title and Tab Text. - $this->name = 'broadcasts'; - $this->title = __( 'Broadcasts', 'convertkit' ); + $this->name = $this->settings->get_name(); + $this->title = $this->settings->get_title(); $this->tab_text = __( 'Broadcasts', 'convertkit' ); // Identify that this is beta functionality. diff --git a/includes/class-convertkit-settings-broadcasts.php b/includes/class-convertkit-settings-broadcasts.php index 9d743a0d2..d0edd87cc 100644 --- a/includes/class-convertkit-settings-broadcasts.php +++ b/includes/class-convertkit-settings-broadcasts.php @@ -190,6 +190,108 @@ public function no_styles() { } + /** + * Returns this settings group's programmatic name. + * + * @since 3.4.0 + * + * @return string + */ + public function get_name() { + + return 'broadcasts'; + + } + + /** + * Returns the title of this settings group. + * + * @since 3.4.0 + * + * @return string + */ + public function get_title() { + + return __( 'Broadcasts Settings', 'convertkit' ); + + } + + /** + * Returns the keys in this settings group that hold credentials or other + * sensitive values. + * + * @since 3.4.0 + * + * @return string[] + */ + public function get_secret_keys() { + + return array(); + + } + + /** + * Returns the JSON Schema describing this settings group, in the shape + * stored by save() / returned by get(), excluding secret keys. + * + * @since 3.4.0 + * + * @return array + */ + public function get_schema() { + + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'enabled' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether importing Broadcasts from Kit to WordPress Posts is enabled.', 'convertkit' ), + ), + 'author_id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'WordPress User ID to assign as the Post author when importing Broadcasts.', 'convertkit' ), + ), + 'post_status' => array( + 'type' => 'string', + 'description' => __( 'WordPress Post status to assign to Posts created from imported Broadcasts (e.g. publish, draft).', 'convertkit' ), + ), + 'category_id' => array( + 'type' => array( 'integer', 'string' ), + 'description' => __( 'WordPress Category ID to assign to Posts created from imported Broadcasts. Blank for none.', 'convertkit' ), + ), + 'import_thumbnail' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether to import the Broadcast thumbnail as the Post\'s Featured Image.', 'convertkit' ), + ), + 'import_images' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether to import images referenced in the Broadcast\'s content into the WordPress Media Library.', 'convertkit' ), + ), + 'published_at_min_date' => array( + 'type' => 'string', + 'format' => 'date', + 'description' => __( 'Earliest published_at date (YYYY-MM-DD) of Broadcasts to import.', 'convertkit' ), + ), + 'enabled_export' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether exporting WordPress Posts to Kit Broadcasts is enabled.', 'convertkit' ), + ), + 'no_styles' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether inline styles on imported Broadcast content should be stripped.', 'convertkit' ), + ), + ), + ); + + } + /** * The default settings, used when the ConvertKit Broadcasts Settings haven't been saved * e.g. on a new installation. diff --git a/includes/mcp/class-convertkit-mcp.php b/includes/mcp/class-convertkit-mcp.php index 5a736a38f..a20e0009a 100644 --- a/includes/mcp/class-convertkit-mcp.php +++ b/includes/mcp/class-convertkit-mcp.php @@ -100,6 +100,7 @@ public function register_settings_abilities( $abilities ) { // Settings instances to register with MCP. $groups = array( new ConvertKit_Settings(), + new ConvertKit_Settings_Broadcasts(), ); // Iterate through settings groups, registering the get and update abilities. diff --git a/tests/Integration/MCPSettingsBroadcastsTest.php b/tests/Integration/MCPSettingsBroadcastsTest.php new file mode 100644 index 000000000..6e17dd885 --- /dev/null +++ b/tests/Integration/MCPSettingsBroadcastsTest.php @@ -0,0 +1,233 @@ + \ConvertKit_MCP_Ability_Settings_Get::class, + 'kit/settings-broadcasts-update' => \ConvertKit_MCP_Ability_Settings_Update::class, + ); + + // Assert that the abilities are registered and are instances of the expected classes. + foreach ( $expected as $name => $class ) { + $this->assertArrayHasKey($name, $abilities); + $this->assertInstanceOf($class, $abilities[ $name ]); + } + } + + /** + * Test that the permission_callback() rejects a user who cannot manage options. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutManageOptionsCapability() + { + // Become a Subscriber (no manage_options capability). + $subscriber_id = static::factory()->user->create([ 'role' => 'subscriber' ]); + wp_set_current_user($subscriber_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that kit/settings-broadcasts-get returns the current settings. + * + * @since 3.4.0 + */ + public function testGetSettings() + { + // Populate settings. + $this->populateSettings(); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/settings-broadcasts-get']->execute_callback([]); + + // Confirm expected settings are returned. + $this->assertArrayHasKey('enabled', $result); + $this->assertEquals('on', $result['enabled']); + $this->assertArrayHasKey('author_id', $result); + $this->assertArrayHasKey('post_status', $result); + $this->assertEquals('draft', $result['post_status']); + $this->assertArrayHasKey('category_id', $result); + $this->assertArrayHasKey('import_thumbnail', $result); + $this->assertArrayHasKey('import_images', $result); + $this->assertArrayHasKey('published_at_min_date', $result); + $this->assertArrayHasKey('enabled_export', $result); + $this->assertArrayHasKey('no_styles', $result); + } + + /** + * Test that kit/settings-broadcasts-update updates the settings. + * + * @since 3.4.0 + */ + public function testUpdateSettings() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/settings-broadcasts-update']->execute_callback( + [ + 'enabled' => 'on', + 'post_status' => 'draft', + 'import_thumbnail' => '', + 'import_images' => 'on', + 'enabled_export' => 'on', + 'no_styles' => 'on', + ] + ); + + // Confirm expected settings are returned. + $this->assertArrayHasKey('enabled', $result); + $this->assertArrayHasKey('author_id', $result); + $this->assertArrayHasKey('post_status', $result); + $this->assertArrayHasKey('category_id', $result); + $this->assertArrayHasKey('import_thumbnail', $result); + $this->assertArrayHasKey('import_images', $result); + $this->assertArrayHasKey('published_at_min_date', $result); + $this->assertArrayHasKey('enabled_export', $result); + $this->assertArrayHasKey('no_styles', $result); + + // Confirm settings are updated. + $this->assertEquals('on', $result['enabled']); + $this->assertEquals('draft', $result['post_status']); + $this->assertEquals('', $result['import_thumbnail']); + $this->assertEquals('on', $result['import_images']); + $this->assertEquals('on', $result['enabled_export']); + $this->assertEquals('on', $result['no_styles']); + } + + /** + * Test that kit/settings-broadcasts-update returns an error if an invalid key is provided. + * + * @since 3.4.0 + */ + public function testUpdateSettingsWithInvalidKeyReturnsError() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/settings-broadcasts-update']->execute_callback([ 'invalid_key' => 'invalid_value' ]); + } + + /** + * Populate the settings with some sensible values for testing. + * + * @since 3.4.0 + */ + private function populateSettings() + { + update_option( + self::SETTINGS_NAME, + [ + 'enabled' => 'on', + 'author_id' => 1, + 'post_status' => 'draft', + 'category_id' => '', + 'import_thumbnail' => 'on', + 'import_images' => '', + 'published_at_min_date' => gmdate( 'Y-m-d', strtotime( '-30 days' ) ), + 'enabled_export' => '', + 'no_styles' => '', + ] + ); + } +} \ No newline at end of file From a57cddf35eb2d31cae65b1985ca228f3f0cd7708 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 24 Jun 2026 12:52:23 +0800 Subject: [PATCH 3/3] Coding standards --- tests/Integration/MCPSettingsBroadcastsTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Integration/MCPSettingsBroadcastsTest.php b/tests/Integration/MCPSettingsBroadcastsTest.php index 6e17dd885..d00089f59 100644 --- a/tests/Integration/MCPSettingsBroadcastsTest.php +++ b/tests/Integration/MCPSettingsBroadcastsTest.php @@ -230,4 +230,4 @@ private function populateSettings() ] ); } -} \ No newline at end of file +}