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