display label and the inline-SVG icon key. * * Each SEO plugin is its own card with its own ability set, since the per-plugin sets are * independent (a site can run more than one). The plugins list backs the installed-but-inactive * probe for that specific plugin. * * @return array}> */ function aafm_integration_cards(): array { // One neutral glyph for every card: the integration "plug" icon. Each card already carries // its own name, status pill, and ability count, so the icon only needs to read as "an // integration" - not classify it. Deliberately NOT the 'abilities'/'bolt' glyph, which means // "enabled" elsewhere in this UI; reusing it here would imply a state the icon doesn't track. return array( 'yoast' => array( 'label' => __( 'Yoast SEO', 'agent-abilities-for-mcp' ), 'icon' => 'integrations', 'plugins' => array( 'wordpress-seo/wp-seo.php' ), ), 'rankmath' => array( 'label' => __( 'Rank Math', 'agent-abilities-for-mcp' ), 'icon' => 'integrations', 'plugins' => array( 'seo-by-rank-math/rank-math.php' ), ), 'aioseo' => array( 'label' => __( 'All in One SEO', 'agent-abilities-for-mcp' ), 'icon' => 'integrations', 'plugins' => array( 'all-in-one-seo-pack/all_in_one_seo_pack.php' ), ), 'acf' => array( 'label' => __( 'ACF', 'agent-abilities-for-mcp' ), 'icon' => 'integrations', 'plugins' => array( 'advanced-custom-fields/acf.php', 'advanced-custom-fields-pro/acf.php', 'secure-custom-fields/secure-custom-fields.php' ), ), 'woocommerce' => array( 'label' => __( 'WooCommerce', 'agent-abilities-for-mcp' ), 'icon' => 'integrations', 'plugins' => array( 'woocommerce/woocommerce.php' ), ), 'slim_seo' => array( 'label' => __( 'Slim SEO', 'agent-abilities-for-mcp' ), 'icon' => 'integrations', 'plugins' => array( 'slim-seo/slim-seo.php' ), ), 'tec' => array( 'label' => __( 'The Events Calendar', 'agent-abilities-for-mcp' ), 'icon' => 'integrations', 'plugins' => array( 'the-events-calendar/the-events-calendar.php' ), ), 'event_tickets' => array( 'label' => __( 'Event Tickets', 'agent-abilities-for-mcp' ), 'icon' => 'integrations', 'plugins' => array( 'event-tickets/event-tickets.php' ), ), 'avada' => array( 'label' => __( 'Avada / Fusion Builder', 'agent-abilities-for-mcp' ), 'icon' => 'integrations', // Avada is a theme, not a plugin - there is no plugin file to probe for an // "installed but inactive" state, so this deliberately stays empty and // aafm_integration_status() falls through to 'not_installed' whenever inactive // (Task 21 Step 7: active/not_installed only, no installed_inactive for this card). 'plugins' => array(), ), 'geodirectory' => array( 'label' => __( 'GeoDirectory', 'agent-abilities-for-mcp' ), 'icon' => 'integrations', 'plugins' => array( 'geodirectory/geodirectory.php' ), ), ); } /** * The detected status of an integration on this site. * * 'active' - host plugin active (aafm_integration_active() true). * 'below_floor' - WooCommerce only: the plugin is active but below the abilities' * required version floor (AAFM_WOOCOMMERCE_MIN_VERSION), so the WC * abilities do not register. Checked before the file-presence probe so a * genuinely-active-but-outdated store gets the accurate reason, not the * generic "Not installed"/"Inactive" copy. * 'missing_dependency' - Event Tickets only: the plugin is active but The Events Calendar is * not, so its ticket abilities do not register (they are gated on a * parent TEC event - see includes/abilities/tec/tickets.php). Checked * before the generic 'active' report so the card never claims a * readiness the runtime registration gate does not actually grant. * 'installed_inactive' - a candidate host plugin file is present but not active. * 'not_installed' - no candidate host plugin file is present. * * @param string $slug Integration slug. * @return string One of 'active' | 'below_floor' | 'missing_dependency' | 'installed_inactive' | * 'not_installed'. */ function aafm_integration_status( string $slug ): string { // Registering Event Tickets' abilities already requires TEC too // (includes/abilities/tec/tickets.php), but this card reported a bare 'active' from Event // Tickets alone, so the card said "Active" and let the operator enable abilities that would // never actually register or appear in tools/list. Checked before the general 'active' report // below, the same way 'below_floor' is checked before it for a version mismatch. if ( 'event_tickets' === $slug && aafm_integration_active( 'event_tickets' ) && ! aafm_integration_active( 'tec' ) ) { return 'missing_dependency'; } if ( aafm_integration_active( $slug ) ) { return 'active'; } if ( 'woocommerce' === $slug && aafm_woocommerce_below_version_floor() ) { return 'below_floor'; } if ( 'tec' === $slug && aafm_tec_below_version_floor() ) { return 'below_floor'; } if ( 'event_tickets' === $slug && aafm_event_tickets_below_version_floor() ) { return 'below_floor'; } if ( 'aioseo' === $slug && aafm_aioseo_below_version_floor() ) { return 'below_floor'; } $cards = aafm_integration_cards(); $files = $cards[ $slug ]['plugins'] ?? array(); foreach ( $files as $file ) { if ( aafm_integration_plugin_file_exists( $file ) ) { return 'installed_inactive'; } } return 'not_installed'; } /** * Whether WooCommerce is installed and active as a host plugin, but below the abilities' * required version floor. * * A separate, real (unfiltered by aafm_integration_active_woocommerce) probe purely for the * Integrations tab's reason line - aafm_woocommerce_active() already reports "not active" for a * below-floor site, which is correct for ability registration but would otherwise show the * misleading generic "Not installed"/"Inactive" copy for a store that is genuinely running, * just on an old WooCommerce. Reads the same aafm_woocommerce_version() seam as the floor check * so a test can pin both consistently. * * @return bool */ function aafm_woocommerce_below_version_floor(): bool { if ( ! class_exists( 'WooCommerce' ) ) { return false; } $version = aafm_woocommerce_version(); return null !== $version && ! version_compare( $version, AAFM_WOOCOMMERCE_MIN_VERSION, '>=' ); } /** * Whether All in One SEO is installed and active, but below AAFM_AIOSEO_MIN_VERSION. * Mirrors aafm_woocommerce_below_version_floor()'s shape, with AIOSEO's aioseo() marker. * * @return bool */ function aafm_aioseo_below_version_floor(): bool { if ( ! function_exists( 'aioseo' ) ) { return false; } $version = aafm_aioseo_version(); return null !== $version && ! version_compare( $version, AAFM_AIOSEO_MIN_VERSION, '>=' ); } /** * Whether The Events Calendar is installed and active, but below AAFM_TEC_MIN_VERSION. * Mirrors aafm_woocommerce_below_version_floor()'s shape exactly. * * @return bool */ function aafm_tec_below_version_floor(): bool { if ( ! class_exists( 'Tribe__Events__Main' ) ) { return false; } $version = aafm_tec_version(); return null !== $version && ! version_compare( $version, AAFM_TEC_MIN_VERSION, '>=' ); } /** * Whether Event Tickets is installed and active, but below AAFM_EVENT_TICKETS_MIN_VERSION. * * @return bool */ function aafm_event_tickets_below_version_floor(): bool { if ( ! class_exists( 'Tribe__Tickets__Main' ) ) { return false; } $version = aafm_event_tickets_version(); return null !== $version && ! version_compare( $version, AAFM_EVENT_TICKETS_MIN_VERSION, '>=' ); } /** * Whether a plugin file is present in the plugins directory (installed-but-inactive probe). * * Read-only existence check against WP_PLUGIN_DIR; never loads or activates the plugin. * * @param string $plugin_file Plugin file relative to the plugins directory. * @return bool */ function aafm_integration_plugin_file_exists( string $plugin_file ): bool { if ( ! defined( 'WP_PLUGIN_DIR' ) ) { return false; } return file_exists( WP_PLUGIN_DIR . '/' . $plugin_file ); } /** * Build the "N read, N write[, N destructive]" breakdown segment of a card header count. * * The manifest already tallies destructive abilities into their own bucket via * aafm_integration_manifest() (total === read + write + destructive), but this string used to * interpolate only read and write, so a card with any destructive ability (WooCommerce has two: * trashing/deleting an order) showed a read+write sum that fell short of the total next to it - * "0 / 52 · 27 read, 23 write" adds up to 50, silently dropping the two destructive rows from the * visible breakdown. * The destructive segment only appears when the count is non-zero, so a card with none stays the * familiar two-part string rather than always showing "0 destructive". * * @param array{total:int,read:int,write:int,destructive:int} $counts Manifest counts for one integration. * @return string Plain text (escaped by the caller). */ function aafm_integration_count_breakdown( array $counts ): string { $destructive = (int) ( $counts['destructive'] ?? 0 ); if ( $destructive > 0 ) { return sprintf( /* translators: 1: read count, 2: write count, 3: destructive count. */ __( '%1$d read, %2$d write, %3$d destructive', 'agent-abilities-for-mcp' ), (int) $counts['read'], (int) $counts['write'], $destructive ); } return sprintf( /* translators: 1: read count, 2: write count. */ __( '%1$d read, %2$d write', 'agent-abilities-for-mcp' ), (int) $counts['read'], (int) $counts['write'] ); } /** * Render the Integrations tab: the disclaimer header, then one card per integration. * * @return void */ function aafm_render_integrations_tab(): void { $registry = aafm_get_abilities_registry(); $enabled = aafm_get_enabled_abilities(); $disclosures = aafm_ability_disclosures(); // Bucket integration abilities by their subject (the integration slug). $by_subject = array(); foreach ( $registry as $name => $meta ) { $subject = (string) ( $meta['subject'] ?? '' ); $by_subject[ $subject ][] = array( 'name' => (string) $name ) + $meta; } echo '
'; // Intro lede + the security disclaimer (humanized copy). echo '

' . esc_html__( 'Connect AI agents to the plugins you already run. An integration\'s abilities show up here only while its plugin is active, and each one stays off until you turn it on.', 'agent-abilities-for-mcp' ) . '

