+ '_wp_convertkit_settings',
+ 'tab' => $this->name,
+ ),
+ $query_args
+ ),
+ admin_url( 'options-general.php' )
+ );
+
+ }
+
+ /**
+ * Finds the UUID of the most recently-created Application Password for the
+ * currently logged in user
+ *
+ * @since 3.4.0
+ *
+ * @return bool|string
+ */
+ private function get_application_password_uuid() {
+
+ // Get the user's Application Passwords.
+ $passwords = WP_Application_Passwords::get_user_application_passwords( get_current_user_id() );
+
+ // Return false if no Application Passwords exist.
+ if ( empty( $passwords ) ) {
+ return false;
+ }
+
+ // Iterate through the Application Passwords and return the password that matches the app name.
+ foreach ( $passwords as $password ) {
+ if ( $password['name'] === CONVERTKIT_MCP_APP_NAME ) {
+ return $password['uuid'];
+ }
+ }
+
+ return false;
+
+ }
+
+}
+
+// Bootstrap.
+add_filter(
+ 'convertkit_admin_settings_register_sections',
+ function ( $sections ) {
+
+ // Don't register the MCP section if the Abilities API is not available (WordPress < 6.9).
+ if ( ! function_exists( 'wp_register_ability' ) ) {
+ return $sections;
+ }
+
+ // Don't register the MCP section if PHP 7.4+ is not installed.
+ if ( version_compare( PHP_VERSION, '7.4', '<' ) ) {
+ return $sections;
+ }
+
+ $sections['mcp'] = new ConvertKit_Admin_Section_MCP();
+ return $sections;
+
+ }
+);
diff --git a/composer.json b/composer.json
index 77993ea04..851ce53ed 100644
--- a/composer.json
+++ b/composer.json
@@ -4,7 +4,8 @@
"type": "project",
"license": "GPLv3",
"require": {
- "convertkit/convertkit-wordpress-libraries": "2.1.7"
+ "convertkit/convertkit-wordpress-libraries": "2.1.7",
+ "wordpress/mcp-adapter": "^0.5.0"
},
"require-dev": {
"php-webdriver/webdriver": "^1.0",
diff --git a/includes/blocks/class-convertkit-block-broadcasts.php b/includes/blocks/class-convertkit-block-broadcasts.php
index d41ba18fb..60db77639 100644
--- a/includes/blocks/class-convertkit-block-broadcasts.php
+++ b/includes/blocks/class-convertkit-block-broadcasts.php
@@ -27,6 +27,9 @@ public function __construct() {
// Register this as a Gutenberg block in the ConvertKit Plugin.
add_filter( 'convertkit_blocks', array( $this, 'register' ) );
+ // Register this block's MCP abilities.
+ add_filter( 'convertkit_abilities', array( $this, 'register_abilities' ) );
+
// Enqueue scripts and styles for this Gutenberg Block in the editor and frontend views.
add_action( 'convertkit_gutenberg_enqueue_scripts_editor_and_frontend', array( $this, 'enqueue_scripts' ) );
add_action( 'convertkit_gutenberg_enqueue_styles_editor_and_frontend', array( $this, 'enqueue_styles' ) );
@@ -171,6 +174,19 @@ public function get_title() {
}
+ /**
+ * Returns this block's plural title.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ public function get_title_plural() {
+
+ return __( 'Kit Broadcasts', 'convertkit' );
+
+ }
+
/**
* Returns this block's icon.
*
diff --git a/includes/blocks/class-convertkit-block-form-trigger.php b/includes/blocks/class-convertkit-block-form-trigger.php
index 4bec4aa50..4acbdd3f4 100644
--- a/includes/blocks/class-convertkit-block-form-trigger.php
+++ b/includes/blocks/class-convertkit-block-form-trigger.php
@@ -27,6 +27,9 @@ public function __construct() {
// Register this as a Gutenberg block in the ConvertKit Plugin.
add_filter( 'convertkit_blocks', array( $this, 'register' ) );
+ // Register this block's MCP abilities.
+ add_filter( 'convertkit_abilities', array( $this, 'register_abilities' ) );
+
// Enqueue scripts and styles for this Gutenberg Block in the editor and frontend views.
add_action( 'convertkit_gutenberg_enqueue_styles_editor_and_frontend', array( $this, 'enqueue_styles' ) );
@@ -73,6 +76,19 @@ public function get_title() {
}
+ /**
+ * Returns this block's plural title.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ public function get_title_plural() {
+
+ return __( 'Kit Form Triggers', 'convertkit' );
+
+ }
+
/**
* Returns this block's icon.
*
diff --git a/includes/blocks/class-convertkit-block-form.php b/includes/blocks/class-convertkit-block-form.php
index f5e121ba8..7a7db6e73 100644
--- a/includes/blocks/class-convertkit-block-form.php
+++ b/includes/blocks/class-convertkit-block-form.php
@@ -27,6 +27,9 @@ public function __construct() {
// Register this as a Gutenberg block in the ConvertKit Plugin.
add_filter( 'convertkit_blocks', array( $this, 'register' ) );
+ // Register this block's MCP abilities.
+ add_filter( 'convertkit_abilities', array( $this, 'register_abilities' ) );
+
// Enqueue scripts for this Gutenberg Block in the editor view.
add_action( 'convertkit_gutenberg_enqueue_scripts', array( $this, 'enqueue_scripts_editor' ) );
@@ -101,6 +104,19 @@ public function get_title() {
}
+ /**
+ * Returns this block's plural title.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ public function get_title_plural() {
+
+ return __( 'Kit Forms', 'convertkit' );
+
+ }
+
/**
* Returns this block's icon.
*
diff --git a/includes/blocks/class-convertkit-block-product.php b/includes/blocks/class-convertkit-block-product.php
index bea8b12a5..aaf522f81 100644
--- a/includes/blocks/class-convertkit-block-product.php
+++ b/includes/blocks/class-convertkit-block-product.php
@@ -27,6 +27,9 @@ public function __construct() {
// Register this as a Gutenberg block in the ConvertKit Plugin.
add_filter( 'convertkit_blocks', array( $this, 'register' ) );
+ // Register this block's MCP abilities.
+ add_filter( 'convertkit_abilities', array( $this, 'register_abilities' ) );
+
// Enqueue scripts and styles for this Gutenberg Block in the editor and frontend views.
add_action( 'convertkit_gutenberg_enqueue_scripts_editor_and_frontend', array( $this, 'enqueue_scripts' ) );
add_action( 'convertkit_gutenberg_enqueue_styles_editor_and_frontend', array( $this, 'enqueue_styles' ) );
@@ -95,6 +98,19 @@ public function get_title() {
}
+ /**
+ * Returns this block's plural title.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ public function get_title_plural() {
+
+ return __( 'Kit Products', 'convertkit' );
+
+ }
+
/**
* Returns this block's icon.
*
diff --git a/includes/blocks/class-convertkit-block.php b/includes/blocks/class-convertkit-block.php
index 12a82447e..35d78d32f 100644
--- a/includes/blocks/class-convertkit-block.php
+++ b/includes/blocks/class-convertkit-block.php
@@ -55,6 +55,28 @@ public function register( $blocks ) {
}
+ /**
+ * Registers this block's MCP abilities.
+ *
+ * @since 3.4.0
+ *
+ * @param array $abilities Abilities to Register.
+ * @return array
+ */
+ public function register_abilities( $abilities ) {
+
+ return array_merge(
+ $abilities,
+ array(
+ 'kit/' . $this->get_name() . '-list' => new ConvertKit_MCP_Ability_Content_List( $this ),
+ 'kit/' . $this->get_name() . '-insert' => new ConvertKit_MCP_Ability_Content_Insert( $this ),
+ 'kit/' . $this->get_name() . '-update' => new ConvertKit_MCP_Ability_Content_Update( $this ),
+ 'kit/' . $this->get_name() . '-delete' => new ConvertKit_MCP_Ability_Content_Delete( $this ),
+ )
+ );
+
+ }
+
/**
* Returns this block's programmatic name, excluding the convertkit- prefix.
*
@@ -83,6 +105,19 @@ public function get_title() {
}
+ /**
+ * Returns this block's plural title.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ public function get_title_plural() {
+
+ return '';
+
+ }
+
/**
* Returns this block's icon.
*
diff --git a/includes/blocks/helpers/class-convertkit-block-post-helper.php b/includes/blocks/helpers/class-convertkit-block-post-helper.php
new file mode 100644
index 000000000..7d25b57b8
--- /dev/null
+++ b/includes/blocks/helpers/class-convertkit-block-post-helper.php
@@ -0,0 +1,310 @@
+post_content );
+ $found = array();
+
+ $occurrence_index = 0;
+
+ foreach ( $blocks as $block ) {
+ if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $block_name ) {
+ continue;
+ }
+
+ $found[] = array(
+ 'occurrence_index' => (int) $occurrence_index,
+ 'attrs' => $block['attrs'],
+ );
+
+ ++$occurrence_index;
+ }
+
+ return $found;
+
+ }
+
+ /**
+ * Inserts a new block into the Post's content at the specified position.
+ *
+ * @since 3.4.0
+ *
+ * @param int $post_id Post ID.
+ * @param string $block_name Programmatic Block Name.
+ * @param array $attrs Block Attributes.
+ * @param string $position One of 'prepend', 'append', 'index'.
+ * @param int $index Zero-based top-level block index; only used when $position is 'index'.
+ * @return WP_Error|array
+ */
+ public static function insert( $post_id, $block_name, $attrs, $position = 'append', $index = 0 ) {
+
+ // If the index is negative, bail.
+ if ( $position === 'index' && (int) $index < 0 ) {
+ return new WP_Error(
+ 'convertkit_block_post_helper_invalid_index',
+ sprintf(
+ /* translators: %d: index */
+ __( 'The supplied index (%d) must be zero or a positive integer.', 'convertkit' ),
+ (int) $index
+ )
+ );
+ }
+
+ // Get Post.
+ $post = get_post( $post_id );
+ if ( ! $post ) {
+ return new WP_Error(
+ 'convertkit_block_post_helper_insert_block_post_not_found',
+ /* translators: %d: Post ID */
+ sprintf( __( 'No Post exists with ID %d.', 'convertkit' ), $post_id )
+ );
+ }
+
+ // Parse blocks.
+ $blocks = parse_blocks( $post->post_content );
+
+ // Build the new block to insert.
+ $new_block = array(
+ 'blockName' => $block_name,
+ 'attrs' => (array) $attrs,
+ 'innerBlocks' => array(),
+ 'innerHTML' => '',
+ 'innerContent' => array(),
+ );
+
+ // Resolve $position into a concrete zero-based splice point in the
+ // top-level block array.
+ switch ( $position ) {
+ case 'prepend':
+ $insert_at = 0;
+ break;
+
+ case 'index':
+ $insert_at = max( 0, min( (int) $index, count( $blocks ) ) );
+ break;
+
+ case 'append':
+ default:
+ $insert_at = count( $blocks );
+ break;
+ }
+
+ // Splice in the new block.
+ array_splice( $blocks, $insert_at, 0, array( $new_block ) );
+
+ // Determine the occurrence index of the newly inserted block, by
+ // counting how many blocks of the same name precede it.
+ $occurrence_index = 0;
+ for ( $i = 0; $i < $insert_at; $i++ ) {
+ if ( isset( $blocks[ $i ]['blockName'] ) && $blocks[ $i ]['blockName'] === $block_name ) {
+ ++$occurrence_index;
+ }
+ }
+
+ // Update Post.
+ $result = wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_content' => serialize_blocks( $blocks ),
+ ),
+ true
+ );
+
+ // Bail if the update failed.
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ // Return the occurrence index of the newly inserted block.
+ return array(
+ 'post_id' => $post_id,
+ 'occurrence_index' => $occurrence_index,
+ );
+
+ }
+
+ /**
+ * Updates the attributes of an existing block in the Post's content.
+ *
+ * @since 3.4.0
+ *
+ * @param int $post_id Post ID.
+ * @param string $block_name Programmatic Block Name.
+ * @param int $occurrence_index Position to update block.
+ * @param array $attrs Block Attributes.
+ * @return WP_Error|array
+ */
+ public static function update( $post_id, $block_name, $occurrence_index, $attrs ) {
+
+ // Get Post.
+ $post = get_post( $post_id );
+ if ( ! $post ) {
+ return new WP_Error(
+ 'convertkit_block_post_helper_update_block_post_not_found',
+ /* translators: %d: post ID */
+ sprintf( __( 'No Post exists with ID %d.', 'convertkit' ), $post_id )
+ );
+ }
+
+ // Parse blocks.
+ $blocks = parse_blocks( $post->post_content );
+ $block_index = 0;
+ $matched = false;
+
+ foreach ( $blocks as $key => $block ) {
+ // Skip if the block name does not match.
+ if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $block_name ) {
+ continue;
+ }
+
+ // Update the block if the occurrence index matches.
+ if ( $block_index === (int) $occurrence_index ) {
+ $blocks[ $key ]['attrs'] = array_merge( (array) $block['attrs'], (array) $attrs );
+ $matched = true;
+ break;
+ }
+
+ ++$block_index;
+ }
+
+ // Bail if the block was not found.
+ if ( ! $matched ) {
+ return new WP_Error(
+ 'convertkit_block_post_helper_occurrence_not_found',
+ /* translators: 1: block name, 2: occurrence index, 3: post ID */
+ sprintf( __( 'No occurrence #%2$d of block %1$s found in post %3$d.', 'convertkit' ), $block_name, (int) $occurrence_index, $post_id )
+ );
+ }
+
+ // Update Post.
+ $result = wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_content' => serialize_blocks( $blocks ),
+ ),
+ true
+ );
+
+ // Bail if the update failed.
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ // Return the occurrence index of the block that was updated.
+ return array(
+ 'post_id' => $post_id,
+ 'occurrence_index' => (int) $occurrence_index,
+ );
+
+ }
+
+ /**
+ * Deletes a specific block from the Post's content.
+ *
+ * @since 3.4.0
+ *
+ * @param int $post_id Post ID.
+ * @param string $block_name Programmatic Block Name.
+ * @param int $occurrence_index Zero-based index among this block's occurrences in the post.
+ * @return WP_Error|array
+ */
+ public static function delete( $post_id, $block_name, $occurrence_index ) {
+
+ // Get Post.
+ $post = get_post( $post_id );
+ if ( ! $post ) {
+ return new WP_Error(
+ 'convertkit_block_post_helper_update_block_post_not_found',
+ /* translators: %d: post ID */
+ sprintf( __( 'No Post exists with ID %d.', 'convertkit' ), $post_id )
+ );
+ }
+
+ // Parse blocks.
+ $blocks = parse_blocks( $post->post_content );
+ $block_index = 0;
+ $matched = false;
+
+ foreach ( $blocks as $key => $block ) {
+ // Skip if the block name does not match.
+ if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $block_name ) {
+ continue;
+ }
+
+ // Delete the block if the occurrence index matches.
+ if ( $block_index === (int) $occurrence_index ) {
+ unset( $blocks[ $key ] );
+ $blocks = array_values( $blocks );
+ $matched = true;
+ break;
+ }
+
+ ++$block_index;
+ }
+
+ // Bail if the block was not found.
+ if ( ! $matched ) {
+ return new WP_Error(
+ 'convertkit_block_post_helper_occurrence_not_found',
+ /* translators: 1: block name, 2: occurrence index, 3: post ID */
+ sprintf( __( 'No occurrence #%2$d of block %1$s found in post %3$d.', 'convertkit' ), $block_name, (int) $occurrence_index, $post_id )
+ );
+ }
+
+ // Update Post.
+ $result = wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_content' => serialize_blocks( $blocks ),
+ ),
+ true
+ );
+
+ // Bail if the update failed.
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ // Return the occurrence index of the block that was deleted.
+ return array(
+ 'post_id' => $post_id,
+ 'occurrence_index' => (int) $occurrence_index,
+ );
+
+ }
+
+}
diff --git a/includes/blocks/helpers/class-convertkit-content-post-helper.php b/includes/blocks/helpers/class-convertkit-content-post-helper.php
new file mode 100644
index 000000000..762ea5edd
--- /dev/null
+++ b/includes/blocks/helpers/class-convertkit-content-post-helper.php
@@ -0,0 +1,307 @@
+post_content ) ? 'block' : 'shortcode';
+
+ }
+
+ /**
+ * Returns the human-readable name of the page builder used to build the
+ * given Post, or false if no supported page builder is detected.
+ *
+ * @since 3.4.0
+ *
+ * @param int $post_id Post ID.
+ * @return string|false
+ */
+ private static function detect_page_builder( $post_id ) {
+
+ // Elementor stores its content in the _elementor_data post meta key,
+ // and flags edited posts via _elementor_edit_mode.
+ if ( 'builder' === get_post_meta( $post_id, '_elementor_edit_mode', true ) ) {
+ return 'Elementor';
+ }
+
+ /**
+ * Filters the detected page builder for a Post.
+ *
+ * Return a non-empty string (the page builder's name) to mark the Post
+ * as built with an unsupported page builder, causing the Content MCP
+ * abilities to return an error rather than writing to post_content.
+ *
+ * @since 3.4.0
+ *
+ * @param string|false $page_builder Detected page builder name, or false.
+ * @param int $post_id Post ID.
+ */
+ return apply_filters( 'convertkit_content_post_helper_detect_page_builder', false, $post_id );
+
+ }
+
+ /**
+ * Returns a WP_Error for an unrecognised content mechanism. Acts as a
+ * defensive fallback; detect_mechanism() should only ever return a known
+ * mechanism or a WP_Error.
+ *
+ * @since 3.4.0
+ *
+ * @param string $mechanism The unrecognised mechanism.
+ * @return WP_Error
+ */
+ private static function unsupported_mechanism_error( $mechanism ) {
+
+ return new WP_Error(
+ 'convertkit_content_post_helper_unsupported_mechanism',
+ sprintf(
+ /* translators: %s: mechanism identifier */
+ __( 'Unsupported content mechanism: %s.', 'convertkit' ),
+ $mechanism
+ )
+ );
+
+ }
+
+}
diff --git a/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php b/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php
new file mode 100644
index 000000000..f13fddce9
--- /dev/null
+++ b/includes/blocks/helpers/class-convertkit-shortcode-post-helper.php
@@ -0,0 +1,557 @@
+post_content, $shortcode_tag );
+ $found = array();
+
+ foreach ( $matches as $occurrence_index => $match ) {
+ $found[] = array(
+ // Zero-based index of this occurrence among occurrences of
+ // this shortcode in the post.
+ 'occurrence_index' => (int) $occurrence_index,
+ 'attrs' => self::parse_attrs( $match ),
+ );
+ }
+
+ // If no shortcodes found, return false.
+ if ( empty( $found ) ) {
+ return false;
+ }
+
+ return $found;
+
+ }
+
+ /**
+ * Inserts a new shortcode into the Post's content at the specified
+ * position.
+ *
+ * @since 3.4.0
+ *
+ * @param int $post_id Post ID.
+ * @param string $shortcode_tag Programmatic Shortcode Tag.
+ * @param array $attrs Shortcode Attributes.
+ * @param string $position One of 'prepend', 'append', 'index'.
+ * @param int $index Zero-based top-level element index; only used when $position is 'index'.
+ * @return WP_Error|array
+ */
+ public static function insert( $post_id, $shortcode_tag, $attrs, $position = 'append', $index = 0 ) {
+
+ // If the index is negative, bail.
+ if ( $position === 'index' && (int) $index < 0 ) {
+ return new WP_Error(
+ 'convertkit_shortcode_post_helper_invalid_index',
+ sprintf(
+ /* translators: %d: index */
+ __( 'The supplied index (%d) must be zero or a positive integer.', 'convertkit' ),
+ (int) $index
+ )
+ );
+ }
+
+ // Get Post.
+ $post = get_post( $post_id );
+ if ( ! $post ) {
+ return new WP_Error(
+ 'convertkit_shortcode_post_helper_insert_post_not_found',
+ /* translators: %d: post ID */
+ sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id )
+ );
+ }
+
+ // Build the shortcode string to insert.
+ $shortcode = self::build_shortcode( $shortcode_tag, $attrs );
+ $content = $post->post_content;
+
+ // Determine the byte offset of the start of each top-level element.
+ $starts = self::get_element_starts( $content );
+
+ // Resolve $position into a concrete byte offset within the content.
+ switch ( $position ) {
+ case 'prepend':
+ $insert_at = 0;
+ break;
+
+ case 'index':
+ // Insert before the Nth top-level element. If no elements
+ // exist, or the index is equal to / beyond count(), append
+ // after all existing content — mirroring how array_splice()
+ // treats an index equal to the array length.
+ if ( empty( $starts ) || (int) $index >= count( $starts ) ) {
+ $insert_at = strlen( $content );
+ } else {
+ $insert_at = $starts[ (int) $index ];
+ }
+ break;
+
+ case 'append':
+ default:
+ $insert_at = strlen( $content );
+ break;
+ }
+
+ // Determine the occurrence index the new shortcode will have, by
+ // counting how many existing occurrences of the same shortcode start
+ // before the insertion offset.
+ $occurrence_index = 0;
+ foreach ( self::match_shortcodes( $content, $shortcode_tag ) as $match ) {
+ if ( $match['offset'] < $insert_at ) {
+ ++$occurrence_index;
+ }
+ }
+
+ // Splice the shortcode into the content at the resolved offset,
+ // wrapped in blank lines so it sits as its own top-level element.
+ // All other content is left byte-for-byte unchanged.
+ $snippet = self::pad_snippet( $shortcode, $content, $insert_at );
+ $content = substr_replace( $content, $snippet, $insert_at, 0 );
+
+ // Update Post.
+ $result = wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_content' => $content,
+ ),
+ true
+ );
+
+ // Bail if the update failed.
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ // Return the occurrence index of the newly inserted shortcode.
+ return array(
+ 'post_id' => $post_id,
+ 'occurrence_index' => $occurrence_index,
+ );
+
+ }
+
+ /**
+ * Updates the attributes of an existing shortcode in the Post's content.
+ *
+ * @since 3.4.0
+ *
+ * @param int $post_id Post ID.
+ * @param string $shortcode_tag Programmatic Shortcode Tag.
+ * @param int $occurrence_index Zero-based occurrence index to update.
+ * @param array $attrs Shortcode Attributes.
+ * @return WP_Error|array
+ */
+ public static function update( $post_id, $shortcode_tag, $occurrence_index, $attrs ) {
+
+ // Get Post.
+ $post = get_post( $post_id );
+ if ( ! $post ) {
+ return new WP_Error(
+ 'convertkit_shortcode_post_helper_update_post_not_found',
+ /* translators: %d: post ID */
+ sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id )
+ );
+ }
+
+ // Match all occurrences of the shortcode.
+ $matches = self::match_shortcodes( $post->post_content, $shortcode_tag );
+
+ // Bail if the requested occurrence does not exist.
+ if ( ! isset( $matches[ (int) $occurrence_index ] ) ) {
+ return new WP_Error(
+ 'convertkit_shortcode_post_helper_occurrence_not_found',
+ sprintf(
+ /* translators: 1: shortcode tag, 2: occurrence index, 3: post ID */
+ __( 'No occurrence #%2$d of shortcode %1$s found in post %3$d.', 'convertkit' ),
+ $shortcode_tag,
+ (int) $occurrence_index,
+ $post_id
+ )
+ );
+ }
+
+ // Build the replacement shortcode, merging new attributes over existing.
+ $match = $matches[ (int) $occurrence_index ];
+ $merged_attrs = array_merge( self::parse_attrs( $match ), (array) $attrs );
+ $replacement = self::build_shortcode( $shortcode_tag, $merged_attrs );
+
+ // Replace the matched shortcode text with the rebuilt shortcode.
+ $content = self::replace_match( $post->post_content, $match, $replacement );
+
+ // Update Post.
+ $result = wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_content' => $content,
+ ),
+ true
+ );
+
+ // Bail if the update failed.
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ // Return the occurrence index that was updated.
+ return array(
+ 'post_id' => $post_id,
+ 'occurrence_index' => (int) $occurrence_index,
+ );
+
+ }
+
+ /**
+ * Deletes a specific shortcode from the Post's content.
+ *
+ * @since 3.4.0
+ *
+ * @param int $post_id Post ID.
+ * @param string $shortcode_tag Programmatic Shortcode Tag.
+ * @param int $occurrence_index Zero-based occurrence index to delete.
+ * @return WP_Error|array
+ */
+ public static function delete( $post_id, $shortcode_tag, $occurrence_index ) {
+
+ // Get Post.
+ $post = get_post( $post_id );
+ if ( ! $post ) {
+ return new WP_Error(
+ 'convertkit_shortcode_post_helper_delete_post_not_found',
+ /* translators: %d: post ID */
+ sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id )
+ );
+ }
+
+ // Match all occurrences of the shortcode.
+ $matches = self::match_shortcodes( $post->post_content, $shortcode_tag );
+
+ // Bail if the requested occurrence does not exist.
+ if ( ! isset( $matches[ (int) $occurrence_index ] ) ) {
+ return new WP_Error(
+ 'convertkit_shortcode_post_helper_occurrence_not_found',
+ sprintf(
+ /* translators: 1: shortcode tag, 2: occurrence index, 3: post ID */
+ __( 'No occurrence #%2$d of shortcode %1$s found in post %3$d.', 'convertkit' ),
+ $shortcode_tag,
+ (int) $occurrence_index,
+ $post_id
+ )
+ );
+ }
+
+ // Remove the matched shortcode text from the content.
+ $content = self::replace_match( $post->post_content, $matches[ (int) $occurrence_index ], '' );
+
+ // Update Post.
+ $result = wp_update_post(
+ array(
+ 'ID' => $post_id,
+ 'post_content' => $content,
+ ),
+ true
+ );
+
+ // Bail if the update failed.
+ if ( is_wp_error( $result ) ) {
+ return $result;
+ }
+
+ // Return the occurrence index that was deleted.
+ return array(
+ 'post_id' => $post_id,
+ 'occurrence_index' => (int) $occurrence_index,
+ );
+
+ }
+
+ /**
+ * Returns all matches of the given shortcode tag within the content, in
+ * document order.
+ *
+ * Each match is an array of:
+ * - 'text' The full matched shortcode string (e.g. `[convertkit_form form="1"]`).
+ * - 'offset' Its byte offset within the content.
+ * - 'atts' The raw attribute string only (e.g. `form="1"`), suitable for
+ * passing directly to shortcode_parse_atts().
+ *
+ * @since 3.4.0
+ *
+ * @param string $content Post content.
+ * @param string $shortcode_tag Programmatic Shortcode Tag.
+ * @return array
+ */
+ private static function match_shortcodes( $content, $shortcode_tag ) {
+
+ // Build a shortcode regex scoped to this single tag.
+ $pattern = get_shortcode_regex( array( $shortcode_tag ) );
+
+ // Bail if there are no matches.
+ if ( ! preg_match_all( '/' . $pattern . '/', $content, $matches, PREG_OFFSET_CAPTURE ) ) {
+ return array();
+ }
+
+ // Build array of shortcode matches.
+ $found = array();
+ foreach ( $matches[0] as $i => $match ) {
+ $found[] = array(
+ 'text' => $match[0],
+ 'offset' => (int) $match[1],
+ 'atts' => isset( $matches[3][ $i ][0] ) ? trim( (string) $matches[3][ $i ][0] ) : '',
+ );
+ }
+
+ return $found;
+
+ }
+
+ /**
+ * Parses the attributes of a single matched shortcode into a key/value
+ * array.
+ *
+ * @since 3.4.0
+ *
+ * @param array $shortcode A match from match_shortcodes().
+ * @return array
+ */
+ private static function parse_attrs( $shortcode ) {
+
+ // Parse the raw attribute string (e.g. `form="1"`). shortcode_parse_atts()
+ // expects only the attributes, without the surrounding brackets or tag name.
+ $attrs = shortcode_parse_atts( $shortcode['atts'] );
+
+ // Discard any positional (non-string keyed) attributes, keeping only
+ // named attributes.
+ foreach ( array_keys( $attrs ) as $key ) {
+ if ( ! is_string( $key ) ) {
+ unset( $attrs[ $key ] );
+ }
+ }
+
+ return $attrs;
+
+ }
+
+ /**
+ * Builds a self-closing shortcode string from a tag and attributes.
+ *
+ * @since 3.4.0
+ *
+ * @param string $shortcode_tag Programmatic Shortcode Tag.
+ * @param array $attrs Shortcode Attributes.
+ * @return string
+ */
+ private static function build_shortcode( $shortcode_tag, $attrs ) {
+
+ $shortcode = '[' . $shortcode_tag;
+
+ foreach ( (array) $attrs as $key => $value ) {
+ // Skip empty attribute names.
+ if ( ! is_string( $key ) || '' === $key ) {
+ continue;
+ }
+
+ $shortcode .= sprintf( ' %s="%s"', $key, esc_attr( (string) $value ) );
+ }
+
+ $shortcode .= ']';
+
+ return $shortcode;
+
+ }
+
+ /**
+ * Replaces a single matched shortcode occurrence with the replacement
+ * string.
+ *
+ * @since 3.4.0
+ *
+ * @param string $content Post content.
+ * @param array $atts A match from match_shortcodes().
+ * @param string $replacement Replacement string (empty string to delete).
+ * @return string
+ */
+ private static function replace_match( $content, $atts, $replacement ) {
+
+ return substr_replace(
+ $content,
+ $replacement,
+ $atts['offset'],
+ strlen( $atts['text'] )
+ );
+
+ }
+
+ /**
+ * Wraps a shortcode snippet in blank-line padding so that, once inserted
+ * at the given offset, it sits as its own top-level element.
+ *
+ * @since 3.4.0
+ *
+ * @param string $shortcode The shortcode string to insert.
+ * @param string $content The content the shortcode is being inserted into.
+ * @param int $offset Byte offset within $content the shortcode will be inserted at.
+ * @return string
+ */
+ private static function pad_snippet( $shortcode, $content, $offset ) {
+
+ // Determine the text immediately before and after the insertion point.
+ $before = substr( $content, 0, $offset );
+ $after = substr( $content, $offset );
+
+ // Add a leading blank line unless the shortcode is at the start of the
+ // content, or already preceded by a blank line.
+ $lead = ( $before === '' || preg_match( '/\R\R\s*$/', $before ) ) ? '' : "\n\n";
+
+ // Add a trailing blank line unless the shortcode is at the end of the
+ // content, or already followed by a blank line.
+ $trail = ( $after === '' || preg_match( '/^\s*\R\R/', $after ) ) ? '' : "\n\n";
+
+ return $lead . $shortcode . $trail;
+
+ }
+
+ /**
+ * Returns the byte offset of the start of each top-level element in the
+ * content, in document order.
+ *
+ * Uses WP_HTML_Tag_Processor (WP 6.2+) for nesting-aware structure, paired
+ * with a regex for the byte offsets the tag processor does not expose.
+ * Falls back to regex alone on older WordPress versions.
+ *
+ * @since 3.4.0
+ *
+ * @param string $content Post content.
+ * @return array
+ */
+ private static function get_element_starts( $content ) {
+
+ if ( trim( (string) $content ) === '' ) {
+ return array();
+ }
+
+ // Candidate offsets, one per regex-matched element-level opener.
+ $pattern = '/<(' . self::ELEMENT_LEVEL_TAGS . ')\b[^>]*>.*?<\/\1>/is';
+ if ( ! preg_match_all( $pattern, $content, $matches, PREG_OFFSET_CAPTURE ) ) {
+ return array();
+ }
+
+ // Fallback for WP < 6.2: regex offsets verbatim, no nesting awareness.
+ if ( ! class_exists( 'WP_HTML_Tag_Processor' ) ) {
+ $starts = array();
+ foreach ( $matches[0] as $match ) {
+ $starts[] = (int) $match[1];
+ }
+ return $starts;
+ }
+
+ // Per-tag queue of regex offsets in document order.
+ $queues = array();
+ foreach ( $matches[1] as $i => $tag_match ) {
+ $queues[ strtoupper( $tag_match[0] ) ][] = (int) $matches[0][ $i ][1];
+ }
+
+ // Walk with depth tracking; record offsets only for depth-zero openers.
+ $processor = new WP_HTML_Tag_Processor( $content );
+ $starts = array();
+ $depth = 0;
+ $element_level_tags = array_flip( explode( '|', strtoupper( self::ELEMENT_LEVEL_TAGS ) ) );
+
+ while ( $processor->next_tag( array( 'tag_closers' => 'visit' ) ) ) {
+ $tag = $processor->get_tag();
+
+ if ( ! isset( $element_level_tags[ $tag ] ) ) {
+ continue;
+ }
+
+ if ( $processor->is_tag_closer() ) {
+ if ( $depth > 0 ) {
+ --$depth;
+ }
+ continue;
+ }
+
+ $offset = array_shift( $queues[ $tag ] );
+
+ if ( $depth === 0 ) {
+ $starts[] = $offset;
+ }
+
+ if ( $tag !== 'HR' ) {
+ ++$depth;
+ }
+ }
+
+ // Treat blank line separated text as paragraphs, matching the logic in wpautop().
+ $opener_prefix = '/^<(?:' . self::ELEMENT_LEVEL_TAGS . ')\b/i';
+ $offset = 0;
+ foreach ( preg_split( '/(\R\R+)/', $content, -1, PREG_SPLIT_DELIM_CAPTURE ) as $i => $chunk ) {
+ // Odd indices are the delimiters captured by PREG_SPLIT_DELIM_CAPTURE.
+ if ( $i % 2 === 1 ) {
+ $offset += strlen( $chunk );
+ continue;
+ }
+
+ $trimmed = trim( $chunk );
+ if ( $trimmed !== '' && ! preg_match( $opener_prefix, $trimmed ) ) {
+ $starts[] = $offset + ( strlen( $chunk ) - strlen( ltrim( $chunk ) ) );
+ }
+
+ $offset += strlen( $chunk );
+ }
+
+ sort( $starts, SORT_NUMERIC );
+
+ return $starts;
+
+ }
+
+}
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/class-convertkit-settings-mcp.php b/includes/class-convertkit-settings-mcp.php
new file mode 100644
index 000000000..9e58b50f1
--- /dev/null
+++ b/includes/class-convertkit-settings-mcp.php
@@ -0,0 +1,121 @@
+settings = $this->get_defaults();
+ } else {
+ $this->settings = array_merge( $this->get_defaults(), $settings );
+ }
+
+ }
+
+ /**
+ * Returns Plugin settings.
+ *
+ * @since 3.4.0
+ *
+ * @return array
+ */
+ public function get() {
+
+ return $this->settings;
+
+ }
+
+ /**
+ * Returns whether the MCP server is enabled.
+ *
+ * @since 3.4.0
+ *
+ * @return bool
+ */
+ public function enabled() {
+
+ return ( $this->settings['enabled'] === 'on' ? true : false );
+
+ }
+
+ /**
+ * The default settings, used when the ConvertKit MCP Settings haven't been saved
+ * e.g. on a new installation.
+ *
+ * @since 2.1.0
+ *
+ * @return array
+ */
+ public function get_defaults() {
+
+ $defaults = array(
+ 'enabled' => '', // blank|on.
+ );
+
+ /**
+ * The default settings, used when the ConvertKit MCP Settings haven't been saved
+ * e.g. on a new installation.
+ *
+ * @since 3.4.0
+ *
+ * @param array $defaults Default settings.
+ */
+ $defaults = apply_filters( 'convertkit_settings_mcp_get_defaults', $defaults );
+
+ return $defaults;
+
+ }
+
+ /**
+ * Saves the given array of settings to the WordPress options table.
+ *
+ * @since 3.4.0
+ *
+ * @param array $settings Settings.
+ */
+ public function save( $settings ) {
+
+ update_option( self::SETTINGS_NAME, array_merge( $this->get(), $settings ) );
+
+ }
+
+}
diff --git a/includes/class-convertkit-settings-restrict-content.php b/includes/class-convertkit-settings-restrict-content.php
index 2330d8a4d..8ae12ed89 100644
--- a/includes/class-convertkit-settings-restrict-content.php
+++ b/includes/class-convertkit-settings-restrict-content.php
@@ -102,6 +102,138 @@ public function get_by_key( $key ) {
}
+ /**
+ * Returns this settings group's programmatic name.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ public function get_name() {
+
+ return 'restrict-content';
+
+ }
+
+ /**
+ * Returns the title of this settings group.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ public function get_title() {
+
+ return __( 'Member Content Settings', 'convertkit' );
+
+ }
+
+ /**
+ * Returns the keys in this settings group that hold credentials or other
+ * sensitive values.
+ *
+ * Member Content settings hold no secrets; returned for interface
+ * consistency.
+ *
+ * @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(
+ 'permit_crawlers' => array(
+ 'type' => 'string',
+ 'enum' => array( '', 'on' ),
+ 'description' => __( 'Whether search engine crawlers are permitted to index Member Content.', 'convertkit' ),
+ ),
+ 'no_access_text_form' => array(
+ 'type' => 'string',
+ 'description' => __( 'Message shown to a visitor without access when content is restricted by Form.', 'convertkit' ),
+ ),
+ 'subscribe_heading' => array(
+ 'type' => 'string',
+ 'description' => __( 'Heading shown above the subscribe call-to-action when content is restricted by Product.', 'convertkit' ),
+ ),
+ 'subscribe_text' => array(
+ 'type' => 'string',
+ 'description' => __( 'Body text shown alongside the subscribe call-to-action when content is restricted by Product.', 'convertkit' ),
+ ),
+ 'no_access_text' => array(
+ 'type' => 'string',
+ 'description' => __( 'Message shown to a visitor without access when content is restricted by Product.', 'convertkit' ),
+ ),
+ 'subscribe_heading_tag' => array(
+ 'type' => 'string',
+ 'description' => __( 'Heading shown above the subscribe call-to-action when content is restricted by Tag.', 'convertkit' ),
+ ),
+ 'subscribe_text_tag' => array(
+ 'type' => 'string',
+ 'description' => __( 'Body text shown alongside the subscribe call-to-action when content is restricted by Tag.', 'convertkit' ),
+ ),
+ 'require_tag_login' => array(
+ 'type' => 'string',
+ 'enum' => array( '', 'on' ),
+ 'description' => __( 'Whether visitors must log in by email to access Member Content restricted by Tag.', 'convertkit' ),
+ ),
+ 'no_access_text_tag' => array(
+ 'type' => 'string',
+ 'description' => __( 'Message shown to a visitor without access when content is restricted by Tag.', 'convertkit' ),
+ ),
+ 'subscribe_button_label' => array(
+ 'type' => 'string',
+ 'description' => __( 'Label for the Subscribe button.', 'convertkit' ),
+ ),
+ 'email_text' => array(
+ 'type' => 'string',
+ 'description' => __( 'Body text shown above the email log-in form.', 'convertkit' ),
+ ),
+ 'email_button_label' => array(
+ 'type' => 'string',
+ 'description' => __( 'Label for the email log-in button.', 'convertkit' ),
+ ),
+ 'email_heading' => array(
+ 'type' => 'string',
+ 'description' => __( 'Heading shown above the email log-in form.', 'convertkit' ),
+ ),
+ 'email_description_text' => array(
+ 'type' => 'string',
+ 'description' => __( 'Description shown beneath the email log-in heading.', 'convertkit' ),
+ ),
+ 'email_check_heading' => array(
+ 'type' => 'string',
+ 'description' => __( 'Heading shown after the visitor requests a magic log-in code.', 'convertkit' ),
+ ),
+ 'email_check_text' => array(
+ 'type' => 'string',
+ 'description' => __( 'Body text shown after the visitor requests a magic log-in code.', 'convertkit' ),
+ ),
+ 'container_css_classes' => array(
+ 'type' => 'string',
+ 'description' => __( 'Additional CSS classes appended to the Restrict Content container element.', 'convertkit' ),
+ ),
+ ),
+ );
+
+ }
+
/**
* The default settings, used when the ConvertKit Restrict Content Settings haven't been saved
* e.g. on a new installation.
diff --git a/includes/class-convertkit-settings.php b/includes/class-convertkit-settings.php
index 13c5234b2..3df8953c6 100644
--- a/includes/class-convertkit-settings.php
+++ b/includes/class-convertkit-settings.php
@@ -568,6 +568,170 @@ 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() {
+
+ $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' ),
+ ),
+ );
+
+ // Per-post-type Default Form settings, mirroring get_defaults().
+ foreach ( convertkit_get_supported_post_types() as $post_type ) {
+ $properties[ $post_type . '_form' ] = array(
+ 'type' => 'integer',
+ 'minimum' => -1,
+ 'description' => sprintf(
+ /* translators: Post type slug. */
+ __( 'Default Form ID to display on %s. `-1` = Plugin Default; `0` = None; positive integer = specific Kit Form ID.', 'convertkit' ),
+ $post_type
+ ),
+ );
+
+ $properties[ $post_type . '_form_position' ] = array(
+ 'type' => 'string',
+ 'enum' => array( 'before_content', 'after_content', 'before_after_content', 'after_element' ),
+ 'description' => sprintf(
+ /* translators: Post type slug. */
+ __( 'Where the Default Form displays relative to the %s content.', 'convertkit' ),
+ $post_type
+ ),
+ );
+
+ $properties[ $post_type . '_form_position_element' ] = array(
+ 'type' => 'string',
+ 'enum' => array( 'p', 'h2', 'h3', 'h4', 'h5', 'h6', 'img' ),
+ 'description' => sprintf(
+ /* translators: 1: Post type slug, 2: Post type slug. */
+ __( 'HTML element after which to display the Default Form on %1$s. Only used when `%2$s_form_position` is `after_element`.', 'convertkit' ),
+ $post_type,
+ $post_type
+ ),
+ );
+
+ $properties[ $post_type . '_form_position_element_index' ] = array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ 'maximum' => 999,
+ 'description' => sprintf(
+ /* translators: 1: Post type slug, 2: Post type slug. */
+ __( 'Nth occurrence of the element after which to display the Default Form on %1$s. Only used when `%2$s_form_position` is `after_element`.', 'convertkit' ),
+ $post_type,
+ $post_type
+ ),
+ );
+ }
+
+ return array(
+ 'type' => 'object',
+ 'additionalProperties' => false,
+ 'properties' => $properties,
+ );
+
+ }
+
/**
* The default settings, used when the ConvertKit Plugin Settings haven't been saved
* e.g. on a new installation.
diff --git a/includes/class-wp-convertkit.php b/includes/class-wp-convertkit.php
index 371ab59d0..47de6d700 100644
--- a/includes/class-wp-convertkit.php
+++ b/includes/class-wp-convertkit.php
@@ -64,6 +64,7 @@ public function initialize() {
$this->initialize_cli_cron();
$this->initialize_frontend();
$this->initialize_global();
+ $this->initialize_mcp();
}
@@ -202,13 +203,14 @@ private function initialize_global() {
$this->classes['broadcasts_importer'] = new ConvertKit_Broadcasts_Importer();
$this->classes['elementor'] = new ConvertKit_Elementor();
$this->classes['gutenberg'] = new ConvertKit_Gutenberg();
- $this->classes['media_library'] = new ConvertKit_Media_Library();
- $this->classes['output_restrict_content'] = new ConvertKit_Output_Restrict_Content();
- $this->classes['restrict_content_cache'] = new ConvertKit_Restrict_Content_Cache();
- $this->classes['review_request'] = new ConvertKit_Review_Request( 'Kit', 'convertkit', CONVERTKIT_PLUGIN_PATH );
- $this->classes['preview_output'] = new ConvertKit_Preview_Output();
- $this->classes['setup'] = new ConvertKit_Setup();
- $this->classes['shortcodes'] = new ConvertKit_Shortcodes();
+ $this->classes['mcp'] = new ConvertKit_MCP();
+ $this->classes['media_library'] = new ConvertKit_Media_Library();
+ $this->classes['output_restrict_content'] = new ConvertKit_Output_Restrict_Content();
+ $this->classes['restrict_content_cache'] = new ConvertKit_Restrict_Content_Cache();
+ $this->classes['review_request'] = new ConvertKit_Review_Request( 'Kit', 'convertkit', CONVERTKIT_PLUGIN_PATH );
+ $this->classes['preview_output'] = new ConvertKit_Preview_Output();
+ $this->classes['setup'] = new ConvertKit_Setup();
+ $this->classes['shortcodes'] = new ConvertKit_Shortcodes();
/**
* Initialize integration classes for the frontend web site.
@@ -219,6 +221,49 @@ private function initialize_global() {
}
+ /**
+ * Initializes the MCP server if enabled in the Plugin's settings.
+ *
+ * @since 3.4.0
+ */
+ public function initialize_mcp() {
+
+ // Bail if the MCP server is not enabled.
+ $settings = new ConvertKit_Settings_MCP();
+ if ( ! $settings->enabled() ) {
+ return;
+ }
+
+ // Bail if the Abilities API is unavailable (WordPress < 6.9).
+ if ( ! function_exists( 'wp_register_ability' ) ) {
+ return;
+ }
+
+ // Bail if PHP 7.4+ is not installed, as this is required for the MCP Adapter classes.
+ if ( version_compare( PHP_VERSION, '7.4', '<' ) ) {
+ return;
+ }
+
+ // Bail if the WordPress MCP Adapter autoloader is missing.
+ if ( ! file_exists( CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php' ) ) {
+ return;
+ }
+
+ // Load MCP Adapter.
+ require_once CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php';
+
+ // Bail if the MCP Adapter class doesn't exist - something went wrong with the autoloader.
+ if ( ! class_exists( 'WP\\MCP\\Core\\McpAdapter' ) ) {
+ return;
+ }
+
+ // Bootstrap the MCP Adapter, per WordPress/mcp-adapter's recommended
+ // integration pattern.
+ // @see https://github.com/WordPress/mcp-adapter#using-mcp-adapter-in-your-plugin.
+ \WP\MCP\Core\McpAdapter::instance();
+
+ }
+
/**
* Runs the Plugin's initialization and update routines, which checks if
* the Plugin has just been updated to a newer version,
diff --git a/includes/functions.php b/includes/functions.php
index 42037ad75..590c340f1 100644
--- a/includes/functions.php
+++ b/includes/functions.php
@@ -307,6 +307,30 @@ function convertkit_get_form_importers() {
}
+/**
+ * Helper method to get registered abilities.
+ *
+ * @since 3.4.0
+ *
+ * @return array Abilities.
+ */
+function convertkit_get_abilities() {
+
+ $abilities = array();
+
+ /**
+ * Registers abilities for the Kit Plugin.
+ *
+ * @since 3.4.0
+ *
+ * @param array $abilities Abilities.
+ */
+ $abilities = apply_filters( 'convertkit_abilities', $abilities );
+
+ return $abilities;
+
+}
+
/**
* Helper method to return the Plugin Settings Link
*
diff --git a/includes/mcp/abilities/category-settings/class-convertkit-mcp-ability-category-settings-get.php b/includes/mcp/abilities/category-settings/class-convertkit-mcp-ability-category-settings-get.php
new file mode 100644
index 000000000..5fc4791bb
--- /dev/null
+++ b/includes/mcp/abilities/category-settings/class-convertkit-mcp-ability-category-settings-get.php
@@ -0,0 +1,135 @@
+ 'object',
+ 'required' => array( 'term_id' ),
+ 'properties' => array(
+ 'term_id' => array(
+ 'type' => 'integer',
+ 'description' => __( 'The Category (term) ID to read Kit settings for.', 'convertkit' ),
+ 'minimum' => 1,
+ ),
+ ),
+ );
+
+ }
+
+ /**
+ * Executes the ability.
+ *
+ * @since 3.4.0
+ *
+ * @param array $input Ability input.
+ * @return array|WP_Error
+ */
+ public function execute_callback( $input ) {
+
+ $term_id = isset( $input['term_id'] ) ? absint( $input['term_id'] ) : 0;
+
+ // Bail if the term does not exist or is not a Category.
+ $valid = $this->validate_term( $term_id );
+ if ( is_wp_error( $valid ) ) {
+ return $valid;
+ }
+
+ // Load the Category's settings.
+ $term_settings = new ConvertKit_Term( $term_id );
+ $settings = $term_settings->get();
+
+ // Cast `form` to int and `form_position` to string so the output
+ // exactly matches the declared schema, regardless of how the value
+ // was stored (defaults may be '' for form, but the schema wants int).
+ $form = isset( $settings['form'] ) && $settings['form'] !== '' ? (int) $settings['form'] : 0;
+ $form_position = isset( $settings['form_position'] ) ? (string) $settings['form_position'] : '';
+
+ return array(
+ 'term_id' => $term_id,
+ 'form' => $form,
+ 'form_position' => $form_position,
+ );
+
+ }
+
+}
diff --git a/includes/mcp/abilities/category-settings/class-convertkit-mcp-ability-category-settings-update.php b/includes/mcp/abilities/category-settings/class-convertkit-mcp-ability-category-settings-update.php
new file mode 100644
index 000000000..71341d60e
--- /dev/null
+++ b/includes/mcp/abilities/category-settings/class-convertkit-mcp-ability-category-settings-update.php
@@ -0,0 +1,163 @@
+ 'object',
+ 'required' => array( 'term_id' ),
+ 'properties' => array_merge(
+ array(
+ 'term_id' => array(
+ 'type' => 'integer',
+ 'description' => __( 'The Category (term) ID to update Kit settings for.', 'convertkit' ),
+ 'minimum' => 1,
+ ),
+ ),
+ $this->get_settings_schema_properties()
+ ),
+ );
+
+ }
+
+ /**
+ * Executes the ability.
+ *
+ * @since 3.4.0
+ *
+ * @param array $input Ability input.
+ * @return array|WP_Error
+ */
+ public function execute_callback( $input ) {
+
+ $term_id = isset( $input['term_id'] ) ? absint( $input['term_id'] ) : 0;
+
+ // Bail if the term does not exist or is not a Category.
+ $valid = $this->validate_term( $term_id );
+ if ( is_wp_error( $valid ) ) {
+ return $valid;
+ }
+
+ // Reject unknown keys.
+ $properties = $this->get_settings_schema_properties();
+ $allowed_keys = array_merge( array( 'term_id' ), array_keys( $properties ) );
+ $unknown_keys = array_diff( array_keys( $input ), $allowed_keys );
+ if ( ! empty( $unknown_keys ) ) {
+ return new WP_Error(
+ 'convertkit_mcp_category_settings_unknown_keys',
+ sprintf(
+ /* translators: %s: Comma-separated list of unknown keys. */
+ __( 'The following settings keys are not recognised: %s.', 'convertkit' ),
+ implode( ', ', $unknown_keys )
+ )
+ );
+ }
+
+ // Validate each provided setting against its declared schema.
+ $validated = array();
+ foreach ( $properties as $key => $property_schema ) {
+ if ( ! array_key_exists( $key, $input ) ) {
+ continue;
+ }
+
+ $valid = rest_validate_value_from_schema( $input[ $key ], $property_schema, $key );
+
+ // Bail if the value is invalid.
+ if ( is_wp_error( $valid ) ) {
+ return $valid;
+ }
+
+ $validated[ $key ] = rest_sanitize_value_from_schema( $input[ $key ], $property_schema, $key );
+ }
+
+ // Bail if no settings were provided.
+ if ( empty( $validated ) ) {
+ return new WP_Error(
+ 'convertkit_mcp_category_settings_no_input',
+ __( 'At least one setting (form or form_position) must be provided.', 'convertkit' )
+ );
+ }
+
+ // Save. ConvertKit_Term::save() merges the provided values into the
+ // term's existing settings internally, so this is a partial update.
+ $term_settings = new ConvertKit_Term( $term_id );
+ $term_settings->save( $validated );
+
+ // Return the post-save state, using the get ability so the shape
+ // exactly matches kit/category-settings-get.
+ $get_ability = new ConvertKit_MCP_Ability_Category_Settings_Get();
+ return $get_ability->execute_callback( array( 'term_id' => $term_id ) );
+
+ }
+
+}
diff --git a/includes/mcp/abilities/category-settings/class-convertkit-mcp-ability-category-settings.php b/includes/mcp/abilities/category-settings/class-convertkit-mcp-ability-category-settings.php
new file mode 100644
index 000000000..aadb33618
--- /dev/null
+++ b/includes/mcp/abilities/category-settings/class-convertkit-mcp-ability-category-settings.php
@@ -0,0 +1,187 @@
+`.
+ *
+ * Scope is limited to the `category` taxonomy, matching the admin UI
+ * (ConvertKit_Admin_Category) and the frontend read path
+ * (ConvertKit_Output::get_term_form_position()).
+ *
+ * @package ConvertKit
+ * @author ConvertKit
+ */
+abstract class ConvertKit_MCP_Ability_Category_Settings extends ConvertKit_MCP_Ability {
+
+ /**
+ * The taxonomy this ability operates on.
+ *
+ * @since 3.4.0
+ *
+ * @var string
+ */
+ const TAXONOMY = 'category';
+
+ /**
+ * Returns the operation suffix used in the ability name (e.g. 'get',
+ * 'update').
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ abstract protected function get_operation();
+
+ /**
+ * Returns the ability name.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ public function get_name() {
+
+ return 'kit/category-settings-' . $this->get_operation();
+
+ }
+
+ /**
+ * Only permit an ability to be executed if the current user can edit
+ * the given category term.
+ *
+ * @since 3.4.0
+ *
+ * @param array $input Ability input.
+ * @return bool|WP_Error
+ */
+ public function permission_callback( $input ) {
+
+ // Get Term ID.
+ $term_id = isset( $input['term_id'] ) ? absint( $input['term_id'] ) : 0;
+
+ // Bail if no Term ID is provided.
+ if ( ! $term_id ) {
+ return new WP_Error(
+ 'convertkit_mcp_missing_term_id',
+ __( 'A term_id is required.', 'convertkit' )
+ );
+ }
+
+ // Bail if the current user cannot edit this term.
+ if ( ! current_user_can( 'edit_term', $term_id ) ) {
+ return new WP_Error(
+ 'convertkit_mcp_cannot_edit_term',
+ __( 'You do not have permission to edit this category.', 'convertkit' )
+ );
+ }
+
+ return true;
+
+ }
+
+ /**
+ * Validates that the given term exists and is in the `category` taxonomy.
+ *
+ * Returned as a shared helper for both verb subclasses' execute_callback.
+ *
+ * @since 3.4.0
+ *
+ * @param int $term_id Term ID.
+ * @return true|WP_Error
+ */
+ protected function validate_term( $term_id ) {
+
+ $term = get_term( $term_id );
+
+ // Bail if the term does not exist.
+ if ( ! $term || is_wp_error( $term ) ) {
+ return new WP_Error(
+ 'convertkit_mcp_term_not_found',
+ sprintf(
+ /* translators: %d: Term ID. */
+ __( 'Term %d does not exist.', 'convertkit' ),
+ $term_id
+ )
+ );
+ }
+
+ // Bail if the term is not in the `category` taxonomy.
+ if ( $term->taxonomy !== self::TAXONOMY ) {
+ return new WP_Error(
+ 'convertkit_mcp_term_wrong_taxonomy',
+ sprintf(
+ /* translators: 1: Term ID, 2: Actual taxonomy, 3: Expected taxonomy. */
+ __( 'Term %1$d is in the "%2$s" taxonomy; this ability only supports the "%3$s" taxonomy.', 'convertkit' ),
+ $term_id,
+ $term->taxonomy,
+ self::TAXONOMY
+ )
+ );
+ }
+
+ return true;
+
+ }
+
+ /**
+ * Returns the JSON Schema properties that describe the two Kit category
+ * settings, shared by both the input and output schemas.
+ *
+ * @since 3.4.0
+ *
+ * @return array
+ */
+ protected function get_settings_schema_properties() {
+
+ return array(
+ 'form' => array(
+ 'type' => 'integer',
+ 'description' => __( 'Form to display for Posts assigned to this Category. `-1` = use the Plugin Default Form; `0` = display no form; any other positive integer is a specific Kit Form ID.', 'convertkit' ),
+ 'minimum' => -1,
+ ),
+ 'form_position' => array(
+ 'type' => 'string',
+ 'description' => __( 'Where the Form displays on the Category archive page. Empty string uses the Plugin default position; `before` displays it before the post list; `after` displays it after.', 'convertkit' ),
+ 'enum' => array( '', 'before', 'after' ),
+ ),
+ );
+
+ }
+
+ /**
+ * Returns the JSON Schema for the ability's output.
+ *
+ * Shared by get and update so a caller can chain update -> confirm.
+ *
+ * @since 3.4.0
+ *
+ * @return array
+ */
+ public function get_output_schema() {
+
+ return array(
+ 'type' => 'object',
+ 'required' => array( 'term_id', 'form', 'form_position' ),
+ 'properties' => array_merge(
+ array(
+ 'term_id' => array(
+ 'type' => 'integer',
+ 'description' => __( 'The Category (term) ID.', 'convertkit' ),
+ ),
+ ),
+ $this->get_settings_schema_properties()
+ ),
+ );
+
+ }
+
+}
diff --git a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-delete.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-delete.php
new file mode 100644
index 000000000..c0081e9e9
--- /dev/null
+++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-delete.php
@@ -0,0 +1,134 @@
+-delete` (e.g. `kit/form-delete`).
+ *
+ * @package ConvertKit
+ * @author ConvertKit
+ */
+class ConvertKit_MCP_Ability_Content_Delete extends ConvertKit_MCP_Ability_Content {
+
+ /**
+ * Sets whether the ability is destructive.
+ *
+ * @since 3.4.0
+ *
+ * @var bool
+ */
+ private $destructive = true; // @phpstan-ignore-line
+
+ /**
+ * Returns the verb this ability represents.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ public function get_verb() {
+
+ return 'delete';
+
+ }
+
+ /**
+ * Returns the ability's human-readable label.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ public function get_label() {
+
+ return sprintf(
+ /* translators: %s: block title */
+ __( 'Delete Existing %s from a Post, Page or Custom Post', 'convertkit' ),
+ $this->block->get_title()
+ );
+
+ }
+
+ /**
+ * Returns the ability's human-readable description.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ public function get_description() {
+
+ return sprintf(
+ /* translators: Block Name */
+ __( 'Removes an existing %s from a Post, Page or Custom Post using the supplied zero-based occurrence index.', 'convertkit' ),
+ $this->block->get_title_plural()
+ );
+
+ }
+
+ /**
+ * Returns the ability's input JSON Schema.
+ *
+ * @since 3.4.0
+ *
+ * @return array
+ */
+ public function get_input_schema() {
+
+ return array(
+ 'type' => 'object',
+ 'required' => array( 'post_id', 'occurrence_index' ),
+ 'properties' => array(
+ 'post_id' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ 'description' => __( 'ID of the post containing the element.', 'convertkit' ),
+ ),
+ 'occurrence_index' => array(
+ 'type' => 'integer',
+ 'minimum' => 0,
+ 'description' => __( 'The zero-based occurrence index of the element to delete.', 'convertkit' ),
+ ),
+ ),
+ );
+
+ }
+
+ /**
+ * Executes the ability.
+ *
+ * @since 3.4.0
+ *
+ * @param array $input Ability input.
+ * @return array|WP_Error
+ */
+ public function execute_callback( $input ) {
+
+ // Get Post ID.
+ $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0;
+
+ // Bail if no Post ID is provided.
+ if ( ! $post_id ) {
+ return new WP_Error(
+ 'convertkit_mcp_missing_post_id',
+ __( 'A post_id is required.', 'convertkit' )
+ );
+ }
+
+ // Get occurrence index.
+ $occurrence_index = isset( $input['occurrence_index'] ) ? (int) $input['occurrence_index'] : 0;
+
+ // Delete the element from the post.
+ return ConvertKit_Content_Post_Helper::delete( $post_id, $this->block->get_name(), $occurrence_index );
+
+ }
+
+}
diff --git a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-insert.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-insert.php
new file mode 100644
index 000000000..d705ec571
--- /dev/null
+++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-insert.php
@@ -0,0 +1,138 @@
+-insert` (e.g. `kit/form-insert`).
+ *
+ * @package ConvertKit
+ * @author ConvertKit
+ */
+class ConvertKit_MCP_Ability_Content_Insert extends ConvertKit_MCP_Ability_Content {
+
+ /**
+ * Returns the verb this ability represents.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ public function get_verb() {
+
+ return 'insert';
+
+ }
+
+ /**
+ * Returns the ability's human-readable label.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ public function get_label() {
+
+ return sprintf(
+ /* translators: %s: block title */
+ __( 'Insert %s into a Page, Post or Custom Post', 'convertkit' ),
+ $this->block->get_title()
+ );
+
+ }
+
+ /**
+ * Returns the ability's human-readable description.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ public function get_description() {
+
+ return sprintf(
+ /* translators: 1: block full name e.g. convertkit/form, 2: block title */
+ __( 'Inserts a new %s in a Page, Post or Custom Post\'s content. The element can be appended (default), prepended, or inserted relative to an existing element using a zero-based index.', 'convertkit' ),
+ $this->block->get_title_plural()
+ );
+
+ }
+
+ /**
+ * Returns the ability's input JSON Schema.
+ *
+ * @since 3.4.0
+ *
+ * @return array
+ */
+ public function get_input_schema() {
+
+ return array(
+ 'type' => 'object',
+ 'required' => array( 'post_id', 'attrs' ),
+ 'properties' => array(
+ 'post_id' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ 'description' => __( 'Page / Post / Custom Post Type ID to insert the element into.', 'convertkit' ),
+ ),
+ 'position' => array(
+ 'type' => 'string',
+ 'enum' => array( 'append', 'prepend', 'index' ),
+ 'default' => 'append',
+ 'description' => __( 'Where to insert the new element. "index" requires the "index" property.', 'convertkit' ),
+ ),
+ 'index' => array(
+ 'type' => 'integer',
+ 'minimum' => 0,
+ 'description' => __( 'When position is "index", the zero-based top-level element index at which to insert the new element.', 'convertkit' ),
+ ),
+ 'attrs' => array(
+ 'type' => 'object',
+ 'description' => __( 'Element attributes.', 'convertkit' ),
+ 'properties' => $this->get_input_schema_properties(),
+ ),
+ ),
+ );
+
+ }
+
+ /**
+ * Executes the ability.
+ *
+ * @since 3.4.0
+ *
+ * @param array $input Ability input.
+ * @return array|WP_Error
+ */
+ public function execute_callback( $input ) {
+
+ // Get Post ID.
+ $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0;
+
+ // Bail if no Post ID is provided.
+ if ( ! $post_id ) {
+ return new WP_Error(
+ 'convertkit_mcp_missing_post_id',
+ __( 'A post_id is required.', 'convertkit' )
+ );
+ }
+
+ // Get attributes, position and index.
+ $attrs = isset( $input['attrs'] ) && is_array( $input['attrs'] ) ? $input['attrs'] : array();
+ $position = isset( $input['position'] ) ? (string) $input['position'] : 'append';
+ $index = isset( $input['index'] ) ? (int) $input['index'] : 0;
+
+ // Insert the element into the post.
+ return ConvertKit_Content_Post_Helper::insert( $post_id, $this->block->get_name(), $attrs, $position, $index );
+
+ }
+
+}
diff --git a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php
new file mode 100644
index 000000000..8f0685d01
--- /dev/null
+++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php
@@ -0,0 +1,188 @@
+-list` (e.g. `kit/form-list`).
+ *
+ * @package ConvertKit
+ * @author ConvertKit
+ */
+class ConvertKit_MCP_Ability_Content_List extends ConvertKit_MCP_Ability_Content {
+
+ /**
+ * 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 verb this ability represents.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ public function get_verb() {
+
+ return 'list';
+
+ }
+
+ /**
+ * Returns the ability's human-readable label.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ public function get_label() {
+
+ return sprintf(
+ /* translators: %s: block title */
+ __( 'List %s in a Post, Page or Custom Post', 'convertkit' ),
+ $this->block->get_title_plural()
+ );
+
+ }
+
+ /**
+ * Returns the ability's human-readable description.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ public function get_description() {
+
+ return sprintf(
+ /* translators: Block Name */
+ __( 'Lists every %s in the given Post, Page or Custom Post, including each occurrence\'s zero-based index and current attribute values.', 'convertkit' ),
+ $this->block->get_title_plural()
+ );
+
+ }
+
+ /**
+ * Returns the ability's input JSON Schema.
+ *
+ * @since 3.4.0
+ *
+ * @return array
+ */
+ public function get_input_schema() {
+
+ return array(
+ 'type' => 'object',
+ 'required' => array( 'post_id' ),
+ 'properties' => array(
+ 'post_id' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ 'description' => __( 'ID of the post to inspect.', 'convertkit' ),
+ ),
+ ),
+ );
+
+ }
+
+ /**
+ * Returns the ability's output JSON Schema.
+ *
+ * @since 3.4.0
+ *
+ * @return array
+ */
+ public function get_output_schema() {
+
+ return array(
+ 'type' => 'object',
+ 'required' => array( 'post_id', 'count', 'occurrences' ),
+ 'properties' => array(
+ 'post_id' => array(
+ 'type' => 'integer',
+ ),
+ 'count' => array(
+ 'type' => 'integer',
+ 'minimum' => 0,
+ ),
+ 'occurrences' => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'required' => array( 'occurrence_index', 'attrs' ),
+ 'properties' => array(
+ 'occurrence_index' => array(
+ 'type' => 'integer',
+ 'minimum' => 0,
+ 'description' => __( 'Zero-based occurrence index among this element\'s appearances in the post.', 'convertkit' ),
+ ),
+ 'attrs' => array(
+ 'type' => 'object',
+ 'description' => __( 'Element attributes for this occurrence.', 'convertkit' ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ }
+
+ /**
+ * Executes the ability.
+ *
+ * @since 3.4.0
+ *
+ * @param array $input Ability input.
+ * @return array|WP_Error
+ */
+ public function execute_callback( $input ) {
+
+ // Get Post ID.
+ $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0;
+
+ // Bail if no Post ID is provided.
+ if ( ! $post_id ) {
+ return new WP_Error(
+ 'convertkit_mcp_missing_post_id',
+ __( 'A post_id is required.', 'convertkit' )
+ );
+ }
+
+ // Find element occurrences in post.
+ $occurrences = ConvertKit_Content_Post_Helper::find( $post_id, $this->block->get_name() );
+ if ( is_wp_error( $occurrences ) ) {
+ return $occurrences;
+ }
+
+ // Return result.
+ return array(
+ 'post_id' => $post_id,
+ 'count' => count( $occurrences ),
+ 'occurrences' => $occurrences,
+ );
+
+ }
+
+}
diff --git a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-update.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-update.php
new file mode 100644
index 000000000..a614f66c9
--- /dev/null
+++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-update.php
@@ -0,0 +1,140 @@
+-update` (e.g. `kit/form-update`).
+ *
+ * @package ConvertKit
+ * @author ConvertKit
+ */
+class ConvertKit_MCP_Ability_Content_Update extends ConvertKit_MCP_Ability_Content {
+
+ /**
+ * Sets whether the ability is idempotent.
+ *
+ * @since 3.4.0
+ *
+ * @var bool
+ */
+ private $idempotent = true; // @phpstan-ignore-line
+
+ /**
+ * Returns the verb this ability represents.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ public function get_verb() {
+
+ return 'update';
+
+ }
+
+ /**
+ * Returns the ability's human-readable label.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ public function get_label() {
+
+ return sprintf(
+ /* translators: %s: block title */
+ __( 'Update Existing %s in a Page, Post or Custom Post', 'convertkit' ),
+ $this->block->get_title()
+ );
+
+ }
+
+ /**
+ * Returns the ability's human-readable description.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ public function get_description() {
+
+ return sprintf(
+ /* translators: Block Name */
+ __( 'Updates the attributes of an existing %s in a Page, Post or Custom Post. The provided attributes are merged into the existing attributes.', 'convertkit' ),
+ $this->block->get_title_plural()
+ );
+
+ }
+
+ /**
+ * Returns the ability's input JSON Schema.
+ *
+ * @since 3.4.0
+ *
+ * @return array
+ */
+ public function get_input_schema() {
+
+ return array(
+ 'type' => 'object',
+ 'required' => array( 'post_id', 'occurrence_index', 'attrs' ),
+ 'properties' => array(
+ 'post_id' => array(
+ 'type' => 'integer',
+ 'minimum' => 1,
+ 'description' => __( 'Page / Post / Custom Post Type ID containing the existing element.', 'convertkit' ),
+ ),
+ 'occurrence_index' => array(
+ 'type' => 'integer',
+ 'minimum' => 0,
+ 'description' => __( 'The zero-based occurrence index of the element to update.', 'convertkit' ),
+ ),
+ 'attrs' => array(
+ 'type' => 'object',
+ 'description' => __( 'Element attributes to update. Any attributes not provided will be left unchanged.', 'convertkit' ),
+ 'properties' => $this->get_input_schema_properties(),
+ ),
+ ),
+ );
+
+ }
+
+ /**
+ * Executes the ability.
+ *
+ * @since 3.4.0
+ *
+ * @param array $input Ability input.
+ * @return array|WP_Error
+ */
+ public function execute_callback( $input ) {
+
+ // Get Post ID.
+ $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0;
+
+ // Bail if no Post ID is provided.
+ if ( ! $post_id ) {
+ return new WP_Error(
+ 'convertkit_mcp_missing_post_id',
+ __( 'A post_id is required.', 'convertkit' )
+ );
+ }
+
+ // Get attributes and occurrence index.
+ $attrs = isset( $input['attrs'] ) && is_array( $input['attrs'] ) ? $input['attrs'] : array();
+ $occurrence_index = isset( $input['occurrence_index'] ) ? (int) $input['occurrence_index'] : 0;
+
+ // Update the element in the post.
+ return ConvertKit_Content_Post_Helper::update( $post_id, $this->block->get_name(), $occurrence_index, $attrs );
+
+ }
+
+}
diff --git a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php
new file mode 100644
index 000000000..539ef3e36
--- /dev/null
+++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php
@@ -0,0 +1,186 @@
+block = $block;
+
+ }
+
+ /**
+ * Returns the ability name, derived from the Kit element's name and the verb
+ * returned by get_verb().
+ *
+ * For example, the Form element's insert ability is named `kit/form-insert`.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ public function get_name() {
+
+ return 'kit/' . $this->block->get_name() . '-' . $this->get_verb();
+
+ }
+
+ /**
+ * Returns the verb this ability represents.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ abstract public function get_verb();
+
+ /**
+ * Only permit an ability to be executed if the current user can edit the given post.
+ *
+ * @since 3.4.0
+ *
+ * @param array $input Ability input.
+ * @return bool|WP_Error
+ */
+ public function permission_callback( $input ) {
+
+ // Get Post ID.
+ $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0;
+
+ // Bail if no Post ID is provided.
+ if ( ! $post_id ) {
+ return new WP_Error(
+ 'convertkit_mcp_missing_post_id',
+ __( 'A post_id is required.', 'convertkit' )
+ );
+ }
+
+ // Bail if the current user does not have permission to edit the post.
+ if ( ! current_user_can( 'edit_post', $post_id ) ) {
+ return new WP_Error(
+ 'convertkit_mcp_cannot_edit_post',
+ __( 'You do not have permission to edit this post.', 'convertkit' )
+ );
+ }
+
+ return true;
+
+ }
+
+ /**
+ * Returns the ability's output JSON Schema.
+ *
+ * @since 3.4.0
+ *
+ * @return array
+ */
+ public function get_output_schema() {
+
+ return array(
+ 'type' => 'object',
+ 'required' => array( 'post_id', 'occurrence_index' ),
+ 'properties' => array(
+ 'post_id' => array(
+ 'type' => 'integer',
+ 'description' => __( 'The Post/Page/Custom Post Type ID.', 'convertkit' ),
+ ),
+ 'occurrence_index' => array(
+ 'type' => 'integer',
+ 'description' => __( 'The zero-based occurrence index of the Kit element in the post.', 'convertkit' ),
+ ),
+ ),
+ );
+
+ }
+
+ /**
+ * Returns JSON Schema properties derived from the block's get_fields(),
+ * suitable for use as the `attrs` object in an Abilities API input schema.
+ *
+ * Used by verb subclasses whose input schema includes an `attrs` object
+ * (insert, update).
+ *
+ * @since 3.4.0
+ *
+ * @return array
+ */
+ protected function get_input_schema_properties() {
+
+ // Define properties.
+ $properties = array();
+ $fields = $this->block->get_fields();
+
+ foreach ( $fields as $field_name => $field ) {
+ $properties[ $field_name ] = array(
+ 'description' => isset( $field['label'] ) ? (string) $field['label'] : '',
+ 'type' => $this->get_input_schema_property_type( $field ),
+ );
+ }
+
+ return $properties;
+
+ }
+
+ /**
+ * Returns the JSON Schema type for the given field definition.
+ *
+ * @since 3.4.0
+ *
+ * @param array $field Field definition.
+ * @return string
+ */
+ private function get_input_schema_property_type( $field ) {
+
+ $type = isset( $field['type'] ) ? (string) $field['type'] : 'string';
+
+ switch ( $type ) {
+ case 'resource':
+ case 'text':
+ case 'color':
+ case 'select':
+ return 'string';
+
+ case 'number':
+ return 'integer';
+
+ case 'toggle':
+ return 'boolean';
+
+ default:
+ // Unknown field type — fall back to string.
+ return 'string';
+ }
+
+ }
+
+}
diff --git a/includes/mcp/abilities/post-settings/class-convertkit-mcp-ability-post-settings-get.php b/includes/mcp/abilities/post-settings/class-convertkit-mcp-ability-post-settings-get.php
new file mode 100644
index 000000000..537b5ed2a
--- /dev/null
+++ b/includes/mcp/abilities/post-settings/class-convertkit-mcp-ability-post-settings-get.php
@@ -0,0 +1,139 @@
+ 'object',
+ 'required' => array( 'post_id' ),
+ 'properties' => array(
+ 'post_id' => array(
+ 'type' => 'integer',
+ 'description' => __( 'The Post/Page/Custom Post Type ID to read Kit settings for.', 'convertkit' ),
+ 'minimum' => 1,
+ ),
+ ),
+ );
+
+ }
+
+ /**
+ * Executes the ability.
+ *
+ * @since 3.4.0
+ *
+ * @param array $input Ability input.
+ * @return array|WP_Error
+ */
+ public function execute_callback( $input ) {
+
+ $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0;
+
+ // Bail if the Post does not exist.
+ if ( ! get_post( $post_id ) ) {
+ return new WP_Error(
+ 'convertkit_mcp_post_not_found',
+ sprintf(
+ /* translators: %d: Post ID. */
+ __( 'Post %d does not exist.', 'convertkit' ),
+ $post_id
+ )
+ );
+ }
+
+ // Load the Post's settings.
+ $post_settings = new ConvertKit_Post( $post_id );
+ $settings = $post_settings->get();
+
+ // Cast values to string so they match the output schema (Post storage
+ // keeps them as strings, but defense-in-depth for numeric coercion).
+ return array(
+ 'post_id' => $post_id,
+ 'form' => (string) $settings['form'],
+ 'landing_page' => (string) $settings['landing_page'],
+ 'tag' => (string) $settings['tag'],
+ 'restrict_content' => (string) $settings['restrict_content'],
+ );
+
+ }
+
+}
diff --git a/includes/mcp/abilities/post-settings/class-convertkit-mcp-ability-post-settings-update.php b/includes/mcp/abilities/post-settings/class-convertkit-mcp-ability-post-settings-update.php
new file mode 100644
index 000000000..f871549bd
--- /dev/null
+++ b/includes/mcp/abilities/post-settings/class-convertkit-mcp-ability-post-settings-update.php
@@ -0,0 +1,172 @@
+ 'object',
+ 'required' => array( 'post_id' ),
+ 'properties' => array_merge(
+ array(
+ 'post_id' => array(
+ 'type' => 'integer',
+ 'description' => __( 'The Post/Page/Custom Post Type ID to update Kit settings for.', 'convertkit' ),
+ 'minimum' => 1,
+ ),
+ ),
+ $this->get_settings_schema_properties()
+ ),
+ );
+
+ }
+
+ /**
+ * Executes the ability.
+ *
+ * @since 3.4.0
+ *
+ * @param array $input Ability input.
+ * @return array|WP_Error
+ */
+ public function execute_callback( $input ) {
+
+ $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0;
+
+ // Bail if the Post does not exist.
+ if ( ! get_post( $post_id ) ) {
+ return new WP_Error(
+ 'convertkit_mcp_post_not_found',
+ sprintf(
+ /* translators: %d: Post ID. */
+ __( 'Post %d does not exist.', 'convertkit' ),
+ $post_id
+ )
+ );
+ }
+
+ // Reject unknown keys.
+ $properties = $this->get_settings_schema_properties();
+ $allowed_keys = array_merge( array( 'post_id' ), array_keys( $properties ) );
+ $unknown_keys = array_diff( array_keys( $input ), $allowed_keys );
+ if ( ! empty( $unknown_keys ) ) {
+ return new WP_Error(
+ 'convertkit_mcp_post_settings_unknown_keys',
+ sprintf(
+ /* translators: %s: Comma-separated list of unknown keys. */
+ __( 'The following settings keys are not recognised: %s.', 'convertkit' ),
+ implode( ', ', $unknown_keys )
+ )
+ );
+ }
+
+ // Validate each provided setting against its declared schema.
+ $validated = array();
+ foreach ( $properties as $key => $property_schema ) {
+ if ( ! array_key_exists( $key, $input ) ) {
+ continue;
+ }
+
+ $valid = rest_validate_value_from_schema( $input[ $key ], $property_schema, $key );
+
+ // Bail if the value is invalid.
+ if ( is_wp_error( $valid ) ) {
+ return $valid;
+ }
+
+ $validated[ $key ] = rest_sanitize_value_from_schema( $input[ $key ], $property_schema, $key );
+ }
+
+ // Bail if no settings were provided.
+ if ( empty( $validated ) ) {
+ return new WP_Error(
+ 'convertkit_mcp_post_settings_no_input',
+ __( 'At least one setting (form, landing_page, tag or restrict_content) must be provided.', 'convertkit' )
+ );
+ }
+
+ // Merge into the Post's existing settings so this is a partial update.
+ $post_settings = new ConvertKit_Post( $post_id );
+ $merged = array_merge( $post_settings->get(), $validated );
+
+ // Save. This fires updated_post_meta, which the Restrict Content
+ // cache class listens for; nothing extra required here.
+ $post_settings->save( $merged );
+
+ // Return the post-save state, using the get ability so the shape
+ // exactly matches kit/post-settings-get.
+ $get_ability = new ConvertKit_MCP_Ability_Post_Settings_Get();
+ return $get_ability->execute_callback( array( 'post_id' => $post_id ) );
+
+ }
+
+}
diff --git a/includes/mcp/abilities/post-settings/class-convertkit-mcp-ability-post-settings.php b/includes/mcp/abilities/post-settings/class-convertkit-mcp-ability-post-settings.php
new file mode 100644
index 000000000..599bca61f
--- /dev/null
+++ b/includes/mcp/abilities/post-settings/class-convertkit-mcp-ability-post-settings.php
@@ -0,0 +1,146 @@
+`.
+ *
+ * @package ConvertKit
+ * @author ConvertKit
+ */
+abstract class ConvertKit_MCP_Ability_Post_Settings extends ConvertKit_MCP_Ability {
+
+ /**
+ * Returns the operation suffix used in the ability name (e.g. 'get',
+ * 'update').
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ abstract protected function get_operation();
+
+ /**
+ * Returns the ability name.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ public function get_name() {
+
+ return 'kit/post-settings-' . $this->get_operation();
+
+ }
+
+ /**
+ * Only permit an ability to be executed if the current user can edit
+ * the given post.
+ *
+ * @since 3.4.0
+ *
+ * @param array $input Ability input.
+ * @return bool|WP_Error
+ */
+ public function permission_callback( $input ) {
+
+ // Get Post ID.
+ $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0;
+
+ // Bail if no Post ID is provided.
+ if ( ! $post_id ) {
+ return new WP_Error(
+ 'convertkit_mcp_missing_post_id',
+ __( 'A post_id is required.', 'convertkit' )
+ );
+ }
+
+ // Bail if the current user does not have permission to edit the post.
+ if ( ! current_user_can( 'edit_post', $post_id ) ) {
+ return new WP_Error(
+ 'convertkit_mcp_cannot_edit_post',
+ __( 'You do not have permission to edit this post.', 'convertkit' )
+ );
+ }
+
+ return true;
+
+ }
+
+ /**
+ * Returns the JSON Schema properties that describe the four Kit post
+ * settings, shared by both the input and output schemas.
+ *
+ * Values are stored by the Plugin as strings (matching what the metabox
+ * submits), so the schemas expose them as strings with format constraints.
+ *
+ * @since 3.4.0
+ *
+ * @return array
+ */
+ protected function get_settings_schema_properties() {
+
+ return array(
+ 'form' => array(
+ 'type' => 'string',
+ 'description' => __( 'Form to display for the Post. `-1` = use the Plugin Default Form for this Post Type; `0` = display no form; any other positive integer is a specific Kit Form ID.', 'convertkit' ),
+ 'pattern' => '^(-1|0|[1-9][0-9]*)$',
+ ),
+ 'landing_page' => array(
+ 'type' => 'string',
+ 'description' => __( 'Kit Landing Page ID to display instead of the Post content. `0` or empty string for none.', 'convertkit' ),
+ 'pattern' => '^([0-9]+)?$',
+ ),
+ 'tag' => array(
+ 'type' => 'string',
+ 'description' => __( 'Kit Tag ID to apply when the Post is viewed by a Kit subscriber. `0` or empty string for none.', 'convertkit' ),
+ 'pattern' => '^([0-9]+)?$',
+ ),
+ 'restrict_content' => array(
+ 'type' => 'string',
+ 'description' => __( 'Restrict Post content to Kit subscribers. Empty string or `0` for no restriction, or one of `form_`, `tag_`, `product_` to require subscription to that resource.', 'convertkit' ),
+ 'pattern' => '^$|^0$|^(form|tag|product)_[1-9][0-9]*$',
+ ),
+ );
+
+ }
+
+ /**
+ * Returns the JSON Schema for the ability's output.
+ *
+ * The output shape is the same for get and update: the four settings
+ * plus the post_id, so a caller can chain update -> confirm without a
+ * follow-up get.
+ *
+ * @since 3.4.0
+ *
+ * @return array
+ */
+ public function get_output_schema() {
+
+ return array(
+ 'type' => 'object',
+ 'required' => array( 'post_id', 'form', 'landing_page', 'tag', 'restrict_content' ),
+ 'properties' => array_merge(
+ array(
+ 'post_id' => array(
+ 'type' => 'integer',
+ 'description' => __( 'The Post/Page/Custom Post Type ID.', 'convertkit' ),
+ ),
+ ),
+ $this->get_settings_schema_properties()
+ ),
+ );
+
+ }
+
+}
diff --git a/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-forms.php b/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-forms.php
new file mode 100644
index 000000000..14c91b206
--- /dev/null
+++ b/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-forms.php
@@ -0,0 +1,133 @@
+ (int) ( $item['id'] ?? 0 ),
+ 'name' => (string) ( $item['name'] ?? '' ),
+ 'format' => isset( $item['format'] ) && $item['format'] !== '' ? (string) $item['format'] : 'inline',
+ );
+
+ }
+
+ /**
+ * Returns the JSON Schema for a single Form item, including the `format`
+ * field added by map_item().
+ *
+ * @since 3.4.0
+ *
+ * @return array
+ */
+ protected function get_item_schema() {
+
+ return array(
+ 'type' => 'object',
+ 'required' => array( 'id', 'name', 'format' ),
+ 'properties' => array(
+ 'id' => array(
+ 'type' => 'integer',
+ 'description' => __( 'Numeric ID of the Kit Form.', 'convertkit' ),
+ ),
+ 'name' => array(
+ 'type' => 'string',
+ 'description' => __( 'Human-readable name of the Kit Form.', 'convertkit' ),
+ ),
+ 'format' => array(
+ 'type' => 'string',
+ 'enum' => array( 'inline', 'modal', 'slide in', 'sticky bar' ),
+ 'description' => __( 'Where and how the Form is displayed. Inline forms render in post content; modal / slide in / sticky bar forms are site-wide overlays triggered elsewhere.', 'convertkit' ),
+ ),
+ ),
+ );
+
+ }
+
+}
diff --git a/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-landing-pages.php b/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-landing-pages.php
new file mode 100644
index 000000000..be0d05def
--- /dev/null
+++ b/includes/mcp/abilities/resources/class-convertkit-mcp-ability-resource-landing-pages.php
@@ -0,0 +1,73 @@
+get_resource() . '-list';
+
+ }
+
+ /**
+ * Returns the resource slug for this ability, used in the ability name
+ * and as a hint for clients (e.g. `forms`, `tags`, `landing-pages`,
+ * `products`).
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ abstract protected function get_resource();
+
+ /**
+ * Returns the fully-qualified class name of the ConvertKit_Resource_*
+ * implementation backing this ability.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ abstract protected function get_resource_class();
+
+ /**
+ * Maps a single raw resource item from the resource class' get() method
+ * into the shape exposed in this ability's output.
+ *
+ * The default implementation returns just id and name. Subclasses may
+ * override to expose additional per-item fields (e.g. Forms includes
+ * `format`) — output_schema() should be overridden to match.
+ *
+ * @since 3.4.0
+ *
+ * @param array $item Raw item from the resource class' get() method.
+ * @return array
+ */
+ protected function map_item( $item ) {
+
+ return array(
+ 'id' => (int) ( $item['id'] ?? 0 ),
+ 'name' => (string) ( $item['name'] ?? '' ),
+ );
+
+ }
+
+ /**
+ * Returns the JSON Schema describing a single item in the output `items`
+ * array.
+ *
+ * Subclasses may override to add per-resource fields. Keep in sync with
+ * map_item() — both describe the same shape, one in schema form and one
+ * in PHP.
+ *
+ * @since 3.4.0
+ *
+ * @return array
+ */
+ protected function get_item_schema() {
+
+ return array(
+ 'type' => 'object',
+ 'required' => array( 'id', 'name' ),
+ 'properties' => array(
+ 'id' => array(
+ 'type' => 'integer',
+ 'description' => __( 'Numeric ID of the resource item.', 'convertkit' ),
+ ),
+ 'name' => array(
+ 'type' => 'string',
+ 'description' => __( 'Human-readable name of the resource item.', 'convertkit' ),
+ ),
+ ),
+ );
+
+ }
+
+ /**
+ * Permission callback for resource-list abilities.
+ *
+ * Listing available Kit resources is permitted for anyone who can edit
+ * posts — the same capability gate that allows placing a Kit element on
+ * a post, where these lists are typically used as a lookup.
+ *
+ * @since 3.4.0
+ *
+ * @param array $input Ability input (unused).
+ * @return bool|WP_Error
+ */
+ public function permission_callback( $input ) {
+
+ if ( ! current_user_can( 'edit_posts' ) ) {
+ return new WP_Error(
+ 'convertkit_mcp_cannot_list_resources',
+ __( 'You do not have permission to list Kit resources.', 'convertkit' )
+ );
+ }
+
+ return true;
+
+ }
+
+ /**
+ * Returns the ability's input JSON Schema.
+ *
+ * Resource-list abilities take 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 array(
+ 'type' => 'object',
+ 'required' => array( 'count', 'items' ),
+ 'properties' => array(
+ 'count' => array(
+ 'type' => 'integer',
+ 'minimum' => 0,
+ 'description' => __( 'The number of items returned.', 'convertkit' ),
+ ),
+ 'items' => array(
+ 'type' => 'array',
+ 'description' => __( 'The resource items.', 'convertkit' ),
+ 'items' => $this->get_item_schema(),
+ ),
+ ),
+ );
+
+ }
+
+ /**
+ * Executes the ability: instantiate the backing resource class, fetch
+ * its cached items, and return them mapped to this ability's output
+ * shape.
+ *
+ * A "no items" result (e.g. the Plugin has not yet cached this resource
+ * from the Kit API) is returned as a successful empty list rather than
+ * an error, so the model can explain the absence to the user.
+ *
+ * @since 3.4.0
+ *
+ * @param array $input Ability input (unused).
+ * @return array|WP_Error
+ */
+ public function execute_callback( $input ) {
+
+ // Instantiate the backing resource class.
+ $resource_class = $this->get_resource_class();
+ if ( ! class_exists( $resource_class ) ) {
+ return new WP_Error(
+ 'convertkit_mcp_resource_class_missing',
+ sprintf(
+ /* translators: %s: Resource class name */
+ __( 'The resource class "%s" does not exist.', 'convertkit' ),
+ $resource_class
+ )
+ );
+ }
+
+ $resource = new $resource_class();
+
+ // Fetch the items from the resource cache. ConvertKit_Resource::get()
+ // returns false when nothing has been cached; normalise that to an
+ // empty array so the output shape is always consistent.
+ $items = $resource->get();
+ if ( ! is_array( $items ) ) {
+ $items = array();
+ }
+
+ // Map each raw item to the ability's output shape.
+ $mapped = array();
+ foreach ( $items as $item ) {
+ $mapped[] = $this->map_item( $item );
+ }
+
+ return array(
+ 'count' => count( $mapped ),
+ 'items' => $mapped,
+ );
+
+ }
+
+}
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-ability.php b/includes/mcp/class-convertkit-mcp-ability.php
new file mode 100644
index 000000000..4cbf26ee6
--- /dev/null
+++ b/includes/mcp/class-convertkit-mcp-ability.php
@@ -0,0 +1,165 @@
+ $this->get_label(),
+ 'description' => $this->get_description(),
+ 'category' => $this->get_category(),
+ 'input_schema' => $this->get_input_schema(),
+ 'output_schema' => $this->get_output_schema(),
+ 'permission_callback' => array( $this, 'permission_callback' ),
+ 'execute_callback' => array( $this, 'execute_callback' ),
+ 'meta' => array(
+ 'annotations' => $this->get_annotations(),
+ ),
+ );
+
+ }
+
+ /**
+ * Returns the ability's human-readable label.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ abstract public function get_label();
+
+ /**
+ * Returns the ability's human-readable description.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ abstract public function get_description();
+
+ /**
+ * Returns the ability's category.
+ *
+ * @since 3.4.0
+ *
+ * @return string
+ */
+ public function get_category() {
+
+ return 'kit';
+
+ }
+
+ /**
+ * Returns the ability's input JSON Schema.
+ *
+ * @since 3.4.0
+ *
+ * @return array
+ */
+ abstract public function get_input_schema();
+
+ /**
+ * Returns the ability's output JSON Schema.
+ *
+ * @since 3.4.0
+ *
+ * @return array
+ */
+ abstract public function get_output_schema();
+
+ /**
+ * Define the annotations for the ability.
+ *
+ * @since 3.4.0
+ *
+ * @return array
+ */
+ public function get_annotations() {
+
+ return array(
+ 'title' => $this->get_label(),
+ 'readonly' => $this->readonly,
+ 'destructive' => $this->destructive,
+ 'idempotent' => $this->idempotent,
+ );
+
+ }
+
+ /**
+ * Permission callback for this ability.
+ *
+ * @since 3.4.0
+ *
+ * @param array $input Ability input.
+ * @return bool|WP_Error
+ */
+ abstract public function permission_callback( $input );
+
+ /**
+ * Execute callback for this ability.
+ *
+ * @since 3.4.0
+ *
+ * @param array $input Ability input.
+ * @return array|WP_Error
+ */
+ abstract public function execute_callback( $input );
+
+}
diff --git a/includes/mcp/class-convertkit-mcp.php b/includes/mcp/class-convertkit-mcp.php
new file mode 100644
index 000000000..7be6e074a
--- /dev/null
+++ b/includes/mcp/class-convertkit-mcp.php
@@ -0,0 +1,289 @@
+get_name() ] = $get;
+ $abilities[ $update->get_name() ] = $update;
+ }
+
+ return $abilities;
+
+ }
+
+ /**
+ * Appends the per-Post Kit settings abilities 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_post_settings_abilities( $abilities ) {
+
+ $abilities['kit/post-settings-get'] = new ConvertKit_MCP_Ability_Post_Settings_Get();
+ $abilities['kit/post-settings-update'] = new ConvertKit_MCP_Ability_Post_Settings_Update();
+
+ return $abilities;
+
+ }
+
+ /**
+ * Appends the per-Category Kit settings abilities 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_category_settings_abilities( $abilities ) {
+
+ $abilities['kit/category-settings-get'] = new ConvertKit_MCP_Ability_Category_Settings_Get();
+ $abilities['kit/category-settings-update'] = new ConvertKit_MCP_Ability_Category_Settings_Update();
+
+ return $abilities;
+
+ }
+
+ /**
+ * Appends the resource-list abilities (Forms, Tags, Landing Pages,
+ * Products) 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_resource_abilities( $abilities ) {
+
+ return array_merge(
+ $abilities,
+ array(
+ 'kit/forms-list' => new ConvertKit_MCP_Ability_Resource_Forms(),
+ 'kit/tags-list' => new ConvertKit_MCP_Ability_Resource_Tags(),
+ 'kit/landing-pages-list' => new ConvertKit_MCP_Ability_Resource_Landing_Pages(),
+ 'kit/products-list' => new ConvertKit_MCP_Ability_Resource_Products(),
+ )
+ );
+
+ }
+
+ /**
+ * Register the 'kit' ability category.
+ *
+ * @since 3.4.0
+ */
+ public function register_abilities_category() {
+
+ wp_register_ability_category(
+ self::CATEGORY_SLUG,
+ array(
+ 'label' => __( 'Kit', 'convertkit' ),
+ 'description' => __( 'Abilities exposed by the Kit Plugin.', 'convertkit' ),
+ )
+ );
+
+ }
+
+ /**
+ * Register abilities with the WordPress Abilities API.
+ *
+ * @since 3.4.0
+ */
+ public function register_abilities() {
+
+ // Get abilities.
+ $abilities = convertkit_get_abilities();
+
+ // Bail if no abilities are available.
+ if ( ! count( $abilities ) ) {
+ return;
+ }
+
+ // Iterate through abilities, registering them.
+ foreach ( $abilities as $ability ) {
+
+ // Skip if this ability is not an instance of ConvertKit_MCP_Ability.
+ if ( ! ( $ability instanceof ConvertKit_MCP_Ability ) ) {
+ continue;
+ }
+
+ // Register ability.
+ wp_register_ability( $ability->get_name(), $ability->get_ability_args() );
+ }
+
+ }
+
+ /**
+ * Register an MCP server that exposes Kit abilities as MCP tools.
+ *
+ * @since 3.4.0
+ *
+ * @param object $adapter The MCP Adapter instance.
+ * @return void
+ */
+ public function register_mcp_server( $adapter ) {
+
+ // Get abilities.
+ $abilities = convertkit_get_abilities();
+
+ // Build array of ability names.
+ $ability_names = array();
+ foreach ( $abilities as $ability ) {
+ $ability_names[] = $ability->get_name();
+ }
+
+ // Create the MCP server.
+ $adapter->create_server(
+ self::SERVER_ID,
+ self::SERVER_NAMESPACE,
+ self::SERVER_ROUTE,
+ __( 'Kit WordPress Plugin MCP', 'convertkit' ),
+ __( 'Exposes Kit Plugin abilities over the Model Context Protocol.', 'convertkit' ),
+ '1.0.0',
+ array( 'WP\\MCP\\Transport\\HttpTransport' ),
+ 'WP\\MCP\\Infrastructure\\ErrorHandling\\ErrorLogMcpErrorHandler',
+ 'WP\\MCP\\Infrastructure\\Observability\\NullMcpObservabilityHandler',
+ $ability_names, // Abilities (Tools).
+ array(), // Resources.
+ array() // Prompts.
+ );
+
+ }
+
+}
diff --git a/tests/EndToEnd.suite.yml b/tests/EndToEnd.suite.yml
index f00924664..23529dc84 100644
--- a/tests/EndToEnd.suite.yml
+++ b/tests/EndToEnd.suite.yml
@@ -41,6 +41,7 @@ modules:
- \Tests\Support\Helper\WPGutenberg
- \Tests\Support\Helper\WPMetabox
- \Tests\Support\Helper\WPNotices
+ - \Tests\Support\Helper\WPRestAPI
- \Tests\Support\Helper\WPQuickEdit
- \Tests\Support\Helper\WPWidget
- \Tests\Support\Helper\Xdebug
diff --git a/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php b/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php
new file mode 100644
index 000000000..cf0a22683
--- /dev/null
+++ b/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php
@@ -0,0 +1,175 @@
+ Kit > MCP.
+ *
+ * @since 3.4.0
+ */
+class PluginSettingsMCPCest
+{
+ /**
+ * Run common actions before running the test functions in this class.
+ *
+ * @since 3.4.0
+ *
+ * @param EndToEndTester $I Tester.
+ */
+ public function _before(EndToEndTester $I)
+ {
+ // Activate Kit Plugin.
+ $I->activateKitPlugin($I);
+
+ // Setup Plugin.
+ $I->setupKitPlugin($I);
+ }
+
+ /**
+ * Tests that enabling and disabling the MCP server setting works with no errors.
+ *
+ * @since 3.4.0
+ *
+ * @param EndToEndTester $I Tester.
+ */
+ public function testEnableAndDisableMCPServerSetting(EndToEndTester $I)
+ {
+ // Check that the MCP server is not registered.
+ $I->doesNotHaveRoute($I, '/kit-mcp');
+
+ // Go to the Plugin's MCP Screen.
+ $I->loadKitSettingsMCPScreen($I);
+
+ // Enable MCP server.
+ $I->checkOption('#enabled');
+ $I->click('Save Changes');
+
+ // Check that no PHP warnings or notices were output.
+ $I->checkNoWarningsAndNoticesOnScreen($I);
+
+ // Check that the MCP server is enabled.
+ $I->waitForElementVisible('#enabled');
+ $I->seeCheckboxIsChecked('#enabled');
+
+ // Check that the MCP server is registered.
+ $I->hasRoute($I, '/kit/mcp');
+ $I->hasRoute($I, '/kit/mcp/v1');
+
+ // Disable MCP server.
+ $I->uncheckOption('#enabled');
+ $I->click('Save Changes');
+
+ // Check that no PHP warnings or notices were output.
+ $I->checkNoWarningsAndNoticesOnScreen($I);
+
+ // Check that the MCP server is disabled.
+ $I->waitForElementVisible('#enabled');
+ $I->dontSeeCheckboxIsChecked('#enabled');
+
+ // Go to the Plugin's MCP Screen.
+ $I->loadKitSettingsMCPScreen($I);
+ $I->wait(2);
+
+ // Check that the MCP server is not registered.
+ $I->doesNotHaveRoute($I, '/kit/mcp');
+ $I->doesNotHaveRoute($I, '/kit/mcp/v1');
+ }
+
+ /**
+ * Tests that generating and revoking an Application Password works with no errors
+ * and that the MCP server is accessible using the Authorization Header generated
+ * via the Application Password.
+ *
+ * @since 3.4.0
+ *
+ * @param EndToEndTester $I Tester.
+ */
+ public function testGenerateAndRevokeApplicationPassword(EndToEndTester $I)
+ {
+ // Go to the Plugin's MCP Screen.
+ $I->loadKitSettingsMCPScreen($I);
+
+ // Enable MCP server.
+ $I->checkOption('#enabled');
+ $I->click('Save Changes');
+
+ // Check that no PHP warnings or notices were output.
+ $I->checkNoWarningsAndNoticesOnScreen($I);
+
+ // Check that the MCP server is enabled.
+ $I->waitForElementVisible('#enabled');
+ $I->seeCheckboxIsChecked('#enabled');
+
+ // Click Create Application Password button.
+ $I->click('Create Application Password');
+
+ // Check that no PHP warnings or notices were output.
+ $I->checkNoWarningsAndNoticesOnScreen($I);
+
+ // Check that the application password was created and contains the correct name.
+ $I->waitForElementVisible('#app_name');
+ $I->seeInField('#app_name', 'Kit WordPress Plugin: MCP Server');
+
+ // Approve the application password.
+ $I->click('input#approve');
+
+ // Check that no PHP warnings or notices were output.
+ $I->checkNoWarningsAndNoticesOnScreen($I);
+
+ // Check that the user is back on the Settings > Kit > MCP screen and the Authentication Header is displayed.
+ $I->waitForElementVisible('#kit-authorization-header');
+
+ // Perform a JSON-RPC `initialize` request against the MCP server using
+ // the Authorization Header generated via the Application Password.
+ $response = $I->callRestEndpoint(
+ '/kit/mcp/v1',
+ $I->grabTextFrom('#kit-authorization-header'),
+ 'POST',
+ [
+ 'jsonrpc' => '2.0',
+ 'id' => 1,
+ 'method' => 'initialize',
+ 'params' => [
+ 'protocolVersion' => '2024-11-05',
+ 'capabilities' => new \stdClass(),
+ 'clientInfo' => [
+ 'name' => 'kit-wordpress-plugin-test',
+ 'version' => '1.0',
+ ],
+ ],
+ ]
+ );
+
+ // Assert the request was authorised and the discovery endpoint responded.
+ $I->assertEquals(200, $response['status']);
+ $I->assertEquals('Kit WordPress Plugin MCP', $response['body']['result']['serverInfo']['name'] ?? null);
+
+ // Reload the MCP settings screen and confirm the Authorization Header is not displayed.
+ $I->loadKitSettingsMCPScreen($I);
+ $I->waitForText('It is not displayed here for security.');
+ $I->waitForElementNotVisible('#kit-authorization-header');
+
+ // Revoke the application password.
+ $I->click('#convertkit-settings-mcp-revoke-application-password');
+
+ // Check that the Revoke Application Password button is no longer visible.
+ $I->waitForElementNotVisible('#convertkit-settings-mcp-revoke-application-password');
+ }
+
+ /**
+ * Deactivate and reset Plugin(s) after each test, if the test passes.
+ * We don't use _after, as this would provide a screenshot of the Plugin
+ * deactivation and not the true test error.
+ *
+ * @since 3.4.0
+ *
+ * @param EndToEndTester $I Tester.
+ */
+ public function _passed(EndToEndTester $I)
+ {
+ $I->deactivateKitPlugin($I);
+ $I->resetKitPlugin($I);
+ }
+}
diff --git a/tests/Integration/BlockPostHelperTest.php b/tests/Integration/BlockPostHelperTest.php
new file mode 100644
index 000000000..020c2bc0d
--- /dev/null
+++ b/tests/Integration/BlockPostHelperTest.php
@@ -0,0 +1,473 @@
+postID = $this->createPost();
+ }
+
+ /**
+ * Performs actions after each test.
+ *
+ * @since 3.4.0
+ */
+ public function tearDown(): void
+ {
+ // Deactivate Plugin.
+ deactivate_plugins('convertkit/wp-convertkit.php');
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test that the find() method returns the correct block indicies and attributes.
+ *
+ * @since 3.4.0
+ */
+ public function testFind()
+ {
+ // Find the block.
+ $blocks = \ConvertKit_Block_Post_Helper::find( $this->postID, 'convertkit/form' );
+ $this->assertIsArray( $blocks );
+ $this->assertCount( 2, $blocks );
+
+ // Assert first matching block indicies and attributes are correct.
+ $this->assertEquals( 0, $blocks[0]['occurrence_index'] );
+ $this->assertEquals( $_ENV['CONVERTKIT_API_FORM_ID'], $blocks[0]['attrs']['form'] );
+
+ // Assert second matching block indicies and attributes are correct.
+ $this->assertEquals( 1, $blocks[1]['occurrence_index'] );
+ $this->assertEquals( $_ENV['CONVERTKIT_API_FORM_ID'], $blocks[1]['attrs']['form'] );
+ }
+
+ /**
+ * Test that the find() method returns an empty array when no blocks match the given block name.
+ *
+ * @since 3.4.0
+ */
+ public function testFindWhenNoBlocksMatch()
+ {
+ $blocks = \ConvertKit_Block_Post_Helper::find( $this->postID, 'fake/block' );
+ $this->assertIsArray($blocks);
+ $this->assertCount(0, $blocks);
+ }
+
+ /**
+ * Test that the find() method returns a WP_Error when the post does not exist.
+ *
+ * @since 3.4.0
+ */
+ public function testFindWhenPostDoesNotExist()
+ {
+ $this->assertInstanceOf(\WP_Error::class, \ConvertKit_Block_Post_Helper::find( 999999, 'convertkit/form' ));
+ }
+
+ /**
+ * Test that the insert() method inserts a new block at the beginning of the content
+ * when the position is set to prepend.
+ *
+ * @since 3.4.0
+ */
+ public function testInsertPrepend()
+ {
+ $result = \ConvertKit_Block_Post_Helper::insert(
+ post_id: $this->postID,
+ block_name: 'convertkit/form',
+ attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ],
+ position: 'prepend'
+ );
+
+ // Confirm result is an array and the post ID is correct.
+ $this->assertIsArray( $result );
+ $this->assertEquals( $this->postID, $result['post_id'] );
+
+ // Confirm content has been updated and the block is inserted at the correct position.
+ $post = get_post($this->postID);
+ $this->assertStringStartsWith( '', $post->post_content );
+ }
+
+ /**
+ * Test that the insert() method inserts a new block at the end of the content
+ * when the position is set to append.
+ *
+ * @since 3.4.0
+ */
+ public function testInsertAppend()
+ {
+ $result = \ConvertKit_Block_Post_Helper::insert(
+ post_id: $this->postID,
+ block_name: 'convertkit/form',
+ attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ],
+ position: 'append'
+ );
+
+ // Confirm result is an array and the post ID is correct.
+ $this->assertIsArray( $result );
+ $this->assertEquals( $this->postID, $result['post_id'] );
+
+ // Confirm content has been updated and the block is inserted at the correct position.
+ $post = get_post($this->postID);
+ $this->assertStringEndsWith( '', $post->post_content );
+ }
+
+ /**
+ * Test that the insert() method inserts a new block at the specified index position.
+ *
+ * @since 3.4.0
+ */
+ public function testInsertIndex()
+ {
+ $result = \ConvertKit_Block_Post_Helper::insert(
+ post_id: $this->postID,
+ block_name: 'convertkit/form',
+ attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ],
+ position: 'index',
+ index: 1
+ );
+
+ // Confirm result is an array and the post ID is correct.
+ $this->assertIsArray( $result );
+ $this->assertEquals( $this->postID, $result['post_id'] );
+
+ // Confirm content has been updated and the block is inserted at the correct position.
+ $post = get_post($this->postID);
+ $this->assertStringContainsString( "\n
Item #1
\n', $post->post_content );
+ }
+
+ /**
+ * Test that the insert() method inserts a new block at the specified index position.
+ *
+ * @since 3.4.0
+ */
+ public function testInsertIndexZero()
+ {
+ $result = \ConvertKit_Block_Post_Helper::insert(
+ post_id: $this->postID,
+ block_name: 'convertkit/form',
+ attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ],
+ position: 'index',
+ index: 0
+ );
+
+ // Confirm result is an array and the post ID is correct.
+ $this->assertIsArray( $result );
+ $this->assertEquals( $this->postID, $result['post_id'] );
+
+ // Confirm content has been updated and the block is inserted at the correct position.
+ $post = get_post($this->postID);
+ $this->assertStringStartsWith( '', $post->post_content );
+ }
+
+ /**
+ * Test that the insert() method inserts a new block at end of the content when
+ * the index is out of bounds.
+ *
+ * @since 3.4.0
+ */
+ public function testInsertIndexOutOfBounds()
+ {
+ $result = \ConvertKit_Block_Post_Helper::insert(
+ post_id: $this->postID,
+ block_name: 'convertkit/form',
+ attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ],
+ position: 'index',
+ index: 100
+ );
+
+ // Confirm result is an array and the post ID is correct.
+ $this->assertIsArray( $result );
+ $this->assertEquals( $this->postID, $result['post_id'] );
+
+ // Confirm content has been updated and the block is inserted at the correct position.
+ $post = get_post($this->postID);
+ $this->assertStringEndsWith( '', $post->post_content );
+ }
+
+ /**
+ * Test that the insert() method returns a WP_Error when the index is negative.
+ *
+ * @since 3.4.0
+ */
+ public function testInsertIndexNegative()
+ {
+ $result = \ConvertKit_Block_Post_Helper::insert(
+ post_id: $this->postID,
+ block_name: 'convertkit/form',
+ attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ],
+ position: 'index',
+ index: -1
+ );
+ $this->assertInstanceOf(\WP_Error::class, $result );
+ }
+
+ /**
+ * Test that the insert() method returns a WP_Error when the post does not exist.
+ *
+ * @since 3.4.0
+ */
+ public function testInsertWhenPostDoesNotExist()
+ {
+ $result = \ConvertKit_Block_Post_Helper::insert(
+ post_id: 999999,
+ block_name: 'convertkit/form',
+ attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ],
+ position: 'index',
+ index: 0
+ );
+ $this->assertInstanceOf(\WP_Error::class, $result );
+ }
+
+ /**
+ * Test that the update() method updates the attributes of an existing block.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdate()
+ {
+ $result = \ConvertKit_Block_Post_Helper::update(
+ post_id: $this->postID,
+ block_name: 'convertkit/form',
+ occurrence_index: 0,
+ attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ]
+ );
+
+ $this->assertIsArray( $result );
+ $this->assertEquals( $this->postID, $result['post_id'] );
+
+ $result = \ConvertKit_Block_Post_Helper::update(
+ post_id: $this->postID,
+ block_name: 'convertkit/form',
+ occurrence_index: 1,
+ attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ]
+ );
+
+ $this->assertIsArray( $result );
+ $this->assertEquals( $this->postID, $result['post_id'] );
+ }
+
+ /**
+ * Test that the update() method returns a WP_Error when the occurrence index is out of bounds.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateWhenOccurrenceIndexIsOutOfBounds()
+ {
+ $result = \ConvertKit_Block_Post_Helper::update(
+ post_id: $this->postID,
+ block_name: 'convertkit/form',
+ occurrence_index: 999,
+ attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ]
+ );
+ $this->assertInstanceOf(\WP_Error::class, $result );
+ }
+
+ /**
+ * Test that the update() method returns a WP_Error when the post does not exist.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateWhenPostDoesNotExist()
+ {
+ $result = \ConvertKit_Block_Post_Helper::update(
+ post_id: 999999,
+ block_name: 'convertkit/form',
+ occurrence_index: 0,
+ attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ]
+ );
+ $this->assertInstanceOf(\WP_Error::class, $result );
+ }
+
+ /**
+ * Test that the delete() method deletes an existing block.
+ *
+ * @since 3.4.0
+ */
+ public function testDelete()
+ {
+ $result = \ConvertKit_Block_Post_Helper::delete(
+ post_id: $this->postID,
+ block_name: 'convertkit/form',
+ occurrence_index: 1
+ );
+ $this->assertIsArray( $result );
+ $this->assertEquals( $this->postID, $result['post_id'] );
+
+ $result = \ConvertKit_Block_Post_Helper::delete(
+ post_id: $this->postID,
+ block_name: 'convertkit/form',
+ occurrence_index: 0
+ );
+ $this->assertIsArray( $result );
+ $this->assertEquals( $this->postID, $result['post_id'] );
+ }
+
+ /**
+ * Test that the delete() method returns a WP_Error when the occurrence index is out of bounds.
+ *
+ * @since 3.4.0
+ */
+ public function testDeleteWhenOccurrenceIndexIsOutOfBounds()
+ {
+ $result = \ConvertKit_Block_Post_Helper::delete(
+ post_id: $this->postID,
+ block_name: 'convertkit/form',
+ occurrence_index: 999
+ );
+ $this->assertInstanceOf(\WP_Error::class, $result );
+ }
+
+ /**
+ * Test that the delete() method returns a WP_Error when the post does not exist.
+ *
+ * @since 3.4.0
+ */
+ public function testDeleteWhenPostDoesNotExist()
+ {
+ $result = \ConvertKit_Block_Post_Helper::delete(
+ post_id: 999999,
+ block_name: 'convertkit/form',
+ occurrence_index: 0
+ );
+ $this->assertInstanceOf(\WP_Error::class, $result );
+ }
+
+ /**
+ * Mocks a post for testing.
+ *
+ * @since 3.4.0
+ * @return int
+ */
+ private function createPost()
+ {
+ // Create a Post with the given block.
+ return $this->factory->post->create(
+ [
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ 'post_title' => 'Block Post',
+ 'post_content' => '
+
Item #1
+
+
+
+
Item #1
+
+
+
+
Item #2: Adhaésionés altéram improbis mi pariendarum sit stulti triarium
+
+
+
+
+
+
+
+
Item #2
+
+
+
+
+
+
Item #3
+
+
+
+
+
+
+
+
+
+
Item #1
+
+
+
+
Item #4
+
+
+
+
Item #1
+
+
+
+
Item #5
+
+
+
+
Item #2
+
+
+
+
Item #2
+',
+ ]
+ );
+ }
+}
diff --git a/tests/Integration/MCPCategorySettingsGetTest.php b/tests/Integration/MCPCategorySettingsGetTest.php
new file mode 100644
index 000000000..3e9da9afb
--- /dev/null
+++ b/tests/Integration/MCPCategorySettingsGetTest.php
@@ -0,0 +1,237 @@
+assertArrayHasKey(self::ABILITY_NAME, $abilities);
+ $this->assertInstanceOf(\ConvertKit_MCP_Ability_Category_Settings_Get::class, $abilities[ self::ABILITY_NAME ]);
+ }
+
+ /**
+ * Test that permission_callback() rejects an input with no term_id.
+ *
+ * @since 3.4.0
+ */
+ public function testPermissionCallbackRejectsMissingTermId()
+ {
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->permission_callback([]);
+
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ $this->assertSame('convertkit_mcp_missing_term_id', $result->get_error_code());
+ }
+
+ /**
+ * Test that permission_callback() rejects a user who cannot edit the
+ * given category (Subscriber role has no manage_categories cap).
+ *
+ * @since 3.4.0
+ */
+ public function testPermissionCallbackDeniesWithoutEditTermCapability()
+ {
+ $term_id = $this->createCategoryAsAdmin();
+
+ // Switch to a subscriber.
+ $subscriber_id = static::factory()->user->create([ 'role' => 'subscriber' ]);
+ wp_set_current_user($subscriber_id);
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->permission_callback([ 'term_id' => $term_id ]);
+
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ $this->assertSame('convertkit_mcp_cannot_edit_term', $result->get_error_code());
+ }
+
+ /**
+ * Test that get returns the default settings when the Category has no
+ * Kit term meta stored.
+ *
+ * @since 3.4.0
+ */
+ public function testGetReturnsDefaultsWhenNoMetaExists()
+ {
+ $term_id = $this->createCategoryAsAdmin();
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback([ 'term_id' => $term_id ]);
+
+ $this->assertIsArray($result);
+ $this->assertSame($term_id, $result['term_id']);
+ $this->assertSame(0, $result['form']);
+ $this->assertSame('', $result['form_position']);
+ }
+
+ /**
+ * Test that get returns stored Kit settings for a Category that has
+ * term meta saved.
+ *
+ * @since 3.4.0
+ */
+ public function testGetReturnsStoredSettings()
+ {
+ $term_id = $this->createCategoryAsAdmin();
+
+ update_term_meta(
+ $term_id,
+ '_wp_convertkit_term_meta',
+ [
+ 'form' => 123,
+ 'form_position' => 'before',
+ ]
+ );
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback([ 'term_id' => $term_id ]);
+
+ $this->assertSame($term_id, $result['term_id']);
+ $this->assertSame(123, $result['form']);
+ $this->assertSame('before', $result['form_position']);
+ }
+
+ /**
+ * Test that get returns `form_position = after` correctly (round-trips
+ * both non-empty enum values, not just `before`).
+ *
+ * @since 3.4.0
+ */
+ public function testGetReturnsFormPositionAfter()
+ {
+ $term_id = $this->createCategoryAsAdmin();
+
+ update_term_meta(
+ $term_id,
+ '_wp_convertkit_term_meta',
+ [
+ 'form' => -1,
+ 'form_position' => 'after',
+ ]
+ );
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback([ 'term_id' => $term_id ]);
+
+ $this->assertSame(-1, $result['form']);
+ $this->assertSame('after', $result['form_position']);
+ }
+
+ /**
+ * Test that get returns a WP_Error when the term is not in the
+ * `category` taxonomy (e.g. it's a `post_tag`).
+ *
+ * @since 3.4.0
+ */
+ public function testGetReturnsErrorForNonCategoryTerm()
+ {
+ $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]);
+ wp_set_current_user($admin_id);
+
+ $tag_id = static::factory()->term->create([ 'taxonomy' => 'post_tag' ]);
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback([ 'term_id' => $tag_id ]);
+
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ $this->assertSame('convertkit_mcp_term_wrong_taxonomy', $result->get_error_code());
+ }
+
+ /**
+ * Test that get returns a WP_Error when the given term_id does not exist.
+ *
+ * @since 3.4.0
+ */
+ public function testGetReturnsErrorForNonExistentTerm()
+ {
+ $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]);
+ wp_set_current_user($admin_id);
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback([ 'term_id' => 999999 ]);
+
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ $this->assertSame('convertkit_mcp_term_not_found', $result->get_error_code());
+ }
+
+ /**
+ * Helper: creates an administrator user, switches to them, and returns
+ * a new Category term ID.
+ *
+ * @since 3.4.0
+ *
+ * @return int
+ */
+ private function createCategoryAsAdmin()
+ {
+ $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]);
+ wp_set_current_user($admin_id);
+
+ return static::factory()->term->create([ 'taxonomy' => 'category' ]);
+ }
+}
diff --git a/tests/Integration/MCPCategorySettingsUpdateTest.php b/tests/Integration/MCPCategorySettingsUpdateTest.php
new file mode 100644
index 000000000..2f350f88d
--- /dev/null
+++ b/tests/Integration/MCPCategorySettingsUpdateTest.php
@@ -0,0 +1,308 @@
+assertArrayHasKey(self::ABILITY_NAME, $abilities);
+ $this->assertInstanceOf(\ConvertKit_MCP_Ability_Category_Settings_Update::class, $abilities[ self::ABILITY_NAME ]);
+ }
+
+ /**
+ * Test that update writes both settings and returns the post-save state.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateWritesBothSettings()
+ {
+ $term_id = $this->createCategoryAsAdmin();
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback(
+ [
+ 'term_id' => $term_id,
+ 'form' => 123,
+ 'form_position' => 'before',
+ ]
+ );
+
+ $this->assertIsArray($result);
+ $this->assertSame($term_id, $result['term_id']);
+ $this->assertSame(123, $result['form']);
+ $this->assertSame('before', $result['form_position']);
+
+ // Confirm persisted to the DB.
+ $stored = get_term_meta($term_id, '_wp_convertkit_term_meta', true);
+ $this->assertSame(123, $stored['form']);
+ $this->assertSame('before', $stored['form_position']);
+ }
+
+ /**
+ * Test that a partial update writes only the provided key and preserves
+ * the other stored setting. Verifies ConvertKit_Term::save()'s internal
+ * merge behaviour is honoured.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdatePartialUpdatePreservesOtherKey()
+ {
+ $term_id = $this->createCategoryAsAdmin();
+
+ // Seed existing settings.
+ update_term_meta(
+ $term_id,
+ '_wp_convertkit_term_meta',
+ [
+ 'form' => 111,
+ 'form_position' => 'after',
+ ]
+ );
+
+ $abilities = convertkit_get_abilities();
+
+ // Update only the form.
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback(
+ [
+ 'term_id' => $term_id,
+ 'form' => 999,
+ ]
+ );
+
+ $this->assertSame(999, $result['form']);
+ $this->assertSame('after', $result['form_position']);
+ }
+
+ /**
+ * Test that update rejects unknown keys in the input.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateRejectsUnknownKeys()
+ {
+ $term_id = $this->createCategoryAsAdmin();
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback(
+ [
+ 'term_id' => $term_id,
+ 'form' => 123,
+ 'not_a_field' => 'garbage',
+ ]
+ );
+
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ $this->assertSame('convertkit_mcp_category_settings_unknown_keys', $result->get_error_code());
+ }
+
+ /**
+ * Test that update rejects a form_position value outside the enum
+ * (must be '', 'before' or 'after').
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateRejectsInvalidFormPosition()
+ {
+ $term_id = $this->createCategoryAsAdmin();
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback(
+ [
+ 'term_id' => $term_id,
+ 'form_position' => 'sideways',
+ ]
+ );
+
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ }
+
+ /**
+ * Test that update rejects a form value below the schema minimum
+ * (schema allows -1 and up).
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateRejectsInvalidFormValue()
+ {
+ $term_id = $this->createCategoryAsAdmin();
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback(
+ [
+ 'term_id' => $term_id,
+ 'form' => -99,
+ ]
+ );
+
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ }
+
+ /**
+ * Test that update rejects a call with only term_id and no settings.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateRejectsWhenNoSettingsProvided()
+ {
+ $term_id = $this->createCategoryAsAdmin();
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback([ 'term_id' => $term_id ]);
+
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ $this->assertSame('convertkit_mcp_category_settings_no_input', $result->get_error_code());
+ }
+
+ /**
+ * Test that update rejects a term that isn't in the `category` taxonomy.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateRejectsNonCategoryTerm()
+ {
+ $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]);
+ wp_set_current_user($admin_id);
+
+ $tag_id = static::factory()->term->create([ 'taxonomy' => 'post_tag' ]);
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback(
+ [
+ 'term_id' => $tag_id,
+ 'form' => 123,
+ ]
+ );
+
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ $this->assertSame('convertkit_mcp_term_wrong_taxonomy', $result->get_error_code());
+ }
+
+ /**
+ * Test that update returns a WP_Error when the given term_id does not exist.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateReturnsErrorForNonExistentTerm()
+ {
+ $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]);
+ wp_set_current_user($admin_id);
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback(
+ [
+ 'term_id' => 999999,
+ 'form' => 123,
+ ]
+ );
+
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ $this->assertSame('convertkit_mcp_term_not_found', $result->get_error_code());
+ }
+
+ /**
+ * Test that update -> get round-trip returns the updated values.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateThenGetRoundTrip()
+ {
+ $term_id = $this->createCategoryAsAdmin();
+
+ $abilities = convertkit_get_abilities();
+
+ $abilities[ self::ABILITY_NAME ]->execute_callback(
+ [
+ 'term_id' => $term_id,
+ 'form' => 555,
+ 'form_position' => 'after',
+ ]
+ );
+
+ $get_result = $abilities['kit/category-settings-get']->execute_callback([ 'term_id' => $term_id ]);
+
+ $this->assertSame(555, $get_result['form']);
+ $this->assertSame('after', $get_result['form_position']);
+ }
+
+ /**
+ * Helper: creates an administrator user, switches to them, and returns
+ * a new Category term ID.
+ *
+ * @since 3.4.0
+ *
+ * @return int
+ */
+ private function createCategoryAsAdmin()
+ {
+ $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]);
+ wp_set_current_user($admin_id);
+
+ return static::factory()->term->create([ 'taxonomy' => 'category' ]);
+ }
+}
diff --git a/tests/Integration/MCPContentBroadcastsTest.php b/tests/Integration/MCPContentBroadcastsTest.php
new file mode 100644
index 000000000..1d2a6a1f7
--- /dev/null
+++ b/tests/Integration/MCPContentBroadcastsTest.php
@@ -0,0 +1,368 @@
+postID = $this->createPostWithBroadcastsBlocks();
+ }
+
+ /**
+ * Performs actions after each test.
+ *
+ * @since 3.4.0
+ */
+ public function tearDown(): void
+ {
+ // Restore the current user.
+ wp_set_current_user(0);
+
+ // Deactivate Plugin.
+ deactivate_plugins('convertkit/wp-convertkit.php');
+
+ parent::tearDown();
+ }
+
+ /**
+ * The ability names registered by the Broadcasts block.
+ *
+ * @since 3.4.0
+ *
+ * @var string[]
+ */
+ private const BROADCASTS_ABILITY_NAMES = array(
+ 'kit/broadcasts-list',
+ 'kit/broadcasts-insert',
+ 'kit/broadcasts-update',
+ 'kit/broadcasts-delete',
+ );
+
+ /**
+ * Test that the Broadcasts block registers all four content abilities via
+ * the convertkit_abilities filter with the expected names.
+ *
+ * @since 3.4.0
+ */
+ public function testAbilitiesRegistered()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // The ability names and classes expected to be registered.
+ $expected = array(
+ 'kit/broadcasts-list' => \ConvertKit_MCP_Ability_Content_List::class,
+ 'kit/broadcasts-insert' => \ConvertKit_MCP_Ability_Content_Insert::class,
+ 'kit/broadcasts-update' => \ConvertKit_MCP_Ability_Content_Update::class,
+ 'kit/broadcasts-delete' => \ConvertKit_MCP_Ability_Content_Delete::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 edit the
+ * given post.
+ *
+ * @since 3.4.0
+ */
+ public function testPermissionCallbackDeniesWithoutEditPostCapability()
+ {
+ // Become a Subscriber (no edit_post 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::BROADCASTS_ABILITY_NAMES as $name ) {
+ // Execute the ability.
+ $result = $abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ]);
+
+ // Assert that the result is a WP_Error.
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ }
+ }
+
+ /**
+ * Test that the permission_callback() rejects a request with no post_id,
+ * with a clear error code.
+ *
+ * @since 3.4.0
+ */
+ public function testPermissionCallbackDeniesWithoutPostId()
+ {
+ // Become an Administrator (has every capability, so the only thing
+ // that can fail here is the missing post_id check).
+ $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]);
+ wp_set_current_user($admin_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::BROADCASTS_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 the permission_callback() permits an Administrator on a
+ * valid post_id.
+ *
+ * @since 3.4.0
+ */
+ public function testPermissionCallbackPermitsAdministrator()
+ {
+ // Become an Administrator.
+ $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]);
+ wp_set_current_user($admin_id);
+
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Assert that the abilities are permission granted.
+ foreach ( self::BROADCASTS_ABILITY_NAMES as $name ) {
+ // Execute the ability.
+ $this->assertTrue($abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ]));
+ }
+ }
+
+ /**
+ * Test that kit/broadcasts-list returns every Broadcasts block occurrence
+ * in the post.
+ *
+ * @since 3.4.0
+ */
+ public function testListReturnsAllBroadcastsOccurrencesInPost()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Execute the ability.
+ $result = $abilities['kit/broadcasts-list']->execute_callback([ 'post_id' => $this->postID ]);
+
+ $this->assertIsArray($result);
+ $this->assertSame($this->postID, $result['post_id']);
+ $this->assertSame(2, $result['count']);
+ $this->assertCount(2, $result['occurrences']);
+
+ // Each occurrence carries an occurrence_index and an attrs object
+ // holding the limit attribute from the seeded post content.
+ foreach ($result['occurrences'] as $i => $occurrence) {
+ $this->assertSame($i, $occurrence['occurrence_index']);
+ $this->assertArrayHasKey('attrs', $occurrence);
+ $this->assertSame(5, (int) $occurrence['attrs']['limit']);
+ }
+ }
+
+ /**
+ * Test that kit/broadcasts-insert appends a new Broadcasts block to the
+ * post, and returns the new occurrence_index.
+ *
+ * @since 3.4.0
+ */
+ public function testInsertAppendsBroadcastsBlock()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Execute the ability.
+ $result = $abilities['kit/broadcasts-insert']->execute_callback(
+ array(
+ 'post_id' => $this->postID,
+ 'attrs' => array(
+ 'limit' => 3,
+ 'display_image' => true,
+ ),
+ 'position' => 'append',
+ )
+ );
+
+ $this->assertIsArray($result);
+ $this->assertSame($this->postID, $result['post_id']);
+ $this->assertSame(2, $result['occurrence_index']);
+
+ // Confirm the post now contains three Broadcasts blocks.
+ $listed = $abilities['kit/broadcasts-list']->execute_callback([ 'post_id' => $this->postID ]);
+ $this->assertSame(3, $listed['count']);
+
+ // Confirm the newly inserted block carries the attrs we passed in.
+ $this->assertSame(3, (int) $listed['occurrences'][2]['attrs']['limit']);
+ $this->assertTrue( (bool) $listed['occurrences'][2]['attrs']['display_image']);
+ }
+
+ /**
+ * Test that kit/broadcasts-update changes the attrs of a specific
+ * occurrence, leaving other occurrences untouched.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateModifiesSingleOccurrence()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Update the second Broadcasts block (occurrence_index 1) to a different limit.
+ $result = $abilities['kit/broadcasts-update']->execute_callback(
+ array(
+ 'post_id' => $this->postID,
+ 'occurrence_index' => 1,
+ 'attrs' => array( 'limit' => 25 ),
+ )
+ );
+
+ $this->assertIsArray($result);
+ $this->assertSame(1, $result['occurrence_index']);
+
+ // Re-list and confirm: occurrence 0 unchanged, occurrence 1 has the new limit.
+ $listed = $abilities['kit/broadcasts-list']->execute_callback([ 'post_id' => $this->postID ]);
+ $this->assertSame(
+ 5,
+ (int) $listed['occurrences'][0]['attrs']['limit'],
+ 'kit/broadcasts-update must not modify other occurrences.'
+ );
+ $this->assertSame(
+ 25,
+ (int) $listed['occurrences'][1]['attrs']['limit'],
+ 'kit/broadcasts-update did not apply the new limit to the requested occurrence.'
+ );
+ }
+
+ /**
+ * Test that kit/broadcasts-delete removes a specific occurrence and the
+ * post now contains one fewer Broadcasts block.
+ *
+ * @since 3.4.0
+ */
+ public function testDeleteRemovesSingleOccurrence()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Execute the ability.
+ $result = $abilities['kit/broadcasts-delete']->execute_callback(
+ array(
+ 'post_id' => $this->postID,
+ 'occurrence_index' => 0,
+ )
+ );
+
+ $this->assertIsArray($result);
+ $this->assertSame(0, $result['occurrence_index']);
+
+ // Confirm the post now contains a single Broadcasts block.
+ $listed = $abilities['kit/broadcasts-list']->execute_callback([ 'post_id' => $this->postID ]);
+ $this->assertSame(1, $listed['count']);
+ }
+
+ /**
+ * Test that kit/broadcasts-update returns a WP_Error when asked to update
+ * an occurrence that does not exist, rather than silently mutating
+ * something else.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateOnMissingOccurrenceReturnsError()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Execute the ability.
+ $result = $abilities['kit/broadcasts-update']->execute_callback(
+ array(
+ 'post_id' => $this->postID,
+ 'occurrence_index' => 99,
+ 'attrs' => array( 'limit' => 5 ),
+ )
+ );
+
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ }
+
+ /**
+ * Creates a Post containing two convertkit/broadcasts blocks interleaved
+ * with non-Kit blocks, mirroring the fixture used by BlockPostHelperTest.
+ *
+ * @since 3.4.0
+ *
+ * @return int
+ */
+ private function createPostWithBroadcastsBlocks(): int
+ {
+ return $this->factory->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ 'post_title' => 'Broadcasts Abilities Fixture',
+ 'post_content' => '
+
Intro paragraph.
+
+
+
+
+
+
Middle paragraph.
+
+
+
+
+
+
Closing paragraph.
+',
+ )
+ );
+ }
+}
diff --git a/tests/Integration/MCPContentFormTest.php b/tests/Integration/MCPContentFormTest.php
new file mode 100644
index 000000000..1bad4ba47
--- /dev/null
+++ b/tests/Integration/MCPContentFormTest.php
@@ -0,0 +1,366 @@
+postID = $this->createPostWithFormBlocks();
+ }
+
+ /**
+ * Performs actions after each test.
+ *
+ * @since 3.4.0
+ */
+ public function tearDown(): void
+ {
+ // Restore the current user.
+ wp_set_current_user(0);
+
+ // Deactivate Plugin.
+ deactivate_plugins('convertkit/wp-convertkit.php');
+
+ parent::tearDown();
+ }
+
+ /**
+ * The ability names registered by the Form block.
+ *
+ * @since 3.4.0
+ *
+ * @var string[]
+ */
+ private const FORM_ABILITY_NAMES = array(
+ 'kit/form-list',
+ 'kit/form-insert',
+ 'kit/form-update',
+ 'kit/form-delete',
+ );
+
+ /**
+ * Test that the Form block registers all four content abilities via the
+ * convertkit_abilities filter with the expected names.
+ *
+ * @since 3.4.0
+ */
+ public function testAbilitiesRegistered()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // The ability names and classes expected to be registered.
+ $expected = array(
+ 'kit/form-list' => \ConvertKit_MCP_Ability_Content_List::class,
+ 'kit/form-insert' => \ConvertKit_MCP_Ability_Content_Insert::class,
+ 'kit/form-update' => \ConvertKit_MCP_Ability_Content_Update::class,
+ 'kit/form-delete' => \ConvertKit_MCP_Ability_Content_Delete::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 edit the
+ * given post.
+ *
+ * @since 3.4.0
+ */
+ public function testPermissionCallbackDeniesWithoutEditPostCapability()
+ {
+ // Become a Subscriber (no edit_post 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::FORM_ABILITY_NAMES as $name ) {
+ // Execute the ability.
+ $result = $abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ]);
+
+ // Assert that the result is a WP_Error.
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ }
+ }
+
+ /**
+ * Test that the permission_callback() rejects a request with no post_id,
+ * with a clear error code.
+ *
+ * @since 3.4.0
+ */
+ public function testPermissionCallbackDeniesWithoutPostId()
+ {
+ // Become an Administrator (has every capability, so the only thing
+ // that can fail here is the missing post_id check).
+ $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]);
+ wp_set_current_user($admin_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::FORM_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 the permission_callback() permits an Administrator on a
+ * valid post_id.
+ *
+ * @since 3.4.0
+ */
+ public function testPermissionCallbackPermitsAdministrator()
+ {
+ // Become an Administrator.
+ $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]);
+ wp_set_current_user($admin_id);
+
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Assert that the abilities are permission granted.
+ foreach ( self::FORM_ABILITY_NAMES as $name ) {
+ // Execute the ability.
+ $this->assertTrue($abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ]));
+ }
+ }
+
+ /**
+ * Test that kit/form-list returns every Form block occurrence in the
+ * post, with shape { post_id, count, occurrences: [{occurrence_index, attrs}] }.
+ *
+ * @since 3.4.0
+ */
+ public function testListReturnsAllFormOccurrencesInPost()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Execute the ability.
+ $result = $abilities['kit/form-list']->execute_callback([ 'post_id' => $this->postID ]);
+
+ $this->assertIsArray($result);
+ $this->assertSame($this->postID, $result['post_id']);
+ $this->assertSame(2, $result['count']);
+ $this->assertCount(2, $result['occurrences']);
+
+ // Each occurrence carries an occurrence_index and an attrs object
+ // holding the form ID from the seeded post content.
+ foreach ($result['occurrences'] as $i => $occurrence) {
+ $this->assertSame($i, $occurrence['occurrence_index']);
+ $this->assertArrayHasKey('attrs', $occurrence);
+ $this->assertSame(
+ (string) $_ENV['CONVERTKIT_API_FORM_ID'],
+ (string) $occurrence['attrs']['form']
+ );
+ }
+ }
+
+ /**
+ * Test that kit/form-insert appends a new Form block to the post, and
+ * returns the new occurrence_index.
+ *
+ * @since 3.4.0
+ */
+ public function testInsertAppendsFormBlock()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Execute the ability.
+ $result = $abilities['kit/form-insert']->execute_callback(
+ array(
+ 'post_id' => $this->postID,
+ 'attrs' => array( 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ),
+ 'position' => 'append',
+ )
+ );
+
+ $this->assertIsArray($result);
+ $this->assertSame($this->postID, $result['post_id']);
+ $this->assertSame(2, $result['occurrence_index']);
+
+ // Confirm the post now contains three Form blocks.
+ $listed = $abilities['kit/form-list']->execute_callback([ 'post_id' => $this->postID ]);
+ $this->assertSame(3, $listed['count']);
+ }
+
+ /**
+ * Test that kit/form-update changes the attrs of a specific occurrence,
+ * leaving other occurrences untouched.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateModifiesSingleOccurrence()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Update the second Form block (occurrence_index 1) to a different form ID.
+ $new_form_id = (string) ( (int) $_ENV['CONVERTKIT_API_FORM_ID'] + 1 );
+ $result = $abilities['kit/form-update']->execute_callback(
+ array(
+ 'post_id' => $this->postID,
+ 'occurrence_index' => 1,
+ 'attrs' => array( 'form' => $new_form_id ),
+ )
+ );
+
+ $this->assertIsArray($result);
+ $this->assertSame(1, $result['occurrence_index']);
+
+ // Re-list and confirm: occurrence 0 unchanged, occurrence 1 has the new form ID.
+ $listed = $abilities['kit/form-list']->execute_callback([ 'post_id' => $this->postID ]);
+ $this->assertSame(
+ (string) $_ENV['CONVERTKIT_API_FORM_ID'],
+ (string) $listed['occurrences'][0]['attrs']['form'],
+ 'kit/form-update must not modify other occurrences.'
+ );
+ $this->assertSame(
+ $new_form_id,
+ (string) $listed['occurrences'][1]['attrs']['form'],
+ 'kit/form-update did not apply the new form ID to the requested occurrence.'
+ );
+ }
+
+ /**
+ * Test that kit/form-delete removes a specific occurrence and the post
+ * now contains one fewer Form block.
+ *
+ * @since 3.4.0
+ */
+ public function testDeleteRemovesSingleOccurrence()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Execute the ability.
+ $result = $abilities['kit/form-delete']->execute_callback(
+ array(
+ 'post_id' => $this->postID,
+ 'occurrence_index' => 0,
+ )
+ );
+
+ $this->assertIsArray($result);
+ $this->assertSame(0, $result['occurrence_index']);
+
+ // Confirm the post now contains a single Form block.
+ $listed = $abilities['kit/form-list']->execute_callback([ 'post_id' => $this->postID ]);
+ $this->assertSame(1, $listed['count']);
+ }
+
+ /**
+ * Test that kit/form-update returns a WP_Error when asked to update an
+ * occurrence that does not exist, rather than silently mutating
+ * something else.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateOnMissingOccurrenceReturnsError()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Execute the ability.
+ $result = $abilities['kit/form-update']->execute_callback(
+ array(
+ 'post_id' => $this->postID,
+ 'occurrence_index' => 99,
+ 'attrs' => array( 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ),
+ )
+ );
+
+ // Assert that the result is a WP_Error.
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ }
+
+ /**
+ * Creates a Post containing two convertkit/form blocks interleaved with
+ * non-Kit blocks, mirroring the fixture used by BlockPostHelperTest.
+ *
+ * @since 3.4.0
+ *
+ * @return int
+ */
+ private function createPostWithFormBlocks(): int
+ {
+ return $this->factory->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ 'post_title' => 'Form Abilities Fixture',
+ 'post_content' => '
+
Intro paragraph.
+
+
+
+
+
+
Middle paragraph.
+
+
+
+
+
+
Closing paragraph.
+',
+ )
+ );
+ }
+}
diff --git a/tests/Integration/MCPContentFormTriggerTest.php b/tests/Integration/MCPContentFormTriggerTest.php
new file mode 100644
index 000000000..afceca369
--- /dev/null
+++ b/tests/Integration/MCPContentFormTriggerTest.php
@@ -0,0 +1,366 @@
+postID = $this->createPostWithFormTriggerBlocks();
+ }
+
+ /**
+ * Performs actions after each test.
+ *
+ * @since 3.4.0
+ */
+ public function tearDown(): void
+ {
+ // Restore the current user.
+ wp_set_current_user(0);
+
+ // Deactivate Plugin.
+ deactivate_plugins('convertkit/wp-convertkit.php');
+
+ parent::tearDown();
+ }
+
+ /**
+ * The ability names registered by the Form Trigger block.
+ *
+ * @since 3.4.0
+ *
+ * @var string[]
+ */
+ private const FORM_TRIGGER_ABILITY_NAMES = array(
+ 'kit/formtrigger-list',
+ 'kit/formtrigger-insert',
+ 'kit/formtrigger-update',
+ 'kit/formtrigger-delete',
+ );
+
+ /**
+ * Test that the Form Trigger block registers all four content abilities via the
+ * convertkit_abilities filter with the expected names.
+ *
+ * @since 3.4.0
+ */
+ public function testAbilitiesRegistered()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // The ability names and classes expected to be registered.
+ $expected = array(
+ 'kit/formtrigger-list' => \ConvertKit_MCP_Ability_Content_List::class,
+ 'kit/formtrigger-insert' => \ConvertKit_MCP_Ability_Content_Insert::class,
+ 'kit/formtrigger-update' => \ConvertKit_MCP_Ability_Content_Update::class,
+ 'kit/formtrigger-delete' => \ConvertKit_MCP_Ability_Content_Delete::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 edit the
+ * given post.
+ *
+ * @since 3.4.0
+ */
+ public function testPermissionCallbackDeniesWithoutEditPostCapability()
+ {
+ // Become a Subscriber (no edit_post 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::FORM_TRIGGER_ABILITY_NAMES as $name ) {
+ // Execute the ability.
+ $result = $abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ]);
+
+ // Assert that the result is a WP_Error.
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ }
+ }
+
+ /**
+ * Test that the permission_callback() rejects a request with no post_id,
+ * with a clear error code.
+ *
+ * @since 3.4.0
+ */
+ public function testPermissionCallbackDeniesWithoutPostId()
+ {
+ // Become an Administrator (has every capability, so the only thing
+ // that can fail here is the missing post_id check).
+ $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]);
+ wp_set_current_user($admin_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::FORM_TRIGGER_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 the permission_callback() permits an Administrator on a
+ * valid post_id.
+ *
+ * @since 3.4.0
+ */
+ public function testPermissionCallbackPermitsAdministrator()
+ {
+ // Become an Administrator.
+ $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]);
+ wp_set_current_user($admin_id);
+
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Assert that the abilities are permission granted.
+ foreach ( self::FORM_TRIGGER_ABILITY_NAMES as $name ) {
+ // Execute the ability.
+ $this->assertTrue($abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ]));
+ }
+ }
+
+ /**
+ * Test that kit/formtrigger-list returns every Form Trigger block occurrence in the
+ * post, with shape { post_id, count, occurrences: [{occurrence_index, attrs}] }.
+ *
+ * @since 3.4.0
+ */
+ public function testListReturnsAllFormTriggerOccurrencesInPost()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Execute the ability.
+ $result = $abilities['kit/formtrigger-list']->execute_callback([ 'post_id' => $this->postID ]);
+
+ $this->assertIsArray($result);
+ $this->assertSame($this->postID, $result['post_id']);
+ $this->assertSame(2, $result['count']);
+ $this->assertCount(2, $result['occurrences']);
+
+ // Each occurrence carries an occurrence_index and an attrs object
+ // holding the form ID from the seeded post content.
+ foreach ($result['occurrences'] as $i => $occurrence) {
+ $this->assertSame($i, $occurrence['occurrence_index']);
+ $this->assertArrayHasKey('attrs', $occurrence);
+ $this->assertSame(
+ (string) $_ENV['CONVERTKIT_API_FORM_FORMAT_MODAL_ID'],
+ (string) $occurrence['attrs']['form']
+ );
+ }
+ }
+
+ /**
+ * Test that kit/formtrigger-insert appends a new Form Trigger block to the post, and
+ * returns the new occurrence_index.
+ *
+ * @since 3.4.0
+ */
+ public function testInsertAppendsFormTriggerBlock()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Execute the ability.
+ $result = $abilities['kit/formtrigger-insert']->execute_callback(
+ array(
+ 'post_id' => $this->postID,
+ 'attrs' => array( 'form' => $_ENV['CONVERTKIT_API_FORM_FORMAT_MODAL_ID'] ),
+ 'position' => 'append',
+ )
+ );
+
+ $this->assertIsArray($result);
+ $this->assertSame($this->postID, $result['post_id']);
+ $this->assertSame(2, $result['occurrence_index']);
+
+ // Confirm the post now contains three Form Trigger blocks.
+ $listed = $abilities['kit/formtrigger-list']->execute_callback([ 'post_id' => $this->postID ]);
+ $this->assertSame(3, $listed['count']);
+ }
+
+ /**
+ * Test that kit/formtrigger-update changes the attrs of a specific occurrence,
+ * leaving other occurrences untouched.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateModifiesSingleOccurrence()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Update the second Form Trigger block (occurrence_index 1) to a different form ID.
+ $new_form_id = (string) ( (int) $_ENV['CONVERTKIT_API_FORM_FORMAT_MODAL_ID'] + 1 );
+ $result = $abilities['kit/formtrigger-update']->execute_callback(
+ array(
+ 'post_id' => $this->postID,
+ 'occurrence_index' => 1,
+ 'attrs' => array( 'form' => $new_form_id ),
+ )
+ );
+
+ $this->assertIsArray($result);
+ $this->assertSame(1, $result['occurrence_index']);
+
+ // Re-list and confirm: occurrence 0 unchanged, occurrence 1 has the new form ID.
+ $listed = $abilities['kit/formtrigger-list']->execute_callback([ 'post_id' => $this->postID ]);
+ $this->assertSame(
+ (string) $_ENV['CONVERTKIT_API_FORM_FORMAT_MODAL_ID'],
+ (string) $listed['occurrences'][0]['attrs']['form'],
+ 'kit/formtrigger-update must not modify other occurrences.'
+ );
+ $this->assertSame(
+ $new_form_id,
+ (string) $listed['occurrences'][1]['attrs']['form'],
+ 'kit/formtrigger-update did not apply the new form ID to the requested occurrence.'
+ );
+ }
+
+ /**
+ * Test that kit/formtrigger-delete removes a specific occurrence and the post
+ * now contains one fewer Form Trigger block.
+ *
+ * @since 3.4.0
+ */
+ public function testDeleteRemovesSingleOccurrence()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Execute the ability.
+ $result = $abilities['kit/formtrigger-delete']->execute_callback(
+ array(
+ 'post_id' => $this->postID,
+ 'occurrence_index' => 0,
+ )
+ );
+
+ $this->assertIsArray($result);
+ $this->assertSame(0, $result['occurrence_index']);
+
+ // Confirm the post now contains a single Form Trigger block.
+ $listed = $abilities['kit/formtrigger-list']->execute_callback([ 'post_id' => $this->postID ]);
+ $this->assertSame(1, $listed['count']);
+ }
+
+ /**
+ * Test that kit/formtrigger-update returns a WP_Error when asked to update an
+ * occurrence that does not exist, rather than silently mutating
+ * something else.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateOnMissingOccurrenceReturnsError()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Execute the ability.
+ $result = $abilities['kit/formtrigger-update']->execute_callback(
+ array(
+ 'post_id' => $this->postID,
+ 'occurrence_index' => 99,
+ 'attrs' => array( 'form' => $_ENV['CONVERTKIT_API_FORM_FORMAT_MODAL_ID'] ),
+ )
+ );
+
+ // Assert that the result is a WP_Error.
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ }
+
+ /**
+ * Creates a Post containing two convertkit/formtrigger blocks interleaved with
+ * non-Kit blocks, mirroring the fixture used by BlockPostHelperTest.
+ *
+ * @since 3.4.0
+ *
+ * @return int
+ */
+ private function createPostWithFormTriggerBlocks(): int
+ {
+ return $this->factory->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ 'post_title' => 'Form Trigger Abilities Fixture',
+ 'post_content' => '
+
Intro paragraph.
+
+
+
+
+
+
Middle paragraph.
+
+
+
+
+
+
Closing paragraph.
+',
+ )
+ );
+ }
+}
diff --git a/tests/Integration/MCPContentProductTest.php b/tests/Integration/MCPContentProductTest.php
new file mode 100644
index 000000000..50a6504ba
--- /dev/null
+++ b/tests/Integration/MCPContentProductTest.php
@@ -0,0 +1,394 @@
+postID = $this->createPostWithProductBlocks();
+ }
+
+ /**
+ * Performs actions after each test.
+ *
+ * @since 3.4.0
+ */
+ public function tearDown(): void
+ {
+ // Restore the current user.
+ wp_set_current_user(0);
+
+ // Deactivate Plugin.
+ deactivate_plugins('convertkit/wp-convertkit.php');
+
+ parent::tearDown();
+ }
+
+ /**
+ * The ability names registered by the Product block.
+ *
+ * @since 3.4.0
+ *
+ * @var string[]
+ */
+ private const PRODUCT_ABILITY_NAMES = array(
+ 'kit/product-list',
+ 'kit/product-insert',
+ 'kit/product-update',
+ 'kit/product-delete',
+ );
+
+ /**
+ * Test that the Product block registers all four content abilities via
+ * the convertkit_abilities filter with the expected names.
+ *
+ * @since 3.4.0
+ */
+ public function testAbilitiesRegistered()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // The ability names and classes expected to be registered.
+ $expected = array(
+ 'kit/product-list' => \ConvertKit_MCP_Ability_Content_List::class,
+ 'kit/product-insert' => \ConvertKit_MCP_Ability_Content_Insert::class,
+ 'kit/product-update' => \ConvertKit_MCP_Ability_Content_Update::class,
+ 'kit/product-delete' => \ConvertKit_MCP_Ability_Content_Delete::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 edit the
+ * given post.
+ *
+ * @since 3.4.0
+ */
+ public function testPermissionCallbackDeniesWithoutEditPostCapability()
+ {
+ // Become a Subscriber (no edit_post 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::PRODUCT_ABILITY_NAMES as $name ) {
+ // Execute the ability.
+ $result = $abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ]);
+
+ // Assert that the result is a WP_Error.
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ }
+ }
+
+ /**
+ * Test that the permission_callback() rejects a request with no post_id,
+ * with a clear error code.
+ *
+ * @since 3.4.0
+ */
+ public function testPermissionCallbackDeniesWithoutPostId()
+ {
+ // Become an Administrator (has every capability, so the only thing
+ // that can fail here is the missing post_id check).
+ $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]);
+ wp_set_current_user($admin_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::PRODUCT_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 the permission_callback() permits an Administrator on a
+ * valid post_id.
+ *
+ * @since 3.4.0
+ */
+ public function testPermissionCallbackPermitsAdministrator()
+ {
+ // Become an Administrator.
+ $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]);
+ wp_set_current_user($admin_id);
+
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Assert that the abilities are permission granted.
+ foreach ( self::PRODUCT_ABILITY_NAMES as $name ) {
+ // Execute the ability.
+ $this->assertTrue($abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ]));
+ }
+ }
+
+ /**
+ * Test that kit/product-list returns every Product block occurrence in
+ * the post.
+ *
+ * @since 3.4.0
+ */
+ public function testListReturnsAllProductOccurrencesInPost()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Execute the ability.
+ $result = $abilities['kit/product-list']->execute_callback([ 'post_id' => $this->postID ]);
+
+ $this->assertIsArray($result);
+ $this->assertSame($this->postID, $result['post_id']);
+ $this->assertSame(2, $result['count']);
+ $this->assertCount(2, $result['occurrences']);
+
+ // Each occurrence carries an occurrence_index and an attrs object
+ // holding the product ID from the seeded post content.
+ foreach ($result['occurrences'] as $i => $occurrence) {
+ $this->assertSame($i, $occurrence['occurrence_index']);
+ $this->assertArrayHasKey('attrs', $occurrence);
+ $this->assertSame(
+ (string) $_ENV['CONVERTKIT_API_PRODUCT_ID'],
+ (string) $occurrence['attrs']['product']
+ );
+ }
+ }
+
+ /**
+ * Test that kit/product-insert appends a new Product block to the post,
+ * and returns the new occurrence_index. Exercises all four primary
+ * Product attributes so each round-trips through the block helper.
+ *
+ * @since 3.4.0
+ */
+ public function testInsertAppendsProductBlock()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Execute the ability.
+ $result = $abilities['kit/product-insert']->execute_callback(
+ array(
+ 'post_id' => $this->postID,
+ 'attrs' => array(
+ 'product' => $_ENV['CONVERTKIT_API_PRODUCT_ID'],
+ 'text' => 'Buy this product',
+ 'discount_code' => $_ENV['CONVERTKIT_API_PRODUCT_DISCOUNT_CODE'],
+ 'checkout' => true,
+ ),
+ 'position' => 'append',
+ )
+ );
+
+ $this->assertIsArray($result);
+ $this->assertSame($this->postID, $result['post_id']);
+ // Two Product blocks existed in setUp(); the newly inserted one is the third.
+ $this->assertSame(2, $result['occurrence_index']);
+
+ // Confirm the post now contains three Product blocks.
+ $listed = $abilities['kit/product-list']->execute_callback([ 'post_id' => $this->postID ]);
+ $this->assertSame(3, $listed['count']);
+
+ // Confirm the newly inserted block carries the attrs we passed in.
+ $this->assertSame(
+ (string) $_ENV['CONVERTKIT_API_PRODUCT_ID'],
+ (string) $listed['occurrences'][2]['attrs']['product']
+ );
+ $this->assertSame('Buy this product', $listed['occurrences'][2]['attrs']['text']);
+ $this->assertSame(
+ (string) $_ENV['CONVERTKIT_API_PRODUCT_DISCOUNT_CODE'],
+ (string) $listed['occurrences'][2]['attrs']['discount_code']
+ );
+ $this->assertTrue( (bool) $listed['occurrences'][2]['attrs']['checkout']);
+ }
+
+ /**
+ * Test that kit/product-update changes the attrs of a specific
+ * occurrence, leaving other occurrences untouched.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateModifiesSingleOccurrence()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Update the second Product block (occurrence_index 1) to a different
+ // product ID and text.
+ $new_product_id = (string) ( (int) $_ENV['CONVERTKIT_API_PRODUCT_ID'] + 1 );
+ $result = $abilities['kit/product-update']->execute_callback(
+ array(
+ 'post_id' => $this->postID,
+ 'occurrence_index' => 1,
+ 'attrs' => array(
+ 'product' => $new_product_id,
+ 'text' => 'Updated CTA',
+ ),
+ )
+ );
+
+ $this->assertIsArray($result);
+ $this->assertSame(1, $result['occurrence_index']);
+
+ // Re-list and confirm: occurrence 0 unchanged, occurrence 1 has the
+ // new product ID and text.
+ $listed = $abilities['kit/product-list']->execute_callback([ 'post_id' => $this->postID ]);
+ $this->assertSame(
+ (string) $_ENV['CONVERTKIT_API_PRODUCT_ID'],
+ (string) $listed['occurrences'][0]['attrs']['product'],
+ 'kit/product-update must not modify other occurrences.'
+ );
+ $this->assertSame(
+ $new_product_id,
+ (string) $listed['occurrences'][1]['attrs']['product'],
+ 'kit/product-update did not apply the new product ID to the requested occurrence.'
+ );
+ $this->assertSame(
+ 'Updated CTA',
+ $listed['occurrences'][1]['attrs']['text'],
+ 'kit/product-update did not apply the new text to the requested occurrence.'
+ );
+ }
+
+ /**
+ * Test that kit/product-delete removes a specific occurrence and the
+ * post now contains one fewer Product block.
+ *
+ * @since 3.4.0
+ */
+ public function testDeleteRemovesSingleOccurrence()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Execute the ability.
+ $result = $abilities['kit/product-delete']->execute_callback(
+ array(
+ 'post_id' => $this->postID,
+ 'occurrence_index' => 0,
+ )
+ );
+
+ $this->assertIsArray($result);
+ $this->assertSame(0, $result['occurrence_index']);
+
+ // Confirm the post now contains a single Product block.
+ $listed = $abilities['kit/product-list']->execute_callback([ 'post_id' => $this->postID ]);
+ $this->assertSame(1, $listed['count']);
+ }
+
+ /**
+ * Test that kit/product-update returns a WP_Error when asked to update
+ * an occurrence that does not exist, rather than silently mutating
+ * something else.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateOnMissingOccurrenceReturnsError()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Execute the ability.
+ $result = $abilities['kit/product-update']->execute_callback(
+ array(
+ 'post_id' => $this->postID,
+ 'occurrence_index' => 99,
+ 'attrs' => array( 'product' => $_ENV['CONVERTKIT_API_PRODUCT_ID'] ),
+ )
+ );
+
+ // Assert that the result is a WP_Error.
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ }
+
+ /**
+ * Creates a Post containing two convertkit/product blocks interleaved
+ * with non-Kit blocks, mirroring the fixture used by BlockPostHelperTest.
+ *
+ * @since 3.4.0
+ *
+ * @return int
+ */
+ private function createPostWithProductBlocks(): int
+ {
+ return $this->factory->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ 'post_title' => 'Product Abilities Fixture',
+ 'post_content' => '
+
Intro paragraph.
+
+
+
+
+
+
Middle paragraph.
+
+
+
+
+
+
Closing paragraph.
+',
+ )
+ );
+ }
+}
diff --git a/tests/Integration/MCPPostSettingsGetTest.php b/tests/Integration/MCPPostSettingsGetTest.php
new file mode 100644
index 000000000..dbb9117a6
--- /dev/null
+++ b/tests/Integration/MCPPostSettingsGetTest.php
@@ -0,0 +1,218 @@
+assertArrayHasKey(self::ABILITY_NAME, $abilities);
+ $this->assertInstanceOf(\ConvertKit_MCP_Ability_Post_Settings_Get::class, $abilities[ self::ABILITY_NAME ]);
+ }
+
+ /**
+ * Test that permission_callback() rejects an input with no post_id.
+ *
+ * @since 3.4.0
+ */
+ public function testPermissionCallbackRejectsMissingPostId()
+ {
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->permission_callback([]);
+
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ $this->assertSame('convertkit_mcp_missing_post_id', $result->get_error_code());
+ }
+
+ /**
+ * Test that permission_callback() rejects a user who cannot edit the given post.
+ *
+ * @since 3.4.0
+ */
+ public function testPermissionCallbackDeniesWithoutEditPostCapability()
+ {
+ // Create a Post by an admin.
+ $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]);
+ $post_id = static::factory()->post->create([ 'post_author' => $admin_id ]);
+
+ // Switch to a subscriber.
+ $subscriber_id = static::factory()->user->create([ 'role' => 'subscriber' ]);
+ wp_set_current_user($subscriber_id);
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->permission_callback([ 'post_id' => $post_id ]);
+
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ $this->assertSame('convertkit_mcp_cannot_edit_post', $result->get_error_code());
+ }
+
+ /**
+ * Test that get returns the default settings when the Post has no
+ * Kit post meta stored.
+ *
+ * @since 3.4.0
+ */
+ public function testGetReturnsDefaultsWhenNoMetaExists()
+ {
+ $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]);
+ wp_set_current_user($admin_id);
+
+ $post_id = static::factory()->post->create();
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback([ 'post_id' => $post_id ]);
+
+ $this->assertIsArray($result);
+ $this->assertSame($post_id, $result['post_id']);
+ $this->assertSame('-1', $result['form']);
+ $this->assertSame('0', $result['landing_page']);
+ $this->assertSame('0', $result['tag']);
+ $this->assertSame('0', $result['restrict_content']);
+ }
+
+ /**
+ * Test that get returns the stored Kit settings for a Post that has
+ * post meta saved.
+ *
+ * @since 3.4.0
+ */
+ public function testGetReturnsStoredSettings()
+ {
+ $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]);
+ wp_set_current_user($admin_id);
+
+ $post_id = static::factory()->post->create();
+
+ update_post_meta(
+ $post_id,
+ '_wp_convertkit_post_meta',
+ [
+ 'form' => '123',
+ 'landing_page' => '456',
+ 'tag' => '789',
+ 'restrict_content' => 'product_101',
+ ]
+ );
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback([ 'post_id' => $post_id ]);
+
+ $this->assertSame($post_id, $result['post_id']);
+ $this->assertSame('123', $result['form']);
+ $this->assertSame('456', $result['landing_page']);
+ $this->assertSame('789', $result['tag']);
+ $this->assertSame('product_101', $result['restrict_content']);
+ }
+
+ /**
+ * Test that get returns settings for a Page (not just Posts) — confirms
+ * the ability isn't coupled to any single post type.
+ *
+ * @since 3.4.0
+ */
+ public function testGetWorksForPages()
+ {
+ $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]);
+ wp_set_current_user($admin_id);
+
+ $page_id = static::factory()->post->create([ 'post_type' => 'page' ]);
+
+ update_post_meta(
+ $page_id,
+ '_wp_convertkit_post_meta',
+ [
+ 'form' => '0',
+ 'landing_page' => '999',
+ 'tag' => '',
+ 'restrict_content' => '',
+ ]
+ );
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback([ 'post_id' => $page_id ]);
+
+ $this->assertSame('0', $result['form']);
+ $this->assertSame('999', $result['landing_page']);
+ }
+
+ /**
+ * Test that get returns a WP_Error when the given post_id does not exist.
+ *
+ * @since 3.4.0
+ */
+ public function testGetReturnsErrorForNonExistentPost()
+ {
+ $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]);
+ wp_set_current_user($admin_id);
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback([ 'post_id' => 999999 ]);
+
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ $this->assertSame('convertkit_mcp_post_not_found', $result->get_error_code());
+ }
+}
diff --git a/tests/Integration/MCPPostSettingsUpdateTest.php b/tests/Integration/MCPPostSettingsUpdateTest.php
new file mode 100644
index 000000000..bcc6ce492
--- /dev/null
+++ b/tests/Integration/MCPPostSettingsUpdateTest.php
@@ -0,0 +1,393 @@
+assertArrayHasKey(self::ABILITY_NAME, $abilities);
+ $this->assertInstanceOf(\ConvertKit_MCP_Ability_Post_Settings_Update::class, $abilities[ self::ABILITY_NAME ]);
+ }
+
+ /**
+ * Test that update writes all four settings and returns the post-save state.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateWritesAllFourSettings()
+ {
+ $post_id = $this->createPostAsAdmin();
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback(
+ [
+ 'post_id' => $post_id,
+ 'form' => '123',
+ 'landing_page' => '456',
+ 'tag' => '789',
+ 'restrict_content' => 'product_101',
+ ]
+ );
+
+ $this->assertIsArray($result);
+ $this->assertSame($post_id, $result['post_id']);
+ $this->assertSame('123', $result['form']);
+ $this->assertSame('456', $result['landing_page']);
+ $this->assertSame('789', $result['tag']);
+ $this->assertSame('product_101', $result['restrict_content']);
+
+ // Confirm persisted to the DB.
+ $stored = get_post_meta($post_id, '_wp_convertkit_post_meta', true);
+ $this->assertSame('123', $stored['form']);
+ $this->assertSame('456', $stored['landing_page']);
+ $this->assertSame('789', $stored['tag']);
+ $this->assertSame('product_101', $stored['restrict_content']);
+ }
+
+ /**
+ * Test that a partial update writes only the provided keys and preserves
+ * the other stored settings.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdatePartialUpdatePreservesOtherKeys()
+ {
+ $post_id = $this->createPostAsAdmin();
+
+ // Seed existing settings.
+ update_post_meta(
+ $post_id,
+ '_wp_convertkit_post_meta',
+ [
+ 'form' => '111',
+ 'landing_page' => '222',
+ 'tag' => '333',
+ 'restrict_content' => 'form_444',
+ ]
+ );
+
+ $abilities = convertkit_get_abilities();
+
+ // Update only the form.
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback(
+ [
+ 'post_id' => $post_id,
+ 'form' => '999',
+ ]
+ );
+
+ $this->assertSame('999', $result['form']);
+ $this->assertSame('222', $result['landing_page']);
+ $this->assertSame('333', $result['tag']);
+ $this->assertSame('form_444', $result['restrict_content']);
+ }
+
+ /**
+ * Test that update rejects unknown keys in the input.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateRejectsUnknownKeys()
+ {
+ $post_id = $this->createPostAsAdmin();
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback(
+ [
+ 'post_id' => $post_id,
+ 'form' => '123',
+ 'not_a_field' => 'garbage',
+ ]
+ );
+
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ $this->assertSame('convertkit_mcp_post_settings_unknown_keys', $result->get_error_code());
+ }
+
+ /**
+ * Test that update rejects a malformed form value (e.g. `abc`).
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateRejectsInvalidFormValue()
+ {
+ $post_id = $this->createPostAsAdmin();
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback(
+ [
+ 'post_id' => $post_id,
+ 'form' => 'abc',
+ ]
+ );
+
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ }
+
+ /**
+ * Test that update accepts `0` for restrict_content as a synonym for
+ * "no restriction". Matches what ConvertKit_Post::get_default_settings()
+ * returns and what the metabox / Gutenberg sidebar submit for the "None"
+ * option, so the get -> update round-trip works for defaulted posts.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateAcceptsZeroForRestrictContent()
+ {
+ $post_id = $this->createPostAsAdmin();
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback(
+ [
+ 'post_id' => $post_id,
+ 'restrict_content' => '0',
+ ]
+ );
+
+ $this->assertIsArray($result);
+ $this->assertSame('0', $result['restrict_content']);
+ }
+
+ /**
+ * Test that update rejects a malformed restrict_content prefix
+ * (must be form_, tag_ or product_).
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateRejectsInvalidRestrictContentFormat()
+ {
+ $post_id = $this->createPostAsAdmin();
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback(
+ [
+ 'post_id' => $post_id,
+ 'restrict_content' => 'sequence_123',
+ ]
+ );
+
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ }
+
+ /**
+ * Test that update rejects a call with only post_id and no settings.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateRejectsWhenNoSettingsProvided()
+ {
+ $post_id = $this->createPostAsAdmin();
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback([ 'post_id' => $post_id ]);
+
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ $this->assertSame('convertkit_mcp_post_settings_no_input', $result->get_error_code());
+ }
+
+ /**
+ * Test that update returns a WP_Error when the given post_id does not exist.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateReturnsErrorForNonExistentPost()
+ {
+ $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]);
+ wp_set_current_user($admin_id);
+
+ $abilities = convertkit_get_abilities();
+
+ $result = $abilities[ self::ABILITY_NAME ]->execute_callback(
+ [
+ 'post_id' => 999999,
+ 'form' => '123',
+ ]
+ );
+
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ $this->assertSame('convertkit_mcp_post_not_found', $result->get_error_code());
+ }
+
+ /**
+ * Test that update -> get round-trip returns the updated values.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateThenGetRoundTrip()
+ {
+ $post_id = $this->createPostAsAdmin();
+
+ $abilities = convertkit_get_abilities();
+
+ $abilities[ self::ABILITY_NAME ]->execute_callback(
+ [
+ 'post_id' => $post_id,
+ 'form' => '555',
+ 'tag' => '666',
+ ]
+ );
+
+ $get_result = $abilities['kit/post-settings-get']->execute_callback([ 'post_id' => $post_id ]);
+
+ $this->assertSame('555', $get_result['form']);
+ $this->assertSame('666', $get_result['tag']);
+ }
+
+ /**
+ * Test that setting restrict_content on a published Post populates
+ * the Restrict Content cache option. Proves integration with the
+ * ConvertKit_Restrict_Content_Cache class.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateRestrictContentPopulatesCache()
+ {
+ $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]);
+ wp_set_current_user($admin_id);
+
+ $post_id = static::factory()->post->create(
+ [
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ ]
+ );
+
+ $abilities = convertkit_get_abilities();
+
+ $abilities[ self::ABILITY_NAME ]->execute_callback(
+ [
+ 'post_id' => $post_id,
+ 'restrict_content' => 'product_101',
+ ]
+ );
+
+ $cache = get_option(\ConvertKit_Restrict_Content_Cache::OPTION_NAME);
+
+ $this->assertIsArray($cache);
+ $this->assertArrayHasKey($post_id, $cache);
+ }
+
+ /**
+ * Test that clearing restrict_content removes the Post from the
+ * Restrict Content cache option.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateClearingRestrictContentRemovesFromCache()
+ {
+ $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]);
+ wp_set_current_user($admin_id);
+
+ $post_id = static::factory()->post->create(
+ [
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ ]
+ );
+
+ $abilities = convertkit_get_abilities();
+
+ // Enable.
+ $abilities[ self::ABILITY_NAME ]->execute_callback(
+ [
+ 'post_id' => $post_id,
+ 'restrict_content' => 'tag_123',
+ ]
+ );
+ $this->assertArrayHasKey($post_id, get_option(\ConvertKit_Restrict_Content_Cache::OPTION_NAME));
+
+ // Clear.
+ $abilities[ self::ABILITY_NAME ]->execute_callback(
+ [
+ 'post_id' => $post_id,
+ 'restrict_content' => '',
+ ]
+ );
+ $this->assertArrayNotHasKey($post_id, get_option(\ConvertKit_Restrict_Content_Cache::OPTION_NAME));
+ }
+
+ /**
+ * Helper: creates an administrator user, switches to them, and returns
+ * a new Post ID.
+ *
+ * @since 3.4.0
+ *
+ * @return int
+ */
+ private function createPostAsAdmin()
+ {
+ $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]);
+ wp_set_current_user($admin_id);
+
+ return static::factory()->post->create();
+ }
+}
diff --git a/tests/Integration/MCPResourceTest.php b/tests/Integration/MCPResourceTest.php
new file mode 100644
index 000000000..e90cf8fd0
--- /dev/null
+++ b/tests/Integration/MCPResourceTest.php
@@ -0,0 +1,307 @@
+settings = new \ConvertKit_Settings();
+ update_option(
+ $this->settings::SETTINGS_NAME,
+ [
+ 'access_token' => $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'],
+ 'refresh_token' => $_ENV['CONVERTKIT_OAUTH_REFRESH_TOKEN'],
+ ]
+ );
+ }
+
+ /**
+ * Performs actions after each test.
+ *
+ * @since 3.4.0
+ */
+ public function tearDown(): void
+ {
+ // Delete credentials and any cached resources so each test starts clean.
+ delete_option($this->settings::SETTINGS_NAME);
+
+ foreach ( self::RESOURCE_CLASSES as $resource_class ) {
+ $resource = new $resource_class();
+ delete_option($resource->settings_name);
+ delete_option($resource->settings_name . '_last_queried');
+ }
+
+ // Restore the current user.
+ wp_set_current_user(0);
+
+ // Deactivate Plugin.
+ deactivate_plugins('convertkit/wp-convertkit.php');
+
+ parent::tearDown();
+ }
+
+ /**
+ * Map of resource-list ability names to the ConvertKit_Resource_* class
+ * backing them. Used by tests that need to seed / clear the resource
+ * cache alongside the ability under test.
+ *
+ * @since 3.4.0
+ *
+ * @var array
+ */
+ private const RESOURCE_CLASSES = array(
+ 'kit/forms-list' => \ConvertKit_Resource_Forms::class,
+ 'kit/tags-list' => \ConvertKit_Resource_Tags::class,
+ 'kit/landing-pages-list' => \ConvertKit_Resource_Landing_Pages::class,
+ 'kit/products-list' => \ConvertKit_Resource_Products::class,
+ );
+
+ /**
+ * Test that the four resource-list abilities are registered with the
+ * `convertkit_abilities` filter, so they are picked up by the Abilities
+ * API and exposed by the MCP server.
+ *
+ * @since 3.4.0
+ */
+ public function testAbilitiesRegistered()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // The ability names and classes expected to be registered.
+ $expected = array(
+ 'kit/forms-list' => \ConvertKit_MCP_Ability_Resource_Forms::class,
+ 'kit/tags-list' => \ConvertKit_MCP_Ability_Resource_Tags::class,
+ 'kit/landing-pages-list' => \ConvertKit_MCP_Ability_Resource_Landing_Pages::class,
+ 'kit/products-list' => \ConvertKit_MCP_Ability_Resource_Products::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 without the
+ * edit_posts capability.
+ *
+ * @since 3.4.0
+ */
+ public function testPermissionCallbackDeniesWithoutEditPostsCapability()
+ {
+ // Become a Subscriber (no edit_posts 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 ( array_keys( self::RESOURCE_CLASSES ) 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 the permission_callback() permits a user with the edit_posts
+ * capability (e.g. an Editor or Administrator).
+ *
+ * @since 3.4.0
+ */
+ public function testPermissionCallbackPermitsWithEditPostsCapability()
+ {
+ // Become an Editor (has edit_posts capability).
+ $editor_id = static::factory()->user->create([ 'role' => 'editor' ]);
+ wp_set_current_user($editor_id);
+
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Assert that the abilities are permission granted.
+ foreach ( array_keys( self::RESOURCE_CLASSES ) as $name ) {
+ // Execute the ability.
+ $this->assertTrue($abilities[ $name ]->permission_callback([]));
+ }
+ }
+
+ /**
+ * Test that the execute_callback() returns an empty (but successful) list
+ * when the resource cache is empty, rather than an error.
+ *
+ * @since 3.4.0
+ */
+ public function testReturnsEmptyListWhenNoResourcesAreCached()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ foreach ( self::RESOURCE_CLASSES as $name => $resource_class ) {
+ // Ensure the cache is empty for this resource.
+ delete_option( ( new $resource_class() )->settings_name );
+
+ // Execute the ability.
+ $result = $abilities[ $name ]->execute_callback([]);
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('count', $result);
+ $this->assertArrayHasKey('items', $result);
+ $this->assertSame(0, $result['count']);
+ $this->assertSame([], $result['items']);
+ }
+ }
+
+ /**
+ * Test that execute_callback() returns the cached items, shaped as
+ * { count, items: [{ id, name, ... }] }, when the resource cache is
+ * populated.
+ *
+ * @since 3.4.0
+ */
+ public function testReturnsCachedItems()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Execute the abilities.
+ foreach ( self::RESOURCE_CLASSES as $name => $resource_class ) {
+ // Populate the resource cache from the Kit API.
+ ( new $resource_class() )->init();
+
+ // Execute the ability.
+ $result = $abilities[ $name ]->execute_callback([]);
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('count', $result);
+ $this->assertArrayHasKey('items', $result);
+ $this->assertGreaterThan(0, $result['count']);
+ $this->assertCount($result['count'], $result['items']);
+
+ // Each item must have id and name.
+ foreach ($result['items'] as $item) {
+ $this->assertArrayHasKey('id', $item);
+ $this->assertArrayHasKey('name', $item);
+ $this->assertIsInt($item['id']);
+ $this->assertIsString($item['name']);
+ }
+ }
+ }
+
+ /**
+ * Test that the Forms ability includes the `format` field on each item,
+ * and that legacy forms (which omit `format` in the raw resource cache)
+ * fall back to 'inline'.
+ *
+ * @since 3.4.0
+ */
+ public function testFormsItemsIncludeFormat()
+ {
+ // Populate the resource cache from the Kit API.
+ ( new \ConvertKit_Resource_Forms() )->init();
+
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Execute the ability.
+ $result = $abilities['kit/forms-list']->execute_callback([]);
+
+ // Assert that the result is an array.
+ $this->assertGreaterThan(0, $result['count']);
+
+ // Assert that the result has items.
+ $allowedFormats = [ 'inline', 'modal', 'slide in', 'sticky bar' ];
+ foreach ($result['items'] as $item) {
+ $this->assertArrayHasKey('format', $item);
+ $this->assertContains($item['format'], $allowedFormats);
+ }
+ }
+
+ /**
+ * Test that the output schema returned by each ability advertises the
+ * same keys (id, name, plus format for forms) that execute_callback()
+ * actually returns. Guards against drift between map_item() and
+ * get_item_schema().
+ *
+ * @since 3.4.0
+ */
+ public function testOutputSchemaMatchesExecuteShape()
+ {
+ // Resolve the abilities array via the same helper the MCP server uses.
+ $abilities = convertkit_get_abilities();
+
+ // Execute the abilities.
+ foreach ( self::RESOURCE_CLASSES as $name => $resource_class ) {
+ // Populate the resource cache from the Kit API.
+ ( new $resource_class() )->init();
+
+ // Execute the ability.
+ $result = $abilities[ $name ]->execute_callback([]);
+
+ if ($result['count'] === 0) {
+ // No items to compare against; skip this ability.
+ continue;
+ }
+
+ // Assert that the output schema is an object.
+ $schema = $abilities[ $name ]->get_output_schema();
+ $this->assertSame('object', $schema['type']);
+ $this->assertSame([ 'count', 'items' ], $schema['required']);
+
+ // Assert that the item schema keys match the result item keys.
+ $itemSchemaKeys = array_keys($schema['properties']['items']['items']['properties']);
+ $itemKeys = array_keys($result['items'][0]);
+
+ sort($itemSchemaKeys);
+ sort($itemKeys);
+
+ $this->assertSame($itemSchemaKeys, $itemKeys);
+ }
+ }
+}
diff --git a/tests/Integration/MCPSettingsBroadcastsTest.php b/tests/Integration/MCPSettingsBroadcastsTest.php
new file mode 100644
index 000000000..d00089f59
--- /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' => '',
+ ]
+ );
+ }
+}
diff --git a/tests/Integration/MCPSettingsGeneralTest.php b/tests/Integration/MCPSettingsGeneralTest.php
new file mode 100644
index 000000000..b9d75ef56
--- /dev/null
+++ b/tests/Integration/MCPSettingsGeneralTest.php
@@ -0,0 +1,613 @@
+ \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);
+
+ // Confirm per-post-type Default Form settings are returned for
+ // each supported post type (page, post at minimum).
+ $this->assertArrayHasKey('page_form', $result);
+ $this->assertArrayHasKey('page_form_position', $result);
+ $this->assertArrayHasKey('page_form_position_element', $result);
+ $this->assertArrayHasKey('page_form_position_element_index', $result);
+ $this->assertArrayHasKey('post_form', $result);
+ $this->assertArrayHasKey('post_form_position', $result);
+ $this->assertArrayHasKey('post_form_position_element', $result);
+ $this->assertArrayHasKey('post_form_position_element_index', $result);
+
+ // Confirm per-post-type Default Form values round-trip correctly.
+ $this->assertEquals( (int) $_ENV['CONVERTKIT_API_FORM_ID'], $result['page_form']);
+ $this->assertEquals('before_content', $result['page_form_position']);
+ $this->assertEquals('h2', $result['page_form_position_element']);
+ $this->assertEquals(2, $result['page_form_position_element_index']);
+ }
+
+ /**
+ * Test that kit/settings-general-get returns all per-post-type Default
+ * Form keys for each supported post type, exercising the dynamic
+ * schema generation.
+ *
+ * @since 3.4.0
+ */
+ public function testGetReturnsPerPostTypeDefaultFormKeys()
+ {
+ $this->populateSettings();
+
+ $abilities = convertkit_get_abilities();
+ $result = $abilities['kit/settings-general-get']->execute_callback([]);
+
+ foreach ( convertkit_get_supported_post_types() as $post_type ) {
+ $this->assertArrayHasKey($post_type . '_form', $result);
+ $this->assertArrayHasKey($post_type . '_form_position', $result);
+ $this->assertArrayHasKey($post_type . '_form_position_element', $result);
+ $this->assertArrayHasKey($post_type . '_form_position_element_index', $result);
+ $this->assertIsInt($result[ $post_type . '_form' ]);
+ $this->assertIsString($result[ $post_type . '_form_position' ]);
+ $this->assertIsString($result[ $post_type . '_form_position_element' ]);
+ $this->assertIsInt($result[ $post_type . '_form_position_element_index' ]);
+ }
+ }
+
+ /**
+ * Test that kit/settings-general-update accepts a positive integer
+ * Form ID for a per-post-type Default Form setting.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdatePageFormAcceptsPositiveInteger()
+ {
+ $abilities = convertkit_get_abilities();
+ $result = $abilities['kit/settings-general-update']->execute_callback(
+ [ 'page_form' => 123 ]
+ );
+
+ $this->assertIsArray($result);
+ $this->assertSame(123, $result['page_form']);
+ }
+
+ /**
+ * Test that kit/settings-general-update accepts `-1` as the Plugin
+ * Default sentinel value for a per-post-type Default Form setting.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdatePageFormAcceptsMinusOne()
+ {
+ $abilities = convertkit_get_abilities();
+ $result = $abilities['kit/settings-general-update']->execute_callback(
+ [ 'page_form' => -1 ]
+ );
+
+ $this->assertSame(-1, $result['page_form']);
+ }
+
+ /**
+ * Test that kit/settings-general-update accepts `0` as the "None"
+ * value for a per-post-type Default Form setting.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdatePageFormAcceptsZero()
+ {
+ $abilities = convertkit_get_abilities();
+ $result = $abilities['kit/settings-general-update']->execute_callback(
+ [ 'page_form' => 0 ]
+ );
+
+ $this->assertSame(0, $result['page_form']);
+ }
+
+ /**
+ * Test that kit/settings-general-update rejects a Form value below the
+ * schema minimum of `-1`.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdatePageFormRejectsBelowMinimum()
+ {
+ $abilities = convertkit_get_abilities();
+ $result = $abilities['kit/settings-general-update']->execute_callback(
+ [ 'page_form' => -99 ]
+ );
+
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ }
+
+ /**
+ * Test that kit/settings-general-update accepts every value in the
+ * `_form_position` enum.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdatePageFormPositionAcceptsEnumValues()
+ {
+ $abilities = convertkit_get_abilities();
+
+ foreach ( [ 'before_content', 'after_content', 'before_after_content', 'after_element' ] as $position ) {
+ $result = $abilities['kit/settings-general-update']->execute_callback(
+ [ 'page_form_position' => $position ]
+ );
+
+ $this->assertIsArray($result, "Expected `$position` to be accepted.");
+ $this->assertSame($position, $result['page_form_position']);
+ }
+ }
+
+ /**
+ * Test that kit/settings-general-update rejects a
+ * `_form_position` value outside the enum.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdatePageFormPositionRejectsUnknownValue()
+ {
+ $abilities = convertkit_get_abilities();
+ $result = $abilities['kit/settings-general-update']->execute_callback(
+ [ 'page_form_position' => 'middle' ]
+ );
+
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ }
+
+ /**
+ * Test that kit/settings-general-update accepts every value in the
+ * `_form_position_element` enum.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdatePageFormPositionElementAcceptsEnumValues()
+ {
+ $abilities = convertkit_get_abilities();
+
+ foreach ( [ 'p', 'h2', 'h3', 'h4', 'h5', 'h6', 'img' ] as $element ) {
+ $result = $abilities['kit/settings-general-update']->execute_callback(
+ [ 'page_form_position_element' => $element ]
+ );
+
+ $this->assertIsArray($result, "Expected `$element` to be accepted.");
+ $this->assertSame($element, $result['page_form_position_element']);
+ }
+ }
+
+ /**
+ * Test that kit/settings-general-update rejects `h1` for
+ * `_form_position_element`. h1 is deliberately excluded
+ * from the enum because it's the post title.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdatePageFormPositionElementRejectsH1()
+ {
+ $abilities = convertkit_get_abilities();
+ $result = $abilities['kit/settings-general-update']->execute_callback(
+ [ 'page_form_position_element' => 'h1' ]
+ );
+
+ $this->assertInstanceOf(\WP_Error::class, $result);
+ }
+
+ /**
+ * Test the acceptable range for
+ * `_form_position_element_index` (1..999 inclusive).
+ *
+ * @since 3.4.0
+ */
+ public function testUpdatePageFormPositionElementIndexRange()
+ {
+ $abilities = convertkit_get_abilities();
+
+ // Boundary values are accepted.
+ foreach ( [ 1, 999 ] as $index ) {
+ $result = $abilities['kit/settings-general-update']->execute_callback(
+ [ 'page_form_position_element_index' => $index ]
+ );
+ $this->assertIsArray($result, "Expected index `$index` to be accepted.");
+ $this->assertSame($index, $result['page_form_position_element_index']);
+ }
+
+ // Out-of-range values are rejected.
+ foreach ( [ 0, 1000, -1 ] as $index ) {
+ $result = $abilities['kit/settings-general-update']->execute_callback(
+ [ 'page_form_position_element_index' => $index ]
+ );
+ $this->assertInstanceOf(\WP_Error::class, $result, "Expected index `$index` to be rejected.");
+ }
+ }
+
+ /**
+ * Test that a partial update to `page_form` preserves other settings
+ * — including other per-post-type keys and unrelated settings.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdatePartialPageFormPreservesOtherSettings()
+ {
+ // Seed a full settings state.
+ $this->populateSettings();
+
+ $abilities = convertkit_get_abilities();
+
+ // Update only page_form.
+ $result = $abilities['kit/settings-general-update']->execute_callback(
+ [ 'page_form' => 999 ]
+ );
+
+ $this->assertSame(999, $result['page_form']);
+
+ // Confirm unrelated settings are unchanged.
+ $this->assertEquals('on', $result['debug']);
+
+ // Confirm the other page_form_* keys are unchanged.
+ $this->assertEquals('before_content', $result['page_form_position']);
+ $this->assertEquals('h2', $result['page_form_position_element']);
+ $this->assertEquals(2, $result['page_form_position_element_index']);
+
+ // Confirm the post_form_* keys are unchanged.
+ $this->assertEquals( (int) $_ENV['CONVERTKIT_API_FORM_ID'], $result['post_form']);
+ }
+
+ /**
+ * Test that update -> get round-trip returns the updated per-post-type
+ * Default Form value.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdatePageFormRoundTrip()
+ {
+ $abilities = convertkit_get_abilities();
+
+ $abilities['kit/settings-general-update']->execute_callback(
+ [ 'page_form' => 555 ]
+ );
+
+ $get_result = $abilities['kit/settings-general-get']->execute_callback([]);
+
+ $this->assertSame(555, $get_result['page_form']);
+ }
+
+ /**
+ * Test that a custom post type added via the
+ * `convertkit_supported_post_types` filter is dynamically added to
+ * the schema, and that get/update work against its keys.
+ *
+ * This proves the schema iterates the filter's supported post types
+ * at request time rather than using a hardcoded list.
+ *
+ * @since 3.4.0
+ */
+ public function testGetIncludesCustomPostTypeRegisteredViaFilter()
+ {
+ // Register the CPT with WordPress first so the filter's output
+ // is meaningful (get_post_type_object() succeeds downstream).
+ register_post_type(
+ 'kit_test_cpt',
+ [
+ 'public' => true,
+ 'label' => 'Kit Test CPT',
+ ]
+ );
+
+ // Append the CPT to the supported post types.
+ $filter = function ( $post_types ) {
+ $post_types[] = 'kit_test_cpt';
+ return $post_types;
+ };
+ add_filter('convertkit_supported_post_types', $filter);
+
+ try {
+ $abilities = convertkit_get_abilities();
+
+ // Get should include the CPT's four Default Form keys.
+ $get_result = $abilities['kit/settings-general-get']->execute_callback([]);
+ $this->assertArrayHasKey('kit_test_cpt_form', $get_result);
+ $this->assertArrayHasKey('kit_test_cpt_form_position', $get_result);
+ $this->assertArrayHasKey('kit_test_cpt_form_position_element', $get_result);
+ $this->assertArrayHasKey('kit_test_cpt_form_position_element_index', $get_result);
+
+ // Update should accept them.
+ $update_result = $abilities['kit/settings-general-update']->execute_callback(
+ [
+ 'kit_test_cpt_form' => 777,
+ 'kit_test_cpt_form_position' => 'after_content',
+ 'kit_test_cpt_form_position_element' => 'h3',
+ 'kit_test_cpt_form_position_element_index' => 5,
+ ]
+ );
+ $this->assertIsArray($update_result);
+ $this->assertSame(777, $update_result['kit_test_cpt_form']);
+ $this->assertSame('after_content', $update_result['kit_test_cpt_form_position']);
+ $this->assertSame('h3', $update_result['kit_test_cpt_form_position_element']);
+ $this->assertSame(5, $update_result['kit_test_cpt_form_position_element_index']);
+ } finally {
+ remove_filter('convertkit_supported_post_types', $filter);
+ unregister_post_type('kit_test_cpt');
+ }
+ }
+
+ /**
+ * Test that kit/settings-general-update rejects a key that looks
+ * post-type-shaped but is not a supported post type — proving
+ * `additionalProperties: false` is enforced against the dynamic schema.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateRejectsUnsupportedPostTypeFormKey()
+ {
+ $abilities = convertkit_get_abilities();
+ $result = $abilities['kit/settings-general-update']->execute_callback(
+ [ 'unsupportedcpt_form' => 123 ]
+ );
+
+ $this->assertInstanceOf(\WP_Error::class, $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' => (int) $_ENV['CONVERTKIT_API_FORM_ID'],
+ 'post_form_position' => 'after_content',
+ 'post_form_position_element' => 'p',
+ 'post_form_position_element_index' => 1,
+ 'page_form' => (int) $_ENV['CONVERTKIT_API_FORM_ID'],
+ 'page_form_position' => 'before_content',
+ 'page_form_position_element' => 'h2',
+ 'page_form_position_element_index' => 2,
+ 'article_form' => (int) $_ENV['CONVERTKIT_API_FORM_ID'],
+ 'product_form' => (int) $_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/tests/Integration/MCPSettingsRestrictContentTest.php b/tests/Integration/MCPSettingsRestrictContentTest.php
new file mode 100644
index 000000000..95a68010a
--- /dev/null
+++ b/tests/Integration/MCPSettingsRestrictContentTest.php
@@ -0,0 +1,257 @@
+ \ConvertKit_MCP_Ability_Settings_Get::class,
+ 'kit/settings-restrict-content-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-restrict-content-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-restrict-content-get']->execute_callback([]);
+
+ // Confirm expected settings are returned.
+ $this->assertArrayHasKey('permit_crawlers', $result);
+ $this->assertEquals('on', $result['permit_crawlers']);
+ $this->assertArrayHasKey('no_access_text_form', $result);
+ $this->assertArrayHasKey('subscribe_heading', $result);
+ $this->assertArrayHasKey('subscribe_text', $result);
+ $this->assertArrayHasKey('no_access_text', $result);
+ $this->assertArrayHasKey('subscribe_heading_tag', $result);
+ $this->assertArrayHasKey('subscribe_text_tag', $result);
+ $this->assertArrayHasKey('require_tag_login', $result);
+ $this->assertEquals('on', $result['require_tag_login']);
+ $this->assertArrayHasKey('no_access_text_tag', $result);
+ $this->assertArrayHasKey('subscribe_button_label', $result);
+ $this->assertArrayHasKey('email_text', $result);
+ $this->assertArrayHasKey('email_button_label', $result);
+ $this->assertArrayHasKey('email_heading', $result);
+ $this->assertArrayHasKey('email_description_text', $result);
+ $this->assertArrayHasKey('email_check_heading', $result);
+ $this->assertArrayHasKey('email_check_text', $result);
+ $this->assertArrayHasKey('container_css_classes', $result);
+ }
+
+ /**
+ * Test that kit/settings-restrict-content-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-restrict-content-update']->execute_callback(
+ [
+ 'permit_crawlers' => 'on',
+ 'subscribe_heading' => 'Updated subscribe heading',
+ 'subscribe_text' => 'Updated subscribe text',
+ 'require_tag_login' => 'on',
+ 'subscribe_button_label' => 'Join now',
+ 'container_css_classes' => 'kit-restrict kit-restrict-custom',
+ ]
+ );
+
+ // Confirm expected settings are returned.
+ $this->assertArrayHasKey('permit_crawlers', $result);
+ $this->assertArrayHasKey('no_access_text_form', $result);
+ $this->assertArrayHasKey('subscribe_heading', $result);
+ $this->assertArrayHasKey('subscribe_text', $result);
+ $this->assertArrayHasKey('no_access_text', $result);
+ $this->assertArrayHasKey('subscribe_heading_tag', $result);
+ $this->assertArrayHasKey('subscribe_text_tag', $result);
+ $this->assertArrayHasKey('require_tag_login', $result);
+ $this->assertArrayHasKey('no_access_text_tag', $result);
+ $this->assertArrayHasKey('subscribe_button_label', $result);
+ $this->assertArrayHasKey('email_text', $result);
+ $this->assertArrayHasKey('email_button_label', $result);
+ $this->assertArrayHasKey('email_heading', $result);
+ $this->assertArrayHasKey('email_description_text', $result);
+ $this->assertArrayHasKey('email_check_heading', $result);
+ $this->assertArrayHasKey('email_check_text', $result);
+ $this->assertArrayHasKey('container_css_classes', $result);
+
+ // Confirm settings are updated.
+ $this->assertEquals('on', $result['permit_crawlers']);
+ $this->assertEquals('Updated subscribe heading', $result['subscribe_heading']);
+ $this->assertEquals('Updated subscribe text', $result['subscribe_text']);
+ $this->assertEquals('on', $result['require_tag_login']);
+ $this->assertEquals('Join now', $result['subscribe_button_label']);
+ $this->assertEquals('kit-restrict kit-restrict-custom', $result['container_css_classes']);
+ }
+
+ /**
+ * Test that kit/settings-restrict-content-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-restrict-content-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,
+ [
+ 'permit_crawlers' => 'on',
+ 'no_access_text_form' => 'No access (form).',
+ 'subscribe_heading' => 'Read with a premium subscription',
+ 'subscribe_text' => 'Only available to premium subscribers.',
+ 'no_access_text' => 'No access (product).',
+ 'subscribe_heading_tag' => 'Subscribe to keep reading',
+ 'subscribe_text_tag' => 'Free but only available to subscribers.',
+ 'require_tag_login' => 'on',
+ 'no_access_text_tag' => 'No access (tag).',
+ 'subscribe_button_label' => 'Subscribe',
+ 'email_text' => 'Already subscribed?',
+ 'email_button_label' => 'Log in',
+ 'email_heading' => 'Log in to read this post',
+ 'email_description_text' => 'We\'ll email you a magic code to log you in.',
+ 'email_check_heading' => 'We just emailed you a log in code',
+ 'email_check_text' => 'Enter the code below to finish logging in',
+ 'container_css_classes' => '',
+ ]
+ );
+ }
+}
diff --git a/tests/Integration/ShortcodePostHelperTest.php b/tests/Integration/ShortcodePostHelperTest.php
new file mode 100644
index 000000000..0188d10ea
--- /dev/null
+++ b/tests/Integration/ShortcodePostHelperTest.php
@@ -0,0 +1,446 @@
+postID = $this->createPost();
+ }
+
+ /**
+ * Performs actions after each test.
+ *
+ * @since 3.4.0
+ */
+ public function tearDown(): void
+ {
+ // Deactivate Plugin.
+ deactivate_plugins('convertkit/wp-convertkit.php');
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test that the find() method returns the correct shortcode indicies and attributes.
+ *
+ * @since 3.4.0
+ */
+ public function testFind()
+ {
+ // Find the shortcode.
+ $shortcodes = \ConvertKit_Shortcode_Post_Helper::find( $this->postID, 'convertkit_form' );
+
+ $this->assertIsArray( $shortcodes );
+ $this->assertCount( 2, $shortcodes );
+
+ // Assert first matching shortcode indicies and attributes are correct.
+ $this->assertEquals( 0, $shortcodes[0]['occurrence_index'] );
+ $this->assertEquals( $_ENV['CONVERTKIT_API_FORM_ID'], $shortcodes[0]['attrs']['form'] );
+
+ // Assert second matching shortcode indicies and attributes are correct.
+ $this->assertEquals( 1, $shortcodes[1]['occurrence_index'] );
+ $this->assertEquals( $_ENV['CONVERTKIT_API_FORM_ID'], $shortcodes[1]['attrs']['form'] );
+ }
+
+ /**
+ * Test that the find() method returns false when no shortcodes match the given shortcode tag.
+ *
+ * @since 3.4.0
+ */
+ public function testFindWhenNoShortcodesMatch()
+ {
+ $this->assertFalse(\ConvertKit_Shortcode_Post_Helper::find( $this->postID, 'fake_shortcode' ));
+ }
+
+ /**
+ * Test that the find() method returns a WP_Error when the post does not exist.
+ *
+ * @since 3.4.0
+ */
+ public function testFindWhenPostDoesNotExist()
+ {
+ $this->assertInstanceOf(\WP_Error::class, \ConvertKit_Shortcode_Post_Helper::find( 999999, 'convertkit_form' ));
+ }
+
+ /**
+ * Test that the insert() method inserts a new shortcode at the beginning of the content
+ * when the position is set to prepend.
+ *
+ * @since 3.4.0
+ */
+ public function testInsertPrepend()
+ {
+ $result = \ConvertKit_Shortcode_Post_Helper::insert(
+ post_id: $this->postID,
+ shortcode_tag: 'convertkit_form',
+ attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ],
+ position: 'prepend'
+ );
+
+ // Confirm result is an array and the post ID is correct.
+ $this->assertIsArray( $result );
+ $this->assertEquals( $this->postID, $result['post_id'] );
+
+ // Confirm content has been updated and the shortcode is inserted at the correct position.
+ $post = get_post($this->postID);
+ $this->assertStringStartsWith( '[convertkit_form form="' . $_ENV['CONVERTKIT_API_FORM_ID'] . '"]', $post->post_content );
+ }
+
+ /**
+ * Test that the insert() method inserts a new shortcode at the end of the content
+ * when the position is set to append.
+ *
+ * @since 3.4.0
+ */
+ public function testInsertAppend()
+ {
+ $result = \ConvertKit_Shortcode_Post_Helper::insert(
+ post_id: $this->postID,
+ shortcode_tag: 'convertkit_form',
+ attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ],
+ position: 'append'
+ );
+
+ // Confirm result is an array and the post ID is correct.
+ $this->assertIsArray( $result );
+ $this->assertEquals( $this->postID, $result['post_id'] );
+
+ // Confirm content has been updated and the shortcode is inserted at the correct position.
+ $post = get_post($this->postID);
+ $this->assertStringEndsWith( '[convertkit_form form="' . $_ENV['CONVERTKIT_API_FORM_ID'] . '"]', $post->post_content );
+ }
+
+ /**
+ * Test that the insert() method inserts a new shortcode at the specified index position.
+ *
+ * @since 3.4.0
+ */
+ public function testInsertIndex()
+ {
+ $result = \ConvertKit_Shortcode_Post_Helper::insert(
+ post_id: $this->postID,
+ shortcode_tag: 'convertkit_form',
+ attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ],
+ position: 'index',
+ index: 1
+ );
+
+ // Confirm result is an array and the post ID is correct.
+ $this->assertIsArray( $result );
+ $this->assertEquals( $this->postID, $result['post_id'] );
+
+ // Confirm content has been updated and the shortcode is inserted at the correct position.
+ $post = get_post($this->postID);
+ $this->assertStringContainsString( "Item #1\n\n[convertkit_form form=\"" . $_ENV['CONVERTKIT_API_FORM_ID'] . '"]', $post->post_content );
+ }
+
+ /**
+ * Test that the insert() method inserts a new shortcode at end of the content when
+ * the index is out of bounds.
+ *
+ * @since 3.4.0
+ */
+ public function testInsertIndexOutOfBounds()
+ {
+ $result = \ConvertKit_Shortcode_Post_Helper::insert(
+ post_id: $this->postID,
+ shortcode_tag: 'convertkit_form',
+ attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ],
+ position: 'index',
+ index: 100
+ );
+
+ // Confirm result is an array and the post ID is correct.
+ $this->assertIsArray( $result );
+ $this->assertEquals( $this->postID, $result['post_id'] );
+
+ // Confirm content has been updated and the shortcode is inserted at the correct position.
+ $post = get_post($this->postID);
+ $this->assertStringEndsWith( '[convertkit_form form="' . $_ENV['CONVERTKIT_API_FORM_ID'] . '"]', $post->post_content );
+ }
+
+ /**
+ * Test that the insert() method inserts a new shortcode at the specified index position.
+ *
+ * @since 3.4.0
+ */
+ public function testInsertIndexZero()
+ {
+ $result = \ConvertKit_Shortcode_Post_Helper::insert(
+ post_id: $this->postID,
+ shortcode_tag: 'convertkit_form',
+ attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ],
+ position: 'index',
+ index: 0
+ );
+
+ // Confirm result is an array and the post ID is correct.
+ $this->assertIsArray( $result );
+ $this->assertEquals( $this->postID, $result['post_id'] );
+
+ // Confirm content has been updated and the shortcode is inserted at the correct position.
+ $post = get_post($this->postID);
+ $this->assertStringStartsWith( '[convertkit_form form="' . $_ENV['CONVERTKIT_API_FORM_ID'] . '"]', $post->post_content );
+ }
+
+ /**
+ * Test that the insert() method returns a WP_Error when the index is negative.
+ *
+ * @since 3.4.0
+ */
+ public function testInsertIndexNegative()
+ {
+ $result = \ConvertKit_Shortcode_Post_Helper::insert(
+ post_id: $this->postID,
+ shortcode_tag: 'convertkit_form',
+ attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ],
+ position: 'index',
+ index: -1
+ );
+ $this->assertInstanceOf(\WP_Error::class, $result );
+ }
+
+ /**
+ * Test that the insert() method returns a WP_Error when the post does not exist.
+ *
+ * @since 3.4.0
+ */
+ public function testInsertWhenPostDoesNotExist()
+ {
+ $result = \ConvertKit_Shortcode_Post_Helper::insert(
+ post_id: 999999,
+ shortcode_tag: 'convertkit_form',
+ attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ],
+ position: 'index',
+ index: 0
+ );
+ $this->assertInstanceOf(\WP_Error::class, $result );
+ }
+
+ /**
+ * Test that the update() method updates the attributes of an existing shortcode.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdate()
+ {
+ $result = \ConvertKit_Shortcode_Post_Helper::update(
+ post_id: $this->postID,
+ shortcode_tag: 'convertkit_form',
+ occurrence_index: 0,
+ attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ]
+ );
+
+ $this->assertIsArray( $result );
+ $this->assertEquals( $this->postID, $result['post_id'] );
+
+ $result = \ConvertKit_Shortcode_Post_Helper::update(
+ post_id: $this->postID,
+ shortcode_tag: 'convertkit_form',
+ occurrence_index: 1,
+ attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ]
+ );
+
+ $this->assertIsArray( $result );
+ $this->assertEquals( $this->postID, $result['post_id'] );
+ }
+
+ /**
+ * Test that the update() method returns a WP_Error when the occurrence index is out of bounds.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateWhenOccurrenceIndexIsOutOfBounds()
+ {
+ $result = \ConvertKit_Shortcode_Post_Helper::update(
+ post_id: $this->postID,
+ shortcode_tag: 'convertkit_form',
+ occurrence_index: 999,
+ attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ]
+ );
+ $this->assertInstanceOf(\WP_Error::class, $result );
+ }
+
+ /**
+ * Test that the update() method returns a WP_Error when the post does not exist.
+ *
+ * @since 3.4.0
+ */
+ public function testUpdateWhenPostDoesNotExist()
+ {
+ $result = \ConvertKit_Shortcode_Post_Helper::update(
+ post_id: 999999,
+ shortcode_tag: 'convertkit_form',
+ occurrence_index: 0,
+ attrs: [ 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ]
+ );
+ $this->assertInstanceOf(\WP_Error::class, $result );
+ }
+
+ /**
+ * Test that the delete() method deletes an existing shortcode.
+ *
+ * @since 3.4.0
+ */
+ public function testDelete()
+ {
+ $result = \ConvertKit_Shortcode_Post_Helper::delete(
+ post_id: $this->postID,
+ shortcode_tag: 'convertkit_form',
+ occurrence_index: 1
+ );
+ $this->assertIsArray( $result );
+ $this->assertEquals( $this->postID, $result['post_id'] );
+
+ $result = \ConvertKit_Shortcode_Post_Helper::delete(
+ post_id: $this->postID,
+ shortcode_tag: 'convertkit_form',
+ occurrence_index: 0
+ );
+ $this->assertIsArray( $result );
+ $this->assertEquals( $this->postID, $result['post_id'] );
+ }
+
+ /**
+ * Test that the delete() method returns a WP_Error when the occurrence index is out of bounds.
+ *
+ * @since 3.4.0
+ */
+ public function testDeleteWhenOccurrenceIndexIsOutOfBounds()
+ {
+ $result = \ConvertKit_Shortcode_Post_Helper::delete(
+ post_id: $this->postID,
+ shortcode_tag: 'convertkit_form',
+ occurrence_index: 999
+ );
+ $this->assertInstanceOf(\WP_Error::class, $result );
+ }
+
+ /**
+ * Test that the delete() method returns a WP_Error when the post does not exist.
+ *
+ * @since 3.4.0
+ */
+ public function testDeleteWhenPostDoesNotExist()
+ {
+ $result = \ConvertKit_Shortcode_Post_Helper::delete(
+ post_id: 999999,
+ shortcode_tag: 'convertkit_form',
+ occurrence_index: 0
+ );
+ $this->assertInstanceOf(\WP_Error::class, $result );
+ }
+
+ /**
+ * Mocks a post for testing.
+ *
+ * @since 3.4.0
+ * @return int
+ */
+ private function createPost()
+ {
+ // Create a Post with the given shortcode.
+ return $this->factory->post->create(
+ [
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ 'post_title' => 'Shortcode Post',
+ 'post_content' => 'Item #1
+
+
Item #1
+
+Item #2: Adhaésionés altéram improbis mi pariendarum sit stulti triarium
+
+
+
+