c = $c; } /** * Hooks. */ public function register(): void { add_action( 'admin_menu', array( $this, 'menu' ), 9 ); add_action( 'admin_enqueue_scripts', array( $this, 'assets' ) ); add_action( 'admin_init', array( $this, 'first_run_redirect' ) ); add_action( 'admin_post_handymcp_save', array( $this, 'handle_post' ) ); add_action( 'admin_notices', array( $this, 'found_notice' ) ); add_action( 'admin_post_handymcp_found', array( $this, 'found_action' ) ); Review::register(); SetupGuide::register(); add_filter( 'plugin_action_links_' . plugin_basename( HANDYMCP_FILE ), array( $this, 'action_links' ) ); add_filter( 'plugin_row_meta', array( $this, 'row_meta' ), 10, 2 ); add_action( 'wp_ajax_handymcp_diag', array( $this, 'ajax_diag' ) ); } /** * Runs the connection check in the background (Connection screen, when the last check is missing or old). */ public function ajax_diag(): void { check_ajax_referer( 'handymcp_diag' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( null, 403 ); } Diagnostics::run(); wp_send_json_success(); } /** * Menu. */ public function menu(): void { add_menu_page( __( 'HandyMCP', 'handymcp' ), __( 'HandyMCP', 'handymcp' ), 'manage_options', self::SLUG, array( $this, 'render' ), 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIiB3aWR0aD0iNTEyIiBoZWlnaHQ9IjUxMiI+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMTggLTIpIj48cmVjdCB4PSIxMzIiIHk9IjkyIiB3aWR0aD0iNzIiIGhlaWdodD0iMzEyIiByeD0iMzYiIGZpbGw9IiNhN2FhYWQiLz48cGF0aCBkPSJNMTY4IDIzMiBDMTY4IDE3NiAyMTQgMTU2IDI2MiAxNTYgQzMxOCAxNTYgMzU2IDE5MiAzNTYgMjUwIEwzNTYgMzAwIEwyODQgMzAwIEwyODQgMjU2IEMyODQgMjM2IDI3MiAyMjYgMjU0IDIyNiBDMjMwIDIyNiAyMDQgMjM4IDIwNCAyNjggTDIwNCAzMDAgTDE2OCAzMDAgWiIgZmlsbD0iI2E3YWFhZCIvPjxwYXRoIGQ9Ik0yODQgMzA3IEgzNTYgVjM4MCBRMzU2IDQwNCAzMzIgNDA0IEgzMDggUTI4NCA0MDQgMjg0IDM4MCBaIiBmaWxsPSIjYTdhYWFkIi8+PHJlY3QgeD0iMjk3IiB5PSIzOTgiIHdpZHRoPSIxOCIgaGVpZ2h0PSI0MC4wIiByeD0iOSIgZmlsbD0iI2E3YWFhZCIvPjxyZWN0IHg9IjMyOSIgeT0iMzk4IiB3aWR0aD0iMTgiIGhlaWdodD0iNDAuMCIgcng9IjkiIGZpbGw9IiNhN2FhYWQiLz48L2c+PHBhdGggZD0iTTQwMCA2Ni4wIEM0MDcuMzYgMTA0LjY0IDQwNy4zNiAxMDQuNjQgNDQ2LjAgMTEyIEM0MDcuMzYgMTE5LjM2IDQwNy4zNiAxMTkuMzYgNDAwIDE1OC4wIEMzOTIuNjQgMTE5LjM2IDM5Mi42NCAxMTkuMzYgMzU0LjAgMTEyIEMzOTIuNjQgMTA0LjY0IDM5Mi42NCAxMDQuNjQgNDAwIDY2LjBaIiBmaWxsPSIjYTdhYWFkIi8+PC9zdmc+' ); foreach ( self::tabs() as $id => $label ) { add_submenu_page( self::SLUG, $label, $label, 'manage_options', 'connect' === $id ? self::SLUG : 'admin.php?page=' . self::SLUG . '&tab=' . $id, 'connect' === $id ? array( $this, 'render' ) : '' ); } add_filter( 'submenu_file', static function ( $file, $parent ) { if ( self::SLUG !== $parent ) { return $file; } $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'connect'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended return 'connect' === $tab || ! isset( self::tabs()[ $tab ] ) ? self::SLUG : 'admin.php?page=' . self::SLUG . '&tab=' . $tab; }, 10, 2 ); // Browser tab title follows the open screen (Settings, Activity...), not always "Connection". add_filter( 'admin_title', static function ( $title ) { if ( ! isset( $_GET['page'] ) || self::SLUG !== sanitize_key( wp_unslash( $_GET['page'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return $title; } $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'connect'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $tabs = self::tabs(); if ( 'connect' === $tab || ! isset( $tabs[ $tab ] ) ) { return $title; } $first = isset( $tabs['connect'] ) ? (string) $tabs['connect'] : ''; $label = wp_strip_all_tags( (string) $tabs[ $tab ] ); return '' !== $first && 0 === strpos( $title, $first ) ? $label . substr( $title, strlen( $first ) ) : $label . ' ‹ ' . $title; } ); } /** * Open the dashboard once after the plugin is first activated. */ public function first_run_redirect(): void { if ( ! get_option( 'handymcp_do_redirect' ) ) { return; } delete_option( 'handymcp_do_redirect' ); if ( wp_doing_ajax() || is_network_admin() || isset( $_GET['activate-multi'] ) || ! current_user_can( 'manage_options' ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return; } wp_safe_redirect( admin_url( 'admin.php?page=' . self::SLUG ) ); exit; } /** * Plugin row link. * * @param string[] $links Links. * @return string[] */ public function action_links( array $links ): array { array_unshift( $links, '' . esc_html__( 'Connect', 'handymcp' ) . '' ); return $links; } /** Current tab, so page titles can link to the matching guide. @var string */ private $cur_tab = ''; /** @var string Access token created on this request's redirect, shown once. */ private $new_token = ''; /** * Help and support links are hidden when the plugin is white labelled for a client. */ public static function show_help(): bool { return true; } /** * Link to the documentation, with an optional section anchor. * * @param string $anchor Section id on the docs page, or "troubleshooting" plus an optional "#id". * @param string $where Where the link sits, for analytics. */ public static function docs_url( string $anchor = '', string $where = 'plugin' ): string { $path = 0 === strpos( $anchor, 'troubleshooting' ) ? 'docs/troubleshooting/' : 'docs/'; $hash = false !== strpos( $anchor, '#' ) ? substr( $anchor, strpos( $anchor, '#' ) ) : ( '' !== $anchor && 0 !== strpos( $anchor, 'troubleshooting' ) ? '#' . $anchor : '' ); return add_query_arg( array( 'utm_source' => 'plugin', 'utm_medium' => sanitize_key( $where ) ), 'https://handymcp.com/' . $path ) . $hash; } /** * Where to ask for help: the WordPress.org forum for the free plugin, support tickets for Pro. */ public static function support_url(): string { if ( defined( 'HANDYMCP_EDITION' ) && 'free' === HANDYMCP_EDITION ) { return 'https://wordpress.org/support/plugin/handymcp/'; } return 'https://handymcp.com/contact/'; } /** * "How this works" link to a docs section. Empty when the plugin is white labelled. * * @param string $anchor Docs section id. * @param string $where Where the link sits, for analytics. */ public static function guide_link( string $anchor, string $where = 'tab' ): string { if ( ! self::show_help() ) { return ''; } return '' . esc_html__( 'How this works', 'handymcp' ) . ' ' . esc_html__( '(opens in a new tab)', 'handymcp' ) . ''; } /** * Docs and Support links under the plugin description on the Plugins screen. * * @param string[] $meta Links. * @param string $file Plugin file. * @return string[] */ public function row_meta( $meta, $file ): array { $meta = (array) $meta; if ( plugin_basename( HANDYMCP_FILE ) !== $file || ! self::show_help() ) { return $meta; } $meta[] = '' . esc_html__( 'Docs', 'handymcp' ) . ''; $meta[] = '' . esc_html__( 'Support', 'handymcp' ) . ''; return $meta; } /** * Assets on our page only. * * @param string $hook Hook suffix. */ public function assets( string $hook ): void { if ( 'toplevel_page_' . self::SLUG !== $hook ) { return; } // Version by file time, so browsers and caches always load the current files after an update. $ver = static function ( string $file ): string { $t = file_exists( HANDYMCP_DIR . 'assets/' . $file ) ? filemtime( HANDYMCP_DIR . 'assets/' . $file ) : false; return HANDYMCP_VERSION . ( $t ? '.' . $t : '' ); }; wp_enqueue_style( 'handymcp-admin', HANDYMCP_URL . 'assets/admin.css', array(), $ver( 'admin.css' ) ); // After saving a settings sub tab, show it straight away (no flash of the first one). $pane = isset( $_GET['pane'] ) ? sanitize_key( wp_unslash( $_GET['pane'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display only. if ( 0 === strpos( $pane, 'mx-acc-' ) ) { wp_add_inline_style( 'handymcp-admin', '.mx-settings:not(.is-tabbed) .mx-pane:not(#' . $pane . '){display:none}' ); } wp_enqueue_script( 'handymcp-admin', HANDYMCP_URL . 'assets/admin.js', array(), $ver( 'admin.js' ), true ); wp_localize_script( 'handymcp-admin', 'handymcpAdmin', array( 'i18n' => array( 'copied' => __( 'Copied', 'handymcp' ), 'unsaved' => __( 'Unsaved changes.', 'handymcp' ), 'recSame' => __( 'Already on the recommended settings.', 'handymcp' ), 'recDone' => __( 'Recommended settings applied. Click Save to keep them.', 'handymcp' ), 'loadMore' => __( 'Load more', 'handymcp' ), 'leave' => __( 'You have unsaved changes. Leave anyway?', 'handymcp' ), 'ok' => __( 'OK', 'handymcp' ), 'cancel' => __( 'Cancel', 'handymcp' ), ), ) ); wp_enqueue_script( 'handymcp-nav', HANDYMCP_URL . 'assets/nav.js', array( 'handymcp-admin' ), $ver( 'nav.js' ), true ); } /** * Tab URL. * * @param string $tab Tab. * @param array $args Extra args. */ private static function url( string $tab, array $args = array() ): string { return add_query_arg( array_merge( array( 'page' => self::SLUG, 'tab' => $tab ), $args ), admin_url( 'admin.php' ) ); } /** * Tabs (also the admin submenu). * * @return array */ private static function tabs(): array { $tabs = array( 'connect' => __( 'Connection', 'handymcp' ), 'integrations' => __( 'Abilities', 'handymcp' ), 'activity' => __( 'Activity', 'handymcp' ), 'history' => __( 'Changes', 'handymcp' ), 'settings' => __( 'Settings', 'handymcp' ), 'about' => __( 'About', 'handymcp' ), 'upgrade' => __( 'Upgrade to Pro', 'handymcp' ), ); return $tabs; } /** * Render the page. */ public function render(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } $tabs = self::tabs(); $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'connect'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $tab = isset( $tabs[ $tab ] ) ? $tab : 'connect'; $this->cur_tab = $tab; $enabled = (bool) $this->c->get( 'settings' )->get( 'enabled', true ); $errors = (int) $this->c->get( 'log' )->stats( 1 )['errors']; $icons = array( 'connect' => 'admin-links', 'integrations' => 'screenoptions', 'activity' => 'chart-bar', 'history' => 'backup', 'settings' => 'admin-generic', 'about' => 'info-outline', 'upgrade' => 'star-filled' ); echo '

'; echo '
' . ( 'upgrade' !== $tab ? $this->trial_card( 'side', $tab ) : '' ) . '
', \HandyMCP\Support\Html::allowed() ); $this->notices(); \HandyMCP\Support\Crash::notice(); ErrorReports::banner(); call_user_func( array( $this, 'tab_' . $tab ) ); echo wp_kses( ( 'upgrade' !== $tab ? $this->trial_card( 'bottom', $tab ) : '' ) . '
', \HandyMCP\Support\Html::allowed() ); } /** * Keep undo warnings (such as a snippet left switched off) for the notice. * * @param mixed $res Result of History::revert or revert_many. */ private function undo_note( $res ): void { $key = 'handymcp_undo_note_' . get_current_user_id(); if ( is_wp_error( $res ) ) { set_transient( $key, $res->get_error_message(), 300 ); } elseif ( is_array( $res ) && ( ! empty( $res['failed'] ) || ! empty( $res['warnings'] ) ) ) { set_transient( $key, implode( ' ', array_merge( (array) ( $res['failed'] ?? array() ), (array) ( $res['warnings'] ?? array() ) ) ), 300 ); } else { delete_transient( $key ); } } /** * Admin notices after an action. */ private function notices(): void { // phpcs:disable WordPress.Security.NonceVerification.Recommended $msg = isset( $_GET['handymcp_msg'] ) ? sanitize_key( wp_unslash( $_GET['handymcp_msg'] ) ) : ''; // phpcs:enable $texts = array( 'saved' => __( 'Settings saved.', 'handymcp' ), 'revoked' => __( 'Access revoked.', 'handymcp' ), 'cleared' => __( 'Cleared.', 'handymcp' ), 'cleared_history' => __( 'Change history cleared.', 'handymcp' ), 'cleared_sessions' => __( 'Connection sessions cleared. Apps stay signed in and start a new session on their next request.', 'handymcp' ), 'cleared_reports' => __( 'Update reports cleared.', 'handymcp' ), 'cleared_log' => __( 'Activity log cleared.', 'handymcp' ), 'undone' => trim( __( 'Change reverted.', 'handymcp' ) . ' ' . (string) get_transient( 'handymcp_undo_note_' . get_current_user_id() ) ), 'checked' => __( 'Connection check finished.', 'handymcp' ), 'paused' => __( 'Connection paused. The app keeps its sign-in but cannot read or change anything until you resume it.', 'handymcp' ), 'resumed' => __( 'Connection resumed. The app can work again right away.', 'handymcp' ), 'undo_failed' => trim( __( 'The change could not be reverted.', 'handymcp' ) . ' ' . (string) get_transient( 'handymcp_undo_note_' . get_current_user_id() ) ), 'oauth_reset' => trim( __( 'Sign-in state cleaned up.', 'handymcp' ) . ' ' . (string) get_transient( 'handymcp_reset_note_' . get_current_user_id() ) ), ); if ( isset( $texts[ $msg ] ) && '' !== trim( (string) $texts[ $msg ] ) ) { echo '

' . esc_html( $texts[ $msg ] ) . '

'; } $stored = get_transient( 'handymcp_new_token_' . get_current_user_id() ); if ( $stored ) { delete_transient( 'handymcp_new_token_' . get_current_user_id() ); } // Kept encrypted for the one redirect after creation, then shown once. $token = is_string( $stored ) && '' !== $stored ? \HandyMCP\Support\Crypto::decrypt( $stored ) : null; if ( $token ) { $this->new_token = $token; echo '

' . esc_html__( 'Your new access token. Copy it now, it is shown only once:', 'handymcp' ) . '

'; echo '

'; } } /** * Connection tab. */ private function tab_connect(): void { $settings = $this->c->get( 'settings' ); $endpoint = rest_url( HANDYMCP_REST_NS . '/mcp' ); $sessions = \HandyMCP\Mcp\Server::recent_sessions( 30 ); $tokens = $this->c->get( 'tokens' )->list(); $stats = $this->c->get( 'log' )->stats( 7 ); global $wpdb; $clients = \HandyMCP\Support\Env::is_sqlite() ? $wpdb->get_results( $wpdb->prepare( "SELECT t.client_id, c.client_name, t.user_id, MAX(t.last_used_at) AS last_used, MIN(t.created_at) AS since, GROUP_CONCAT(DISTINCT t.scopes) AS scopes FROM %i t LEFT JOIN %i c ON c.client_id = t.client_id WHERE t.kind IN ('at','rt') AND t.revoked = 0 AND (t.expires_at IS NULL OR t.expires_at > UTC_TIMESTAMP()) GROUP BY t.client_id, c.client_name, t.user_id ORDER BY last_used DESC", Installer::table( 'tokens' ), Installer::table( 'oauth_clients' ) ) ) // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom tables, admin screen needs current data. : $wpdb->get_results( $wpdb->prepare( "SELECT t.client_id, c.client_name, t.user_id, MAX(t.last_used_at) AS last_used, MIN(t.created_at) AS since, GROUP_CONCAT(DISTINCT t.scopes SEPARATOR ' ') AS scopes FROM %i t LEFT JOIN %i c ON c.client_id = t.client_id WHERE t.kind IN ('at','rt') AND t.revoked = 0 AND (t.expires_at IS NULL OR t.expires_at > UTC_TIMESTAMP()) GROUP BY t.client_id, c.client_name, t.user_id ORDER BY last_used DESC", Installer::table( 'tokens' ), Installer::table( 'oauth_clients' ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom tables, admin screen needs current data. $pats = array_values( array_filter( $tokens, static function ( $t ) { return 'pat' === $t->kind; } ) ); $this->page_head( __( 'Connection', 'handymcp' ), __( 'Connect AI apps and control their access.', 'handymcp' ) ); $sg_diag = get_option( Diagnostics::RESULT ); $sg_chk = is_array( $sg_diag ) && ! empty( $sg_diag['checks'] ) ? array_column( (array) $sg_diag['checks'], 'status' ) : array(); SetupGuide::render( count( $clients ) + count( $pats ), ! $sg_chk ? 'none' : ( in_array( 'fail', $sg_chk, true ) ? 'fail' : ( in_array( 'warn', $sg_chk, true ) ? 'warn' : 'ok' ) ), \HandyMCP\Support\SiteBrief::filled(), self::url( 'settings', array( 'pane' => 'mx-acc-brief' ) ), self::docs_url( 'connect', 'setup' ), add_query_arg( array( 'utm_source' => 'plugin', 'utm_medium' => 'setup' ), 'https://handymcp.com/prompts/' ) ); echo '
'; echo '
' . (int) count( $clients ) . '' . esc_html__( 'Connected apps', 'handymcp' ) . '
'; echo '
' . (int) $stats['calls'] . '' . esc_html__( 'Requests, last 7 days', 'handymcp' ) . '
'; echo '
' . (int) $stats['writes'] . '' . esc_html__( 'Changes made', 'handymcp' ) . '
'; echo '
' . (int) $stats['errors'] . '' . esc_html__( 'Errors', 'handymcp' ) . '
'; echo '
'; echo '
'; // Connect guide. echo '

' . esc_html__( 'Connect an AI app', 'handymcp' ) . '

' . esc_html__( 'Paste this URL into your AI app.', 'handymcp' ) . '

'; echo '
'; $apps = Clients::all(); // The app picker list (not the connected apps above). $groups = array(); foreach ( $apps as $cid => $cl ) { $groups[ $cl['group'] ][ $cid ] = $cl['name']; } echo '
'; $first = true; foreach ( $apps as $cid => $cl ) { echo '
    '; foreach ( $cl['steps'] as $step ) { echo '
  1. ' . wp_kses( $step, array( 'strong' => array(), 'code' => array() ) ) . '
  2. '; } echo '
'; if ( ! empty( $cl['code'] ) ) { $cfg_id = 'handymcp-cfg-' . $cid; echo '
' . esc_html( $cl['file'] ) . '
'; } echo '
'; $first = false; } if ( ! is_ssl() ) { echo '
' . esc_html__( 'This site is not on HTTPS. Claude and ChatGPT only connect to HTTPS sites.', 'handymcp' ) . '
'; } $blocker = AppPasswordPolicy::blocker(); if ( ! AppPasswordPolicy::available() && $blocker ) { $on = (bool) $settings->get( 'allow_app_passwords' ); /* translators: %s: plugin */ $ap_txt = $on /* translators: %s: plugin */ ? sprintf( __( 'Application Passwords are turned off by %s, but allowed for MCP requests. OAuth and access tokens never need them.', 'handymcp' ), $blocker ) /* translators: %s: plugin */ : sprintf( __( 'Application Passwords are turned off by %s. OAuth and access tokens work without them; allow them only for an app that needs one.', 'handymcp' ), $blocker ); echo wp_kses( '

' . esc_html( $ap_txt ) . '

' . $this->mini_form( 'app_passwords', array( 'value' => $on ? '0' : '1' ), $on ? __( 'Turn off for MCP', 'handymcp' ) : __( 'Allow for MCP only', 'handymcp' ) ) . '
', \HandyMCP\Support\Html::allowed() ); } echo '
'; // Connected apps. echo '

' . esc_html__( 'Connected apps', 'handymcp' ) . '

' . '' . '

'; $health = \HandyMCP\Auth\AppHealth::problems(); $waiting = \HandyMCP\Auth\AppHealth::waiting(); $listed = array(); if ( ! $clients && ! $health ) { echo '
' . esc_html__( 'No apps connected yet.', 'handymcp' ) . '
'; } else { echo '
    '; foreach ( $clients as $cl ) { $listed[] = (string) $cl->client_id; $last_ts = max( $cl->last_used ? (int) strtotime( $cl->last_used . ' UTC' ) : 0, \HandyMCP\Auth\AppHealth::last_used( (string) $cl->client_id ) ); $cl_problem = $health[ (string) $cl->client_id ] ?? null; $user = get_userdata( (int) $cl->user_id ); $name = $cl->client_name ? $cl->client_name : $cl->client_id; $scopes = Scopes::sanitize( array_unique( array_filter( (array) preg_split( '/[\s,]+/', (string) $cl->scopes ) ) ) ); echo 'client_id ) ? ' class="is-paused"' : '' ) . '>
    ' . esc_html( $name ) . ( Pause::is_paused( 'client', (string) $cl->client_id ) ? ' ' . esc_html__( 'Paused', 'handymcp' ) . '' : '' ) . '
    '; echo '' . esc_html( $user ? $user->display_name : '#' . $cl->user_id ) . '·'; echo '' . esc_html( $last_ts ? sprintf( /* translators: %s time */ __( 'active %s ago', 'handymcp' ), human_time_diff( $last_ts ) ) : __( 'not used yet', 'handymcp' ) ) . '
    '; if ( $cl_problem ) { echo '
    ' . esc_html( \HandyMCP\Auth\AppHealth::message( (string) $name, $cl_problem ) ) . '
    '; } elseif ( isset( $waiting[ (string) $cl->client_id ] ) ) { echo '
    ' . esc_html( \HandyMCP\Auth\AppHealth::waiting_message( (string) $name, (int) $waiting[ (string) $cl->client_id ]['time'] ) ) . '
    '; } $no_tools = $this->c->get( 'registry' )->no_tools_message( new \HandyMCP\Auth\Identity( (int) $cl->user_id, 'oauth', 0, $scopes ) ); echo wp_kses( '
    ' . implode( ' ', array_map( static function ( $s ) { return '' . esc_html( Scopes::short( $s ) ) . ''; }, $scopes ) ) . '
    ' . ( '' !== $no_tools ? '
    ' . esc_html( $no_tools ) . '
    ' : '' ) . '
    ', \HandyMCP\Support\Html::allowed() ); $paused = Pause::is_paused( 'client', (string) $cl->client_id ); echo wp_kses( '
    ' . $this->pause_button( 'client', (string) $cl->client_id, $paused ) . $this->mini_form( 'revoke_client', array( 'client_id' => $cl->client_id ), __( 'Disconnect', 'handymcp' ), true ) . '
    ', \HandyMCP\Support\Html::allowed() ); } foreach ( $health as $hid => $hp ) { if ( in_array( (string) $hid, $listed, true ) ) { continue; } echo '
  • ' . esc_html( (string) $hp['name'] ) . ' ' . esc_html__( 'Needs reconnect', 'handymcp' ) . '
    ' . esc_html( \HandyMCP\Auth\AppHealth::message( (string) $hp['name'], $hp ) ) . '
  • '; } echo '
'; } echo '
'; // Connection check: one status row; the full checklist is in a dialog. $diag = get_option( Diagnostics::RESULT ); $has = is_array( $diag ) && ! empty( $diag['checks'] ); $checks = $has ? (array) $diag['checks'] : array(); $fails = count( array_filter( $checks, static function ( $c ) { return 'fail' === ( $c['status'] ?? '' ); } ) ); $warns = count( array_filter( $checks, static function ( $c ) { return 'warn' === ( $c['status'] ?? '' ); } ) ); $state = ! $has ? 'none' : ( $fails ? 'fail' : ( $warns ? 'warn' : 'ok' ) ); $when = $has ? sprintf( /* translators: %s: time, e.g. 5 mins */ __( 'Checked %s ago.', 'handymcp' ), human_time_diff( (int) $diag['when'] ) ) : ''; if ( 'fail' === $state ) { /* translators: %d: number of problems. */ $line = sprintf( _n( '%d problem found.', '%d problems found.', $fails + $warns, 'handymcp' ), $fails + $warns ); } elseif ( 'warn' === $state ) { /* translators: %d: number of warnings. */ $line = sprintf( _n( '%d thing to look at.', '%d things to look at.', $warns, 'handymcp' ), $warns ); } elseif ( 'ok' === $state ) { $line = __( 'Everything looks good.', 'handymcp' ); } else { $line = __( 'Not checked yet.', 'handymcp' ); } // Troubleshooting answer for each check, so "See fixes" opens the right one. $fixes = array( 'https' => 'the-connection-check-says-the-site-is-not-on-https', 'urls' => 'the-site-address-and-wordpress-address-do-not-match', 'permalinks' => 'the-sign-in-page-shows-not-found-or-a-404', 'endpoint' => 'a-firewall-or-cache-blocks-the-mcp-endpoint', 'endpoint_cache' => 'a-firewall-or-cache-blocks-the-mcp-endpoint', 'ai_agents' => 'requests-from-ai-apps-are-blocked-as-bots', 'auth_header' => 'error-401-or-unauthorized', 'code_saves' => 'edits-with-scripts-iframes-or-code-fail', 'waf_code' => 'edits-with-scripts-iframes-or-code-fail', 'discovery' => 'the-sign-in-page-shows-not-found-or-a-404', 'wellknown' => 'the-well-known-addresses-return-404-or-not-found', 'wellknown_foreign' => 'the-well-known-addresses-return-404-or-not-found', 'issuer_path' => 'the-well-known-addresses-return-404-or-not-found', 'cloudflare' => 'cloudflare-blocks-the-connection', 'siteground' => 'my-site-is-on-siteground', 'rest_blockers' => 'a-plugin-turns-off-the-rest-api', 'security_plugins' => 'a-security-plugin-setting-blocks-ai-apps', 'signin' => 'i-approved-the-app-but-it-still-cannot-connect', 'app_passwords' => 'application-passwords-are-turned-off-by-a-security-plugin', 'cron' => 'scheduled-updates-did-not-run', 'object_cache' => 'the-connection-check-warns-about-the-object-cache' ); $first = 'connect'; foreach ( array( 'fail', 'warn' ) as $lvl ) { foreach ( $checks as $c ) { if ( $lvl === ( $c['status'] ?? '' ) && isset( $fixes[ $c['id'] ?? '' ] ) ) { $first = $fixes[ $c['id'] ]; break 2; } } } // Checked by itself when there is no result yet or it is more than a day old (a daily check also runs in the background). $stale = ! $has || empty( $diag['when'] ) || time() - (int) $diag['when'] > DAY_IN_SECONDS; if ( ! $has ) { $line = __( 'Checking your connection…', 'handymcp' ); } $full = isset( $_GET['report'] ) && $has; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display only. $auto = $has && isset( $_GET['handymcp_msg'] ) && 'checked' === sanitize_key( wp_unslash( $_GET['handymcp_msg'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display only. echo '

' . esc_html__( 'Connection check', 'handymcp' ) . '

' . esc_html( $line ) . ' ' . esc_html( $when ) . ( in_array( $state, array( 'fail', 'warn' ), true ) && self::show_help() ? ' ' . esc_html__( 'See fixes', 'handymcp' ) . '' : '' ) . '

'; if ( $has ) { echo '' . esc_html__( 'View report', 'handymcp' ) . ''; } echo wp_kses( $this->mini_form( 'diagnostics', array(), $has ? __( 'Check again', 'handymcp' ) : __( 'Run connection check', 'handymcp' ), false, $has ? '' : 'button-primary' ) . '
', \HandyMCP\Support\Html::allowed() ); if ( $has ) { $order = array( 'fail' => 0, 'warn' => 1, 'ok' => 2 ); usort( $checks, static function ( $a, $b ) use ( $order ) { return ( $order[ $a['status'] ] ?? 3 ) <=> ( $order[ $b['status'] ] ?? 3 ); } ); $icons = array( 'ok' => 'yes-alt', 'warn' => 'warning', 'fail' => 'dismiss' ); $names = array( 'ok' => __( 'OK', 'handymcp' ), 'warn' => __( 'Warning', 'handymcp' ), 'fail' => __( 'Problem', 'handymcp' ) ); $list = '

' . esc_html( (string) $diag['summary'] ) . ' ' . esc_html( $when ) . '

    '; foreach ( $checks as $chk ) { $st = (string) $chk['status']; $list .= '
  • ' . esc_html( $chk['label'] ) . ' (' . esc_html( $names[ $st ] ?? $st ) . ')
    ' . esc_html( $chk['detail'] ) . '
    ' . ( ! empty( $chk['fix'] ) && 'ok' !== $st ? '
    ' . esc_html( $chk['fix'] ) . '
    ' : '' ) . ( 'ok' !== $st && isset( $fixes[ $chk['id'] ?? '' ] ) && self::show_help() ? '' . esc_html__( 'How to fix this', 'handymcp' ) . '' : '' ) . '
  • '; } $list .= '
'; $report = ''; if ( $full ) { echo wp_kses( '', \HandyMCP\Support\Html::allowed() ); } else { echo wp_kses( '

' . esc_html__( 'Connection check', 'handymcp' ) . '

' . $list . $report . '
' . $this->mini_form( 'diagnostics', array(), __( 'Check again', 'handymcp' ), false, '' ) . '
', \HandyMCP\Support\Html::allowed() ); } } echo '
'; if ( $has ) { $this->connection_help( $diag, $endpoint ); } echo '
'; // Tokens. echo '

' . esc_html__( 'Access tokens', 'handymcp' ) . '

' . esc_html__( 'For apps without OAuth sign-in.', 'handymcp' ) . '

'; if ( $pats ) { echo '
    '; foreach ( array_slice( $pats, 0, 100 ) as $t ) { $tp = Pause::is_paused( 'token', (string) $t->id ); echo wp_kses( '
    ' . esc_html( $t->name ) . ( $tp ? ' ' . esc_html__( 'Paused', 'handymcp' ) . '' : '' ) . '
    ' . esc_html( $t->token_prefix ) . '…' . esc_html( $t->expires_at ? sprintf( /* translators: %s date */ __( 'expires %s', 'handymcp' ), mysql2date( 'M j, Y', $t->expires_at ) ) : __( 'never expires', 'handymcp' ) ) . '
    ' . $this->pause_button( 'token', (string) $t->id, $tp ) . $this->mini_form( 'revoke_token', array( 'token_id' => $t->id ), __( 'Revoke', 'handymcp' ), true ) . '
    ', \HandyMCP\Support\Html::allowed() ); } echo '
'; } if ( ! $pats ) { echo '
' . esc_html__( 'No tokens yet.', 'handymcp' ) . '
'; } echo '
'; echo '

' . esc_html__( 'Create an access token', 'handymcp' ) . '

'; wp_nonce_field( 'handymcp_save' ); echo ''; echo '
'; echo '
' . esc_html__( 'What it may do', 'handymcp' ) . '
'; foreach ( Scopes::all() as $scope => $label ) { if ( ! Scopes::relevant( $scope ) ) { continue; } $checked = in_array( $scope, array( 'read', 'content:write' ), true ); echo ''; } echo '
'; echo '
'; echo '
'; if ( $sessions ) { echo wp_kses( '

' . esc_html__( 'Recent sessions', 'handymcp' ) . '

' . esc_html__( 'Last 30 days. Older ones are removed automatically.', 'handymcp' ) . '

' . $this->mini_form( 'clear_sessions', array(), __( 'Clear', 'handymcp' ), true, 'button-link-delete', __( 'Clear the session list? Connected apps stay connected.', 'handymcp' ) ) . '
    ', \HandyMCP\Support\Html::allowed() ); foreach ( array_slice( $sessions, 0, 100 ) as $s ) { $name = trim( $s->client_name . ' ' . $s->client_version ); $name = '' === $name ? __( 'Unknown client', 'handymcp' ) : $name; echo '
  • ' . esc_html( $name ) . '
    ' . esc_html( $s->auth_method ) . '·' . esc_html( sprintf( /* translators: %d requests */ _n( '%d request', '%d requests', (int) $s->request_count, 'handymcp' ), (int) $s->request_count ) ) . '
    ' . esc_html( human_time_diff( strtotime( $s->last_seen_at . ' UTC' ) ) . ' ' . __( 'ago', 'handymcp' ) ) . '
  • '; } echo '
'; } echo '
'; } /** * Extra help under the connection check: SiteGround ticket, static * discovery files and the access token fallback, each only when the last * check found the matching problem. * * @param array $diag Last connection check. * @param string $endpoint MCP server URL. */ private function connection_help( array $diag, string $endpoint ): void { $checks = (array) ( $diag['checks'] ?? array() ); $status = array(); foreach ( $checks as $c ) { $status[ $c['id'] ?? '' ] = $c['status'] ?? ''; } $wk = (array) ( $diag['wellknown'] ?? array() ); $wk_bad = ( isset( $wk['as'] ) && ! $wk['as'] ) || ( isset( $wk['prm'] ) && false === $wk['prm'] ); $sg = ! empty( $diag['siteground'] ); $blocked = ! empty( $diag['blocked'] ) || ! empty( $diag['sg_block'] ); $oauth_ko = $blocked || in_array( $status['discovery'] ?? '', array( 'fail', 'warn' ), true ) || 'fail' === ( $status['ai_agents'] ?? '' ); $box = static function ( string $id, string $title, string $text, int $rows = 8 ): string { return '
' . esc_html( $title ) . '
'; }; // SiteGround: the ticket only when something SiteGround controls actually blocks. // Its own /.well-known/ answer is handled by the plugin and is not a block. if ( $sg ) { $sg_cache = 'fail' === ( $status['endpoint_cache'] ?? '' ); if ( $blocked || $sg_cache ) { $html = '

' . esc_html__( 'Your site is on SiteGround', 'handymcp' ) . '

' . esc_html( $blocked ? __( 'SiteGround blocks part of the connection. Its Anti-Bot AI has no switch in Site Tools, so send this text to SiteGround support (Site Tools > Help > Contact us).', 'handymcp' ) : __( 'SiteGround caches the connection. Add these exclusions in Speed Optimizer, then purge the cache.', 'handymcp' ) ) . '

'; if ( $blocked ) { $html .= $box( 'handymcp-sg-ticket', __( 'Support ticket', 'handymcp' ), Blockers::siteground_ticket(), 10 ); } if ( $sg_cache ) { $html .= $box( 'handymcp-sg-cache', __( 'Cache exclusions', 'handymcp' ), Blockers::siteground_cache(), 6 ); } } else { $html = '
' . esc_html__( 'On SiteGround. No blocks found.', 'handymcp' ) . '

' . esc_html__( 'If an AI app ever fails to connect, send this text to SiteGround support.', 'handymcp' ) . '

'; $html .= $box( 'handymcp-sg-ticket', __( 'Support ticket', 'handymcp' ), Blockers::siteground_ticket(), 10 ) . '
'; } echo wp_kses( $html . '
', \HandyMCP\Support\Html::allowed() ); } // Discovery files that do not depend on WordPress answering /.well-known/. if ( $wk_bad ) { $oauth = $this->c->get( 'oauth' ); $flags = JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT; $path = \HandyMCP\Auth\OAuthServer::issuer_path_mode(); $intro = $path ? __( 'Optional: if you also want the files at /.well-known/, create them yourself:', 'handymcp' ) : __( 'Your server answers /.well-known/ itself, so WordPress never sees those requests. Most AI apps still sign in through the fallback addresses this plugin answers, and the sign-in hint now points to a copy under /wp-json/. If an app still cannot find the sign-in page, create the two files yourself:', 'handymcp' ); $steps = '
' . esc_html( Blockers::wellknown_steps() ) . '
' . $box( 'handymcp-wk-as', '.well-known/oauth-authorization-server', (string) wp_json_encode( $oauth->server_metadata(), $flags ), 10 ) . $box( 'handymcp-wk-prm', '.well-known/oauth-protected-resource', (string) wp_json_encode( $oauth->resource_metadata(), $flags ), 8 ); if ( $path ) { // Handled automatically: one short line, the optional steps folded away. $html = '
' . esc_html__( 'Your host answers /.well-known itself, so HandyMCP announces its sign-in server under /wp-json. Nothing to do.', 'handymcp' ) . '
' . '
' . esc_html__( 'Optional: create the /.well-known files yourself', 'handymcp' ) . '' . $steps . '
'; } else { $html = '

' . esc_html__( 'Sign-in details at /.well-known/', 'handymcp' ) . '

' . esc_html( $intro ) . '

' . $steps; } echo wp_kses( $html . '
', \HandyMCP\Support\Html::allowed() ); } // Access token instead of OAuth. if ( $oauth_ko ) { $token = '' !== $this->new_token ? $this->new_token : 'YOUR_TOKEN'; $html = '

' . esc_html__( 'Connect with an access token instead', 'handymcp' ) . '

' . esc_html__( 'OAuth sign-in looks blocked on this site. Apps that let you add a header can connect with an access token, which skips the sign-in pages.', 'handymcp' ) . '

'; if ( '' === $this->new_token ) { $html .= '
' . $this->mini_form( 'token_fallback', array(), __( 'Create an access token', 'handymcp' ), false, 'button-primary' ) . '

' . esc_html__( 'The token can read and edit content, expires in 90 days and is shown once. The setup text below then includes it.', 'handymcp' ) . '

'; } else { $html .= '

' . esc_html__( 'Your token is filled in below. Copy the setup now: the token is shown only once.', 'handymcp' ) . '

'; } $json = (string) wp_json_encode( array( 'mcpServers' => array( 'handymcp' => array( 'command' => 'npx', 'args' => array( '-y', 'mcp-remote', $endpoint, '--header', 'Authorization:${AUTH_HEADER}' ), 'env' => array( 'AUTH_HEADER' => 'Bearer ' . $token ) ) ) ), JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT ); $html .= $box( 'handymcp-tf-cc', __( 'Claude Code (terminal)', 'handymcp' ), 'claude mcp add --transport http handymcp ' . $endpoint . ' --header "Authorization: Bearer ' . $token . '"', 3 ); $html .= $box( 'handymcp-tf-json', __( 'Claude Desktop and other apps (claude_desktop_config.json or the app\'s MCP settings)', 'handymcp' ), $json, 14 ); echo wp_kses( $html . '
', \HandyMCP\Support\Html::allowed() ); } if ( ! \HandyMCP\Support\SiteBrief::filled() ) { echo wp_kses( '
' . esc_html__( 'Tell AI apps about your site: your voice, words to avoid and rules to follow.', 'handymcp' ) . ' ' . esc_html__( 'Add a site brief', 'handymcp' ) . '
', \HandyMCP\Support\Html::allowed() ); } // Last resort for an app stuck in a sign-in loop. Only shown while sign-in looks // broken or no app is connected yet; a working connection does not need it. $signin_trouble = $oauth_ko; if ( ! $signin_trouble && $this->c->get( 'tokens' )->has_connection() ) { return; } echo wp_kses( '
' . esc_html__( 'Still can\'t sign in? Reset sign-in data', 'handymcp' ) . '