'; echo '
'; echo wp_kses( aafm_get_notice_html( 'warning', __( 'These integrations let an agent read and write real data on your site, including personal data through WooCommerce and ACF: customer names, emails, addresses, order details. What an agent can actually touch is still bounded by the WordPress role of the account it connects as, and by the abilities you switch on below. Everything it does is recorded in the activity log. Turn on only what you trust the connected agent to handle.', 'agent-abilities-for-mcp' ) ), aafm_admin_allowed_html() ); echo '
'; // One outer form for every per-ability toggle across all integration cards (never a // nested form). The save handler binds to the aafm_save_abilities AJAX action, which writes // the same option as the Abilities tab; the stored value is a flat list of enabled ability names. echo '
'; wp_nonce_field( 'aafm_admin', 'aafm_nonce' ); // This form saves through aafm_save_abilities into the same option as the Abilities tab, but it // only renders integration toggles. Rather than carrying off-tab abilities forward as // client-side hidden inputs (which a stale tab or a tamper could drop or flip), it declares // the subjects it OWNS via aafm_scope[]. The server preserves every persisted ability outside // that scope from the stored option and only updates the in-scope ones - see // aafm_resolve_scoped_enabled_input(). $integration_subjects = array_keys( aafm_integration_cards() ); foreach ( $integration_subjects as $scope_subject ) { printf( '', esc_attr( $scope_subject ) ); } $descriptor = aafm_integration_ability_manifest(); foreach ( aafm_integration_cards() as $slug => $card ) { $status = aafm_integration_status( $slug ); $disabled = ( 'active' !== $status ); // Ability rows always come from the static descriptor, so an inactive host still shows the // full list. When the host is active the live registry holds the same set, so prefer the // live rows (they carry the real risk/group/description); otherwise fall back to the // descriptor's static rows, which are rendered disabled below. $rows = $disabled ? ( $descriptor[ $slug ] ?? array() ) : ( $by_subject[ $slug ] ?? ( $descriptor[ $slug ] ?? array() ) ); // Each card is a native
accordion (collapsed by default - no open attribute), so // the whole section is the toggle. The card classes ride on the
so the existing // .aafm-integration-{slug} hooks and .is-disabled muting still apply. printf( '
', esc_attr( $slug ), $disabled ? ' is-disabled' : '' ); $counts = aafm_integration_manifest()[ $slug ] ?? null; // The ENABLED count is computed from the saved option, exactly like the Abilities tab: // count how many of this integration's ability names the operator has turned on. Count // against the same manifest set that backs the total/read/write tallies ($descriptor), // so the enabled figure can never exceed the denominator shown beside it. if ( null !== $counts ) { $enabled_in_card = 0; foreach ( $descriptor[ $slug ] ?? array() as $manifest_row ) { if ( in_array( (string) $manifest_row['name'], $enabled, true ) ) { ++$enabled_in_card; } } $counts['enabled'] = $enabled_in_card; } // The IS the card head: icon + label + status pill + count. A real // toggles on click and on Enter/Space natively, so the accordion stays keyboard-accessible. echo ''; echo ''; echo wp_kses( aafm_icon( $card['icon'] ), aafm_svg_allowed_html() ); echo ''; echo '

