Image Manager > AI assistants. */ return (bool) apply_filters( 'afsd_im_ai_enabled', ! empty( Settings::get()['ai_assistants'] ) ); } /** * Register the category. * * @return void */ public static function category(): void { if ( ! self::api() || ! self::enabled() || ( function_exists( 'wp_has_ability_category' ) && wp_has_ability_category( self::CATEGORY ) ) ) { return; } wp_register_ability_category( self::CATEGORY, array( 'label' => __( 'Image Manager', 'affstackdev-image-manager' ), 'description' => __( 'Image optimization, the media library\'s folders and collections, copy-first editing and media backups in AffStackDev Image Manager. Changes are proposed for the site owner to approve in WordPress.', 'affstackdev-image-manager' ), ) ); } /** * Register every ability. * * @return void */ public static function register(): void { if ( ! self::api() || ! self::enabled() ) { return; } foreach ( self::definitions() as $name => $args ) { $args['category'] = self::CATEGORY; wp_register_ability( self::CATEGORY . '/' . $name, $args ); } } /** * Full names of the abilities. * * @return array */ public static function names(): array { return array_map( static fn( $name ) => self::CATEGORY . '/' . $name, array_keys( self::definitions() ) ); } /** * Image Manager's own MCP address, /wp-json/affstackdev-image-manager/mcp, listing only its abilities. * * @param object $adapter The MCP Adapter. * @return void */ public static function server( $adapter ): void { if ( ! self::enabled() || ! is_object( $adapter ) || ! method_exists( $adapter, 'create_server' ) || ! class_exists( '\WP\MCP\Transport\HttpTransport' ) ) { return; } $tools = array_values( array_filter( self::names(), static fn( $n ) => function_exists( 'wp_get_ability' ) && wp_get_ability( $n ) ) ); if ( ! $tools ) { return; } try { $adapter->create_server( self::CATEGORY, self::CATEGORY, 'mcp', __( 'AffStackDev Image Manager', 'affstackdev-image-manager' ), __( 'Read AffStackDev Image Manager and propose changes for the site owner to approve in WordPress.', 'affstackdev-image-manager' ), VERSION, array( \WP\MCP\Transport\HttpTransport::class ), class_exists( '\WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler' ) ? \WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler::class : null, class_exists( '\WP\MCP\Infrastructure\Observability\NullMcpObservabilityHandler' ) ? \WP\MCP\Infrastructure\Observability\NullMcpObservabilityHandler::class : null, $tools ); } catch ( \Throwable $error ) { // The address is optional; abilities still answer through WordPress's REST routes. return; } } /** * Ability metadata: what AI apps show and ask before. * * @param bool $read_only Reads only. * @return array */ private static function meta( bool $read_only ): array { $annotations = array( 'readonly' => $read_only ); if ( $read_only ) { $annotations['idempotent'] = true; } else { $annotations['destructive'] = false; } return array( 'annotations' => $annotations, 'show_in_rest' => true, 'public' => true, 'mcp' => array( 'public' => true, 'type' => 'tool', ), ); } /** * Remove empty "required" lists from a catalog schema (JSON Schema needs at least one name there). * * @param mixed $schema Schema. * @return mixed */ public static function clean( $schema ) { if ( ! is_array( $schema ) ) { return $schema; } if ( array_key_exists( 'required', $schema ) && array() === $schema['required'] ) { unset( $schema['required'] ); } if ( isset( $schema['properties'] ) && array() === $schema['properties'] ) { $schema['properties'] = (object) array(); } foreach ( array( 'properties', 'items', 'additionalProperties' ) as $key ) { if ( isset( $schema[ $key ] ) && is_array( $schema[ $key ] ) ) { $schema[ $key ] = 'properties' === $key ? array_map( array( __CLASS__, 'clean' ), $schema[ $key ] ) : self::clean( $schema[ $key ] ); } } return $schema; } /** * The operations an AI may propose (every reviewed operation but the read). * * @return array */ private static function changes(): array { return array_values( array_filter( Reviewed_Owner::catalog(), static fn( $d ) => 'inspect' !== $d['operation'] ) ); } /** * The read definition's input schema (sections and filters). * * @return array */ private static function inspect_schema(): array { foreach ( Reviewed_Owner::catalog() as $d ) { if ( 'inspect' === $d['operation'] ) { return self::clean( $d['input_schema'] ); } } return array( 'type' => 'object' ); } /** * Run a read and turn the owner's failure codes into errors an AI app can act on. * * @param callable $work The read. * @return mixed */ private static function read( callable $work ) { try { return $work(); } catch ( \Throwable $e ) { return new \WP_Error( 'im_ai_' . sanitize_key( $e->getMessage() ), __( 'Image Manager could not answer that. Check the section, the media reference or the parameters.', 'affstackdev-image-manager' ), array( 'status' => 400 ) ); } } /** * Every ability. * * @return array Short name => arguments. */ public static function definitions(): array { $can = array( AI_Proposals::class, 'can' ); $ops = array_map( static fn( $d ) => $d['operation'], self::changes() ); $change = array( 'type' => 'object', 'additionalProperties' => false, 'properties' => array( 'operation' => array( 'type' => 'string', 'enum' => $ops, 'description' => __( 'The change, as list-changes names it.', 'affstackdev-image-manager' ), ), 'parameters' => array( 'type' => 'object', 'description' => __( 'Its parameters, following the schema list-changes gives for it.', 'affstackdev-image-manager' ), ), ), 'required' => array( 'operation' ), ); $propose = $change; $propose['properties']['note'] = array( 'type' => 'string', 'maxLength' => 500, 'description' => __( 'Why: shown to the site owner with the proposal.', 'affstackdev-image-manager' ), ); return array( 'inspect' => array( 'label' => __( 'Read Image Manager', 'affstackdev-image-manager' ), 'description' => __( 'Read one section of Image Manager: settings, the optimization workflow and its images, the media library with folders and collections, editing drafts, backups and remote backup, health, folder imports, files with no use found, folders for pages and posts, sizes fitted to the page, the CDN.', 'affstackdev-image-manager' ), 'input_schema' => self::inspect_schema(), 'execute_callback' => static fn( $input = null ) => self::read( static fn() => Reviewed_Owner::inspect( is_array( $input ) ? $input : array() ) ), 'permission_callback' => $can, 'meta' => self::meta( true ), ), 'list-changes' => array( 'label' => __( 'List the changes Image Manager can make', 'affstackdev-image-manager' ), 'description' => __( 'Every change an AI may propose: its name, what it does, the screen it belongs to and the schema of its parameters.', 'affstackdev-image-manager' ), 'input_schema' => array( 'type' => 'object', 'additionalProperties' => false, 'properties' => array( 'query' => array( 'type' => 'string', 'maxLength' => 80, 'description' => __( 'Only changes whose name, title or description contain these words.', 'affstackdev-image-manager' ), ), ), 'default' => array(), ), 'execute_callback' => static function ( $input = null ) { $q = strtolower( trim( (string) ( is_array( $input ) ? ( $input['query'] ?? '' ) : '' ) ) ); $out = array(); foreach ( self::changes() as $d ) { if ( '' !== $q && ! str_contains( strtolower( $d['operation'] . ' ' . $d['title'] . ' ' . $d['effect'] ), $q ) ) { continue; } $out[] = array( 'operation' => $d['operation'], 'title' => $d['title'], 'effect' => $d['effect'], 'screen' => $d['screen'] ?? '', 'input_schema' => self::clean( $d['input_schema'] ), ); } return array( 'changes' => $out ); }, 'permission_callback' => $can, 'meta' => self::meta( true ), ), 'preview-change' => array( 'label' => __( 'Preview an Image Manager change', 'affstackdev-image-manager' ), 'description' => __( 'What a change would touch and its expected result, without changing anything.', 'affstackdev-image-manager' ), 'input_schema' => $change, 'execute_callback' => static function ( $input = null ) { $def = AI_Proposals::definition( (string) ( $input['operation'] ?? '' ) ); if ( is_wp_error( $def ) ) { return $def; } $params = AI_Proposals::validate( $def, (array) ( $input['parameters'] ?? array() ) ); return is_wp_error( $params ) ? $params : Reviewed_Actions::inspect( $def['operation'], $params, AI_Proposals::principal() ); }, 'permission_callback' => $can, 'meta' => self::meta( true ), ), 'propose-change' => array( 'label' => __( 'Propose an Image Manager change', 'affstackdev-image-manager' ), 'description' => __( 'Prepare a change for the site owner, who approves or rejects it in WordPress. Nothing changes until then; the answer is the exact review the owner sees.', 'affstackdev-image-manager' ), 'input_schema' => $propose, // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- only compared, to name how the proposal arrived. 'execute_callback' => static fn( $input = null ) => AI_Proposals::propose( (string) ( $input['operation'] ?? '' ), (array) ( $input['parameters'] ?? array() ), (string) ( $input['note'] ?? '' ), ( defined( 'REST_REQUEST' ) && REST_REQUEST && str_contains( (string) ( $_SERVER['REQUEST_URI'] ?? '' ), '/mcp' ) ) ? 'mcp' : 'rest' ), 'permission_callback' => $can, 'meta' => self::meta( false ), ), 'proposal' => array( 'label' => __( 'Read a proposed change', 'affstackdev-image-manager' ), 'description' => __( 'Whether the site owner approved, rejected or has not yet decided a proposed change, and its result.', 'affstackdev-image-manager' ), 'input_schema' => array( 'type' => 'object', 'additionalProperties' => false, 'properties' => array( 'proposal_id' => array( 'type' => 'string', 'pattern' => '^[a-f0-9-]{36}$', ), ), 'required' => array( 'proposal_id' ), ), 'execute_callback' => static function ( $input = null ) { $p = AI_Proposals::get( (string) ( $input['proposal_id'] ?? '' ) ); return $p ? AI_Proposals::view( $p ) : new \WP_Error( 'im_ai_no_proposal', __( 'No such proposal.', 'affstackdev-image-manager' ), array( 'status' => 404 ) ); }, 'permission_callback' => $can, 'meta' => self::meta( true ), ), ); } }