diff --git a/admin/section/class-convertkit-admin-section-mcp.php b/admin/section/class-convertkit-admin-section-mcp.php index 8cb7f2be8..2157501b5 100644 --- a/admin/section/class-convertkit-admin-section-mcp.php +++ b/admin/section/class-convertkit-admin-section-mcp.php @@ -14,6 +14,15 @@ */ class ConvertKit_Admin_Section_MCP extends ConvertKit_Admin_Section_Base { + /** + * The authorization header to display on screen. + * + * @since 3.4.0 + * + * @var bool|string + */ + private $authorization_header = false; + /** * Constructor. * @@ -44,6 +53,9 @@ public function __construct() { ), ); + $this->maybe_generate_authentication_header(); + $this->maybe_revoke_application_password(); + // Register and maybe output notices for this settings screen, and the Intercom messenger. if ( $this->on_settings_screen( $this->name ) ) { add_action( 'convertkit_settings_base_render_before', array( $this, 'maybe_output_notices' ) ); @@ -56,6 +68,80 @@ public function __construct() { } + /** + * Generates the authentication header to display on screen, if the user + * has just created an Application Password. + * + * @since 3.4.0 + */ + private function maybe_generate_authentication_header() { + + // Bail if we're not on the settings screen. + if ( ! $this->on_settings_screen( $this->name ) ) { + return; + } + + // Bail if nonce verification fails. + if ( ! isset( $_REQUEST['_convertkit_settings_mcp_create_application_password'] ) ) { + return; + } + if ( ! wp_verify_nonce( sanitize_key( $_REQUEST['_convertkit_settings_mcp_create_application_password'] ), 'convertkit-mcp-create-application-password' ) ) { + return; + } + + // Bail if the user login and password are not included in the request. + if ( ! isset( $_REQUEST['user_login'] ) || ! isset( $_REQUEST['password'] ) ) { + return; + } + + // Build the authorization header to display on screen. + $user_login = sanitize_text_field( wp_unslash( $_REQUEST['user_login'] ) ); + $password = sanitize_text_field( wp_unslash( $_REQUEST['password'] ) ); + $this->authorization_header = base64_encode( $user_login . ':' . $password ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + + } + + /** + * Revokes the Application Password, if the user clicked the Revoke Application Password button. + * + * @since 3.4.0 + */ + private function maybe_revoke_application_password() { + + // Bail if we're not on the settings screen. + if ( ! $this->on_settings_screen( $this->name ) ) { + return; + } + + // Bail if nonce verification fails. + if ( ! isset( $_REQUEST['_convertkit_settings_mcp_revoke_application_password'] ) ) { + return; + } + if ( ! wp_verify_nonce( sanitize_key( $_REQUEST['_convertkit_settings_mcp_revoke_application_password'] ), 'convertkit-mcp-revoke-application-password' ) ) { + return; + } + + // Get the Application Password UUID. + $application_password_uuid = $this->get_application_password_uuid(); + + // Bail if no Application Password UUID exists. + if ( ! $application_password_uuid ) { + return; + } + + // Revoke the Application Password. + $result = WP_Application_Passwords::delete_application_password( get_current_user_id(), $application_password_uuid ); + if ( is_wp_error( $result ) ) { + $this->output_error( $result->get_error_message() ); + return; + } + + // Reload the settings screen. + wp_safe_redirect( $this->get_settings_url() ); + exit(); + + } + /** * Enqueues scripts for the Settings > MCP screen. * @@ -95,12 +181,36 @@ public function register_fields() { 'label' => __( 'When enabled, allows AI clients to connect to the Kit Plugin using MCP.', 'convertkit' ), 'description' => sprintf( '%s
%s', - __( 'Go to your AI tool to add a custom connector by pasting this URL to connect to this plugin:', 'convertkit' ), - get_site_url() . '/wp-json/kit/mcp/v1' + __( 'MCP server URL:', 'convertkit' ), + esc_url( ConvertKit_MCP::get_server_url() ) ), ) ); + // Bail if MCP is not enabled — none of the connect UI applies. + if ( ! $this->settings->enabled() ) { + return; + } + + // If an Application Password exists for this Plugin, display the instructions and revoke section. + if ( $this->get_application_password_uuid() ) { + add_settings_field( + 'connect', + __( 'Connection', 'convertkit' ), + array( $this, 'instructions_disconnect_callback' ), + $this->settings_key, + $this->name + ); + } else { + add_settings_field( + 'connect', + __( 'Connection', 'convertkit' ), + array( $this, 'connect_callback' ), + $this->settings_key, + $this->name + ); + } + } /** @@ -112,7 +222,7 @@ public function print_section_info() { ?> -

+

get_settings_url( + array( + '_convertkit_settings_mcp_create_application_password' => wp_create_nonce( 'convertkit-mcp-create-application-password' ), + ) + ) + ) + . '&reject_url=' . rawurlencode( $this->get_settings_url() ); + ?> +

+ +

+

+ + + +

+ get_settings_url( array( '_convertkit_settings_mcp_revoke_application_password' => wp_create_nonce( 'convertkit-mcp-revoke-application-password' ) ) ); + + // Fetch query parameters to build the Basic auth header. + if ( $this->authorization_header ) { + ?> +

+ + Basic authorization_header ); ?> +