' . esc_html__( 'Use this when an AI app keeps failing to sign in. It removes app registrations that have no active sign-in, pending sign-in codes, and expired or revoked tokens. Connected apps and your access tokens keep working. Then connect the app again.', 'handymcp' ) . '

' . $this->mini_form( 'reset_oauth', array(), __( 'Reset OAuth state', 'handymcp' ), true, '', __( 'Remove unused app registrations, pending sign-in codes and expired or revoked tokens? Connected apps and access tokens are kept.', 'handymcp' ) ) . '
', \HandyMCP\Support\Html::allowed() ); } /** * Ability categories shown as tabs on the Abilities screen. * * @return array id => [label, group ids]. */ private static function ability_categories(): array { return array( 'content' => array( __( 'Content and media', 'handymcp' ), array( 'wordpress', 'gutenberg', 'images', 'assistant' ) ), 'builders' => array( __( 'Page builders', 'handymcp' ), array( 'elementor' ) ), 'forms' => array( __( 'Forms', 'handymcp' ), array( 'forms' ) ), 'commerce' => array( __( 'Store', 'handymcp' ), array( 'woocommerce' ) ), 'seo' => array( __( 'SEO', 'handymcp' ), array( 'seo' ) ), 'site' => array( __( 'Site', 'handymcp' ), array( 'site' ) ), ); } /** * Category of a group. * * @param array $g Group. */ private static function category_of( array $g ): string { if ( ! empty( $g['category'] ) && isset( self::ability_categories()[ $g['category'] ] ) ) { return (string) $g['category']; } foreach ( self::ability_categories() as $cid => $c ) { if ( in_array( $g['id'], $c[1], true ) ) { return $cid; } } return 'site'; } /** * Abilities tab: every tool, grouped by category and integration. Each * integration has one Off / Read only / Full switch; open it to fine tune * single abilities. Closed groups are not laid out by the browser, and the * state is saved as one compact field, so hundreds of abilities stay fast. */ private function tab_integrations(): void { $reg = $this->c->get( 'registry' ); $groups = $reg->groups(); $tools = $reg->all(); $by_group = array(); foreach ( $tools as $t ) { $by_group[ $t['group'] ][] = $t; } $detected = array_filter( $groups, static function ( $g ) { return ! empty( $g['detected'] ); } ); $missing = array_filter( $groups, static function ( $g ) { return empty( $g['detected'] ) && empty( $g['error'] ); } ); $errors = array_filter( $groups, static function ( $g ) { return ! empty( $g['error'] ); } ); $cats = self::ability_categories(); $total = 0; $on = 0; $writes = 0; $per_cat = array_fill_keys( array_keys( $cats ), 0 ); foreach ( $detected as $g ) { foreach ( $by_group[ $g['id'] ] ?? array() as $t ) { ++$total; ++$per_cat[ self::category_of( $g ) ]; if ( $reg->enabled( $t ) ) { ++$on; $writes += $t['write'] ? 1 : 0; } } } $summary = '
' . (int) $total . ' ' . esc_html__( 'abilities', 'handymcp' ) . '' . (int) $on . ' ' . esc_html__( 'on', 'handymcp' ) . '' . (int) $writes . ' ' . esc_html__( 'can change the site', 'handymcp' ) . '
'; $this->page_head( __( 'Abilities', 'handymcp' ), __( 'What AI apps may read and change on this site.', 'handymcp' ), $summary ); if ( $this->c->get( 'settings' )->get( 'read_only', false ) ) { echo '
' . esc_html__( 'Read-only mode is on.', 'handymcp' ) . ' ' . esc_html__( 'AI apps can only read, whatever is switched on below.', 'handymcp' ) . '' . esc_html__( 'Change', 'handymcp' ) . '
'; } foreach ( $errors as $g ) { echo '

