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() { diff --git a/includes/mcp/class-convertkit-mcp.php b/includes/mcp/class-convertkit-mcp.php index 9d0f9ae23..6d206d36c 100644 --- a/includes/mcp/class-convertkit-mcp.php +++ b/includes/mcp/class-convertkit-mcp.php @@ -92,6 +92,12 @@ 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 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(). @@ -207,6 +213,67 @@ 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; + + } + + /** + * 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/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 @@ + 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/tests/Integration/MCPResourceTest.php b/tests/Integration/MCPResourceListTest.php similarity index 97% rename from tests/Integration/MCPResourceTest.php rename to tests/Integration/MCPResourceListTest.php index e90cf8fd0..9df3f79f7 100644 --- a/tests/Integration/MCPResourceTest.php +++ b/tests/Integration/MCPResourceListTest.php @@ -5,7 +5,8 @@ use lucatume\WPBrowser\TestCase\WPTestCase; /** - * Tests for the Kit MCP resource-list abilities: + * Tests for the Kit MCP resource-list abilities (tools), exposed as the + * kit/*-list tools. The MCP Resource primitives are covered by MCPResourcesTest. * * - kit/forms-list (ConvertKit_MCP_Ability_Resource_Forms) * - kit/tags-list (ConvertKit_MCP_Ability_Resource_Tags) @@ -14,7 +15,7 @@ * * @since 3.4.0 */ -class MCPResourceTest extends WPTestCase +class MCPResourceListTest extends WPTestCase { /** * The testing implementation. diff --git a/tests/Integration/MCPResourcesTest.php b/tests/Integration/MCPResourcesTest.php new file mode 100644 index 000000000..e26261813 --- /dev/null +++ b/tests/Integration/MCPResourcesTest.php @@ -0,0 +1,363 @@ + 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 8fde5c191..bd05784e2 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -110,6 +110,23 @@ 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/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';