+

+ +

+ +

+ +
+ +

+ +

+ +

+ + authorization_header + ? 'Basic ' . $this->authorization_header + : __( 'Your base64 encoded username and application password', 'convertkit' ); + + // Claude desktop / Cline JSON. + // + // The Authorization header value is inlined (not passed via the + // `env` block + `${VAR}` substitution as the mcp-remote docs + // suggest), because mcp-remote's variable substitution is unreliable + // with Basic auth values that contain `$` characters in their + // base64 payload — the substitution silently leaves the literal + // `${KIT_AUTH}` string in place, producing 401s. + $claude_desktop_config = wp_json_encode( + array( + 'mcpServers' => array( + 'kit-wordpress' => array( + 'command' => 'npx', + 'args' => array( + '-y', + 'mcp-remote', + $server_url, + '--header', + 'Authorization: ' . $auth_header, + ), + ), + ), + ), + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES + ); + + // Cursor JSON. + $cursor_config = wp_json_encode( + array( + 'mcpServers' => array( + 'kit-wordpress' => array( + 'url' => $server_url, + 'headers' => array( + 'Authorization' => $auth_header, + ), + ), + ), + ), + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES + ); + ?> + +

+

+ claude_desktop_config.json', + ); + ?> +
+ macOS: ~/Library/Application Support/Claude/claude_desktop_config.json +
+ Windows: %APPDATA%\Claude\claude_desktop_config.json +

+
+ +

+

+ +
+ + + +

+ +

+

+ ~/.cursor/mcp.json' + ); + ?> +

+
+ +

+

+ +

+

+ + +

+

+ + +

