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 d835b9e06..bd05784e2 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';