*/ public static function execute( $input = array() ): array { $criteria = is_array( $input ) ? $input : array(); $ability_list = self::filter_list( self::collect_visible_abilities(), $criteria ); $total = count( $ability_list ); [ $page, $per_page ] = self::resolve_pagination( $criteria ); $offset = ( $page - 1 ) * $per_page; $page_items = array_values( array_slice( $ability_list, $offset, $per_page ) ); return array( 'abilities' => $page_items, 'total' => $total, 'returned' => count( $page_items ), 'page' => $page, 'per_page' => $per_page, 'has_more' => ( $offset + count( $page_items ) ) < $total, ); } /** * Every tool-typed ability the current request may see. * * Extracted from `execute()` unchanged — the two gates below are the * security boundary and must run before any caller-supplied filtering. * * PUBLIC so `ServerGuide` can build its inventory from the same list this * tool paginates. A guide that counted the registry directly would report * abilities the caller cannot actually reach, which is worse than no * inventory: it would send a model looking for something the exposure gate * will refuse. * * @return array> */ public static function collect_visible_abilities(): array { $abilities = function_exists( 'wp_get_abilities' ) ? \wp_get_abilities() : array(); $ability_list = array(); foreach ( $abilities as $ability ) { // Only surface tool-typed abilities — matches vendor's semantics. if ( 'tool' !== self::mcp_type( $ability ) ) { continue; } if ( ! self::apply_exposure_filter( $ability, 'discover' ) ) { continue; } $ability_list[] = array( 'name' => $ability->get_name(), 'label' => $ability->get_label(), 'description' => $ability->get_description(), // F089 — lets a model see which category values exist before // using the `category` filter, and gives `search` a fourth // field to match on. 'category' => $ability->get_category(), 'tab_group' => self::tab_group( $ability ), ); } return $ability_list; } /** * The ability's toolset group, or '' when it declares none. * * `meta.acrossai.tab_group` is how the AcrossAI Abilities Manager organises * its library: one string that is simultaneously the group, the admin tab, * that plugin's REST `?tab_group=` filter and the second half of its * `toolset/` tool name. It is the axis a model most often wants to * slice on, and until now this tool could not. * * **This is the one place this plugin reads `meta.acrossai.*`.** Reading a * generic meta KEY is not the same thing as hardcoding another plugin's * vocabulary — no group name appears here, and the rule at * `ToolAbilities.php:15` still holds. Do not read this as licence to name * `toolset/*` slugs anywhere in this plugin. * * Returns '' when the sibling is absent, so every ability is ungrouped and * a `tab_group` filter matches nothing. That is the honest answer: there * are no groups on such a site. * * @param \WP_Ability $ability Ability to read. * @return string */ private static function tab_group( $ability ): string { if ( ! method_exists( $ability, 'get_meta' ) ) { return ''; } $meta = $ability->get_meta(); // No is_array() guard: WP_Ability::get_meta() declares `: array` in core, // so the check is dead code and PHPStan level 8 flags it. if ( ! isset( $meta['acrossai']['tab_group'] ) ) { return ''; } return (string) $meta['acrossai']['tab_group']; } /** * Apply `search`, `category`, `namespace` and `tab_group`. Criteria AND together; * an absent or empty criterion is a no-op. * * @param array> $entries Visible abilities. * @param array $criteria Raw caller input. * @return array> */ private static function filter_list( array $entries, array $criteria ): array { $search = isset( $criteria['search'] ) ? strtolower( trim( (string) $criteria['search'] ) ) : ''; $category = isset( $criteria['category'] ) ? trim( (string) $criteria['category'] ) : ''; $namespace = isset( $criteria['namespace'] ) ? trim( (string) $criteria['namespace'], " \t\n\r\0\x0B/" ) : ''; $tab_group = isset( $criteria['tab_group'] ) ? trim( (string) $criteria['tab_group'] ) : ''; if ( '' === $search && '' === $category && '' === $namespace && '' === $tab_group ) { return $entries; } $matches = array(); foreach ( $entries as $entry ) { if ( '' !== $tab_group && ( $entry['tab_group'] ?? '' ) !== $tab_group ) { continue; } if ( '' !== $category && $entry['category'] !== $category ) { continue; } if ( '' !== $namespace && ! Compat::str_starts_with( $entry['name'], $namespace . '/' ) ) { continue; } if ( '' !== $search && ! self::matches_search( $entry, $search ) ) { continue; } $matches[] = $entry; } return $matches; } /** * Case-insensitive substring match across the four text fields. * * @param array $entry One ability entry. * @param string $search Already lower-cased needle. */ private static function matches_search( array $entry, string $search ): bool { foreach ( array( 'name', 'label', 'description', 'category' ) as $field ) { if ( Compat::str_contains( strtolower( $entry[ $field ] ?? '' ), $search ) ) { return true; } } return false; } /** * Resolve `page` and `per_page`, applying defaults and clamping. * * Core does not fill per-property schema defaults — it only applies the * top-level one — so the defaults are applied here. * * @param array $criteria Raw caller input. * @return array{0:int, 1:int} [ page, per_page ] */ private static function resolve_pagination( array $criteria ): array { // Operator setting (MCP → Settings → Ability Discovery) is the base // default; the filter below still wins so programmatic control is not // locked out by a saved option. $default = (int) get_option( SettingsMenu::DISCOVER_PER_PAGE_OPTION, self::PER_PAGE_DEFAULT ); if ( $default < 1 ) { $default = self::PER_PAGE_DEFAULT; } /** * Filter the default page size for `discover-abilities`. * * Receives the operator's saved setting, not the constant, so a * callback can adjust relative to what the site chose. * * @since 0.3.4 (Feature 089) * * @param int $per_page Default page size. */ $default = (int) apply_filters( 'acrossai_mcp_discover_abilities_default_per_page', $default ); /** * Filter the maximum page size for `discover-abilities`. * * @since 0.3.4 (Feature 089) * * @param int $maximum Maximum page size. */ $maximum = (int) apply_filters( 'acrossai_mcp_discover_abilities_max_per_page', self::PER_PAGE_MAXIMUM ); $maximum = max( 1, $maximum ); $per_page = isset( $criteria['per_page'] ) ? (int) $criteria['per_page'] : $default; $per_page = max( 1, min( $maximum, $per_page ) ); $page = isset( $criteria['page'] ) ? (int) $criteria['page'] : 1; $page = max( 1, $page ); return array( $page, $per_page ); } }