,is_admin:bool}> */ function aafm_agent_user_candidates(): array { $users = get_users( array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- narrows to app-password holders instead of paging through every user. 'meta_key' => WP_Application_Passwords::USERMETA_KEY_APPLICATION_PASSWORDS, 'meta_compare' => 'EXISTS', 'orderby' => 'ID', 'order' => 'ASC', 'fields' => array( 'ID', 'user_login' ), ) ); $candidates = array(); foreach ( $users as $user ) { $user_id = (int) $user->ID; $app_pws = WP_Application_Passwords::get_user_application_passwords( $user_id ); if ( empty( $app_pws ) ) { continue; } // Roles and the admin flag are read inside the checked-read scope. A caps load that fails // there shows no roles and flags the account as an administrator, the cautious reading. The // application-passwords read above loads the same usermeta first, outside the scope, so that // row appears only when the cache did not keep that load; a load that fails above drops the // user from this list instead. $read = aafm_with_checked_reads( static function () use ( $user_id ): array { $wp_user = aafm_exact_object( 'user', $user_id ); return array( 'roles' => ( $wp_user instanceof WP_User ) ? array_values( $wp_user->roles ) : array(), 'is_admin' => user_can( $user_id, 'manage_options' ), ); }, aafm_generic_error() ); if ( is_wp_error( $read ) ) { $read = array( 'roles' => array(), 'is_admin' => true, ); } $candidates[] = array( 'id' => $user_id, 'login' => (string) $user->user_login, 'roles' => array_map( 'strval', $read['roles'] ), 'is_admin' => $read['is_admin'], ); } return $candidates; } /** * Meta key stamped on a user this plugin created through the dedicated-agent-user flow * ({@see aafm_create_agent_user()}). It is the durable, plugin-owned record that tells a real * agent connection apart from any unrelated application-password holder (Jetpack, a mobile app, * some other REST integration), so the onboarding "Connect your agent" signal never reads a * false positive. Single source of truth: both the stamp and the reader below reference it. * * @return string */ function aafm_agent_user_marker_meta_key(): string { return 'aafm_agent_user'; } /** * IDs of users this plugin created as dedicated agent users - the ones carrying the marker * meta {@see aafm_agent_user_marker_meta_key()}. Unlike {@see aafm_agent_user_candidates()}, * which counts every application-password holder for the security warning, this counts only * accounts the plugin itself made for the agent, so it is the honest "an agent user exists" * signal for the setup checklist. * * @return int[] User IDs, ascending. */ function aafm_created_agent_users(): array { $ids = get_users( array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- narrows to plugin-created agent users by our own marker meta. 'meta_key' => aafm_agent_user_marker_meta_key(), 'meta_compare' => 'EXISTS', 'orderby' => 'ID', 'order' => 'ASC', 'fields' => 'ids', ) ); return array_map( 'intval', $ids ); } /** * Whether the plugin has created at least one dedicated agent user. * * @return bool */ function aafm_has_created_agent_user(): bool { return array() !== aafm_created_agent_users(); } /** * Count of abilities the operator has enabled. * * @return int */ function aafm_enabled_ability_count(): int { return count( aafm_get_enabled_abilities() ); } /** * Total abilities in the catalog (enabled or not). * * @return int */ function aafm_total_ability_count(): int { return count( aafm_get_abilities_registry() ); } /** * The MCP protocol version this plugin speaks. Single source of truth so other * code (help tab, connection configs) can reference it rather than re-literal it. * * @return string */ function aafm_mcp_protocol_version(): string { return '2025-06-18'; } /** * Total number of rows in the activity log. * * @return int Non-negative row count. */ function aafm_activity_count(): int { return aafm_activity_count_matching( array() ); } /** * Exact count of distinct agent principals seen in the activity log over the last * 24 hours (UTC), computed in one bounded query against the plugin's own audit table. * * This is recent-principal activity read back from the audit log, NOT a live socket or * connection count. created_at is stored as a UTC ('mysql', true) datetime string, so the * cutoff is computed with gmdate() and the query counts every distinct principal at once - * no page cap, so it never undercounts on a busy site. * * @return int Number of distinct principals active in the last 24 hours. */ function aafm_recent_agent_count(): int { global $wpdb; $table = aafm_activity_log_table(); $cutoff = gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ); // Routed through aafm_wpdb_scalar() rather than a bare get_var() - the // dashboard renders several counts back to back, so a failed query could otherwise inherit the // previous count's value and display a plausible but wrong number. // // event_type <> 'write_outcome' excludes the write-and-confirm contract's own rows: a // write_outcome row carries the same principal as the ability_call row for the same request, // so excluding it drops no agent from the count, and including it would inflate this number // for an admin save (a write_outcome row with the admin's own principal) exactly as it already // would for the schema-stamp row's principal 0 (audit/log.php:136). 'ability_call' is not // excluded here: unlike aafm_agent_call_count(), this count already includes admin-only event // types such as setting_changed in 1.7.5, and narrowing it further would change today's number. $view = aafm_wpdb_scalar( $wpdb->prepare( "SELECT COUNT(DISTINCT principal_user_id) FROM %i WHERE created_at >= %s AND event_type <> 'write_outcome'", $table, $cutoff ) ); return $view['ok'] ? max( 0, (int) $view['value'] ) : 0; } /** * Whether any user has approved an OAuth connection (a live grant exists). * * Read-only; returns false when OAuth is disabled or nobody has approved yet. * * @return bool */ function aafm_has_oauth_grant(): bool { return ! empty( aafm_oauth_list_grants() ); } /** * The three setup steps, each derived from real, observable site state - never a * faked "connected" signal. Step done-ness comes straight from the data helpers: * * [0] abilities are enabled - aafm_enabled_ability_count() > 0 * [1] agent is connected - aafm_has_oauth_grant() || aafm_has_created_agent_user() * [2] a call has been made - aafm_agent_call_count() > 0 (a logged agent tool call) * * Step [1] keys off a live OAuth grant or a user THIS plugin created as a dedicated agent * (the marker meta), never a bare application-password holder - app passwords are minted for * many unrelated reasons, so counting them here flagged "connected" before any agent had. * * Step [2] gets the same discipline: it counts only rows recording an agent tool call, never * the log's other traffic. Which rows qualify, and the exclusions that keep that honest, are * owned by aafm_agent_call_count() and documented there - deliberately not restated here, since * a second prose copy would drift the moment the catalog gains an entry, the same way a second * copy of the WHERE clause would. The raw total (aafm_activity_count()) flipped this step to * done the moment step [0] wrote its ability_enabled row, before any call existed. A denied or * rate-limited call still counts: the agent reached the endpoint and invoked a real tool, which * is exactly what this step exists to prove. * * The zero-based index is the contract callers rely on: $steps[0] is always the * abilities step, $steps[1] is always the connect step. * * @return array */ function aafm_setup_steps(): array { $tab_url = static function ( string $tab ): string { return add_query_arg( array( 'page' => 'agent-abilities-for-mcp', 'tab' => $tab, ), admin_url( 'admin.php' ) ); }; return array( array( 'title' => __( 'Enable the abilities you want', 'agent-abilities-for-mcp' ), 'desc' => __( 'Nothing is exposed until you turn it on. Pick the abilities the agent should have on the Abilities tab.', 'agent-abilities-for-mcp' ), 'done' => aafm_enabled_ability_count() > 0, 'href' => $tab_url( 'abilities' ), ), array( 'title' => __( 'Connect your agent', 'agent-abilities-for-mcp' ), 'desc' => __( 'Approve it in the browser over OAuth, or set up an Application Password for a dedicated agent user. Either way its reach stays capped by what you turn on.', 'agent-abilities-for-mcp' ), 'done' => aafm_has_oauth_grant() || aafm_has_created_agent_user(), 'href' => $tab_url( 'connection' ), ), array( 'title' => __( 'Make your first call', 'agent-abilities-for-mcp' ), 'desc' => __( 'Point your MCP client at the endpoint and run one request. It shows up here once the activity log records it.', 'agent-abilities-for-mcp' ), 'done' => aafm_agent_call_count() > 0, 'href' => $tab_url( 'connection' ), ), ); } /** * Render the Dashboard tab: a guided setup checklist, a four-card stat grid, and a * two-card row (endpoint + versions). * * The checklist reflects real, observable state from aafm_setup_steps(); when all three * steps are done it collapses into a single "all set" success notice. The stat grid and * cards reuse the same counts the page already computes - enabled abilities, recent agent * activity (read from the audit log, not live connections), audit-log size, and agent * users, with an inline warning when an agent user can manage the site. Nothing here * changes state. The page shell (heading and lede) is rendered by page.php, not here. * * @return void */ function aafm_render_dashboard_tab(): void { $endpoint = aafm_endpoint_url(); $enabled = aafm_enabled_ability_count(); // Single source of truth for "available / total" - counts the full catalog (core + every // integration's manifest total) so an inactive integration still contributes its count and // the Dashboard never disagrees with the Abilities tab. $total = aafm_available_ability_count(); $adapter = aafm_loaded_adapter_version(); $candidates = aafm_agent_user_candidates(); $created_agents = aafm_created_agent_users(); $recent = aafm_recent_agent_count(); $log_rows = aafm_activity_count(); $retention = aafm_log_retention_days(); // Two different questions, kept apart on purpose. $created_agents is the honest "agent // users this plugin made" count (the card value). $admin_agents is the security signal: // admins who hold an application password, so they can reach the API and manage the site - // the correct broad scope for that caution, whether or not this plugin created them. $admin_agents = array_values( array_filter( $candidates, static fn( array $c ): bool => ! empty( $c['is_admin'] ) ) ); $adapter_label = ( null === $adapter ) ? __( 'not loaded', 'agent-abilities-for-mcp' ) : $adapter; $steps = aafm_setup_steps(); $done_count = count( array_filter( $steps, static fn( array $s ): bool => ! empty( $s['done'] ) ) ); $step_total = count( $steps ); echo '
'; // Setup steps are always rendered inside a collapsible
. While any // step is pending the panel is open (the open attribute) with a "Finish setting up" summary, a // progress bar, and the X-of-Y count. Once every step is done the panel collapses (no open // attribute) into a "Setup complete" recap - the steps stay available behind the summary, and // the old standalone success notice is dropped so there is no notice-plus-gap to clean up. $is_complete = $done_count === $step_total; $open_attr = $is_complete ? '' : ' open'; printf( '
', esc_attr( $open_attr ) ); echo ''; if ( $is_complete ) { echo ''; echo '

' . esc_html__( 'Setup complete, steps below', 'agent-abilities-for-mcp' ) . '

'; } else { echo '

' . esc_html__( 'Finish setting up', 'agent-abilities-for-mcp' ) . '

'; printf( '%s', esc_html( sprintf( /* translators: 1: number of completed setup steps, 2: total setup steps. */ __( '%1$d of %2$d done', 'agent-abilities-for-mcp' ), $done_count, $step_total ) ) ); // Progress bar: filled to done / total. Width is a computed integer percent. $progress_pct = $step_total > 0 ? (int) round( ( $done_count / $step_total ) * 100 ) : 0; printf( '', esc_attr( (string) $progress_pct ) ); } echo '
'; echo '
'; // The first not-done step is the "active" one (blue marker, number shown); the rest // of the not-done steps are plain "to do" (grey marker, number shown). Done steps get // the green check marker. When complete there is no active step at all. $active_marked = false; $step_num = 0; foreach ( $steps as $step ) { ++$step_num; $is_done = ! empty( $step['done'] ); if ( $is_done ) { $state_cls = 'aafm-step-done'; $pill_class = 'aafm-pill aafm-pill-success'; $pill_text = __( 'Done', 'agent-abilities-for-mcp' ); } elseif ( ! $active_marked ) { $state_cls = 'aafm-step-active'; $pill_class = 'aafm-pill aafm-pill-warn'; $pill_text = __( 'To do', 'agent-abilities-for-mcp' ); $active_marked = true; } else { $state_cls = 'aafm-step-todo'; $pill_class = 'aafm-pill aafm-pill-neutral'; $pill_text = __( 'To do', 'agent-abilities-for-mcp' ); } printf( '
', esc_attr( $state_cls ) ); if ( $is_done ) { echo ''; echo wp_kses( aafm_icon( 'check' ), aafm_svg_allowed_html() ); echo ''; } else { printf( '%s', esc_html( (string) $step_num ) ); } echo '
'; printf( '

%s

', esc_html( (string) $step['title'] ) ); printf( '

%s

', esc_html( (string) $step['desc'] ) ); // The active step gets a primary CTA with a trailing arrow; other to-do steps don't. if ( 'aafm-step-active' === $state_cls ) { echo wp_kses( sprintf( '

%2$s %3$s

', esc_url( (string) $step['href'] ), esc_html__( 'Go to step', 'agent-abilities-for-mcp' ), aafm_icon( 'arrow-right' ) ), aafm_admin_allowed_html() ); } echo '
'; printf( '%2$s', esc_attr( $pill_class ), esc_html( $pill_text ) ); echo '
'; } echo '
'; // .aafm-setup-steps echo '
'; aafm_render_review_inbox_card(); aafm_render_missing_tools_card( aafm_missing_tools_data() ); // Stat grid - four cards reusing the counts computed above. The compact mockup // treatment: a value line plus a .stat-sub and/or a small pill, no embedded notices. echo '
'; // Enabled abilities. aafm_render_stat_head( 'aafm-stat-abilities', __( 'Enabled abilities', 'agent-abilities-for-mcp' ), 'bolt' ); printf( '
%1$s %2$s
', esc_html( number_format_i18n( $enabled ) ), esc_html( sprintf( /* translators: %d: total number of abilities in the catalog. */ __( 'of %d', 'agent-abilities-for-mcp' ), $total ) ) ); if ( 0 === $enabled ) { echo '
' . esc_html__( 'Turn some on to start', 'agent-abilities-for-mcp' ) . '
'; } else { $still_off = max( 0, $total - $enabled ); printf( '
%s
', esc_html( sprintf( /* translators: %s: number of abilities still turned off. */ __( '%s still off', 'agent-abilities-for-mcp' ), number_format_i18n( $still_off ) ) ) ); } echo '
'; // Recent agents (24h). aafm_render_stat_head( 'aafm-stat-recent', __( 'Recent agents (24h)', 'agent-abilities-for-mcp' ), 'recent' ); printf( '
%s
', esc_html( number_format_i18n( $recent ) ) ); echo '
' . esc_html__( 'Separate agent users seen in the activity log in the last 24 hours. This is recent activity from the log, not a count of live connections.', 'agent-abilities-for-mcp' ) . '
'; echo '
'; // Audit log. aafm_render_stat_head( 'aafm-stat-audit', __( 'Audit log', 'agent-abilities-for-mcp' ), 'audit' ); printf( '
%1$s %2$s
', esc_html( number_format_i18n( $log_rows ) ), esc_html( _n( 'entry', 'entries', $log_rows, 'agent-abilities-for-mcp' ) ) ); if ( $retention > 0 ) { $log_sub = sprintf( /* translators: %s: number of days of activity the log keeps. */ _n( 'Keeping the last %s day of activity.', 'Keeping the last %s days of activity.', $retention, 'agent-abilities-for-mcp' ), number_format_i18n( $retention ) ); } else { $log_sub = __( 'Keeping all activity.', 'agent-abilities-for-mcp' ); } printf( '
%s
', esc_html( $log_sub ) ); echo ''; // Agent users. The VALUE counts only agent users this plugin created (the marker), never a // bare application-password holder - counting those flagged admins with app passwords as // "agent users" they never were. The admin-app-password caution below is a separate, honest // security heads-up computed from the broad candidate set, worded so it does not imply those // admins are agent users. $created_count = count( $created_agents ); aafm_render_stat_head( 'aafm-stat-agent-users', __( 'Agent users', 'agent-abilities-for-mcp' ), 'shield' ); printf( '
%s
', esc_html( number_format_i18n( $created_count ) ) ); if ( 0 === $created_count ) { echo '
' . esc_html__( 'No agent user yet', 'agent-abilities-for-mcp' ) . '
'; } else { echo '
' . esc_html__( 'Created by this plugin', 'agent-abilities-for-mcp' ) . '
'; } // Separate security caution: admins who hold an application password can reach the API and // manage the site, whether or not they are the plugin's agent user. Kept out of the value so // the card's number and this warning never contradict each other. if ( ! empty( $admin_agents ) ) { $admin_count = count( $admin_agents ); $logins = implode( ', ', array_map( static fn( array $c ): string => (string) $c['login'], $admin_agents ) ); echo '
' . esc_html__( 'Review access', 'agent-abilities-for-mcp' ) . '
'; $caution = sprintf( /* translators: 1: number of admin accounts, 2: comma-separated list of their logins. */ _n( '%1$s account can reach the API and manage this site: %2$s.', '%1$s accounts can reach the API and manage this site: %2$s.', $admin_count, 'agent-abilities-for-mcp' ), number_format_i18n( $admin_count ), $logins ); $caution .= ' ' . __( 'If you connect an agent, give it its own low-privilege user instead.', 'agent-abilities-for-mcp' ); printf( '
%s
', esc_html( $caution ) ); } echo ''; echo ''; // .aafm-stat-grid // Lower row: endpoint + versions. echo '
'; // Endpoint card - keeps the existing aafm-copy button + data-copy contract (admin.js binds to it). echo '
'; // Permalink-mode info pill at the row end. $pretty = (bool) get_option( 'permalink_structure' ); $permalink_label = $pretty ? __( 'Pretty permalinks', 'agent-abilities-for-mcp' ) : __( 'Plain permalinks', 'agent-abilities-for-mcp' ); echo '
' . wp_kses( aafm_card_head_html( 'endpoint', __( 'MCP endpoint', 'agent-abilities-for-mcp' ), '', '' . esc_html( $permalink_label ) . '' ), aafm_admin_allowed_html() ) . '
'; echo '
'; echo '

' . esc_html__( 'Point your MCP client here. The Connection tab builds the full client config for you.', 'agent-abilities-for-mcp' ) . '

'; echo wp_kses( sprintf( '
%1$s
', esc_html( $endpoint ), esc_attr( $endpoint ), aafm_icon( 'copy' ), esc_html__( 'Copy', 'agent-abilities-for-mcp' ) ), aafm_admin_allowed_html() ); echo '
'; echo '
'; // Versions card. echo '
'; echo '
' . wp_kses( aafm_card_head_html( 'clock', __( 'Versions', 'agent-abilities-for-mcp' ) ), aafm_admin_allowed_html() ) . '
'; echo '
'; echo '
'; printf( '
%1$s
%2$s
', esc_html__( 'Plugin', 'agent-abilities-for-mcp' ), esc_html( AAFM_VERSION ) ); printf( '
%1$s
%2$s
', esc_html__( 'PHP', 'agent-abilities-for-mcp' ), esc_html( PHP_VERSION ) ); printf( '
%1$s
%2$s
', esc_html__( 'MCP protocol', 'agent-abilities-for-mcp' ), esc_html( aafm_mcp_protocol_version() ) ); printf( '
%1$s
%2$s
', esc_html__( 'Bundled adapter', 'agent-abilities-for-mcp' ), esc_html( $adapter_label ) ); echo '
'; echo '
'; echo '
'; echo '
'; // .aafm-dashboard-lower echo ''; // .aafm-dashboard }