' . esc_html( $g['label'] . ': ' . $g['error'] ) . '

'; } echo '
'; wp_nonce_field( 'handymcp_save' ); echo ''; // Toolbar: search, category, status, actions. echo '
'; echo '
'; echo '
'; foreach ( $cats as $cid => $c ) { $in_cat = array_filter( $detected, static function ( $g ) use ( $cid, $by_group ) { return self::category_of( $g ) === $cid && ! empty( $by_group[ $g['id'] ] ); } ); if ( ! $in_cat ) { continue; } echo '

' . esc_html( $c[0] ) . '

'; foreach ( $in_cat as $g ) { $list = $by_group[ $g['id'] ]; $g_on = count( array_filter( $list, array( $reg, 'enabled' ) ) ); $plug = array(); foreach ( (array) $g['plugins'] as $name => $version ) { $plug[] = $name . ( $version ? ' ' . $version : '' ); } $search = strtolower( $g['label'] . ' ' . implode( ' ', array_keys( (array) $g['plugins'] ) ) ); echo '
'; echo ''; $doc = array( 'woocommerce' => 'woocommerce', 'elementor' => 'builders', 'gutenberg' => 'builders', 'images' => 'photos', 'seo' => 'abilities', 'forms' => 'abilities' )[ $g['id'] ] ?? ''; echo '

' . esc_html( $g['description'] ) . ( '' !== $doc && self::show_help() ? ' ' . wp_kses( self::guide_link( $doc, 'ability-' . $g['id'] ), \HandyMCP\Support\Html::allowed() ) : '' ) . '

'; foreach ( $list as $t ) { $kind = $t['confirm'] ? 'confirm' : ( $t['write'] ? 'write' : 'read' ); $label = array( 'read' => __( 'Read', 'handymcp' ), 'write' => __( 'Changes', 'handymcp' ), 'confirm' => __( 'Asks first', 'handymcp' ) )[ $kind ]; $full = (string) preg_replace( '/\s+/', ' ', $t['description'] ); echo '
' . esc_html( $t['title'] ? $t['title'] : $t['name'] ) . ' ' . esc_html( $label ) . '

' . esc_html( self::short_text( $full ) ) . '

'; echo '
'; } echo '
'; } } echo ''; echo '
'; echo '
'; echo '
'; $by_cat = array(); foreach ( $missing as $g ) { if ( 'builders' === $g['id'] ) { continue; // Same plugin as the Beaver Builder chip. } $names = ! empty( $g['offers'] ) ? (array) $g['offers'] : array( $g['label'] ); foreach ( $names as $n ) { $by_cat[ self::category_of( $g ) ][] = $n; } } // Integrations that cover several plugins: also list the ones not installed yet. foreach ( $detected as $g ) { foreach ( array_diff( (array) ( $g['offers'] ?? array() ), array_keys( (array) ( $g['plugins'] ?? array() ) ) ) as $n ) { $by_cat[ self::category_of( $g ) ][] = $n; } } if ( $by_cat ) { echo '
' . esc_html( sprintf( /* translators: %d count */ __( 'Available when installed (%d)', 'handymcp' ), array_sum( array_map( 'count', $by_cat ) ) ) ) . '' . '' . '
'; foreach ( $cats as $cid => $c ) { if ( empty( $by_cat[ $cid ] ) ) { continue; } echo '
' . esc_html( $c[0] ) . '
'; foreach ( $by_cat[ $cid ] as $label ) { echo '' . esc_html( $label ) . ''; } echo '
'; } echo '
'; } $this->pro_more_abilities(); } /** * Page header used by every tab: title, one line of help, actions. * * @param string $title Title. * @param string $desc Description. * @param string $actions Actions HTML (already escaped). */ private function page_head( string $title, string $desc, string $actions = '' ): void { $guides = array( 'connect' => 'connect', 'integrations' => 'abilities', 'activity' => 'activity', 'history' => 'undo', 'settings' => 'settings-server', 'upgrade' => 'plans' ); if ( isset( $guides[ $this->cur_tab ] ) ) { $actions = self::guide_link( $guides[ $this->cur_tab ], 'tab-' . $this->cur_tab ) . $actions; } echo wp_kses( '

' . esc_html( $title ) . '

' . ( '' !== $desc ? '

' . esc_html( $desc ) . '

' : '' ) . '
' . ( $actions ? '
' . $actions . '
' : '' ) . '
', \HandyMCP\Support\Html::allowed() ); } /** * Activity tab: every call an AI app made, newest first, grouped by day. */ private function tab_activity(): void { // phpcs:disable WordPress.Security.NonceVerification.Recommended $filter = isset( $_GET['show'] ) ? sanitize_key( wp_unslash( $_GET['show'] ) ) : 'all'; $older = isset( $_GET['older'] ) ? max( 0, (int) $_GET['older'] ) : 0; // phpcs:enable $per = 200; $query = array( 'limit' => $per, 'offset' => $older * $per ); if ( 'changes' === $filter ) { $query['writes'] = true; } elseif ( 'errors' === $filter ) { $query['status'] = 'error'; } $log = $this->c->get( 'log' ); $res = $log->query( $query ); $counts = array( 'all' => $log->query( array( 'limit' => 1 ) )['total'], 'changes' => $log->query( array( 'limit' => 1, 'writes' => true ) )['total'], 'errors' => $log->query( array( 'limit' => 1, 'status' => 'error' ) )['total'], ); $this->page_head( __( 'Activity', 'handymcp' ), __( 'Every request from AI apps, newest first.', 'handymcp' ), $res['rows'] ? '' . $this->mini_form( 'export_log', array( 'format' => 'csv' ), __( 'Export CSV', 'handymcp' ), false, '' ) . $this->mini_form( 'export_log', array( 'format' => 'json' ), __( 'Export JSON', 'handymcp' ), false, '' ) . $this->mini_form( 'clear_log', array(), __( 'Clear activity', 'handymcp' ), true, 'button-link-delete', __( 'Delete the whole activity log?', 'handymcp' ) ) . '' : '' ); echo ''; if ( ! $res['rows'] ) { echo '

' . esc_html__( 'Nothing here yet', 'handymcp' ) . '

' . esc_html__( 'Requests from AI apps show up here.', 'handymcp' ) . '