+ '_wp_convertkit_settings', + 'tab' => $this->name, + ), + $query_args + ), + admin_url( 'options-general.php' ) + ); + + } + + /** + * Finds the UUID of the most recently-created Application Password for the + * currently logged in user + * + * @since 3.4.0 + * + * @return bool|string + */ + private function get_application_password_uuid() { + + // Get the user's Application Passwords. + $passwords = WP_Application_Passwords::get_user_application_passwords( get_current_user_id() ); + + // Return false if no Application Passwords exist. + if ( empty( $passwords ) ) { + return false; + } + + // Iterate through the Application Passwords and return the password that matches the app name. + foreach ( $passwords as $password ) { + if ( $password['name'] === CONVERTKIT_MCP_APP_NAME ) { + return $password['uuid']; + } + } + + return false; + + } + } // Bootstrap. diff --git a/includes/mcp/class-convertkit-mcp.php b/includes/mcp/class-convertkit-mcp.php index f9f8f7b13..8f0fcdd99 100644 --- a/includes/mcp/class-convertkit-mcp.php +++ b/includes/mcp/class-convertkit-mcp.php @@ -57,6 +57,19 @@ class ConvertKit_MCP { */ const SERVER_ROUTE = 'v1'; + /** + * Returns the absolute URL that MCP clients connect to. + * + * @since 3.4.0 + * + * @return string + */ + public static function get_server_url() { + + return rest_url( self::SERVER_NAMESPACE . '/' . self::SERVER_ROUTE ); + + } + /** * Constructor. * diff --git a/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php b/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php index 3d7a009ea..cf0a22683 100644 --- a/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php +++ b/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php @@ -77,6 +77,87 @@ public function testEnableAndDisableMCPServerSetting(EndToEndTester $I) $I->doesNotHaveRoute($I, '/kit/mcp/v1'); } + /** + * Tests that generating and revoking an Application Password works with no errors + * and that the MCP server is accessible using the Authorization Header generated + * via the Application Password. + * + * @since 3.4.0 + * + * @param EndToEndTester $I Tester. + */ + public function testGenerateAndRevokeApplicationPassword(EndToEndTester $I) + { + // Go to the Plugin's MCP Screen. + $I->loadKitSettingsMCPScreen($I); + + // Enable MCP server. + $I->checkOption('#enabled'); + $I->click('Save Changes'); + + // Check that no PHP warnings or notices were output. + $I->checkNoWarningsAndNoticesOnScreen($I); + + // Check that the MCP server is enabled. + $I->waitForElementVisible('#enabled'); + $I->seeCheckboxIsChecked('#enabled'); + + // Click Create Application Password button. + $I->click('Create Application Password'); + + // Check that no PHP warnings or notices were output. + $I->checkNoWarningsAndNoticesOnScreen($I); + + // Check that the application password was created and contains the correct name. + $I->waitForElementVisible('#app_name'); + $I->seeInField('#app_name', 'Kit WordPress Plugin: MCP Server'); + + // Approve the application password. + $I->click('input#approve'); + + // Check that no PHP warnings or notices were output. + $I->checkNoWarningsAndNoticesOnScreen($I); + + // Check that the user is back on the Settings > Kit > MCP screen and the Authentication Header is displayed. + $I->waitForElementVisible('#kit-authorization-header'); + + // Perform a JSON-RPC `initialize` request against the MCP server using + // the Authorization Header generated via the Application Password. + $response = $I->callRestEndpoint( + '/kit/mcp/v1', + $I->grabTextFrom('#kit-authorization-header'), + 'POST', + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + 'capabilities' => new \stdClass(), + 'clientInfo' => [ + 'name' => 'kit-wordpress-plugin-test', + 'version' => '1.0', + ], + ], + ] + ); + + // Assert the request was authorised and the discovery endpoint responded. + $I->assertEquals(200, $response['status']); + $I->assertEquals('Kit WordPress Plugin MCP', $response['body']['result']['serverInfo']['name'] ?? null); + + // Reload the MCP settings screen and confirm the Authorization Header is not displayed. + $I->loadKitSettingsMCPScreen($I); + $I->waitForText('It is not displayed here for security.'); + $I->waitForElementNotVisible('#kit-authorization-header'); + + // Revoke the application password. + $I->click('#convertkit-settings-mcp-revoke-application-password'); + + // Check that the Revoke Application Password button is no longer visible. + $I->waitForElementNotVisible('#convertkit-settings-mcp-revoke-application-password'); + } + /** * Deactivate and reset Plugin(s) after each test, if the test passes. * We don't use _after, as this would provide a screenshot of the Plugin diff --git a/tests/Support/Helper/WPRestAPI.php b/tests/Support/Helper/WPRestAPI.php index 26011ebe2..479a50a35 100644 --- a/tests/Support/Helper/WPRestAPI.php +++ b/tests/Support/Helper/WPRestAPI.php @@ -35,6 +35,51 @@ public function doesNotHaveRoute($I, $route) $I->assertFalse( in_array( $route, $this->getRoutes(), true ) ); } + /** + * Call a REST API endpoint. + * + * @since 3.4.0 + * + * @param string $endpoint Endpoint. + * @param string $authorizationHeader Authorization Header. + * @param string $method Method. + * @param array $body Body. + * @return array + */ + public function callRestEndpoint( $endpoint, $authorizationHeader, $method = 'GET', $body = null ) { + $url = $_ENV['WORDPRESS_URL'] . '/wp-json' . $endpoint; + + $args = [ + 'method' => $method, + 'headers' => [ + 'Authorization' => $authorizationHeader, + 'Content-Type' => 'application/json', + ], + 'timeout' => 10, + ]; + + // Only attach a body when there's something to send. WP's HTTP layer + // calls http_build_query() on `body` when the method is GET, which + // fails if we've already JSON-encoded the value to a string. + if ( ! empty( $body ) ) { + $args['body'] = wp_json_encode( $body ); + } + + $response = wp_remote_request( $url, $args ); + + if ( is_wp_error( $response ) ) { + return [ + 'status' => 0, + 'body' => $response->get_error_message(), + ]; + } + + return [ + 'status' => wp_remote_retrieve_response_code( $response ), + 'body' => json_decode( wp_remote_retrieve_body( $response ), true ), + ]; + } + /** * Get the routes registered in the REST API. * diff --git a/wp-convertkit.php b/wp-convertkit.php index 0b9c56fbf..caf41b0b4 100644 --- a/wp-convertkit.php +++ b/wp-convertkit.php @@ -30,6 +30,7 @@ define( 'CONVERTKIT_PLUGIN_VERSION', '3.3.4' ); define( 'CONVERTKIT_OAUTH_CLIENT_ID', 'HXZlOCj-K5r0ufuWCtyoyo3f688VmMAYSsKg1eGvw0Y' ); define( 'CONVERTKIT_OAUTH_CLIENT_REDIRECT_URI', 'https://app.kit.com/wordpress/redirect' ); +define( 'CONVERTKIT_MCP_APP_NAME', 'Kit WordPress Plugin: MCP Server' ); // 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' ) ) {