array( 'name' => __( 'MCP Tools', 'ai-conduit' ), 'singular_name' => __( 'MCP Tool', 'ai-conduit' ), ), 'public' => false, 'show_ui' => false, 'show_in_rest' => false, 'supports' => array( 'title' ), 'capabilities' => array( 'create_posts' => 'manage_wlconduit_tools', 'edit_posts' => 'manage_wlconduit_tools', 'edit_others_posts' => 'manage_wlconduit_tools', 'delete_posts' => 'manage_wlconduit_tools', 'read_private_posts' => 'manage_wlconduit_tools', 'publish_posts' => 'manage_wlconduit_tools', ), 'map_meta_cap' => false, ) ); } /** * Save a new tool. * * @since 1.0.0 * * @param array $config The tool configuration. * @return int|WP_Error The post ID on success, WP_Error on failure. */ public function save_tool( array $config ): int|WP_Error { $post_id = wp_insert_post( array( 'post_type' => self::POST_TYPE, 'post_title' => sanitize_text_field( $config['tool_name'] ?? '' ), 'post_status' => 'publish', ), true ); if ( is_wp_error( $post_id ) ) { return $post_id; } update_post_meta( $post_id, self::META_KEY, wp_json_encode( $this->sanitize_config( $config ) ) ); return $post_id; } /** * Update an existing tool. * * @since 1.0.0 * * @param int $id The tool post ID. * @param array $config The updated tool configuration. * @return int|WP_Error The post ID on success, WP_Error on failure. */ public function update_tool( int $id, array $config ): int|WP_Error { $post = get_post( $id ); if ( ! $post || self::POST_TYPE !== $post->post_type ) { return new WP_Error( 'not_found', __( 'Tool not found.', 'ai-conduit' ), array( 'status' => 404 ) ); } $result = wp_update_post( array( 'ID' => $id, 'post_title' => sanitize_text_field( $config['tool_name'] ?? $post->post_title ), ), true ); if ( is_wp_error( $result ) ) { return $result; } update_post_meta( $id, self::META_KEY, wp_json_encode( $this->sanitize_config( $config ) ) ); return $id; } /** * Delete a tool. * * @since 1.0.0 * * @param int $id The tool post ID. * @return bool|WP_Error True on success, WP_Error on failure. */ public function delete_tool( int $id ): bool|WP_Error { $post = get_post( $id ); if ( ! $post || self::POST_TYPE !== $post->post_type ) { return new WP_Error( 'not_found', __( 'Tool not found.', 'ai-conduit' ), array( 'status' => 404 ) ); } $result = wp_delete_post( $id, true ); return false !== $result; } /** * Get a single tool by ID. * * @since 1.0.0 * * @param int $id The tool post ID. * @return array|null The tool data or null if not found. */ public function get_tool( int $id ): ?array { $post = get_post( $id ); if ( ! $post || self::POST_TYPE !== $post->post_type ) { return null; } $config = json_decode( get_post_meta( $id, self::META_KEY, true ), true ); return array( 'id' => $post->ID, 'config' => is_array( $config ) ? $config : array(), 'created' => $post->post_date, 'modified' => $post->post_modified, ); } /** * Check if a tool name already exists, optionally excluding a specific tool ID. * * @since 1.0.0 * * @param string $tool_name The tool name to check. * @param int|null $exclude_id Optional tool ID to exclude (for updates). * @return bool True if the name already exists. */ public function tool_name_exists( string $tool_name, ?int $exclude_id = null ): bool { $posts = get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', ) ); foreach ( $posts as $post_id ) { if ( $exclude_id && $post_id === $exclude_id ) { continue; } $config = json_decode( get_post_meta( $post_id, self::META_KEY, true ), true ); if ( is_array( $config ) && ( $config['tool_name'] ?? '' ) === $tool_name ) { return true; } } return false; } /** * Get all published tools. * * @since 1.0.0 * * @return array List of tool summaries. */ public function get_all_tools(): array { $posts = get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'title', 'order' => 'ASC', ) ); $tools = array(); foreach ( $posts as $post ) { $config = json_decode( get_post_meta( $post->ID, self::META_KEY, true ), true ); if ( ! is_array( $config ) ) { $config = array(); } $tools[] = array( 'id' => $post->ID, 'tool_name' => $config['tool_name'] ?? $post->post_title, 'description' => $config['description'] ?? '', 'block_count' => count( $config['blocks'] ?? array() ), 'created' => $post->post_date, 'modified' => $post->post_modified, ); } return $tools; } /** * Export tool configs by ID, or all tools if no IDs provided. * * Returns raw config arrays suitable for import on another site. * Site-specific data (post ID, dates) is stripped. * * @since 1.0.0 * * @param array $ids Optional list of tool post IDs. Empty for all. * @return array List of tool config arrays. */ public function export_tools( array $ids = array() ): array { if ( empty( $ids ) ) { $posts = get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', ) ); $ids = $posts; } $configs = array(); foreach ( $ids as $id ) { $tool = $this->get_tool( absint( $id ) ); if ( ! $tool || empty( $tool['config'] ) ) { continue; } $configs[] = $tool['config']; } return $configs; } /** * Import a single tool config with block type validation and auto-rename on conflict. * * Validates that all block types in the config are registered in the block registry. * If the tool name already exists, it is automatically renamed with an _imported suffix. * The config is saved through the same sanitize_config() pipeline as the UI. * * @since 1.0.0 * * @param array $config The tool configuration to import. * @return array Result with 'status', and either 'id'/'tool_name' on success or 'message' on error. */ public function import_tool( array $config ): array { $tool_name = sanitize_text_field( $config['tool_name'] ?? '' ); if ( empty( $tool_name ) ) { return array( 'status' => 'error', 'message' => __( 'Tool name is missing.', 'ai-conduit' ), ); } // Validate all block types exist in the registry. $unknown_types = array(); foreach ( $config['blocks'] ?? array() as $block ) { $block_type = $block['block_type'] ?? ''; if ( empty( $block_type ) ) { continue; } if ( ! Wlconduit_Block_Registry::get( $block_type ) ) { $unknown_types[] = $block_type; } } if ( ! empty( $unknown_types ) ) { return array( 'status' => 'error', 'message' => sprintf( /* translators: %s: comma-separated list of unknown block type slugs */ __( 'Unknown block type(s): %s. The required add-on may not be installed.', 'ai-conduit' ), implode( ', ', array_unique( $unknown_types ) ) ), ); } // Auto-rename on conflict: try name_imported, name_imported_2, etc. if ( $this->tool_name_exists( $tool_name ) ) { $base = $tool_name . '_imported'; $candidate = $base; $counter = 2; while ( $this->tool_name_exists( $candidate ) ) { $candidate = $base . '_' . $counter; ++$counter; } $config['tool_name'] = $candidate; } $result = $this->save_tool( $config ); if ( is_wp_error( $result ) ) { return array( 'status' => 'error', 'message' => $result->get_error_message(), ); } return array( 'status' => 'created', 'id' => $result, 'tool_name' => $config['tool_name'], ); } /** * Sanitize a full tool configuration array. * * @since 1.0.0 * * @param array $config Raw tool configuration. * @return array Sanitized configuration. */ private function sanitize_config( array $config ): array { $sanitized = array( 'tool_name' => sanitize_text_field( $config['tool_name'] ?? '' ), 'description' => sanitize_textarea_field( $config['description'] ?? '' ), 'required_roles' => array( 'administrator' ), 'blocks' => array(), 'version' => 1, ); if ( ! empty( $config['required_roles'] ) && is_array( $config['required_roles'] ) ) { $sanitized['required_roles'] = array_values( array_map( 'sanitize_text_field', $config['required_roles'] ) ); if ( ! in_array( 'administrator', $sanitized['required_roles'], true ) ) { $sanitized['required_roles'][] = 'administrator'; } } if ( ! empty( $config['blocks'] ) && is_array( $config['blocks'] ) ) { foreach ( $config['blocks'] as $block ) { if ( empty( $block['block_type'] ) ) { continue; } $sanitized_block = array( 'block_type' => sanitize_text_field( $block['block_type'] ), 'config' => array(), ); if ( ! empty( $block['config'] ) && is_array( $block['config'] ) ) { $sanitized_block['config'] = $this->sanitize_block_config( $block['config'], $sanitized_block['block_type'] ); } $sanitized['blocks'][] = $sanitized_block; } } return $sanitized; } /** * Sanitize a single block configuration. * * @since 1.0.0 * * @param array $config Raw block configuration. * @param string $block_type The block type this config belongs to. * @return array Sanitized block configuration. */ private function sanitize_block_config( array $config, string $block_type = '' ): array { $sanitized = array(); // post_type only belongs on Posts blocks; strip phantom values from // other block types to keep result_key derivation correct. if ( 'posts' === $block_type && isset( $config['post_type'] ) ) { $sanitized['post_type'] = sanitize_text_field( $config['post_type'] ); } if ( isset( $config['fields'] ) && is_array( $config['fields'] ) ) { $sanitized['fields'] = array_map( 'sanitize_text_field', $config['fields'] ); } if ( isset( $config['filters'] ) && is_array( $config['filters'] ) ) { $sanitized['filters'] = array(); foreach ( $config['filters'] as $key => $filter ) { $sanitized_key = sanitize_text_field( $key ); $sanitized['filters'][ $sanitized_key ] = array( 'enabled' => ! empty( $filter['enabled'] ), 'description' => sanitize_text_field( $filter['description'] ?? '' ), ); if ( isset( $filter['default'] ) ) { $sanitized['filters'][ $sanitized_key ]['default'] = sanitize_text_field( (string) $filter['default'] ); } if ( isset( $filter['max'] ) ) { $sanitized['filters'][ $sanitized_key ]['max'] = min( 50, absint( $filter['max'] ) ); } } } if ( isset( $config['meta_keys'] ) && is_array( $config['meta_keys'] ) ) { $sanitized['meta_keys'] = array(); foreach ( $config['meta_keys'] as $key => $meta_config ) { $sanitized_key = sanitize_text_field( $key ); $sanitized['meta_keys'][ $sanitized_key ] = array( 'as_field' => ! empty( $meta_config['as_field'] ), 'as_filter' => ! empty( $meta_config['as_filter'] ), 'type' => in_array( $meta_config['type'] ?? 'CHAR', array( 'CHAR', 'NUMERIC', 'DECIMAL', 'DATE', 'DATETIME', 'TIME', 'BINARY', 'UNSIGNED' ), true ) ? $meta_config['type'] : 'CHAR', 'compare' => array(), ); if ( ! empty( $meta_config['compare'] ) && is_array( $meta_config['compare'] ) ) { $allowed_compare = array( '=', '!=', '<', '>', '<=', '>=', 'LIKE', 'NOT LIKE', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' ); $sanitized['meta_keys'][ $sanitized_key ]['compare'] = array_values( array_intersect( $meta_config['compare'], $allowed_compare ) ); } if ( empty( $sanitized['meta_keys'][ $sanitized_key ]['compare'] ) ) { $sanitized['meta_keys'][ $sanitized_key ]['compare'] = array( '=' ); } } } if ( isset( $config['taxonomies'] ) && is_array( $config['taxonomies'] ) ) { $sanitized['taxonomies'] = array(); foreach ( $config['taxonomies'] as $tax_slug => $tax_config ) { $sanitized_slug = sanitize_text_field( $tax_slug ); $sanitized['taxonomies'][ $sanitized_slug ] = array( 'as_field' => ! empty( $tax_config['as_field'] ), 'as_filter' => ! empty( $tax_config['as_filter'] ), 'terms' => array(), ); if ( ! empty( $tax_config['terms'] ) && is_array( $tax_config['terms'] ) ) { $sanitized['taxonomies'][ $sanitized_slug ]['terms'] = array_map( 'sanitize_text_field', $tax_config['terms'] ); } } } if ( isset( $config['options'] ) && is_array( $config['options'] ) ) { $sanitized['options'] = array(); foreach ( $config['options'] as $option_key => $option_config ) { $sanitized_key = sanitize_text_field( $option_key ); $sanitized['options'][ $sanitized_key ] = array( 'as_field' => ! empty( $option_config['as_field'] ), ); } } if ( isset( $config['joins'] ) && is_array( $config['joins'] ) ) { $sanitized['joins'] = array(); $allowed_join_types = array( 'inner', 'left', 'right' ); foreach ( $config['joins'] as $join ) { $sanitized_join = array( 'label' => sanitize_text_field( $join['label'] ?? '' ), 'target_type' => sanitize_text_field( $join['target_type'] ?? '' ), 'target_post_type' => sanitize_text_field( $join['target_post_type'] ?? '' ), 'local_field' => sanitize_text_field( $join['local_field'] ?? '' ), 'local_field_from' => $join['local_field_from'] ?? 'self', 'target_field' => sanitize_text_field( $join['target_field'] ?? '' ), 'join_type' => 'inner', 'target_fields' => array(), 'target_meta_keys' => array(), ); if ( ! empty( $join['join_type'] ) && in_array( $join['join_type'], $allowed_join_types, true ) ) { $sanitized_join['join_type'] = $join['join_type']; } if ( 'self' !== $sanitized_join['local_field_from'] ) { $sanitized_join['local_field_from'] = absint( $sanitized_join['local_field_from'] ); } if ( ! empty( $join['target_fields'] ) && is_array( $join['target_fields'] ) ) { $sanitized_join['target_fields'] = array_map( 'sanitize_text_field', $join['target_fields'] ); } if ( ! empty( $join['target_meta_keys'] ) && is_array( $join['target_meta_keys'] ) ) { foreach ( $join['target_meta_keys'] as $mk => $mc ) { $sanitized_join['target_meta_keys'][ sanitize_text_field( $mk ) ] = array( 'as_field' => ! empty( $mc['as_field'] ), ); } } $sanitized['joins'][] = $sanitized_join; } } if ( isset( $config['object_type'] ) ) { $sanitized['object_type'] = sanitize_text_field( $config['object_type'] ); } return $sanitized; } }