'; return; } $clients = $this->client_names(); $tools = $this->c->get( 'registry' )->all(); $today = current_time( 'Y-m-d' ); $yday = wp_date( 'Y-m-d', time() - DAY_IN_SECONDS ); $last = ''; echo '
    '; foreach ( $res['rows'] as $row ) { $day = get_date_from_gmt( $row->created_at, 'Y-m-d' ); if ( $day !== $last ) { $last = $day; echo '
  • ' . esc_html( $day === $today ? __( 'Today', 'handymcp' ) : ( $day === $yday ? __( 'Yesterday', 'handymcp' ) : date_i18n( 'l, j M Y', strtotime( $day ) ) ) ) . '
  • '; } $user = get_userdata( (int) $row->user_id ); $state = 'success' === $row->status ? ( (int) $row->is_write ? 'change' : 'read' ) : ( 'denied' === $row->status ? 'denied' : 'error' ); $icon = array( 'change' => 'yes', 'read' => 'visibility', 'denied' => 'lock', 'error' => 'no-alt' )[ $state ]; $label = array( 'change' => __( 'Changed', 'handymcp' ), 'read' => __( 'Read', 'handymcp' ), 'denied' => __( 'Refused', 'handymcp' ), 'error' => __( 'Error', 'handymcp' ) )[ $state ]; $title = self::change_info( (string) $row->tool, $tools )[0]; $target = $this->target_link( (string) $row->object_ref ); $who = trim( ( $user ? $user->display_name : '' ) . ( $row->client ? ' · ' . ( $clients[ $row->client ] ?? $row->client ) : '' ), ' ·' ); echo '
  • ' . esc_html( $label ) . ''; echo wp_kses( '
    ' . esc_html( $title ) . ( $target ? ' ' . $target . '' : '' ) . '
    ', \HandyMCP\Support\Html::allowed() ); echo '
    ' . esc_html( $row->tool ) . '' . ( $row->summary ? '' . esc_html( mb_strimwidth( (string) $row->summary, 0, 160, '…' ) ) . '' : '' ) . '
    '; if ( $row->args && '[]' !== $row->args && '{}' !== $row->args ) { echo '
    ' . esc_html__( 'Details', 'handymcp' ) . '
    ' . esc_html( (string) wp_json_encode( json_decode( (string) $row->args, true ), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ) . '
    '; } echo '
    ' . esc_html( mb_strimwidth( $who, 0, 34, '…' ) ) . '
  • '; } echo '
'; if ( $res['total'] > ( $older + 1 ) * $per ) { echo '

' . esc_html__( 'Older activity', 'handymcp' ) . '

'; } } /** * OAuth client id => app name. * * @return array */ private function client_names(): array { global $wpdb; $out = array(); foreach ( (array) $wpdb->get_results( $wpdb->prepare( 'SELECT client_id, client_name FROM %i', Installer::table( 'oauth_clients' ) ) ) as $r ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table, admin screen needs current data. $out[ $r->client_id ] = $r->client_name ? $r->client_name : $r->client_id; } return $out; } /** * Undo history tab: changes grouped by day, then by task (one conversation * with an AI app). A task can be undone as a whole or change by change. */ private function tab_history(): void { $change = isset( $_GET['change'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['change'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only view. if ( '' !== $change ) { $this->render_change( $change ); return; } $paged = isset( $_GET['paged'] ) ? max( 1, (int) $_GET['paged'] ) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $rows = \HandyMCP\Tools\History::operations( 150, ( $paged - 1 ) * 150 ); foreach ( $rows as &$row ) { if ( 0 === strpos( (string) $row['tool'], 'revert ' ) ) { $row['tool'] = 'undo:' . substr( (string) $row['tool'], 7 ); // Older history rows. } } unset( $row ); $tools = $this->c->get( 'registry' )->all(); $days = (int) $this->c->get( 'settings' )->get( 'history_days', 30 ); $this->page_head( __( 'Changes made by AI apps', 'handymcp' ), __( 'See what changed and undo any of the last 20 changes an AI app made. HandyMCP Pro keeps the full history.', 'handymcp' ), $rows ? $this->mini_form( 'clear_history', array(), __( 'Clear history', 'handymcp' ), true, 'button-link-delete', __( 'Delete the whole undo history? Changes made so far can no longer be undone.', 'handymcp' ) ) : '' ); if ( ! $rows ) { echo '

' . esc_html__( 'No changes yet', 'handymcp' ) . '

' . esc_html( sprintf( /* translators: %d days */ __( 'The last 20 changes made by AI apps appear here and can be undone for %d days.', 'handymcp' ), $days ) ) . '

'; return; } $this->pro_history_note( count( array_unique( wp_list_pluck( $rows, 'op_id' ) ) ) ); $titles = array(); $kinds = wp_list_pluck( $rows, 'tool', 'op_id' ); foreach ( $rows as $r ) { $titles[ $r['op_id'] ] = self::change_info( (string) $r['tool'], $tools, (string) $r['labels'] )[0]; $ref = 0 === strpos( (string) $r['tool'], 'undo:' ) ? substr( (string) $r['tool'], 5 ) : ''; if ( $ref && isset( $kinds[ $ref ] ) && 0 === strpos( (string) $kinds[ $ref ], 'undo:' ) ) { $titles[ $r['op_id'] ] = preg_replace( '/^' . preg_quote( __( 'Undid changes to', 'handymcp' ), '/' ) . '/', __( 'Redid changes to', 'handymcp' ), $titles[ $r['op_id'] ] ); } } // Group: day > task (same conversation, no gap over 20 minutes). $days_list = array(); foreach ( $rows as $r ) { $day = get_date_from_gmt( $r['created_at'], 'Y-m-d' ); $time = strtotime( $r['created_at'] . ' UTC' ); // Older changes have no task id: group them by person and time instead. $key = (string) ( $r['task'] ? $r['task'] : 'user-' . $r['user_id'] ); $last = isset( $days_list[ $day ] ) ? end( $days_list[ $day ] ) : null; if ( $last && $last['key'] === $key && $last['start'] - $time < 20 * MINUTE_IN_SECONDS ) { $idx = key( $days_list[ $day ] ); $days_list[ $day ][ $idx ]['ops'][] = $r; $days_list[ $day ][ $idx ]['start'] = $time; } else { $days_list[ $day ][] = array( 'key' => $key, 'start' => $time, 'end' => $time, 'ops' => array( $r ) ); } } $today = current_time( 'Y-m-d' ); $yday = wp_date( 'Y-m-d', time() - DAY_IN_SECONDS ); echo '
'; foreach ( $days_list as $day => $tasks ) { $label = $day === $today ? __( 'Today', 'handymcp' ) : ( $day === $yday ? __( 'Yesterday', 'handymcp' ) : date_i18n( 'l, j M Y', strtotime( $day ) ) ); echo '

' . esc_html( $label ) . '

'; foreach ( $tasks as $task ) { $this->render_task( $task, $titles, $tools ); } } echo '
'; if ( 150 === count( $rows ) ) { echo '

' . esc_html__( 'Older changes', 'handymcp' ) . '

'; } } /** * One task card. * * @param array $task Task. * @param array $titles Op titles. * @param array> $tools Tools. */ private function render_task( array $task, array $titles, array $tools ): void { $ops = $task['ops']; $changes = array_filter( $ops, static function ( $r ) { return 0 !== strpos( (string) $r['tool'], 'undo:' ); } ); $open = array_filter( $changes, static function ( $r ) { return empty( $r['reverted_at'] ); } ); $touched = array(); foreach ( $changes as $r ) { foreach ( array_filter( array_map( 'trim', explode( '|', (string) $r['labels'] ) ) ) as $l ) { $l = (string) preg_replace( '/^New (\S+)/', '$1', $l ); $touched[ ucfirst( $l ) ] = true; } } $user = get_userdata( (int) $ops[0]['user_id'] ); $from = wp_date( get_option( 'time_format' ), (int) $task['start'] ); $to = wp_date( get_option( 'time_format' ), (int) $task['end'] ); $single = 1 === count( $ops ); echo '
'; $first_op = $ops[0]; $headline = $single ? $titles[ $ops[0]['op_id'] ] : ( $titles[ $first_op['op_id'] ] ?? '' ) . ' ' . sprintf( /* translators: %d more */ _n( 'and %d more change', 'and %d more changes', count( $ops ) - 1, 'handymcp' ), count( $ops ) - 1 ); echo '
' . esc_html( $headline ) . '
'; echo '
' . esc_html( $from === $to ? $from : $from . ' to ' . $to ) . '' . ( $user ? '·' . esc_html( $user->display_name ) . '' : '' ) . '
'; if ( $touched && ! $single ) { $names = array_keys( $touched ); echo '
'; foreach ( array_slice( $names, 0, 4 ) as $l ) { echo '' . esc_html( mb_strimwidth( $l, 0, 44, '…' ) ) . ''; } if ( count( $names ) > 4 ) { echo '+' . ( count( $names ) - 4 ) . ''; } echo '
'; } echo '
'; if ( ! $single && $open ) { echo wp_kses( $this->mini_form( 'undo_many', array( 'ops' => implode( ',', wp_list_pluck( $open, 'op_id' ) ) ), count( $open ) > 1 ? sprintf( /* translators: %d changes */ __( 'Undo all %d', 'handymcp' ), count( $open ) ) : __( 'Undo', 'handymcp' ), true, 'mx-btn-undo', __( 'Undo every change in this task? Everything goes back to how it was before the task started.', 'handymcp' ) ), \HandyMCP\Support\Html::allowed() ); } elseif ( $single ) { echo wp_kses( $this->op_action( $ops[0], $titles ), \HandyMCP\Support\Html::allowed() ); } elseif ( ! $open ) { echo '' . esc_html__( 'All undone', 'handymcp' ) . ''; } echo '
'; if ( ! $single ) { echo '
' . esc_html__( 'Show each change', 'handymcp' ) . '
    '; foreach ( $ops as $r ) { $info = self::change_info( (string) $r['tool'], $tools, (string) $r['labels'] ); $undone = ! empty( $r['reverted_at'] ); echo wp_kses( '
  • ' . esc_html( $titles[ $r['op_id'] ] ?? $info[0] ) . '
    ' . esc_html( get_date_from_gmt( $r['created_at'], get_option( 'time_format' ) ) ) . '
    ' . $this->op_action( $r, $titles ) . '
  • ', \HandyMCP\Support\Html::allowed() ); } echo '
'; } echo '
'; } /** * Before and after of one change. * * @param string $op Operation id. */ private function render_change( string $op ): void { $valid = \HandyMCP\Support\ChangeView::valid_op( $op ); $sum = $valid ? \HandyMCP\Tools\History::op_summary( $op ) : array(); $back = '' . esc_html__( 'Back to Changes', 'handymcp' ) . ''; $desc = __( 'Each field as it was before the change, and as it is now.', 'handymcp' ); if ( $sum ) { $title = self::change_info( (string) $sum['tool'], $this->c->get( 'registry' )->all(), (string) $sum['labels'] )[0]; $who = get_userdata( (int) $sum['user_id'] ); $desc = $title . ' · ' . get_date_from_gmt( (string) $sum['created_at'], get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ) . ( $who ? ' · ' . $who->display_name : '' ); $back .= ! empty( $sum['reverted_at'] ) ? ' ' . esc_html__( 'Undone', 'handymcp' ) . '' : ' ' . $this->undo_button( $sum ); } $this->page_head( __( 'What changed', 'handymcp' ), $desc, $back ); $items = $valid ? \HandyMCP\Support\ChangeView::build( $op, current_user_can( 'manage_options' ) ) : array(); if ( ! $items ) { echo '

' . esc_html__( 'This change is no longer in the history', 'handymcp' ) . '

' . esc_html__( 'Older changes are removed after the number of days set in Settings.', 'handymcp' ) . '

'; return; } echo ''; foreach ( $items as $item ) { echo '

' . ( '' !== $item['link'] ? '' . esc_html( $item['label'] ) . '' : esc_html( $item['label'] ) ) . '

'; if ( '' !== $item['note'] ) { echo '

' . esc_html( $item['note'] ) . '

'; } $short = array_filter( $item['fields'], static function ( $f ) { return empty( $f['long'] ); } ); if ( $short ) { echo ''; foreach ( $short as $f ) { echo ''; } echo '
' . esc_html__( 'Field', 'handymcp' ) . '' . esc_html__( 'Before', 'handymcp' ) . '' . esc_html__( 'Now', 'handymcp' ) . '
' . esc_html( $f['name'] ) . '' . ( '' === $f['before'] ? '' . esc_html__( '(empty)', 'handymcp' ) . '' : esc_html( $f['before'] ) ) . '' . ( '' === $f['after'] ? '' . esc_html__( '(empty)', 'handymcp' ) . '' : esc_html( $f['after'] ) ) . '
'; } foreach ( $item['fields'] as $f ) { if ( empty( $f['long'] ) ) { continue; } $diff = \HandyMCP\Support\ChangeView::diff_html( $f['before'], $f['after'] ); if ( '' !== $diff ) { echo '

' . esc_html( $f['name'] ) . '

' . wp_kses_post( $diff ) . '
'; } } echo '
'; } echo ''; } /** * Undo / Redo button or state for one change. * * @param array $r Row. * @param array $titles Titles. */ private function op_action( array $r, array $titles ): string { $is_undo = 0 === strpos( (string) $r['tool'], 'undo:' ); $view = '' . esc_html__( 'View changes', 'handymcp' ) . ''; if ( ! empty( $r['reverted_at'] ) ) { return $view . '' . esc_html( $is_undo ? __( 'Redone', 'handymcp' ) : __( 'Undone', 'handymcp' ) ) . ''; } return $view . $this->undo_button( $r ) . ''; } /** * Undo or Redo form for one change. * * @param array $r Row. */ private function undo_button( array $r ): string { $is_undo = 0 === strpos( (string) $r['tool'], 'undo:' ); return $this->mini_form( 'undo', array( 'history_id' => $r['op_id'] ), $is_undo ? __( 'Redo', 'handymcp' ) : __( 'Undo', 'handymcp' ), true, 'mx-btn-undo', $is_undo ? __( 'Redo this change?', 'handymcp' ) : __( 'Undo this change? Everything it touched goes back to how it was before.', 'handymcp' ) ); } /** * Friendly title, icon and colour for a change. * * @param string $tool Tool name. * @param array> $tools Registered tools. * @return array{0:string,1:string,2:string} */ private static function change_info( string $tool, array $tools, string $labels = '' ): array { $first = trim( (string) strtok( $labels, '|' ) ); if ( 0 === strpos( $tool, 'revert ' ) ) { $tool = 'undo:' . substr( $tool, 7 ); } if ( 0 === strpos( $tool, 'undo:' ) ) { return array( sprintf( /* translators: %s what */ __( 'Undid changes to %s', 'handymcp' ), $first ? $first : __( 'the site', 'handymcp' ) ), 'undo', 'grey' ); } $verbs = array( 'elementor_save_page' => __( 'Saved Elementor layout', 'handymcp' ), 'elementor_update_element' => __( 'Edited an Elementor element', 'handymcp' ), 'elementor_add_element' => __( 'Added an Elementor element', 'handymcp' ), 'elementor_remove_element' => __( 'Removed an Elementor element', 'handymcp' ), 'elementor_move_element' => __( 'Moved an Elementor element', 'handymcp' ), 'elementor_save_template' => __( 'Saved Elementor template', 'handymcp' ), 'elementor_page_settings' => __( 'Changed page settings', 'handymcp' ), 'elementor_update_globals' => __( 'Changed Elementor global colors and fonts', 'handymcp' ), 'gutenberg_save_content' => __( 'Saved block content', 'handymcp' ), 'sections_build_page' => __( 'Built a page from sections', 'handymcp' ), 'wp_create_post' => __( 'Created', 'handymcp' ), 'wp_update_post' => __( 'Edited', 'handymcp' ), 'wp_duplicate_post' => __( 'Duplicated', 'handymcp' ), 'wp_edit_text' => __( 'Changed text in', 'handymcp' ), 'wp_trash_post' => __( 'Moved to trash', 'handymcp' ), 'wp_upload_media' => __( 'Uploaded media', 'handymcp' ), 'images_add' => __( 'Added images', 'handymcp' ), 'wp_edit_image' => __( 'Edited an image', 'handymcp' ), 'wp_regenerate_thumbnails' => __( 'Rebuilt thumbnails', 'handymcp' ), 'wp_replace_media' => __( 'Replaced a media file', 'handymcp' ), 'wp_save_menu_item' => __( 'Changed a menu', 'handymcp' ), 'woo_save_product' => __( 'Saved product', 'handymcp' ), 'batch' => __( 'Several changes in one step', 'handymcp' ), ); $icons = array( 'elementor' => array( 'layout', 'purple' ), 'gutenberg' => array( 'block-default', 'purple' ), 'sections' => array( 'layout', 'purple' ), 'woo' => array( 'cart', 'green' ), 'forms' => array( 'feedback', 'green' ), 'cf7' => array( 'feedback', 'green' ), 'files' => array( 'media-code', 'red' ), 'plugins' => array( 'admin-plugins', 'blue' ), 'themes' => array( 'admin-appearance', 'blue' ), 'updates' => array( 'update', 'blue' ), 'settings' => array( 'admin-settings', 'amber' ), 'customizer' => array( 'admin-customizer', 'amber' ), 'brand' => array( 'art', 'amber' ), 'users' => array( 'admin-users', 'amber' ), 'search_replace' => array( 'search', 'red' ), 'cache' => array( 'performance', 'blue' ), 'seo' => array( 'chart-line', 'green' ), 'images' => array( 'format-image', 'purple' ), 'xstore' => array( 'layout', 'purple' ), 'uae' => array( 'layout', 'purple' ), 'schedule' => array( 'calendar-alt', 'blue' ), 'white_label' => array( 'hidden', 'amber' ), 'hub' => array( 'networking', 'blue' ), 'hub_client' => array( 'networking', 'blue' ), 'beaver' => array( 'layout', 'purple' ), 'acf' => array( 'database', 'green' ), 'security' => array( 'shield', 'red' ), 'wp_' => array( 'admin-post', 'purple' ) ); $icon = array( 'marker', 'grey' ); foreach ( $icons as $k => $v ) { if ( 0 === strpos( $tool, $k ) ) { $icon = $v; break; } } if ( isset( $verbs[ $tool ] ) ) { $title = $verbs[ $tool ] . ( $first ? ': ' . $first : '' ); } else { $title = isset( $tools[ $tool ]['title'] ) && $tools[ $tool ]['title'] ? self::short_text( (string) $tools[ $tool ]['title'], 60 ) : ucfirst( str_replace( '_', ' ', $tool ) ); if ( $first ) { $title .= ': ' . $first; } } return array( $title, $icon[0], $icon[1] ); } /** * Link to the object a log row refers to. * * @param string $ref Reference like post_id:12. */ private function target_link( string $ref ): string { if ( ! preg_match( '/^(post_id|id|product_id|order_id|template_id):(\d+)$/', $ref, $m ) ) { return esc_html( $ref ); } $id = (int) $m[2]; $title = get_the_title( $id ); $link = get_edit_post_link( $id ); if ( ! $link ) { return esc_html( '#' . $id ); } return '' . esc_html( ( $title ? $title : '#' . $id ) ) . ''; } /** * About tab: who makes the plugin and how to reach him. */ private function tab_about(): void { $site = 'https://sukeshdas.com/'; $utm = array( 'utm_source' => 'handymcp', 'utm_medium' => 'about' ); $link = static function ( string $path ) use ( $site, $utm ): string { return add_query_arg( $utm, 0 === strpos( $path, 'https://' ) ? $path : $site . ltrim( $path, '/' ) ); }; $stats = array( array( '1,200+', __( 'Websites built', 'handymcp' ) ), array( '70', __( 'Countries served', 'handymcp' ) ), array( '2018', __( 'Building since', 'handymcp' ) ), array( __( 'Top Rated', 'handymcp' ), __( 'Fiverr Vetted Pro', 'handymcp' ) ), ); $services = array( __( 'WordPress and WooCommerce development', 'handymcp' ), __( 'Custom plugins and integrations', 'handymcp' ), __( 'AI automation and MCP setups', 'handymcp' ), __( 'Squarespace sites (Circle Platinum Partner)', 'handymcp' ), __( 'Analytics and conversion tracking', 'handymcp' ), __( 'Speed, security and care plans', 'handymcp' ), ); $this->page_head( __( 'About', 'handymcp' ), __( 'Who builds this plugin, and how to reach him.', 'handymcp' ) ); echo '
'; echo '
'; echo '

