From 254d671df3b97562e016c9aa24b0631b9ff265c0 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 20 Apr 2026 17:52:01 +0800 Subject: [PATCH 01/15] MCP + Abilities API: Registration Framework --- includes/blocks/class-convertkit-block.php | 341 ++++++++++++++++++ includes/class-wp-convertkit.php | 13 +- includes/functions.php | 24 ++ ...ss-convertkit-mcp-ability-block-delete.php | 189 ++++++++++ ...ss-convertkit-mcp-ability-block-insert.php | 224 ++++++++++++ ...lass-convertkit-mcp-ability-block-list.php | 194 ++++++++++ ...ss-convertkit-mcp-ability-block-update.php | 207 +++++++++++ .../class-convertkit-mcp-ability-block.php | 233 ++++++++++++ includes/mcp/class-convertkit-mcp-ability.php | 137 +++++++ includes/mcp/class-convertkit-mcp.php | 176 +++++++++ wp-convertkit.php | 8 + 11 files changed, 1740 insertions(+), 6 deletions(-) create mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php create mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php create mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php create mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php create mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php create mode 100644 includes/mcp/class-convertkit-mcp-ability.php create mode 100644 includes/mcp/class-convertkit-mcp.php diff --git a/includes/blocks/class-convertkit-block.php b/includes/blocks/class-convertkit-block.php index 12a82447e..b2ed81923 100644 --- a/includes/blocks/class-convertkit-block.php +++ b/includes/blocks/class-convertkit-block.php @@ -531,4 +531,345 @@ public function is_block_visible( $atts ) { } + /** + * Returns the block's full Gutenberg name (e.g. `convertkit/form`). + * + * @since 3.4.0 + * + * @return string + */ + public function get_full_block_name() { + + return 'convertkit/' . $this->get_name(); + + } + + /** + * Returns JSON Schema properties derived from this block's get_attributes() + * and get_fields(), suitable for use as the `attrs` object in an Abilities + * API input schema. + * + * Structural/styling attributes injected by Gutenberg (align, style, + * backgroundColor, textColor, className, is_gutenberg_example) are excluded + * so the agent-facing schema only covers block-specific attributes. + * + * Where possible, the schema is enriched using get_fields(): the field's + * `label` becomes the property description, and `resource`-type fields + * become an enum of valid IDs drawn from the corresponding resource class. + * + * @since 3.4.0 + * + * @return array + */ + public function get_input_schema_properties() { + + $properties = array(); + $fields = is_array( $this->get_fields() ) ? $this->get_fields() : array(); + + // JSON Schema type for each Gutenberg attribute type. + $type_map = array( + 'string' => 'string', + 'number' => 'integer', + 'boolean' => 'boolean', + 'object' => 'object', + 'array' => 'array', + ); + + // Attributes that are either provided by Gutenberg's own block supports + // or are internal-only. These should not appear in the agent-facing schema. + $skip_attrs = array( + 'align', + 'style', + 'backgroundColor', + 'textColor', + 'className', + 'is_gutenberg_example', + ); + + foreach ( $this->get_attributes() as $name => $definition ) { + if ( in_array( $name, $skip_attrs, true ) ) { + continue; + } + + $type = isset( $definition['type'] ) ? $definition['type'] : 'string'; + $json_type = isset( $type_map[ $type ] ) ? $type_map[ $type ] : 'string'; + $properties[ $name ] = array( 'type' => $json_type ); + + // Enrich from the field definition, if one exists. + if ( ! isset( $fields[ $name ] ) ) { + continue; + } + + $field = $fields[ $name ]; + + if ( ! empty( $field['label'] ) ) { + $properties[ $name ]['description'] = (string) $field['label']; + } + + // For resource-type fields, narrow the schema to a concrete list of + // valid IDs. This prevents agents from passing IDs that don't exist. + if ( isset( $field['type'] ) && $field['type'] === 'resource' && ! empty( $field['values'] ) && is_array( $field['values'] ) ) { + $ids = array_keys( $field['values'] ); + if ( ! empty( $ids ) ) { + // The attribute is typed as string in Gutenberg, but IDs are + // naturally integers. Preserve whatever the attribute's + // declared type is, and just cast enum values to match. + $properties[ $name ]['enum'] = array_map( + function ( $id ) use ( $json_type ) { + return $json_type === 'string' ? (string) $id : (int) $id; + }, + $ids + ); + } + } + } + + return $properties; + + } + + /** + * Finds all top-level occurrences of this block in the given post's content. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @return array|WP_Error Array of ['index' => int, 'attrs' => array] entries, or WP_Error if the post is missing. + */ + public function find_blocks_in_post( $post_id ) { + + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( + 'convertkit_block_post_not_found', + /* translators: %d: post ID */ + sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id ) + ); + } + + $blocks = parse_blocks( $post->post_content ); + $full_name = $this->get_full_block_name(); + $found = array(); + + foreach ( $blocks as $index => $block ) { + if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $full_name ) { + continue; + } + + $found[] = array( + 'index' => (int) $index, + 'attrs' => isset( $block['attrs'] ) ? (array) $block['attrs'] : array(), + ); + } + + return $found; + + } + + /** + * Inserts this block into the given post's content at the specified position. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @param array $attrs Block attributes to set on the inserted block. + * @param string $position One of 'append', 'prepend', 'at_index'. + * @param int $index Zero-based index when $position is 'at_index'. + * @return array|WP_Error ['block_count' => int, 'position_used' => string] on success. + */ + public function insert_into_post( $post_id, $attrs, $position = 'append', $index = 0 ) { + + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( + 'convertkit_block_post_not_found', + /* translators: %d: post ID */ + sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id ) + ); + } + + $blocks = parse_blocks( $post->post_content ); + + $new_block = array( + 'blockName' => $this->get_full_block_name(), + 'attrs' => (array) $attrs, + 'innerBlocks' => array(), + 'innerHTML' => '', + 'innerContent' => array(), + ); + + switch ( $position ) { + case 'prepend': + array_unshift( $blocks, $new_block ); + break; + + case 'at_index': + $index = max( 0, min( (int) $index, count( $blocks ) ) ); + array_splice( $blocks, $index, 0, array( $new_block ) ); + break; + + case 'append': + default: + $blocks[] = $new_block; + $position = 'append'; + break; + } + + $updated = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => serialize_blocks( $blocks ), + ), + true + ); + + if ( is_wp_error( $updated ) ) { + return $updated; + } + + return array( + 'block_count' => count( $blocks ), + 'position_used' => $position, + ); + + } + + /** + * Replaces the attributes of a specific top-level occurrence of this block + * in the given post's content. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @param int $target_index Zero-based index among this block's occurrences in the post (not the block-array index). + * @param array $attrs New attributes to apply. + * @param bool $merge If true, merge $attrs into the existing block attrs. If false, replace entirely. + * @return array|WP_Error + */ + public function replace_in_post( $post_id, $target_index, $attrs, $merge = true ) { + + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( + 'convertkit_block_post_not_found', + /* translators: %d: post ID */ + sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id ) + ); + } + + $blocks = parse_blocks( $post->post_content ); + $full_name = $this->get_full_block_name(); + $occurrence = 0; + $matched = false; + $final_attrs = array(); + + foreach ( $blocks as $key => $block ) { + if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $full_name ) { + continue; + } + + if ( $occurrence === (int) $target_index ) { + $existing = isset( $block['attrs'] ) ? (array) $block['attrs'] : array(); + $final_attrs = $merge ? array_merge( $existing, (array) $attrs ) : (array) $attrs; + $blocks[ $key ]['attrs'] = $final_attrs; + $matched = true; + break; + } + + ++$occurrence; + } + + if ( ! $matched ) { + return new WP_Error( + 'convertkit_block_occurrence_not_found', + /* translators: 1: block name, 2: target index, 3: post ID */ + sprintf( __( 'No occurrence #%2$d of block %1$s found in post %3$d.', 'convertkit' ), $full_name, (int) $target_index, $post_id ) + ); + } + + $updated = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => serialize_blocks( $blocks ), + ), + true + ); + + if ( is_wp_error( $updated ) ) { + return $updated; + } + + return array( + 'attrs' => $final_attrs, + ); + + } + + /** + * Deletes a specific top-level occurrence of this block from the given + * post's content. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @param int $target_index Zero-based index among this block's occurrences in the post. + * @return array|WP_Error + */ + public function delete_from_post( $post_id, $target_index ) { + + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( + 'convertkit_block_post_not_found', + /* translators: %d: post ID */ + sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id ) + ); + } + + $blocks = parse_blocks( $post->post_content ); + $full_name = $this->get_full_block_name(); + $occurrence = 0; + $matched = false; + + foreach ( $blocks as $key => $block ) { + if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $full_name ) { + continue; + } + + if ( $occurrence === (int) $target_index ) { + unset( $blocks[ $key ] ); + $blocks = array_values( $blocks ); + $matched = true; + break; + } + + ++$occurrence; + } + + if ( ! $matched ) { + return new WP_Error( + 'convertkit_block_occurrence_not_found', + /* translators: 1: block name, 2: target index, 3: post ID */ + sprintf( __( 'No occurrence #%2$d of block %1$s found in post %3$d.', 'convertkit' ), $full_name, (int) $target_index, $post_id ) + ); + } + + $updated = wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => serialize_blocks( $blocks ), + ), + true + ); + + if ( is_wp_error( $updated ) ) { + return $updated; + } + + return array( + 'block_count' => count( $blocks ), + ); + + } + } diff --git a/includes/class-wp-convertkit.php b/includes/class-wp-convertkit.php index 9e49cc858..63cfa5263 100644 --- a/includes/class-wp-convertkit.php +++ b/includes/class-wp-convertkit.php @@ -201,12 +201,13 @@ 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['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['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. 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/blocks/class-convertkit-mcp-ability-block-delete.php b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php new file mode 100644 index 000000000..87983b712 --- /dev/null +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php @@ -0,0 +1,189 @@ +-delete` (e.g. `kit/form-delete`). + * + * @package ConvertKit + * @author ConvertKit + */ +class ConvertKit_MCP_Ability_Block_Delete extends ConvertKit_MCP_Ability_Block { + + /** + * Returns the verb this ability represents. + * + * @since 3.4.0 + * + * @return string + */ + protected 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 a %s block from a 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 */ + __( 'Removes a single occurrence of the %1$s (%2$s) block from the given post.', 'convertkit' ), + $this->block->get_full_block_name(), + $this->block->get_title() + ); + + } + + /** + * MCP annotations: destructive and not readonly; not idempotent, as repeated + * calls will attempt to delete sequential occurrences rather than a no-op. + * + * @since 3.4.0 + * + * @return array + */ + public function get_annotations() { + + return array( + 'title' => $this->get_label(), + 'readonly' => false, + 'destructive' => true, + 'idempotent' => false, + ); + + } + + /** + * 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', 'target' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'ID of the post containing the block.', 'convertkit' ), + ), + 'target' => $this->get_target_schema(), + ), + ); + + } + + /** + * 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', 'block', 'deleted_occurrence_index' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + ), + 'block' => array( + 'type' => 'string', + 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), + ), + 'deleted_occurrence_index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'Zero-based occurrence index of the deleted block among this block\'s appearances in the post prior to deletion.', '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 target. + $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); + + // Resolve target. + $occurrence_index = $this->resolve_target( $post_id, $target ); + + // Bail if the target is not found. + if ( is_wp_error( $occurrence_index ) ) { + return $occurrence_index; + } + + // Delete block from post. + $result = $this->block->delete_from_post( $post_id, $occurrence_index ); + if ( is_wp_error( $result ) ) { + return $result; + } + + // Return result. + return array( + 'post_id' => $post_id, + 'block' => $this->block->get_full_block_name(), + 'deleted_occurrence_index' => (int) $occurrence_index, + ); + + } + +} diff --git a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php new file mode 100644 index 000000000..71dab6771 --- /dev/null +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php @@ -0,0 +1,224 @@ +-insert` (e.g. `kit/form-insert`). + * + * @package ConvertKit + * @author ConvertKit + */ +class ConvertKit_MCP_Ability_Block_Insert extends ConvertKit_MCP_Ability_Block { + + /** + * Returns the verb this ability represents. + * + * @since 3.4.0 + * + * @return string + */ + protected 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 a %s block into a 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 %1$s (%2$s) block into the given post\'s content. The block can be appended (default), prepended, or positioned relative to an existing occurrence by zero-based index.', 'convertkit' ), + $this->block->get_full_block_name(), + $this->block->get_title() + ); + + } + + /** + * MCP annotations: not readonly, not destructive, not idempotent + * (repeated calls insert additional blocks). + * + * @since 3.4.0 + * + * @return array + */ + public function get_annotations() { + + return array( + 'title' => $this->get_label(), + 'readonly' => false, + 'destructive' => false, + 'idempotent' => false, + ); + + } + + /** + * 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' => __( 'ID of the post to insert the block into.', 'convertkit' ), + ), + 'attrs' => array( + 'type' => 'object', + 'description' => __( 'Block attributes for the new occurrence.', 'convertkit' ), + 'properties' => $this->block->get_input_schema_properties(), + ), + 'position' => array( + 'type' => 'string', + 'enum' => array( 'append', 'prepend', 'at_index' ), + 'default' => 'append', + 'description' => __( 'Where to insert the new block. "at_index" requires the "index" property.', 'convertkit' ), + ), + 'index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'When position is "at_index", the zero-based top-level block index at which to insert the new block.', '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', 'block', 'occurrence_index', 'attrs' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + ), + 'block' => array( + 'type' => 'string', + 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), + ), + 'occurrence_index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'Zero-based occurrence index of the newly inserted block among this block\'s appearances in the post.', 'convertkit' ), + ), + 'attrs' => array( + 'type' => 'object', + 'description' => __( 'Attributes of the newly inserted block.', '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 attributes. + $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 block into post. + $result = $this->block->insert_into_post( $post_id, $attrs, $position, $index ); + if ( is_wp_error( $result ) ) { + return $result; + } + + // Re-list occurrences to determine the newly inserted block's + // zero-based occurrence index among this block's appearances. + $occurrences = $this->block->find_blocks_in_post( $post_id ); + $occurrence_index = 0; + if ( is_array( $occurrences ) && count( $occurrences ) > 0 ) { + switch ( $position ) { + case 'prepend': + $occurrence_index = 0; + break; + + case 'at_index': + case 'append': + default: + // Find the first occurrence whose attrs match the just-inserted + // attrs; fall back to the last occurrence for 'append' and + // the first-after-$index for 'at_index'. + $occurrence_index = count( $occurrences ) - 1; + break; + } + } + + // Return result. + return array( + 'post_id' => $post_id, + 'block' => $this->block->get_full_block_name(), + 'occurrence_index' => (int) $occurrence_index, + 'attrs' => $attrs, + ); + + } + +} diff --git a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php new file mode 100644 index 000000000..36c3f6358 --- /dev/null +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php @@ -0,0 +1,194 @@ +-list` (e.g. `kit/form-list`). + * + * @package ConvertKit + * @author ConvertKit + */ +class ConvertKit_MCP_Ability_Block_List extends ConvertKit_MCP_Ability_Block { + + /** + * Returns the verb this ability represents. + * + * @since 3.4.0 + * + * @return string + */ + protected 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 blocks in a 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 */ + __( 'Lists every occurrence of the %1$s (%2$s) block in the given post, including each occurrence\'s zero-based index and current attribute values.', 'convertkit' ), + $this->block->get_full_block_name(), + $this->block->get_title() + ); + + } + + /** + * MCP annotations: readonly + idempotent. + * + * @since 3.4.0 + * + * @return array + */ + public function get_annotations() { + + return array( + 'title' => $this->get_label(), + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ); + + } + + /** + * 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', 'block', 'count', 'occurrences' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + ), + 'block' => array( + 'type' => 'string', + 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), + ), + 'count' => array( + 'type' => 'integer', + 'minimum' => 0, + ), + 'occurrences' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'required' => array( 'index', 'attrs' ), + 'properties' => array( + 'index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'Zero-based occurrence index among this block\'s appearances in the post.', 'convertkit' ), + ), + 'attrs' => array( + 'type' => 'object', + 'description' => __( 'Block 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 blocks in post. + $occurrences = $this->block->find_blocks_in_post( $post_id ); + if ( is_wp_error( $occurrences ) ) { + return $occurrences; + } + + // Return result. + return array( + 'post_id' => $post_id, + 'block' => $this->block->get_full_block_name(), + 'count' => count( $occurrences ), + 'occurrences' => $occurrences, + ); + + } + +} diff --git a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php new file mode 100644 index 000000000..895c742ad --- /dev/null +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php @@ -0,0 +1,207 @@ +-update` (e.g. `kit/form-update`). + * + * By default the provided attributes are merged into the existing attributes. + * Set `replace_all` to true to replace all attributes with the supplied set. + * + * @package ConvertKit + * @author ConvertKit + */ +class ConvertKit_MCP_Ability_Block_Update extends ConvertKit_MCP_Ability_Block { + + /** + * Returns the verb this ability represents. + * + * @since 3.4.0 + * + * @return string + */ + protected 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 a %s block in a 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 */ + __( 'Updates the attributes of a single occurrence of the %1$s (%2$s) block in the given post. By default the provided attributes are merged into the existing attributes; set replace_all to true to replace them entirely.', 'convertkit' ), + $this->block->get_full_block_name(), + $this->block->get_title() + ); + + } + + /** + * MCP annotations: not readonly, not destructive, idempotent + * (repeating the same update yields the same result). + * + * @since 3.4.0 + * + * @return array + */ + public function get_annotations() { + + return array( + 'title' => $this->get_label(), + 'readonly' => false, + 'destructive' => false, + 'idempotent' => true, + ); + + } + + /** + * 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', 'target', 'attrs' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'minimum' => 1, + 'description' => __( 'ID of the post containing the block.', 'convertkit' ), + ), + 'target' => $this->get_target_schema(), + 'attrs' => array( + 'type' => 'object', + 'description' => __( 'Attribute values to apply to the target block.', 'convertkit' ), + 'properties' => $this->block->get_input_schema_properties(), + ), + 'replace_all' => array( + 'type' => 'boolean', + 'default' => false, + 'description' => __( 'If true, all existing attributes are replaced with the supplied set. If false (default), the supplied attributes are merged into the existing attributes.', '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', 'block', 'occurrence_index', 'attrs' ), + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + ), + 'block' => array( + 'type' => 'string', + 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), + ), + 'occurrence_index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'Zero-based occurrence index of the updated block.', 'convertkit' ), + ), + 'attrs' => array( + 'type' => 'object', + 'description' => __( 'Attributes of the updated block.', '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 target. + $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); + $attrs = isset( $input['attrs'] ) && is_array( $input['attrs'] ) ? $input['attrs'] : array(); + $merge = ! ( isset( $input['replace_all'] ) && (bool) $input['replace_all'] ); + + // Resolve target. + $occurrence_index = $this->resolve_target( $post_id, $target ); + if ( is_wp_error( $occurrence_index ) ) { + return $occurrence_index; + } + + // Update block in post. + $result = $this->block->replace_in_post( $post_id, $occurrence_index, $attrs, $merge ); + if ( is_wp_error( $result ) ) { + return $result; + } + + // Return result. + return array( + 'post_id' => $post_id, + 'block' => $this->block->get_full_block_name(), + 'occurrence_index' => (int) $occurrence_index, + 'attrs' => isset( $result['attrs'] ) ? $result['attrs'] : $attrs, + ); + + } + +} diff --git a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php new file mode 100644 index 000000000..55b86ed62 --- /dev/null +++ b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php @@ -0,0 +1,233 @@ +block = $block; + + } + + /** + * Returns the ability name, derived from the block's name and the verb + * returned by get_verb(). + * + * @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 protected 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 JSON Schema fragment for a `target` object describing which + * occurrence of the block the ability should act on. Used by update/delete. + * + * @since 3.4.0 + * + * @return array + */ + protected function get_target_schema() { + + return array( + 'type' => 'object', + 'description' => __( 'Identifies which occurrence of this block in the post to act on. Either by an attribute value match, or by zero-based occurrence index.', 'convertkit' ), + 'oneOf' => array( + array( + 'type' => 'object', + 'required' => array( 'by', 'attribute', 'value' ), + 'properties' => array( + 'by' => array( + 'type' => 'string', + 'enum' => array( 'attribute' ), + ), + 'attribute' => array( + 'type' => 'string', + 'description' => __( 'The block attribute name to match against (e.g. "form").', 'convertkit' ), + ), + 'value' => array( + 'description' => __( 'The value the attribute must match.', 'convertkit' ), + ), + ), + ), + array( + 'type' => 'object', + 'required' => array( 'by', 'index' ), + 'properties' => array( + 'by' => array( + 'type' => 'string', + 'enum' => array( 'index' ), + ), + 'index' => array( + 'type' => 'integer', + 'minimum' => 0, + 'description' => __( 'Zero-based occurrence index among this block\'s appearances in the post.', 'convertkit' ), + ), + ), + ), + ), + ); + + } + + /** + * Resolves a target descriptor into the zero-based occurrence index of the + * block in the post. + * + * @since 3.4.0 + * + * @param int $post_id Post ID. + * @param array $target Target descriptor (see get_target_schema()). + * @return int|WP_Error Zero-based occurrence index, or WP_Error. + */ + protected function resolve_target( $post_id, $target ) { + + // Bail if target is not an array or does not have a 'by' key. + if ( ! is_array( $target ) || empty( $target['by'] ) ) { + return new WP_Error( + 'convertkit_mcp_invalid_target', + __( 'target.by is required.', 'convertkit' ) + ); + } + + // Find blocks in post. + $occurrences = $this->block->find_blocks_in_post( $post_id ); + if ( is_wp_error( $occurrences ) ) { + return $occurrences; + } + + // Bail if no blocks are found. + if ( empty( $occurrences ) ) { + return new WP_Error( + 'convertkit_mcp_no_block_occurrences', + /* translators: 1: block name, 2: post ID */ + sprintf( __( 'No occurrences of block %1$s found in post %2$d.', 'convertkit' ), $this->block->get_full_block_name(), $post_id ) + ); + } + + // Resolve target. + switch ( $target['by'] ) { + case 'index': + $idx = isset( $target['index'] ) ? (int) $target['index'] : -1; + if ( $idx < 0 || $idx >= count( $occurrences ) ) { + return new WP_Error( + 'convertkit_mcp_target_index_out_of_range', + /* translators: 1: requested index, 2: number of occurrences */ + sprintf( __( 'Target index %1$d is out of range; post has %2$d occurrence(s).', 'convertkit' ), $idx, count( $occurrences ) ) + ); + } + return $idx; + + case 'attribute': + $attr = isset( $target['attribute'] ) ? (string) $target['attribute'] : ''; + $value = isset( $target['value'] ) ? $target['value'] : null; + if ( $attr === '' ) { + return new WP_Error( + 'convertkit_mcp_invalid_target', + __( 'target.attribute is required when target.by is "attribute".', 'convertkit' ) + ); + } + foreach ( $occurrences as $i => $occ ) { + if ( ! isset( $occ['attrs'][ $attr ] ) ) { + continue; + } + // Loose comparison so '123' == 123 resolves the same target, + // since Gutenberg attributes are often stringly typed. + if ( $occ['attrs'][ $attr ] == $value ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual + return $i; + } + } + return new WP_Error( + 'convertkit_mcp_target_not_found', + /* translators: 1: attribute name, 2: value, 3: block name */ + sprintf( __( 'No occurrence of block %3$s has %1$s = %2$s.', 'convertkit' ), $attr, wp_json_encode( $value ), $this->block->get_full_block_name() ) + ); + + default: + return new WP_Error( + 'convertkit_mcp_invalid_target', + /* translators: %s: invalid 'by' value */ + sprintf( __( 'Unknown target.by value "%s". Expected "attribute" or "index".', 'convertkit' ), (string) $target['by'] ) + ); + } + + } + +} diff --git a/includes/mcp/class-convertkit-mcp-ability.php b/includes/mcp/class-convertkit-mcp-ability.php new file mode 100644 index 000000000..e06170df1 --- /dev/null +++ b/includes/mcp/class-convertkit-mcp-ability.php @@ -0,0 +1,137 @@ + $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 + */ + abstract public function get_category(); + + /** + * 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(); + + /** + * Returns the MCP annotations for this ability. + * + * Defaults to a non-readonly, non-destructive, non-idempotent action. + * Subclasses override the returned array to set the appropriate hints. + * + * @since 3.4.0 + * + * @return array + */ + public function get_annotations() { + + return array( + 'title' => $this->get_label(), + 'readonly' => false, + 'destructive' => false, + 'idempotent' => false, + ); + + } + + /** + * 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..e7ad2a6f5 --- /dev/null +++ b/includes/mcp/class-convertkit-mcp.php @@ -0,0 +1,176 @@ + __( '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 ) { + + // Bail if the adapter is not an object or does not have the create_server method. + if ( ! is_object( $adapter ) || ! method_exists( $adapter, 'create_server' ) ) { + return; + } + + // Get abilities. + $abilities = convertkit_get_abilities(); + + // Bail if no abilities are available. + if ( ! count( $abilities ) ) { + return; + } + + // 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 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/wp-convertkit.php b/wp-convertkit.php index 34eabff92..3e4230a85 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -98,6 +98,14 @@ require_once CONVERTKIT_PLUGIN_PATH . '/includes/block-formatters/class-convertkit-block-formatter.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/block-formatters/class-convertkit-block-formatter-form-link.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/block-formatters/class-convertkit-block-formatter-product-link.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/class-convertkit-mcp-ability.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/class-convertkit-mcp-ability-set-page-form.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/class-convertkit-mcp.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/plugin-sidebars/class-convertkit-plugin-sidebar.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/plugin-sidebars/class-convertkit-plugin-sidebar-post-settings.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/pre-publish-actions/class-convertkit-pre-publish-action.php'; From 6e52159fc3717b505ffe1402443d7a16864b1d59 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 20 Apr 2026 18:01:15 +0800 Subject: [PATCH 02/15] Remove block abilities --- ...ss-convertkit-mcp-ability-block-delete.php | 189 -------------- ...ss-convertkit-mcp-ability-block-insert.php | 224 ----------------- ...lass-convertkit-mcp-ability-block-list.php | 194 --------------- ...ss-convertkit-mcp-ability-block-update.php | 207 ---------------- .../class-convertkit-mcp-ability-block.php | 233 ------------------ wp-convertkit.php | 6 - 6 files changed, 1053 deletions(-) delete mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php delete mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php delete mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php delete mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php delete mode 100644 includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php diff --git a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php deleted file mode 100644 index 87983b712..000000000 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php +++ /dev/null @@ -1,189 +0,0 @@ --delete` (e.g. `kit/form-delete`). - * - * @package ConvertKit - * @author ConvertKit - */ -class ConvertKit_MCP_Ability_Block_Delete extends ConvertKit_MCP_Ability_Block { - - /** - * Returns the verb this ability represents. - * - * @since 3.4.0 - * - * @return string - */ - protected 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 a %s block from a 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 */ - __( 'Removes a single occurrence of the %1$s (%2$s) block from the given post.', 'convertkit' ), - $this->block->get_full_block_name(), - $this->block->get_title() - ); - - } - - /** - * MCP annotations: destructive and not readonly; not idempotent, as repeated - * calls will attempt to delete sequential occurrences rather than a no-op. - * - * @since 3.4.0 - * - * @return array - */ - public function get_annotations() { - - return array( - 'title' => $this->get_label(), - 'readonly' => false, - 'destructive' => true, - 'idempotent' => false, - ); - - } - - /** - * 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', 'target' ), - 'properties' => array( - 'post_id' => array( - 'type' => 'integer', - 'minimum' => 1, - 'description' => __( 'ID of the post containing the block.', 'convertkit' ), - ), - 'target' => $this->get_target_schema(), - ), - ); - - } - - /** - * 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', 'block', 'deleted_occurrence_index' ), - 'properties' => array( - 'post_id' => array( - 'type' => 'integer', - ), - 'block' => array( - 'type' => 'string', - 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), - ), - 'deleted_occurrence_index' => array( - 'type' => 'integer', - 'minimum' => 0, - 'description' => __( 'Zero-based occurrence index of the deleted block among this block\'s appearances in the post prior to deletion.', '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 target. - $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); - - // Resolve target. - $occurrence_index = $this->resolve_target( $post_id, $target ); - - // Bail if the target is not found. - if ( is_wp_error( $occurrence_index ) ) { - return $occurrence_index; - } - - // Delete block from post. - $result = $this->block->delete_from_post( $post_id, $occurrence_index ); - if ( is_wp_error( $result ) ) { - return $result; - } - - // Return result. - return array( - 'post_id' => $post_id, - 'block' => $this->block->get_full_block_name(), - 'deleted_occurrence_index' => (int) $occurrence_index, - ); - - } - -} diff --git a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php deleted file mode 100644 index 71dab6771..000000000 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php +++ /dev/null @@ -1,224 +0,0 @@ --insert` (e.g. `kit/form-insert`). - * - * @package ConvertKit - * @author ConvertKit - */ -class ConvertKit_MCP_Ability_Block_Insert extends ConvertKit_MCP_Ability_Block { - - /** - * Returns the verb this ability represents. - * - * @since 3.4.0 - * - * @return string - */ - protected 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 a %s block into a 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 %1$s (%2$s) block into the given post\'s content. The block can be appended (default), prepended, or positioned relative to an existing occurrence by zero-based index.', 'convertkit' ), - $this->block->get_full_block_name(), - $this->block->get_title() - ); - - } - - /** - * MCP annotations: not readonly, not destructive, not idempotent - * (repeated calls insert additional blocks). - * - * @since 3.4.0 - * - * @return array - */ - public function get_annotations() { - - return array( - 'title' => $this->get_label(), - 'readonly' => false, - 'destructive' => false, - 'idempotent' => false, - ); - - } - - /** - * 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' => __( 'ID of the post to insert the block into.', 'convertkit' ), - ), - 'attrs' => array( - 'type' => 'object', - 'description' => __( 'Block attributes for the new occurrence.', 'convertkit' ), - 'properties' => $this->block->get_input_schema_properties(), - ), - 'position' => array( - 'type' => 'string', - 'enum' => array( 'append', 'prepend', 'at_index' ), - 'default' => 'append', - 'description' => __( 'Where to insert the new block. "at_index" requires the "index" property.', 'convertkit' ), - ), - 'index' => array( - 'type' => 'integer', - 'minimum' => 0, - 'description' => __( 'When position is "at_index", the zero-based top-level block index at which to insert the new block.', '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', 'block', 'occurrence_index', 'attrs' ), - 'properties' => array( - 'post_id' => array( - 'type' => 'integer', - ), - 'block' => array( - 'type' => 'string', - 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), - ), - 'occurrence_index' => array( - 'type' => 'integer', - 'minimum' => 0, - 'description' => __( 'Zero-based occurrence index of the newly inserted block among this block\'s appearances in the post.', 'convertkit' ), - ), - 'attrs' => array( - 'type' => 'object', - 'description' => __( 'Attributes of the newly inserted block.', '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 attributes. - $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 block into post. - $result = $this->block->insert_into_post( $post_id, $attrs, $position, $index ); - if ( is_wp_error( $result ) ) { - return $result; - } - - // Re-list occurrences to determine the newly inserted block's - // zero-based occurrence index among this block's appearances. - $occurrences = $this->block->find_blocks_in_post( $post_id ); - $occurrence_index = 0; - if ( is_array( $occurrences ) && count( $occurrences ) > 0 ) { - switch ( $position ) { - case 'prepend': - $occurrence_index = 0; - break; - - case 'at_index': - case 'append': - default: - // Find the first occurrence whose attrs match the just-inserted - // attrs; fall back to the last occurrence for 'append' and - // the first-after-$index for 'at_index'. - $occurrence_index = count( $occurrences ) - 1; - break; - } - } - - // Return result. - return array( - 'post_id' => $post_id, - 'block' => $this->block->get_full_block_name(), - 'occurrence_index' => (int) $occurrence_index, - 'attrs' => $attrs, - ); - - } - -} diff --git a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php deleted file mode 100644 index 36c3f6358..000000000 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php +++ /dev/null @@ -1,194 +0,0 @@ --list` (e.g. `kit/form-list`). - * - * @package ConvertKit - * @author ConvertKit - */ -class ConvertKit_MCP_Ability_Block_List extends ConvertKit_MCP_Ability_Block { - - /** - * Returns the verb this ability represents. - * - * @since 3.4.0 - * - * @return string - */ - protected 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 blocks in a 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 */ - __( 'Lists every occurrence of the %1$s (%2$s) block in the given post, including each occurrence\'s zero-based index and current attribute values.', 'convertkit' ), - $this->block->get_full_block_name(), - $this->block->get_title() - ); - - } - - /** - * MCP annotations: readonly + idempotent. - * - * @since 3.4.0 - * - * @return array - */ - public function get_annotations() { - - return array( - 'title' => $this->get_label(), - 'readonly' => true, - 'destructive' => false, - 'idempotent' => true, - ); - - } - - /** - * 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', 'block', 'count', 'occurrences' ), - 'properties' => array( - 'post_id' => array( - 'type' => 'integer', - ), - 'block' => array( - 'type' => 'string', - 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), - ), - 'count' => array( - 'type' => 'integer', - 'minimum' => 0, - ), - 'occurrences' => array( - 'type' => 'array', - 'items' => array( - 'type' => 'object', - 'required' => array( 'index', 'attrs' ), - 'properties' => array( - 'index' => array( - 'type' => 'integer', - 'minimum' => 0, - 'description' => __( 'Zero-based occurrence index among this block\'s appearances in the post.', 'convertkit' ), - ), - 'attrs' => array( - 'type' => 'object', - 'description' => __( 'Block 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 blocks in post. - $occurrences = $this->block->find_blocks_in_post( $post_id ); - if ( is_wp_error( $occurrences ) ) { - return $occurrences; - } - - // Return result. - return array( - 'post_id' => $post_id, - 'block' => $this->block->get_full_block_name(), - 'count' => count( $occurrences ), - 'occurrences' => $occurrences, - ); - - } - -} diff --git a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php deleted file mode 100644 index 895c742ad..000000000 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php +++ /dev/null @@ -1,207 +0,0 @@ --update` (e.g. `kit/form-update`). - * - * By default the provided attributes are merged into the existing attributes. - * Set `replace_all` to true to replace all attributes with the supplied set. - * - * @package ConvertKit - * @author ConvertKit - */ -class ConvertKit_MCP_Ability_Block_Update extends ConvertKit_MCP_Ability_Block { - - /** - * Returns the verb this ability represents. - * - * @since 3.4.0 - * - * @return string - */ - protected 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 a %s block in a 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 */ - __( 'Updates the attributes of a single occurrence of the %1$s (%2$s) block in the given post. By default the provided attributes are merged into the existing attributes; set replace_all to true to replace them entirely.', 'convertkit' ), - $this->block->get_full_block_name(), - $this->block->get_title() - ); - - } - - /** - * MCP annotations: not readonly, not destructive, idempotent - * (repeating the same update yields the same result). - * - * @since 3.4.0 - * - * @return array - */ - public function get_annotations() { - - return array( - 'title' => $this->get_label(), - 'readonly' => false, - 'destructive' => false, - 'idempotent' => true, - ); - - } - - /** - * 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', 'target', 'attrs' ), - 'properties' => array( - 'post_id' => array( - 'type' => 'integer', - 'minimum' => 1, - 'description' => __( 'ID of the post containing the block.', 'convertkit' ), - ), - 'target' => $this->get_target_schema(), - 'attrs' => array( - 'type' => 'object', - 'description' => __( 'Attribute values to apply to the target block.', 'convertkit' ), - 'properties' => $this->block->get_input_schema_properties(), - ), - 'replace_all' => array( - 'type' => 'boolean', - 'default' => false, - 'description' => __( 'If true, all existing attributes are replaced with the supplied set. If false (default), the supplied attributes are merged into the existing attributes.', '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', 'block', 'occurrence_index', 'attrs' ), - 'properties' => array( - 'post_id' => array( - 'type' => 'integer', - ), - 'block' => array( - 'type' => 'string', - 'description' => __( 'The full block name, e.g. convertkit/form.', 'convertkit' ), - ), - 'occurrence_index' => array( - 'type' => 'integer', - 'minimum' => 0, - 'description' => __( 'Zero-based occurrence index of the updated block.', 'convertkit' ), - ), - 'attrs' => array( - 'type' => 'object', - 'description' => __( 'Attributes of the updated block.', '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 target. - $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : array(); - $attrs = isset( $input['attrs'] ) && is_array( $input['attrs'] ) ? $input['attrs'] : array(); - $merge = ! ( isset( $input['replace_all'] ) && (bool) $input['replace_all'] ); - - // Resolve target. - $occurrence_index = $this->resolve_target( $post_id, $target ); - if ( is_wp_error( $occurrence_index ) ) { - return $occurrence_index; - } - - // Update block in post. - $result = $this->block->replace_in_post( $post_id, $occurrence_index, $attrs, $merge ); - if ( is_wp_error( $result ) ) { - return $result; - } - - // Return result. - return array( - 'post_id' => $post_id, - 'block' => $this->block->get_full_block_name(), - 'occurrence_index' => (int) $occurrence_index, - 'attrs' => isset( $result['attrs'] ) ? $result['attrs'] : $attrs, - ); - - } - -} diff --git a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php b/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php deleted file mode 100644 index 55b86ed62..000000000 --- a/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php +++ /dev/null @@ -1,233 +0,0 @@ -block = $block; - - } - - /** - * Returns the ability name, derived from the block's name and the verb - * returned by get_verb(). - * - * @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 protected 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 JSON Schema fragment for a `target` object describing which - * occurrence of the block the ability should act on. Used by update/delete. - * - * @since 3.4.0 - * - * @return array - */ - protected function get_target_schema() { - - return array( - 'type' => 'object', - 'description' => __( 'Identifies which occurrence of this block in the post to act on. Either by an attribute value match, or by zero-based occurrence index.', 'convertkit' ), - 'oneOf' => array( - array( - 'type' => 'object', - 'required' => array( 'by', 'attribute', 'value' ), - 'properties' => array( - 'by' => array( - 'type' => 'string', - 'enum' => array( 'attribute' ), - ), - 'attribute' => array( - 'type' => 'string', - 'description' => __( 'The block attribute name to match against (e.g. "form").', 'convertkit' ), - ), - 'value' => array( - 'description' => __( 'The value the attribute must match.', 'convertkit' ), - ), - ), - ), - array( - 'type' => 'object', - 'required' => array( 'by', 'index' ), - 'properties' => array( - 'by' => array( - 'type' => 'string', - 'enum' => array( 'index' ), - ), - 'index' => array( - 'type' => 'integer', - 'minimum' => 0, - 'description' => __( 'Zero-based occurrence index among this block\'s appearances in the post.', 'convertkit' ), - ), - ), - ), - ), - ); - - } - - /** - * Resolves a target descriptor into the zero-based occurrence index of the - * block in the post. - * - * @since 3.4.0 - * - * @param int $post_id Post ID. - * @param array $target Target descriptor (see get_target_schema()). - * @return int|WP_Error Zero-based occurrence index, or WP_Error. - */ - protected function resolve_target( $post_id, $target ) { - - // Bail if target is not an array or does not have a 'by' key. - if ( ! is_array( $target ) || empty( $target['by'] ) ) { - return new WP_Error( - 'convertkit_mcp_invalid_target', - __( 'target.by is required.', 'convertkit' ) - ); - } - - // Find blocks in post. - $occurrences = $this->block->find_blocks_in_post( $post_id ); - if ( is_wp_error( $occurrences ) ) { - return $occurrences; - } - - // Bail if no blocks are found. - if ( empty( $occurrences ) ) { - return new WP_Error( - 'convertkit_mcp_no_block_occurrences', - /* translators: 1: block name, 2: post ID */ - sprintf( __( 'No occurrences of block %1$s found in post %2$d.', 'convertkit' ), $this->block->get_full_block_name(), $post_id ) - ); - } - - // Resolve target. - switch ( $target['by'] ) { - case 'index': - $idx = isset( $target['index'] ) ? (int) $target['index'] : -1; - if ( $idx < 0 || $idx >= count( $occurrences ) ) { - return new WP_Error( - 'convertkit_mcp_target_index_out_of_range', - /* translators: 1: requested index, 2: number of occurrences */ - sprintf( __( 'Target index %1$d is out of range; post has %2$d occurrence(s).', 'convertkit' ), $idx, count( $occurrences ) ) - ); - } - return $idx; - - case 'attribute': - $attr = isset( $target['attribute'] ) ? (string) $target['attribute'] : ''; - $value = isset( $target['value'] ) ? $target['value'] : null; - if ( $attr === '' ) { - return new WP_Error( - 'convertkit_mcp_invalid_target', - __( 'target.attribute is required when target.by is "attribute".', 'convertkit' ) - ); - } - foreach ( $occurrences as $i => $occ ) { - if ( ! isset( $occ['attrs'][ $attr ] ) ) { - continue; - } - // Loose comparison so '123' == 123 resolves the same target, - // since Gutenberg attributes are often stringly typed. - if ( $occ['attrs'][ $attr ] == $value ) { // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual - return $i; - } - } - return new WP_Error( - 'convertkit_mcp_target_not_found', - /* translators: 1: attribute name, 2: value, 3: block name */ - sprintf( __( 'No occurrence of block %3$s has %1$s = %2$s.', 'convertkit' ), $attr, wp_json_encode( $value ), $this->block->get_full_block_name() ) - ); - - default: - return new WP_Error( - 'convertkit_mcp_invalid_target', - /* translators: %s: invalid 'by' value */ - sprintf( __( 'Unknown target.by value "%s". Expected "attribute" or "index".', 'convertkit' ), (string) $target['by'] ) - ); - } - - } - -} diff --git a/wp-convertkit.php b/wp-convertkit.php index 3e4230a85..1cbedbc10 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -99,12 +99,6 @@ require_once CONVERTKIT_PLUGIN_PATH . '/includes/block-formatters/class-convertkit-block-formatter-form-link.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/block-formatters/class-convertkit-block-formatter-product-link.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/class-convertkit-mcp-ability.php'; -require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block.php'; -require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-list.php'; -require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-insert.php'; -require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-update.php'; -require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/blocks/class-convertkit-mcp-ability-block-delete.php'; -require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/class-convertkit-mcp-ability-set-page-form.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/class-convertkit-mcp.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/plugin-sidebars/class-convertkit-plugin-sidebar.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/plugin-sidebars/class-convertkit-plugin-sidebar-post-settings.php'; From 1b4a5beef56efd82a3b093920cbc9ebe57fcb621 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Mon, 20 Apr 2026 18:10:24 +0800 Subject: [PATCH 03/15] Remove block helpers --- includes/blocks/class-convertkit-block.php | 341 --------------------- 1 file changed, 341 deletions(-) diff --git a/includes/blocks/class-convertkit-block.php b/includes/blocks/class-convertkit-block.php index b2ed81923..12a82447e 100644 --- a/includes/blocks/class-convertkit-block.php +++ b/includes/blocks/class-convertkit-block.php @@ -531,345 +531,4 @@ public function is_block_visible( $atts ) { } - /** - * Returns the block's full Gutenberg name (e.g. `convertkit/form`). - * - * @since 3.4.0 - * - * @return string - */ - public function get_full_block_name() { - - return 'convertkit/' . $this->get_name(); - - } - - /** - * Returns JSON Schema properties derived from this block's get_attributes() - * and get_fields(), suitable for use as the `attrs` object in an Abilities - * API input schema. - * - * Structural/styling attributes injected by Gutenberg (align, style, - * backgroundColor, textColor, className, is_gutenberg_example) are excluded - * so the agent-facing schema only covers block-specific attributes. - * - * Where possible, the schema is enriched using get_fields(): the field's - * `label` becomes the property description, and `resource`-type fields - * become an enum of valid IDs drawn from the corresponding resource class. - * - * @since 3.4.0 - * - * @return array - */ - public function get_input_schema_properties() { - - $properties = array(); - $fields = is_array( $this->get_fields() ) ? $this->get_fields() : array(); - - // JSON Schema type for each Gutenberg attribute type. - $type_map = array( - 'string' => 'string', - 'number' => 'integer', - 'boolean' => 'boolean', - 'object' => 'object', - 'array' => 'array', - ); - - // Attributes that are either provided by Gutenberg's own block supports - // or are internal-only. These should not appear in the agent-facing schema. - $skip_attrs = array( - 'align', - 'style', - 'backgroundColor', - 'textColor', - 'className', - 'is_gutenberg_example', - ); - - foreach ( $this->get_attributes() as $name => $definition ) { - if ( in_array( $name, $skip_attrs, true ) ) { - continue; - } - - $type = isset( $definition['type'] ) ? $definition['type'] : 'string'; - $json_type = isset( $type_map[ $type ] ) ? $type_map[ $type ] : 'string'; - $properties[ $name ] = array( 'type' => $json_type ); - - // Enrich from the field definition, if one exists. - if ( ! isset( $fields[ $name ] ) ) { - continue; - } - - $field = $fields[ $name ]; - - if ( ! empty( $field['label'] ) ) { - $properties[ $name ]['description'] = (string) $field['label']; - } - - // For resource-type fields, narrow the schema to a concrete list of - // valid IDs. This prevents agents from passing IDs that don't exist. - if ( isset( $field['type'] ) && $field['type'] === 'resource' && ! empty( $field['values'] ) && is_array( $field['values'] ) ) { - $ids = array_keys( $field['values'] ); - if ( ! empty( $ids ) ) { - // The attribute is typed as string in Gutenberg, but IDs are - // naturally integers. Preserve whatever the attribute's - // declared type is, and just cast enum values to match. - $properties[ $name ]['enum'] = array_map( - function ( $id ) use ( $json_type ) { - return $json_type === 'string' ? (string) $id : (int) $id; - }, - $ids - ); - } - } - } - - return $properties; - - } - - /** - * Finds all top-level occurrences of this block in the given post's content. - * - * @since 3.4.0 - * - * @param int $post_id Post ID. - * @return array|WP_Error Array of ['index' => int, 'attrs' => array] entries, or WP_Error if the post is missing. - */ - public function find_blocks_in_post( $post_id ) { - - $post = get_post( $post_id ); - if ( ! $post ) { - return new WP_Error( - 'convertkit_block_post_not_found', - /* translators: %d: post ID */ - sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id ) - ); - } - - $blocks = parse_blocks( $post->post_content ); - $full_name = $this->get_full_block_name(); - $found = array(); - - foreach ( $blocks as $index => $block ) { - if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $full_name ) { - continue; - } - - $found[] = array( - 'index' => (int) $index, - 'attrs' => isset( $block['attrs'] ) ? (array) $block['attrs'] : array(), - ); - } - - return $found; - - } - - /** - * Inserts this block into the given post's content at the specified position. - * - * @since 3.4.0 - * - * @param int $post_id Post ID. - * @param array $attrs Block attributes to set on the inserted block. - * @param string $position One of 'append', 'prepend', 'at_index'. - * @param int $index Zero-based index when $position is 'at_index'. - * @return array|WP_Error ['block_count' => int, 'position_used' => string] on success. - */ - public function insert_into_post( $post_id, $attrs, $position = 'append', $index = 0 ) { - - $post = get_post( $post_id ); - if ( ! $post ) { - return new WP_Error( - 'convertkit_block_post_not_found', - /* translators: %d: post ID */ - sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id ) - ); - } - - $blocks = parse_blocks( $post->post_content ); - - $new_block = array( - 'blockName' => $this->get_full_block_name(), - 'attrs' => (array) $attrs, - 'innerBlocks' => array(), - 'innerHTML' => '', - 'innerContent' => array(), - ); - - switch ( $position ) { - case 'prepend': - array_unshift( $blocks, $new_block ); - break; - - case 'at_index': - $index = max( 0, min( (int) $index, count( $blocks ) ) ); - array_splice( $blocks, $index, 0, array( $new_block ) ); - break; - - case 'append': - default: - $blocks[] = $new_block; - $position = 'append'; - break; - } - - $updated = wp_update_post( - array( - 'ID' => $post_id, - 'post_content' => serialize_blocks( $blocks ), - ), - true - ); - - if ( is_wp_error( $updated ) ) { - return $updated; - } - - return array( - 'block_count' => count( $blocks ), - 'position_used' => $position, - ); - - } - - /** - * Replaces the attributes of a specific top-level occurrence of this block - * in the given post's content. - * - * @since 3.4.0 - * - * @param int $post_id Post ID. - * @param int $target_index Zero-based index among this block's occurrences in the post (not the block-array index). - * @param array $attrs New attributes to apply. - * @param bool $merge If true, merge $attrs into the existing block attrs. If false, replace entirely. - * @return array|WP_Error - */ - public function replace_in_post( $post_id, $target_index, $attrs, $merge = true ) { - - $post = get_post( $post_id ); - if ( ! $post ) { - return new WP_Error( - 'convertkit_block_post_not_found', - /* translators: %d: post ID */ - sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id ) - ); - } - - $blocks = parse_blocks( $post->post_content ); - $full_name = $this->get_full_block_name(); - $occurrence = 0; - $matched = false; - $final_attrs = array(); - - foreach ( $blocks as $key => $block ) { - if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $full_name ) { - continue; - } - - if ( $occurrence === (int) $target_index ) { - $existing = isset( $block['attrs'] ) ? (array) $block['attrs'] : array(); - $final_attrs = $merge ? array_merge( $existing, (array) $attrs ) : (array) $attrs; - $blocks[ $key ]['attrs'] = $final_attrs; - $matched = true; - break; - } - - ++$occurrence; - } - - if ( ! $matched ) { - return new WP_Error( - 'convertkit_block_occurrence_not_found', - /* translators: 1: block name, 2: target index, 3: post ID */ - sprintf( __( 'No occurrence #%2$d of block %1$s found in post %3$d.', 'convertkit' ), $full_name, (int) $target_index, $post_id ) - ); - } - - $updated = wp_update_post( - array( - 'ID' => $post_id, - 'post_content' => serialize_blocks( $blocks ), - ), - true - ); - - if ( is_wp_error( $updated ) ) { - return $updated; - } - - return array( - 'attrs' => $final_attrs, - ); - - } - - /** - * Deletes a specific top-level occurrence of this block from the given - * post's content. - * - * @since 3.4.0 - * - * @param int $post_id Post ID. - * @param int $target_index Zero-based index among this block's occurrences in the post. - * @return array|WP_Error - */ - public function delete_from_post( $post_id, $target_index ) { - - $post = get_post( $post_id ); - if ( ! $post ) { - return new WP_Error( - 'convertkit_block_post_not_found', - /* translators: %d: post ID */ - sprintf( __( 'No post exists with ID %d.', 'convertkit' ), $post_id ) - ); - } - - $blocks = parse_blocks( $post->post_content ); - $full_name = $this->get_full_block_name(); - $occurrence = 0; - $matched = false; - - foreach ( $blocks as $key => $block ) { - if ( ! isset( $block['blockName'] ) || $block['blockName'] !== $full_name ) { - continue; - } - - if ( $occurrence === (int) $target_index ) { - unset( $blocks[ $key ] ); - $blocks = array_values( $blocks ); - $matched = true; - break; - } - - ++$occurrence; - } - - if ( ! $matched ) { - return new WP_Error( - 'convertkit_block_occurrence_not_found', - /* translators: 1: block name, 2: target index, 3: post ID */ - sprintf( __( 'No occurrence #%2$d of block %1$s found in post %3$d.', 'convertkit' ), $full_name, (int) $target_index, $post_id ) - ); - } - - $updated = wp_update_post( - array( - 'ID' => $post_id, - 'post_content' => serialize_blocks( $blocks ), - ), - true - ); - - if ( is_wp_error( $updated ) ) { - return $updated; - } - - return array( - 'block_count' => count( $blocks ), - ); - - } - } From 6aa6dcce7f817cca17bad4de63b676fc010c701b Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 23 Apr 2026 17:54:14 +0800 Subject: [PATCH 04/15] Add annotations as class properties --- includes/mcp/class-convertkit-mcp-ability.php | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/includes/mcp/class-convertkit-mcp-ability.php b/includes/mcp/class-convertkit-mcp-ability.php index e06170df1..017b53c41 100644 --- a/includes/mcp/class-convertkit-mcp-ability.php +++ b/includes/mcp/class-convertkit-mcp-ability.php @@ -15,6 +15,33 @@ */ abstract class ConvertKit_MCP_Ability { + /** + * Sets whether the ability is readonly. + * + * @since 3.4.0 + * + * @var bool + */ + private $readonly = false; + + /** + * Sets whether the ability is destructive. + * + * @since 3.4.0 + * + * @var bool + */ + private $destructive = false; + + /** + * Sets whether the ability is idempotent. + * + * @since 3.4.0 + * + * @var bool + */ + private $idempotent = false; + /** * Returns the ability name, prefixed with `kit/` (e.g. `kit/form-insert`). * @@ -73,7 +100,11 @@ abstract public function get_description(); * * @return string */ - abstract public function get_category(); + public function get_category() { + + return 'kit'; + + } /** * Returns the ability's input JSON Schema. @@ -107,9 +138,9 @@ public function get_annotations() { return array( 'title' => $this->get_label(), - 'readonly' => false, - 'destructive' => false, - 'idempotent' => false, + 'readonly' => $this->readonly, + 'destructive' => $this->destructive, + 'idempotent' => $this->idempotent, ); } From 5f362a2a7dd8da8731a92b389af24d038d0f4f48 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 8 May 2026 16:31:38 +0800 Subject: [PATCH 05/15] Added MCP Server Tests --- .github/workflows/tests.yml | 2 +- includes/mcp/class-convertkit-mcp.php | 5 -- tests/Integration/MCPTest.php | 111 ++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 tests/Integration/MCPTest.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b72cd3642..62c9b173f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -68,7 +68,7 @@ jobs: WORDPRESS_V3_BLOCK_EDITOR_ENABLED: true WORDPRESS_DB_SQL_DUMP_FILE: tests/Support/Data/dump.sql INSTALL_PLUGINS: "admin-menu-editor autoptimize beaver-builder-lite-version block-visibility contact-form-7 classic-editor custom-post-type-ui debloat elementor forminator jetpack-boost mailchimp-for-wp rocket-lazy-load woocommerce wordpress-seo wpforms-lite litespeed-cache wp-crontrol wp-super-cache w3-total-cache wp-fastest-cache wp-optimize sg-cachepress" # Don't include this repository's Plugin here. - INSTALL_PLUGINS_URLS: "https://downloads.wordpress.org/plugin/convertkit-for-woocommerce.1.6.4.zip http://cktestplugins.wpengine.com/wp-content/uploads/2024/01/convertkit-action-filter-tests.zip http://cktestplugins.wpengine.com/wp-content/uploads/2024/11/disable-doing-it-wrong-notices.zip http://cktestplugins.wpengine.com/wp-content/uploads/2025/03/uncode-js_composer.7.8.zip http://cktestplugins.wpengine.com/wp-content/uploads/2025/03/uncode-core.zip" # URLs to specific third party Plugins + INSTALL_PLUGINS_URLS: "https://downloads.wordpress.org/plugin/convertkit-for-woocommerce.1.6.4.zip http://cktestplugins.wpengine.com/wp-content/uploads/2024/01/convertkit-action-filter-tests.zip http://cktestplugins.wpengine.com/wp-content/uploads/2024/11/disable-doing-it-wrong-notices.zip http://cktestplugins.wpengine.com/wp-content/uploads/2025/03/uncode-js_composer.7.8.zip http://cktestplugins.wpengine.com/wp-content/uploads/2025/03/uncode-core.zip https://github.com/WordPress/mcp-adapter/releases/download/v0.5.0/mcp-adapter.zip" # URLs to specific third party Plugins INSTALL_THEMES_URLS: "http://cktestplugins.wpengine.com/wp-content/uploads/2025/03/uncode.zip http://cktestplugins.wpengine.com/wp-content/uploads/2026/03/Divi_5.zip http://cktestplugins.wpengine.com/wp-content/uploads/2026/01/impeka.zip" CONVERTKIT_API_KEY: ${{ secrets.CONVERTKIT_API_KEY }} # ConvertKit API Key, stored in the repository's Settings > Secrets CONVERTKIT_API_SECRET: ${{ secrets.CONVERTKIT_API_SECRET }} # ConvertKit API Secret, stored in the repository's Settings > Secrets diff --git a/includes/mcp/class-convertkit-mcp.php b/includes/mcp/class-convertkit-mcp.php index e7ad2a6f5..9a744ec54 100644 --- a/includes/mcp/class-convertkit-mcp.php +++ b/includes/mcp/class-convertkit-mcp.php @@ -144,11 +144,6 @@ public function register_mcp_server( $adapter ) { // Get abilities. $abilities = convertkit_get_abilities(); - // Bail if no abilities are available. - if ( ! count( $abilities ) ) { - return; - } - // Build array of ability names. $ability_names = array(); foreach ( $abilities as $ability ) { diff --git a/tests/Integration/MCPTest.php b/tests/Integration/MCPTest.php new file mode 100644 index 000000000..360178873 --- /dev/null +++ b/tests/Integration/MCPTest.php @@ -0,0 +1,111 @@ +dispatch( $request ); + + // Assert response is unsuccessful. + $this->assertSame( 401, $response->get_status() ); + } + + /** + * Test that the Kit MCP server is registered with the MCP Adapter and + * exposes its discovery endpoint at /wp-json/kit-mcp/v1. + * + * @since 3.4.0 + */ + public function testKitMCPServerCreated() + { + // Create and become administrator. + $this->actAsAdministrator(); + + // Make request. + $request = new \WP_REST_Request('POST', '/kit-mcp/v1'); + $request->set_header('Content-Type', 'application/json'); + $request->set_body( + wp_json_encode( + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + 'capabilities' => new \stdClass(), + 'clientInfo' => [ + 'name' => 'test', + 'version' => '1.0', + ], + ], + ] + ) + ); + $response = rest_get_server()->dispatch($request); + + // Assert the discovery endpoint is registered and responds successfully. + $this->assertSame(200, $response->get_status()); + + // Assert the response identifies itself as the Kit MCP server. + $data = $response->get_data(); + $this->assertSame('Kit MCP', $data['result']->serverInfo['name'] ?? null); + } + + /** + * Act as an administrator user. + * + * @since 3.4.0 + */ + private function actAsAdministrator() + { + $administrator_id = static::factory()->user->create( [ 'role' => 'administrator' ] ); + wp_set_current_user( $administrator_id ); + } +} From 8e37e2e7990d4d9aec2562102114f35d818c0739 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 8 May 2026 17:07:29 +0800 Subject: [PATCH 06/15] Load MCP Adapter as Composer Dependency --- .distignore | 2 -- .github/workflows/deploy.yml | 2 ++ .github/workflows/tests-backward-compat.yml | 2 ++ .github/workflows/tests.yml | 4 +++- .scripts/create-plugin-zip.sh | 2 -- composer.json | 3 ++- tests/Integration/MCPTest.php | 2 -- wp-convertkit.php | 7 +++++++ 8 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.distignore b/.distignore index edab61b68..b583cbafc 100644 --- a/.distignore +++ b/.distignore @@ -7,8 +7,6 @@ /node_modules /resources/frontend/css/*.map /tests -/vendor/autoload.php -/vendor/composer /vendor/convertkit/convertkit-wordpress-libraries/.git /vendor/convertkit/convertkit-wordpress-libraries/.github /vendor/convertkit/convertkit-wordpress-libraries/tests diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0f6bfecbf..a75769b66 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -43,11 +43,13 @@ jobs: "resources/frontend/css/frontend.css" "resources/frontend/js/dist/frontend.min.asset.php" "resources/frontend/js/dist/frontend.min.js" + "vendor/autoload.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-log.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-resource-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-review-request.php" + "vendor/wordpress/mcp-adapter/mcp-adapter.php" ) for file in "${files[@]}"; do diff --git a/.github/workflows/tests-backward-compat.yml b/.github/workflows/tests-backward-compat.yml index 97e7a5170..97e7d7b2a 100644 --- a/.github/workflows/tests-backward-compat.yml +++ b/.github/workflows/tests-backward-compat.yml @@ -311,11 +311,13 @@ jobs: "resources/frontend/css/frontend.css" "resources/frontend/js/dist/frontend.min.asset.php" "resources/frontend/js/dist/frontend.min.js" + "vendor/autoload.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-log.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-resource-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-review-request.php" + "vendor/wordpress/mcp-adapter/mcp-adapter.php" ) for file in "${files[@]}"; do diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 62c9b173f..826e243d1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -68,7 +68,7 @@ jobs: WORDPRESS_V3_BLOCK_EDITOR_ENABLED: true WORDPRESS_DB_SQL_DUMP_FILE: tests/Support/Data/dump.sql INSTALL_PLUGINS: "admin-menu-editor autoptimize beaver-builder-lite-version block-visibility contact-form-7 classic-editor custom-post-type-ui debloat elementor forminator jetpack-boost mailchimp-for-wp rocket-lazy-load woocommerce wordpress-seo wpforms-lite litespeed-cache wp-crontrol wp-super-cache w3-total-cache wp-fastest-cache wp-optimize sg-cachepress" # Don't include this repository's Plugin here. - INSTALL_PLUGINS_URLS: "https://downloads.wordpress.org/plugin/convertkit-for-woocommerce.1.6.4.zip http://cktestplugins.wpengine.com/wp-content/uploads/2024/01/convertkit-action-filter-tests.zip http://cktestplugins.wpengine.com/wp-content/uploads/2024/11/disable-doing-it-wrong-notices.zip http://cktestplugins.wpengine.com/wp-content/uploads/2025/03/uncode-js_composer.7.8.zip http://cktestplugins.wpengine.com/wp-content/uploads/2025/03/uncode-core.zip https://github.com/WordPress/mcp-adapter/releases/download/v0.5.0/mcp-adapter.zip" # URLs to specific third party Plugins + INSTALL_PLUGINS_URLS: "https://downloads.wordpress.org/plugin/convertkit-for-woocommerce.1.6.4.zip http://cktestplugins.wpengine.com/wp-content/uploads/2024/01/convertkit-action-filter-tests.zip http://cktestplugins.wpengine.com/wp-content/uploads/2024/11/disable-doing-it-wrong-notices.zip http://cktestplugins.wpengine.com/wp-content/uploads/2025/03/uncode-js_composer.7.8.zip http://cktestplugins.wpengine.com/wp-content/uploads/2025/03/uncode-core.zip" # URLs to specific third party Plugins INSTALL_THEMES_URLS: "http://cktestplugins.wpengine.com/wp-content/uploads/2025/03/uncode.zip http://cktestplugins.wpengine.com/wp-content/uploads/2026/03/Divi_5.zip http://cktestplugins.wpengine.com/wp-content/uploads/2026/01/impeka.zip" CONVERTKIT_API_KEY: ${{ secrets.CONVERTKIT_API_KEY }} # ConvertKit API Key, stored in the repository's Settings > Secrets CONVERTKIT_API_SECRET: ${{ secrets.CONVERTKIT_API_SECRET }} # ConvertKit API Secret, stored in the repository's Settings > Secrets @@ -350,11 +350,13 @@ jobs: "resources/frontend/css/frontend.css" "resources/frontend/js/dist/frontend.min.asset.php" "resources/frontend/js/dist/frontend.min.js" + "vendor/autoload.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-log.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-resource-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-review-request.php" + "vendor/wordpress/mcp-adapter/mcp-adapter.php" ) for file in "${files[@]}"; do diff --git a/.scripts/create-plugin-zip.sh b/.scripts/create-plugin-zip.sh index c7d170886..4de10c744 100644 --- a/.scripts/create-plugin-zip.sh +++ b/.scripts/create-plugin-zip.sh @@ -14,11 +14,9 @@ zip -r convertkit.zip . \ -x ".wordpress-org/*" \ -x "log/*" \ -x "tests/*" \ --x "vendor/composer/*" \ -x "vendor/convertkit/convertkit-wordpress-libraries/.github" \ -x "vendor/convertkit/convertkit-wordpress-libraries/tests/*" \ -x "vendor/convertkit/convertkit-wordpress-libraries/composer.json" \ --x "vendor/autoload.php" \ -x "*.distignore" \ -x "*.env.*" \ -x ".gitignore" \ diff --git a/composer.json b/composer.json index 37be0f728..3ff418d75 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,8 @@ "type": "project", "license": "GPLv3", "require": { - "convertkit/convertkit-wordpress-libraries": "2.1.6" + "convertkit/convertkit-wordpress-libraries": "2.1.6", + "wordpress/mcp-adapter": "^0.5.0" }, "require-dev": { "php-webdriver/webdriver": "^1.0", diff --git a/tests/Integration/MCPTest.php b/tests/Integration/MCPTest.php index 360178873..3f79ea1c5 100644 --- a/tests/Integration/MCPTest.php +++ b/tests/Integration/MCPTest.php @@ -27,7 +27,6 @@ public function setUp(): void { parent::setUp(); activate_plugins('convertkit/wp-convertkit.php'); - activate_plugins('mcp-adapter/mcp-adapter.php'); } /** @@ -37,7 +36,6 @@ public function setUp(): void */ public function tearDown(): void { - deactivate_plugins('mcp-adapter/mcp-adapter.php'); deactivate_plugins('convertkit/wp-convertkit.php'); parent::tearDown(); } diff --git a/wp-convertkit.php b/wp-convertkit.php index 3ba3f3e99..35e5bc708 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -31,6 +31,13 @@ define( 'CONVERTKIT_OAUTH_CLIENT_ID', 'HXZlOCj-K5r0ufuWCtyoyo3f688VmMAYSsKg1eGvw0Y' ); define( 'CONVERTKIT_OAUTH_CLIENT_REDIRECT_URI', 'https://app.kit.com/wordpress/redirect' ); +// Load the WordPress MCP Adapter via composer's autoload. +// Only load on PHP 7.4+, since the MCP Adapter requires it. +if ( version_compare( PHP_VERSION, '7.4', '>=' ) + && file_exists( CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php' ) ) { + require_once CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php'; +} + // Load shared classes, if they have not been included by another Kit Plugin. if ( ! trait_exists( 'ConvertKit_API_Traits' ) && ! trait_exists( 'ConvertKit_API\ConvertKit_API_Traits' ) ) { require_once CONVERTKIT_PLUGIN_PATH . '/vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php'; From d1ecb12881e47e8ad8289786807f4e143b2a7472 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 8 May 2026 17:15:23 +0800 Subject: [PATCH 07/15] Load WordPress MCP Adapter directly --- .distignore | 2 ++ .github/workflows/deploy.yml | 1 - .github/workflows/tests-backward-compat.yml | 1 - .github/workflows/tests.yml | 1 - wp-convertkit.php | 8 ++++---- 5 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.distignore b/.distignore index b583cbafc..edab61b68 100644 --- a/.distignore +++ b/.distignore @@ -7,6 +7,8 @@ /node_modules /resources/frontend/css/*.map /tests +/vendor/autoload.php +/vendor/composer /vendor/convertkit/convertkit-wordpress-libraries/.git /vendor/convertkit/convertkit-wordpress-libraries/.github /vendor/convertkit/convertkit-wordpress-libraries/tests diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a75769b66..ec195ab3b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -43,7 +43,6 @@ jobs: "resources/frontend/css/frontend.css" "resources/frontend/js/dist/frontend.min.asset.php" "resources/frontend/js/dist/frontend.min.js" - "vendor/autoload.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-log.php" diff --git a/.github/workflows/tests-backward-compat.yml b/.github/workflows/tests-backward-compat.yml index 97e7d7b2a..9c98cb79b 100644 --- a/.github/workflows/tests-backward-compat.yml +++ b/.github/workflows/tests-backward-compat.yml @@ -311,7 +311,6 @@ jobs: "resources/frontend/css/frontend.css" "resources/frontend/js/dist/frontend.min.asset.php" "resources/frontend/js/dist/frontend.min.js" - "vendor/autoload.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-log.php" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 826e243d1..a08ee7ca5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -350,7 +350,6 @@ jobs: "resources/frontend/css/frontend.css" "resources/frontend/js/dist/frontend.min.asset.php" "resources/frontend/js/dist/frontend.min.js" - "vendor/autoload.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-log.php" diff --git a/wp-convertkit.php b/wp-convertkit.php index 35e5bc708..cb7b4d9d0 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -31,11 +31,11 @@ define( 'CONVERTKIT_OAUTH_CLIENT_ID', 'HXZlOCj-K5r0ufuWCtyoyo3f688VmMAYSsKg1eGvw0Y' ); define( 'CONVERTKIT_OAUTH_CLIENT_REDIRECT_URI', 'https://app.kit.com/wordpress/redirect' ); -// Load the WordPress MCP Adapter via composer's autoload. -// Only load on PHP 7.4+, since the MCP Adapter requires it. +// Load the WordPress MCP Adapter directly. PHP 7.4+ required. if ( version_compare( PHP_VERSION, '7.4', '>=' ) - && file_exists( CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php' ) ) { - require_once CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php'; + && ! class_exists( 'WP\\MCP\\Plugin' ) + && file_exists( CONVERTKIT_PLUGIN_PATH . '/vendor/wordpress/mcp-adapter/mcp-adapter.php' ) ) { + require_once CONVERTKIT_PLUGIN_PATH . '/vendor/wordpress/mcp-adapter/mcp-adapter.php'; } // Load shared classes, if they have not been included by another Kit Plugin. From f9e6af223883b3ca437f6221331afe3899ac89ce Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 8 May 2026 17:23:01 +0800 Subject: [PATCH 08/15] Initialize MCP Adapter --- .distignore | 2 -- .github/workflows/deploy.yml | 1 + .github/workflows/tests-backward-compat.yml | 1 + .github/workflows/tests.yml | 1 + wp-convertkit.php | 12 ++++++------ 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.distignore b/.distignore index edab61b68..b583cbafc 100644 --- a/.distignore +++ b/.distignore @@ -7,8 +7,6 @@ /node_modules /resources/frontend/css/*.map /tests -/vendor/autoload.php -/vendor/composer /vendor/convertkit/convertkit-wordpress-libraries/.git /vendor/convertkit/convertkit-wordpress-libraries/.github /vendor/convertkit/convertkit-wordpress-libraries/tests diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ec195ab3b..a75769b66 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -43,6 +43,7 @@ jobs: "resources/frontend/css/frontend.css" "resources/frontend/js/dist/frontend.min.asset.php" "resources/frontend/js/dist/frontend.min.js" + "vendor/autoload.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-log.php" diff --git a/.github/workflows/tests-backward-compat.yml b/.github/workflows/tests-backward-compat.yml index 9c98cb79b..97e7d7b2a 100644 --- a/.github/workflows/tests-backward-compat.yml +++ b/.github/workflows/tests-backward-compat.yml @@ -311,6 +311,7 @@ jobs: "resources/frontend/css/frontend.css" "resources/frontend/js/dist/frontend.min.asset.php" "resources/frontend/js/dist/frontend.min.js" + "vendor/autoload.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-log.php" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a08ee7ca5..826e243d1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -350,6 +350,7 @@ jobs: "resources/frontend/css/frontend.css" "resources/frontend/js/dist/frontend.min.asset.php" "resources/frontend/js/dist/frontend.min.js" + "vendor/autoload.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-log.php" diff --git a/wp-convertkit.php b/wp-convertkit.php index cb7b4d9d0..eeccab08e 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -31,13 +31,13 @@ define( 'CONVERTKIT_OAUTH_CLIENT_ID', 'HXZlOCj-K5r0ufuWCtyoyo3f688VmMAYSsKg1eGvw0Y' ); define( 'CONVERTKIT_OAUTH_CLIENT_REDIRECT_URI', 'https://app.kit.com/wordpress/redirect' ); -// Load the WordPress MCP Adapter directly. PHP 7.4+ required. -if ( version_compare( PHP_VERSION, '7.4', '>=' ) - && ! class_exists( 'WP\\MCP\\Plugin' ) - && file_exists( CONVERTKIT_PLUGIN_PATH . '/vendor/wordpress/mcp-adapter/mcp-adapter.php' ) ) { - require_once CONVERTKIT_PLUGIN_PATH . '/vendor/wordpress/mcp-adapter/mcp-adapter.php'; +// Load Composer autoloader. Provides the WordPress MCP Adapter classes. +if ( file_exists( CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php' ) ) { + require_once CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php'; + if ( class_exists( 'WP\\MCP\\Plugin' ) ) { + \WP\MCP\Plugin::instance(); + } } - // Load shared classes, if they have not been included by another Kit Plugin. if ( ! trait_exists( 'ConvertKit_API_Traits' ) && ! trait_exists( 'ConvertKit_API\ConvertKit_API_Traits' ) ) { require_once CONVERTKIT_PLUGIN_PATH . '/vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php'; From bab16d0b562b5e425f78be4e09616fb64af6f5ef Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 8 May 2026 17:32:35 +0800 Subject: [PATCH 09/15] Fix Coding Standards + MCP Adapter on PHP 7.2 + 7.3 --- .github/workflows/coding-standards.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 2da178f5d..ba8567d6f 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -144,9 +144,18 @@ jobs: tools: cs2pr # Installs wp-browser, Codeception, PHP CodeSniffer and anything else needed to run tests. + # On PHP 7.2 / 7.3, ignore the platform PHP requirement so dependencies + # like wordpress/mcp-adapter (which requires PHP 7.4+) can still install. + # We're only running coding standards / static analysis here, not + # executing the MCP Adapter code, so this is safe. - name: Run Composer working-directory: ${{ env.PLUGIN_DIR }} - run: composer update + run: | + if [[ "${{ matrix.php-versions }}" == "7.2" || "${{ matrix.php-versions }}" == "7.3" ]]; then + composer update --ignore-platform-req=php + else + composer update + fi - name: Build PHP Autoloader working-directory: ${{ env.PLUGIN_DIR }} From 94830d8b91af1c01201867244c48f557631718aa Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 8 May 2026 17:33:28 +0800 Subject: [PATCH 10/15] Use McpAdapter::instance() to initialize the MCP Adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin::instance() is wrong, as it’ll error on WordPress versions < 6.4 due to wp_admin_notice() --- wp-convertkit.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/wp-convertkit.php b/wp-convertkit.php index eeccab08e..6444631de 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -34,8 +34,13 @@ // Load Composer autoloader. Provides the WordPress MCP Adapter classes. if ( file_exists( CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php' ) ) { require_once CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php'; - if ( class_exists( 'WP\\MCP\\Plugin' ) ) { - \WP\MCP\Plugin::instance(); + + // 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 + if ( class_exists( 'WP\\MCP\\Core\\McpAdapter' ) ) { + \WP\MCP\Core\McpAdapter::instance(); } } // Load shared classes, if they have not been included by another Kit Plugin. From 4c9682fb859d620b7feb9b9a7d29c074a2354cd2 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 8 May 2026 17:39:28 +0800 Subject: [PATCH 11/15] Coding standards --- wp-convertkit.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/wp-convertkit.php b/wp-convertkit.php index 6444631de..0bb861422 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -37,8 +37,7 @@ // 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 + // @see https://github.com/WordPress/mcp-adapter#using-mcp-adapter-in-your-plugin. if ( class_exists( 'WP\\MCP\\Core\\McpAdapter' ) ) { \WP\MCP\Core\McpAdapter::instance(); } From 7799dc43768b90f9e190256be94750348729f529 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 8 May 2026 17:59:49 +0800 Subject: [PATCH 12/15] MCP Adapter: Only load if Abilities API exists and PHP 7.4+ is used --- wp-convertkit.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/wp-convertkit.php b/wp-convertkit.php index 0bb861422..5e2600c64 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -31,8 +31,9 @@ define( 'CONVERTKIT_OAUTH_CLIENT_ID', 'HXZlOCj-K5r0ufuWCtyoyo3f688VmMAYSsKg1eGvw0Y' ); define( 'CONVERTKIT_OAUTH_CLIENT_REDIRECT_URI', 'https://app.kit.com/wordpress/redirect' ); -// Load Composer autoloader. Provides the WordPress MCP Adapter classes. -if ( file_exists( CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php' ) ) { +// Load WordPress MCP Adapter if the Abilities API is available (WordPress 6.9+) +// and PHP 7.4+ is installed. +if ( file_exists( CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php' ) && function_exists( 'wp_register_ability' ) && version_compare( PHP_VERSION, '7.4', '>=' ) ) { require_once CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php'; // Bootstrap the MCP Adapter, per WordPress/mcp-adapter's recommended From 1caa36b8c499cc544f2e9c2ec0f59c03e53a524c Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 8 May 2026 18:01:02 +0800 Subject: [PATCH 13/15] =?UTF-8?q?Coding=20Standards:=20Don=E2=80=99t=20gen?= =?UTF-8?q?erate=20`vendor/composer/platform=5Fcheck.php`,=20so=207.2=20an?= =?UTF-8?q?d=207.3=20coding=20standards=20can=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 3ff418d75..763a127da 100644 --- a/composer.json +++ b/composer.json @@ -96,7 +96,8 @@ "config": { "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true - } + }, + "platform-check": false }, "repositories": [ { From b3208cc1b0217a9a7d88a17a7ee002a29a83afd8 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 8 May 2026 18:12:26 +0800 Subject: [PATCH 14/15] Revert platform-check changes --- .github/workflows/coding-standards.yml | 11 +---------- .github/workflows/tests.yml | 8 ++++++++ composer.json | 3 +-- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index ba8567d6f..2da178f5d 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -144,18 +144,9 @@ jobs: tools: cs2pr # Installs wp-browser, Codeception, PHP CodeSniffer and anything else needed to run tests. - # On PHP 7.2 / 7.3, ignore the platform PHP requirement so dependencies - # like wordpress/mcp-adapter (which requires PHP 7.4+) can still install. - # We're only running coding standards / static analysis here, not - # executing the MCP Adapter code, so this is safe. - name: Run Composer working-directory: ${{ env.PLUGIN_DIR }} - run: | - if [[ "${{ matrix.php-versions }}" == "7.2" || "${{ matrix.php-versions }}" == "7.3" ]]; then - composer update --ignore-platform-req=php - else - composer update - fi + run: composer update - name: Build PHP Autoloader working-directory: ${{ env.PLUGIN_DIR }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 826e243d1..a7fb90e13 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -332,6 +332,14 @@ jobs: working-directory: ${{ env.PLUGIN_DIR }} run: composer update + # Install the WordPress MCP Adapter, which requires PHP 7.4+. We don't + # include it in composer.json because the Plugin must support PHP 7.1+, + # and Composer cannot conditionally require packages by PHP version. + # All test matrix entries run on PHP 7.4+, so this is always safe here. + - name: Install MCP Adapter + working-directory: ${{ env.PLUGIN_DIR }} + run: composer require wordpress/mcp-adapter:^0.5.0 + # Build the frontend CSS and JS assets - name: Run npm working-directory: ${{ env.PLUGIN_DIR }} diff --git a/composer.json b/composer.json index 763a127da..3ff418d75 100644 --- a/composer.json +++ b/composer.json @@ -96,8 +96,7 @@ "config": { "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true - }, - "platform-check": false + } }, "repositories": [ { From e462e246ed5895381296719f7c52a398792d2399 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Fri, 8 May 2026 18:54:28 +0800 Subject: [PATCH 15/15] Coding Standards: Remove `wordpress/mcp-adapter` on PHP 7.2 and 7.3 --- .github/workflows/coding-standards.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 2da178f5d..51e91270c 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -144,9 +144,22 @@ jobs: tools: cs2pr # Installs wp-browser, Codeception, PHP CodeSniffer and anything else needed to run tests. + # --ignore-platform-req=php is required as wordpress/mcp-adapter otherwise won't install + # on PHP 7.2 and 7.3, resulting in composer errors. - name: Run Composer working-directory: ${{ env.PLUGIN_DIR }} - run: composer update + run: | + if [[ "${{ matrix.php-versions }}" == "7.2" || "${{ matrix.php-versions }}" == "7.3" ]]; then + composer update --ignore-platform-req=php + else + composer update + fi + + # Remove the wordpress/mcp-adapter package. We don't need it for coding standards, and composer + # commands will fail if it's installed and using PHP 7.2 or 7.3. + - name: Remove wordpress/mcp-adapter + working-directory: ${{ env.PLUGIN_DIR }} + run: composer remove wordpress/mcp-adapter --no-update - name: Build PHP Autoloader working-directory: ${{ env.PLUGIN_DIR }}