diff --git a/.distignore b/.distignore index edab61b68..b583cbafc 100644 --- a/.distignore +++ b/.distignore @@ -7,8 +7,6 @@ /node_modules /resources/frontend/css/*.map /tests -/vendor/autoload.php -/vendor/composer /vendor/convertkit/convertkit-wordpress-libraries/.git /vendor/convertkit/convertkit-wordpress-libraries/.github /vendor/convertkit/convertkit-wordpress-libraries/tests diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 2da178f5d..51e91270c 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -144,9 +144,22 @@ jobs: tools: cs2pr # Installs wp-browser, Codeception, PHP CodeSniffer and anything else needed to run tests. + # --ignore-platform-req=php is required as wordpress/mcp-adapter otherwise won't install + # on PHP 7.2 and 7.3, resulting in composer errors. - name: Run Composer working-directory: ${{ env.PLUGIN_DIR }} - run: composer update + run: | + if [[ "${{ matrix.php-versions }}" == "7.2" || "${{ matrix.php-versions }}" == "7.3" ]]; then + composer update --ignore-platform-req=php + else + composer update + fi + + # Remove the wordpress/mcp-adapter package. We don't need it for coding standards, and composer + # commands will fail if it's installed and using PHP 7.2 or 7.3. + - name: Remove wordpress/mcp-adapter + working-directory: ${{ env.PLUGIN_DIR }} + run: composer remove wordpress/mcp-adapter --no-update - name: Build PHP Autoloader working-directory: ${{ env.PLUGIN_DIR }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0f6bfecbf..a75769b66 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -43,11 +43,13 @@ jobs: "resources/frontend/css/frontend.css" "resources/frontend/js/dist/frontend.min.asset.php" "resources/frontend/js/dist/frontend.min.js" + "vendor/autoload.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-log.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-resource-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-review-request.php" + "vendor/wordpress/mcp-adapter/mcp-adapter.php" ) for file in "${files[@]}"; do diff --git a/.github/workflows/tests-backward-compat.yml b/.github/workflows/tests-backward-compat.yml index 97e7a5170..97e7d7b2a 100644 --- a/.github/workflows/tests-backward-compat.yml +++ b/.github/workflows/tests-backward-compat.yml @@ -311,11 +311,13 @@ jobs: "resources/frontend/css/frontend.css" "resources/frontend/js/dist/frontend.min.asset.php" "resources/frontend/js/dist/frontend.min.js" + "vendor/autoload.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-log.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-resource-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-review-request.php" + "vendor/wordpress/mcp-adapter/mcp-adapter.php" ) for file in "${files[@]}"; do diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b72cd3642..a7fb90e13 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -332,6 +332,14 @@ jobs: working-directory: ${{ env.PLUGIN_DIR }} run: composer update + # Install the WordPress MCP Adapter, which requires PHP 7.4+. We don't + # include it in composer.json because the Plugin must support PHP 7.1+, + # and Composer cannot conditionally require packages by PHP version. + # All test matrix entries run on PHP 7.4+, so this is always safe here. + - name: Install MCP Adapter + working-directory: ${{ env.PLUGIN_DIR }} + run: composer require wordpress/mcp-adapter:^0.5.0 + # Build the frontend CSS and JS assets - name: Run npm working-directory: ${{ env.PLUGIN_DIR }} @@ -350,11 +358,13 @@ jobs: "resources/frontend/css/frontend.css" "resources/frontend/js/dist/frontend.min.asset.php" "resources/frontend/js/dist/frontend.min.js" + "vendor/autoload.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-log.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-resource-v4.php" "vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-review-request.php" + "vendor/wordpress/mcp-adapter/mcp-adapter.php" ) for file in "${files[@]}"; do diff --git a/.scripts/create-plugin-zip.sh b/.scripts/create-plugin-zip.sh index c7d170886..4de10c744 100644 --- a/.scripts/create-plugin-zip.sh +++ b/.scripts/create-plugin-zip.sh @@ -14,11 +14,9 @@ zip -r convertkit.zip . \ -x ".wordpress-org/*" \ -x "log/*" \ -x "tests/*" \ --x "vendor/composer/*" \ -x "vendor/convertkit/convertkit-wordpress-libraries/.github" \ -x "vendor/convertkit/convertkit-wordpress-libraries/tests/*" \ -x "vendor/convertkit/convertkit-wordpress-libraries/composer.json" \ --x "vendor/autoload.php" \ -x "*.distignore" \ -x "*.env.*" \ -x ".gitignore" \ diff --git a/composer.json b/composer.json index 37be0f728..3ff418d75 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,8 @@ "type": "project", "license": "GPLv3", "require": { - "convertkit/convertkit-wordpress-libraries": "2.1.6" + "convertkit/convertkit-wordpress-libraries": "2.1.6", + "wordpress/mcp-adapter": "^0.5.0" }, "require-dev": { "php-webdriver/webdriver": "^1.0", diff --git a/includes/class-wp-convertkit.php b/includes/class-wp-convertkit.php index 9e49cc858..63cfa5263 100644 --- a/includes/class-wp-convertkit.php +++ b/includes/class-wp-convertkit.php @@ -201,12 +201,13 @@ private function initialize_global() { $this->classes['broadcasts_importer'] = new ConvertKit_Broadcasts_Importer(); $this->classes['elementor'] = new ConvertKit_Elementor(); $this->classes['gutenberg'] = new ConvertKit_Gutenberg(); - $this->classes['media_library'] = new ConvertKit_Media_Library(); - $this->classes['output_restrict_content'] = new ConvertKit_Output_Restrict_Content(); - $this->classes['review_request'] = new ConvertKit_Review_Request( 'Kit', 'convertkit', CONVERTKIT_PLUGIN_PATH ); - $this->classes['preview_output'] = new ConvertKit_Preview_Output(); - $this->classes['setup'] = new ConvertKit_Setup(); - $this->classes['shortcodes'] = new ConvertKit_Shortcodes(); + $this->classes['mcp'] = new ConvertKit_MCP(); + $this->classes['media_library'] = new ConvertKit_Media_Library(); + $this->classes['output_restrict_content'] = new ConvertKit_Output_Restrict_Content(); + $this->classes['review_request'] = new ConvertKit_Review_Request( 'Kit', 'convertkit', CONVERTKIT_PLUGIN_PATH ); + $this->classes['preview_output'] = new ConvertKit_Preview_Output(); + $this->classes['setup'] = new ConvertKit_Setup(); + $this->classes['shortcodes'] = new ConvertKit_Shortcodes(); /** * Initialize integration classes for the frontend web site. diff --git a/includes/functions.php b/includes/functions.php index 42037ad75..590c340f1 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -307,6 +307,30 @@ function convertkit_get_form_importers() { } +/** + * Helper method to get registered abilities. + * + * @since 3.4.0 + * + * @return array Abilities. + */ +function convertkit_get_abilities() { + + $abilities = array(); + + /** + * Registers abilities for the Kit Plugin. + * + * @since 3.4.0 + * + * @param array $abilities Abilities. + */ + $abilities = apply_filters( 'convertkit_abilities', $abilities ); + + return $abilities; + +} + /** * Helper method to return the Plugin Settings Link * diff --git a/includes/mcp/class-convertkit-mcp-ability.php b/includes/mcp/class-convertkit-mcp-ability.php new file mode 100644 index 000000000..017b53c41 --- /dev/null +++ b/includes/mcp/class-convertkit-mcp-ability.php @@ -0,0 +1,168 @@ + $this->get_label(), + 'description' => $this->get_description(), + 'category' => $this->get_category(), + 'input_schema' => $this->get_input_schema(), + 'output_schema' => $this->get_output_schema(), + 'permission_callback' => array( $this, 'permission_callback' ), + 'execute_callback' => array( $this, 'execute_callback' ), + 'meta' => array( + 'annotations' => $this->get_annotations(), + ), + ); + + } + + /** + * Returns the ability's human-readable label. + * + * @since 3.4.0 + * + * @return string + */ + abstract public function get_label(); + + /** + * Returns the ability's human-readable description. + * + * @since 3.4.0 + * + * @return string + */ + abstract public function get_description(); + + /** + * Returns the ability's category. + * + * @since 3.4.0 + * + * @return string + */ + public function get_category() { + + return 'kit'; + + } + + /** + * Returns the ability's input JSON Schema. + * + * @since 3.4.0 + * + * @return array + */ + abstract public function get_input_schema(); + + /** + * Returns the ability's output JSON Schema. + * + * @since 3.4.0 + * + * @return array + */ + abstract public function get_output_schema(); + + /** + * Returns the MCP annotations for this ability. + * + * Defaults to a non-readonly, non-destructive, non-idempotent action. + * Subclasses override the returned array to set the appropriate hints. + * + * @since 3.4.0 + * + * @return array + */ + public function get_annotations() { + + return array( + 'title' => $this->get_label(), + 'readonly' => $this->readonly, + 'destructive' => $this->destructive, + 'idempotent' => $this->idempotent, + ); + + } + + /** + * Permission callback for this ability. + * + * @since 3.4.0 + * + * @param array $input Ability input. + * @return bool|WP_Error + */ + abstract public function permission_callback( $input ); + + /** + * Execute callback for this ability. + * + * @since 3.4.0 + * + * @param array $input Ability input. + * @return array|WP_Error + */ + abstract public function execute_callback( $input ); + +} diff --git a/includes/mcp/class-convertkit-mcp.php b/includes/mcp/class-convertkit-mcp.php new file mode 100644 index 000000000..9a744ec54 --- /dev/null +++ b/includes/mcp/class-convertkit-mcp.php @@ -0,0 +1,171 @@ + __( 'Kit', 'convertkit' ), + 'description' => __( 'Abilities exposed by the Kit Plugin.', 'convertkit' ), + ) + ); + + } + + /** + * Register abilities with the WordPress Abilities API. + * + * @since 3.4.0 + */ + public function register_abilities() { + + // Get abilities. + $abilities = convertkit_get_abilities(); + + // Bail if no abilities are available. + if ( ! count( $abilities ) ) { + return; + } + + // Iterate through abilities, registering them. + foreach ( $abilities as $ability ) { + + // Skip if this ability is not an instance of ConvertKit_MCP_Ability. + if ( ! ( $ability instanceof ConvertKit_MCP_Ability ) ) { + continue; + } + + // Register ability. + wp_register_ability( $ability->get_name(), $ability->get_ability_args() ); + } + + } + + /** + * Register an MCP server that exposes Kit abilities as MCP tools. + * + * @since 3.4.0 + * + * @param object $adapter The MCP Adapter instance. + * @return void + */ + public function register_mcp_server( $adapter ) { + + // Bail if the adapter is not an object or does not have the create_server method. + if ( ! is_object( $adapter ) || ! method_exists( $adapter, 'create_server' ) ) { + return; + } + + // Get abilities. + $abilities = convertkit_get_abilities(); + + // Build array of ability names. + $ability_names = array(); + foreach ( $abilities as $ability ) { + $ability_names[] = $ability->get_name(); + } + + // Create the MCP server. + $adapter->create_server( + self::SERVER_ID, + self::SERVER_NAMESPACE, + self::SERVER_ROUTE, + __( 'Kit MCP', 'convertkit' ), + __( 'Exposes Kit Plugin abilities over the Model Context Protocol.', 'convertkit' ), + '1.0.0', + array( 'WP\\MCP\\Transport\\HttpTransport' ), + 'WP\\MCP\\Infrastructure\\ErrorHandling\\ErrorLogMcpErrorHandler', + 'WP\\MCP\\Infrastructure\\Observability\\NullMcpObservabilityHandler', + $ability_names, // Abilities (Tools). + array(), // Resources. + array() // Prompts. + ); + + } + +} diff --git a/tests/Integration/MCPTest.php b/tests/Integration/MCPTest.php new file mode 100644 index 000000000..3f79ea1c5 --- /dev/null +++ b/tests/Integration/MCPTest.php @@ -0,0 +1,109 @@ +dispatch( $request ); + + // Assert response is unsuccessful. + $this->assertSame( 401, $response->get_status() ); + } + + /** + * Test that the Kit MCP server is registered with the MCP Adapter and + * exposes its discovery endpoint at /wp-json/kit-mcp/v1. + * + * @since 3.4.0 + */ + public function testKitMCPServerCreated() + { + // Create and become administrator. + $this->actAsAdministrator(); + + // Make request. + $request = new \WP_REST_Request('POST', '/kit-mcp/v1'); + $request->set_header('Content-Type', 'application/json'); + $request->set_body( + wp_json_encode( + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + 'capabilities' => new \stdClass(), + 'clientInfo' => [ + 'name' => 'test', + 'version' => '1.0', + ], + ], + ] + ) + ); + $response = rest_get_server()->dispatch($request); + + // Assert the discovery endpoint is registered and responds successfully. + $this->assertSame(200, $response->get_status()); + + // Assert the response identifies itself as the Kit MCP server. + $data = $response->get_data(); + $this->assertSame('Kit MCP', $data['result']->serverInfo['name'] ?? null); + } + + /** + * Act as an administrator user. + * + * @since 3.4.0 + */ + private function actAsAdministrator() + { + $administrator_id = static::factory()->user->create( [ 'role' => 'administrator' ] ); + wp_set_current_user( $administrator_id ); + } +} diff --git a/wp-convertkit.php b/wp-convertkit.php index 2a69ac039..5e2600c64 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -31,6 +31,18 @@ define( 'CONVERTKIT_OAUTH_CLIENT_ID', 'HXZlOCj-K5r0ufuWCtyoyo3f688VmMAYSsKg1eGvw0Y' ); define( 'CONVERTKIT_OAUTH_CLIENT_REDIRECT_URI', 'https://app.kit.com/wordpress/redirect' ); +// Load WordPress MCP Adapter if the Abilities API is available (WordPress 6.9+) +// and PHP 7.4+ is installed. +if ( file_exists( CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php' ) && function_exists( 'wp_register_ability' ) && version_compare( PHP_VERSION, '7.4', '>=' ) ) { + require_once CONVERTKIT_PLUGIN_PATH . '/vendor/autoload.php'; + + // Bootstrap the MCP Adapter, per WordPress/mcp-adapter's recommended + // integration pattern. + // @see https://github.com/WordPress/mcp-adapter#using-mcp-adapter-in-your-plugin. + if ( class_exists( 'WP\\MCP\\Core\\McpAdapter' ) ) { + \WP\MCP\Core\McpAdapter::instance(); + } +} // Load shared classes, if they have not been included by another Kit Plugin. if ( ! trait_exists( 'ConvertKit_API_Traits' ) && ! trait_exists( 'ConvertKit_API\ConvertKit_API_Traits' ) ) { require_once CONVERTKIT_PLUGIN_PATH . '/vendor/convertkit/convertkit-wordpress-libraries/src/class-convertkit-api-traits.php'; @@ -98,6 +110,8 @@ require_once CONVERTKIT_PLUGIN_PATH . '/includes/block-formatters/class-convertkit-block-formatter.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/block-formatters/class-convertkit-block-formatter-form-link.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/block-formatters/class-convertkit-block-formatter-product-link.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/class-convertkit-mcp-ability.php'; +require_once CONVERTKIT_PLUGIN_PATH . '/includes/mcp/class-convertkit-mcp.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/plugin-sidebars/class-convertkit-plugin-sidebar.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/plugin-sidebars/class-convertkit-plugin-sidebar-post-settings.php'; require_once CONVERTKIT_PLUGIN_PATH . '/includes/pre-publish-actions/class-convertkit-pre-publish-action.php';