' . esc_html( $card['label'] ) . '

'; echo wp_kses( aafm_integration_status_pill( $status ), aafm_admin_allowed_html() ); echo ''; if ( null !== $counts ) { // The enabled figure is wrapped in its own span so admin.js can patch just that // number after a save, instead of the whole card header reading stale ("0 / 52") // until the next full page load. wp_kses (not esc_html) because the composed // string now carries that one span of our own trusted markup around an escaped int. printf( '

%s

', wp_kses( sprintf( /* translators: 1: enabled abilities (wrapped for a live update after save), 2: total abilities, 3: read/write/destructive breakdown, e.g. "27 read, 23 write, 2 destructive". */ __( '%1$s / %2$d · %3$s', 'agent-abilities-for-mcp' ), '' . esc_html( (string) (int) $counts['enabled'] ) . '', (int) $counts['total'], esc_html( aafm_integration_count_breakdown( $counts ) ) ), aafm_admin_allowed_html() ) ); } echo '
'; echo '
'; // Accordion content: the status note, the per-card filter, then the ability list directly. echo '
'; echo '

' . esc_html__( 'Maintained by Agent Abilities.', 'agent-abilities-for-mcp' ) . '

'; echo '

' . esc_html( aafm_integration_status_note( $slug, $status ) ) . '

