From 864cb47e661bebd8e2b71f29a8c6638eaa11af81 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 24 Jun 2026 13:41:47 +0800 Subject: [PATCH 01/10] Abilities API: Plugin Settings: Member Content --- ...s-convertkit-settings-restrict-content.php | 132 +++++++++ includes/mcp/class-convertkit-mcp.php | 1 + .../MCPSettingsRestrictContentTest.php | 257 ++++++++++++++++++ 3 files changed, 390 insertions(+) create mode 100644 tests/Integration/MCPSettingsRestrictContentTest.php diff --git a/includes/class-convertkit-settings-restrict-content.php b/includes/class-convertkit-settings-restrict-content.php index 6653c18df..e6f403cc3 100644 --- a/includes/class-convertkit-settings-restrict-content.php +++ b/includes/class-convertkit-settings-restrict-content.php @@ -115,6 +115,138 @@ public function get_by_key( $key ) { } + /** + * Returns this settings group's programmatic name. + * + * @since 3.4.0 + * + * @return string + */ + public function get_name() { + + return 'restrict-content'; + + } + + /** + * Returns the title of this settings group. + * + * @since 3.4.0 + * + * @return string + */ + public function get_title() { + + return __( 'Member Content Settings', 'convertkit' ); + + } + + /** + * Returns the keys in this settings group that hold credentials or other + * sensitive values. + * + * Member Content settings hold no secrets; returned for interface + * consistency. + * + * @since 3.4.0 + * + * @return string[] + */ + public function get_secret_keys() { + + return array(); + + } + + /** + * Returns the JSON Schema describing this settings group, in the shape + * stored by save() / returned by get(), excluding secret keys. + * + * @since 3.4.0 + * + * @return array + */ + public function get_schema() { + + return array( + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => array( + 'permit_crawlers' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether search engine crawlers are permitted to index Member Content.', 'convertkit' ), + ), + 'no_access_text_form' => array( + 'type' => 'string', + 'description' => __( 'Message shown to a visitor without access when content is restricted by Form.', 'convertkit' ), + ), + 'subscribe_heading' => array( + 'type' => 'string', + 'description' => __( 'Heading shown above the subscribe call-to-action when content is restricted by Product.', 'convertkit' ), + ), + 'subscribe_text' => array( + 'type' => 'string', + 'description' => __( 'Body text shown alongside the subscribe call-to-action when content is restricted by Product.', 'convertkit' ), + ), + 'no_access_text' => array( + 'type' => 'string', + 'description' => __( 'Message shown to a visitor without access when content is restricted by Product.', 'convertkit' ), + ), + 'subscribe_heading_tag' => array( + 'type' => 'string', + 'description' => __( 'Heading shown above the subscribe call-to-action when content is restricted by Tag.', 'convertkit' ), + ), + 'subscribe_text_tag' => array( + 'type' => 'string', + 'description' => __( 'Body text shown alongside the subscribe call-to-action when content is restricted by Tag.', 'convertkit' ), + ), + 'require_tag_login' => array( + 'type' => 'string', + 'enum' => array( '', 'on' ), + 'description' => __( 'Whether visitors must log in by email to access Member Content restricted by Tag.', 'convertkit' ), + ), + 'no_access_text_tag' => array( + 'type' => 'string', + 'description' => __( 'Message shown to a visitor without access when content is restricted by Tag.', 'convertkit' ), + ), + 'subscribe_button_label' => array( + 'type' => 'string', + 'description' => __( 'Label for the Subscribe button.', 'convertkit' ), + ), + 'email_text' => array( + 'type' => 'string', + 'description' => __( 'Body text shown above the email log-in form.', 'convertkit' ), + ), + 'email_button_label' => array( + 'type' => 'string', + 'description' => __( 'Label for the email log-in button.', 'convertkit' ), + ), + 'email_heading' => array( + 'type' => 'string', + 'description' => __( 'Heading shown above the email log-in form.', 'convertkit' ), + ), + 'email_description_text' => array( + 'type' => 'string', + 'description' => __( 'Description shown beneath the email log-in heading.', 'convertkit' ), + ), + 'email_check_heading' => array( + 'type' => 'string', + 'description' => __( 'Heading shown after the visitor requests a magic log-in code.', 'convertkit' ), + ), + 'email_check_text' => array( + 'type' => 'string', + 'description' => __( 'Body text shown after the visitor requests a magic log-in code.', 'convertkit' ), + ), + 'container_css_classes' => array( + 'type' => 'string', + 'description' => __( 'Additional CSS classes appended to the Restrict Content container element.', 'convertkit' ), + ), + ), + ); + + } + /** * The default settings, used when the ConvertKit Restrict Content Settings haven't been saved * e.g. on a new installation. diff --git a/includes/mcp/class-convertkit-mcp.php b/includes/mcp/class-convertkit-mcp.php index a20e0009a..f9f8f7b13 100644 --- a/includes/mcp/class-convertkit-mcp.php +++ b/includes/mcp/class-convertkit-mcp.php @@ -101,6 +101,7 @@ public function register_settings_abilities( $abilities ) { $groups = array( new ConvertKit_Settings(), new ConvertKit_Settings_Broadcasts(), + new ConvertKit_Settings_Restrict_Content(), ); // Iterate through settings groups, registering the get and update abilities. diff --git a/tests/Integration/MCPSettingsRestrictContentTest.php b/tests/Integration/MCPSettingsRestrictContentTest.php new file mode 100644 index 000000000..95a68010a --- /dev/null +++ b/tests/Integration/MCPSettingsRestrictContentTest.php @@ -0,0 +1,257 @@ + \ConvertKit_MCP_Ability_Settings_Get::class, + 'kit/settings-restrict-content-update' => \ConvertKit_MCP_Ability_Settings_Update::class, + ); + + // Assert that the abilities are registered and are instances of the expected classes. + foreach ( $expected as $name => $class ) { + $this->assertArrayHasKey($name, $abilities); + $this->assertInstanceOf($class, $abilities[ $name ]); + } + } + + /** + * Test that the permission_callback() rejects a user who cannot manage options. + * + * @since 3.4.0 + */ + public function testPermissionCallbackDeniesWithoutManageOptionsCapability() + { + // Become a Subscriber (no manage_options capability). + $subscriber_id = static::factory()->user->create([ 'role' => 'subscriber' ]); + wp_set_current_user($subscriber_id); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Assert that the abilities are permission denied. + foreach ( self::ABILITY_NAMES as $name ) { + // Execute the ability. + $result = $abilities[ $name ]->permission_callback([]); + + // Assert that the result is a WP_Error. + $this->assertInstanceOf(\WP_Error::class, $result); + } + } + + /** + * Test that kit/settings-restrict-content-get returns the current settings. + * + * @since 3.4.0 + */ + public function testGetSettings() + { + // Populate settings. + $this->populateSettings(); + + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/settings-restrict-content-get']->execute_callback([]); + + // Confirm expected settings are returned. + $this->assertArrayHasKey('permit_crawlers', $result); + $this->assertEquals('on', $result['permit_crawlers']); + $this->assertArrayHasKey('no_access_text_form', $result); + $this->assertArrayHasKey('subscribe_heading', $result); + $this->assertArrayHasKey('subscribe_text', $result); + $this->assertArrayHasKey('no_access_text', $result); + $this->assertArrayHasKey('subscribe_heading_tag', $result); + $this->assertArrayHasKey('subscribe_text_tag', $result); + $this->assertArrayHasKey('require_tag_login', $result); + $this->assertEquals('on', $result['require_tag_login']); + $this->assertArrayHasKey('no_access_text_tag', $result); + $this->assertArrayHasKey('subscribe_button_label', $result); + $this->assertArrayHasKey('email_text', $result); + $this->assertArrayHasKey('email_button_label', $result); + $this->assertArrayHasKey('email_heading', $result); + $this->assertArrayHasKey('email_description_text', $result); + $this->assertArrayHasKey('email_check_heading', $result); + $this->assertArrayHasKey('email_check_text', $result); + $this->assertArrayHasKey('container_css_classes', $result); + } + + /** + * Test that kit/settings-restrict-content-update updates the settings. + * + * @since 3.4.0 + */ + public function testUpdateSettings() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/settings-restrict-content-update']->execute_callback( + [ + 'permit_crawlers' => 'on', + 'subscribe_heading' => 'Updated subscribe heading', + 'subscribe_text' => 'Updated subscribe text', + 'require_tag_login' => 'on', + 'subscribe_button_label' => 'Join now', + 'container_css_classes' => 'kit-restrict kit-restrict-custom', + ] + ); + + // Confirm expected settings are returned. + $this->assertArrayHasKey('permit_crawlers', $result); + $this->assertArrayHasKey('no_access_text_form', $result); + $this->assertArrayHasKey('subscribe_heading', $result); + $this->assertArrayHasKey('subscribe_text', $result); + $this->assertArrayHasKey('no_access_text', $result); + $this->assertArrayHasKey('subscribe_heading_tag', $result); + $this->assertArrayHasKey('subscribe_text_tag', $result); + $this->assertArrayHasKey('require_tag_login', $result); + $this->assertArrayHasKey('no_access_text_tag', $result); + $this->assertArrayHasKey('subscribe_button_label', $result); + $this->assertArrayHasKey('email_text', $result); + $this->assertArrayHasKey('email_button_label', $result); + $this->assertArrayHasKey('email_heading', $result); + $this->assertArrayHasKey('email_description_text', $result); + $this->assertArrayHasKey('email_check_heading', $result); + $this->assertArrayHasKey('email_check_text', $result); + $this->assertArrayHasKey('container_css_classes', $result); + + // Confirm settings are updated. + $this->assertEquals('on', $result['permit_crawlers']); + $this->assertEquals('Updated subscribe heading', $result['subscribe_heading']); + $this->assertEquals('Updated subscribe text', $result['subscribe_text']); + $this->assertEquals('on', $result['require_tag_login']); + $this->assertEquals('Join now', $result['subscribe_button_label']); + $this->assertEquals('kit-restrict kit-restrict-custom', $result['container_css_classes']); + } + + /** + * Test that kit/settings-restrict-content-update returns an error if an invalid key is provided. + * + * @since 3.4.0 + */ + public function testUpdateSettingsWithInvalidKeyReturnsError() + { + // Resolve the abilities array via the same helper the MCP server uses. + $abilities = convertkit_get_abilities(); + + // Execute the ability. + $result = $abilities['kit/settings-restrict-content-update']->execute_callback([ 'invalid_key' => 'invalid_value' ]); + } + + /** + * Populate the settings with some sensible values for testing. + * + * @since 3.4.0 + */ + private function populateSettings() + { + update_option( + self::SETTINGS_NAME, + [ + 'permit_crawlers' => 'on', + 'no_access_text_form' => 'No access (form).', + 'subscribe_heading' => 'Read with a premium subscription', + 'subscribe_text' => 'Only available to premium subscribers.', + 'no_access_text' => 'No access (product).', + 'subscribe_heading_tag' => 'Subscribe to keep reading', + 'subscribe_text_tag' => 'Free but only available to subscribers.', + 'require_tag_login' => 'on', + 'no_access_text_tag' => 'No access (tag).', + 'subscribe_button_label' => 'Subscribe', + 'email_text' => 'Already subscribed?', + 'email_button_label' => 'Log in', + 'email_heading' => 'Log in to read this post', + 'email_description_text' => 'We\'ll email you a magic code to log you in.', + 'email_check_heading' => 'We just emailed you a log in code', + 'email_check_text' => 'Enter the code below to finish logging in', + 'container_css_classes' => '', + ] + ); + } +} From 66fc12f9bd1a9df2d7bc7ebaca33e4ea6c51cf46 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 24 Jun 2026 16:03:58 +0800 Subject: [PATCH 02/10] Abilities API: Authentication --- .../class-convertkit-admin-section-mcp.php | 104 +++++++++++++++++- includes/mcp/class-convertkit-mcp.php | 13 +++ wp-convertkit.php | 1 + 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/admin/section/class-convertkit-admin-section-mcp.php b/admin/section/class-convertkit-admin-section-mcp.php index 8cb7f2be8..122984ece 100644 --- a/admin/section/class-convertkit-admin-section-mcp.php +++ b/admin/section/class-convertkit-admin-section-mcp.php @@ -95,12 +95,26 @@ 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. + add_settings_field( + 'connect', + __( 'Connect a client', 'convertkit' ), + array( $this, $this->get_application_password() === false ? 'connect_callback' : 'instructions_disconnect_callback' ), + $this->settings_key, + $this->name + ); + } /** @@ -152,6 +166,92 @@ public function enabled_callback( $args ) { } + /** + * Renders the Connect a client setting, to allow the user to generate an Application Password + * for this Plugin which is used to connect AI clients to the MCP Server. + * + * @since 3.4.0 + */ + public function connect_callback() { + + // Build the WordPress authorize-application.php URL. + // See: https://developer.wordpress.org/advanced-administration/security/application-passwords/. + $authorize_url = add_query_arg( + array( + 'app_name' => CONVERTKIT_MCP_APP_NAME, + 'success_url' => $this->get_settings_url( array( 'application_password' => 1 ) ), + 'reject_url' => $this->get_settings_url(), + ), + admin_url( 'authorize-application.php' ) + ); + ?> +

+ +

+

+ + + +

+ +

+ + + get_application_password(); + var_dump( $application_password ); + ?> +

+ Date: Wed, 24 Jun 2026 16:33:48 +0800 Subject: [PATCH 03/10] Revoke Application Password --- .../class-convertkit-admin-section-mcp.php | 75 ++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/admin/section/class-convertkit-admin-section-mcp.php b/admin/section/class-convertkit-admin-section-mcp.php index 122984ece..3fcb5704d 100644 --- a/admin/section/class-convertkit-admin-section-mcp.php +++ b/admin/section/class-convertkit-admin-section-mcp.php @@ -44,6 +44,9 @@ public function __construct() { ), ); + // Maybe revoke the Application Password. + $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 +59,47 @@ public function __construct() { } + /** + * 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. + $application_password = $this->get_application_password(); + + // Bail if no Application Password exists. + if ( ! $application_password ) { + 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. * @@ -204,6 +248,9 @@ public function connect_callback() { */ public function instructions_disconnect_callback() { + // Build disconnect URL. + $disconnect_url = $this->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 ( array_key_exists( 'user_login', $_REQUEST ) && array_key_exists( 'password', $_REQUEST ) ) { $user_login = $_REQUEST['user_login']; @@ -219,10 +266,36 @@ public function instructions_disconnect_callback() { var_dump( $application_password ); ?>

+

+ +

'_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 @@ -243,7 +316,7 @@ private function get_application_password() { // Iterate through the Application Passwords and return the password that matches the app name. foreach ( $passwords as $password ) { - if ( $password['name'] === self::APP_NAME ) { + if ( $password['name'] === CONVERTKIT_MCP_APP_NAME ) { return $password; } } From 7225e39394ac7d486abb892b9e3af0f1506980d6 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 24 Jun 2026 17:00:19 +0800 Subject: [PATCH 04/10] Improve flow for creating and destroying application password for MCP --- .../class-convertkit-admin-section-mcp.php | 128 +++++++++++++----- 1 file changed, 96 insertions(+), 32 deletions(-) diff --git a/admin/section/class-convertkit-admin-section-mcp.php b/admin/section/class-convertkit-admin-section-mcp.php index 3fcb5704d..5e934897c 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,7 +53,7 @@ public function __construct() { ), ); - // Maybe revoke the Application Password. + $this->maybe_generate_authentication_header(); $this->maybe_revoke_application_password(); // Register and maybe output notices for this settings screen, and the Intercom messenger. @@ -59,6 +68,39 @@ 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. * @@ -151,13 +193,23 @@ public function register_fields() { } // If an Application Password exists for this Plugin, display the instructions and revoke section. - add_settings_field( - 'connect', - __( 'Connect a client', 'convertkit' ), - array( $this, $this->get_application_password() === false ? 'connect_callback' : 'instructions_disconnect_callback' ), - $this->settings_key, - $this->name - ); + if ( $this->get_application_password() ) { + 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 + ); + } } @@ -170,7 +222,7 @@ public function print_section_info() { ?> -

+

CONVERTKIT_MCP_APP_NAME, - 'success_url' => $this->get_settings_url( array( 'application_password' => 1 ) ), - 'reject_url' => $this->get_settings_url(), - ), - admin_url( 'authorize-application.php' ) - ); + // We don't use add_query_arg(), as rawurlencode() is needed for authorize-application.php's JS to work correctly. + $authorize_url = admin_url( 'authorize-application.php' ) + . '?app_name=' . rawurlencode( CONVERTKIT_MCP_APP_NAME ) + . '&success_url=' . rawurlencode( + $this->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() ); ?>

- +

@@ -249,25 +304,34 @@ public function connect_callback() { public function instructions_disconnect_callback() { // Build disconnect URL. - $disconnect_url = $this->get_settings_url( array( '_convertkit_settings_mcp_revoke_application_password' => wp_create_nonce( 'convertkit-mcp-revoke-application-password' ) ) ); + $disconnect_url = $this->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 ( array_key_exists( 'user_login', $_REQUEST ) && array_key_exists( 'password', $_REQUEST ) ) { - $user_login = $_REQUEST['user_login']; - $password = $_REQUEST['password']; - $basic = base64_encode( $user_login . ':' . $password ); + if ( $this->authorization_header ) { + ?> +

+ + Basic authorization_header ); ?> +

+

+ +

+ +

+ +
+ +

+

- - - get_application_password(); - var_dump( $application_password ); - ?> +

- + @TODO Configs here.

'_wp_convertkit_settings', - 'tab' => $this->name, + 'tab' => $this->name, ), $query_args ), From e9fc23adec13cf8b3169df8e715507f973521462 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 24 Jun 2026 17:27:08 +0800 Subject: [PATCH 05/10] Output configs for common AI clients --- .../class-convertkit-admin-section-mcp.php | 101 +++++++++++++++++- includes/mcp/class-convertkit-mcp.php | 2 +- wp-convertkit.php | 2 +- 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/admin/section/class-convertkit-admin-section-mcp.php b/admin/section/class-convertkit-admin-section-mcp.php index 5e934897c..317a2c275 100644 --- a/admin/section/class-convertkit-admin-section-mcp.php +++ b/admin/section/class-convertkit-admin-section-mcp.php @@ -330,8 +330,107 @@ public function instructions_disconnect_callback() {

+ + 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 + ); + ?> + +

