From eb06dfa4514787c39a3cfa62db309b0b25a5ecd8 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 5 Nov 2025 17:28:38 -0700 Subject: [PATCH 01/17] Add MCP Server --- inc/class-wp-ultimo.php | 2 + inc/managers/class-broadcast-manager.php | 5 +- inc/managers/class-customer-manager.php | 14 +++-- inc/managers/class-discount-code-manager.php | 3 + inc/managers/class-domain-manager.php | 3 + inc/managers/class-event-manager.php | 33 ++++++---- inc/managers/class-membership-manager.php | 63 ++++++++++---------- inc/managers/class-payment-manager.php | 33 +++++----- inc/managers/class-product-manager.php | 3 + inc/managers/class-site-manager.php | 20 ++++--- inc/managers/class-webhook-manager.php | 3 + inc/models/class-event.php | 9 ++- inc/models/class-site.php | 5 +- 13 files changed, 119 insertions(+), 77 deletions(-) diff --git a/inc/class-wp-ultimo.php b/inc/class-wp-ultimo.php index 7cadbd4bb..577c566f1 100644 --- a/inc/class-wp-ultimo.php +++ b/inc/class-wp-ultimo.php @@ -642,6 +642,8 @@ function () { * Cron Schedules */ \WP_Ultimo\Cron::get_instance(); + + \WP_Ultimo\MCP_Adapter::get_instance(); } /** diff --git a/inc/managers/class-broadcast-manager.php b/inc/managers/class-broadcast-manager.php index 9fb4e1d3c..6c46fecc4 100644 --- a/inc/managers/class-broadcast-manager.php +++ b/inc/managers/class-broadcast-manager.php @@ -27,6 +27,7 @@ class Broadcast_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; + use \WP_Ultimo\Apis\MCP_Abilities; use \WP_Ultimo\Traits\Singleton; /** @@ -57,6 +58,8 @@ public function init(): void { $this->enable_wp_cli(); + $this->enable_mcp_abilities(); + /** * Add unseen broadcast notices to the panel. */ @@ -97,7 +100,7 @@ public function add_unseen_broadcast_notices(): void { $targets = [$targets]; } - $dismissed = get_user_meta(get_current_user_id(), 'wu_dismissed_admin_notices'); + $dismissed = get_user_meta(get_current_user_id(), 'wu_dismissed_admin_notices', false); if (in_array($current_customer->get_id(), $targets, true) && ! in_array($broadcast->get_id(), $dismissed, true)) { $notice = '' . $broadcast->get_title() . ' ' . $broadcast->get_content() . ''; diff --git a/inc/managers/class-customer-manager.php b/inc/managers/class-customer-manager.php index 7cfe793ac..c09ecb37e 100644 --- a/inc/managers/class-customer-manager.php +++ b/inc/managers/class-customer-manager.php @@ -27,6 +27,7 @@ class Customer_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; + use \WP_Ultimo\Apis\MCP_Abilities; use \WP_Ultimo\Traits\Singleton; /** @@ -57,6 +58,8 @@ public function init(): void { $this->enable_wp_cli(); + $this->enable_mcp_abilities(); + add_action( 'init', function () { @@ -118,8 +121,8 @@ public function handle_resend_verification_email(): void { * @return array $response The Heartbeat response */ public function on_heartbeat_send($response) { - - $this->log_ip_and_last_login(wp_get_current_user()); + $user = wp_get_current_user(); + $this->log_ip_and_last_login($user->user_login, $user); return $response; } @@ -127,15 +130,14 @@ public function on_heartbeat_send($response) { /** * Saves the IP address and last_login date onto the user. * - * @since 2.0.0 - * + * @param string $user_login The username of the user that logged in. * @param \WP_User $user The WP User object of the user that logged in. * @return void */ - public function log_ip_and_last_login($user): void { + public function log_ip_and_last_login($user_login, $user): void { if ( ! is_a($user, '\WP_User')) { - $user = get_user_by('login', $user); + $user = get_user_by('login', $user_login); } if ( ! $user) { diff --git a/inc/managers/class-discount-code-manager.php b/inc/managers/class-discount-code-manager.php index f2ca6f32b..ec0f2e96f 100644 --- a/inc/managers/class-discount-code-manager.php +++ b/inc/managers/class-discount-code-manager.php @@ -26,6 +26,7 @@ class Discount_Code_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; + use \WP_Ultimo\Apis\MCP_Abilities; use \WP_Ultimo\Traits\Singleton; /** @@ -56,6 +57,8 @@ public function init(): void { $this->enable_wp_cli(); + $this->enable_mcp_abilities(); + add_action('wu_gateway_payment_processed', [$this, 'maybe_add_use_on_payment_received']); } diff --git a/inc/managers/class-domain-manager.php b/inc/managers/class-domain-manager.php index 4093977fb..28e0be9ce 100644 --- a/inc/managers/class-domain-manager.php +++ b/inc/managers/class-domain-manager.php @@ -28,6 +28,7 @@ class Domain_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; + use \WP_Ultimo\Apis\MCP_Abilities; use \WP_Ultimo\Traits\Singleton; /** @@ -132,6 +133,8 @@ public function init(): void { $this->enable_wp_cli(); + $this->enable_mcp_abilities(); + $this->set_cookie_domain(); add_action('plugins_loaded', [$this, 'load_integrations']); diff --git a/inc/managers/class-event-manager.php b/inc/managers/class-event-manager.php index 050d555ef..3d6d25dd0 100644 --- a/inc/managers/class-event-manager.php +++ b/inc/managers/class-event-manager.php @@ -11,6 +11,7 @@ namespace WP_Ultimo\Managers; +use Psr\Log\LogLevel; use WP_Ultimo\Models\Base_Model; use WP_Ultimo\Models\Event; @@ -26,8 +27,10 @@ class Event_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; + use \WP_Ultimo\Apis\MCP_Abilities; use \WP_Ultimo\Traits\Singleton; + const LOG_FILE_NAME = 'events'; /** * The manager slug. * @@ -72,6 +75,8 @@ public function init(): void { $this->enable_wp_cli(); + $this->enable_mcp_abilities(); + add_action('init', [$this, 'register_all_events']); add_action('wp_ajax_wu_get_event_payload_preview', [$this, 'event_payload_preview']); @@ -248,22 +253,24 @@ public function get_event_type_as_options() { * @param string $slug The slug of the event. Something like payment_received. * @param array $payload with the events information. * - * @return array with returns message for now. + * @return bool */ public function do_event($slug, $payload) { $registered_event = $this->get_event($slug); if ( ! $registered_event) { - return ['error' => 'Event not found']; + wu_log_add(self::LOG_FILE_NAME, 'Event not found', LogLevel::ERROR); + return false; } $payload_diff = array_diff_key(wu_maybe_lazy_load_payload($registered_event['payload']), $payload); - if (isset($payload_diff[0])) { + if (! empty($payload_diff[0])) { foreach ($payload_diff[0] as $diff_key => $diff_value) { - return ['error' => 'Param required:' . $diff_key]; + wu_log_add(self::LOG_FILE_NAME, 'Param required:' . $diff_key, LogLevel::ERROR); } + return false; } $payload['wu_version'] = wu_get_version(); @@ -275,7 +282,7 @@ public function do_event($slug, $payload) { /** * Saves in the database */ - $this->save_event($slug, $payload); + return $this->save_event($slug, $payload); } /** @@ -334,9 +341,9 @@ public function get_event($slug) { * * @param string $slug of the event. * @param array $payload with event params. - * @return void. + * @return bool. */ - public function save_event($slug, $payload): void { + public function save_event($slug, $payload): bool { $event = new Event( [ @@ -349,7 +356,11 @@ public function save_event($slug, $payload): void { ] ); - $event->save(); + $return = $event->save(); + if ( is_wp_error($return)) { + return false; + } + return (bool) $return; } /** @@ -590,7 +601,7 @@ public function get_model_payload(string $model, ?object $model_object = null) { * * @since 2.0.0 */ - public function clean_old_events(): bool { + public function clean_old_events(): void { /* * Add a filter setting this to 0 or false * to prevent old events from being ever deleted. @@ -598,7 +609,7 @@ public function clean_old_events(): bool { $threshold_days = apply_filters('wu_events_threshold_days', 1); if (empty($threshold_days)) { - return false; + return; } $events_to_remove = wu_get_events( @@ -624,8 +635,6 @@ public function clean_old_events(): bool { // Translators: 1: Number of successfully removed events. 2: Number of failed events to remove. wu_log_add('wu-cron', sprintf(__('Removed %1$d events successfully. Failed to remove %2$d events.', 'ultimate-multisite'), $success_count, count($events_to_remove) - $success_count)); - - return true; } /** diff --git a/inc/managers/class-membership-manager.php b/inc/managers/class-membership-manager.php index ea4c4d922..fb5c416e6 100644 --- a/inc/managers/class-membership-manager.php +++ b/inc/managers/class-membership-manager.php @@ -26,8 +26,10 @@ class Membership_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; + use \WP_Ultimo\Apis\MCP_Abilities; use \WP_Ultimo\Traits\Singleton; + const LOG_FILE_NAME = 'memberships'; /** * The manager slug. * @@ -56,6 +58,8 @@ public function init(): void { $this->enable_wp_cli(); + $this->enable_mcp_abilities(); + add_action( 'init', function () { @@ -120,14 +124,15 @@ public function publish_pending_site(): void { * @since 2.0.0 * * @param int $membership_id The membership id. - * @return bool|\WP_Error + * @return void */ public function async_publish_pending_site($membership_id) { $membership = wu_get_membership($membership_id); if ( ! $membership) { - return new \WP_Error('error', __('An unexpected error happened.', 'ultimate-multisite')); + wu_log_add(self::LOG_FILE_NAME, __('An unexpected error happened.', 'ultimate-multisite'), LogLevel::ERROR); + return; } $status = $membership->publish_pending_site(); @@ -135,8 +140,6 @@ public function async_publish_pending_site($membership_id) { if (is_wp_error($status)) { wu_log_add('site-errors', $status, LogLevel::ERROR); } - - return $status; } /** @@ -176,7 +179,7 @@ public function check_pending_site_created() { * @since 2.0.0 * * @param int $membership_id The membership id. - * @return bool|\WP_Error + * @return void */ public function async_membership_swap($membership_id) { @@ -185,13 +188,15 @@ public function async_membership_swap($membership_id) { $membership = wu_get_membership($membership_id); if ( ! $membership) { - return new \WP_Error('error', __('An unexpected error happened.', 'ultimate-multisite')); + wu_log_add(self::LOG_FILE_NAME, __('An unexpected error happened.', 'ultimate-multisite'), LogLevel::ERROR); + return; } $scheduled_swap = $membership->get_scheduled_swap(); if (empty($scheduled_swap)) { - return new \WP_Error('error', __('An unexpected error happened.', 'ultimate-multisite')); + wu_log_add(self::LOG_FILE_NAME, __('An unexpected error happened.', 'ultimate-multisite'), LogLevel::ERROR); + return; } $order = $scheduled_swap->order; @@ -205,13 +210,14 @@ public function async_membership_swap($membership_id) { if (is_wp_error($status)) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - - return new \WP_Error('error', __('An unexpected error happened.', 'ultimate-multisite')); + wu_log_add(self::LOG_FILE_NAME, $status->get_error_message(), LogLevel::ERROR); + return; } } catch (\Throwable $exception) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - return new \WP_Error('error', __('An unexpected error happened.', 'ultimate-multisite')); + wu_log_add(self::LOG_FILE_NAME, $exception->getMessage(), LogLevel::ERROR); + return; } /* @@ -220,8 +226,6 @@ public function async_membership_swap($membership_id) { $membership->delete_scheduled_swap(); $wpdb->query('COMMIT'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - - return true; } /** @@ -292,7 +296,7 @@ public function mark_cancelled_date($old_value, $new_value, $item_id): void { * * @param int $membership_id The ID of the membership being transferred. * @param int $target_customer_id The new owner. - * @return mixed + * @return void */ public function async_transfer_membership($membership_id, $target_customer_id) { @@ -303,7 +307,8 @@ public function async_transfer_membership($membership_id, $target_customer_id) { $target_customer = wu_get_customer($target_customer_id); if ( ! $membership || ! $target_customer || absint($membership->get_customer_id()) === absint($target_customer->get_id())) { - return new \WP_Error('error', __('An unexpected error happened.', 'ultimate-multisite')); + wu_log_add(self::LOG_FILE_NAME, __('An unexpected error happened.', 'ultimate-multisite'), LogLevel::ERROR); + return; } $wpdb->query('START TRANSACTION'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery @@ -330,8 +335,8 @@ public function async_transfer_membership($membership_id, $target_customer_id) { if (is_wp_error($saved)) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - - return $saved; + wu_log_add(self::LOG_FILE_NAME, $saved->get_error_message(), LogLevel::ERROR); + return; } } @@ -345,19 +350,18 @@ public function async_transfer_membership($membership_id, $target_customer_id) { if (is_wp_error($saved)) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - return $saved; + wu_log_add(self::LOG_FILE_NAME, $saved->get_error_message(), LogLevel::ERROR); + return; } } catch (\Throwable $e) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - return new \WP_Error('exception', $e->getMessage()); + wu_log_add(self::LOG_FILE_NAME, $e->getMessage(), LogLevel::ERROR); } $wpdb->query('COMMIT'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $membership->unlock(); - - return true; } /** @@ -366,7 +370,7 @@ public function async_transfer_membership($membership_id, $target_customer_id) { * @since 2.0.0 * * @param int $membership_id The ID of the membership being deleted. - * @return mixed + * @return void */ public function async_delete_membership($membership_id) { @@ -375,7 +379,8 @@ public function async_delete_membership($membership_id) { $membership = wu_get_membership($membership_id); if ( ! $membership) { - return new \WP_Error('error', __('An unexpected error happened.', 'ultimate-multisite')); + wu_log_add(self::LOG_FILE_NAME, __('An unexpected error happened.', 'ultimate-multisite'), LogLevel::ERROR); + return; } $wpdb->query('START TRANSACTION'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery @@ -400,8 +405,8 @@ public function async_delete_membership($membership_id) { if (is_wp_error($saved)) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - - return $saved; + wu_log_add(self::LOG_FILE_NAME, $saved->get_error_message(), LogLevel::ERROR); + return; } } @@ -412,17 +417,15 @@ public function async_delete_membership($membership_id) { if (is_wp_error($saved)) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - - return $saved; + wu_log_add(self::LOG_FILE_NAME, $saved->get_error_message(), LogLevel::ERROR); + return; } } catch (\Throwable $e) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - - return new \WP_Error('exception', $e->getMessage()); + wu_log_add(self::LOG_FILE_NAME, $e->getMessage(), LogLevel::ERROR); + return; } $wpdb->query('COMMIT'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - - return true; } } diff --git a/inc/managers/class-payment-manager.php b/inc/managers/class-payment-manager.php index b2c30077a..ac2861a2a 100644 --- a/inc/managers/class-payment-manager.php +++ b/inc/managers/class-payment-manager.php @@ -11,6 +11,7 @@ namespace WP_Ultimo\Managers; +use Psr\Log\LogLevel; use WP_Ultimo\Managers\Base_Manager; use WP_Ultimo\Models\Payment; use WP_Ultimo\Logger; @@ -29,8 +30,10 @@ class Payment_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; + use \WP_Ultimo\Apis\MCP_Abilities; use \WP_Ultimo\Traits\Singleton; + const LOG_FILE_NAME = 'payments'; /** * The manager slug. * @@ -59,6 +62,8 @@ public function init(): void { $this->enable_wp_cli(); + $this->enable_mcp_abilities(); + $this->register_forms(); add_action( @@ -317,7 +322,7 @@ public function invoice_viewer(): void { * * @param int $payment_id The ID of the payment being transferred. * @param int $target_customer_id The new owner. - * @return mixed + * @return void */ public function async_transfer_payment($payment_id, $target_customer_id) { @@ -328,7 +333,8 @@ public function async_transfer_payment($payment_id, $target_customer_id) { $target_customer = wu_get_customer($target_customer_id); if ( ! $payment || ! $target_customer || $payment->get_customer_id() === $target_customer->get_id()) { - return new \WP_Error('error', __('An unexpected error happened.', 'ultimate-multisite')); + wu_log_add(self::LOG_FILE_NAME, __('An unexpected error happened.', 'ultimate-multisite'), LogLevel::ERROR); + return; } $wpdb->query('START TRANSACTION'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery @@ -344,18 +350,17 @@ public function async_transfer_payment($payment_id, $target_customer_id) { if (is_wp_error($saved)) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - - return $saved; + wu_log_add(self::LOG_FILE_NAME, $saved->get_error_message(), LogLevel::ERROR); + return; } } catch (\Throwable $e) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery + wu_log_add(self::LOG_FILE_NAME, $e->getMessage(), LogLevel::ERROR); - return new \WP_Error('exception', $e->getMessage()); + return; } $wpdb->query('COMMIT'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - - return true; } /** @@ -364,7 +369,7 @@ public function async_transfer_payment($payment_id, $target_customer_id) { * @since 2.0.0 * * @param int $payment_id The ID of the payment being deleted. - * @return mixed + * @return void */ public function async_delete_payment($payment_id) { @@ -373,7 +378,8 @@ public function async_delete_payment($payment_id) { $payment = wu_get_payment($payment_id); if ( ! $payment) { - return new \WP_Error('error', __('An unexpected error happened.', 'ultimate-multisite')); + wu_log_add(self::LOG_FILE_NAME, __('An unexpected error happened.', 'ultimate-multisite'), LogLevel::ERROR); + return; } $wpdb->query('START TRANSACTION'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery @@ -387,18 +393,17 @@ public function async_delete_payment($payment_id) { if (is_wp_error($saved)) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - - return $saved; + wu_log_add(self::LOG_FILE_NAME, $saved->get_error_message(), LogLevel::ERROR); + return; } } catch (\Throwable $e) { $wpdb->query('ROLLBACK'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - return new \WP_Error('exception', $e->getMessage()); + wu_log_add(self::LOG_FILE_NAME, $e->getMessage(), LogLevel::ERROR); + return; } $wpdb->query('COMMIT'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery - - return true; } /** diff --git a/inc/managers/class-product-manager.php b/inc/managers/class-product-manager.php index 1a2a4389c..89151c45d 100644 --- a/inc/managers/class-product-manager.php +++ b/inc/managers/class-product-manager.php @@ -26,6 +26,7 @@ class Product_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; + use \WP_Ultimo\Apis\MCP_Abilities; use \WP_Ultimo\Traits\Singleton; /** @@ -55,5 +56,7 @@ public function init(): void { $this->enable_rest_api(); $this->enable_wp_cli(); + + $this->enable_mcp_abilities(); } } diff --git a/inc/managers/class-site-manager.php b/inc/managers/class-site-manager.php index 5daf07baf..63233b380 100644 --- a/inc/managers/class-site-manager.php +++ b/inc/managers/class-site-manager.php @@ -14,6 +14,7 @@ use WP_Ultimo\Helpers\Screenshot; use WP_Ultimo\Database\Sites\Site_Type; use WP_Ultimo\Database\Memberships\Membership_Status; +use WP_Ultimo\Models\Site; // Exit if accessed directly defined('ABSPATH') || exit; @@ -27,6 +28,7 @@ class Site_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; + use \WP_Ultimo\Apis\MCP_Abilities; use \WP_Ultimo\Traits\Singleton; /** @@ -57,6 +59,8 @@ public function init(): void { $this->enable_wp_cli(); + $this->enable_mcp_abilities(); + add_action('after_setup_theme', [$this, 'additional_thumbnail_sizes']); add_action('wp_ajax_wu_get_screenshot', [$this, 'get_site_screenshot']); @@ -83,11 +87,11 @@ public function init(): void { add_filter('mucd_string_to_replace', [$this, 'search_and_replace_on_duplication'], 10, 3); - add_filter('wu_site_created', [$this, 'search_and_replace_for_new_site'], 10, 2); + add_action('wu_site_created', [$this, 'search_and_replace_for_new_site'], 10, 2); add_action('wu_handle_bulk_action_form_site_delete-pending', [$this, 'handle_delete_pending_sites'], 100, 3); - add_action('users_list_table_query_args', [$this, 'hide_super_admin_from_list'], 10, 1); + add_filter('users_list_table_query_args', [$this, 'hide_super_admin_from_list'], 10, 1); add_action('wu_before_handle_order_submission', [$this, 'maybe_validate_add_new_site'], 15); @@ -325,14 +329,14 @@ public function lock_site(): void { * @since 2.0.0 * * @param int $site_id The site ID. - * @return mixed + * @return void */ public function async_get_site_screenshot($site_id) { $site = wu_get_site($site_id); if ( ! $site) { - return false; + return; } $domain = $site->get_active_site_url(); @@ -340,12 +344,12 @@ public function async_get_site_screenshot($site_id) { $attachment_id = Screenshot::take_screenshot($domain); if ( ! $attachment_id) { - return false; + return; } $site->set_featured_image_id($attachment_id); - return $site->save(); + $site->save(); } /** @@ -598,8 +602,8 @@ public function get_search_and_replace_settings() { * Handles search and replace for new blogs from WordPress. * * @since 1.7.0 - * @param array $data The date being saved. - * @param object $site The site object. + * @param array $data The date being saved. + * @param Site $site The site object. * @return void */ public static function search_and_replace_for_new_site($data, $site): void { diff --git a/inc/managers/class-webhook-manager.php b/inc/managers/class-webhook-manager.php index eff77f94d..492eff177 100644 --- a/inc/managers/class-webhook-manager.php +++ b/inc/managers/class-webhook-manager.php @@ -26,6 +26,7 @@ class Webhook_Manager extends Base_Manager { use \WP_Ultimo\Apis\Rest_Api; use \WP_Ultimo\Apis\WP_CLI; + use \WP_Ultimo\Apis\MCP_Abilities; use \WP_Ultimo\Traits\Singleton; /** @@ -72,6 +73,8 @@ public function init(): void { $this->enable_wp_cli(); + $this->enable_mcp_abilities(); + add_action('init', [$this, 'register_webhook_listeners']); add_action('wp_ajax_wu_send_test_event', [$this, 'send_test_event']); diff --git a/inc/models/class-event.php b/inc/models/class-event.php index 3ef5ac302..01b938416 100644 --- a/inc/models/class-event.php +++ b/inc/models/class-event.php @@ -10,6 +10,7 @@ namespace WP_Ultimo\Models; use WP_Ultimo\Models\Base_Model; +use WP_User; // Exit if accessed directly defined('ABSPATH') || exit; @@ -268,7 +269,7 @@ public function get_message() { */ public function interpolate_message($message, $payload): string { - $payload = json_decode(json_encode($payload), true); + $payload = json_decode(json_encode($payload), true); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode $interpolation_keys = []; @@ -351,7 +352,7 @@ public function get_author_id() { * Returns the user associated with this author. * * @since 2.0.0 - * @return WP_User + * @return ?WP_User */ public function get_author_user() { @@ -377,6 +378,7 @@ public function get_author_display_name() { if ($user) { return $user->display_name; } + return ''; } /** @@ -392,6 +394,7 @@ public function get_author_email_address() { if ($user) { return $user->user_email; } + return ''; } /** @@ -623,8 +626,8 @@ public function to_array() { /** * Override to clear event count. * + * @return bool|\WP_Error * @since 2.0.0 - * @return int|false */ public function save() { diff --git a/inc/models/class-site.php b/inc/models/class-site.php index e53d4467d..c424bd3d3 100644 --- a/inc/models/class-site.php +++ b/inc/models/class-site.php @@ -1606,10 +1606,9 @@ public function save() { * @since 2.0.0 * * @param array $data The object data that will be stored. - * @param \WP_Ultimo\Models\Base_Model $this The object instance. + * @param \WP_Ultimo\Models\Base_Model $site The object instance. */ - do_action('wu_site_created', $data, $this); // @phpstan-ignore-line - + do_action('wu_site_created', $data, $this); } if ( ! is_wp_error($saved) && wu_get_setting('enable_screenshot_generator', true)) { From 9d9ce8678ec17c0eed8ab6442157f620a4d57686 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 5 Nov 2025 17:52:20 -0700 Subject: [PATCH 02/17] Add the mcp adapter --- composer.lock | 153 +-------- inc/api/trait-mcp-abilities.php | 550 ++++++++++++++++++++++++++++++++ inc/class-mcp-adapter.php | 202 ++++++++++++ 3 files changed, 753 insertions(+), 152 deletions(-) create mode 100644 inc/api/trait-mcp-abilities.php create mode 100644 inc/class-mcp-adapter.php diff --git a/composer.lock b/composer.lock index 6a9d5dd6b..439c254ac 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e60e744a8cc00d9f6a35cb83fcb16a84", + "content-hash": "449cfb9599185a01b55da4704cc1930a", "packages": [ { "name": "amphp/amp", @@ -4720,155 +4720,6 @@ }, "time": "2025-07-15T09:32:30+00:00" }, - { - "name": "wordpress/abilities-api", - "version": "dev-trunk", - "source": { - "type": "git", - "url": "https://github.com/WordPress/abilities-api.git", - "reference": "e309018d2eeb2c043214f68f42d503d1000d272a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/WordPress/abilities-api/zipball/e309018d2eeb2c043214f68f42d503d1000d272a", - "reference": "e309018d2eeb2c043214f68f42d503d1000d272a", - "shasum": "" - }, - "require": { - "php": "^7.4 | ^8" - }, - "require-dev": { - "automattic/vipwpcs": "^3.0", - "dealerdirect/phpcodesniffer-composer-installer": "^1.0", - "phpcompatibility/php-compatibility": "10.x-dev as 9.99.99", - "phpcompatibility/phpcompatibility-wp": "^2.1", - "phpstan/extension-installer": "^1.3", - "phpstan/phpstan": "^2.1.22", - "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.3", - "phpunit/phpunit": "^8.5|^9.6", - "slevomat/coding-standard": "^8.0", - "squizlabs/php_codesniffer": "^3.9", - "szepeviktor/phpstan-wordpress": "^2.0.2", - "wp-coding-standards/wpcs": "^3.1", - "wp-phpunit/wp-phpunit": "^6.5", - "wpackagist-plugin/plugin-check": "^1.6", - "yoast/phpunit-polyfills": "^4.0" - }, - "default-branch": true, - "type": "library", - "extra": { - "installer-paths": { - "vendor/{$vendor}/{$name}/": [ - "wpackagist-plugin/plugin-check" - ] - } - }, - "autoload": { - "files": [ - "includes/bootstrap.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "GPL-2.0-or-later" - ], - "authors": [ - { - "name": "WordPress AI Team", - "homepage": "https://make.wordpress.org/ai/" - } - ], - "description": "AI Abilities for WordPress.", - "homepage": "https://github.com/WordPress/abilities-api", - "keywords": [ - "abilities", - "ai", - "api", - "llm", - "wordpress" - ], - "support": { - "issues": "https://github.com/WordPress/abilities-api/issues", - "source": "https://github.com/WordPress/abilities-api" - }, - "time": "2025-10-27T16:58:34+00:00" - }, - { - "name": "wordpress/mcp-adapter", - "version": "dev-trunk", - "source": { - "type": "git", - "url": "https://github.com/WordPress/mcp-adapter.git", - "reference": "db59ef55da2a4e8e5d21d3a439b242e0c530a88a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/WordPress/mcp-adapter/zipball/db59ef55da2a4e8e5d21d3a439b242e0c530a88a", - "reference": "db59ef55da2a4e8e5d21d3a439b242e0c530a88a", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "automattic/vipwpcs": "^3.0", - "php-stubs/wp-cli-stubs": "^2.12", - "phpcompatibility/php-compatibility": "10.x-dev as 9.99.99", - "phpcompatibility/phpcompatibility-wp": "^2.1", - "phpstan/extension-installer": "^1.3", - "phpstan/php-8-stubs": "^0.4.24", - "phpstan/phpstan": "^2.1.22", - "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpunit/phpunit": "^9.6", - "slevomat/coding-standard": "^8.0", - "szepeviktor/phpstan-wordpress": "^2.0", - "wp-phpunit/wp-phpunit": "^6.5", - "wpackagist-plugin/plugin-check": "^1.6", - "yoast/phpunit-polyfills": "^4.0" - }, - "default-branch": true, - "type": "library", - "extra": { - "installer-paths": { - "vendor/{$vendor}/{$name}/": [ - "wpackagist-plugin/plugin-check" - ] - } - }, - "autoload": { - "psr-4": { - "WP\\MCP\\": "includes/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "GPL-2.0-or-later" - ], - "authors": [ - { - "name": "WordPress AI Team", - "homepage": "https://make.wordpress.org/ai/" - } - ], - "description": "Adapter for Abilities API, letting WordPress abilities to be used as MCP tools, resources or prompts", - "homepage": "https://github.com/wordpress/mcp-adapter", - "keywords": [ - "abilities-api", - "adapter", - "ai", - "api", - "integration", - "mcp", - "model-context-protocol", - "wordpress" - ], - "support": { - "issues": "https://github.com/wordpress/mcp-adapter/issues", - "source": "https://github.com/wordpress/mcp-adapter" - }, - "time": "2025-11-05T15:20:51+00:00" - }, { "name": "wp-cli/process", "version": "v5.9.99", @@ -12129,8 +11980,6 @@ "stability-flags": { "rakit/validation": 20, "rpnzl/arrch": 20, - "wordpress/abilities-api": 20, - "wordpress/mcp-adapter": 20, "wp-ultimo/autoloader-plugin": 20 }, "prefer-stable": false, diff --git a/inc/api/trait-mcp-abilities.php b/inc/api/trait-mcp-abilities.php new file mode 100644 index 000000000..2baab5199 --- /dev/null +++ b/inc/api/trait-mcp-abilities.php @@ -0,0 +1,550 @@ +slug; + } + + /** + * Enable MCP abilities for this entity. + * Should be called by the manager to register abilities. + * + * @since 2.5.0 + * @return void + */ + public function enable_mcp_abilities(): void { + + $is_enabled = \WP_Ultimo\MCP_Adapter::get_instance()->is_mcp_enabled(); + + if (! $is_enabled) { + return; + } + + if (! function_exists('register_ability')) { + return; + } + + add_action('wu_mcp_adapter_initialized', [$this, 'register_abilities']); + } + + /** + * Register abilities with the Abilities API. + * + * @since 2.5.0 + * @return void + */ + public function register_abilities(): void { + + $ability_prefix = $this->get_mcp_ability_prefix(); + $model_name = $this->slug; + $display_name = ucwords(str_replace(['_', '-'], ' ', $model_name)); + + if (in_array('get_item', $this->enabled_mcp_abilities, true)) { + $this->register_get_item_ability($ability_prefix, $display_name); + } + + if (in_array('get_items', $this->enabled_mcp_abilities, true)) { + $this->register_get_items_ability($ability_prefix, $display_name); + } + + if (in_array('create_item', $this->enabled_mcp_abilities, true)) { + $this->register_create_item_ability($ability_prefix, $display_name); + } + + if (in_array('update_item', $this->enabled_mcp_abilities, true)) { + $this->register_update_item_ability($ability_prefix, $display_name); + } + + if (in_array('delete_item', $this->enabled_mcp_abilities, true)) { + $this->register_delete_item_ability($ability_prefix, $display_name); + } + + /** + * Fires after MCP abilities are registered for an entity. + * + * @since 2.5.0 + * @param string $ability_prefix The ability prefix. + * @param string $model_name The model name. + * @param object $this The manager instance. + */ + do_action('wu_mcp_abilities_registered', $ability_prefix, $model_name, $this); + } + + /** + * Register the get item ability. + * + * @since 2.5.0 + * @param string $ability_prefix The ability prefix. + * @param string $display_name The display name. + * @return void + */ + protected function register_get_item_ability(string $ability_prefix, string $display_name): void { + + register_ability( + "{$ability_prefix}_get_item", + [ + // translators: %s: entity name (e.g., Customer, Site, Product) + 'label' => sprintf(__('Get %s by ID', 'ultimate-multisite'), $display_name), + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('Retrieve a single %s by its ID', 'ultimate-multisite'), strtolower($display_name)), + 'callback' => [$this, 'mcp_get_item'], + 'inputs' => [ + 'id' => [ + 'label' => __('ID', 'ultimate-multisite'), + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('The ID of the %s to retrieve', 'ultimate-multisite'), strtolower($display_name)), + 'type' => 'integer', + 'required' => true, + ], + ], + 'outputs' => [ + 'item' => [ + 'label' => $display_name, + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('The %s object', 'ultimate-multisite'), strtolower($display_name)), + 'type' => 'object', + ], + ], + ] + ); + } + + /** + * Register the get items ability. + * + * @since 2.5.0 + * @param string $ability_prefix The ability prefix. + * @param string $display_name The display name. + * @return void + */ + protected function register_get_items_ability(string $ability_prefix, string $display_name): void { + + register_ability( + "{$ability_prefix}_get_items", + [ + // translators: %s: entity name (e.g., Customer, Site, Product) + 'label' => sprintf(__('List %s', 'ultimate-multisite'), $display_name), + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('Retrieve a list of %s with optional filters', 'ultimate-multisite'), strtolower($display_name)), + 'callback' => [$this, 'mcp_get_items'], + 'inputs' => [ + 'per_page' => [ + 'label' => __('Per Page', 'ultimate-multisite'), + 'description' => __('Number of items to retrieve per page', 'ultimate-multisite'), + 'type' => 'integer', + 'required' => false, + 'default' => 10, + ], + 'page' => [ + 'label' => __('Page', 'ultimate-multisite'), + 'description' => __('Page number to retrieve', 'ultimate-multisite'), + 'type' => 'integer', + 'required' => false, + 'default' => 1, + ], + ], + 'outputs' => [ + 'items' => [ + 'label' => $display_name, + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('Array of %s objects', 'ultimate-multisite'), strtolower($display_name)), + 'type' => 'array', + ], + 'total' => [ + 'label' => __('Total', 'ultimate-multisite'), + 'description' => __('Total number of items', 'ultimate-multisite'), + 'type' => 'integer', + ], + ], + ] + ); + } + + /** + * Register the create item ability. + * + * @since 2.5.0 + * @param string $ability_prefix The ability prefix. + * @param string $display_name The display name. + * @return void + */ + protected function register_create_item_ability(string $ability_prefix, string $display_name): void { + + $schema = $this->get_mcp_schema_for_ability('create'); + + register_ability( + "{$ability_prefix}_create_item", + [ + // translators: %s: entity name (e.g., Customer, Site, Product) + 'label' => sprintf(__('Create %s', 'ultimate-multisite'), $display_name), + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('Create a new %s', 'ultimate-multisite'), strtolower($display_name)), + 'callback' => [$this, 'mcp_create_item'], + 'inputs' => $schema, + 'outputs' => [ + 'item' => [ + 'label' => $display_name, + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('The created %s object', 'ultimate-multisite'), strtolower($display_name)), + 'type' => 'object', + ], + ], + ] + ); + } + + /** + * Register the update item ability. + * + * @since 2.5.0 + * @param string $ability_prefix The ability prefix. + * @param string $display_name The display name. + * @return void + */ + protected function register_update_item_ability(string $ability_prefix, string $display_name): void { + + $schema = $this->get_mcp_schema_for_ability('update'); + + register_ability( + "{$ability_prefix}_update_item", + [ + // translators: %s: entity name (e.g., Customer, Site, Product) + 'label' => sprintf(__('Update %s', 'ultimate-multisite'), $display_name), + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('Update an existing %s', 'ultimate-multisite'), strtolower($display_name)), + 'callback' => [$this, 'mcp_update_item'], + 'inputs' => array_merge( + [ + 'id' => [ + 'label' => __('ID', 'ultimate-multisite'), + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('The ID of the %s to update', 'ultimate-multisite'), strtolower($display_name)), + 'type' => 'integer', + 'required' => true, + ], + ], + $schema + ), + 'outputs' => [ + 'item' => [ + 'label' => $display_name, + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('The updated %s object', 'ultimate-multisite'), strtolower($display_name)), + 'type' => 'object', + ], + ], + ] + ); + } + + /** + * Register the delete item ability. + * + * @since 2.5.0 + * @param string $ability_prefix The ability prefix. + * @param string $display_name The display name. + * @return void + */ + protected function register_delete_item_ability(string $ability_prefix, string $display_name): void { + + register_ability( + "{$ability_prefix}_delete_item", + [ + // translators: %s: entity name (e.g., Customer, Site, Product) + 'label' => sprintf(__('Delete %s', 'ultimate-multisite'), $display_name), + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('Delete a %s by its ID', 'ultimate-multisite'), strtolower($display_name)), + 'callback' => [$this, 'mcp_delete_item'], + 'inputs' => [ + 'id' => [ + 'label' => __('ID', 'ultimate-multisite'), + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('The ID of the %s to delete', 'ultimate-multisite'), strtolower($display_name)), + 'type' => 'integer', + 'required' => true, + ], + ], + 'outputs' => [ + 'success' => [ + 'label' => __('Success', 'ultimate-multisite'), + 'description' => __('Whether the deletion was successful', 'ultimate-multisite'), + 'type' => 'boolean', + ], + ], + ] + ); + } + + /** + * Get MCP schema for an ability (create or update). + * + * @since 2.5.0 + * @param string $context The context (create or update). + * @return array + */ + protected function get_mcp_schema_for_ability(string $context = 'create'): array { + + if (! method_exists($this, 'get_arguments_schema')) { + return []; + } + + $rest_schema = $this->get_arguments_schema('update' === $context); + + $mcp_schema = []; + + foreach ($rest_schema as $key => $args) { + $mcp_schema[ $key ] = [ + 'label' => $args['description'] ?? ucfirst(str_replace('_', ' ', $key)), + 'description' => $args['description'] ?? '', + 'type' => $args['type'] ?? 'string', + 'required' => $args['required'] ?? false, + ]; + + if (isset($args['default'])) { + $mcp_schema[ $key ]['default'] = $args['default']; + } + + if (isset($args['enum'])) { + $mcp_schema[ $key ]['enum'] = $args['enum']; + } + } + + return $mcp_schema; + } + + /** + * MCP callback to get a single item. + * + * @since 2.5.0 + * @param array $args The arguments passed to the ability. + * @return array|\WP_Error + */ + public function mcp_get_item(array $args) { + + if (! isset($args['id'])) { + return new \WP_Error('missing_id', __('ID is required', 'ultimate-multisite')); + } + + $item = $this->model_class::get_by_id($args['id']); + + if (empty($item)) { + return new \WP_Error( + "wu_{$this->slug}_not_found", + sprintf( + // translators: %s: entity name (e.g., Customer, Site, Product) + __('%s not found', 'ultimate-multisite'), + ucfirst(str_replace('_', ' ', $this->slug)) + ) + ); + } + + return [ + 'item' => $item->to_array(), + ]; + } + + /** + * MCP callback to get a list of items. + * + * @since 2.5.0 + * @param array $args The arguments passed to the ability. + * @return array + */ + public function mcp_get_items(array $args): array { + + $query_args = array_merge( + [ + 'per_page' => 10, + 'page' => 1, + ], + $args + ); + + $items = $this->model_class::query($query_args); + + $total = $this->model_class::query(array_merge($query_args, ['count' => true])); + + return [ + 'items' => array_map( + function ($item) { + return $item->to_array(); + }, + $items + ), + 'total' => $total, + ]; + } + + /** + * MCP callback to create an item. + * + * @since 2.5.0 + * @param array $args The arguments passed to the ability. + * @return array|\WP_Error + */ + public function mcp_create_item(array $args) { + + $model_name = (new $this->model_class([]))->model; + + $saver_function = "wu_create_{$model_name}"; + + if (function_exists($saver_function)) { + $item = call_user_func($saver_function, $args); + + $saved = is_wp_error($item) ? $item : true; + } else { + $item = new $this->model_class($args); + + $saved = $item->save(); + } + + if (is_wp_error($saved)) { + return $saved; + } + + if (! $saved) { + return new \WP_Error( + "wu_{$this->slug}_save_failed", + __('Failed to save item', 'ultimate-multisite') + ); + } + + return [ + 'item' => $item->to_array(), + ]; + } + + /** + * MCP callback to update an item. + * + * @since 2.5.0 + * @param array $args The arguments passed to the ability. + * @return array|\WP_Error + */ + public function mcp_update_item(array $args) { + + if (! isset($args['id'])) { + return new \WP_Error('missing_id', __('ID is required', 'ultimate-multisite')); + } + + $id = $args['id']; + unset($args['id']); + + $item = $this->model_class::get_by_id($id); + + if (empty($item)) { + return new \WP_Error( + "wu_{$this->slug}_not_found", + sprintf( + // translators: %s: entity name (e.g., Customer, Site, Product) + __('%s not found', 'ultimate-multisite'), + ucfirst(str_replace('_', ' ', $this->slug)) + ) + ); + } + + foreach ($args as $param => $value) { + $set_method = "set_{$param}"; + + if ('meta' === $param) { + $item->update_meta_batch($value); + } elseif (method_exists($item, $set_method)) { + call_user_func([$item, $set_method], $value); + } + } + + $saved = $item->save(); + + if (is_wp_error($saved)) { + return $saved; + } + + if (! $saved) { + return new \WP_Error( + "wu_{$this->slug}_save_failed", + __('Failed to update item', 'ultimate-multisite') + ); + } + + return [ + 'item' => $item->to_array(), + ]; + } + + /** + * MCP callback to delete an item. + * + * @since 2.5.0 + * @param array $args The arguments passed to the ability. + * @return array|\WP_Error + */ + public function mcp_delete_item(array $args) { + + if (! isset($args['id'])) { + return new \WP_Error('missing_id', __('ID is required', 'ultimate-multisite')); + } + + $item = $this->model_class::get_by_id($args['id']); + + if (empty($item)) { + return new \WP_Error( + "wu_{$this->slug}_not_found", + sprintf( + // translators: %s: entity name (e.g., Customer, Site, Product) + __('%s not found', 'ultimate-multisite'), + ucfirst(str_replace('_', ' ', $this->slug)) + ) + ); + } + + $result = $item->delete(); + + return [ + 'success' => ! is_wp_error($result) && $result, + ]; + } +} diff --git a/inc/class-mcp-adapter.php b/inc/class-mcp-adapter.php new file mode 100644 index 000000000..bdf68a46a --- /dev/null +++ b/inc/class-mcp-adapter.php @@ -0,0 +1,202 @@ +is_mcp_enabled()) { + return; + } + + try { + $this->adapter = McpAdapterCore::instance(); + + /** + * Fires after the MCP adapter is initialized. + * + * Allows other plugins and themes to register their own abilities. + * + * @since 2.5.0 + * @param MCP_Adapter $mcp_adapter The MCP adapter instance. + */ + do_action('wu_mcp_adapter_initialized', $this); + } catch (\Exception $e) { + wu_log_add( + 'mcp-adapter', + sprintf( + // translators: %s: error message from the exception + __('Failed to initialize MCP adapter: %s', 'ultimate-multisite'), + $e->getMessage() + ) + ); + } + } + + /** + * Add the admin interface to configure MCP adapter. + * + * @since 2.5.0 + * @return void + */ + public function add_settings(): void { + + wu_register_settings_field( + 'api', + 'mcp_header', + [ + 'title' => __('MCP Adapter Settings', 'ultimate-multisite'), + 'desc' => __('Options related to the Model Context Protocol (MCP) adapter. MCP allows AI assistants to interact with Ultimate Multisite programmatically.', 'ultimate-multisite'), + 'type' => 'header', + ] + ); + + wu_register_settings_field( + 'api', + 'enable_mcp', + [ + 'title' => __('Enable MCP Adapter', 'ultimate-multisite'), + 'desc' => __('Tick this box to enable the Model Context Protocol (MCP) adapter. This allows AI assistants to interact with Ultimate Multisite through the Abilities API.', 'ultimate-multisite'), + 'type' => 'toggle', + 'default' => 0, + ] + ); + + wu_register_settings_field( + 'api', + 'mcp_server_url', + [ + 'title' => __('MCP Server URL', 'ultimate-multisite'), + 'desc' => sprintf( + // translators: %s: the HTTP endpoint URL for the MCP server + __('HTTP endpoint: %s', 'ultimate-multisite'), + '' . rest_url('mcp/mcp-adapter-default-server') . '' + ), + 'tooltip' => __('This is the URL where the MCP server is accessible via HTTP.', 'ultimate-multisite'), + 'type' => 'note', + 'classes' => 'wu-text-gray-700 wu-text-xs', + 'require' => [ + 'enable_mcp' => 1, + ], + ] + ); + + wu_register_settings_field( + 'api', + 'mcp_stdio_command', + [ + 'title' => __('STDIO Command', 'ultimate-multisite'), + 'desc' => sprintf( + // translators: %s: the WP-CLI command to run the MCP server + __('Command: %s', 'ultimate-multisite'), + 'wp mcp-adapter serve --server=mcp-adapter-default-server --user=admin' + ), + 'tooltip' => __('This is the WP-CLI command to run the MCP server via STDIO transport.', 'ultimate-multisite'), + 'type' => 'note', + 'classes' => 'wu-text-gray-700 wu-text-xs', + 'require' => [ + 'enable_mcp' => 1, + ], + ] + ); + } + + /** + * Get the MCP adapter instance. + * + * @since 2.5.0 + * @return McpAdapterCore|null + */ + public function get_adapter(): ?McpAdapterCore { + + return $this->adapter; + } + + /** + * Checks if the MCP adapter is enabled. + * + * @since 2.5.0 + * @return bool + */ + public function is_mcp_enabled(): bool { + + /** + * Allow plugin developers to force a given state for the MCP adapter. + * + * @since 2.5.0 + * @param bool $enabled Whether the MCP adapter is enabled. + * @return bool + */ + return apply_filters('wu_is_mcp_enabled', wu_get_setting('enable_mcp', false)); + } +} From f078ca419daa92f9fe3569d25242626bba4cd9f0 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 5 Nov 2025 17:56:47 -0700 Subject: [PATCH 03/17] add new deps --- composer.lock | 153 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 152 insertions(+), 1 deletion(-) diff --git a/composer.lock b/composer.lock index 439c254ac..6a9d5dd6b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "449cfb9599185a01b55da4704cc1930a", + "content-hash": "e60e744a8cc00d9f6a35cb83fcb16a84", "packages": [ { "name": "amphp/amp", @@ -4720,6 +4720,155 @@ }, "time": "2025-07-15T09:32:30+00:00" }, + { + "name": "wordpress/abilities-api", + "version": "dev-trunk", + "source": { + "type": "git", + "url": "https://github.com/WordPress/abilities-api.git", + "reference": "e309018d2eeb2c043214f68f42d503d1000d272a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/abilities-api/zipball/e309018d2eeb2c043214f68f42d503d1000d272a", + "reference": "e309018d2eeb2c043214f68f42d503d1000d272a", + "shasum": "" + }, + "require": { + "php": "^7.4 | ^8" + }, + "require-dev": { + "automattic/vipwpcs": "^3.0", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "phpcompatibility/php-compatibility": "10.x-dev as 9.99.99", + "phpcompatibility/phpcompatibility-wp": "^2.1", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan": "^2.1.22", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpstan/phpstan-phpunit": "^2.0.3", + "phpunit/phpunit": "^8.5|^9.6", + "slevomat/coding-standard": "^8.0", + "squizlabs/php_codesniffer": "^3.9", + "szepeviktor/phpstan-wordpress": "^2.0.2", + "wp-coding-standards/wpcs": "^3.1", + "wp-phpunit/wp-phpunit": "^6.5", + "wpackagist-plugin/plugin-check": "^1.6", + "yoast/phpunit-polyfills": "^4.0" + }, + "default-branch": true, + "type": "library", + "extra": { + "installer-paths": { + "vendor/{$vendor}/{$name}/": [ + "wpackagist-plugin/plugin-check" + ] + } + }, + "autoload": { + "files": [ + "includes/bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "WordPress AI Team", + "homepage": "https://make.wordpress.org/ai/" + } + ], + "description": "AI Abilities for WordPress.", + "homepage": "https://github.com/WordPress/abilities-api", + "keywords": [ + "abilities", + "ai", + "api", + "llm", + "wordpress" + ], + "support": { + "issues": "https://github.com/WordPress/abilities-api/issues", + "source": "https://github.com/WordPress/abilities-api" + }, + "time": "2025-10-27T16:58:34+00:00" + }, + { + "name": "wordpress/mcp-adapter", + "version": "dev-trunk", + "source": { + "type": "git", + "url": "https://github.com/WordPress/mcp-adapter.git", + "reference": "db59ef55da2a4e8e5d21d3a439b242e0c530a88a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/mcp-adapter/zipball/db59ef55da2a4e8e5d21d3a439b242e0c530a88a", + "reference": "db59ef55da2a4e8e5d21d3a439b242e0c530a88a", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "automattic/vipwpcs": "^3.0", + "php-stubs/wp-cli-stubs": "^2.12", + "phpcompatibility/php-compatibility": "10.x-dev as 9.99.99", + "phpcompatibility/phpcompatibility-wp": "^2.1", + "phpstan/extension-installer": "^1.3", + "phpstan/php-8-stubs": "^0.4.24", + "phpstan/phpstan": "^2.1.22", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.0", + "szepeviktor/phpstan-wordpress": "^2.0", + "wp-phpunit/wp-phpunit": "^6.5", + "wpackagist-plugin/plugin-check": "^1.6", + "yoast/phpunit-polyfills": "^4.0" + }, + "default-branch": true, + "type": "library", + "extra": { + "installer-paths": { + "vendor/{$vendor}/{$name}/": [ + "wpackagist-plugin/plugin-check" + ] + } + }, + "autoload": { + "psr-4": { + "WP\\MCP\\": "includes/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "WordPress AI Team", + "homepage": "https://make.wordpress.org/ai/" + } + ], + "description": "Adapter for Abilities API, letting WordPress abilities to be used as MCP tools, resources or prompts", + "homepage": "https://github.com/wordpress/mcp-adapter", + "keywords": [ + "abilities-api", + "adapter", + "ai", + "api", + "integration", + "mcp", + "model-context-protocol", + "wordpress" + ], + "support": { + "issues": "https://github.com/wordpress/mcp-adapter/issues", + "source": "https://github.com/wordpress/mcp-adapter" + }, + "time": "2025-11-05T15:20:51+00:00" + }, { "name": "wp-cli/process", "version": "v5.9.99", @@ -11980,6 +12129,8 @@ "stability-flags": { "rakit/validation": 20, "rpnzl/arrch": 20, + "wordpress/abilities-api": 20, + "wordpress/mcp-adapter": 20, "wp-ultimo/autoloader-plugin": 20 }, "prefer-stable": false, From 2c72ff3219d9dd45650d78284000b3026117fe69 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 5 Nov 2025 17:56:59 -0700 Subject: [PATCH 04/17] Fix tests --- tests/WP_Ultimo/Models/Event_Test.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/WP_Ultimo/Models/Event_Test.php b/tests/WP_Ultimo/Models/Event_Test.php index 5b1f14076..3e1947aae 100644 --- a/tests/WP_Ultimo/Models/Event_Test.php +++ b/tests/WP_Ultimo/Models/Event_Test.php @@ -316,8 +316,8 @@ public function test_author_methods_with_no_user() { $this->event->set_author_id(0); $this->assertNull($this->event->get_author_user()); - $this->assertNull($this->event->get_author_display_name()); - $this->assertNull($this->event->get_author_email_address()); + $this->assertEmpty($this->event->get_author_display_name()); + $this->assertEmtpy($this->event->get_author_email_address()); } /** From 3e740a3f3327d3b18bcfb509a758b6734f9975b9 Mon Sep 17 00:00:00 2001 From: David Stone Date: Sat, 8 Nov 2025 00:05:37 -0700 Subject: [PATCH 05/17] Add magic link feature --- inc/admin-pages/class-list-admin-page.php | 2 + .../class-site-edit-admin-page.php | 4 +- .../class-site-list-admin-page.php | 10 +- inc/class-wp-ultimo.php | 7 + inc/functions/domain.php | 2 +- inc/functions/url.php | 62 ++ .../class-customers-site-list-table.php | 2 +- inc/list-tables/class-site-list-table.php | 2 +- inc/managers/class-domain-manager.php | 11 + inc/sso/class-admin-bar-magic-links.php | 149 +++++ inc/sso/class-magic-link.php | 593 ++++++++++++++++++ inc/sso/class-sso.php | 4 +- inc/ui/class-my-sites-element.php | 2 +- 13 files changed, 836 insertions(+), 14 deletions(-) create mode 100644 inc/sso/class-admin-bar-magic-links.php create mode 100644 inc/sso/class-magic-link.php diff --git a/inc/admin-pages/class-list-admin-page.php b/inc/admin-pages/class-list-admin-page.php index e8ade8527..67e41b2bb 100644 --- a/inc/admin-pages/class-list-admin-page.php +++ b/inc/admin-pages/class-list-admin-page.php @@ -15,6 +15,8 @@ namespace WP_Ultimo\Admin_Pages; // Exit if accessed directly +use WP_List_Table; + defined('ABSPATH') || exit; /** diff --git a/inc/admin-pages/class-site-edit-admin-page.php b/inc/admin-pages/class-site-edit-admin-page.php index 0d88afd8c..fae2697c0 100644 --- a/inc/admin-pages/class-site-edit-admin-page.php +++ b/inc/admin-pages/class-site-edit-admin-page.php @@ -646,12 +646,12 @@ public function action_links() { 'icon' => 'wu-cog', ], [ - 'url' => get_site_url($this->get_object()->get_id()), + 'url' => $this->get_object()->get_active_site_url(), 'label' => __('Visit Site', 'ultimate-multisite'), 'icon' => 'wu-link', ], [ - 'url' => get_admin_url($this->get_object()->get_id()), + 'url' => wu_get_admin_url($this->get_object()->get_id()), 'label' => __('Dashboard', 'ultimate-multisite'), 'icon' => 'dashboard', ], diff --git a/inc/admin-pages/class-site-list-admin-page.php b/inc/admin-pages/class-site-list-admin-page.php index 4069315e6..2eb7d60a4 100644 --- a/inc/admin-pages/class-site-list-admin-page.php +++ b/inc/admin-pages/class-site-list-admin-page.php @@ -231,12 +231,10 @@ public function handle_publish_pending_site_modal(): void { * Handles the add/edit of line items. * * @since 2.0.0 - * @return mixed + * @return void */ public function handle_add_new_site_modal() { - global $current_site; - $domain_type = wu_request('tab', is_subdomain_install() ? 'sub-domain' : 'sub-directory'); if ('domain' === $domain_type) { @@ -263,13 +261,13 @@ public function handle_add_new_site_modal() { $site = wu_create_site($atts); if (is_wp_error($site)) { - return wp_send_json_error($site); + wp_send_json_error($site); } - if ($site->get_blog_id() === false) { + if (! $site->get_blog_id()) { $error = new \WP_Error('error', __('Something wrong happened.', 'ultimate-multisite')); - return wp_send_json_error($error); + wp_send_json_error($error); } $redirect = current_user_can('wu_edit_sites') ? 'wp-ultimo-edit-site' : 'wp-ultimo-sites'; diff --git a/inc/class-wp-ultimo.php b/inc/class-wp-ultimo.php index 577c566f1..ec2b1b9c3 100644 --- a/inc/class-wp-ultimo.php +++ b/inc/class-wp-ultimo.php @@ -438,6 +438,8 @@ protected function load_extra_components(): void { */ WP_Ultimo\SSO\SSO::get_instance(); + WP_Ultimo\SSO\Magic_Link::get_instance(); + /* * Loads the debugger tools */ @@ -668,6 +670,11 @@ protected function load_admin_pages(): void { */ new WP_Ultimo\Admin_Pages\Top_Admin_Nav_Menu(); + /* + * Initialize magic links for admin bar My Sites menu. + */ + \WP_Ultimo\SSO\Admin_Bar_Magic_Links::get_instance(); + /* * Loads the Checkout Form admin page. */ diff --git a/inc/functions/domain.php b/inc/functions/domain.php index 8cdf29dd7..7734326b3 100644 --- a/inc/functions/domain.php +++ b/inc/functions/domain.php @@ -30,7 +30,7 @@ function wu_get_domain($domain_id) { * @since 2.0.0 * * @param array $query Query arguments. - * @return \WP_Ultimo\Models\Domain[] + * @return \WP_Ultimo\Models\Domain[]|string[]|int */ function wu_get_domains($query = []) { diff --git a/inc/functions/url.php b/inc/functions/url.php index 96a7b0a0c..125ae8c81 100644 --- a/inc/functions/url.php +++ b/inc/functions/url.php @@ -93,3 +93,65 @@ function wu_ajax_url($when = null, $query_args = [], $site_id = false, $scheme = return apply_filters('wu_ajax_url', $url, $query_args, $when, $site_id); } + + +/** + * Adds a magic link if needed. + * + * @param ?int $blog_id Blog id. + * @param string $path Path. + * @param ?string $scheme Scheme. + * + * @return false|string + */ +function wu_get_admin_url($blog_id = null, $path = '', $scheme = 'admin') { + if (! $blog_id) { + return get_admin_url($blog_id, $path, $scheme); + } + + $current_user_id = get_current_user_id(); + $admin_url = get_admin_url($blog_id, $path, $scheme); + + if ( ! $current_user_id ) { + return $admin_url; + } + + $magic_link = \WP_Ultimo\SSO\Magic_Link::get_instance(); + + // Check if a magic link is needed. + if ( ! $magic_link->site_needs_magic_link($blog_id) ) { + return $admin_url; + } + + $magic_link_url = $magic_link->generate_magic_link($current_user_id, $blog_id, $admin_url); + + return $magic_link_url ?: get_admin_url($blog_id, $path, $scheme); +} + +/** + * Adds a magic link if needed. + * + * @param ?int $blog_id Blog id. + * + * @return false|string + */ +function wu_get_home_url($blog_id = null) { + $site = wu_get_site($blog_id); + $home_url = $site->get_active_site_url(); + $current_user_id = get_current_user_id(); + + if ( ! $current_user_id) { + return $home_url; + } + + $magic_link = \WP_Ultimo\SSO\Magic_Link::get_instance(); + + // Check if magic link is needed. + if ( ! $magic_link->site_needs_magic_link($blog_id) ) { + return $home_url; + } + + $magic_link_url = $magic_link->generate_magic_link($current_user_id, $blog_id, $home_url); + + return $magic_link_url ?: get_home_url($blog_id); +} diff --git a/inc/list-tables/class-customers-site-list-table.php b/inc/list-tables/class-customers-site-list-table.php index a151a11bd..827a80bc8 100644 --- a/inc/list-tables/class-customers-site-list-table.php +++ b/inc/list-tables/class-customers-site-list-table.php @@ -84,7 +84,7 @@ public function column_responsive($item): void { 'icon' => 'dashicons-wu-browser wu-align-middle wu-mr-1', 'label' => __('Go to the Dashboard', 'ultimate-multisite'), 'value' => __('Dashboard', 'ultimate-multisite'), - 'url' => get_admin_url($item->get_id()), + 'url' => wu_get_admin_url($item->get_id()), ], 'membership' => [ 'icon' => 'dashicons-wu-rotate-ccw wu-align-middle wu-mr-1', diff --git a/inc/list-tables/class-site-list-table.php b/inc/list-tables/class-site-list-table.php index 55cb7ffb3..84825ed51 100644 --- a/inc/list-tables/class-site-list-table.php +++ b/inc/list-tables/class-site-list-table.php @@ -112,7 +112,7 @@ public function column_cb($item): string { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Site $item Site object. + * @param \WP_Ultimo\Models\Site $item Site object. */ public function column_path($item): string { diff --git a/inc/managers/class-domain-manager.php b/inc/managers/class-domain-manager.php index 28e0be9ce..6cc19a346 100644 --- a/inc/managers/class-domain-manager.php +++ b/inc/managers/class-domain-manager.php @@ -539,6 +539,17 @@ public function add_sso_settings(): void { ], ] ); + + wu_register_settings_field( + 'sso', + 'enable_magic_links', + [ + 'title' => __('Enable Magic Links', 'ultimate-multisite'), + 'desc' => __('Enables magic link authentication for custom domains. Magic links provide a fallback authentication method for browsers that don\'t support third-party cookies. When enabled, dashboard and site links will automatically log users in when accessing sites with custom domains. Tokens are cryptographically secure, one-time use, and expire after 10 minutes.', 'ultimate-multisite'), + 'type' => 'toggle', + 'default' => 1, + ] + ); } /** diff --git a/inc/sso/class-admin-bar-magic-links.php b/inc/sso/class-admin-bar-magic-links.php new file mode 100644 index 000000000..aa5ea37fd --- /dev/null +++ b/inc/sso/class-admin-bar-magic-links.php @@ -0,0 +1,149 @@ +get_nodes() as $node) { + $parts = explode('-', $node->id); + if ('blog-' === $parts[0] && is_numeric($parts[1]) && '-d' === $parts[2]) { + $site_id = (int) $parts[1]; + } else { + continue; + } + + // Generate magic link. + $magic_link = wu_get_admin_url($site_id); + + if ( ! $magic_link ) { + continue; + } + + // Update the node with the magic link. + $node->href = $magic_link; + + $wp_admin_bar->add_node($node); + } + } + + /** + * Show access denied splash screen with magic links. + * + * This replaces the WordPress core access denied splash screen + * with our own version that uses magic links for sites with custom domains. + * + * @since 2.0.0 + * @return void + */ + public function show_access_denied_with_magic_links(): void { + + // Only run in multisite and if user is logged in. + if ( ! is_multisite() || ! is_user_logged_in() || is_network_admin() ) { + return; + } + + $blogs = get_blogs_of_user(get_current_user_id()); + + // If user has blogs and current blog is not in their list, show our custom message. + if ( wp_list_filter($blogs, array('userblog_id' => get_current_blog_id())) ) { + return; + } + + $blog_name = get_bloginfo('name'); + + if ( empty($blogs) ) { + wp_die( + sprintf( + /* translators: 1: Site title. */ + __('You attempted to access the "%1$s" dashboard, but you do not currently have privileges on this site. If you believe you should be able to access the "%1$s" dashboard, please contact your network administrator.'), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $blog_name // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + ), + 403 + ); + } + + $output = '

' . sprintf( + /* translators: 1: Site title. */ + __('You attempted to access the "%1$s" dashboard, but you do not currently have privileges on this site. If you believe you should be able to access the "%1$s" dashboard, please contact your network administrator.'), + $blog_name + ) . '

'; + $output .= '

' . __('If you reached this screen by accident and meant to visit one of your own sites, here are some shortcuts to help you find your way.') . '

'; + + $output .= '

' . __('Your Sites') . '

'; + $output .= ''; + + foreach ( $blogs as $blog ) { + $site_id = (int) $blog->userblog_id; + + // Get dashboard URL (with magic link if needed). + $dashboard_url = wu_get_admin_url($site_id); + + // Get home URL (with magic link if needed). + $home_url = wu_get_home_url($site_id); + + $output .= ''; + $output .= ''; + $output .= ''; + $output .= ''; + } + + $output .= '
' . esc_html($blog->blogname) . '' . __('Visit Dashboard') . ' | ' . + '' . __('View Site') . '
'; + + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output is escaped above with esc_url() and esc_html(). + wp_die($output, 403); + } +} diff --git a/inc/sso/class-magic-link.php b/inc/sso/class-magic-link.php new file mode 100644 index 000000000..231a1c41a --- /dev/null +++ b/inc/sso/class-magic-link.php @@ -0,0 +1,593 @@ +is_enabled() ) { + return false; + } + + // Verify user exists and has access to the site. + if ( ! $this->verify_user_site_access($user_id, $site_id) ) { + return false; + } + + // Generate secure token. + $token = $this->generate_token(); + + // Get user agent and IP for additional security. + $user_agent = $this->get_user_agent(); + $ip_address = $this->get_client_ip(); + + // Store token data with security context. + $token_data = array( + 'user_id' => $user_id, + 'site_id' => $site_id, + 'redirect_to' => $redirect_to, + 'created_at' => time(), + 'user_agent' => $user_agent, + 'ip_address' => $ip_address, + ); + + $transient_key = self::TRANSIENT_PREFIX . $token; + + wu_switch_blog_and_run( + fn() => set_transient($transient_key, $token_data, self::TOKEN_EXPIRATION) + ); + + $site = wu_get_site($site_id); + + // Build the magic link URL. + $site_url = $site->get_active_site_url(); + + $magic_link = add_query_arg( + array( + self::TOKEN_QUERY_VAR => $token, + ), + $site_url + ); + + /** + * Filter the generated magic link URL. + * + * @since 2.0.0 + * + * @param string $magic_link The magic link URL. + * @param int $user_id The user ID. + * @param int $site_id The site ID. + * @param string $redirect_to The redirect URL. + */ + return apply_filters('wu_magic_link_url', $magic_link, $user_id, $site_id, $redirect_to); + } + + /** + * Generate a cryptographically secure token. + * + * @since 2.0.0 + * @return string The generated token. + */ + protected function generate_token() { + + return bin2hex(random_bytes(32)); + } + + /** + * Verify that a user has access to a specific site. + * + * @since 2.0.0 + * + * @param int $user_id The user ID. + * @param int $site_id The site ID. + * @return bool True if user has access, false otherwise. + */ + protected function verify_user_site_access($user_id, $site_id) { + + $user = get_userdata($user_id); + + if ( ! $user ) { + return false; + } + + // Check if user is a member of the site. + return is_user_member_of_blog($user_id, $site_id); + } + + /** + * Handle magic link token verification and login. + * + * @since 2.0.0 + * @return void + */ + public function handle_magic_link(): void { + + $token = wu_request(self::TOKEN_QUERY_VAR); + + if ( empty($token) ) { + return; + } + + // Verify and consume the token. + $token_data = $this->verify_and_consume_token($token); + + if ( false === $token_data ) { + // Token is invalid, expired, or already used. + $this->handle_invalid_token(); + return; + } + + // Extract token data. + $user_id = $token_data['user_id']; + $site_id = $token_data['site_id']; + $redirect_to = $token_data['redirect_to']; + + // Verify we're on the correct site. + if ( get_current_blog_id() !== $site_id ) { + $this->handle_invalid_token('Wrong site for this token.'); + return; + } + + // Verify user still has access to the site. + if ( ! $this->verify_user_site_access($user_id, $site_id) ) { + $this->handle_invalid_token('User does not have access to this site.'); + return; + } + + // Log the user in. + wp_set_auth_cookie($user_id, true); + + /** + * Fires after a user is logged in via magic link. + * + * @since 2.0.0 + * + * @param int $user_id The user ID. + * @param int $site_id The site ID. + */ + do_action('wu_magic_link_login', $user_id, $site_id); + + if ( empty($redirect_to) ) { + return; + } + + // Remove the token from the URL and redirect. + $redirect_to = remove_query_arg(self::TOKEN_QUERY_VAR, $redirect_to); + + nocache_headers(); + + wp_safe_redirect($redirect_to); + + exit; + } + + /** + * Verify a token and mark it as used. + * + * @since 2.0.0 + * + * @param string $token The token to verify. + * @return array|false Token data on success, false on failure. + */ + protected function verify_and_consume_token($token) { + + $transient_key = self::TRANSIENT_PREFIX . $token; + + $token_data = wu_switch_blog_and_run( + fn() => get_transient($transient_key) + ); + + if ( false === $token_data ) { + wu_log_add('magic-link', sprintf('Token not found or expired: %s', $token)); + return false; + } + + // Verify security context (user agent and IP). + if ( ! $this->verify_security_context($token_data) ) { + wu_log_add('magic-link', sprintf('Security context mismatch for token: %s', $token)); + wu_switch_blog_and_run( + fn() => delete_transient($transient_key) + ); + return false; + } + + // Delete the transient to ensure one-time use. + wu_switch_blog_and_run( + fn() => delete_transient($transient_key) + ); + + // Log successful authentication for audit trail. + wu_log_add( + 'magic-link', + sprintf( + 'Successful magic link login for user %d to site %d from IP %s', + $token_data['user_id'], + $token_data['site_id'], + $this->get_client_ip() + ) + ); + + return $token_data; + } + + /** + * Handle invalid token scenario. + * + * @since 2.0.0 + * + * @param string $reason Optional. Reason for invalid token. + * @return void + */ + protected function handle_invalid_token($reason = '') { + + if ( $reason ) { + wu_log_add('magic-link', sprintf('Invalid token: %s', $reason)); + } + + /** + * Fires when an invalid magic link token is encountered. + * + * @since 2.0.0 + * + * @param string $reason The reason for the invalid token. + */ + do_action('wu_magic_link_invalid_token', $reason); + } + + /** + * Check if a site has a custom domain different from the main site. + * + * @since 2.0.0 + * + * @param int $site_id The site ID to check. + * @return bool True if site has a custom domain, false otherwise. + */ + public function site_needs_magic_link($site_id) { + + $site = wu_get_site($site_id); + + if ( ! $site ) { + return false; + } + + // Check if site has a primary mapped domain. + $primary_domain = $site->get_primary_mapped_domain(); + + if ( ! $primary_domain ) { + return false; + } + + // Get the main site domain. + $main_site_domain = wp_parse_url(get_site_url(wu_get_main_site_id()), PHP_URL_HOST); + + // Get the custom domain. + $custom_domain = $primary_domain->get_domain(); + + // If not a subdomain we need a magic link + return ! str_ends_with($custom_domain, $main_site_domain); + } + + /** + * Check if magic links are enabled. + * + * @since 2.0.0 + * @return bool True if enabled, false otherwise. + */ + protected function is_enabled() { + + /** + * Filter whether magic links are enabled. + * + * @since 2.0.0 + * + * @param bool $enabled Whether magic links are enabled. + */ + $enabled = apply_filters('wu_magic_links_enabled', wu_get_setting('enable_magic_links', true)); + + return (bool) $enabled; + } + + /** + * Verify security context (user agent and IP address). + * + * @since 2.0.0 + * + * @param array $token_data Token data containing security context. + * @return bool True if security context matches, false otherwise. + */ + protected function verify_security_context($token_data) { + + // Get current user agent and IP. + $current_user_agent = $this->get_user_agent(); + $current_ip = $this->get_client_ip(); + + // Get stored values. + $stored_user_agent = $token_data['user_agent'] ?? ''; + $stored_ip = $token_data['ip_address'] ?? ''; + + /** + * Filter whether to enforce user agent verification. + * + * Set to false to allow tokens to work across different browsers/devices. + * This reduces security but increases usability. + * + * @since 2.0.0 + * + * @param bool $enforce Whether to enforce user agent matching. + */ + $enforce_user_agent = apply_filters('wu_magic_link_enforce_user_agent', true); + + /** + * Filter whether to enforce IP address verification. + * + * Set to false to allow tokens to work from different networks. + * This reduces security but increases usability (e.g., for mobile users switching networks). + * + * @since 2.0.0 + * + * @param bool $enforce Whether to enforce IP address matching. + */ + $enforce_ip = apply_filters('wu_magic_link_enforce_ip', false); + + // Verify user agent if enforced. + if ( $enforce_user_agent && $stored_user_agent !== $current_user_agent ) { + wu_log_add( + 'magic-link', + sprintf( + 'User agent mismatch. Expected: %s, Got: %s', + $stored_user_agent, + $current_user_agent + ) + ); + return false; + } + + // Verify IP address if enforced. + if ( $enforce_ip && $stored_ip !== $current_ip ) { + wu_log_add( + 'magic-link', + sprintf( + 'IP address mismatch. Expected: %s, Got: %s', + $stored_ip, + $current_ip + ) + ); + return false; + } + + return true; + } + + /** + * Get the current user's user agent. + * + * @since 2.0.0 + * @return string The user agent string. + */ + protected function get_user_agent() { + + return isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : ''; + } + + /** + * Get the client's IP address. + * + * Checks for proxies and load balancers. + * + * @since 2.0.0 + * @return string The client IP address. + */ + protected function get_client_ip() { + + $ip = ''; + + // Check for proxied IP addresses. + $headers = array( + 'HTTP_CF_CONNECTING_IP', // Cloudflare. + 'HTTP_X_FORWARDED_FOR', // Common proxy header. + 'HTTP_X_REAL_IP', // Nginx proxy. + 'REMOTE_ADDR', // Direct connection. + ); + + foreach ( $headers as $header ) { + if ( ! empty($_SERVER[ $header ]) ) { + $ip = sanitize_text_field(wp_unslash($_SERVER[ $header ])); + + // X-Forwarded-For may contain multiple IPs, take the first one. + if ( strpos($ip, ',') !== false ) { + $ip_list = explode(',', $ip); + $ip = trim($ip_list[0]); + } + + // Validate IP address. + if ( filter_var($ip, FILTER_VALIDATE_IP) ) { + break; + } + } + } + + return $ip; + } + + /** + * Maybe convert a URL to a magic link. + * + * This method is hooked into the wp_frontend_admin/my_site_url filter + * to convert URLs to magic links when accessing sites with custom domains. + * + * @since 2.0.0 + * + * @param string $url The URL to potentially convert. + * @return string The magic link URL or original URL. + */ + public function maybe_convert_to_magic_link($url) { + + // If not enabled, return original URL. + if ( ! $this->is_enabled() ) { + return $url; + } + + // Get current user ID. + $current_user_id = get_current_user_id(); + + if ( ! $current_user_id ) { + return $url; + } + + // Try to extract site ID from URL if not provided. + $site_id = $this->extract_site_id_from_url($url); + + if ( ! $site_id ) { + return $url; + } + + // Check if this site needs a magic link. + if ( ! $this->site_needs_magic_link($site_id) ) { + return $url; + } + + // Generate magic link with the original URL as redirect target. + $magic_link = $this->generate_magic_link($current_user_id, $site_id, $url); + + return $magic_link ?: $url; + } + + /** + * Extract site ID from a URL. + * + * Attempts to determine which site a URL belongs to by parsing the domain. + * + * @since 2.0.0 + * + * @param string $url The URL to parse. + * @return int|null The site ID or null if not found. + */ + protected function extract_site_id_from_url($url) { + + $parsed_url = wp_parse_url($url); + + if ( ! isset($parsed_url['host']) ) { + return null; + } + + $host = $parsed_url['host']; + + // Try to find a domain mapping for this host. + $domain = wu_get_domain_by_domain($host); + + if ( $domain ) { + return $domain->get_blog_id(); + } + + // Try to get site by domain (for subdomain/subdirectory installs). + $site = get_site_by_path($host, isset($parsed_url['path']) ? $parsed_url['path'] : '/'); + + if ( $site ) { + return $site->blog_id; + } + + return null; + } +} diff --git a/inc/sso/class-sso.php b/inc/sso/class-sso.php index 26fa16208..c4ad08b3f 100644 --- a/inc/sso/class-sso.php +++ b/inc/sso/class-sso.php @@ -605,8 +605,8 @@ public function add_additional_origins($allowed_origins) { ); foreach ($domains as $domain) { - $additional_domains[] = "http://{$domain->get_domain()}"; - $additional_domains[] = "https://{$domain->get_domain()}"; + $additional_domains[] = "http://{$domain}"; + $additional_domains[] = "https://{$domain}"; } } diff --git a/inc/ui/class-my-sites-element.php b/inc/ui/class-my-sites-element.php index 29f6d8bf1..ddb15310b 100644 --- a/inc/ui/class-my-sites-element.php +++ b/inc/ui/class-my-sites-element.php @@ -402,7 +402,7 @@ function ($user_sites, $wp_site) use ($customer_sites) { public function get_manage_url($site_id, $type = 'default', $custom_page_id = 0) { if ('wp_admin' === $type) { - return get_admin_url($site_id); + return wu_get_admin_url($site_id); } if ('custom_page' === $type) { From 91d1761a6e071746b7282ae7fffe471498e233e9 Mon Sep 17 00:00:00 2001 From: David Stone Date: Sat, 8 Nov 2025 00:06:20 -0700 Subject: [PATCH 06/17] fix infinite loop --- inc/models/class-domain.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/inc/models/class-domain.php b/inc/models/class-domain.php index f3a1bc15c..7465af4cf 100644 --- a/inc/models/class-domain.php +++ b/inc/models/class-domain.php @@ -236,14 +236,12 @@ public function get_site() { */ public function get_path() { if (! isset($this->path)) { - $site = $this->get_site(); + // don't use $this->get_site() as it causes infinite loop and native is faster anyway. + $site = \WP_Site::get_instance($this->get_blog_id()); if ( ! $site) { return null; - } elseif ($site instanceof \WP_Site) { - $this->path = $site->path; - } elseif ($site instanceof Site) { - $this->path = $site->get_path(); } + $this->path = $site->path; } return $this->path; From cb70b44dfaf57cb5f15f0d5c0b6315dbe53ec882 Mon Sep 17 00:00:00 2001 From: David Stone Date: Sat, 8 Nov 2025 00:06:39 -0700 Subject: [PATCH 07/17] fix broken api --- inc/class-api.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/inc/class-api.php b/inc/class-api.php index 953623236..b651d84a8 100644 --- a/inc/class-api.php +++ b/inc/class-api.php @@ -368,7 +368,7 @@ public function maybe_log_api_call($request): void { * @param string|array $handler The callback. * @param \WP_REST_Request $request The request object. * - * @return mixed + * @return void */ public function log_api_errors($result, $handler, $request) { @@ -389,7 +389,6 @@ public function log_api_errors($result, $handler, $request) { wu_log_add('api-errors', $result); } - return $result; } /** From 27d72bc0076faef0882811ebf5332da6d2c061ab Mon Sep 17 00:00:00 2001 From: David Stone Date: Sat, 8 Nov 2025 00:20:16 -0700 Subject: [PATCH 08/17] finish mcp --- inc/api/trait-mcp-abilities.php | 317 +++++++++++++++++++++----------- inc/class-mcp-adapter.php | 169 +++++++++++++++-- 2 files changed, 355 insertions(+), 131 deletions(-) diff --git a/inc/api/trait-mcp-abilities.php b/inc/api/trait-mcp-abilities.php index 2baab5199..33db13f19 100644 --- a/inc/api/trait-mcp-abilities.php +++ b/inc/api/trait-mcp-abilities.php @@ -46,7 +46,7 @@ trait MCP_Abilities { */ public function get_mcp_ability_prefix(): string { - return 'wu_' . $this->slug; + return 'multisite-ultimate/' . str_replace('_', '-', $this->slug); } /** @@ -64,11 +64,60 @@ public function enable_mcp_abilities(): void { return; } - if (! function_exists('register_ability')) { + if (! function_exists('wp_register_ability')) { return; } - add_action('wu_mcp_adapter_initialized', [$this, 'register_abilities']); + add_action('wp_abilities_api_categories_init', [$this, 'register_ability_category']); + add_action('wp_abilities_api_init', [$this, 'register_abilities']); + } + + /** + * Register the ability category for this entity. + * + * @since 2.5.0 + * @return void + */ + public function register_ability_category(): void { + + if (! function_exists('wp_register_ability_category')) { + return; + } + + if (wp_has_ability_category('multisite-ultimate')) { + return; + } + wp_register_ability_category( + 'multisite-ultimate', + [ + 'label' => __('Multisite Ultimate', 'ultimate-multisite'), + 'description' => __('CRUD operations for Multisite Ultimate entities including customers, sites, products, memberships, and more.', 'ultimate-multisite'), + ] + ); + } + + /** + * Permission callback for MCP abilities. + * Checks if the current user has the required capabilities. + * + * @since 2.5.0 + * @param array $input_data The input data passed to the ability. + * @return bool|\WP_Error True if user has permission, WP_Error otherwise. + */ + public function mcp_permission_callback(array $input_data) { + unset($input_data); + + $capability = "wu_read_{$this->slug}"; + + if (! current_user_can($capability) && ! current_user_can('manage_network')) { + return new \WP_Error( + 'rest_forbidden', + __('You do not have permission to access this resource.', 'ultimate-multisite'), + ['status' => 403] + ); + } + + return true; } /** @@ -124,29 +173,41 @@ public function register_abilities(): void { */ protected function register_get_item_ability(string $ability_prefix, string $display_name): void { - register_ability( - "{$ability_prefix}_get_item", + wp_register_ability( + "$ability_prefix-get-item", [ // translators: %s: entity name (e.g., Customer, Site, Product) - 'label' => sprintf(__('Get %s by ID', 'ultimate-multisite'), $display_name), + 'label' => sprintf(__('Get %s by ID', 'ultimate-multisite'), $display_name), // translators: %s: entity name (e.g., customer, site, product) - 'description' => sprintf(__('Retrieve a single %s by its ID', 'ultimate-multisite'), strtolower($display_name)), - 'callback' => [$this, 'mcp_get_item'], - 'inputs' => [ - 'id' => [ - 'label' => __('ID', 'ultimate-multisite'), - // translators: %s: entity name (e.g., customer, site, product) - 'description' => sprintf(__('The ID of the %s to retrieve', 'ultimate-multisite'), strtolower($display_name)), - 'type' => 'integer', - 'required' => true, + 'description' => sprintf(__('Retrieve a single %s by its ID', 'ultimate-multisite'), strtolower($display_name)), + 'category' => 'multisite-ultimate', + 'execute_callback' => [$this, 'mcp_get_item'], + 'permission_callback' => [$this, 'mcp_permission_callback'], + 'input_schema' => [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('The ID of the %s to retrieve', 'ultimate-multisite'), strtolower($display_name)), + 'type' => 'integer', + ], + ], + 'required' => ['id'], + ], + 'output_schema' => [ + 'type' => 'object', + 'properties' => [ + 'item' => [ + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('The %s object', 'ultimate-multisite'), strtolower($display_name)), + 'type' => 'object', + ], ], ], - 'outputs' => [ - 'item' => [ - 'label' => $display_name, - // translators: %s: entity name (e.g., customer, site, product) - 'description' => sprintf(__('The %s object', 'ultimate-multisite'), strtolower($display_name)), - 'type' => 'object', + 'meta' => [ + 'mcp' => [ + 'public' => true, // Expose via MCP (required for MCP access) + 'type' => 'tool', ], ], ] @@ -163,41 +224,46 @@ protected function register_get_item_ability(string $ability_prefix, string $dis */ protected function register_get_items_ability(string $ability_prefix, string $display_name): void { - register_ability( - "{$ability_prefix}_get_items", + wp_register_ability( + "$ability_prefix-get-items", [ // translators: %s: entity name (e.g., Customer, Site, Product) - 'label' => sprintf(__('List %s', 'ultimate-multisite'), $display_name), + 'label' => sprintf(__('List %s', 'ultimate-multisite'), $display_name), // translators: %s: entity name (e.g., customer, site, product) - 'description' => sprintf(__('Retrieve a list of %s with optional filters', 'ultimate-multisite'), strtolower($display_name)), - 'callback' => [$this, 'mcp_get_items'], - 'inputs' => [ - 'per_page' => [ - 'label' => __('Per Page', 'ultimate-multisite'), - 'description' => __('Number of items to retrieve per page', 'ultimate-multisite'), - 'type' => 'integer', - 'required' => false, - 'default' => 10, - ], - 'page' => [ - 'label' => __('Page', 'ultimate-multisite'), - 'description' => __('Page number to retrieve', 'ultimate-multisite'), - 'type' => 'integer', - 'required' => false, - 'default' => 1, + 'description' => sprintf(__('Retrieve a list of %s with optional filters', 'ultimate-multisite'), strtolower($display_name)), + 'category' => 'multisite-ultimate', + 'execute_callback' => [$this, 'mcp_get_items'], + 'permission_callback' => [$this, 'mcp_permission_callback'], + 'input_schema' => [ + 'type' => 'object', + 'properties' => [ + 'per_page' => [ + 'description' => __('Number of items to retrieve per page', 'ultimate-multisite'), + 'type' => 'integer', + 'default' => 10, + ], + 'page' => [ + 'description' => __('Page number to retrieve', 'ultimate-multisite'), + 'type' => 'integer', + 'default' => 1, + ], ], ], - 'outputs' => [ - 'items' => [ - 'label' => $display_name, - // translators: %s: entity name (e.g., customer, site, product) - 'description' => sprintf(__('Array of %s objects', 'ultimate-multisite'), strtolower($display_name)), - 'type' => 'array', - ], - 'total' => [ - 'label' => __('Total', 'ultimate-multisite'), - 'description' => __('Total number of items', 'ultimate-multisite'), - 'type' => 'integer', + 'output_schema' => [ + 'type' => 'object', + 'properties' => [ + 'items' => [ + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('Array of %s objects', 'ultimate-multisite'), strtolower($display_name)), + 'type' => 'array', + 'items' => [ + 'type' => 'object', + ], + ], + 'total' => [ + 'description' => __('Total number of items', 'ultimate-multisite'), + 'type' => 'integer', + ], ], ], ] @@ -214,23 +280,27 @@ protected function register_get_items_ability(string $ability_prefix, string $di */ protected function register_create_item_ability(string $ability_prefix, string $display_name): void { - $schema = $this->get_mcp_schema_for_ability('create'); + $input_schema = $this->get_mcp_schema_for_ability('create'); - register_ability( - "{$ability_prefix}_create_item", + wp_register_ability( + "$ability_prefix-create-item", [ // translators: %s: entity name (e.g., Customer, Site, Product) - 'label' => sprintf(__('Create %s', 'ultimate-multisite'), $display_name), + 'label' => sprintf(__('Create %s', 'ultimate-multisite'), $display_name), // translators: %s: entity name (e.g., customer, site, product) - 'description' => sprintf(__('Create a new %s', 'ultimate-multisite'), strtolower($display_name)), - 'callback' => [$this, 'mcp_create_item'], - 'inputs' => $schema, - 'outputs' => [ - 'item' => [ - 'label' => $display_name, - // translators: %s: entity name (e.g., customer, site, product) - 'description' => sprintf(__('The created %s object', 'ultimate-multisite'), strtolower($display_name)), - 'type' => 'object', + 'description' => sprintf(__('Create a new %s', 'ultimate-multisite'), strtolower($display_name)), + 'category' => 'multisite-ultimate', + 'execute_callback' => [$this, 'mcp_create_item'], + 'permission_callback' => [$this, 'mcp_permission_callback'], + 'input_schema' => $input_schema, + 'output_schema' => [ + 'type' => 'object', + 'properties' => [ + 'item' => [ + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('The created %s object', 'ultimate-multisite'), strtolower($display_name)), + 'type' => 'object', + ], ], ], ] @@ -247,35 +317,41 @@ protected function register_create_item_ability(string $ability_prefix, string $ */ protected function register_update_item_ability(string $ability_prefix, string $display_name): void { - $schema = $this->get_mcp_schema_for_ability('update'); + $input_schema = $this->get_mcp_schema_for_ability('update'); + + // Add ID to the properties + $input_schema['properties']['id'] = [ + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('The ID of the %s to update', 'ultimate-multisite'), strtolower($display_name)), + 'type' => 'integer', + ]; - register_ability( - "{$ability_prefix}_update_item", + // Add ID to required fields + if (! isset($input_schema['required'])) { + $input_schema['required'] = []; + } + $input_schema['required'][] = 'id'; + + wp_register_ability( + "$ability_prefix-update-item", [ // translators: %s: entity name (e.g., Customer, Site, Product) - 'label' => sprintf(__('Update %s', 'ultimate-multisite'), $display_name), + 'label' => sprintf(__('Update %s', 'ultimate-multisite'), $display_name), // translators: %s: entity name (e.g., customer, site, product) - 'description' => sprintf(__('Update an existing %s', 'ultimate-multisite'), strtolower($display_name)), - 'callback' => [$this, 'mcp_update_item'], - 'inputs' => array_merge( - [ - 'id' => [ - 'label' => __('ID', 'ultimate-multisite'), + 'description' => sprintf(__('Update an existing %s', 'ultimate-multisite'), strtolower($display_name)), + 'category' => 'multisite-ultimate', + 'execute_callback' => [$this, 'mcp_update_item'], + 'permission_callback' => [$this, 'mcp_permission_callback'], + 'input_schema' => $input_schema, + 'output_schema' => [ + 'type' => 'object', + 'properties' => [ + 'item' => [ // translators: %s: entity name (e.g., customer, site, product) - 'description' => sprintf(__('The ID of the %s to update', 'ultimate-multisite'), strtolower($display_name)), - 'type' => 'integer', - 'required' => true, + 'description' => sprintf(__('The updated %s object', 'ultimate-multisite'), strtolower($display_name)), + 'type' => 'object', ], ], - $schema - ), - 'outputs' => [ - 'item' => [ - 'label' => $display_name, - // translators: %s: entity name (e.g., customer, site, product) - 'description' => sprintf(__('The updated %s object', 'ultimate-multisite'), strtolower($display_name)), - 'type' => 'object', - ], ], ] ); @@ -291,28 +367,34 @@ protected function register_update_item_ability(string $ability_prefix, string $ */ protected function register_delete_item_ability(string $ability_prefix, string $display_name): void { - register_ability( - "{$ability_prefix}_delete_item", + wp_register_ability( + "$ability_prefix-delete-item", [ // translators: %s: entity name (e.g., Customer, Site, Product) - 'label' => sprintf(__('Delete %s', 'ultimate-multisite'), $display_name), + 'label' => sprintf(__('Delete %s', 'ultimate-multisite'), $display_name), // translators: %s: entity name (e.g., customer, site, product) - 'description' => sprintf(__('Delete a %s by its ID', 'ultimate-multisite'), strtolower($display_name)), - 'callback' => [$this, 'mcp_delete_item'], - 'inputs' => [ - 'id' => [ - 'label' => __('ID', 'ultimate-multisite'), - // translators: %s: entity name (e.g., customer, site, product) - 'description' => sprintf(__('The ID of the %s to delete', 'ultimate-multisite'), strtolower($display_name)), - 'type' => 'integer', - 'required' => true, + 'description' => sprintf(__('Delete a %s by its ID', 'ultimate-multisite'), strtolower($display_name)), + 'category' => 'multisite-ultimate', + 'execute_callback' => [$this, 'mcp_delete_item'], + 'permission_callback' => [$this, 'mcp_permission_callback'], + 'input_schema' => [ + 'type' => 'object', + 'properties' => [ + 'id' => [ + // translators: %s: entity name (e.g., customer, site, product) + 'description' => sprintf(__('The ID of the %s to delete', 'ultimate-multisite'), strtolower($display_name)), + 'type' => 'integer', + ], ], + 'required' => ['id'], ], - 'outputs' => [ - 'success' => [ - 'label' => __('Success', 'ultimate-multisite'), - 'description' => __('Whether the deletion was successful', 'ultimate-multisite'), - 'type' => 'boolean', + 'output_schema' => [ + 'type' => 'object', + 'properties' => [ + 'success' => [ + 'description' => __('Whether the deletion was successful', 'ultimate-multisite'), + 'type' => 'boolean', + ], ], ], ] @@ -321,6 +403,7 @@ protected function register_delete_item_ability(string $ability_prefix, string $ /** * Get MCP schema for an ability (create or update). + * Returns JSON Schema format for input_schema. * * @since 2.5.0 * @param string $context The context (create or update). @@ -334,26 +417,38 @@ protected function get_mcp_schema_for_ability(string $context = 'create'): array $rest_schema = $this->get_arguments_schema('update' === $context); - $mcp_schema = []; + $properties = []; + $required = []; foreach ($rest_schema as $key => $args) { - $mcp_schema[ $key ] = [ - 'label' => $args['description'] ?? ucfirst(str_replace('_', ' ', $key)), - 'description' => $args['description'] ?? '', + $properties[ $key ] = [ + 'description' => $args['description'] ?? ucfirst(str_replace('_', ' ', $key)), 'type' => $args['type'] ?? 'string', - 'required' => $args['required'] ?? false, ]; if (isset($args['default'])) { - $mcp_schema[ $key ]['default'] = $args['default']; + $properties[ $key ]['default'] = $args['default']; } if (isset($args['enum'])) { - $mcp_schema[ $key ]['enum'] = $args['enum']; + $properties[ $key ]['enum'] = $args['enum']; + } + + if (isset($args['required']) && $args['required']) { + $required[] = $key; } } - return $mcp_schema; + $schema = [ + 'type' => 'object', + 'properties' => $properties, + ]; + + if (! empty($required)) { + $schema['required'] = $required; + } + + return $schema; } /** diff --git a/inc/class-mcp-adapter.php b/inc/class-mcp-adapter.php index bdf68a46a..1e11cb3bb 100644 --- a/inc/class-mcp-adapter.php +++ b/inc/class-mcp-adapter.php @@ -9,7 +9,9 @@ namespace WP_Ultimo; +use Psr\Log\LogLevel; use WP\MCP\Core\McpAdapter as McpAdapterCore; +use WP\MCP\Transport\HttpTransport; // Exit if accessed directly defined('ABSPATH') || exit; @@ -33,7 +35,9 @@ class MCP_Adapter implements \WP_Ultimo\Interfaces\Singleton { * @since 2.5.0 * @var McpAdapterCore|null */ - private ?McpAdapterCore $adapter = null; + private $adapter = null; + + private \WP_REST_Request $current_request; /** * Initiates the MCP adapter hooks. @@ -80,6 +84,7 @@ public function initialize_adapter(): void { } try { + add_action('mcp_adapter_init', array($this, 'initialize_mcp_server')); $this->adapter = McpAdapterCore::instance(); /** @@ -103,6 +108,59 @@ public function initialize_adapter(): void { } } + /** + * Init MCP. + * + * @return void + */ + public function initialize_mcp_server(): void { + $abilities_ids = $this->get_mcp_abilities(); + + // Bail if no abilities are available. + if ( empty($abilities_ids) ) { + return; + } + + add_filter('rest_pre_dispatch', [$this, 'rest_pre_dispatch_save_request'], 10, 3); + + /* + * Temporarily disable MCP validation during server creation. + * Workaround for validator bug with union types (e.g., ["integer", "null"]). + * This will be removed once the mcp-adapter validator bug is fixed. + * + * @see https://github.com/WordPress/mcp-adapter/issues/47 + */ + add_filter('mcp_validation_enabled', '__return_false', 999); + + try { + // Create MCP server. + $this->adapter->create_server( + 'ultimate-multisite-server', + 'ultimate-multisite', + 'mcp-adapter', + __('Ultimate Multisite MCP Server', 'ultimate-multisite'), + __('AI-accessible Ultimate Multisite operations via MCP', 'ultimate-multisite'), + '1.0.0', + [HttpTransport::class], + \WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler::class, + \WP\MCP\Infrastructure\Observability\NullMcpObservabilityHandler::class, + $abilities_ids, + [], + [], + [$this, 'permission_callback'] + ); + } catch ( \Throwable $e ) { + wu_log_add( + 'mcp', + 'MCP server initialization failed: ' . $e->getMessage(), + LogLevel::ERROR + ); + } finally { + // Re-enable MCP validation immediately after server creation. + remove_filter('mcp_validation_enabled', '__return_false', 999); + } + } + /** * Add the admin interface to configure MCP adapter. * @@ -131,39 +189,31 @@ public function add_settings(): void { 'default' => 0, ] ); - wu_register_settings_field( 'api', - 'mcp_server_url', + 'mcp_serveer_urel', [ 'title' => __('MCP Server URL', 'ultimate-multisite'), - 'desc' => sprintf( - // translators: %s: the HTTP endpoint URL for the MCP server - __('HTTP endpoint: %s', 'ultimate-multisite'), - '' . rest_url('mcp/mcp-adapter-default-server') . '' - ), + 'desc' => '', 'tooltip' => __('This is the URL where the MCP server is accessible via HTTP.', 'ultimate-multisite'), - 'type' => 'note', - 'classes' => 'wu-text-gray-700 wu-text-xs', + 'copy' => true, + 'type' => 'text-display', + 'default' => rest_url('mcp/mcp-adapter-default-server'), 'require' => [ 'enable_mcp' => 1, ], ] ); - wu_register_settings_field( 'api', - 'mcp_stdio_command', + 'mcp_stdio_commande', [ 'title' => __('STDIO Command', 'ultimate-multisite'), - 'desc' => sprintf( - // translators: %s: the WP-CLI command to run the MCP server - __('Command: %s', 'ultimate-multisite'), - 'wp mcp-adapter serve --server=mcp-adapter-default-server --user=admin' - ), + 'desc' => '', 'tooltip' => __('This is the WP-CLI command to run the MCP server via STDIO transport.', 'ultimate-multisite'), - 'type' => 'note', - 'classes' => 'wu-text-gray-700 wu-text-xs', + 'copy' => true, + 'type' => 'text-display', + 'default' => 'wp mcp-adapter serve --server=mcp-adapter-default-server --user=admin', 'require' => [ 'enable_mcp' => 1, ], @@ -177,7 +227,7 @@ public function add_settings(): void { * @since 2.5.0 * @return McpAdapterCore|null */ - public function get_adapter(): ?McpAdapterCore { + public function get_adapter(): McpAdapterCore { return $this->adapter; } @@ -199,4 +249,83 @@ public function is_mcp_enabled(): bool { */ return apply_filters('wu_is_mcp_enabled', wu_get_setting('enable_mcp', false)); } + + + /** + * Get abilities for MCP server. + * + * Filters abilities to include only those with 'wu_' namespace by default, + * with a filter to allow inclusion of abilities from other namespaces. + * + * @return array Array of ability IDs for MCP server. + */ + private function get_mcp_abilities(): array { + + // Check if the abilities API is available. + if ( ! function_exists('wp_get_abilities') ) { + return array(); + } + + $all_abilities_ids = array_keys(wp_get_abilities()); + + // Filter abilities based on namespace and custom filter. + $mcp_abilities = array_filter( + $all_abilities_ids, + function ($ability_id) { + // Include Ultimate Multisite abilities by default. + $include = str_starts_with($ability_id, 'multisite-ultimate/'); + + // Allow filter to override inclusion decision. + /** + * Filter to override MCP ability inclusion decision. + * + * @since 2.4.8 + * @param bool $include Whether to include the ability. + * @param string $ability_id The ability ID. + */ + return apply_filters('wu_mcp_include_ability', $include, $ability_id); + } + ); + + // Re-index array. + return array_values($mcp_abilities); + } + + /** + * Use API Key. + * + * @param \WP_REST_Request|null $request The Request. + * + * @return bool|\WP_Error + */ + public function permission_callback(\WP_REST_Request $request = null) { + if ($request) { + $this->current_request = $request; + } + if (! $this->current_request) { + return new \WP_Error('no_request_object', 'Request Object lost', ['status' => 500]); + } + + $result = API::get_instance()->check_authorization($this->current_request); + + if ( ! $result) { + return new \WP_Error('invalid_api_key', 'Invalid API key', ['status' => 403]); + } + + return $result; + } + + /** + * For backwards compatibility we use pre dispatch to save the request object. + * + * @param \WP_REST_Response $result The result. + * @param \WP_REST_Server $server The server. + * @param \WP_REST_Request $request The request. + * + * @return mixed + */ + public function rest_pre_dispatch_save_request($result, $server, $request) { + $this->current_request = $request; + return $result; + } } From 58d7a7d2dcb881c94f2dc2cb5ef7d3ac365da1cb Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 10 Nov 2025 10:20:01 -0700 Subject: [PATCH 09/17] use 0 params --- inc/api/trait-mcp-abilities.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/inc/api/trait-mcp-abilities.php b/inc/api/trait-mcp-abilities.php index 33db13f19..2dfba3b2b 100644 --- a/inc/api/trait-mcp-abilities.php +++ b/inc/api/trait-mcp-abilities.php @@ -68,8 +68,8 @@ public function enable_mcp_abilities(): void { return; } - add_action('wp_abilities_api_categories_init', [$this, 'register_ability_category']); - add_action('wp_abilities_api_init', [$this, 'register_abilities']); + add_action('wp_abilities_api_categories_init', [$this, 'register_ability_category'], 10, 0); + add_action('wp_abilities_api_init', [$this, 'register_abilities'], 10, 0); } /** From 5eda5f39645bba80de83770e68c2b4814d7b2a84 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 10 Nov 2025 10:21:56 -0700 Subject: [PATCH 10/17] seed property --- inc/class-mcp-adapter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inc/class-mcp-adapter.php b/inc/class-mcp-adapter.php index 1e11cb3bb..09301a795 100644 --- a/inc/class-mcp-adapter.php +++ b/inc/class-mcp-adapter.php @@ -37,7 +37,7 @@ class MCP_Adapter implements \WP_Ultimo\Interfaces\Singleton { */ private $adapter = null; - private \WP_REST_Request $current_request; + private ?\WP_REST_Request $current_request = null; /** * Initiates the MCP adapter hooks. From ea67973d17c2746c8093d66e0693c1109bd4ee63 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 10 Nov 2025 10:23:39 -0700 Subject: [PATCH 11/17] allow nulls --- inc/class-mcp-adapter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inc/class-mcp-adapter.php b/inc/class-mcp-adapter.php index 09301a795..59fca6108 100644 --- a/inc/class-mcp-adapter.php +++ b/inc/class-mcp-adapter.php @@ -227,7 +227,7 @@ public function add_settings(): void { * @since 2.5.0 * @return McpAdapterCore|null */ - public function get_adapter(): McpAdapterCore { + public function get_adapter(): ?McpAdapterCore { return $this->adapter; } From d215bb8892f32c2ed7410786d6dc46eb68819499 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 10 Nov 2025 10:25:41 -0700 Subject: [PATCH 12/17] Update inc/sso/class-admin-bar-magic-links.php Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- inc/sso/class-admin-bar-magic-links.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inc/sso/class-admin-bar-magic-links.php b/inc/sso/class-admin-bar-magic-links.php index aa5ea37fd..598c311d1 100644 --- a/inc/sso/class-admin-bar-magic-links.php +++ b/inc/sso/class-admin-bar-magic-links.php @@ -59,7 +59,7 @@ public function modify_my_sites_menu($wp_admin_bar): void { // Process each node. foreach ($wp_admin_bar->get_nodes() as $node) { $parts = explode('-', $node->id); - if ('blog-' === $parts[0] && is_numeric($parts[1]) && '-d' === $parts[2]) { + if (count($parts) >= 3 && 'blog' === $parts[0] && is_numeric($parts[1]) && 'd' === $parts[2]) { $site_id = (int) $parts[1]; } else { continue; From 01eef2407d04dd55682935ae8bafc782497de5d4 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 10 Nov 2025 10:27:24 -0700 Subject: [PATCH 13/17] Update inc/functions/url.php Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- inc/functions/url.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/inc/functions/url.php b/inc/functions/url.php index 125ae8c81..08de312b3 100644 --- a/inc/functions/url.php +++ b/inc/functions/url.php @@ -137,6 +137,11 @@ function wu_get_admin_url($blog_id = null, $path = '', $scheme = 'admin') { */ function wu_get_home_url($blog_id = null) { $site = wu_get_site($blog_id); + + if (! $site) { + return get_home_url($blog_id); + } + $home_url = $site->get_active_site_url(); $current_user_id = get_current_user_id(); From 811b4ecf4a9b695955db1ce7f44aca9b278c2fdc Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 11 Nov 2025 13:12:10 -0700 Subject: [PATCH 14/17] fix test --- tests/WP_Ultimo/Models/Event_Test.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/WP_Ultimo/Models/Event_Test.php b/tests/WP_Ultimo/Models/Event_Test.php index 3e1947aae..d2e31c47b 100644 --- a/tests/WP_Ultimo/Models/Event_Test.php +++ b/tests/WP_Ultimo/Models/Event_Test.php @@ -317,7 +317,7 @@ public function test_author_methods_with_no_user() { $this->assertNull($this->event->get_author_user()); $this->assertEmpty($this->event->get_author_display_name()); - $this->assertEmtpy($this->event->get_author_email_address()); + $this->assertEmpty($this->event->get_author_email_address()); } /** From 578e543cdf3fb7c2a25a05160b6b7b2c084e4e09 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 11 Nov 2025 13:18:46 -0700 Subject: [PATCH 15/17] match directory to namespace --- inc/{api => apis}/class-register-endpoint.php | 0 inc/{api => apis}/schemas/broadcast-create.php | 0 inc/{api => apis}/schemas/broadcast-update.php | 0 inc/{api => apis}/schemas/checkout-form-create.php | 0 inc/{api => apis}/schemas/checkout-form-update.php | 0 inc/{api => apis}/schemas/customer-create.php | 0 inc/{api => apis}/schemas/customer-update.php | 0 inc/{api => apis}/schemas/discount-code-create.php | 0 inc/{api => apis}/schemas/discount-code-update.php | 0 inc/{api => apis}/schemas/domain-create.php | 0 inc/{api => apis}/schemas/domain-update.php | 0 inc/{api => apis}/schemas/email-create.php | 0 inc/{api => apis}/schemas/email-update.php | 0 inc/{api => apis}/schemas/event-create.php | 0 inc/{api => apis}/schemas/event-update.php | 0 inc/{api => apis}/schemas/membership-create.php | 0 inc/{api => apis}/schemas/membership-update.php | 0 inc/{api => apis}/schemas/payment-create.php | 0 inc/{api => apis}/schemas/payment-update.php | 0 inc/{api => apis}/schemas/product-create.php | 0 inc/{api => apis}/schemas/product-update.php | 0 inc/{api => apis}/schemas/site-create.php | 0 inc/{api => apis}/schemas/site-update.php | 0 inc/{api => apis}/schemas/webhook-create.php | 0 inc/{api => apis}/schemas/webhook-update.php | 0 inc/{api => apis}/trait-mcp-abilities.php | 0 inc/{api => apis}/trait-rest-api.php | 0 inc/{api => apis}/trait-wp-cli.php | 0 28 files changed, 0 insertions(+), 0 deletions(-) rename inc/{api => apis}/class-register-endpoint.php (100%) rename inc/{api => apis}/schemas/broadcast-create.php (100%) rename inc/{api => apis}/schemas/broadcast-update.php (100%) rename inc/{api => apis}/schemas/checkout-form-create.php (100%) rename inc/{api => apis}/schemas/checkout-form-update.php (100%) rename inc/{api => apis}/schemas/customer-create.php (100%) rename inc/{api => apis}/schemas/customer-update.php (100%) rename inc/{api => apis}/schemas/discount-code-create.php (100%) rename inc/{api => apis}/schemas/discount-code-update.php (100%) rename inc/{api => apis}/schemas/domain-create.php (100%) rename inc/{api => apis}/schemas/domain-update.php (100%) rename inc/{api => apis}/schemas/email-create.php (100%) rename inc/{api => apis}/schemas/email-update.php (100%) rename inc/{api => apis}/schemas/event-create.php (100%) rename inc/{api => apis}/schemas/event-update.php (100%) rename inc/{api => apis}/schemas/membership-create.php (100%) rename inc/{api => apis}/schemas/membership-update.php (100%) rename inc/{api => apis}/schemas/payment-create.php (100%) rename inc/{api => apis}/schemas/payment-update.php (100%) rename inc/{api => apis}/schemas/product-create.php (100%) rename inc/{api => apis}/schemas/product-update.php (100%) rename inc/{api => apis}/schemas/site-create.php (100%) rename inc/{api => apis}/schemas/site-update.php (100%) rename inc/{api => apis}/schemas/webhook-create.php (100%) rename inc/{api => apis}/schemas/webhook-update.php (100%) rename inc/{api => apis}/trait-mcp-abilities.php (100%) rename inc/{api => apis}/trait-rest-api.php (100%) rename inc/{api => apis}/trait-wp-cli.php (100%) diff --git a/inc/api/class-register-endpoint.php b/inc/apis/class-register-endpoint.php similarity index 100% rename from inc/api/class-register-endpoint.php rename to inc/apis/class-register-endpoint.php diff --git a/inc/api/schemas/broadcast-create.php b/inc/apis/schemas/broadcast-create.php similarity index 100% rename from inc/api/schemas/broadcast-create.php rename to inc/apis/schemas/broadcast-create.php diff --git a/inc/api/schemas/broadcast-update.php b/inc/apis/schemas/broadcast-update.php similarity index 100% rename from inc/api/schemas/broadcast-update.php rename to inc/apis/schemas/broadcast-update.php diff --git a/inc/api/schemas/checkout-form-create.php b/inc/apis/schemas/checkout-form-create.php similarity index 100% rename from inc/api/schemas/checkout-form-create.php rename to inc/apis/schemas/checkout-form-create.php diff --git a/inc/api/schemas/checkout-form-update.php b/inc/apis/schemas/checkout-form-update.php similarity index 100% rename from inc/api/schemas/checkout-form-update.php rename to inc/apis/schemas/checkout-form-update.php diff --git a/inc/api/schemas/customer-create.php b/inc/apis/schemas/customer-create.php similarity index 100% rename from inc/api/schemas/customer-create.php rename to inc/apis/schemas/customer-create.php diff --git a/inc/api/schemas/customer-update.php b/inc/apis/schemas/customer-update.php similarity index 100% rename from inc/api/schemas/customer-update.php rename to inc/apis/schemas/customer-update.php diff --git a/inc/api/schemas/discount-code-create.php b/inc/apis/schemas/discount-code-create.php similarity index 100% rename from inc/api/schemas/discount-code-create.php rename to inc/apis/schemas/discount-code-create.php diff --git a/inc/api/schemas/discount-code-update.php b/inc/apis/schemas/discount-code-update.php similarity index 100% rename from inc/api/schemas/discount-code-update.php rename to inc/apis/schemas/discount-code-update.php diff --git a/inc/api/schemas/domain-create.php b/inc/apis/schemas/domain-create.php similarity index 100% rename from inc/api/schemas/domain-create.php rename to inc/apis/schemas/domain-create.php diff --git a/inc/api/schemas/domain-update.php b/inc/apis/schemas/domain-update.php similarity index 100% rename from inc/api/schemas/domain-update.php rename to inc/apis/schemas/domain-update.php diff --git a/inc/api/schemas/email-create.php b/inc/apis/schemas/email-create.php similarity index 100% rename from inc/api/schemas/email-create.php rename to inc/apis/schemas/email-create.php diff --git a/inc/api/schemas/email-update.php b/inc/apis/schemas/email-update.php similarity index 100% rename from inc/api/schemas/email-update.php rename to inc/apis/schemas/email-update.php diff --git a/inc/api/schemas/event-create.php b/inc/apis/schemas/event-create.php similarity index 100% rename from inc/api/schemas/event-create.php rename to inc/apis/schemas/event-create.php diff --git a/inc/api/schemas/event-update.php b/inc/apis/schemas/event-update.php similarity index 100% rename from inc/api/schemas/event-update.php rename to inc/apis/schemas/event-update.php diff --git a/inc/api/schemas/membership-create.php b/inc/apis/schemas/membership-create.php similarity index 100% rename from inc/api/schemas/membership-create.php rename to inc/apis/schemas/membership-create.php diff --git a/inc/api/schemas/membership-update.php b/inc/apis/schemas/membership-update.php similarity index 100% rename from inc/api/schemas/membership-update.php rename to inc/apis/schemas/membership-update.php diff --git a/inc/api/schemas/payment-create.php b/inc/apis/schemas/payment-create.php similarity index 100% rename from inc/api/schemas/payment-create.php rename to inc/apis/schemas/payment-create.php diff --git a/inc/api/schemas/payment-update.php b/inc/apis/schemas/payment-update.php similarity index 100% rename from inc/api/schemas/payment-update.php rename to inc/apis/schemas/payment-update.php diff --git a/inc/api/schemas/product-create.php b/inc/apis/schemas/product-create.php similarity index 100% rename from inc/api/schemas/product-create.php rename to inc/apis/schemas/product-create.php diff --git a/inc/api/schemas/product-update.php b/inc/apis/schemas/product-update.php similarity index 100% rename from inc/api/schemas/product-update.php rename to inc/apis/schemas/product-update.php diff --git a/inc/api/schemas/site-create.php b/inc/apis/schemas/site-create.php similarity index 100% rename from inc/api/schemas/site-create.php rename to inc/apis/schemas/site-create.php diff --git a/inc/api/schemas/site-update.php b/inc/apis/schemas/site-update.php similarity index 100% rename from inc/api/schemas/site-update.php rename to inc/apis/schemas/site-update.php diff --git a/inc/api/schemas/webhook-create.php b/inc/apis/schemas/webhook-create.php similarity index 100% rename from inc/api/schemas/webhook-create.php rename to inc/apis/schemas/webhook-create.php diff --git a/inc/api/schemas/webhook-update.php b/inc/apis/schemas/webhook-update.php similarity index 100% rename from inc/api/schemas/webhook-update.php rename to inc/apis/schemas/webhook-update.php diff --git a/inc/api/trait-mcp-abilities.php b/inc/apis/trait-mcp-abilities.php similarity index 100% rename from inc/api/trait-mcp-abilities.php rename to inc/apis/trait-mcp-abilities.php diff --git a/inc/api/trait-rest-api.php b/inc/apis/trait-rest-api.php similarity index 100% rename from inc/api/trait-rest-api.php rename to inc/apis/trait-rest-api.php diff --git a/inc/api/trait-wp-cli.php b/inc/apis/trait-wp-cli.php similarity index 100% rename from inc/api/trait-wp-cli.php rename to inc/apis/trait-wp-cli.php From 36b7dd509bcec70b354f0b3b2d09c6ee9191ddad Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 11 Nov 2025 14:14:04 -0700 Subject: [PATCH 16/17] don't use use --- inc/admin-pages/class-list-admin-page.php | 6 ++-- inc/list-tables/class-base-list-table.php | 36 ++++++++++--------- .../class-checkout-form-list-table.php | 15 +------- .../class-discount-code-list-table.php | 13 ------- .../class-payment-list-table-widget.php | 8 +++-- inc/list-tables/class-webhook-list-table.php | 13 ------- .../class-product-list-table.php | 13 ------- .../customer-panel/class-site-list-table.php | 13 ------- 8 files changed, 27 insertions(+), 90 deletions(-) diff --git a/inc/admin-pages/class-list-admin-page.php b/inc/admin-pages/class-list-admin-page.php index 67e41b2bb..16d4e116b 100644 --- a/inc/admin-pages/class-list-admin-page.php +++ b/inc/admin-pages/class-list-admin-page.php @@ -15,8 +15,6 @@ namespace WP_Ultimo\Admin_Pages; // Exit if accessed directly -use WP_List_Table; - defined('ABSPATH') || exit; /** @@ -49,7 +47,7 @@ abstract class List_Admin_Page extends Base_Admin_Page { * Holds the WP_List_Table instance to be used on the list * * @since 1.8.2 - * @var WP_List_Table + * @var \WP_List_Table */ protected $table; @@ -219,7 +217,7 @@ public function save_screen_option($value, $option, $other_value) { * Dumb function. Child classes need to implement this to set the table that Ultimate Multisite will use * * @since 1.8.2 - * @return WP_List_Table + * @return \WP_List_Table */ public function get_table() { diff --git a/inc/list-tables/class-base-list-table.php b/inc/list-tables/class-base-list-table.php index 29838ab69..86f86ee8f 100644 --- a/inc/list-tables/class-base-list-table.php +++ b/inc/list-tables/class-base-list-table.php @@ -223,7 +223,7 @@ public function get_label($label = 'singular') { * @param integer $per_page Number of items to display per page. * @param integer $page_number Current page. * @param boolean $count If we should count records or return the actual records. - * @return array + * @return array|int */ public function get_items($per_page = 5, $page_number = 1, $count = false) { @@ -273,7 +273,7 @@ public function get_items($per_page = 5, $page_number = 1, $count = false) { * @param array $query_args The query args. * @return mixed */ - protected function _get_items($query_args) { + protected function _get_items($query_args) { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore $query_class = new $this->query_class(); @@ -518,10 +518,22 @@ public function display(): void { } } + /** + * Returns the filters for this page. + * + * @since 2.0.0 + */ + public function get_filters(): array { + + return [ + 'filters' => [], + 'date_filters' => [], + ]; + } + /** * Display the filters if they exist. * - * @todo: refator * @since 2.0.0 * @return void */ @@ -635,7 +647,7 @@ public function process_single_action() {} * Handles the bulk processing. * * @since 2.0.0 - * @return bool + * @return bool|\WP_Error */ public static function process_bulk_action() { @@ -824,7 +836,7 @@ public function column_default($item, $column_name) { * @param string $date Valid date to be used inside a strtotime call. * @return string */ - public function _column_datetime($date) { + public function _column_datetime($date) { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore if ( ! wu_validate_date($date)) { return __('--', 'ultimate-multisite'); @@ -1150,7 +1162,7 @@ public function column_cb($item) { * * @since 2.0.0 */ - public function _get_js_var_name(): string { + public function _get_js_var_name(): string { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore return str_replace('-', '_', $this->id); } @@ -1161,7 +1173,7 @@ public function _get_js_var_name(): string { * @since 2.0.0 * @return void */ - public function _js_vars(): void { + public function _js_vars(): void { // phpcs:ignore PSR2.Methods.MethodDeclaration.Underscore /** * Call the parent method for backwards compat. @@ -1305,16 +1317,6 @@ public function get_sortable_columns() { public function get_extra_fields() { return []; - - $_filter_fields = []; - - if (isset($filters['filters'])) { - foreach ($filters['filters'] as $field_name => $field) { - $_filter_fields[ $field_name ] = wu_request($field_name, ''); - } - } - - return $_filter_fields; } /** diff --git a/inc/list-tables/class-checkout-form-list-table.php b/inc/list-tables/class-checkout-form-list-table.php index 2311ca563..83d35b898 100644 --- a/inc/list-tables/class-checkout-form-list-table.php +++ b/inc/list-tables/class-checkout-form-list-table.php @@ -128,7 +128,7 @@ public function column_shortcode($item): string { __('Copy to the Clipboard', 'ultimate-multisite') ); - return sprintf('', esc_attr($item->get_shortcode()), ''); + return sprintf('', esc_attr($item->get_shortcode())); } /** @@ -203,17 +203,4 @@ public function get_columns() { return $columns; } - - /** - * Returns the filters for this page. - * - * @since 2.0.0 - */ - public function get_filters(): array { - - return [ - 'filters' => [], - 'date_filters' => [], - ]; - } } diff --git a/inc/list-tables/class-discount-code-list-table.php b/inc/list-tables/class-discount-code-list-table.php index a545e8967..49f69227a 100644 --- a/inc/list-tables/class-discount-code-list-table.php +++ b/inc/list-tables/class-discount-code-list-table.php @@ -196,17 +196,4 @@ public function get_columns() { return $columns; } - - /** - * Returns the filters for this page. - * - * @since 2.0.0 - */ - public function get_filters(): array { - - return [ - 'filters' => [], - 'date_filters' => [], - ]; - } } diff --git a/inc/list-tables/class-payment-list-table-widget.php b/inc/list-tables/class-payment-list-table-widget.php index b670fca95..cb9ccf71b 100644 --- a/inc/list-tables/class-payment-list-table-widget.php +++ b/inc/list-tables/class-payment-list-table-widget.php @@ -229,9 +229,11 @@ public function get_columns() { * Returns the filters for this page. * * @since 2.0.0 - * @return void. + * @return array */ - public function get_filters() {} + public function get_filters(): array { + return []; + } /** * Overrides the parent method to include the custom ajax functionality for Ultimate Multisite. @@ -239,5 +241,5 @@ public function get_filters() {} * @since 2.0.0 * @return void */ - public function _js_vars(): void {} + public function _js_vars(): void {} // phpcs:ignore PSR2 } diff --git a/inc/list-tables/class-webhook-list-table.php b/inc/list-tables/class-webhook-list-table.php index adb841520..258821d43 100644 --- a/inc/list-tables/class-webhook-list-table.php +++ b/inc/list-tables/class-webhook-list-table.php @@ -181,17 +181,4 @@ public function get_columns() { return $columns; } - - /** - * Returns the filters for this page. - * - * @since 2.0.0 - */ - public function get_filters(): array { - - return [ - 'filters' => [], - 'date_filters' => [], - ]; - } } diff --git a/inc/list-tables/customer-panel/class-product-list-table.php b/inc/list-tables/customer-panel/class-product-list-table.php index ad2eb73ef..a4b026a1c 100644 --- a/inc/list-tables/customer-panel/class-product-list-table.php +++ b/inc/list-tables/customer-panel/class-product-list-table.php @@ -48,19 +48,6 @@ public function get_columns() { return []; } - /** - * Resets the filters. - * - * @since 2.0.0 - */ - public function get_filters(): array { - - return [ - 'filters' => [], - 'date_filters' => [], - ]; - } - /** * Resets bulk actions. * diff --git a/inc/list-tables/customer-panel/class-site-list-table.php b/inc/list-tables/customer-panel/class-site-list-table.php index 2b88113d2..a9457ec17 100644 --- a/inc/list-tables/customer-panel/class-site-list-table.php +++ b/inc/list-tables/customer-panel/class-site-list-table.php @@ -48,19 +48,6 @@ public function get_columns() { return []; } - /** - * Clears filters. - * - * @since 2.0.0 - */ - public function get_filters(): array { - - return [ - 'filters' => [], - 'date_filters' => [], - ]; - } - /** * Clears views. * From ad3cecc8dab43af4c50f5655c37c4f0d23238065 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 11 Nov 2025 14:26:16 -0700 Subject: [PATCH 17/17] don't use use update docs --- inc/admin-pages/class-dashboard-admin-page.php | 2 +- inc/apis/class-register-endpoint.php | 2 +- inc/class-autoloader.php | 2 -- inc/country/class-country.php | 12 ++++++------ inc/functions/settings.php | 8 +++----- inc/helpers/class-validator.php | 6 +++--- .../host-providers/class-gridpane-host-provider.php | 6 ++---- inc/list-tables/class-checkout-form-list-table.php | 8 ++++---- inc/list-tables/class-discount-code-list-table.php | 10 +++++----- inc/list-tables/class-domain-list-table.php | 9 ++++----- .../class-membership-list-table-widget.php | 6 +++--- inc/list-tables/class-membership-list-table.php | 6 +++--- inc/list-tables/class-payment-list-table.php | 2 +- inc/list-tables/class-product-list-table.php | 12 ++++++------ inc/list-tables/class-site-list-table.php | 4 +--- 15 files changed, 43 insertions(+), 52 deletions(-) diff --git a/inc/admin-pages/class-dashboard-admin-page.php b/inc/admin-pages/class-dashboard-admin-page.php index bc7e38746..6679b6190 100644 --- a/inc/admin-pages/class-dashboard-admin-page.php +++ b/inc/admin-pages/class-dashboard-admin-page.php @@ -125,7 +125,7 @@ public function hooks(): void { * * @since 2.0.0 * - * @param WP_Ultimo\Admin_Pages\Base_Admin_Page $page The page object. + * @param \WP_Ultimo\Admin_Pages\Base_Admin_Page $page The page object. * @return void */ public function render_filter($page): void { diff --git a/inc/apis/class-register-endpoint.php b/inc/apis/class-register-endpoint.php index 085169142..fbd342eec 100644 --- a/inc/apis/class-register-endpoint.php +++ b/inc/apis/class-register-endpoint.php @@ -566,7 +566,7 @@ public function maybe_create_customer($p) { * * @param array $p The request parameters. * @param \WP_Ultimo\Models\Membership $membership The membership created. - * @return array|\WP_Ultimo\Models\Site\|\WP_Error + * @return array|\WP_Error */ public function maybe_create_site($p, $membership) { diff --git a/inc/class-autoloader.php b/inc/class-autoloader.php index 233d88a61..c8a1f442c 100644 --- a/inc/class-autoloader.php +++ b/inc/class-autoloader.php @@ -9,8 +9,6 @@ namespace WP_Ultimo; -use Pablo_Pacheco\WP_Namespace_Autoloader\WP_Namespace_Autoloader; - // Exit if accessed directly defined('ABSPATH') || exit; diff --git a/inc/country/class-country.php b/inc/country/class-country.php index aba3565d6..19db79d48 100644 --- a/inc/country/class-country.php +++ b/inc/country/class-country.php @@ -80,7 +80,7 @@ public function get_states() { * * @param array $states List of states in a XX => Name format. * @param string $country_code Two-letter ISO code for the country. - * @param WP_Ultimo\Country\Country $current_country Instance of the current class. + * @param \WP_Ultimo\Country\Country $current_country Instance of the current class. * @return array The filtered list of states. */ return apply_filters('wu_country_get_states', $states, $this->country_code, $this); @@ -140,7 +140,7 @@ public function get_cities($state_code = '') { * @param array $cities List of state city names. No keys are present. * @param string $country_code Two-letter ISO code for the country. * @param string $state_code Two-letter ISO code for the state. - * @param WP_Ultimo\Country\Country $current_country Instance of the current class. + * @param \WP_Ultimo\Country\Country $current_country Instance of the current class. * @return array The filtered list of states. */ return apply_filters('wu_country_get_cities', $cities, $this->country_code, $state_code, $this); @@ -238,14 +238,14 @@ public function get_administrative_division_name($state_code = null, $ucwords = /** * Returns nice name of the country administrative sub-divisions. * - * @since 2.0.11 - * * @param string $name The division name. Usually something like state, province, region, etc. * @param string $country_code Two-letter ISO code for the country. * @param string $state_code Two-letter ISO code for the state. - * @param WP_Ultimo\Country\Country $current_country Instance of the current class. - * @param bool $current_country Instance of the current class. + * @param bool $ucwords if we uppercase the words. + * @param \WP_Ultimo\Country\Country $current_country Instance of the current class. + * * @return string The modified division name. + *@since 2.0.11 */ return apply_filters('wu_country_get_administrative_division_name', $name, $this->country_code, $state_code, $ucwords, $this); } diff --git a/inc/functions/settings.php b/inc/functions/settings.php index 7dd0edcc5..fc5592859 100644 --- a/inc/functions/settings.php +++ b/inc/functions/settings.php @@ -6,8 +6,6 @@ * @since 2.0.0 */ -use WP_Ultimo\Dependencies\Intervention\Image\ImageManagerStatic as Image; - // Exit if accessed directly defined('ABSPATH') || exit; @@ -33,12 +31,12 @@ function wu_get_all_settings() { * @since 2.0.0 * * @param string $setting Settings name to return. - * @param mixed $default Default value for the setting if it doesn't exist. + * @param mixed $default_value Default value for the setting if it doesn't exist. * @return mixed The value of that setting */ -function wu_get_setting($setting, $default = false) { +function wu_get_setting($setting, $default_value = false) { - return WP_Ultimo()->settings->get_setting($setting, $default); + return WP_Ultimo()->settings->get_setting($setting, $default_value); } /** diff --git a/inc/helpers/class-validator.php b/inc/helpers/class-validator.php index 8f279eb57..48af911ba 100644 --- a/inc/helpers/class-validator.php +++ b/inc/helpers/class-validator.php @@ -36,7 +36,7 @@ class Validator { * Holds an instance of the validator object. * * @since 2.0.0 - * @var Rakit\Validation\Validator + * @var \Rakit\Validation\Validator */ protected $validator; @@ -44,7 +44,7 @@ class Validator { * Holds an instance of the validation being performed. * * @since 2.0.0 - * @var Rakit\Validation\Validation + * @var \Rakit\Validation\Validation */ protected $validation; @@ -181,7 +181,7 @@ protected function cast_to_wp_error($errors) { * Get holds an instance of the validation being performed. * * @since 2.0.0 - * @return Rakit\Validation\Validation + * @return \Rakit\Validation\Validation */ public function get_validation() { diff --git a/inc/integrations/host-providers/class-gridpane-host-provider.php b/inc/integrations/host-providers/class-gridpane-host-provider.php index 9d5d8808b..8cb375dc6 100644 --- a/inc/integrations/host-providers/class-gridpane-host-provider.php +++ b/inc/integrations/host-providers/class-gridpane-host-provider.php @@ -9,8 +9,6 @@ namespace WP_Ultimo\Integrations\Host_Providers; -use WP_Ultimo\Integrations\Host_Providers\Base_Host_Provider; - // Exit if accessed directly defined('ABSPATH') || exit; @@ -137,7 +135,7 @@ public function send_gridpane_api_request($endpoint, $data = [], $method = 'POST * @since 2.0.0 * @param string $domain The domain name being mapped. * @param int $site_id ID of the site that is receiving that mapping. - * @return object\WP_Error + * @return object|\WP_Error */ public function on_add_domain($domain, $site_id) { @@ -157,7 +155,7 @@ public function on_add_domain($domain, $site_id) { * @since 2.0.0 * @param string $domain The domain name being removed. * @param int $site_id ID of the site that is receiving that mapping. - * @return object\WP_Error + * @return object|\WP_Error */ public function on_remove_domain($domain, $site_id) { diff --git a/inc/list-tables/class-checkout-form-list-table.php b/inc/list-tables/class-checkout-form-list-table.php index 83d35b898..d254d5aaa 100644 --- a/inc/list-tables/class-checkout-form-list-table.php +++ b/inc/list-tables/class-checkout-form-list-table.php @@ -52,7 +52,7 @@ public function __construct() { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Checkout_Form $item Checkout Form object. + * @param \WP_Ultimo\Models\Checkout_Form $item Checkout Form object. */ public function column_name($item): string { @@ -89,7 +89,7 @@ public function column_name($item): string { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Checkout_Form $item Checkout Form object. + * @param \WP_Ultimo\Models\Checkout_Form $item Checkout Form object. * @return string */ public function column_slug($item) { @@ -104,7 +104,7 @@ public function column_slug($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Checkout_Form $item Checkout Form object. + * @param \WP_Ultimo\Models\Checkout_Form $item Checkout Form object. */ public function column_steps($item): string { // translators: %1$d: number of steps, %2$d: number of fields @@ -116,7 +116,7 @@ public function column_steps($item): string { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Checkout_Form $item Checkout Form object. + * @param \WP_Ultimo\Models\Checkout_Form $item Checkout Form object. */ public function column_shortcode($item): string { diff --git a/inc/list-tables/class-discount-code-list-table.php b/inc/list-tables/class-discount-code-list-table.php index 49f69227a..5d5dee308 100644 --- a/inc/list-tables/class-discount-code-list-table.php +++ b/inc/list-tables/class-discount-code-list-table.php @@ -52,7 +52,7 @@ public function __construct() { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Discount_Code $item Discount_Code object. + * @param \WP_Ultimo\Models\Discount_Code $item Discount_Code object. */ public function column_name($item): string { @@ -84,7 +84,7 @@ public function column_name($item): string { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Discount_Code $item Discount_Code object. + * @param \WP_Ultimo\Models\Discount_Code $item Discount_Code object. * * @return string */ @@ -109,7 +109,7 @@ public function column_value($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Discount_Code $item Discount_Code object. + * @param \WP_Ultimo\Models\Discount_Code $item Discount_Code object. * * @return string */ @@ -134,7 +134,7 @@ public function column_setup_fee_value($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Discount_Code $item Discount_Code object. + * @param \WP_Ultimo\Models\Discount_Code $item Discount_Code object. * @return string */ public function column_uses($item) { @@ -158,7 +158,7 @@ public function column_uses($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Discount_Code $item Discount_Code object. + * @param \WP_Ultimo\Models\Discount_Code $item Discount_Code object. * @return string */ public function column_coupon_code($item) { diff --git a/inc/list-tables/class-domain-list-table.php b/inc/list-tables/class-domain-list-table.php index f81bc3e73..199105ba5 100644 --- a/inc/list-tables/class-domain-list-table.php +++ b/inc/list-tables/class-domain-list-table.php @@ -9,7 +9,6 @@ namespace WP_Ultimo\List_Tables; -use WP_Ultimo\Models\Domain; use WP_Ultimo\Database\Domains\Domain_Stage; // Exit if accessed directly @@ -72,7 +71,7 @@ public function get_extra_query_fields() { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Domain $item Domain object. + * @param \WP_Ultimo\Models\Domain $item Domain object. */ public function column_domain($item): string { @@ -98,7 +97,7 @@ public function column_domain($item): string { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Domain $item Domain object. + * @param \WP_Ultimo\Models\Domain $item Domain object. * @return string */ public function column_active($item) { @@ -111,7 +110,7 @@ public function column_active($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Domain $item Domain object. + * @param \WP_Ultimo\Models\Domain $item Domain object. * @return string */ public function column_primary_domain($item) { @@ -124,7 +123,7 @@ public function column_primary_domain($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Domain $item Domain object. + * @param \WP_Ultimo\Models\Domain $item Domain object. * @return string */ public function column_secure($item) { diff --git a/inc/list-tables/class-membership-list-table-widget.php b/inc/list-tables/class-membership-list-table-widget.php index 58fc7421a..829bac26b 100644 --- a/inc/list-tables/class-membership-list-table-widget.php +++ b/inc/list-tables/class-membership-list-table-widget.php @@ -115,7 +115,7 @@ public function get_extra_query_fields() { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Membership $item Membership object. + * @param \WP_Ultimo\Models\Membership $item Membership object. */ public function column_hash($item): string { @@ -199,7 +199,7 @@ public function column_amount($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Membership $item Membership object. + * @param \WP_Ultimo\Models\Membership $item Membership object. * @return string */ public function column_customer($item) { @@ -274,5 +274,5 @@ public function get_columns() { * @since 2.0.0 * @return void */ - public function _js_vars(): void {} + public function _js_vars(): void {} // phpcs:ignore PSR2 } diff --git a/inc/list-tables/class-membership-list-table.php b/inc/list-tables/class-membership-list-table.php index 00815e364..f0f7cad36 100644 --- a/inc/list-tables/class-membership-list-table.php +++ b/inc/list-tables/class-membership-list-table.php @@ -66,7 +66,7 @@ public function get_extra_query_fields() { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Membership $item Membership object. + * @param \WP_Ultimo\Models\Membership $item Membership object. * @return string */ public function column_hash($item) { @@ -101,7 +101,7 @@ public function column_hash($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Membership $item Membership object. + * @param \WP_Ultimo\Models\Membership $item Membership object. * @return string */ public function column_status($item) { @@ -120,7 +120,7 @@ public function column_status($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Membership $item Membership object. + * @param \WP_Ultimo\Models\Membership $item Membership object. * @return string */ public function column_amount($item) { diff --git a/inc/list-tables/class-payment-list-table.php b/inc/list-tables/class-payment-list-table.php index f0ddd7d11..053109f7a 100644 --- a/inc/list-tables/class-payment-list-table.php +++ b/inc/list-tables/class-payment-list-table.php @@ -107,7 +107,7 @@ public function column_hash($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Payment $item Payment object. + * @param \WP_Ultimo\Models\Payment $item Payment object. * @return string */ public function column_status($item) { diff --git a/inc/list-tables/class-product-list-table.php b/inc/list-tables/class-product-list-table.php index a29fa8c91..01201992c 100644 --- a/inc/list-tables/class-product-list-table.php +++ b/inc/list-tables/class-product-list-table.php @@ -52,7 +52,7 @@ public function __construct() { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Product $item Product object. + * @param \WP_Ultimo\Models\Product $item Product object. * @return string */ public function column_name($item) { @@ -91,7 +91,7 @@ public function column_name($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Product $item Product object. + * @param \WP_Ultimo\Models\Product $item Product object. * @return string */ public function column_type($item) { @@ -108,7 +108,7 @@ public function column_type($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Product $item Product object. + * @param \WP_Ultimo\Models\Product $item Product object. * @return string */ public function column_slug($item) { @@ -123,7 +123,7 @@ public function column_slug($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Product $item Product object. + * @param \WP_Ultimo\Models\Product $item Product object. * @return string */ public function column_amount($item) { @@ -169,7 +169,7 @@ public function column_amount($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Product $item Product object. + * @param \WP_Ultimo\Models\Product $item Product object. * @return string */ public function column_setup_fee($item) { @@ -266,7 +266,7 @@ public function get_columns() { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Product $item The line item being displayed. + * @param \WP_Ultimo\Models\Product $item The line item being displayed. * @return void */ public function single_row_grid($item): void { diff --git a/inc/list-tables/class-site-list-table.php b/inc/list-tables/class-site-list-table.php index 84825ed51..8ed6f68f7 100644 --- a/inc/list-tables/class-site-list-table.php +++ b/inc/list-tables/class-site-list-table.php @@ -121,8 +121,6 @@ public function column_path($item): string { 'model' => 'site', ]; - $title = $item->get_title(); - $title = sprintf('%s', wu_network_admin_url('wp-ultimo-edit-site', $url_atts), $item->get_title()); // Concatenate the two blocks @@ -220,7 +218,7 @@ public function column_blog_id($item) { * * @since 2.0.0 * - * @param WP_Ultimo\Models\Site $item Site object. + * @param \WP_Ultimo\Models\Site $item Site object. * @return string */ public function column_type($item) {