From 44bf7d812f2b6eacdd4fa27ba30b880b0a9f9b71 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Tue, 8 Sep 2026 10:52:41 +0800 Subject: [PATCH 1/3] MCP: Resources --- includes/mcp/class-convertkit-mcp.php | 36 ++ .../class-convertkit-mcp-resource-account.php | 110 ++++++ ...onvertkit-mcp-resource-forms-reference.php | 106 +++++ .../class-convertkit-mcp-resource-forms.php | 83 ++++ ...-convertkit-mcp-resource-landing-pages.php | 83 ++++ .../class-convertkit-mcp-resource-list.php | 102 +++++ ...class-convertkit-mcp-resource-overview.php | 109 ++++++ ...class-convertkit-mcp-resource-products.php | 83 ++++ ...lass-convertkit-mcp-resource-reference.php | 66 ++++ ...cp-resource-restrict-content-reference.php | 101 +++++ ...ertkit-mcp-resource-settings-reference.php | 99 +++++ ...class-convertkit-mcp-resource-settings.php | 138 +++++++ .../class-convertkit-mcp-resource-tags.php | 83 ++++ ...sourceTest.php => MCPResourceListTest.php} | 5 +- tests/Integration/MCPResourcesTest.php | 363 ++++++++++++++++++ wp-convertkit.php | 12 + 16 files changed, 1577 insertions(+), 2 deletions(-) create mode 100644 includes/mcp/resources/class-convertkit-mcp-resource-account.php create mode 100644 includes/mcp/resources/class-convertkit-mcp-resource-forms-reference.php create mode 100644 includes/mcp/resources/class-convertkit-mcp-resource-forms.php create mode 100644 includes/mcp/resources/class-convertkit-mcp-resource-landing-pages.php create mode 100644 includes/mcp/resources/class-convertkit-mcp-resource-list.php create mode 100644 includes/mcp/resources/class-convertkit-mcp-resource-overview.php create mode 100644 includes/mcp/resources/class-convertkit-mcp-resource-products.php create mode 100644 includes/mcp/resources/class-convertkit-mcp-resource-reference.php create mode 100644 includes/mcp/resources/class-convertkit-mcp-resource-restrict-content-reference.php create mode 100644 includes/mcp/resources/class-convertkit-mcp-resource-settings-reference.php create mode 100644 includes/mcp/resources/class-convertkit-mcp-resource-settings.php create mode 100644 includes/mcp/resources/class-convertkit-mcp-resource-tags.php rename tests/Integration/{MCPResourceTest.php => MCPResourceListTest.php} (97%) create mode 100644 tests/Integration/MCPResourcesTest.php diff --git a/includes/mcp/class-convertkit-mcp.php b/includes/mcp/class-convertkit-mcp.php index 9d0f9ae23..aeaf54c96 100644 --- a/includes/mcp/class-convertkit-mcp.php +++ b/includes/mcp/class-convertkit-mcp.php @@ -92,6 +92,9 @@ public function __construct() { // so they're added here rather than via a per-class register_abilities(). add_filter( 'convertkit_abilities', array( $this, 'register_resource_abilities' ) ); + // Register MCP resources (live-state lists, account, settings and reference docs). + add_filter( 'convertkit_resources', array( $this, 'register_mcp_resources' ) ); + // Register settings get / update abilities for each Plugin settings // These are owned by the Plugin (not by any single feature), // so they're added here rather than via a per-class register_abilities(). @@ -207,6 +210,39 @@ public function register_resource_abilities( $abilities ) { } + /** + * Appends the MCP resources (live-state lists, account, settings and + * reference docs) to the convertkit_resources filter, so they are + * registered with the Abilities API and exposed as MCP Resources. + * + * @since 3.5.0 + * + * @param array $resources Resources to register. + * @return array + */ + public function register_mcp_resources( $resources ) { + + $mcp_resources = array( + new ConvertKit_MCP_Resource_Forms(), + new ConvertKit_MCP_Resource_Tags(), + new ConvertKit_MCP_Resource_Landing_Pages(), + new ConvertKit_MCP_Resource_Products(), + new ConvertKit_MCP_Resource_Account(), + new ConvertKit_MCP_Resource_Settings(), + new ConvertKit_MCP_Resource_Overview(), + new ConvertKit_MCP_Resource_Forms_Reference(), + new ConvertKit_MCP_Resource_Restrict_Content_Reference(), + new ConvertKit_MCP_Resource_Settings_Reference(), + ); + + foreach ( $mcp_resources as $resource ) { + $resources[ $resource->get_name() ] = $resource; + } + + return $resources; + + } + /** * Register the 'kit' ability category. * diff --git a/includes/mcp/resources/class-convertkit-mcp-resource-account.php b/includes/mcp/resources/class-convertkit-mcp-resource-account.php new file mode 100644 index 000000000..d94764bc7 --- /dev/null +++ b/includes/mcp/resources/class-convertkit-mcp-resource-account.php @@ -0,0 +1,110 @@ +get(); + + // get() returns false when nothing is cached; normalise to an empty object. + if ( ! is_array( $data ) ) { + return '{}'; + } + + return (string) wp_json_encode( $data ); + + } + +} diff --git a/includes/mcp/resources/class-convertkit-mcp-resource-forms-reference.php b/includes/mcp/resources/class-convertkit-mcp-resource-forms-reference.php new file mode 100644 index 000000000..c784df1d4 --- /dev/null +++ b/includes/mcp/resources/class-convertkit-mcp-resource-forms-reference.php @@ -0,0 +1,106 @@ +get_ability_class(); + if ( ! class_exists( $ability_class ) ) { + return new WP_Error( + 'convertkit_mcp_resource_ability_missing', + sprintf( + /* translators: %s: Ability class name */ + __( 'The ability class "%s" does not exist.', 'convertkit' ), + $ability_class + ) + ); + } + + $ability = new $ability_class(); + $result = $ability->execute_callback( $input ); + if ( is_wp_error( $result ) ) { + return $result; + } + + return (string) wp_json_encode( $result ); + + } + +} diff --git a/includes/mcp/resources/class-convertkit-mcp-resource-overview.php b/includes/mcp/resources/class-convertkit-mcp-resource-overview.php new file mode 100644 index 000000000..959078bf6 --- /dev/null +++ b/includes/mcp/resources/class-convertkit-mcp-resource-overview.php @@ -0,0 +1,109 @@ +get_content_lines() ); + + } + +} diff --git a/includes/mcp/resources/class-convertkit-mcp-resource-restrict-content-reference.php b/includes/mcp/resources/class-convertkit-mcp-resource-restrict-content-reference.php new file mode 100644 index 000000000..f98508798 --- /dev/null +++ b/includes/mcp/resources/class-convertkit-mcp-resource-restrict-content-reference.php @@ -0,0 +1,101 @@ +-get` tool; change values with `kit/settings--update`.', 'convertkit' ), + '', + '## ' . __( 'general', 'convertkit' ), + '', + __( 'The Kit account connection and site-wide defaults: the default Form displayed for each post type, and how non-inline Forms load. This is where most Form defaults are set.', 'convertkit' ), + '', + '## ' . __( 'broadcasts', 'convertkit' ), + '', + __( 'Importing Kit Broadcasts into WordPress as posts: whether importing is enabled, and the author, category and status applied to imported posts.', 'convertkit' ), + '', + '## ' . __( 'restrict-content', 'convertkit' ), + '', + __( 'Site-wide Restrict Content behaviour: the teaser shown to non-qualifying visitors, the calls to action, and how subscribers authenticate. Per-post gating is set with `kit/post-settings-update` — see `kit://reference/restrict-content`.', 'convertkit' ), + ); + + } + +} diff --git a/includes/mcp/resources/class-convertkit-mcp-resource-settings.php b/includes/mcp/resources/class-convertkit-mcp-resource-settings.php new file mode 100644 index 000000000..a2263b335 --- /dev/null +++ b/includes/mcp/resources/class-convertkit-mcp-resource-settings.php @@ -0,0 +1,138 @@ +execute_callback( $input ); + $result[ $settings->get_name() ] = is_wp_error( $values ) ? array() : $values; + } + + return (string) wp_json_encode( $result ); + + } + +} diff --git a/includes/mcp/resources/class-convertkit-mcp-resource-tags.php b/includes/mcp/resources/class-convertkit-mcp-resource-tags.php new file mode 100644 index 000000000..33ab722ab --- /dev/null +++ b/includes/mcp/resources/class-convertkit-mcp-resource-tags.php @@ -0,0 +1,83 @@ + backing ConvertKit_Resource_* + * class, used to seed / clear the resource cache. + * + * @since 3.5.0 + * + * @var array + */ + private const LIST_RESOURCES = array( + 'kit/forms' => \ConvertKit_Resource_Forms::class, + 'kit/tags' => \ConvertKit_Resource_Tags::class, + 'kit/landing-pages' => \ConvertKit_Resource_Landing_Pages::class, + 'kit/products' => \ConvertKit_Resource_Products::class, + ); + + /** + * Reference (Markdown) resource names. + * + * @since 3.5.0 + * + * @var string[] + */ + private const REFERENCE_RESOURCES = array( + 'kit/overview', + 'kit/reference-forms', + 'kit/reference-restrict-content', + 'kit/reference-settings', + ); + + /** + * Performs actions before each test. + * + * @since 3.5.0 + */ + public function setUp(): void + { + parent::setUp(); + + // Activate Plugin. + activate_plugins('convertkit/wp-convertkit.php'); + + // Store credentials, so the live-state resources can fetch data from + // the Kit API when init() is called. + $this->settings = new \ConvertKit_Settings(); + update_option( + $this->settings::SETTINGS_NAME, + [ + 'access_token' => $_ENV['CONVERTKIT_OAUTH_ACCESS_TOKEN'], + 'refresh_token' => $_ENV['CONVERTKIT_OAUTH_REFRESH_TOKEN'], + ] + ); + } + + /** + * Performs actions after each test. + * + * @since 3.5.0 + */ + public function tearDown(): void + { + // Delete credentials and any cached resources so each test starts clean. + delete_option($this->settings::SETTINGS_NAME); + + foreach ( self::LIST_RESOURCES as $resource_class ) { + $resource = new $resource_class(); + delete_option($resource->settings_name); + delete_option($resource->settings_name . '_last_queried'); + } + + $account = new \ConvertKit_Resource_Account(); + delete_option($account->settings_name); + delete_option($account->settings_name . '_last_queried'); + + // Restore the current user. + wp_set_current_user(0); + + // Deactivate Plugin. + deactivate_plugins('convertkit/wp-convertkit.php'); + + parent::tearDown(); + } + + /** + * Test that all resources are registered with the `convertkit_resources` + * filter, so they are picked up by the Abilities API and exposed by the + * MCP server. + * + * @since 3.5.0 + */ + public function testResourcesRegistered() + { + $resources = convertkit_get_resources(); + + $expected = array( + 'kit/forms' => \ConvertKit_MCP_Resource_Forms::class, + 'kit/tags' => \ConvertKit_MCP_Resource_Tags::class, + 'kit/landing-pages' => \ConvertKit_MCP_Resource_Landing_Pages::class, + 'kit/products' => \ConvertKit_MCP_Resource_Products::class, + 'kit/account' => \ConvertKit_MCP_Resource_Account::class, + 'kit/settings' => \ConvertKit_MCP_Resource_Settings::class, + 'kit/overview' => \ConvertKit_MCP_Resource_Overview::class, + 'kit/reference-forms' => \ConvertKit_MCP_Resource_Forms_Reference::class, + 'kit/reference-restrict-content' => \ConvertKit_MCP_Resource_Restrict_Content_Reference::class, + 'kit/reference-settings' => \ConvertKit_MCP_Resource_Settings_Reference::class, + ); + + foreach ( $expected as $name => $class ) { + $this->assertArrayHasKey($name, $resources); + $this->assertInstanceOf($class, $resources[ $name ]); + } + } + + /** + * Test that every registered resource advertises a kit:// URI and a + * supported MIME type. + * + * @since 3.5.0 + */ + public function testResourcesHaveUriAndMimeType() + { + $resources = convertkit_get_resources(); + + foreach ( $resources as $name => $resource ) { + $this->assertStringStartsWith('kit://', $resource->get_uri()); + $this->assertContains( + $resource->get_mime_type(), + [ 'application/json', 'text/markdown' ] + ); + } + } + + /** + * Test that live-state and reference resources deny access to a user + * without the edit_posts capability. + * + * @since 3.5.0 + */ + public function testEditPostsResourcesDenyWithoutEditPostsCapability() + { + // Become a Subscriber (no edit_posts capability). + $subscriber_id = static::factory()->user->create([ 'role' => 'subscriber' ]); + wp_set_current_user($subscriber_id); + + $resources = convertkit_get_resources(); + + $names = array_merge( + array_keys( self::LIST_RESOURCES ), + self::REFERENCE_RESOURCES + ); + + foreach ( $names as $name ) { + $this->assertInstanceOf(\WP_Error::class, $resources[ $name ]->permission_callback([])); + } + } + + /** + * Test that an Editor can read the live-state list and reference resources. + * + * @since 3.5.0 + */ + public function testEditPostsResourcesPermitWithEditPostsCapability() + { + // Become an Editor (has edit_posts capability). + $editor_id = static::factory()->user->create([ 'role' => 'editor' ]); + wp_set_current_user($editor_id); + + $resources = convertkit_get_resources(); + + $names = array_merge( + array_keys( self::LIST_RESOURCES ), + self::REFERENCE_RESOURCES + ); + + foreach ( $names as $name ) { + $this->assertTrue($resources[ $name ]->permission_callback([])); + } + } + + /** + * Test that the account and settings resources require the manage_options + * capability: denied for an Editor, permitted for an Administrator. + * + * @since 3.5.0 + */ + public function testManageOptionsResourcesEnforceCapability() + { + $resources = convertkit_get_resources(); + + // Editor is denied. + $editor_id = static::factory()->user->create([ 'role' => 'editor' ]); + wp_set_current_user($editor_id); + $this->assertInstanceOf(\WP_Error::class, $resources['kit/account']->permission_callback([])); + $this->assertInstanceOf(\WP_Error::class, $resources['kit/settings']->permission_callback([])); + + // Administrator is permitted. + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + $this->assertTrue($resources['kit/account']->permission_callback([])); + $this->assertTrue($resources['kit/settings']->permission_callback([])); + } + + /** + * Test that list resources return a valid JSON { count, items } string + * with count 0 when nothing is cached. + * + * @since 3.5.0 + */ + public function testListResourcesReturnEmptyJsonWhenNoCache() + { + $resources = convertkit_get_resources(); + + foreach ( self::LIST_RESOURCES as $name => $resource_class ) { + // Ensure the cache is empty for this resource. + delete_option( ( new $resource_class() )->settings_name ); + + $json = $resources[ $name ]->execute_callback([]); + $this->assertIsString($json); + + $data = json_decode($json, true); + $this->assertIsArray($data); + $this->assertArrayHasKey('count', $data); + $this->assertArrayHasKey('items', $data); + $this->assertSame(0, $data['count']); + $this->assertSame([], $data['items']); + } + } + + /** + * Test that each list resource returns exactly its backing list tool's + * output, JSON encoded. This proves the resource is single-sourced from + * the tool, independent of how many items the connected account holds. + * + * @since 3.5.0 + */ + public function testListResourcesMatchBackingTool() + { + $resources = convertkit_get_resources(); + + // Each list resource wraps the equivalent resource-list ability. + $abilities = array( + 'kit/forms' => \ConvertKit_MCP_Ability_Resource_Forms::class, + 'kit/tags' => \ConvertKit_MCP_Ability_Resource_Tags::class, + 'kit/landing-pages' => \ConvertKit_MCP_Ability_Resource_Landing_Pages::class, + 'kit/products' => \ConvertKit_MCP_Ability_Resource_Products::class, + ); + + foreach ( self::LIST_RESOURCES as $name => $resource_class ) { + // Populate the resource cache from the Kit API. + ( new $resource_class() )->init(); + + // The backing tool's output shape. + $expected = ( new $abilities[ $name ]() )->execute_callback([]); + $this->assertIsArray($expected); + $this->assertArrayHasKey('count', $expected); + $this->assertArrayHasKey('items', $expected); + + // The resource must return exactly that, JSON encoded. + $this->assertSame( (string) wp_json_encode($expected), $resources[ $name ]->execute_callback([])); + } + } + + /** + * Test that the account resource returns an empty JSON object when no + * account is cached, and exactly the cached account data as JSON once + * populated (whatever shape the account API returns). + * + * @since 3.5.0 + */ + public function testAccountResource() + { + $resources = convertkit_get_resources(); + $account = new \ConvertKit_Resource_Account(); + + // Empty cache returns an empty object. + delete_option($account->settings_name); + $this->assertSame('{}', $resources['kit/account']->execute_callback([])); + + // Populated cache returns exactly the cached account data, JSON encoded. + $account->init(); + $stored = $account->get(); + $expected = is_array($stored) ? (string) wp_json_encode($stored) : '{}'; + $this->assertSame($expected, $resources['kit/account']->execute_callback([])); + } + + /** + * Test that the settings resource returns a JSON object keyed by settings + * group. + * + * @since 3.5.0 + */ + public function testSettingsResource() + { + $resources = convertkit_get_resources(); + + $data = json_decode($resources['kit/settings']->execute_callback([]), true); + $this->assertIsArray($data); + $this->assertArrayHasKey('general', $data); + $this->assertArrayHasKey('broadcasts', $data); + $this->assertArrayHasKey('restrict-content', $data); + } + + /** + * Test that reference resources return non-empty Markdown beginning with a + * heading. + * + * @since 3.5.0 + */ + public function testReferenceResourcesReturnMarkdown() + { + $resources = convertkit_get_resources(); + + foreach ( self::REFERENCE_RESOURCES as $name ) { + $this->assertSame('text/markdown', $resources[ $name ]->get_mime_type()); + + $content = $resources[ $name ]->execute_callback([]); + $this->assertIsString($content); + $this->assertNotEmpty($content); + $this->assertStringStartsWith('#', $content); + } + } +} diff --git a/wp-convertkit.php b/wp-convertkit.php index ac23fcc03..067821936 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -110,6 +110,18 @@ require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/class-convertkit-mcp-ability.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/class-convertkit-mcp-resource.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/class-convertkit-mcp-prompt.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/resources/class-convertkit-mcp-resource-list.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/resources/class-convertkit-mcp-resource-reference.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/resources/class-convertkit-mcp-resource-forms.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/resources/class-convertkit-mcp-resource-tags.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/resources/class-convertkit-mcp-resource-landing-pages.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/resources/class-convertkit-mcp-resource-products.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/resources/class-convertkit-mcp-resource-account.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/resources/class-convertkit-mcp-resource-settings.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/resources/class-convertkit-mcp-resource-overview.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/resources/class-convertkit-mcp-resource-forms-reference.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/resources/class-convertkit-mcp-resource-restrict-content-reference.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/resources/class-convertkit-mcp-resource-settings-reference.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/class-convertkit-mcp.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php'; From 965a80d785d2c9fbd304ff7d1cb414db5cccb91c Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Tue, 8 Sep 2026 11:11:05 +0800 Subject: [PATCH 2/3] MCP: Prompts --- includes/mcp/class-convertkit-mcp.php | 31 +++ .../class-convertkit-mcp-prompt-add-form.php | 122 +++++++++++ .../class-convertkit-mcp-prompt-audit.php | 87 ++++++++ ...mcp-prompt-configure-broadcasts-import.php | 83 ++++++++ ...convertkit-mcp-prompt-restrict-content.php | 118 +++++++++++ .../class-convertkit-mcp-prompt-setup.php | 83 ++++++++ tests/Integration/MCPPromptsTest.php | 193 ++++++++++++++++++ wp-convertkit.php | 5 + 8 files changed, 722 insertions(+) create mode 100644 includes/mcp/prompts/class-convertkit-mcp-prompt-add-form.php create mode 100644 includes/mcp/prompts/class-convertkit-mcp-prompt-audit.php create mode 100644 includes/mcp/prompts/class-convertkit-mcp-prompt-configure-broadcasts-import.php create mode 100644 includes/mcp/prompts/class-convertkit-mcp-prompt-restrict-content.php create mode 100644 includes/mcp/prompts/class-convertkit-mcp-prompt-setup.php create mode 100644 tests/Integration/MCPPromptsTest.php diff --git a/includes/mcp/class-convertkit-mcp.php b/includes/mcp/class-convertkit-mcp.php index aeaf54c96..6d206d36c 100644 --- a/includes/mcp/class-convertkit-mcp.php +++ b/includes/mcp/class-convertkit-mcp.php @@ -95,6 +95,9 @@ public function __construct() { // Register MCP resources (live-state lists, account, settings and reference docs). add_filter( 'convertkit_resources', array( $this, 'register_mcp_resources' ) ); + // Register MCP prompts (guided workflows). + add_filter( 'convertkit_prompts', array( $this, 'register_mcp_prompts' ) ); + // Register settings get / update abilities for each Plugin settings // These are owned by the Plugin (not by any single feature), // so they're added here rather than via a per-class register_abilities(). @@ -243,6 +246,34 @@ public function register_mcp_resources( $resources ) { } + /** + * Appends the MCP prompts (guided workflows) to the convertkit_prompts + * filter, so they are registered with the Abilities API and exposed as + * MCP Prompts. + * + * @since 3.5.0 + * + * @param array $prompts Prompts to register. + * @return array + */ + public function register_mcp_prompts( $prompts ) { + + $mcp_prompts = array( + new ConvertKit_MCP_Prompt_Setup(), + new ConvertKit_MCP_Prompt_Add_Form(), + new ConvertKit_MCP_Prompt_Restrict_Content(), + new ConvertKit_MCP_Prompt_Configure_Broadcasts_Import(), + new ConvertKit_MCP_Prompt_Audit(), + ); + + foreach ( $mcp_prompts as $prompt ) { + $prompts[ $prompt->get_name() ] = $prompt; + } + + return $prompts; + + } + /** * Register the 'kit' ability category. * diff --git a/includes/mcp/prompts/class-convertkit-mcp-prompt-add-form.php b/includes/mcp/prompts/class-convertkit-mcp-prompt-add-form.php new file mode 100644 index 000000000..0ac0e37ee --- /dev/null +++ b/includes/mcp/prompts/class-convertkit-mcp-prompt-add-form.php @@ -0,0 +1,122 @@ + array( + 'description' => __( 'The Kit Form to add, by name or numeric ID.', 'convertkit' ), + ), + 'scope' => array( + 'description' => __( 'Where to add it: default, post, category or inline.', 'convertkit' ), + ), + ); + + } + + /** + * Returns the prompt text. + * + * @since 3.5.0 + * + * @param array $input Prompt arguments. + * @return array|WP_Error + */ + public function execute_callback( $input ) { + + $form = isset( $input['form'] ) ? trim( (string) $input['form'] ) : ''; + $scope = isset( $input['scope'] ) ? trim( (string) $input['scope'] ) : ''; + + $provided = array(); + if ( '' !== $form ) { + /* translators: %s: Form name or ID. */ + $provided[] = sprintf( __( '- Requested Form: %s', 'convertkit' ), $form ); + } + if ( '' !== $scope ) { + /* translators: %s: scope. */ + $provided[] = sprintf( __( '- Requested scope: %s', 'convertkit' ), $scope ); + } + + return $this->render( + array( + '# ' . __( 'Add a Kit Form', 'convertkit' ), + __( 'Goal: show a Kit Form as a site default, on a single post or page, on a category, or inline in content.', 'convertkit' ), + count( $provided ) ? implode( "\n", $provided ) : '', + '## ' . __( 'Preflight', 'convertkit' ), + __( '- Read `kit://forms` and map the requested Form to its numeric ID. If nothing matches, list the available Forms and ask the user.', 'convertkit' ), + __( '- See `kit://reference/forms` for how the Form shown on a post is resolved (post overrides category overrides the default).', 'convertkit' ), + '## ' . __( 'Choose the scope', 'convertkit' ), + __( '- **Default for a post type** — `kit/settings-general-update`.', 'convertkit' ), + __( '- **A single Page or Post** — `kit/post-settings-update`, setting `form` (needs `post_id`).', 'convertkit' ), + __( '- **A category** — `kit/category-settings-update`.', 'convertkit' ), + __( '- **Inline at a chosen position in the content** — `kit/form-insert`.', 'convertkit' ), + '## ' . __( 'Steps', 'convertkit' ), + __( '1. Confirm the Form and the scope with the user.', 'convertkit' ), + __( '2. Apply it with the matching tool above. Confirm before writing.', 'convertkit' ), + __( '3. Verify with the matching read tool (e.g. `kit/post-settings-get`).', 'convertkit' ), + ) + ); + + } + +} diff --git a/includes/mcp/prompts/class-convertkit-mcp-prompt-audit.php b/includes/mcp/prompts/class-convertkit-mcp-prompt-audit.php new file mode 100644 index 000000000..cab224887 --- /dev/null +++ b/includes/mcp/prompts/class-convertkit-mcp-prompt-audit.php @@ -0,0 +1,87 @@ +render( + array( + '# ' . __( 'Audit Kit configuration', 'convertkit' ), + __( 'Goal: review the current Kit setup and report gaps. This is read only — do not change any settings; if the user wants a fix, point them to the relevant prompt (setup, add-form, restrict-content).', 'convertkit' ), + '## ' . __( 'Gather', 'convertkit' ), + __( '- `kit://account` — is the site connected?', 'convertkit' ), + __( '- `kit://settings` — default Forms, broadcasts and restrict-content configuration.', 'convertkit' ), + __( '- `kit://forms`, `kit://tags`, `kit://products` — what exists on the account.', 'convertkit' ), + __( '- For a sample of published Pages and Posts, `kit/post-settings-get` to see their Form / Landing Page / Tag / Restrict Content settings.', 'convertkit' ), + '## ' . __( 'Report', 'convertkit' ), + __( '- Whether the account is connected and which account it is.', 'convertkit' ), + __( '- Whether a default Form is set per post type, and which.', 'convertkit' ), + __( '- Content that references a Form, Tag or Product ID that no longer exists on the account.', 'convertkit' ), + __( '- Where Restrict Content is in use, and any content that looks like it should be gated but is not.', 'convertkit' ), + __( 'Summarise findings as a short list, most important first, with the suggested fix for each.', 'convertkit' ), + ) + ); + + } + +} diff --git a/includes/mcp/prompts/class-convertkit-mcp-prompt-configure-broadcasts-import.php b/includes/mcp/prompts/class-convertkit-mcp-prompt-configure-broadcasts-import.php new file mode 100644 index 000000000..ed1410b31 --- /dev/null +++ b/includes/mcp/prompts/class-convertkit-mcp-prompt-configure-broadcasts-import.php @@ -0,0 +1,83 @@ +render( + array( + '# ' . __( 'Import Kit Broadcasts as posts', 'convertkit' ), + __( 'Goal: turn Kit Broadcasts (emails) into WordPress posts automatically, and control how those posts are assigned.', 'convertkit' ), + '## ' . __( 'Preflight', 'convertkit' ), + __( '- Read `kit://account` to confirm the site is connected, and `kit://settings` for the current broadcasts settings.', 'convertkit' ), + '## ' . __( 'Steps', 'convertkit' ), + __( '1. Ask the user whether to enable importing, and which author, category and post status imported posts should use (and whether to import the email thumbnail as the featured image).', 'convertkit' ), + __( '2. Apply with `kit/settings-broadcasts-update`. Confirm before writing.', 'convertkit' ), + __( '3. Verify with `kit/settings-broadcasts-get`.', 'convertkit' ), + __( 'Note: once enabled, importing runs automatically on a schedule — there is no manual "import now" step to call here.', 'convertkit' ), + ) + ); + + } + +} diff --git a/includes/mcp/prompts/class-convertkit-mcp-prompt-restrict-content.php b/includes/mcp/prompts/class-convertkit-mcp-prompt-restrict-content.php new file mode 100644 index 000000000..243656b9e --- /dev/null +++ b/includes/mcp/prompts/class-convertkit-mcp-prompt-restrict-content.php @@ -0,0 +1,118 @@ + array( + 'description' => __( 'The ID of the Page or Post to restrict.', 'convertkit' ), + ), + 'gate' => array( + 'description' => __( 'What to gate by: a Product, Tag or Form (name or ID).', 'convertkit' ), + ), + ); + + } + + /** + * Returns the prompt text. + * + * @since 3.5.0 + * + * @param array $input Prompt arguments. + * @return array|WP_Error + */ + public function execute_callback( $input ) { + + $post_id = isset( $input['post_id'] ) ? trim( (string) $input['post_id'] ) : ''; + $gate = isset( $input['gate'] ) ? trim( (string) $input['gate'] ) : ''; + + $provided = array(); + if ( '' !== $post_id ) { + /* translators: %s: Post ID. */ + $provided[] = sprintf( __( '- Post to restrict: %s', 'convertkit' ), $post_id ); + } + if ( '' !== $gate ) { + /* translators: %s: gate description. */ + $provided[] = sprintf( __( '- Gate by: %s', 'convertkit' ), $gate ); + } + + return $this->render( + array( + '# ' . __( 'Restrict content to subscribers', 'convertkit' ), + __( 'Goal: gate a single post or page so only qualifying visitors see the full content. Read `kit://reference/restrict-content` first for how gating works and what the visitor sees.', 'convertkit' ), + count( $provided ) ? implode( "\n", $provided ) : '', + '## ' . __( 'Preflight', 'convertkit' ), + __( '- Decide what to gate by: a **Product** (paywall) from `kit://products`, a **Tag** from `kit://tags`, or a **Form** from `kit://forms`. Map the name to its numeric ID.', 'convertkit' ), + __( '- Confirm the target post ID with the user if not given.', 'convertkit' ), + '## ' . __( 'Steps', 'convertkit' ), + __( '1. Confirm the post and the gate with the user.', 'convertkit' ), + __( '2. Set it with `kit/post-settings-update`, supplying `restrict_content` (needs `post_id`). Confirm before writing.', 'convertkit' ), + __( '3. Verify with `kit/post-settings-get`.', 'convertkit' ), + __( 'Site wide behaviour (teaser, wording, login) lives in the restrict-content settings group — read `kit://settings` if the user wants to review it.', 'convertkit' ), + ) + ); + + } + +} diff --git a/includes/mcp/prompts/class-convertkit-mcp-prompt-setup.php b/includes/mcp/prompts/class-convertkit-mcp-prompt-setup.php new file mode 100644 index 000000000..543a2f80e --- /dev/null +++ b/includes/mcp/prompts/class-convertkit-mcp-prompt-setup.php @@ -0,0 +1,83 @@ +render( + array( + '# ' . __( 'Set up Kit', 'convertkit' ), + __( 'Goal: confirm the WordPress site is connected to a Kit account, then set sensible defaults. Read `kit://overview` first for how the pieces fit together.', 'convertkit' ), + '## ' . __( 'Preflight', 'convertkit' ), + __( '- Read `kit://account`. If it is empty, the site is not connected yet: tell the user to connect it under Settings > Kit in WordPress (this uses OAuth sign in and cannot be done over MCP), then stop until it is connected.', 'convertkit' ), + __( '- Read `kit://settings` for the current configuration and `kit://forms` for the available Forms.', 'convertkit' ), + '## ' . __( 'Steps', 'convertkit' ), + __( '1. Ask the user which Form should show by default, and on which post types. Use `kit://forms` to map the Form name to its numeric ID.', 'convertkit' ), + __( '2. Set the default Form(s) with `kit/settings-general-update`. Confirm the change with the user before applying it.', 'convertkit' ), + __( '3. Verify with `kit/settings-general-get`.', 'convertkit' ), + ) + ); + + } + +} diff --git a/tests/Integration/MCPPromptsTest.php b/tests/Integration/MCPPromptsTest.php new file mode 100644 index 000000000..811abb888 --- /dev/null +++ b/tests/Integration/MCPPromptsTest.php @@ -0,0 +1,193 @@ + class. + * + * @since 3.5.0 + * + * @var array + */ + private const PROMPTS = array( + 'kit/setup' => \ConvertKit_MCP_Prompt_Setup::class, + 'kit/add-form' => \ConvertKit_MCP_Prompt_Add_Form::class, + 'kit/restrict-content' => \ConvertKit_MCP_Prompt_Restrict_Content::class, + 'kit/configure-broadcasts-import' => \ConvertKit_MCP_Prompt_Configure_Broadcasts_Import::class, + 'kit/audit' => \ConvertKit_MCP_Prompt_Audit::class, + ); + + /** + * Performs actions before each test. + * + * @since 3.5.0 + */ + public function setUp(): void + { + parent::setUp(); + + // Activate Plugin. + activate_plugins('convertkit/wp-convertkit.php'); + } + + /** + * Performs actions after each test. + * + * @since 3.5.0 + */ + public function tearDown(): void + { + // Restore the current user. + wp_set_current_user(0); + + // Deactivate Plugin. + deactivate_plugins('convertkit/wp-convertkit.php'); + + parent::tearDown(); + } + + /** + * Test that all prompts are registered with the `convertkit_prompts` + * filter, so they are picked up by the Abilities API and exposed by the + * MCP server. + * + * @since 3.5.0 + */ + public function testPromptsRegistered() + { + $prompts = convertkit_get_prompts(); + + foreach ( self::PROMPTS as $name => $class ) { + $this->assertArrayHasKey($name, $prompts); + $this->assertInstanceOf($class, $prompts[ $name ]); + } + } + + /** + * Test that each prompt advertises itself to the MCP Adapter as a prompt. + * + * @since 3.5.0 + */ + public function testPromptsAreMarkedAsPrompts() + { + $prompts = convertkit_get_prompts(); + + foreach ( self::PROMPTS as $name => $class ) { + $args = $prompts[ $name ]->get_ability_args(); + $this->assertSame('prompt', $args['meta']['mcp']['type']); + $this->assertTrue($args['meta']['mcp']['public']); + } + } + + /** + * Test that each prompt returns non-empty text beginning with a heading. + * + * @since 3.5.0 + */ + public function testPromptsReturnText() + { + $prompts = convertkit_get_prompts(); + + foreach ( self::PROMPTS as $name => $class ) { + $result = $prompts[ $name ]->execute_callback([]); + $this->assertIsArray($result); + $this->assertArrayHasKey('text', $result); + $this->assertNotEmpty($result['text']); + $this->assertStringStartsWith('#', $result['text']); + } + } + + /** + * Test that the prompt permission callback requires the manage_options + * capability: denied for an Editor, permitted for an Administrator. + * + * @since 3.5.0 + */ + public function testPromptsRequireManageOptions() + { + $prompts = convertkit_get_prompts(); + + // Editor is denied. + $editor_id = static::factory()->user->create([ 'role' => 'editor' ]); + wp_set_current_user($editor_id); + foreach ( array_keys( self::PROMPTS ) as $name ) { + $this->assertInstanceOf(\WP_Error::class, $prompts[ $name ]->permission_callback([])); + } + + // Administrator is permitted. + $admin_id = static::factory()->user->create([ 'role' => 'administrator' ]); + wp_set_current_user($admin_id); + foreach ( array_keys( self::PROMPTS ) as $name ) { + $this->assertTrue($prompts[ $name ]->permission_callback([])); + } + } + + /** + * Test that prompts declaring arguments expose them in their input schema. + * + * @since 3.5.0 + */ + public function testPromptArgumentsInInputSchema() + { + $prompts = convertkit_get_prompts(); + + $addFormSchema = $prompts['kit/add-form']->get_input_schema(); + $this->assertArrayHasKey('form', $addFormSchema['properties']); + $this->assertArrayHasKey('scope', $addFormSchema['properties']); + + $restrictSchema = $prompts['kit/restrict-content']->get_input_schema(); + $this->assertArrayHasKey('post_id', $restrictSchema['properties']); + $this->assertArrayHasKey('gate', $restrictSchema['properties']); + } + + /** + * Test that provided argument values are woven into the prompt text. + * + * @since 3.5.0 + */ + public function testProvidedArgumentsAppearInPromptText() + { + $prompts = convertkit_get_prompts(); + + $addForm = $prompts['kit/add-form']->execute_callback( + [ + 'form' => 'Weekly Newsletter', + 'scope' => 'category', + ] + ); + $this->assertStringContainsString('Weekly Newsletter', $addForm['text']); + $this->assertStringContainsString('Requested scope: category', $addForm['text']); + + $restrict = $prompts['kit/restrict-content']->execute_callback( + [ + 'post_id' => '12345', + 'gate' => 'Premium Membership', + ] + ); + $this->assertStringContainsString('12345', $restrict['text']); + $this->assertStringContainsString('Premium Membership', $restrict['text']); + } +} diff --git a/wp-convertkit.php b/wp-convertkit.php index 067821936..0538c00c2 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -122,6 +122,11 @@ require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/resources/class-convertkit-mcp-resource-forms-reference.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/resources/class-convertkit-mcp-resource-restrict-content-reference.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/resources/class-convertkit-mcp-resource-settings-reference.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/prompts/class-convertkit-mcp-prompt-setup.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/prompts/class-convertkit-mcp-prompt-add-form.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/prompts/class-convertkit-mcp-prompt-restrict-content.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/prompts/class-convertkit-mcp-prompt-configure-broadcasts-import.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/prompts/class-convertkit-mcp-prompt-audit.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/class-convertkit-mcp.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/content/class-convertkit-mcp-ability-content.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/abilities/content/class-convertkit-mcp-ability-content-list.php'; From 7189bd70d8d1b5ff5fb41148d9ea4a9fe3fb8675 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 9 Sep 2026 22:41:17 +0800 Subject: [PATCH 3/3] PHPStan compat. --- includes/class-convertkit-resource-account.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/class-convertkit-resource-account.php b/includes/class-convertkit-resource-account.php index c6de9568e..049af8065 100644 --- a/includes/class-convertkit-resource-account.php +++ b/includes/class-convertkit-resource-account.php @@ -118,7 +118,7 @@ public function refresh() { * * @since 3.4.0 * - * @return array + * @return bool|array */ public function get() {