'; aafm_render_integration_filter( $slug ); aafm_render_integration_abilities( $slug, $rows, $enabled, $disclosures, $disabled ); echo '
'; echo '
'; } echo '
'; echo ''; echo '
'; // .aafm-integrations } /** * The status pill markup for an integration card head. * * @param string $status One of 'active' | 'below_floor' | 'missing_dependency' | * 'installed_inactive' | 'not_installed'. * @return string Escaped HTML. */ function aafm_integration_status_pill( string $status ): string { $map = array( 'active' => array( 'aafm-pill-success', __( 'Active', 'agent-abilities-for-mcp' ) ), 'below_floor' => array( 'aafm-pill-warn', __( 'Update required', 'agent-abilities-for-mcp' ) ), 'missing_dependency' => array( 'aafm-pill-warn', __( 'Requires The Events Calendar', 'agent-abilities-for-mcp' ) ), 'installed_inactive' => array( 'aafm-pill-warn', __( 'Inactive', 'agent-abilities-for-mcp' ) ), 'not_installed' => array( 'aafm-pill-neutral', __( 'Not installed', 'agent-abilities-for-mcp' ) ), ); list( $class, $label ) = $map[ $status ] ?? $map['not_installed']; return sprintf( '%2$s', esc_attr( $class ), esc_html( $label ) ); } /** * The plain-language status note shown under an integration card head. * * @param string $slug Integration slug. * @param string $status Detected status. * @return string Plain text (escaped by the caller). */ function aafm_integration_status_note( string $slug, string $status ): string { $cards = aafm_integration_cards(); $label = $cards[ $slug ]['label'] ?? $slug; switch ( $status ) { case 'active': return __( 'Active. Turn on the abilities you want this agent to use.', 'agent-abilities-for-mcp' ); case 'below_floor': // This used to hardcode WooCommerce's own constant and // version function for EVERY integration's below-floor notice, so a TEC or Event // Tickets site below its real floor was told to install a WooCommerce version it // likely already had. Each versioned integration's own minimum-version constant and // version-reader function, matching the exact pair aafm_{slug}_below_version_floor() // already checks against for that slug. $version_floor = array( 'woocommerce' => array( AAFM_WOOCOMMERCE_MIN_VERSION, 'aafm_woocommerce_version' ), 'tec' => array( AAFM_TEC_MIN_VERSION, 'aafm_tec_version' ), 'event_tickets' => array( AAFM_EVENT_TICKETS_MIN_VERSION, 'aafm_event_tickets_version' ), 'aioseo' => array( AAFM_AIOSEO_MIN_VERSION, 'aafm_aioseo_version' ), ); [ $min_version, $version_reader ] = $version_floor[ $slug ] ?? array( '', '__return_null' ); return sprintf( /* translators: 1: the integration plugin name, e.g. WooCommerce. 2: the minimum required version. 3: the version installed on this site. */ __( '%1$s %2$s or newer is required for these abilities; this site is running %3$s.', 'agent-abilities-for-mcp' ), $label, $min_version, (string) $version_reader() ); case 'missing_dependency': // Event Tickets only, currently: every ticket ability is gated on a parent TEC event // (includes/abilities/tec/tickets.php), so Event Tickets alone cannot register them. return __( 'Event Tickets is active, but its ticket abilities are gated on a parent event and need The Events Calendar active too. Install and activate The Events Calendar to use these abilities.', 'agent-abilities-for-mcp' ); case 'installed_inactive': return sprintf( /* translators: %s: the integration plugin name, e.g. WooCommerce. */ __( 'Installed but not active. Activate %s to use these abilities.', 'agent-abilities-for-mcp' ), $label ); case 'not_installed': default: return __( 'Not installed. Install and activate the plugin to expose its abilities here.', 'agent-abilities-for-mcp' ); } } /** * Render the per-card search + read/write filter for one integration card. * * A search box plus an All / Read Only / Write toggle group, modelled on the reference MCP * client's tool filter. The controls are scoped to this card by data-card and drive admin.js's * per-card filter, which toggles row visibility only - the buttons are type="button" and the * search input is not an aafm_abilities[] field, so neither interferes with the form submit. * * @param string $slug Integration slug. * @return void */ function aafm_render_integration_filter( string $slug ): void { echo '
'; aafm_render_filter_controls( 'aafm-int-search-' . $slug ); echo '
'; } /** * Render the per-ability toggles for one integration, with a group enable/disable-all control * that confirms before bulk-enabling a PII/destructive group. * * The toggle markup mirrors the Abilities tab exactly so the shared save handler binds to the * same name="aafm_abilities[]" inputs. The bulk control is a type="button" (never a nested form), * per the Wave-0 nested-form lesson. The list renders directly inside the card's accordion body - * the section
is the only collapsible now, so there is no inner sub-collapsible. * * @param string $slug Integration slug. * @param array> $rows This integration's ability rows. * @param array $enabled Enabled ability names. * @param array $disclosures Disclosure map. * @param bool $disabled True when the host is inactive - rows render * read-only and never submit. * @return void */ function aafm_render_integration_abilities( string $slug, array $rows, array $enabled, array $disclosures, bool $disabled = false ): void { $has_sensitive = false; foreach ( $rows as $row ) { if ( 'destructive' === (string) ( $row['risk'] ?? '' ) ) { $has_sensitive = true; break; } } // Group enable/disable-all control (a type="button", never a nested form). The // data-has-sensitive flag tells the JS to window.confirm() before bulk-enabling a group // that can read or change personal data. printf( '