' . esc_html__( 'Made by Sukesh Das', 'handymcp' ) . '

'; echo '

' . esc_html__( 'HandyMCP is built and maintained by Sukesh Das, an independent web developer. I build WordPress, WooCommerce and custom websites for agencies, coaches, healthcare practices and small businesses, and I made this plugin so AI apps can do real work on those sites safely, with every change checked and easy to undo.', 'handymcp' ) . '

'; echo '
'; echo '
'; foreach ( $stats as $st ) { echo '
' . esc_html( $st[1] ) . '
' . esc_html( $st[0] ) . '
'; } echo '
'; echo '
'; echo '

' . esc_html__( 'What I can help with', 'handymcp' ) . '

' . esc_html__( 'Need more than the plugin? I take on projects and ongoing work.', 'handymcp' ) . '

    '; foreach ( $services as $sv ) { echo '
  • ' . esc_html( $sv ) . '
  • '; } echo '
' . esc_html__( 'Start a project', 'handymcp' ) . '
'; echo '

' . esc_html__( 'Help with the plugin', 'handymcp' ) . '

' . esc_html__( 'Questions, bugs or ideas go straight to me.', 'handymcp' ) . '

'; /* translators: %s: plugin version. */ echo '

' . esc_html( sprintf( __( 'HandyMCP %s. Thank you for using it.', 'handymcp' ), HANDYMCP_VERSION ) ) . '

'; } /** * Upgrade tab: what HandyMCP Pro adds. */ private function tab_upgrade(): void { $url = self::pro_url( 'upgrade' ); $trial = self::trial_url( 'upgrade' ); $this->page_head( __( 'HandyMCP Pro', 'handymcp' ), __( 'Everything in HandyMCP, plus the rest of your site.', 'handymcp' ), '' . esc_html__( 'See plans and pricing', 'handymcp' ) . '' ); $groups = array( array( 'yes-alt', __( 'Approval mode and protected pages', 'handymcp' ), __( 'AI changes to live pages wait for you, with the text and a live desktop and mobile preview side by side. Lock pages AI apps must never change, and fix any page with AI from the Pages, Posts and Products lists.', 'handymcp' ), ), array( 'layout', __( 'Every page builder', 'handymcp' ), __( 'WPBakery, Avada, Divi, Beaver Builder, Flatsome UX Builder, GeneratePress Elements, Blocksy, Astra and XStore, plus Elementor Pro Theme Builder (headers, footers, single and archive templates, popups with display conditions) and Elementor addon packs.', 'handymcp' ), ), array( 'cart', __( 'WooCommerce and Easy Digital Downloads', 'handymcp' ), __( 'The free plugin edits products, prices, stock and simple coupons. Pro adds variations, order updates and refunds, coupon product rules, customers, reviews, sales reports, shipping and taxes, dynamic pricing and cart abandonment.', 'handymcp' ), ), array( 'translation', __( 'Languages and custom fields', 'handymcp' ), __( 'WPML, Polylang and translation plugins. ACF, Meta Box, Pods and JetEngine fields and post types.', 'handymcp' ), ), array( 'chart-line', __( 'All SEO plugins', 'handymcp' ), __( 'All in One SEO, SEOPress and The SEO Framework, canonical URLs and noindex, and bulk fixes for pages with missing titles or descriptions.', 'handymcp' ), ), array( 'feedback', __( 'Forms and email', 'handymcp' ), __( 'Create and edit Contact Form 7, Gravity Forms, WPForms and MetForm forms, manage entries, and check mail delivery.', 'handymcp' ), ), array( 'admin-plugins', __( 'Plugins, themes, users and settings', 'handymcp' ), __( 'Install and update plugins and themes with a backup and automatic rollback, scheduled updates with email reports, Customizer, site settings, users and comments.', 'handymcp' ), ), array( 'shield', __( 'Speed, security and backups', 'handymcp' ), __( 'Cache plugins, full Wordfence control (who is blocked, block and unblock, login attempts, every setting), security scans, backups before risky work, site and page audits, brand kit and ready sections.', 'handymcp' ), ), array( 'media-code', __( 'Code and developer tools', 'handymcp' ), __( 'Build a new block theme from a design and review it on a private preview link before it goes live. Run familiar WP-CLI commands without a shell. Edit theme and plugin files and switch on code snippets: every change is checked, backed up and undone automatically if the site breaks. Also search and replace, REST routes and WordPress abilities.', 'handymcp' ), ), array( 'update', __( 'Safe scheduled updates', 'handymcp' ), __( 'Daily or weekly updates of plugins and themes. Each one is backed up first, then the home page, login, REST API and admin-ajax are checked. If the update broke the site, the old version is put back automatically and the report says so. WordPress auto-updates leave a broken site broken.', 'handymcp' ), ), array( 'warning', __( 'Malware cleanup', 'handymcp' ), __( 'Start scans in Wordfence, Sucuri or Defender and read what they found. Then let the AI clean infected theme and plugin files and spam in the database, with backups and undo. HandyMCP does not scan by itself; it works with your security plugin.', 'handymcp' ), ), array( 'backup', __( 'Full undo history', 'handymcp' ), __( 'Undo any change for as long as you keep the history, not only the last 20.', 'handymcp' ), ), array( 'networking', __( 'For agencies', 'handymcp' ), __( 'White label on client sites with every Pro plan. The Agency plan adds a client sites hub to see, update and manage every site from one dashboard and one AI connection.', 'handymcp' ), ), ); $rows = array( array( __( 'Posts, pages, media, menus and terms', 'handymcp' ), true, true ), array( __( 'Block editor (Gutenberg) pages', 'handymcp' ), true, true ), array( __( 'Elementor pages, widgets, templates, global colors and fonts', 'handymcp' ), true, true ), array( __( 'Stock photos (Openverse, Pexels, Unsplash) with alt text and credit', 'handymcp' ), true, true ), array( __( 'Stock videos from Pexels', 'handymcp' ), true, true ), array( __( 'Upload images from the chat or any image URL', 'handymcp' ), true, true ), array( __( 'Crop, resize, rotate and convert images to WebP, rebuild thumbnails', 'handymcp' ), true, true ), array( __( 'Replace an image and keep its URL, with a backup to restore', 'handymcp' ), true, true ), array( __( 'SEO titles and descriptions, and a list of pages missing them (Yoast SEO, Rank Math)', 'handymcp' ), true, true ), array( __( 'WooCommerce products, prices, stock and categories', 'handymcp' ), true, true ), array( __( 'WooCommerce coupons', 'handymcp' ), __( 'Create and edit', 'handymcp' ), __( 'Plus product and category rules', 'handymcp' ) ), array( __( 'WooCommerce orders', 'handymcp' ), __( 'View', 'handymcp' ), __( 'View, update and refund', 'handymcp' ) ), array( __( 'Contact Form 7 and WPForms', 'handymcp' ), __( 'Create and edit', 'handymcp' ), __( 'Create and edit', 'handymcp' ) ), array( __( 'Gravity Forms', 'handymcp' ), __( 'Read only', 'handymcp' ), __( 'Create and edit', 'handymcp' ) ), array( __( 'Build MetForm forms and manage form entries', 'handymcp' ), false, true ), array( __( 'Check that site email is being delivered', 'handymcp' ), false, true ), array( __( 'Change history and undo', 'handymcp' ), __( 'Last 20 changes', 'handymcp' ), __( 'Full history', 'handymcp' ) ), array( __( 'Before and after view of every AI change', 'handymcp' ), true, true ), array( __( 'Clear crash reports that name the plugin or theme at fault', 'handymcp' ), true, true ), array( __( 'Activity log, OAuth, roles and rate limits', 'handymcp' ), true, true ), array( __( 'Site brief: your voice and rules for every AI app', 'handymcp' ), true, true ), array( __( 'No daily limits, credits or per-call fees', 'handymcp' ), true, true ), array( __( 'Approval mode: check AI changes with a live before and after preview', 'handymcp' ), false, true ), array( __( 'Protected pages AI apps cannot change', 'handymcp' ), false, true ), array( __( 'Fix with AI from the Pages, Posts and Products lists', 'handymcp' ), false, true ), array( __( 'Elementor Pro Theme Builder and popups', 'handymcp' ), false, true ), array( __( 'WPBakery, Avada, Divi, Beaver Builder, Flatsome and Elementor addon packs', 'handymcp' ), false, true ), array( __( 'WooCommerce variations, customers and sales reports', 'handymcp' ), false, true ), array( __( 'Easy Digital Downloads, dynamic pricing and cart abandonment', 'handymcp' ), false, true ), array( __( 'WPML, Polylang, ACF, Meta Box, Pods and JetEngine', 'handymcp' ), false, true ), array( __( 'All SEO plugins and bulk SEO fixes', 'handymcp' ), false, true ), array( __( 'Plugin and theme updates with backup and rollback', 'handymcp' ), false, true ), array( __( 'Safe scheduled updates with site checks, automatic rollback and email reports', 'handymcp' ), false, true ), array( __( 'Malware cleanup with your security plugin, with backups and undo', 'handymcp' ), false, true ), array( __( 'Search and replace across the whole site, with preview and undo', 'handymcp' ), false, true ), array( __( 'Theme settings, Flatsome, GeneratePress, Blocksy, XStore and Ultimate Addons', 'handymcp' ), false, true ), array( __( 'Brand kit, ready sections and page audits', 'handymcp' ), false, true ), array( __( 'Users, roles and comments', 'handymcp' ), false, true ), array( __( 'Customizer and site settings', 'handymcp' ), false, true ), array( __( 'Redirection, GTM4WP, MailPoet, TablePress, Events Calendar, WP Mail SMTP', 'handymcp' ), false, true ), array( __( 'Cache, security and backup plugins, with a backup before risky work', 'handymcp' ), false, true ), array( __( 'Build custom themes and plugins, checked file edits and code snippets', 'handymcp' ), false, true ), array( __( 'Build a new block theme with a private preview before it goes live', 'handymcp' ), false, true ), array( __( 'WP-CLI style commands, no shell or WP-CLI install needed', 'handymcp' ), false, true ), array( __( 'Switch on PHP snippets safely: site check and automatic switch-off', 'handymcp' ), false, true ), array( __( 'Full Wordfence control: blocked IPs, unblock, login attempts and settings', 'handymcp' ), false, true ), array( __( 'REST routes and WordPress abilities from other plugins', 'handymcp' ), false, true ), array( __( 'Priority email support', 'handymcp' ), false, true ), array( __( 'White label: hide the plugin on client sites', 'handymcp' ), false, true ), array( __( 'Client sites hub: manage every site from one dashboard', 'handymcp' ), false, __( 'Agency plan', 'handymcp' ) ), ); $cell = static function ( $v ): string { if ( true === $v ) { return '' . esc_html__( 'Included', 'handymcp' ) . ''; } if ( false === $v ) { return '' . esc_html__( 'Not included', 'handymcp' ) . ''; } return esc_html( (string) $v ); }; echo '
'; echo '
'; foreach ( $rows as $r ) { echo wp_kses( '', \HandyMCP\Support\Html::allowed() ); } echo '
' . esc_html__( 'Feature', 'handymcp' ) . '' . esc_html__( 'Free', 'handymcp' ) . '' . esc_html__( 'This plugin', 'handymcp' ) . '' . esc_html__( 'Pro', 'handymcp' ) . '' . esc_html__( 'Everything, plus the rest of your site', 'handymcp' ) . '
' . esc_html( $r[0] ) . '' . $cell( $r[1] ) . '' . $cell( $r[2] ) . '
'; echo ''; echo '
'; echo '

' . esc_html__( 'Pro features in detail', 'handymcp' ) . '

'; foreach ( $groups as $g ) { echo '

' . esc_html( $g[1] ) . '

' . esc_html( $g[2] ) . '

'; } echo '
'; echo '

' . esc_html__( 'Moving to Pro is one click', 'handymcp' ) . '

' . esc_html__( 'Install HandyMCP Pro and activate it. It turns this plugin off and keeps your connected apps, settings, activity and undo history, so your AI apps keep working without connecting again.', 'handymcp' ) . '

'; } /** * Free trial link (opens the trial form on handymcp.com). * * @param string $where Screen or element, for the link. */ private static function trial_url( string $where ): string { return add_query_arg( array( 'utm_source' => 'plugin', 'utm_medium' => sanitize_key( $where ) . '-trial', 'utm_campaign' => 'free' ), 'https://handymcp.com/' ) . '#free-trial'; } /** * Small free trial card under the side nav, or at the end of the page on narrow screens. * * @param string $where side or bottom. * @param string $tab Current tab, to come back to after hiding it. */ private function trial_card( string $where, string $tab ): string { if ( in_array( 'trial', self::pro_hidden(), true ) ) { return ''; } return '
' . $this->pro_dismiss( 'trial', $tab ) . '' . esc_html__( '7 days free', 'handymcp' ) . '' . esc_html__( 'Try HandyMCP Pro', 'handymcp' ) . '

' . esc_html__( 'WooCommerce, every builder, safe updates and full undo. No card needed.', 'handymcp' ) . '

