From 44bf7d812f2b6eacdd4fa27ba30b880b0a9f9b71 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Tue, 8 Sep 2026 10:52:41 +0800 Subject: [PATCH] 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';