%5$s

', esc_attr( $slug ), $has_sensitive ? ' data-has-sensitive="1"' : '', esc_html__( 'Enable all / Disable all', 'agent-abilities-for-mcp' ), esc_html__( 'Enable all reads', 'agent-abilities-for-mcp' ), $disabled ? '' : wp_kses( aafm_enable_writes_button_html( 'integration-' . $slug, $rows ), aafm_admin_allowed_html() ) ); // Each per-plugin card renders a flat ability list directly in the accordion body. echo '
'; foreach ( $rows as $ability ) { aafm_render_integration_ability_row( $ability, $enabled, $disclosures, $disabled ); } echo '
'; } /** * Render a single ability row inside an integration card. * * Extracted so both the flat list and the SEO sub-section loops share identical markup. * * The disclosure hint is resolved the same way for active and inactive rows - prefer the * aafm_ability_disclosures() line for this ability name, fall back to the row's own description - * so the descriptor never carries its own copy of the disclosure text. * * @param array $ability Ability data row. * @param array $enabled Enabled ability names. * @param array $disclosures Disclosure map. * @param bool $disabled True when the host is inactive - the checkbox renders * disabled (so it never submits) and the row carries * aria-disabled, while staying fully readable. * @return void */ function aafm_render_integration_ability_row( array $ability, array $enabled, array $disclosures, bool $disabled = false ): void { $name = (string) $ability['name']; $risk = (string) ( $ability['risk'] ?? 'read' ); $hint = (string) ( $disclosures[ $name ] ?? ( $ability['description'] ?? '' ) ); // Per-ability id on the title

, used as the checkbox's accessible name via // aria-labelledby - without it a screen reader announces the bare toggle as just // "checkbox". sanitize_key keeps the slug DOM-safe (ability names hold a slash). $title_id = 'aafm-int-ability-title-' . sanitize_key( $name ); // data-high-risk mirrors the Abilities tab so one JS binding can scope on it. It matters most // here: all ten native built-in high-risk abilities are WooCommerce writes and render on this // tab, so this is the attribute that keeps a write-scoped bulk control off refunds, orders, // coupons and tax rates once the operator unlocks the category. See the fuller note in // aafm_render_ability_row(). Held bridge rows carry the same attribute on the Other plugins tab. printf( '
', esc_attr( $risk ), aafm_ability_is_high_risk( $name ) ? ' data-high-risk="1"' : '', $disabled ? ' aria-disabled="true"' : '' ); // An inactive host has nothing enabled, so a disabled row never renders checked; it also // carries the disabled attribute so it stays out of the submitted aafm_abilities[] list. A // locked ability - high-risk, or a write while read-only mode is on - gets the same lock // treatment as the Abilities tab instead of a checkbox, see aafm_render_ability_toggle_control(). // The two are independent: a row can be host-disabled, lock-disabled, both, or neither. $lock_reason = aafm_render_ability_toggle_control( $name, $title_id, $enabled, $disabled ); echo '
'; printf( '

%2$s

%3$s', esc_attr( $title_id ), esc_html( (string) ( $ability['label'] ?? $name ) ), esc_attr( $risk ) ); if ( 'read' === $risk ) { echo ' ' . esc_html__( 'read-only', 'agent-abilities-for-mcp' ) . ''; } // Membership in the high-risk category does not change when the operator unlocks it, so the // badge stays on an unlocked, enabled row too - mirrors aafm_render_ability_row(). if ( aafm_ability_is_high_risk( $name ) ) { echo ' ' . esc_html__( 'high-risk', 'agent-abilities-for-mcp' ) . ''; } printf( '

%1$s

', esc_html( $hint ) ); if ( null !== $lock_reason ) { echo '

' . esc_html( aafm_ability_lock_note( $lock_reason ) ) . '

'; } echo '
'; }