' . esc_html__( 'Start free trial', 'handymcp' ) . '
'; } /** * Link to the HandyMCP Pro pricing page, tagged with where it was clicked. * * @param string $where Screen or element the link sits on. */ private static function pro_url( string $where ): string { return add_query_arg( array( 'utm_source' => 'plugin', 'utm_medium' => sanitize_key( $where ), 'utm_campaign' => 'free' ), 'https://handymcp.com/#pricing' ); } /** * Small "Pro" label. */ private static function pro_pill( string $plan = '' ): string { return '' . esc_html( '' !== $plan ? $plan : __( 'Pro', 'handymcp' ) ) . ''; } /** * "Go Pro" button (opens the pricing page in a new tab). * * @param string $where Screen or element, for the link. * @param string $label Button text. * @param string $class Extra classes. */ private static function pro_button( string $where, string $label = '', string $class = 'button-primary' ): string { $label = '' !== $label ? $label : __( 'Go Pro', 'handymcp' ); return '' . esc_html( $label ) . ' ' . esc_html__( '(opens in a new tab)', 'handymcp' ) . ''; } /** * Pro sections the current user has hidden. * * @return array */ private static function pro_hidden(): array { $v = get_user_meta( get_current_user_id(), 'handymcp_hidden_pro', true ); return is_array( $v ) ? array_values( array_filter( $v, 'is_string' ) ) : array(); } /** * Close button that hides one Pro section for the current user. * * @param string $id Section id. * @param string $tab Tab to come back to. */ private function pro_dismiss( string $id, string $tab ): string { $html = '
'; $html .= '' . wp_referer_field( false ); $html .= ''; $html .= '
'; return $html; } /** * What HandyMCP Pro adds, by category. Each entry: icon, name, benefit, * and how to tell whether the plugin or theme is active here (constants, * classes or theme names, with the name to show). Nothing here runs Pro code. * * @return array>}> */ private static function pro_catalog(): array { return array( 'builders' => array( __( 'Page builders', 'handymcp' ), array( array( 'icon' => 'layout', 'name' => 'WPBakery', 'text' => __( 'Build and edit WPBakery pages, rows and elements.', 'handymcp' ), 'on' => __( 'With Pro the AI can build and edit your WPBakery pages.', 'handymcp' ), 'detect' => array( 'const:WPB_VC_VERSION' => 'WPBakery' ) ), array( 'icon' => 'layout', 'name' => 'Avada', 'text' => __( 'Fusion Builder pages, containers and elements.', 'handymcp' ), 'on' => __( 'With Pro the AI can build and edit Avada pages with Fusion Builder.', 'handymcp' ), 'detect' => array( 'const:FUSION_BUILDER_VERSION' => 'Avada', 'theme:Avada' => 'Avada' ) ), array( 'icon' => 'layout', 'name' => 'Divi', 'text' => __( 'Divi Builder layouts, modules and text.', 'handymcp' ), 'on' => __( 'With Pro the AI can edit Divi layouts and modules.', 'handymcp' ), 'detect' => array( 'const:ET_BUILDER_VERSION' => 'Divi', 'theme:Divi' => 'Divi' ) ), array( 'icon' => 'layout', 'name' => 'Beaver Builder', 'text' => __( 'Beaver Builder layouts, rows and modules.', 'handymcp' ), 'on' => __( 'With Pro the AI can edit Beaver Builder layouts.', 'handymcp' ), 'detect' => array( 'const:FL_BUILDER_VERSION' => 'Beaver Builder' ) ), array( 'icon' => 'layout', 'name' => 'Flatsome', 'text' => __( 'UX Builder pages, UX Blocks, header, footer and every theme option.', 'handymcp' ), 'on' => __( 'With Pro the AI can build Flatsome pages and customize the header, footer and theme options.', 'handymcp' ), 'detect' => array( 'theme:Flatsome' => 'Flatsome' ) ), array( 'icon' => 'welcome-widgets-menus', 'name' => __( 'Elementor Pro Theme Builder and popups', 'handymcp' ), 'text' => __( 'Headers, footers, single and archive templates, popups with conditions.', 'handymcp' ), 'on' => __( 'With Pro the AI can build headers, footers, templates and popups.', 'handymcp' ), 'detect' => array( 'const:ELEMENTOR_PRO_VERSION' => 'Elementor Pro' ) ), array( 'icon' => 'admin-customizer', 'name' => __( 'Theme builders and theme settings', 'handymcp' ), 'text' => __( 'Flatsome header, footer and theme options, GeneratePress Elements, Blocksy, XStore, Ultimate Addons, and settings of Kadence, Astra, OceanWP, Neve and more.', 'handymcp' ), 'on' => __( 'With Pro the AI can change theme settings and theme builder layouts.', 'handymcp' ), 'detect' => array( 'const:GP_PREMIUM_VERSION' => 'GeneratePress Premium', 'const:BLOCKSY_PLUGIN_VERSION' => 'Blocksy Companion', 'theme:XStore' => 'XStore', 'const:UAEL_VER' => 'Ultimate Addons for Elementor', 'theme:Kadence' => 'Kadence', 'theme:Astra' => 'Astra', 'theme:OceanWP' => 'OceanWP', 'theme:Neve' => 'Neve' ) ), array( 'icon' => 'art', 'name' => __( 'Brand kit and ready sections', 'handymcp' ), 'text' => __( 'Save brand colors and fonts once, build pages from ready sections, check pages for SEO and accessibility.', 'handymcp' ), 'detect' => array() ), array( 'icon' => 'screenoptions', 'name' => __( 'Elementor addon packs', 'handymcp' ), 'text' => __( 'ElementsKit, Happy Addons, Premium Addons, PowerPack, JetElements and more.', 'handymcp' ), 'on' => __( 'With Pro the AI can use its widgets when it builds pages.', 'handymcp' ), 'detect' => array( 'const:ELEMENTSKIT_LITE_VERSION' => 'ElementsKit', 'const:HAPPY_ADDONS_VERSION' => 'Happy Addons', 'const:PREMIUM_ADDONS_VERSION' => 'Premium Addons', 'const:POWERPACK_ELEMENTS_LITE_VER' => 'PowerPack', 'class:Jet_Elements' => 'JetElements' ) ), ), ), 'store' => array( __( 'Store', 'handymcp' ), array( array( 'icon' => 'cart', 'name' => 'WooCommerce', 'text' => __( 'Products, variations, stock, orders, refunds, coupon rules and sales reports.', 'handymcp' ), 'on' => __( 'With Pro the AI can manage products, orders and coupons.', 'handymcp' ), 'detect' => array( 'class:WooCommerce' => 'WooCommerce' ) ), array( 'icon' => 'download', 'name' => 'Easy Digital Downloads', 'text' => __( 'Downloads, prices, payments, discounts and customers.', 'handymcp' ), 'on' => __( 'With Pro the AI can manage downloads, payments and discount codes.', 'handymcp' ), 'detect' => array( 'const:EDD_VERSION' => 'Easy Digital Downloads' ) ), array( 'icon' => 'tag', 'name' => __( 'Dynamic pricing', 'handymcp' ), 'text' => __( 'Bulk, tiered and cart discount rules.', 'handymcp' ), 'on' => __( 'With Pro the AI can set up and change your pricing rules.', 'handymcp' ), 'detect' => array( 'const:RP_WCDPD_VERSION' => 'WooCommerce Dynamic Pricing & Discounts' ) ), array( 'icon' => 'email-alt', 'name' => __( 'Cart abandonment', 'handymcp' ), 'text' => __( 'Recovery emails and abandoned cart reports.', 'handymcp' ), 'on' => __( 'With Pro the AI can edit recovery emails and read abandoned carts.', 'handymcp' ), 'detect' => array( 'const:CARTFLOWS_CA_VER' => 'Cart Abandonment Recovery' ) ), array( 'icon' => 'heart', 'name' => __( 'Wishlist and social login', 'handymcp' ), 'text' => __( 'YITH Wishlist settings and Nextend Social Login buttons and providers.', 'handymcp' ), 'on' => __( 'With Pro the AI can set up your wishlist and social login buttons.', 'handymcp' ), 'detect' => array( 'const:YITH_WCWL' => 'YITH WooCommerce Wishlist', 'class:NextendSocialLogin' => 'Nextend Social Login' ) ), ), ), 'languages' => array( __( 'Languages', 'handymcp' ), array( array( 'icon' => 'translation', 'name' => 'WPML', 'text' => __( 'Translate pages and keep languages in sync.', 'handymcp' ), 'on' => __( 'With Pro the AI can translate pages and link them in WPML.', 'handymcp' ), 'detect' => array( 'const:ICL_SITEPRESS_VERSION' => 'WPML' ) ), array( 'icon' => 'translation', 'name' => 'Polylang', 'text' => __( 'Create translations and set each page\'s language.', 'handymcp' ), 'on' => __( 'With Pro the AI can create and link Polylang translations.', 'handymcp' ), 'detect' => array( 'const:POLYLANG_VERSION' => 'Polylang' ) ), array( 'icon' => 'translation', 'name' => 'GTranslate', 'text' => __( 'Language switcher and translation settings.', 'handymcp' ), 'on' => __( 'With Pro the AI can change your GTranslate languages and switcher.', 'handymcp' ), 'detect' => array( 'const:GTRANSLATE_VERSION' => 'GTranslate' ) ), array( 'icon' => 'translation', 'name' => 'Loco Translate', 'text' => __( 'Edit theme and plugin translation strings.', 'handymcp' ), 'on' => __( 'With Pro the AI can fix theme and plugin wording in Loco.', 'handymcp' ), 'detect' => array( 'const:LOCO_VERSION' => 'Loco Translate' ) ), ), ), 'fields' => array( __( 'Custom fields', 'handymcp' ), array( array( 'icon' => 'database', 'name' => 'ACF', 'text' => __( 'Read and fill Advanced Custom Fields, groups and options.', 'handymcp' ), 'on' => __( 'With Pro the AI can read and fill your ACF fields.', 'handymcp' ), 'detect' => array( 'class:ACF' => 'ACF' ) ), array( 'icon' => 'database', 'name' => 'Meta Box', 'text' => __( 'Meta Box fields on posts, pages and custom types.', 'handymcp' ), 'on' => __( 'With Pro the AI can read and fill Meta Box fields.', 'handymcp' ), 'detect' => array( 'const:RWMB_VER' => 'Meta Box' ) ), array( 'icon' => 'database', 'name' => 'Pods', 'text' => __( 'Pods fields and custom content types.', 'handymcp' ), 'on' => __( 'With Pro the AI can work with your Pods content.', 'handymcp' ), 'detect' => array( 'const:PODS_VERSION' => 'Pods' ) ), array( 'icon' => 'database', 'name' => 'JetEngine', 'text' => __( 'JetEngine meta fields and custom post types.', 'handymcp' ), 'on' => __( 'With Pro the AI can fill JetEngine fields.', 'handymcp' ), 'detect' => array( 'class:Jet_Engine' => 'JetEngine' ) ), ), ), 'care' => array( __( 'Site care', 'handymcp' ), array( array( 'icon' => 'performance', 'name' => __( 'Cache plugins', 'handymcp' ), 'text' => __( 'Clear and tune WP Rocket, LiteSpeed, W3 Total Cache and others.', 'handymcp' ), 'on' => __( 'With Pro the AI can clear and tune the cache after every change.', 'handymcp' ), 'detect' => array( 'const:WP_ROCKET_VERSION' => 'WP Rocket', 'const:LSCWP_V' => 'LiteSpeed Cache', 'class:W3TC\\Dispatcher' => 'W3 Total Cache', 'const:WPCACHEHOME' => 'WP Super Cache', 'const:AUTOPTIMIZE_PLUGIN_VERSION' => 'Autoptimize', 'const:WPO_VERSION' => 'WP-Optimize', 'const:FLYING_PRESS_VERSION' => 'FlyingPress' ) ), array( 'icon' => 'shield', 'name' => __( 'Security plugins', 'handymcp' ), 'text' => __( 'Run scans and read results from your security plugin.', 'handymcp' ), 'on' => __( 'With Pro the AI can run scans and explain what they found.', 'handymcp' ), 'detect' => array( 'const:WORDFENCE_VERSION' => 'Wordfence', 'class:ITSEC_Core' => 'Solid Security', 'const:SUCURISCAN_VERSION' => 'Sucuri', 'const:DEFENDER_VERSION' => 'Defender', 'const:AIO_WP_SECURITY_VERSION' => 'All-In-One Security' ) ), array( 'icon' => 'backup', 'name' => __( 'Backups', 'handymcp' ), 'text' => __( 'Take a backup before risky work and list existing ones.', 'handymcp' ), 'on' => __( 'With Pro the AI can take a backup before it makes big changes.', 'handymcp' ), 'detect' => array( 'class:UpdraftPlus' => 'UpdraftPlus', 'class:Ai1wm_Main_Controller' => 'All-in-One WP Migration' ) ), array( 'icon' => 'update', 'name' => __( 'Safe scheduled updates', 'handymcp' ), 'text' => __( 'Weekly or daily updates that back up each plugin or theme first, check the site after, put the old version back if it broke, and email you a report.', 'handymcp' ), 'detect' => array() ), array( 'icon' => 'admin-plugins', 'name' => __( 'Plugins, themes and settings', 'handymcp' ), 'text' => __( 'Install, update and switch plugins and themes with a backup and automatic rollback, plus Customizer and site settings.', 'handymcp' ), 'detect' => array() ), array( 'icon' => 'warning', 'name' => __( 'Malware cleanup', 'handymcp' ), 'text' => __( 'Read what your security plugin found, clean infected theme and plugin files and spam in the database, with backups and undo.', 'handymcp' ), 'detect' => array() ), array( 'icon' => 'search', 'name' => __( 'Search and replace', 'handymcp' ), 'text' => __( 'Change text or URLs everywhere, builder data included, with a preview first and one-click undo.', 'handymcp' ), 'detect' => array() ), array( 'icon' => 'admin-users', 'name' => __( 'Users and roles', 'handymcp' ), 'text' => __( 'Change roles and emails, reset passwords and sign users out, always after you confirm.', 'handymcp' ), 'detect' => array() ), array( 'icon' => 'yes-alt', 'name' => __( 'Site and page audits', 'handymcp' ), 'text' => __( 'Find broken links, slow pages and missing SEO, then fix them.', 'handymcp' ), 'detect' => array() ), array( 'icon' => 'media-code', 'name' => __( 'Custom themes and plugins', 'handymcp' ), 'text' => __( 'Build new themes and plugins, and edit your own theme and plugin files. Every edit is checked, backed up and undone if the site breaks.', 'handymcp' ), 'detect' => array() ), array( 'icon' => 'chart-line', 'name' => __( 'SEO plugin settings', 'handymcp' ), 'text' => __( 'Site-wide titles, schema, social images, sitemaps and category SEO in Rank Math, Yoast, All in One SEO, SEOPress and The SEO Framework.', 'handymcp' ), 'on' => __( 'With Pro the AI can change its site-wide settings, social defaults and category SEO, not just single pages.', 'handymcp' ), 'detect' => array( 'class:RankMath' => 'Rank Math', 'const:WPSEO_VERSION' => 'Yoast SEO', 'const:AIOSEO_VERSION' => 'All in One SEO', 'const:SEOPRESS_VERSION' => 'SEOPress', 'const:THE_SEO_FRAMEWORK_VERSION' => 'The SEO Framework' ) ), array( 'icon' => 'admin-links', 'name' => __( 'More plugins', 'handymcp' ), 'text' => __( 'Redirection, Google Tag Manager for WordPress, MailPoet, TablePress, The Events Calendar, WP Mail SMTP, CPT UI and the WordPress Abilities API.', 'handymcp' ), 'on' => __( 'With Pro the AI can work with it directly.', 'handymcp' ), 'detect' => array( 'const:REDIRECTION_VERSION' => 'Redirection', 'const:GTM4WP_VERSION' => 'GTM4WP', 'const:MAILPOET_VERSION' => 'MailPoet', 'class:TablePress' => 'TablePress', 'class:Tribe__Events__Main' => 'The Events Calendar', 'class:WPMailSMTP\\Options' => 'WP Mail SMTP', 'const:CPTUI_VERSION' => 'Custom Post Type UI' ) ), array( 'icon' => 'editor-code', 'name' => __( 'Code snippets', 'handymcp' ), 'text' => __( 'Add, fix and switch on snippets in Code Snippets and WPCode. The site is checked right after and a snippet that breaks it is switched off again.', 'handymcp' ), 'on' => __( 'With Pro the AI can add, fix and switch on snippets safely.', 'handymcp' ), 'detect' => array( 'const:CODE_SNIPPETS_VERSION' => 'Code Snippets', 'const:WPCODE_VERSION' => 'WPCode' ) ), array( 'icon' => 'editor-code', 'name' => __( 'WP-CLI style commands', 'handymcp' ), 'text' => __( 'Familiar commands like wp plugin list, wp cron event run, wp cache flush and wp search-replace, run inside WordPress. No shell or WP-CLI install needed, and changes can be undone.', 'handymcp' ), 'detect' => array() ), array( 'icon' => 'admin-appearance', 'name' => __( 'Theme builder', 'handymcp' ), 'text' => __( 'Build a new block theme from a design, review it with your real content on a private preview link, then switch to it with one undo.', 'handymcp' ), 'detect' => array() ), array( 'icon' => 'shield', 'name' => __( 'Wordfence control', 'handymcp' ), 'text' => __( 'See who Wordfence blocks and why, block and unblock IPs, clear lockouts, read login attempts and change any Wordfence setting.', 'handymcp' ), 'detect' => array( 'const:WORDFENCE_VERSION' => 'Wordfence' ) ), ), ), 'agency' => array( __( 'White label and client sites', 'handymcp' ), array( array( 'icon' => 'admin-appearance', 'name' => __( 'White label', 'handymcp' ), 'text' => __( 'Hide the plugin from your clients\' wp-admin while the AI connection keeps working, with a private link to show it again. Included in every Pro plan.', 'handymcp' ), 'detect' => array() ), array( 'icon' => 'networking', 'name' => __( 'Agency hub', 'handymcp' ), 'text' => __( 'Manage every client site from one dashboard and one AI connection.', 'handymcp' ), 'detect' => array() ), ), ), ); } /** * Name of the first active plugin or theme an entry covers, or ''. * * @param array $detect Checks => display name. */ private static function pro_active( array $detect ): string { $theme = wp_get_theme(); $names = array( $theme->get( 'Name' ), $theme->get_template() ); if ( $theme->parent() ) { $names[] = $theme->parent()->get( 'Name' ); } foreach ( $detect as $check => $name ) { list( $kind, $what ) = explode( ':', $check, 2 ); if ( ( 'const' === $kind && defined( $what ) ) || ( 'class' === $kind && class_exists( $what, false ) ) || ( 'theme' === $kind && in_array( $what, $names, true ) ) ) { return $name; } } return ''; } /** * One locked Pro card. * * @param array $f Entry from pro_catalog(). * @param string $active Name of the active plugin, or ''. */ private static function pro_card( array $f, string $active ): string { /* translators: %s: plugin or theme name. */ $text = $active ? sprintf( __( '%s is active.', 'handymcp' ), $active ) . ' ' . ( $f['on'] ?? $f['text'] ) : $f['text']; $html = ''; $html .= '' . esc_html( $f['name'] ) . ' ' . self::pro_pill() . ''; $html .= ( $active ? '' . esc_html__( 'Installed on this site', 'handymcp' ) . '' : '' ) . '' . esc_html( $text ) . ''; return $html; } /** * Abilities tab: what Pro would add, with this site's plugins first. */ private function pro_more_abilities(): void { if ( in_array( 'abilities', self::pro_hidden(), true ) ) { return; } $here = ''; $others = ''; $count = 0; foreach ( self::pro_catalog() as $cat ) { $cards = ''; foreach ( $cat[1] as $f ) { $active = self::pro_active( (array) $f['detect'] ); if ( $active ) { $here .= self::pro_card( $f, $active ); ++$count; } else { $cards .= self::pro_card( $f, '' ); } } if ( '' !== $cards ) { $others .= '

' . esc_html( $cat[0] ) . '

' . $cards . '
'; } } echo wp_kses( '

' . esc_html__( 'More with HandyMCP Pro', 'handymcp' ) . ' ' . self::pro_pill() . '

', \HandyMCP\Support\Html::allowed() ); if ( $count ) { /* translators: %d: number of plugins and themes. */ echo esc_html( sprintf( _n( 'Pro works with %d plugin or theme you already use, plus the rest of your site.', 'Pro works with %d plugins and themes you already use, plus the rest of your site.', $count, 'handymcp' ), $count ) ); } else { echo esc_html__( 'Let the AI work with the rest of your site: other builders, stores, languages, fields and site care.', 'handymcp' ); } echo '

'; echo wp_kses( $this->pro_dismiss( 'abilities', 'integrations' ), \HandyMCP\Support\Html::allowed() ); if ( '' !== $here ) { echo wp_kses( '

' . esc_html__( 'For this site', 'handymcp' ) . '

' . $here . '
', \HandyMCP\Support\Html::allowed() ); } echo wp_kses( $others, \HandyMCP\Support\Html::allowed() ); echo wp_kses( '
' . esc_html__( 'These are not part of the free plugin. Pro adds them as soon as you activate it.', 'handymcp' ) . '' . self::pro_button( 'abilities', __( 'See what Pro adds', 'handymcp' ) ) . '
', \HandyMCP\Support\Html::allowed() ); } /** * Undo history tab: note once the free limit of 20 changes is reached. * * @param int $changes Changes in the history. */ private function pro_history_note( int $changes ): void { if ( $changes < 20 || in_array( 'history', self::pro_hidden(), true ) ) { return; } echo wp_kses( '
' . esc_html__( 'Free keeps your last 20 changes.', 'handymcp' ) . ' ' . esc_html__( 'Pro keeps the full history, so you can undo older work too.', 'handymcp' ) . '' . self::pro_button( 'history' ) . $this->pro_dismiss( 'history', 'history' ) . '
', \HandyMCP\Support\Html::allowed() ); } /** * Blurred sample with a centered overlay card. The sample is static, * made-up HTML: nothing on this site is read for it. * * @param string $sample Sample HTML (already escaped). * @param string $label Accessible name of the overlay. * @param string $message One sentence on the benefit. * @param string $where Screen, for the link. */ private static function pro_stage( string $sample, string $label, string $message, string $where ): string { return '
' . '

' . esc_html( $message ) . '

' . self::pro_button( $where, 'sites' === $where ? __( 'Get Agency', 'handymcp' ) : '' ) . '

' . esc_html__( 'The names and numbers behind this box are made-up examples.', 'handymcp' ) . '

'; } /** * Settings tab: a locked "White label" sub-tab. */ private function pro_white_label_pane(): void { $sample = '
' . esc_html__( 'Hide from wp-admin', 'handymcp' ) . '
'; $sample .= '
' . esc_html__( 'Private link to show it again', 'handymcp' ) . '
'; self::acc_open( __( 'White label', 'handymcp' ), __( 'Hide the plugin on client sites.', 'handymcp' ), 'lock', __( 'Pro', 'handymcp' ), false, 'mx-acc-white-label' ); echo wp_kses( self::pro_stage( $sample, __( 'White label', 'handymcp' ), __( 'Hide HandyMCP from your clients\' wp-admin while the AI connection keeps working, with a private link to show it again.', 'handymcp' ), 'settings' ), \HandyMCP\Support\Html::allowed() ); self::acc_close(); } /** * Settings > Site brief: house rules every connected AI app follows. */ private function brief_section(): void { $b = \HandyMCP\Support\SiteBrief::get(); $filled = \HandyMCP\Support\SiteBrief::filled(); $this->section( __( 'Site brief', 'handymcp' ), __( 'House rules every connected AI app follows: your voice, words and do\'s and don\'ts.', 'handymcp' ), 'media-text', $filled ? __( 'In use', 'handymcp' ) : __( 'Empty', 'handymcp' ), false, 'mx-acc-brief' ); foreach ( \HandyMCP\Support\SiteBrief::fields() as $key => $field ) { echo '

' . esc_html( $field[1] ) . '

'; } /* translators: %s: estimated number of tokens. */ echo '
' . esc_html__( 'Brief size', 'handymcp' ) . '

' . esc_html( sprintf( /* translators: %s: estimated number of tokens. */ __( 'About %s tokens, sent to every app when it connects. The detailed style guide is sent only when an app needs it.', 'handymcp' ), number_format_i18n( (int) round( array_sum( array_map( static function ( $k ) use ( $b ) { return 'guide' === $k ? 0 : strlen( (string) ( $b[ $k ] ?? '' ) ); }, array_keys( \HandyMCP\Support\SiteBrief::fields() ) ) ) / 4 ) ) ) ) . '

'; echo '
'; if ( '' !== \HandyMCP\Support\SiteBrief::extra() ) { echo '
' . esc_html__( 'Added by code', 'handymcp' ) . '

' . esc_html__( 'A plugin or custom code on this site adds more rules to the brief (they are included in the preview below). Move them into the fields above so you can edit them here, then remove the code.', 'handymcp' ) . '

'; } $text = \HandyMCP\Support\SiteBrief::text(); if ( '' === $text ) { echo '
' . esc_html__( 'What AI apps see', 'handymcp' ) . '

' . esc_html__( 'Nothing yet. Fill in any field above and save.', 'handymcp' ) . '

'; } else { $words = str_word_count( wp_strip_all_tags( $text ) ); /* translators: handymcp: number of words. */ echo '
' . esc_html__( 'What AI apps see', 'handymcp' ) . ' ' . esc_html( sprintf( __( 'about %s words, sent to every app when it connects', 'handymcp' ), number_format_i18n( $words ) ) ) . '
' . esc_html( $text ) . '
'; } echo '
'; self::acc_close(); } /** * Settings tab. */ private function tab_settings(): void { $s = $this->c->get( 'settings' )->all(); $this->page_head( __( 'Settings', 'handymcp' ), '' ); $yes = static function ( $v ) { return $v ? __( 'on', 'handymcp' ) : __( 'off', 'handymcp' ); }; echo '
'; echo '
'; wp_nonce_field( 'handymcp_save' ); echo ''; $this->section( __( 'Server', 'handymcp' ), __( 'How apps connect, sign in and see tools.', 'handymcp' ), 'admin-network', sprintf( /* translators: 1, 2: on/off */ __( 'Server %1$s · OAuth %2$s', 'handymcp' ), $yes( ! empty( $s['enabled'] ) ), $yes( ! empty( $s['oauth_enabled'] ) ) ), true ); $this->toggle_row( 'enabled', __( 'MCP server', 'handymcp' ), __( 'Turn off to pause every connection.', 'handymcp' ), $s ); $this->toggle_row( 'read_only', __( 'Read-only mode', 'handymcp' ), __( 'AI apps can read and report, but every change is refused. Good for audits and client sites you only monitor.', 'handymcp' ), $s ); $this->toggle_row( 'oauth_enabled', __( 'OAuth sign-in', 'handymcp' ), __( 'Sign-in and approval screen for Claude, ChatGPT and others. Recommended.', 'handymcp' ), $s ); $this->toggle_row( 'dcr_enabled', __( 'Automatic app registration', 'handymcp' ), __( 'Required by Claude and ChatGPT connectors. You still approve every app.', 'handymcp' ), $s ); $this->toggle_row( 'app_password_auth', __( 'Application Passwords', 'handymcp' ), __( 'Allow Basic authentication with a WordPress Application Password.', 'handymcp' ), $s ); echo wp_kses( '
' . esc_html__( 'Tool list', 'handymcp' ) . '' . self::tip( __( 'Compact sends core tools and looks up the rest on demand. Uses far fewer tokens.', 'handymcp' ) ) . '
' . esc_html__( 'Tool list', 'handymcp' ) . '', \HandyMCP\Support\Html::allowed() ); echo ''; echo ''; echo '
'; ErrorReports::settings_row(); echo '
'; self::acc_close(); $this->section( __( 'Access', 'handymcp' ), __( 'Roles and app password access.', 'handymcp' ), 'groups', sprintf( /* translators: %d: count */ _n( '%d role', '%d roles', count( (array) $s['allowed_roles'] ), 'handymcp' ), count( (array) $s['allowed_roles'] ) ) ); echo wp_kses( '
' . esc_html__( 'Allowed roles', 'handymcp' ) . '' . self::tip( __( 'The AI can never do more than the connected user\'s role allows.', 'handymcp' ) ) . '
' . esc_html__( 'Allowed roles', 'handymcp' ) . '', \HandyMCP\Support\Html::allowed() ); foreach ( wp_roles()->roles as $role => $data ) { echo ''; } echo '
'; echo wp_kses( '
' . esc_html__( 'Application Password access', 'handymcp' ) . '' . self::tip( __( 'What an app signed in with an Application Password may do. OAuth apps choose this on the approval screen.', 'handymcp' ) ) . '
' . esc_html__( 'Application Password access', 'handymcp' ) . '', \HandyMCP\Support\Html::allowed() ); foreach ( Scopes::all() as $scope => $label ) { echo ''; } echo '
'; echo '
'; self::acc_close(); $this->brief_section(); $keys = count( array_filter( array( $s['pexels_key'] ?? '', $s['unsplash_key'] ?? '' ) ) ); $this->section( __( 'Photos', 'handymcp' ), __( 'Optional free keys for better photos. A Pexels key also unlocks stock videos.', 'handymcp' ), 'format-image', $keys ? sprintf( /* translators: %d: count */ _n( '%d key set', '%d keys set', $keys, 'handymcp' ), $keys ) : __( 'Openverse only', 'handymcp' ) ); foreach ( array( 'pexels_key' => array( 'Pexels API key', 'https://www.pexels.com/api/' ), 'unsplash_key' => array( 'Unsplash access key', 'https://unsplash.com/developers' ) ) as $key => $info ) { echo '
' . ( '' !== (string) $s[ $key ] ? '' : '' ) . '
'; } echo '
'; self::acc_close(); $this->section( __( 'Limits', 'handymcp' ), __( 'Rate limits, history, logs and cleanup.', 'handymcp' ), 'performance', sprintf( /* translators: 1: reads, 2: writes */ __( '%1$d reads · %2$d changes / min', 'handymcp' ), (int) $s['rate_read'], (int) $s['rate_write'] ) ); $this->number_row( 'rate_read', __( 'Reads per minute', 'handymcp' ), '', $s, __( 'per connection', 'handymcp' ) ); $this->number_row( 'rate_write', __( 'Changes per minute', 'handymcp' ), '', $s, __( 'per connection', 'handymcp' ) ); $this->number_row( 'rate_auth_fail', __( 'Failed sign-ins before a block', 'handymcp' ), '', $s, __( 'tries, then a 15 minute block', 'handymcp' ) ); $this->number_row( 'log_retention_days', __( 'Keep activity log', 'handymcp' ), '', $s, __( 'days', 'handymcp' ) ); $this->number_row( 'history_days', __( 'Keep undo history', 'handymcp' ), __( 'Older snapshots are removed. Only the last 20 changes are kept.', 'handymcp' ), $s, __( 'days', 'handymcp' ) ); $this->toggle_row( 'delete_data', __( 'Remove all data when the plugin is deleted', 'handymcp' ), __( 'Turn on only before removing the plugin for good. Data is kept while the other HandyMCP plugin (free or Pro) is still installed, so your settings carry over.', 'handymcp' ), $s ); echo wp_kses( '
' . self::tip( __( 'Only for browser-based MCP clients. One per line.', 'handymcp' ) ) . '
', \HandyMCP\Support\Html::allowed() ); echo '
'; self::acc_close(); echo ''; $this->pro_white_label_pane(); echo ''; } /** * Small "?" with the help text on hover or focus. * * @param string $text Help text (plain). */ public static function tip( string $text ): string { $text = trim( wp_strip_all_tags( $text ) ); if ( '' === $text ) { return ''; } return '?'; } /** * First sentence of a text, cut to a short line. * * @param string $text Text. * @param int $max Max length. */ private static function short_text( string $text, int $max = 72 ): string { $text = trim( (string) preg_replace( '/\s+/', ' ', $text ) ); $text = (string) preg_replace( '/\s*\([^)]*\)/', '', $text ); $parts = preg_split( '/(?<=[.!?])\s|:\s|;\s|,?\s+e\.g\.?|\s+-\s|\s*\{/u', $text ); $text = trim( (string) ( $parts[0] ?? $text ) ); $text = (string) preg_replace( '/,\s*(each|with|which|so|then)\b.*$/u', '', $text ); $text = rtrim( $text, '.' ); if ( mb_strlen( $text ) > $max ) { $cut = mb_substr( $text, 0, $max ); $sp = mb_strrpos( $cut, ' ' ); $text = rtrim( $sp ? mb_substr( $cut, 0, $sp ) : $cut, ' ,;:-' ) . '…'; } return $text; } /** * Open a settings section card. * * @param string $title Title. * @param string $help Help text. */ private function section( string $title, string $help, string $icon = 'admin-generic', string $chip = '', bool $open = false, string $id = '' ): void { self::acc_open( $title, $help, $icon, $chip, $open, $id ); echo '
'; } /** * Open an accordion panel (settings page). * * @param string $title Title. * @param string $help One-line summary. * @param string $icon Dashicon name. * @param string $chip Status chip. * @param bool $open Open by default. * @param string $id Anchor id. */ public static function acc_open( string $title, string $help, string $icon, string $chip = '', bool $open = false, string $id = '' ): void { $id = '' !== $id ? $id : 'mx-acc-' . sanitize_title( $title ); echo '

' . esc_html( $title ) . '

' . ( '' !== $help ? '

' . esc_html( $help ) . '

' : '' ) . '
' . ( '' !== $chip ? '' . esc_html( $chip ) . '' : '' ) . '
'; } /** * Close an accordion panel. */ public static function acc_close(): void { echo '
'; } /** * Switch row. * * @param string $key Setting. * @param string $label Label. * @param string $help Help. * @param array $s Settings. */ private function toggle_row( string $key, string $label, string $help, array $s ): void { $id = 'handymcp-' . $key; echo wp_kses( '
' . self::tip( $help ) . '
', \HandyMCP\Support\Html::allowed() ); echo '
'; } /** * Number row. * * @param string $key Setting. * @param string $label Label. * @param string $help Help. * @param array $s Settings. */ private function number_row( string $key, string $label, string $help, array $s, string $unit = '' ): void { $id = 'handymcp-' . $key; echo wp_kses( '
' . ( '' !== $help ? self::tip( $help ) : '' ) . '
' . ( '' !== $unit ? '' . esc_html( $unit ) . '' : '' ) . '
', \HandyMCP\Support\Html::allowed() ); } /** * Pause or Resume button for a connection. * * @param string $kind client|token. * @param string $id Id. * @param bool $paused Current state. */ private function pause_button( string $kind, string $id, bool $paused ): string { return $this->mini_form( 'pause', array( 'kind' => $kind, 'id' => $id, 'paused' => $paused ? '0' : '1' ), $paused ? __( 'Resume', 'handymcp' ) : __( 'Pause', 'handymcp' ), false, $paused ? 'button-primary mx-btn-resume' : 'mx-btn-pause' ); } /** * Stream the activity log as CSV or JSON. * * @param string $format csv|json. */ private function export_log( string $format ): void { global $wpdb; $t = \HandyMCP\Database\Installer::table( 'log' ); $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT id, created_at, user_id, client, auth_method, tool, tool_group, is_write, status, object_ref, summary, duration_ms FROM %i ORDER BY id DESC LIMIT 20000', $t ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom table export. $name = 'handymcp-activity-' . gmdate( 'Y-m-d' ) . '.' . $format; nocache_headers(); header( 'Content-Disposition: attachment; filename="' . $name . '"' ); $out = fopen( 'php://output', 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen if ( 'json' === $format ) { header( 'Content-Type: application/json; charset=utf-8' ); fwrite( $out, (string) wp_json_encode( $rows, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose return; } header( 'Content-Type: text/csv; charset=utf-8' ); fputcsv( $out, array( 'id', 'time_utc', 'user_id', 'app', 'auth', 'tool', 'group', 'change', 'status', 'object', 'summary', 'duration_ms' ), ',', '"', '' ); foreach ( (array) $rows as $r ) { // Cells starting with = + - @ would run as formulas in spreadsheet apps. fputcsv( $out, array_map( static function ( $v ) { $v = (string) $v; return preg_match( '/^[=+\-@\t\r]/', $v ) ? "'" . $v : $v; }, array_values( $r ) ), ',', '"', '' ); } fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose } /** * Small POST form with one button. * * @param string $op Operation. * @param array $fields Hidden fields. * @param string $label Button label. * @param bool $confirm Ask before submitting. * @param string $class Button class. * @param string $question Confirm text. */ private function mini_form( string $op, array $fields, string $label, bool $confirm = false, string $class = 'button-link-delete', string $question = '' ): string { $question = $question ? $question : __( 'Are you sure?', 'handymcp' ); $html = '
'; $html .= '' . wp_referer_field( false ); $html .= ''; foreach ( $fields as $k => $v ) { $html .= ''; } $html .= '
'; return $html; } /** * Supported plugins found since the owner last looked, checked only when the active plugins or theme change. * * @return array */ private function found(): array { $sig = md5( (string) wp_json_encode( array( get_option( 'active_plugins', array() ), get_site_option( 'active_sitewide_plugins', array() ), get_stylesheet() ) ) ); $state = get_option( 'handymcp_found_plugins' ); $build = HANDYMCP_VERSION . '|' . plugin_basename( HANDYMCP_FILE ); if ( ! is_array( $state ) || ( $state['sig'] ?? '' ) !== $sig || ( $state['build'] ?? '' ) !== $build ) { $now = array(); $labels = array(); $core = array( 'wordpress', 'built in', 'openverse', 'wordpress object cache', 'file editing' ); $core_ids = array( 'wordpress', 'site', 'files', 'images', 'gutenberg', 'design', 'schedule', 'assistant', 'backups', 'mail', 'themes', 'abilities', 'rest', 'cache_object' ); foreach ( $this->c->get( 'registry' )->groups() as $id => $g ) { if ( empty( $g['detected'] ) || empty( $g['plugins'] ) || ( $g['tools']['read'] + $g['tools']['write'] ) <= 0 ) { continue; } $label = (string) array_key_first( (array) $g['plugins'] ); $key = strtolower( trim( $label ) ); if ( '' === $key || in_array( (string) $id, $core_ids, true ) || in_array( $key, $core, true ) || 0 === strpos( $key, 'twenty ' ) || isset( $labels[ $key ] ) ) { continue; } $labels[ $key ] = true; $now[ $id ] = array( 'label' => $label, 'mode' => (string) $g['mode'], 'at' => time() ); } // A new HandyMCP version or a switch between the free plugin and Pro adds groups, not plugins: start fresh. $first = ! is_array( $state ) || ( $state['build'] ?? '' ) !== $build; $seen = $first ? array_keys( $now ) : (array) ( $state['seen'] ?? array() ); $new = $first ? array() : array_intersect_key( (array) ( $state['new'] ?? array() ), $now ); foreach ( $now as $id => $g ) { if ( ! in_array( $id, $seen, true ) ) { $new[ $id ] = $g; } } $state = array( 'sig' => $sig, 'build' => $build, 'seen' => array_values( array_unique( array_merge( $seen, array_keys( $now ) ) ) ), 'new' => $new ); update_option( 'handymcp_found_plugins', $state, false ); } // A notice nobody acted on fades after two weeks. return array_filter( (array) $state['new'], static function ( $g ) { return (int) ( $g['at'] ?? 0 ) > time() - 14 * DAY_IN_SECONDS; } ); } /** * Tell administrators when HandyMCP starts working with a newly installed plugin. */ public function found_notice(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } $found = $this->found(); if ( ! $found ) { return; } $link = static function ( string $do, string $id ): string { return wp_nonce_url( add_query_arg( array( 'action' => 'handymcp_found', 'do' => $do, 'group' => $id ), admin_url( 'admin-post.php' ) ), 'handymcp_found' ); }; $rows = ''; foreach ( $found as $id => $g ) { $limited = 'write' !== $g['mode']; $rows .= '

' . esc_html( sprintf( /* translators: %s: plugin name. */ __( 'HandyMCP now works with %s.', 'handymcp' ), $g['label'] ) ) . ' ' . esc_html( 'off' === $g['mode'] ? __( 'It is switched off, so AI apps cannot use it yet.', 'handymcp' ) : ( $limited ? __( 'AI apps can read it. Changes are off until you allow them.', 'handymcp' ) : __( 'AI apps can use it now. Your existing connections work with it, no need to reconnect.', 'handymcp' ) ) ) . ' ' . ( $limited ? '' . esc_html__( 'Allow changes', 'handymcp' ) . ' ' : '' ) . '' . esc_html__( 'Review in Abilities', 'handymcp' ) . ' ' . '' . esc_html__( 'Dismiss', 'handymcp' ) . '

'; } echo wp_kses( '
' . $rows . '
', \HandyMCP\Support\Html::allowed() ); } /** * Allow changes for, or dismiss, a newly found plugin. */ public function found_action(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Your account is not allowed to do this. Ask an administrator of this site.', 'handymcp' ), 403 ); } check_admin_referer( 'handymcp_found' ); $id = isset( $_GET['group'] ) ? sanitize_key( wp_unslash( $_GET['group'] ) ) : ''; $state = get_option( 'handymcp_found_plugins' ); if ( is_array( $state ) && isset( $state['new'][ $id ] ) ) { if ( isset( $_GET['do'] ) && 'allow' === sanitize_key( wp_unslash( $_GET['do'] ) ) ) { $this->c->get( 'settings' )->save( array( 'groups' => array( $id => 'write' ) ) ); } unset( $state['new'][ $id ] ); update_option( 'handymcp_found_plugins', $state, false ); } wp_safe_redirect( wp_get_referer() ? wp_get_referer() : self::url( 'integrations' ) ); exit; } /** * Handle form posts. */ public function handle_post(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Your account is not allowed to do this. Ask an administrator of this site.', 'handymcp' ), 403 ); } check_admin_referer( 'handymcp_save' ); $op = isset( $_POST['op'] ) ? sanitize_key( wp_unslash( $_POST['op'] ) ) : ''; $settings = $this->c->get( 'settings' ); $tab = 'connect'; $msg = 'saved'; switch ( $op ) { case 'settings': $in = array(); foreach ( array( 'enabled', 'read_only', 'oauth_enabled', 'dcr_enabled', 'app_password_auth', 'rate_read', 'rate_write', 'rate_auth_fail', 'log_retention_days', 'allowed_origins', 'tool_mode', 'pexels_key', 'unsplash_key', 'pexels_key_clear', 'unsplash_key_clear', 'history_days', 'delete_data' ) as $k ) { if ( isset( $_POST[ $k ] ) ) { $in[ $k ] = sanitize_textarea_field( wp_unslash( $_POST[ $k ] ) ); } } $in['allowed_roles'] = isset( $_POST['allowed_roles'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['allowed_roles'] ) ) : array(); $in['app_password_scopes'] = isset( $_POST['app_password_scopes'] ) ? array_map( 'sanitize_text_field', (array) wp_unslash( $_POST['app_password_scopes'] ) ) : array( 'read' ); if ( isset( $_POST['brief'] ) && is_array( $_POST['brief'] ) ) { \HandyMCP\Support\SiteBrief::save( array_map( 'sanitize_textarea_field', wp_unslash( $_POST['brief'] ) ) ); } $settings->save( $in ); $tab = 'settings'; break; case 'abilities': $reg = $this->c->get( 'registry' ); $known = array(); $all_g = $reg->groups(); foreach ( $reg->all() as $t ) { if ( ! empty( $all_g[ $t['group'] ]['detected'] ) ) { $known[ $t['name'] ] = $t; } } $off_json = isset( $_POST['tools_off_json'] ) && is_string( $_POST['tools_off_json'] ) ? (string) wp_unslash( $_POST['tools_off_json'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON, decoded below and each name passed through sanitize_key(). if ( '' !== $off_json ) { // Compact form sent by the page script: only the names switched off. $off = json_decode( $off_json, true ); $off = is_array( $off ) ? array_values( array_intersect( array_map( 'sanitize_key', array_filter( $off, 'is_string' ) ), array_keys( $known ) ) ) : array(); } else { $on = isset( $_POST['tools_on'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['tools_on'] ) ) : array(); $off = array_values( array_diff( array_keys( $known ), $on ) ); } $keep = array_diff( (array) $settings->get( 'tools_off', array() ), array_keys( $known ) ); // Tools of inactive plugins keep their state. // Each group's mode follows its switches: any write tool on = write, only reads on = read, none = off. $groups = array(); foreach ( $known as $name => $t ) { $cur = $groups[ $t['group'] ] ?? 'off'; if ( ! in_array( $name, $off, true ) ) { $cur = $t['write'] ? 'write' : ( 'write' === $cur ? 'write' : 'read' ); } $groups[ $t['group'] ] = $cur; } $settings->save( array( 'groups' => $groups, 'tools_off' => array_values( array_merge( $keep, $off ) ) ) ); delete_option( 'handymcp_found_plugins' ); $tab = 'integrations'; break; case 'diagnostics': Diagnostics::run(); $msg = 'checked'; break; case 'export_log': $this->export_log( isset( $_POST['format'] ) && 'json' === sanitize_key( wp_unslash( $_POST['format'] ) ) ? 'json' : 'csv' ); exit; case 'groups': $groups = isset( $_POST['groups'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['groups'] ) ) : array(); $settings->save( array( 'groups' => $groups ) ); delete_option( 'handymcp_found_plugins' ); $tab = 'integrations'; break; case 'app_passwords': $settings->save( array( 'allow_app_passwords' => rest_sanitize_boolean( sanitize_text_field( wp_unslash( $_POST['value'] ?? '' ) ) ) ) ); break; case 'create_token': $name = isset( $_POST['name'] ) ? sanitize_text_field( wp_unslash( $_POST['name'] ) ) : 'Token'; $scopes = isset( $_POST['scopes'] ) ? Scopes::sanitize( array_map( 'sanitize_text_field', (array) wp_unslash( $_POST['scopes'] ) ) ) : array( 'read' ); $scopes = Scopes::grant( $scopes, array_keys( Scopes::all() ) ); $days = isset( $_POST['expires'] ) ? max( 0, min( 3650, (int) $_POST['expires'] ) ) : 90; $issued = $this->c->get( 'tokens' )->issue( 'pat', get_current_user_id(), $scopes, $days * DAY_IN_SECONDS, $name ); set_transient( 'handymcp_new_token_' . get_current_user_id(), \HandyMCP\Support\Crypto::encrypt( (string) $issued['token'] ), 5 * MINUTE_IN_SECONDS ); $msg = ''; break; case 'token_fallback': $issued = $this->c->get( 'tokens' )->issue( 'pat', get_current_user_id(), array( 'read', 'content:write' ), 90 * DAY_IN_SECONDS, __( 'AI app (access token)', 'handymcp' ) ); set_transient( 'handymcp_new_token_' . get_current_user_id(), \HandyMCP\Support\Crypto::encrypt( (string) $issued['token'] ), 5 * MINUTE_IN_SECONDS ); $msg = ''; break; case 'reset_oauth': $n = $this->c->get( 'oauth' )->reset_state(); /* translators: 1: removed apps, 2: removed codes, 3: removed tokens. */ set_transient( 'handymcp_reset_note_' . get_current_user_id(), sprintf( __( 'Removed %1$d unused app registrations, %2$d pending sign-in codes and %3$d expired or revoked tokens.', 'handymcp' ), $n['clients'], $n['codes'], $n['tokens'] ), MINUTE_IN_SECONDS ); $tab = 'connect'; $msg = 'oauth_reset'; break; case 'pause': $kind = isset( $_POST['kind'] ) && 'token' === sanitize_key( wp_unslash( $_POST['kind'] ) ) ? 'token' : 'client'; $pid = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : ''; $now = isset( $_POST['paused'] ) && '1' === sanitize_key( wp_unslash( $_POST['paused'] ) ); Pause::set( $kind, $pid, $now ); $msg = $now ? 'paused' : 'resumed'; break; case 'revoke_token': Pause::forget( 'token', isset( $_POST['token_id'] ) ? (string) (int) $_POST['token_id'] : '' ); $this->c->get( 'tokens' )->revoke( isset( $_POST['token_id'] ) ? (int) $_POST['token_id'] : 0, 'revoked_in_dashboard' ); $msg = 'revoked'; break; case 'revoke_client': Pause::forget( 'client', isset( $_POST['client_id'] ) ? sanitize_text_field( wp_unslash( $_POST['client_id'] ) ) : '' ); $revoke_id = isset( $_POST['client_id'] ) ? sanitize_text_field( wp_unslash( $_POST['client_id'] ) ) : ''; $this->c->get( 'tokens' )->revoke_client( $revoke_id ); \HandyMCP\Auth\AppHealth::clear( $revoke_id ); $msg = 'revoked'; break; case 'undo': $id = isset( $_POST['history_id'] ) ? sanitize_text_field( wp_unslash( $_POST['history_id'] ) ) : ''; $res = \HandyMCP\Tools\History::revert( $id ); $tab = 'history'; $msg = is_wp_error( $res ) ? 'undo_failed' : 'undone'; $this->undo_note( $res ); break; case 'undo_many': $ops = isset( $_POST['ops'] ) ? array_filter( array_map( 'sanitize_text_field', explode( ',', sanitize_text_field( wp_unslash( $_POST['ops'] ) ) ) ), static function ( $o ) { return (bool) preg_match( '/^op_[0-9]{12}_[a-f0-9]{6}$/', $o ); } ) : array(); $tab = 'history'; if ( ! $ops ) { $msg = 'undo_failed'; break; } $res = \HandyMCP\Tools\History::revert_many( array_values( $ops ) ); $msg = $res['failed'] ? 'undo_failed' : 'undone'; $this->undo_note( $res ); break; case 'clear_history': global $wpdb; $wpdb->query( $wpdb->prepare( 'TRUNCATE TABLE %i', Installer::table( 'history' ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- emptying a custom table. $tab = 'history'; $msg = 'cleared_history'; break; case 'clear_sessions': global $wpdb; $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', Installer::table( 'sessions' ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- emptying a custom table. $tab = 'connect'; $msg = 'cleared_sessions'; break; case 'pro_hide': $section = isset( $_POST['section'] ) ? sanitize_key( wp_unslash( $_POST['section'] ) ) : ''; $back = isset( $_POST['back'] ) ? sanitize_key( wp_unslash( $_POST['back'] ) ) : ''; if ( in_array( $section, array( 'abilities', 'history', 'trial' ), true ) ) { update_user_meta( get_current_user_id(), 'handymcp_hidden_pro', array_values( array_unique( array_merge( self::pro_hidden(), array( $section ) ) ) ) ); } $tab = isset( self::tabs()[ $back ] ) ? $back : 'connect'; $msg = ''; break; case 'clear_log': global $wpdb; $wpdb->query( $wpdb->prepare( 'TRUNCATE TABLE %i', Installer::table( 'log' ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- emptying a custom table. $tab = 'activity'; $msg = 'cleared_log'; break; default: $msg = ''; break; } // Settings: come back to the sub tab that was saved, not the first one. $pane = 'settings' === $tab && isset( $_POST['mx_pane'] ) ? sanitize_key( wp_unslash( $_POST['mx_pane'] ) ) : ''; $args = $msg ? array( 'handymcp_msg' => $msg ) : array(); if ( 0 === strpos( $pane, 'mx-acc-' ) ) { $args['pane'] = $pane; } wp_safe_redirect( self::url( $tab, $args ) ); // No #anchor: it would scroll the page. exit; } }