+

+ ~/Library/Application Support/Claude/claude_desktop_config.json' + ); + ?> +

+
+ +

+

+ +
+ + + +

+ +

+

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

+
+ +

+

+ +

+

+ + +

- @TODO Configs here. + +

Date: Thu, 25 Jun 2026 13:46:38 +0800 Subject: [PATCH 06/10] Update Member Content settings class --- ...s-convertkit-settings-restrict-content.php | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/includes/class-convertkit-settings-restrict-content.php b/includes/class-convertkit-settings-restrict-content.php index e6f403cc3..6fc496328 100644 --- a/includes/class-convertkit-settings-restrict-content.php +++ b/includes/class-convertkit-settings-restrict-content.php @@ -311,6 +311,27 @@ public function save( $settings ) { update_option( self::SETTINGS_NAME, array_merge( $this->get(), $settings ) ); + // Reload settings in class, to reflect changes. + $this->refresh_settings(); + + } + + /** + * Reloads settings from the options table so this instance has the latest values. + * + * @since 3.3.5 + */ + private function refresh_settings() { + + $settings = get_option( self::SETTINGS_NAME ); + + if ( ! $settings ) { + $this->settings = $this->get_defaults(); + return; + } + + $this->settings = array_merge( $this->get_defaults(), $settings ); + } } From b0c00e471b4415a8f2020862750eec9d5bcea9d7 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 25 Jun 2026 14:53:27 +0800 Subject: [PATCH 07/10] Added test --- .../plugin-screens/PluginSettingsMCPCest.php | 80 +++++++++++++++++++ tests/Support/Helper/WPRestAPI.php | 45 +++++++++++ 2 files changed, 125 insertions(+) diff --git a/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php b/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php index 3d7a009ea..b08814ade 100644 --- a/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php +++ b/tests/EndToEnd/general/plugin-screens/PluginSettingsMCPCest.php @@ -77,6 +77,86 @@ 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); + + // Revoke the application password. + $I->click('Revoke Application Password'); + + // 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 not displayed. + $I->waitForElementNotVisible('#kit-authorization-header'); + $I->dontSee('Authorization Header:'); + } + /** * 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. * From 783199721f00302aa9a680596b983fc73976060c Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 25 Jun 2026 15:32:45 +0800 Subject: [PATCH 08/10] Fix tests --- .../class-convertkit-admin-section-mcp.php | 16 ++++++++++------ .../plugin-screens/PluginSettingsMCPCest.php | 15 ++++++++------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/admin/section/class-convertkit-admin-section-mcp.php b/admin/section/class-convertkit-admin-section-mcp.php index 317a2c275..a02f52979 100644 --- a/admin/section/class-convertkit-admin-section-mcp.php +++ b/admin/section/class-convertkit-admin-section-mcp.php @@ -288,7 +288,7 @@ public function connect_callback() {

- +

@@ -310,8 +310,8 @@ public function instructions_disconnect_callback() { if ( $this->authorization_header ) { ?>

- - Basic authorization_header ); ?> + + Basic authorization_header ); ?>

@@ -328,7 +328,7 @@ public function instructions_disconnect_callback() { } ?>

- +

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

- +
assertEquals(200, $response['status']); $I->assertEquals('Kit WordPress Plugin MCP', $response['body']['result']['serverInfo']['name'] ?? null); - // Revoke the application password. - $I->click('Revoke Application Password'); + // 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'); - // Check that no PHP warnings or notices were output. - $I->checkNoWarningsAndNoticesOnScreen($I); + // Revoke the application password. + $I->click('#convertkit-settings-mcp-revoke-application-password'); - // Check that the user is back on the Settings > Kit > MCP screen and the Authentication Header is not displayed. - $I->waitForElementNotVisible('#kit-authorization-header'); - $I->dontSee('Authorization Header:'); + // Check that the Revoke Application Password button is no longer visible. + $I->waitForElementNotVisible('#convertkit-settings-mcp-revoke-application-password'); } /** From 717d93c140a9824917a271f15b812f62294f1f50 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Thu, 25 Jun 2026 15:39:08 +0800 Subject: [PATCH 09/10] PHPStan compat. --- .../class-convertkit-admin-section-mcp.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/admin/section/class-convertkit-admin-section-mcp.php b/admin/section/class-convertkit-admin-section-mcp.php index a02f52979..2157501b5 100644 --- a/admin/section/class-convertkit-admin-section-mcp.php +++ b/admin/section/class-convertkit-admin-section-mcp.php @@ -121,16 +121,16 @@ private function maybe_revoke_application_password() { return; } - // Get the Application Password. - $application_password = $this->get_application_password(); + // Get the Application Password UUID. + $application_password_uuid = $this->get_application_password_uuid(); - // Bail if no Application Password exists. - if ( ! $application_password ) { + // 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'] ); + $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; @@ -193,7 +193,7 @@ public function register_fields() { } // If an Application Password exists for this Plugin, display the instructions and revoke section. - if ( $this->get_application_password() ) { + if ( $this->get_application_password_uuid() ) { add_settings_field( 'connect', __( 'Connection', 'convertkit' ), @@ -471,7 +471,7 @@ private function get_settings_url( $query_args = array() ) { * * @return bool|string */ - private function get_application_password() { + private function get_application_password_uuid() { // Get the user's Application Passwords. $passwords = WP_Application_Passwords::get_user_application_passwords( get_current_user_id() ); @@ -484,7 +484,7 @@ private function get_application_password() { // 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; + return $password['uuid']; } } From 2953206240500b0a1b5b313c0f0193c948ccbc07 Mon Sep 17 00:00:00 2001 From: Tim Carr Date: Wed, 8 Jul 2026 16:21:54 +0800 Subject: [PATCH 10/10] Set correct URL for MCP server --- includes/mcp/class-convertkit-mcp.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/mcp/class-convertkit-mcp.php b/includes/mcp/class-convertkit-mcp.php index 8f0fcdd99..df5db04ed 100644 --- a/includes/mcp/class-convertkit-mcp.php +++ b/includes/mcp/class-convertkit-mcp.php @@ -66,7 +66,7 @@ class ConvertKit_MCP { */ public static function get_server_url() { - return rest_url( self::SERVER_NAMESPACE . '/' . self::SERVER_ROUTE ); + return rest_url( self::SERVER_NAMESPACE . '/' . self::SERVER_ROUTE . '/mcp' ); }