WP_REST_Server::READABLE, 'callback' => [ $this, 'get_abilities' ], 'permission_callback' => [ $this, 'check_permission' ], ] ); register_rest_route( $namespace, '/abilities/bulk', [ 'methods' => WP_REST_Server::EDITABLE, 'callback' => [ $this, 'bulk_update' ], 'permission_callback' => [ $this, 'check_permission' ], 'args' => [ 'ids' => [ 'type' => 'array', 'required' => true, 'items' => [ 'type' => 'string' ], ], 'enabled' => [ 'type' => 'boolean', 'required' => true, ], ], ] ); register_rest_route( $namespace, '/abilities/(?P[a-z0-9_-]+)/(?P[a-z0-9_-]+)', [ 'methods' => WP_REST_Server::EDITABLE, 'callback' => [ $this, 'update_ability' ], 'permission_callback' => [ $this, 'check_permission' ], 'args' => [ 'enabled' => [ 'type' => 'boolean', 'required' => true, ], ], ] ); } /** * GET /abilities — the full dataset for the screen. * * @return WP_REST_Response * @since 1.3.0 */ public function get_abilities(): WP_REST_Response { return rest_ensure_response( AbilitiesPayload::build() ); } /** * POST /abilities/{namespace}/{name} — toggle one ability's enabled state. * * The id carries a slash, so it arrives as two path segments and is * reconstructed here. * * @param WP_REST_Request $request The request. * * @return WP_REST_Response|WP_Error * @since 1.3.0 */ public function update_ability( WP_REST_Request $request ): WP_REST_Response|WP_Error { $ability_id = $request['namespace'] . '/' . $request['name']; // `wp_has_ability()`, not `wp_get_ability()`: the latter raises // _doing_it_wrong() before returning null, so a request naming an id // that does not exist would log a notice on its way to an honest 404. if ( ! function_exists( 'wp_has_ability' ) || ! wp_has_ability( $ability_id ) ) { return new WP_Error( 'albert_ability_not_found', __( 'Ability not found.', 'albert-ai-butler' ), [ 'status' => 404 ] ); } // The MCP adapter's own tools are protected — they can never be disabled. if ( in_array( $ability_id, AbilitiesRegistry::get_protected_abilities(), true ) ) { return new WP_Error( 'albert_ability_protected', __( 'This ability is required by the MCP adapter and can’t be toggled.', 'albert-ai-butler' ), [ 'status' => 403 ] ); } AbilitiesState::set_enabled( $ability_id, (bool) $request['enabled'] ); return rest_ensure_response( AbilitiesPayload::row( $ability_id ) ); } /** * POST /abilities/bulk — enable or disable many abilities at once. * * @param WP_REST_Request $request The request. * * @return WP_REST_Response * @since 1.3.0 */ public function bulk_update( WP_REST_Request $request ): WP_REST_Response { $has_registry = function_exists( 'wp_has_ability' ); $protected = AbilitiesRegistry::get_protected_abilities(); $ids = array_values( array_filter( array_map( 'strval', (array) $request['ids'] ), static function ( string $id ) use ( $has_registry, $protected ): bool { if ( ! preg_match( '#^[a-z0-9_-]+/[a-z0-9_-]+$#', $id ) ) { return false; } // Protected abilities (the MCP adapter's own tools) can never // be disabled — drop them. if ( in_array( $id, $protected, true ) ) { return false; } // Only act on abilities that actually exist, so phantom ids // can't bloat the disabled-abilities option. Asked with // wp_has_ability(), which is silent; wp_get_ability() // raises _doing_it_wrong() per miss, so a bulk request // carrying phantom ids would print one notice each. return ! $has_registry || wp_has_ability( $id ); } ) ); $enabled = (bool) $request['enabled']; AbilitiesState::set_enabled_bulk( $ids, $enabled ); return rest_ensure_response( [ 'updated' => count( $ids ), 'enabled' => $enabled, ] ); } /** * Opt this controller's requests into the abilities management context. * * Keeps disabled abilities in the registry for our endpoint so the screen * can list and re-enable them. Matches by request URI (available before any * dispatch), mirroring how the admin page is detected by its `page` query arg. * * @param bool $is_management_context Incoming value. * * @return bool * @since 1.3.0 */ public function filter_management_context( bool $is_management_context ): bool { return $is_management_context || $this->is_abilities_request(); } /** * Whether the current request targets this controller's abilities routes. * * @return bool * @since 1.3.0 */ private function is_abilities_request(): bool { // Input sanitizer (not esc_url_raw, which is an output escaper and would // mangle encoded path characters). The value is only substring-matched. $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; // Match the path only — a query string like ?q=albert/v1/abilities on an // unrelated REST request must not count as our endpoint. $path = strtok( $uri, '?' ); return is_string( $path ) && str_contains( $path, '/' . Plugin::rest_namespace() . '/abilities' ); } }