From d0d10240f4fbf1c2d597f66cbf770b5984a0195c Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 27 May 2026 17:32:25 +0800 Subject: [PATCH 1/3] Register Forms as MCP Tools --- includes/blocks/class-convertkit-block-form.php | 16 ++++++++++++++++ ...ass-convertkit-mcp-ability-content-delete.php | 9 ++++----- ...ass-convertkit-mcp-ability-content-insert.php | 7 +++---- ...class-convertkit-mcp-ability-content-list.php | 11 +++++------ ...ass-convertkit-mcp-ability-content-update.php | 9 ++++----- 5 files changed, 32 insertions(+), 20 deletions(-) diff --git a/includes/blocks/class-convertkit-block-form.php b/includes/blocks/class-convertkit-block-form.php index f5e121ba8..7a7db6e73 100644 --- a/includes/blocks/class-convertkit-block-form.php +++ b/includes/blocks/class-convertkit-block-form.php @@ -27,6 +27,9 @@ public function __construct() { // Register this as a Gutenberg block in the ConvertKit Plugin. add_filter( 'convertkit_blocks', array( $this, 'register' ) ); + // Register this block's MCP abilities. + add_filter( 'convertkit_abilities', array( $this, 'register_abilities' ) ); + // Enqueue scripts for this Gutenberg Block in the editor view. add_action( 'convertkit_gutenberg_enqueue_scripts', array( $this, 'enqueue_scripts_editor' ) ); @@ -101,6 +104,19 @@ public function get_title() { } + /** + * Returns this block's plural title. + * + * @since 3.4.0 + * + * @return string + */ + public function get_title_plural() { + + return __( 'Kit Forms', 'convertkit' ); + + } + /** * Returns this block's icon. * diff --git a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-delete.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-delete.php index fc21b383a..c0081e9e9 100644 --- a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-delete.php +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-delete.php @@ -51,7 +51,7 @@ public function get_label() { return sprintf( /* translators: %s: block title */ - __( 'Delete an existing %s element from a post', 'convertkit' ), + __( 'Delete Existing %s from a Post, Page or Custom Post', 'convertkit' ), $this->block->get_title() ); @@ -67,10 +67,9 @@ public function get_label() { 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) element from the given post.', 'convertkit' ), - 'convertkit/' . $this->block->get_name(), - $this->block->get_title() + /* translators: Block Name */ + __( 'Removes an existing %s from a Post, Page or Custom Post using the supplied zero-based occurrence index.', 'convertkit' ), + $this->block->get_title_plural() ); } diff --git a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-insert.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-insert.php index 5c14151f7..d705ec571 100644 --- a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-insert.php +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-insert.php @@ -42,7 +42,7 @@ public function get_label() { return sprintf( /* translators: %s: block title */ - __( 'Insert a %s element into a post', 'convertkit' ), + __( 'Insert %s into a Page, Post or Custom Post', 'convertkit' ), $this->block->get_title() ); @@ -59,9 +59,8 @@ 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) element into the given post\'s content. The element can be appended (default), prepended, or positioned relative to an existing element using a zero-based index.', 'convertkit' ), - 'convertkit/' . $this->block->get_name(), - $this->block->get_title() + __( 'Inserts a new %s in a Page, Post or Custom Post\'s content. The element can be appended (default), prepended, or inserted relative to an existing element using a zero-based index.', 'convertkit' ), + $this->block->get_title_plural() ); } diff --git a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php index 398b51db5..3e05e3bc9 100644 --- a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php @@ -60,8 +60,8 @@ public function get_label() { return sprintf( /* translators: %s: block title */ - __( 'List %s elements in a post', 'convertkit' ), - $this->block->get_title() + __( 'List %s in a Post, Page or Custom Post', 'convertkit' ), + $this->block->get_title_plural() ); } @@ -76,10 +76,9 @@ public function get_label() { 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) element in the given post, including each occurrence\'s zero-based index and current attribute values.', 'convertkit' ), - 'convertkit/' . $this->block->get_name(), - $this->block->get_title() + /* translators: Block Name */ + __( 'Lists every %s in the given Post, Page or Custom Post, including each occurrence\'s zero-based index and current attribute values.', 'convertkit' ), + $this->block->get_title_plural() ); } diff --git a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-update.php b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-update.php index d7c73ca47..a614f66c9 100644 --- a/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-update.php +++ b/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-update.php @@ -51,7 +51,7 @@ public function get_label() { return sprintf( /* translators: %s: block title */ - __( 'Update an existing %s element in a post', 'convertkit' ), + __( 'Update Existing %s in a Page, Post or Custom Post', 'convertkit' ), $this->block->get_title() ); @@ -67,10 +67,9 @@ public function get_label() { 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) element in the given post. By default the provided attributes are merged into the existing attributes.', 'convertkit' ), - 'convertkit/' . $this->block->get_name(), - $this->block->get_title() + /* translators: Block Name */ + __( 'Updates the attributes of an existing %s in a Page, Post or Custom Post. The provided attributes are merged into the existing attributes.', 'convertkit' ), + $this->block->get_title_plural() ); } From 72f0b0b82e43cfeea99887a4e68c700781a226f9 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 27 May 2026 22:34:28 +0800 Subject: [PATCH 2/3] Added tests --- includes/blocks/class-convertkit-block.php | 8 +- tests/Integration/MCPContentFormTest.php | 356 +++++++++++++++++++++ 2 files changed, 360 insertions(+), 4 deletions(-) create mode 100644 tests/Integration/MCPContentFormTest.php diff --git a/includes/blocks/class-convertkit-block.php b/includes/blocks/class-convertkit-block.php index c308a1bd0..b94b8226f 100644 --- a/includes/blocks/class-convertkit-block.php +++ b/includes/blocks/class-convertkit-block.php @@ -68,10 +68,10 @@ public function register_abilities( $abilities ) { return array_merge( $abilities, array( - new ConvertKit_MCP_Ability_Content_List( $this ), - new ConvertKit_MCP_Ability_Content_Insert( $this ), - new ConvertKit_MCP_Ability_Content_Update( $this ), - new ConvertKit_MCP_Ability_Content_Delete( $this ), + 'kit/' . $this->get_name() . '-list' => new ConvertKit_MCP_Ability_Content_List( $this ), + 'kit/' . $this->get_name() . '-insert' => new ConvertKit_MCP_Ability_Content_Insert( $this ), + 'kit/' . $this->get_name() . '-update' => new ConvertKit_MCP_Ability_Content_Update( $this ), + 'kit/' . $this->get_name() . '-delete' => new ConvertKit_MCP_Ability_Content_Delete( $this ), ) ); diff --git a/tests/Integration/MCPContentFormTest.php b/tests/Integration/MCPContentFormTest.php new file mode 100644 index 000000000..79bbe1f1b --- /dev/null +++ b/tests/Integration/MCPContentFormTest.php @@ -0,0 +1,356 @@ +postID = $this->createPostWithFormBlocks(); + } + + /** + * Performs actions after each test. + * + * @since 3.4.0 + */ + public function tearDown(): void + { + // Restore the current user. + wp_set_current_user(0); + + // Deactivate Plugin. + deactivate_plugins('convertkit/wp-convertkit.php'); + + parent::tearDown(); + } + + /** + * The ability names registered by the Form block. + * + * @since 3.4.0 + * + * @var string[] + */ + private const FORM_ABILITY_NAMES = array( + 'kit/form-list', + 'kit/form-insert', + 'kit/form-update', + 'kit/form-delete', + ); + + /** + * Test that the Form block registers all four content abilities via the + * convertkit_abilities filter with the expected names. + * + * @since 3.4.0 + */ + public function testAbilitiesRegistered() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // The ability names and classes expected to be registered. + $expected = array( + 'kit/form-list' => \ConvertKit_MCP_Ability_Content_List::class, + 'kit/form-insert' => \ConvertKit_MCP_Ability_Content_Insert::class, + 'kit/form-update' => \ConvertKit_MCP_Ability_Content_Update::class, + 'kit/form-delete' => \ConvertKit_MCP_Ability_Content_Delete::class, + ); + + // Assert that the abilities are registered and are instances of the expected classes. + foreach ( $expected as $name => $class ) { + $this->assertArrayHasKey($name, $abilities); + $this->assertInstanceOf($class, $abilities[ $name ]); + } + } + + /** + * Test that the permission_callback() rejects a user who cannot edit the + * given post. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutEditPostCapability() + { + // Become a Subscriber (no edit_post capability). + $subscriber_id = static::factory()->user->create([ 'role' => 'subscriber' ]); + wp_set_current_user($subscriber_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::FORM_ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that the permission_callback() rejects a request with no post_id, + * with a clear error code. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutPostId() + { + // Become an Administrator (has every capability, so the only thing + // that can fail here is the missing post_id check). + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::FORM_ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that the permission_callback() permits an Administrator on a + * valid post_id. + * + * @since 3.4.0 + */ + public function testPermissionCallbackPermitsAdministrator() + { + // Become an Administrator. + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission granted. + foreach ( self::FORM_ABILITY_NAMES as $name ) { + // Execute the ability. + $this->assertTrue($abilities[ $name ]->permission_callback([ 'post_id' => $this->postID ])); + } + } + + /** + * Test that kit/form-list returns every Form block occurrence in the + * post, with shape { post_id, count, occurrences: [{occurrence_index, attrs}] }. + * + * @since 3.4.0 + */ + public function testListReturnsAllFormOccurrencesInPost() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/form-list']->execute_callback([ 'post_id' => $this->postID ]); + + $this->assertIsArray($result); + $this->assertSame($this->postID, $result['post_id']); + $this->assertSame(2, $result['count']); + $this->assertCount(2, $result['occurrences']); + + // Each occurrence carries an occurrence_index and an attrs object + // holding the form ID from the seeded post content. + foreach ($result['occurrences'] as $i => $occurrence) { + $this->assertSame($i, $occurrence['occurrence_index']); + $this->assertArrayHasKey('attrs', $occurrence); + $this->assertSame( + (string) $_ENV['CONVERTKIT_API_FORM_ID'], + (string) $occurrence['attrs']['form'] + ); + } + } + + /** + * Test that kit/form-insert appends a new Form block to the post, and + * returns the new occurrence_index. + * + * @since 3.4.0 + */ + public function testInsertAppendsFormBlock() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/form-insert']->execute_callback(array( + 'post_id' => $this->postID, + 'attrs' => array( 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ), + 'position' => 'append', + )); + + $this->assertIsArray($result); + $this->assertSame($this->postID, $result['post_id']); + $this->assertSame(2, $result['occurrence_index']); + + // Confirm the post now contains three Form blocks. + $listed = $abilities['kit/form-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame(3, $listed['count']); + } + + /** + * Test that kit/form-update changes the attrs of a specific occurrence, + * leaving other occurrences untouched. + * + * @since 3.4.0 + */ + public function testUpdateModifiesSingleOccurrence() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Update the second Form block (occurrence_index 1) to a different form ID. + $new_form_id = (string) ( (int) $_ENV['CONVERTKIT_API_FORM_ID'] + 1 ); + $result = $abilities['kit/form-update']->execute_callback(array( + 'post_id' => $this->postID, + 'occurrence_index' => 1, + 'attrs' => array( 'form' => $new_form_id ), + )); + + $this->assertIsArray($result); + $this->assertSame(1, $result['occurrence_index']); + + // Re-list and confirm: occurrence 0 unchanged, occurrence 1 has the new form ID. + $listed = $abilities['kit/form-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame( + (string) $_ENV['CONVERTKIT_API_FORM_ID'], + (string) $listed['occurrences'][0]['attrs']['form'], + 'kit/form-update must not modify other occurrences.' + ); + $this->assertSame( + $new_form_id, + (string) $listed['occurrences'][1]['attrs']['form'], + 'kit/form-update did not apply the new form ID to the requested occurrence.' + ); + } + + /** + * Test that kit/form-delete removes a specific occurrence and the post + * now contains one fewer Form block. + * + * @since 3.4.0 + */ + public function testDeleteRemovesSingleOccurrence() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/form-delete']->execute_callback(array( + 'post_id' => $this->postID, + 'occurrence_index' => 0, + )); + + $this->assertIsArray($result); + $this->assertSame(0, $result['occurrence_index']); + + // Confirm the post now contains a single Form block. + $listed = $abilities['kit/form-list']->execute_callback([ 'post_id' => $this->postID ]); + $this->assertSame(1, $listed['count']); + } + + /** + * Test that kit/form-update returns a WP_Error when asked to update an + * occurrence that does not exist, rather than silently mutating + * something else. + * + * @since 3.4.0 + */ + public function testUpdateOnMissingOccurrenceReturnsError() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/form-update']->execute_callback(array( + 'post_id' => $this->postID, + 'occurrence_index' => 99, + 'attrs' => array( 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ), + )); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + + /** + * Creates a Post containing two convertkit/form blocks interleaved with + * non-Kit blocks, mirroring the fixture used by BlockPostHelperTest. + * + * @since 3.4.0 + * + * @return int + */ + private function createPostWithFormBlocks(): int + { + return $this->factory->post->create(array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Form Abilities Fixture', + 'post_content' => ' +

Intro paragraph.

+ + + + + +

Middle paragraph.

+ + + + + +

Closing paragraph.

+', + )); + } +} From 235a395c0a106616bcbbb79c1578ccd303d2470a Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 27 May 2026 22:34:59 +0800 Subject: [PATCH 3/3] Coding standards --- tests/Integration/MCPContentFormTest.php | 60 ++++++++++++++---------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/tests/Integration/MCPContentFormTest.php b/tests/Integration/MCPContentFormTest.php index 79bbe1f1b..1bad4ba47 100644 --- a/tests/Integration/MCPContentFormTest.php +++ b/tests/Integration/MCPContentFormTest.php @@ -223,11 +223,13 @@ public function testInsertAppendsFormBlock() $abilities = convertkit_get_abilities(); // Execute the ability. - $result = $abilities['kit/form-insert']->execute_callback(array( - 'post_id' => $this->postID, - 'attrs' => array( 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ), - 'position' => 'append', - )); + $result = $abilities['kit/form-insert']->execute_callback( + array( + 'post_id' => $this->postID, + 'attrs' => array( 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ), + 'position' => 'append', + ) + ); $this->assertIsArray($result); $this->assertSame($this->postID, $result['post_id']); @@ -251,11 +253,13 @@ public function testUpdateModifiesSingleOccurrence() // Update the second Form block (occurrence_index 1) to a different form ID. $new_form_id = (string) ( (int) $_ENV['CONVERTKIT_API_FORM_ID'] + 1 ); - $result = $abilities['kit/form-update']->execute_callback(array( - 'post_id' => $this->postID, - 'occurrence_index' => 1, - 'attrs' => array( 'form' => $new_form_id ), - )); + $result = $abilities['kit/form-update']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 1, + 'attrs' => array( 'form' => $new_form_id ), + ) + ); $this->assertIsArray($result); $this->assertSame(1, $result['occurrence_index']); @@ -286,10 +290,12 @@ public function testDeleteRemovesSingleOccurrence() $abilities = convertkit_get_abilities(); // Execute the ability. - $result = $abilities['kit/form-delete']->execute_callback(array( - 'post_id' => $this->postID, - 'occurrence_index' => 0, - )); + $result = $abilities['kit/form-delete']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 0, + ) + ); $this->assertIsArray($result); $this->assertSame(0, $result['occurrence_index']); @@ -312,11 +318,13 @@ public function testUpdateOnMissingOccurrenceReturnsError() $abilities = convertkit_get_abilities(); // Execute the ability. - $result = $abilities['kit/form-update']->execute_callback(array( - 'post_id' => $this->postID, - 'occurrence_index' => 99, - 'attrs' => array( 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ), - )); + $result = $abilities['kit/form-update']->execute_callback( + array( + 'post_id' => $this->postID, + 'occurrence_index' => 99, + 'attrs' => array( 'form' => $_ENV['CONVERTKIT_API_FORM_ID'] ), + ) + ); // Assert that the result is a WP_Error. $this->assertInstanceOf(\WP_Error::class, $result); @@ -332,11 +340,12 @@ public function testUpdateOnMissingOccurrenceReturnsError() */ private function createPostWithFormBlocks(): int { - return $this->factory->post->create(array( - 'post_type' => 'page', - 'post_status' => 'publish', - 'post_title' => 'Form Abilities Fixture', - 'post_content' => ' + return $this->factory->post->create( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Form Abilities Fixture', + 'post_content' => '

Intro paragraph.

@@ -351,6 +360,7 @@ private function createPostWithFormBlocks(): int

Closing paragraph.

', - )); + ) + ); } }